はじめに
Swift6が登場した現在のiOS開発において、Sendable
というプロトコルを見かける頻度が爆増しました。
Sendable
調べたこと何回かあるけど、毎回ふわっとしか理解せず次見かけた時「なんだっけこれ、、」と何度も調べる羽目になっていたので、これを機に完全に理解したと思う心意気でキャッチアップしました。
ので今回はそのキャッチアップした情報を記事にしてみました。
なるべくコードベースで確認して情報の正当性を担保することを心がけていますが、説明や解釈の誤りなどございましたらご指摘いただけますと幸いです。
Sendable
とは
protocolであり、Sendable
に準拠することでデータ競合が起きないということを示す型です。
つまりSendable
に準拠した構造体やクラス、enumなどはデータ競合が起きないことがコンパイル側で保証してくれるのです。
と解説されている記事はおそらく山のようにありますし、
私はこの解説では最初ちんぷんかんぷんでしたので、もう少し深掘ります。
Sendable
がある時にどんな困り事が解決されているのかが、分かればSendable
の意味も腑に落ちると考えているので、どんな困り事が解決されているのか考えてみましょう。
先ほどの説明を読むと、データ競合 が起きないとあるので、データ競合 がなんなのかが理解できればSendable
のイメージがつかめる気がします!
データ競合とは?
まずはWikipediaでデータ競合の意味を見てみます。
マルチスレッドのプログラムにおいて、データ競合は次の条件がすべて満たされた場合に発生する。
1つのプロセス内の2つ以上のスレッドがメモリ上の同じ場所に同時にアクセスする。
そのアクセスの少なくとも1つが書き込みアクセスである。
どのスレッドも、排他的ロックを使用して、そのメモリへのアクセスを制御していない。
定義だけ見てもイメージしづらいので、実際のコード競合が起きうるコードを見てみましょう。
以下コードではデータ競合を発生させる可能性があります。
class Foo {
var value = ""
}
let foo = Foo() // @MainActorの変数
@MainActor
func main() {
DispatchQueue.global().async {
foo.value = "Hello, World! on DispatchQueue.global" // ①
// Main actor-isolated let 'foo' can not be referenced from a nonisolated context
}
Task.detached {
await foo.value = "Hello, World! on Task" // ②
// Non-sendable type 'Foo' in implicitly asynchronous access to main actor-isolated let 'foo' cannot cross actor boundary
}
Task.detached {
await print(foo.value) // ③
// Non-sendable type 'Foo' in implicitly asynchronous access to main actor-isolated let 'foo' cannot cross actor boundary
}
}
main()
上記例では、以下複数のスレッドでfoo
に対して非同期で読み書きを行なっています。
- ①
DispatchQueue.global().async
のブロックで書き込み - ②
Task.detached
のブロックで書き込み - ③
Task.detached
のブロックで読み込み
また上記のfoo
は排他制御(読み書きを行なっている間に他の書き込みが行えない様にする)を行なっていません。
よって、データ競合の定義にある条件を全て満たしており、データ競合が起きうる可能性があります。
データ競合が起きると何が起きるのか
データ競合が起きた場合の結果は他の言語でもそうだと思いますが、保証されていないとされています。
最悪の症状として下記にもあるとおり、常に失敗せず成功することもあるという予測不能な動作が起こることです。
この特性により、潜在的な問題を抱えたままテストで顕在化せずユーザーの元へリリースされてしまうということがしばしば起こります。
従来、可変状態は、慎重な実行時同期によって手動で保護する必要がありました。ロックやキューなどのツールを使用すると、データ競合の防止は完全にプログラマー次第でした。これは、正しく行うだけでなく、長期間にわたって正しく維持することも非常に困難です。同期の必要性を判断することさえ難しい場合があります。最悪なのは、アンセーフ コードでは実行時に失敗が保証されないことです。このコードは、データ競合の不正確で予測不可能な動作特性を示すために非常に異常な条件が必要であるため、多くの場合は機能しているように見えることがあります。
より正式には、データ競合は、あるスレッドがメモリにアクセスしているときに、別のスレッドが同じメモリを変更しているときに発生します。Swift 6 言語モードでは、コンパイル時にデータ競合を防ぐことで、これらの問題が解消されます。
上記説明でもわかるとおり、これまでデータ競合による不具合を検知することが難しかったのですが、swift6ではこれがデータ競合が起こり得るコードはコンパイルエラーとなり未然に防ぐことができる様になっています。
これはSendable
に準拠した型以外が、スレッドを跨いで参照されているかどうかを見て、もし跨いでいた場合はコンパイルエラーとする仕様になっているためです。
Sendabale
がデータ競合が起きないことを保証してくれているので、逆にSendabale
に準拠していない型がスレッドを跨ぐといったデータ競合を起こしうる実装をブロックすることでデータ競合を静的解析で防いでいるのです。
ここまでわかるとSendable
の意味が見えてくるのではないでしょうか。
Sendableに準拠するには
Sendable
の意味はわかったので、続いてどうすれば準拠できるのか見ていきます。
準拠する対象が値型、列挙型か参照型かによって、準拠するための条件が変わってくるため分けて考えていきます。
値型、列挙型がSendable
に準拠する方法
以下が準拠するための条件です。
- プロパティ(enumの場合はAssociated Value)が全て
Sendable
に準拠していること
以下はSendable
に準拠できている例です
struct Bar: Sendable {
var value: String // Stringが`Sendable`に準拠している
}
enum Hoge: Sendable {
case a(Bar) // Barが`Sendable`に準拠している
}
以下はSendable
に準拠できていない例です
struct Bar: Sendable { // Stored property 'value' of 'Sendable'-conforming struct 'Bar' has non-sendable type 'Foo'
var value: Foo // プロパティのvalueが`Sendable`に準拠していない
}
enum Hoge: Sendable { // Associated value 'a' of 'Sendable'-conforming enum 'Hoge' has non-sendable type 'Foo'
case a(Foo) // Associated valueが`Sendable`に準拠していない
}
class Foo {
var value = ""
}
値型、列挙型は上記の条件を意識せずとも、気づいたらSendable
に準拠していたということもあります。
というのも、値型、列挙型は上記の条件を満たしている場合はSendable
に暗黙的に準拠するという仕様のためです。
以下のように、Sendable
を明示的に記載して準拠させずとも、勝手にSendable
に準拠しています。
struct Bar { // 暗黙的に`Sendable`に準拠している
var value: String // Stringが`Sendable`に準拠している
}
enum Hoge { // 暗黙的に`Sendable`に準拠している
case a(Bar) // Barが`Sendable`に準拠している
}
しかし、モジュールを跨いだ参照になると暗黙的なSendable
は効かず、参照している側(importしている側)からはSendable
には見えないという仕様があるので、そのときは明示的にSendable
に準拠させる必要があります。
参照型がSendable
に準拠する方法
以下が準拠するための条件です。
- プロパティが全て
Sendable
に準拠していること - プロパティが全て定数であること(※)
-
final
なclassであること
補足
※「プロパティが全て定数であること」の条件には例外もあります。
actor
であれば、変数のプロパティを保持していても暗黙的にSendable
に準拠しているので、「プロパティが全て定数であること」を満たす必要はありません。
以下は参照型がSendable
に準拠している例です
final class Foo: Sendable {
let value = ""
}
以下は参照型がSendable
に準拠できていない例です
Sendable
に準拠していないプロパティがある例
class Foo: Sendable { // Non-final class 'Foo' cannot conform to 'Sendable'; use '@unchecked Sendable'
let value = ""
}
プロパティが定数でない例
final class Foo: Sendable {
let value = ""
let value2 = Boo() // Stored property 'value2' of 'Sendable'-conforming class 'Foo' has non-sendable type 'Boo'
}
class Boo {
let value = ""
}
finalがない例
class Foo: Sendable { // Non-final class 'Foo' cannot conform to 'Sendable'; use '@unchecked Sendable'
let value = ""
}
上記で見て取れるように、参照型は値型や列挙型よりもSendable
に準拠するための条件が多いです。
一つ目の プロパティが全てSendable
に準拠していること という条件は値型や列挙型と同じですが、その他の制約がなぜ必要かも見ていきます。
「プロパティが全て定数であること」がSendable
に必要な理由
これは、参照型の他の変数に渡してもその変数には同じインスタンスが保持される特性に起因していると考えています。
以下ケースのように、Task.detached
でfoo
を別スレッドで書き込みを行う際に、キャプチャリストにてfoo
をキャプチャしても同じインスタンスのためデータ競合の可能性が出てきます。
final class Foo {
var value = ""
}
func main() {
let foo = Foo() // ①
Task.detached {
foo.value = "Hello, World! on Task"
}
Task.detached { [foo] in // ①と同じインスタンスのfoo
let foo = foo // ①と同じインスタンスのfoo
foo.value = "Hello, World! on Task2"
}
foo.value = "Hello, World! on main"
}
main()
そのため、class
のような参照型をデータ競合が発生しないことを担保するためには、
保持しているプロパティは変更できないようにする必要があります。
これがstruct
といった値型であれば、キャプチャリストにてキャプチャするとクロージャ内では別インスタンスを参照することになるためスレッドを跨いで参照してもデータ競合は防がれます。
struct Bar: Sendable {
var value: String
}
func main() {
var bar = Bar(value: "") // ①
Task.detached {
bar.value = "Hello, World! on Task"
}
Task.detached { [bar] in // ①とは異なるインスタンス
var bar = bar // ①とは異なるインスタンス
bar.value = "Hello, World! on Task2"
}
bar.value = "Hello, World! on main"
}
main()
「final
なclassであること」がSendable
に必要な理由
こちらは過去に以下記事を書きましたので、よかったらご参照ください。
上記の他に値型、列挙型、参照型関係なく、例外的に制約なしに強制的にSendable
に準拠させる手段もあります。
それが、@unchecked Sendable
を使用することです。
class Foo: @unchecked Sendable {
var value = ""
}
この方法はコンパイラがSendable
に準拠できているかというチェックをスキップしてSendable
に適合できますが、その性質上コンパイラがデータ競合を起きないことを保証しているわけではありません。
そのため@unchecked Sendable
を使用する場合は、開発者自身がデータ競合が起きないことを保証する必要があるので、使用には注意が必要です。
例えば、以下の様にキューで保護してデータ競合が起きないことが開発者にはわかるが、コンパイラにはわからない場合だと@unchecked Sendable
を使用するのが有効と考えています。
final class Foo: @unchecked Sendable {
private var _value = ""
private let executeQueue = DispatchQueue(label: "Foo.executeQueue")
var value: String { // valueを取得するときはシリアルキューを介して読み取る
get async {
await read()
}
}
func update(_ value: String) async {
return await withCheckedContinuation { continuation in
executeQueue.async(flags: .barrier) { // valueの値を書きこむときは、シリアルキューを介して、他の_valueへのアクセスが終わってから書き込みを行う
self._value = value
continuation.resume()
}
}
}
private func read() async -> String {
return await withCheckedContinuation { continuation in
executeQueue.async {
continuation.resume(returning: self._value)
}
}
}
}
以上が、Sendable
に準拠する方法でした。
Swift ConcurrencyにおいてSendable
であるとどんなことができるのか
swift concurrencyでは同時アクセスから保護してくれる領域があります。
これをisolation domain(※)といったりするそうで、主に以下3種類があります。
- Global Actorに隔離されている領域
- Actorに隔離されている領域
- Actorに隔離されていない領域
これらの領域間を跨ぐことをisolation baundaryと呼び、swift concurrencyではSendable
でない型がこれを行うことができない様になっています。
この仕様は上記で解説したデータ競合を防ぐためです。
以下は、MainActorからnonisolated(Actorに隔離されていない領域)へisolation baundaryしている例です。
例の通り、Sendable
に準拠した型はMainActorからnonisolatedへ渡す(isolation baundary)ことができます。
class NonSendableObject {
var value = ""
}
struct SendableObject: Sendable {
var value: String
}
@MainActor
func main(sendableObject: SendableObject, nonSendableObject: NonSendableObject) async {
await sendNonIsolatedDomain(sendableObject) // MainActorからnonisolatedへSendableの型を渡す
await sendNonIsolatedDomain(nonSendableObject) // MainActorからnonisolatedへSendableでない型を渡す → ❌コンパイルエラー
}
nonisolated
func sendNonIsolatedDomain(_ value: SendableObject) async {
print("sendNonIsolatedDomain: \(value)")
}
nonisolated
func sendNonIsolatedDomain(_ value: NonSendableObject) async {
print("sendNonIsolatedDomain: \(value)")
}
Task { // MainActor
await main(sendableObject: SendableObject(value: ""),
nonSendableObject: NonSendableObject())
}
【参考】
- https://www.youtube.com/watch?v=AUcn2y2jjNs
- https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
おわり
Sendable
について、概念の意味や意義、使用方法を整理してみました。
ご参考になる方がいらっしゃれば幸いです。