Swift 5.5がリリースされた。おめでとうございます。
Swift 5.5の目玉はもちろんSwift Concurrencyだ。言語機能として並行処理がサポートされた。async/awaitの構文だけでなく、Structured Concurrencyとしての整理や、actorの導入など、野心的な取り組みと言える。
Swift Concurrency
Swift Concurrencyに直接関係するSwift Evolutionの提案はこれだけある。
- SE-0296 Async/await
- SE-0297 Concurrency Interoperability with Objective-C
- SE-0298 Async/Await: Sequences
- SE-0300 Continuations for interfacing async tasks with synchronous code
- SE-0304 Structured concurrency
- SE-0306 Actors
- SE-0311 Task Local Values
- SE-0313 Improved control over actor isolation
- SE-0314 AsyncStream and AsyncThrowingStream
- SE-0316 Global actors
- SE-0317 async let bindings
これだけの提案が行われ、実装されたことに圧倒される。
「The Swift Programming Language」にもConcurrencyの章が追加されている。こちらではわかりやすく説明されているので、概要を掴みやすい。
そろそろ本題。
タスクのキャンセル
Structured Concurrencyでは、親のタスクがキャンセルされると子のタスクもキャンセルされる。キャンセルされたかどうかはTask.isCancelled
やTask.checkCancellation()
とかで確認できる。
withTaskCancellationHandler
タスクがキャンセルされたときに何か実行するにはwithTaskCancellationHandler
を使う。
func withTaskCancellationHandler<T>(operation: () async throws -> T, onCancel handler: @Sendable () -> Void) async rethrows -> T
onCancel
が@Sendable
であることは重要なので、覚えておいてください。
コールバックを引数に取る関数からasync関数を作るwithUnsafeThrowingContinuation
とwithTaskCancellationHandler
を組み合わせる例が、SE-0300 Continuations for interfacing async tasks with synchronous codeのAdditional examplesにある。
import Foundation func download(url: URL) async throws -> Data? { var urlSessionTask: URLSessionTask? return try await withTaskCancellationHandler { try await withUnsafeThrowingContinuation { continuation in urlSessionTask = URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: data) } } urlSessionTask?.resume() } } onCancel: { urlSessionTask?.cancel() } }
最新のAPIに合わせて少し書き換えているが、これは次のエラーでコンパイルできない。
Reference to captured var 'urlSessionTask' in concurrently-executing code
var urlSessionTask: URLSessionTask?
がvar
であるため、エラーが起きている。これはdiag::concurrent_access_of_local_capture
というメッセージだった。
diag::concurrent_access_of_local_capture
エラーを出しているのはここ。
ちょっとコードを読むと、「flow-sensitive concurrent captures」なる機能についても言及されているが、今はフラグで無効になっている。
これはキャプチャした後に変更されないとわかっていればOKとするもので、今回のケースではいずれにせよコンパイルが通らないのが正しいように見える。
var
じゃなくてlet
にして、参照型を間に挟めばコンパイルが通る。
import Foundation class Wrapper<Wrapped> { var value: Wrapped init(_ value: Wrapped) { self.value = value } } func download(url: URL) async throws -> Data? { let urlSessionTask: Wrapper<URLSessionTask?> = Wrapper(nil) return try await withTaskCancellationHandler { try await withUnsafeThrowingContinuation { continuation in urlSessionTask.value = URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: data) } } urlSessionTask.value?.resume() } } onCancel: { urlSessionTask.value?.cancel() } }
一見よいように思うかもしれないが、var変数のキャプチャはNGでこれはOKなのって不思議じゃないですか?ということで、Other Swift Flagsに-Xfrontend -warn-concurrency
を設定する。
-warn-concurrency
-warn-concurrency
ってなんなんだという話だけど、これは互換性と関係している。Swift 5系では破壊的な変更を避けつつ、並行処理でデータ競合を避けるための道具を提供する。そしてSwift 6ではチェックを厳密にすることで、デフォルトでデータの競合について安全になる。-warn-concurrency
オプションをつけると、Swift 6で問題になる箇所を予め知るためのもの、ということらしい。
さて、-warn-concurrency
オプションをつけると、該当の箇所に次の警告が現れる。
Cannot use let 'urlSessionTask' with a non-sendable type 'Wrapper<URLSessionTask?>' from concurrently-executed code
onCancel
は@Sendable
クロージャだが、そこからSendableではない値を参照していることで、警告が出た。class Wrapper<Wrapped>
を@unchecked Sendable
にすれば警告は消えるが、Wrapper
がちゃんとスレッドセーフなわけではないので、コンパイラを誤魔化しているだけの効果しかない。
Sendable
Swift 6でチェックされる項目の一つはSendableについてである。
Sendableの提案は受理されているが、現時点では実装済みになっていない。Swift 6でデフォルトでチェックされるようになるのを待っているのだと思うが、Swiftの標準ライブラリでは既に使われているし、自分たちで使うこともできる。
Sendableというのはactor間でやり取りできる型を表すマーカープロトコルである。マーカープロトコルはコンパイラに対する目印。
Sendableにできるかどうかは、例えば次のようになっている。
- structのような値型は、Copy on Writeなので、actor間でやり取りしても競合が起きない。変更可能なプロパティがSendableであればSendableにできる
- classは参照型なので、変更可能なプロパティを持っていないときだけSendableにできる。ただし
final
じゃないといけない - actorは参照型だけど内部状態をデータ競合から保護しているのでSendable
classだけど、内部的にデータ競合を防ぐ仕組み(ロックとか)を持っていることもある。そういうときは@unchecked Sendable
に指定できる。これはコンパイラにチェックされないSendableである。
そしてクロージャを@Sendable
でアノテーションすると、クロージャもSendableになる。SendableなクロージャがキャプチャできるのはSendableだけである。
Swift Atomics
ここでとりあえずapple/swift-atomicsのManagedAtomicLazyReference
を使ってみる。要するにデータの競合が排除されていればいいので、今回の用途には大袈裟な感じもするが、使ってしまう。
import Foundation import Atomics func download(url: URL) async throws -> Data? { let urlSessionTask = ManagedAtomicLazyReference<URLSessionTask>() return try await withTaskCancellationHandler { try await withUnsafeThrowingContinuation { continuation in let task = urlSessionTask.storeIfNilThenLoad(URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: data) } }) task.resume() } } onCancel: { urlSessionTask.load()?.cancel() } }
ManagedAtomicLazyReference
は、遅延して初期化される参照型をメモリ管理しつつ保持してくれる。
Swift Atomicsの1.0.2時点ではManagedAtomicLazyReference
はSendableではないが、次のバージョンではSendableになるはずだ。
次のようにしてこれを先取りする。
extension ManagedAtomicLazyReference: @unchecked Sendable where Instance: Sendable {} extension URLSessionTask: @unchecked Sendable {}
勢いよくURLSessionTask
もSendableにしてしまっているが、スレッドセーフなはずなので大丈夫だと思う。
ここまでやって、ついに-warn-concurrency
でも警告が出なくなったはずだ。
実際にはresume
する前にもキャンセルのチェックをしないと、タイミングによってうまくキャンセルされないはずなので、チェックする。ここまでのコードをまとめると次のようになるはず。
import Foundation import Atomics extension ManagedAtomicLazyReference: @unchecked Sendable where Instance: Sendable {} extension URLSessionTask: @unchecked Sendable {} func download(url: URL) async throws -> Data? { let urlSessionTask = ManagedAtomicLazyReference<URLSessionTask>() return try await withTaskCancellationHandler { return try await withUnsafeThrowingContinuation { continuation in let task = urlSessionTask.storeIfNilThenLoad(URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: data) } }) if Task.isCancelled { continuation.resume(throwing: CancellationError()) return } task.resume() } } onCancel: { urlSessionTask.load()?.cancel() } }
いかがでしたか?
withTaskCancellationHandler
を題材にしてSwift Concurrencyの-warn-concurrency
を試してみたつもりですが、いかがでしたか?なんとなく大袈裟に感じたように思う。現実には、URLSessionはすでにSwift Concurrencyに対応したインターフェースを持っているので、こういうコードを書くことはない。とはいえ-warn-concurrency
すると、普通のアプリでもSendableに関連した警告がいくらでも出てくるのではないか。
Swiftは安全な言語を志向しているから、デフォルトでデータ競合からも安全になっていく。(これは別にSwiftに限ったことではない。)このことはポジティブに受け止めていいはずだし、Swift 6が楽しみである。
とはいえ現状では、-warn-concurrency
でやっていくのは少し難しい。スレッドセーフなclassでもSendableがついていなかったりするので、単純にやりにくい。Swift自体もConcurrencyのサポートを始めたところで、Swift 6までにまだよくなるだろうから、焦らなくてもいいはずだ。
特にアプリの開発では、たまに-warn-concurrency
を有効にしてみて、なるほど、くらいでいいような気がする。反対にライブラリの場合は、今のうちから-warn-concurrency
を設定しておくと、将来的な破壊的変更を避けられるかもしれない。
こちらからは以上です。