cockscomblog?

cockscomb on hatena blog

Swift ConcurrencyのwithTaskCancellationHandlerとSendable

Swift 5.5がリリースされた。おめでとうございます。

Swift 5.5の目玉はもちろんSwift Concurrencyだ。言語機能として並行処理がサポートされた。async/awaitの構文だけでなく、Structured Concurrencyとしての整理や、actorの導入など、野心的な取り組みと言える。

Swift Concurrency

Swift Concurrencyに直接関係するSwift Evolutionの提案はこれだけある。

これだけの提案が行われ、実装されたことに圧倒される。

The Swift Programming Language」にもConcurrencyの章が追加されている。こちらではわかりやすく説明されているので、概要を掴みやすい。

そろそろ本題。

タスクのキャンセル

Structured Concurrencyでは、親のタスクがキャンセルされると子のタスクもキャンセルされる。キャンセルされたかどうかはTask.isCancelledTask.checkCancellation()とかで確認できる。

withTaskCancellationHandler

タスクがキャンセルされたときに何か実行するにはwithTaskCancellationHandlerを使う。

func withTaskCancellationHandler<T>(operation: () async throws -> T, onCancel handler: @Sendable () -> Void) async rethrows -> T

onCancel@Sendableであることは重要なので、覚えておいてください。

コールバックを引数に取る関数からasync関数を作るwithUnsafeThrowingContinuationwithTaskCancellationHandlerを組み合わせる例が、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を設定する。

f:id:cockscomb:20210926102445p:plain
Other Swift Flags

-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-atomicsManagedAtomicLazyReferenceを使ってみる。要するにデータの競合が排除されていればいいので、今回の用途には大袈裟な感じもするが、使ってしまう。

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を設定しておくと、将来的な破壊的変更を避けられるかもしれない。

こちらからは以上です。