3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Sendable理解した

Last updated at Posted at 2024-12-08

はじめに

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に準拠しているので、「プロパティが全て定数であること」を満たす必要はありません。

https://t.co/br7aXPg93m

以下は参照型が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.detachedfooを別スレッドで書き込みを行う際に、キャプチャリストにて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())
}

【参考】

おわり

Sendableについて、概念の意味や意義、使用方法を整理してみました。
ご参考になる方がいらっしゃれば幸いです。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?