概要
Swift Concurrency の大きな目的の1つとしてデータ競合を防ぐことがあります。 Swift 6 からはコンパイル時のデータ競合のチェックが厳しくなり、競合を起こす可能性のあるコードはコンパイルエラーになると言われています。これに備えるため、我々が日々開発しているアプリケーションのコードも Swift Concurrency に対応していく必要があるでしょう。
Swift Concurrency への移行を一気に行うのは難しいため、徐々に移行していくための仕組みが整えられています。この記事ではその中の一部として、 Xcode 14 から Build Settings の項目に追加された Strict Concurrency Check と、 import につける @preconcurrency アトリビュートについて紹介します。
記事中での検証は Xcode 14 Beta 4 で行っています。
この記事では Sendable についてなんとなくでも理解をしていることを前提としています。もし今から理解したいと言う場合は、例えば以下のリソースがおすすめです。
- Protect mutable state with Swift actors
- Swift ConcurrencyのwithTaskCancellationHandlerとSendable
- Sendable and @Sendable closures explained with code examples
- Swift Concurrency チートシート case19 actor-boundary を越える
- SE-0302 -
Sendableand@Sendableclosures
Strict Concurrency Check
Xcode 14 から、「どのくらい厳しく Concurrency Check をするか」を表す Strict Concurrency Check が Build Settings に追加されました。
Swift Package の場合はコンパイラフラグから設定ができます。
targets: [
.target(
name: "MyTarget",
dependencies: [],
swiftSettings: [
.unsafeFlags([
"-strict-concurrency=complete"
])
]
)
]
2022-08-16 追記
@omochimetaru さんに指摘いただいて unsafeFlags の指定方法を修正しました。また、 unsafeFlags を使っている target が含まれる product は他の package からバージョン指定で dependencies に追加した際にエラーが出てライブラリとして利用できなくなるので注意が必要です。より詳しくは コメント を参照ください。
追記ここまで
Strict Concurrency Check に設定する値によって、 Sendable に関するチェックをどこまで行うかを変えることができます。
Strict Concurrency Check に設定することができる値は、 Minimal / Targeted / Complete の3種類です。それぞれについて詳しく見ていきます。
Minimal
Minimal は最も緩く Concurrency チェックをする設定です。最も緩いと言ってもこの設定がデフォルトで、 Swift 5.6 以前と同じレベルのチェックを行います。
2022-08-17 追記
@iceman5499 さんに指摘いただいたのですが、一時は Minimal がデフォルトでしたが、 最新では次に紹介する Targeted がデフォルトになっているようです。正式リリースまでに変わる可能性もある気がするのでまた改めて追記・修正します。より詳しくは コメント を参照ください。
追記ここまで
Minimal では Sendable に準拠させようとしている型が実際には Sendale ではないことに対して警告が出ます。例えば、可変なプロパティを持つ class は複数の Task から同時に触られるとデータ競合を起こしうるので Sendable に正しく準拠することができず、以下のような警告に出ます。
final class Dog: Sendable {
var name: String // ❗️Stored property 'name' of 'Sendable'-conforming class 'Dog' is mutable
init(name: String) {
self.name = name
}
}
Targeted
Targeted では、 Minimal で行われるチェックに加えて、 Swift Concurrency を使っているコードに対して Sendable チェックが有効になります。例えば、 Task に渡すクロージャは @Sendable なので Sendable に準拠する型しかキャプチャできませんが、 Sendable でない Dog 型のインスタンスを渡そうとすると以下のような警告が出ます。
final class Dog {
var name: String
init(name: String) {
self.name = name
}
}
func playWithDog(_ dog: Dog) async {
Task {
print("play with \(dog.name)") // ❗️Capture of 'dog' with non-sendable type 'Dog' in a `@Sendable` closure
}
}
この警告により、 Swift Concurrency を利用しているコードではデータ競合の可能性がコンパイル時にわかるようになります。
手元で検証した限り、 Swift 5.6 以前で -warn-concurrency オプションをつけてビルドした際に走るチェックと同じレベルの厳しさが Targeted のようです。 Swift 5.7 では -warn-concurrency の振る舞いが変わって Compelete 相当のチェックを行うようになっていそうです。
Complete
Complete では、 Targeted で行われるチェックに加えて、Swift Concurrency を使っていない箇所でも警告が出るようになります。
func playWithDog2(_ dog: Dog) {
DispatchQueue.global().async {
print("play with \(dog.name)") // ❗️Capture of 'dog' with non-sendable type 'Dog' in a `@Sendable` closure
}
}
この playWithDog2 は Swift Concurrency が登場する前から書くことができたので Sendable チェックに引っかかるのは一見不思議に思えます。しかし、実際は今までコンパイル時に検出できなかっただけで、この関数はデータ競合を発生させる可能性があります。以下のように、2つのスレッドが同時に同じインスタンスに触り、かつ一方が書き込むということが起きうるからです。
let dog = Dog(name: "John")
playWithDog2(dog)
dog.name = "Pochi" // playWithDog2 の中では別スレッドから dog を読み込むのでここの書き込みと競合し得る
Strict Concurrency Check を Complete にすることで、このような今までは検出できなかったデータ競合がコンパイル時にわかるようになります。
Concurrency 関連の警告との向き合い方
1つ認識しておきたいのは、 Strict Concurrency Check を厳しくして警告が発生するようになっても、何かが悪くなったわけではなくそれは今現在データ競合が発生し得る箇所である可能性が高いということです。もちろん、そのことをどう受け止めて対応していくかは人やプロジェクトの性質によると思います。データ競合が起こり得ると言っても今まで普通に動いてたんだからまあ大丈夫という考え方も、早く直さないという考え方もあると思います。
いずれにしてもデータ競合の可能性がコンパイル時にわかるようになったのは純粋に素晴らしいことだと思うので、一度 Strict Concurrency Check を Complete にしてみてどういう警告が出るのかを確認してみてもいいかもしれません。
@preconcurrency import
Strict Concurrency Check を Targeted 以上にすると、型が Sendable に準拠していない場合に警告が発生することがありました。その型が自分が管理している型なら何らかの対応ができますが、利用しているライブラリなど他のモジュールの型だったらどうなるでしょうか。
プロパティがすべて Sendable な struct は暗黙的に Sendable に準拠しますが、その struct が public な場合は明示的に準拠させないといけなくなります。これは public な型はモジュールのインターフェースであり、インターフェースが特定のプロトコルに暗黙的に準拠するのは互換性の観点から好ましくないからだと思います。そのため、基本的にモジュールの作者が Concurrency に対応しようと思わない限りモジュールの型が Sendable になることはありません。また、ある型を Sendable に準拠させることができるのは型が定義されているのと同じファイル内でないといけないという制限があるため、利用側で Sendable に準拠させることはできません。そのため、 Targeted 以上の設定で警告を消すには、基本的にライブラリの作者が Concurrency に対応してくれるのを待つしかないと思います。もちろん Minimal にすれば警告は消えますが、そうすると自分が管理している型の Sendable チェックまで効かなくなってしまいます。
このような状況を解決するために用意されているのが @preconcurrency import です。以下のような状況を考えます。 AnimalKit に定義されている Dog は Sendable に準拠していないため、 App 側で警告が出ています。そして、 App 側で Dog を Sendable に準拠させることはできません。
// -- AnimalKit module --
public final class Dog {
public var name: String
public init(name: String) {
self.name = name
}
}
// -- App module --
import AnimalKit
func playWithDog(_ dog: Dog) async {
Task {
print("play with \(dog.name)") // ❗️Capture of 'dog' with non-sendable type 'Dog' in a `@Sendable` closure
}
}
ここで @preconcurrency をつけて import することで、 import 先のモジュールの型に由来する Sendable 周りの警告が出なくなります。これにより、利用しているライブラリが Concurrency 対応するまでの間、そのライブラリが原因の警告だけを選択的に見ないようにすることができるというわけです。
// -- App module --
@preconcurrency import AnimalKit
func playWithDog(_ dog: Dog) async {
Task {
print("play with \(dog.name)") // ❗️Capture of 'dog' with non-sendable type 'Dog' in a `@Sendable` closure
}
}
ライブラリ側で Concurrency 対応が完了すると、 @preconcurrency を外すことができるようになります。 Concurrency 対応の結果としては、 Dog が Sendable に準拠するようになる場合と、逆に Dog が Sendable でないことが確定する場合の2通りあります。
まずは Dog が Sendable に準拠する場合を考えます。 AnimalKit の作者が、 name はインスタンスを生成した後に変更することはないので var ではなく let で定義できることに気づくとします。そうすると Dog はイミュータブルになるので Sendable に準拠できるようになります。
public final class Dog: Sendable {
public let name: String
public init(name: String) {
self.name = name
}
}
この変更を取り込むことで @preconcurrency は不要になりますが、それに AnimalKit の利用者が気づけるように Xcode が今度は @preconcurrency 側に警告を出してくれます。
@preconcurrency import BookKit // '@preconcurrency' attribute on module 'BookKit' is unused
func playWithDog(_ dog: Dog) async {
Task {
print("play with \(dog.name)")
}
}
この警告のおかげで無駄な @preconcurrency が残り続けることは考えづらいので、個人的には必要なら @preconcurrency を躊躇なく使っていってよいのではないかと感じています。もちろん、定期的に @preconcurrency で import しているモジュールを洗い出して Concurrency の対応状況をチェックするなどの活動は必要だと思います。
AnimalKit の Concurrency 対応のもう一つの結果として、 Dog が Sendable でないことが確定する場合も考えられます。プロポーザルの記述 を読む限り、将来的に Swift 6 でコンパイルするようになれば明示的に Sendable に準拠しない型は全て Sendable でないと判断されるようになるはずですが、 Swift 5 の時点では利用しているライブラリの型が Sendable でないと確定するのは
- 型が
Sendableに準拠しているが、available でないとき - 型が
Sendableに特定の条件下で準拠しているが、その条件が満たされていないとき
の2通りです。検証のために簡単に Sendable でない型を作るには、以下のように書いて前者の条件を満たすとよいでしょう。
public final class Dog {
public let name: String
public init(name: String) {
self.name = name
}
}
@available(*, unavailable)
extension Dog: Sendable {}
AnimalKit 側でこのように Dog が Sendable でないことが確定した場合は、以下のように App 側で @preconcurrency を無視して警告が復活します。
@preconcurrency import BookKit
func playWithDog(_ dog: Dog) async {
Task {
print("play with \(dog.name)") // ❗️Capture of 'dog' with non-sendable type 'Dog' in a `@Sendable` closure
}
}
このように、 Dog が Sendable でないことが確定した場合でもモジュールの利用者は気づくことができるようになっています。
まとめ
- Xcode 14 から Build Settings に追加された
Strict Concurrency Checkにより Concurrency チェックの厳しさを変えられる-
Minimal:Sendableへの準拠を宣言した型が実際には Sendable でない時に警告が出る。 Swift 5.6 までの挙動と同じ -
Targeted: Swift Concurrency を利用しているコードでデータ競合の可能性がある箇所で警告が出る -
Complete: Swift Concurrency を利用していないコードも含めてデータ競合の可能性がある箇所で警告が出る
-
-
Targeted以上の厳しさでは、importしている他のモジュールが Concurrency 対応していないせいで警告が出てしまうことがある。これを@preconcurrencyで抑制することができる
