概要
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 -
Sendable
and@Sendable
closures
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
で抑制することができる