Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save stzn/1f09b7e2a2c02b4829a4c1e917e77ec9 to your computer and use it in GitHub Desktop.
Save stzn/1f09b7e2a2c02b4829a4c1e917e77ec9 to your computer and use it in GitHub Desktop.
Data Race Safety in The Swift Concurrency Migration Guide

データ競合安全

Swiftの基本的な概念について学び、データ競合のない並行コードを実現する方法を知りましょう。

原文 https://github.com/apple/swift-migration-guide/blob/main/Guide.docc/DataRaceSafety.md
更新日 2024/7/6(翻訳を最後に更新した日付)
ここまで反映 https://github.com/apple/swift-migration-guide/commit/24e31ffc589fefb42f08877878e689eb29b1644b

従来、可変状態(mutable state)は、細心の注意を払い、実行時の同期によって手動で保護する必要がありました。
つまり、ロックやキューなどのツールを使用して、データ競合を防ぐ完全にプログラマー任せです。これは、正しく実行するだけでなく、ずっと正しく実行し続けることも非常に難しいものです。
同期の必要性を判断することさえ難しいかもしれません。

最悪なのは、安全でないコードでは実行時に失敗が保証されないことです。
このコードは多くの場合は正しく動いているように見えますが、それはおそらく、データ競合の特徴である不正確で予測不可能な挙動が表面化するのにはかなり特殊な条件が必要になるからでしょう。

より正確にいうと、データ競合は、あるスレッドがメモリにアクセスしている際に、別のスレッドが同じメモリを変更することで発生します。 Swift 6の言語モードは、コンパイル時にデータレースを防ぐことによって、これらの問題を排除します。

重要: 他の言語で async/await やアクターのような構造に遭遇したことがあるかもしれません。Swiftのこれらの概念との類似性は表面的なものでしかないかもしれないので、特に注意してください。

データ隔離

Swiftの並行処理システムは、コンパイラがすべての可変状態の安全性を理解し、検証することを可能にします。
これは、データ隔離と呼ばれる仕組みで実現されています。データ隔離は、可変状態への相互排他的なアクセスを保証します。これは、同期の一形態であり、概念的にはロックに似ています。しかし、ロックとは異なり、データ隔離が提供する保護は以下の場所で起こります。<

Swiftプログラマーは、静的と動的という2つの方法でデータを隔離します:

静的という用語は、実行時の状態に影響されないプログラム要素を記述するために使用されます。関数定義のようなこれらの要素は、キーワードとアノテーションで構成されています。Swiftの並行処理システムは、型システムを拡張したものです。関数と型を宣言するときは、静的に行ないます。データ隔離は、これらの静的宣言の一部になる場合があります。

ただし、型システムだけでは、実行時の挙動を十分に説明できない場合があります。例としては、Swiftに公開されているObjective-Cの型が挙げられます。Swiftコードの外部で行なわれたこの宣言では、安全な使用を保証するためにコンパイラに十分な情報が提供されない場合があるのです。このような状況に対応するために、データ隔離の要件を動的に表現できる追加機能があります。

データ隔離は、静的であれ動的であれ、コンパイラがあなたの書いたSwiftコードにデータ競合がないことを保証します。

注記: 動的な隔離についての詳細は、IncrementalAdoptionを参照してください。

隔離ドメイン

データの隔離は、共有可変状態(shared mutable state)を保護するための仕組みです。ただし、独立した個々の隔離単位について話すと役に立つことがよくあります。これは隔離ドメインと呼ばれます。特定のドメインが保護する状態の範囲は、大きく異なります。隔離ドメインは、単一の変数を保護することも、ユーザーインターフェースのようなサブシステム全体を保護することもあります。

隔離ドメインの重要な特徴は、それが提供する安全性です。可変状態は一度に1つの隔離ドメインからのみアクセスできます。ある隔離ドメインから別の隔離ドメインに可変状態を渡せますが、別のドメインからその状態に同時にアクセスすることは決してできません。コンパイラが、これが保証されていえるかを検証します。

たとえ自分で明示的に定義していなくても、すべての関数や変数の宣言には、明確に定義された静的な隔離ドメインが存在します。これらのドメインは常に3つのカテゴリのうちの1つに分類されます。

  1. 非隔離(Non-isolated)
  2. アクターに隔離されている
  3. グローバルアクターに隔離されている

非隔離(Non-isolated)

関数や変数は明示的な隔離ドメインにの一部である必要はありません。実際、隔離されていないのがデフォルトで、*非隔離(non-isolated)*と呼ばれます。つまり、すべてのデータ隔離のルールが適用されるため、非隔離のコードが別のドメインで保護されている状態に変更することはできません。

func sailTheSea() {
}

このトップレベル関数は静的に隔離されていないため、非隔離です。他の分非隔離の関数を安全に呼び出したり、非隔離の変数にアクセスできますが、他の隔離ドメインに存在するものには何もアクセスできません。

class Chicken {
    let name: String
    var currentHunger: HungerLevel
}

これは、非隔離型の例です。継承は、静的な隔離も引き継ぎます。しかし、スーパークラスもプロトコル準拠もないこの単純なクラスは、デフォルトの隔離を使用しています。

データ隔離は、非隔離の存在が、他のドメインから可変状態にアクセスできないことを保証します。この結果、非隔離の関数や変数は、他のドメインからアクセスしても常に安全です。

アクター

アクターは、プログラマーに、そのドメイン内で動作するメソッドとともに、隔離ドメインを定義する方法を提供します。アクターの格納プロパティはすべて、それらを囲むアクターインスタンスに隔離されます。

actor Island {
    var flock: [Chicken]
    var food: [Pineapple]

    func addToFlock() {
        flock.append(Chicken())
    }
}

ここで、すべてのIslandインスタンスは、そのプロパティへのアクセスを保護するために使用される新しいドメインを定義します。メソッドIsland.addToFlockselfに隔離されていると言われます。メソッド本体は、その隔離ドメインを共有するすべてのデータにアクセスでき、flockプロパティに同期的にアクセスできます。

アクターの隔離は、選択的に無効にできます。これは、隔離された型のなかでコードを整理したいけれども、それに伴う隔離の要件は避けたいという場合に便利です。非隔離メソッドは保護された状態に、同期的にアクセスすることはできません。

actor Island {
    var flock: [Chicken]
    var food: [Pineapple]

    nonisolated func canGrow() -> PlantSpecies {
        // neither flock nor food are accessible here
    }
}

アクターの隔離ドメインは、それ自身のメソッドに限定されません。隔離されたパラメータを受け取る関数は、他の形式の同期を必要とせずに、アクターが隔離した状態にアクセスできるようにします。

func addToFlock(of island: isolated Island) {
    island.flock.append(Chicken())
}

注記: アクターの概要については、The Swift Programming Language の Actors セクションを参照してください。

グローバルアクター

グローバルアクターは、通常のアクターの特性をすべて持ちますが、宣言の隔離ドメインを静的に割り当てる手段も提供します。これは、アクター名と一致するアノテーションをつけることによって行なわれます。グローバルアクターは、そこに含まれるすべてのものを、共有可変状態を扱う単一の隔離ドメインのなかで同時に使用する必要がある場合に特に便利です。

@MainActor
class ChickenValley {
    var flock: [Chicken]
    var food: [Pineapple]
}

このクラスはMainActorに対して静的に隔離されています。これにより、その可変状態へのすべてのアクセスが、その隔離ドメインから行なわれるようになります。

nonisolatedキーワードを使用することで、この型のアクターの隔離をオプトアウトできます。そして、他のアクターと同様に、そうすることで保護された状態へのアクセスはできなくなります。

@MainActor
class ChickenValley {
    var flock: [Chicken]
    var food: [Pineapple]

    nonisolated func canGrow() -> PlantSpecies {
        // flock、food、その他のMainActorに隔離された状態にはアクセスできない
    }
}

タスク

タスクは、プログラム内で並行して実行できる作業の単位です。タスクの外側で、Swiftは並行コードを実行できませんが、それは常に手動で開始しなければならないということではありません。一般的に、非同期関数は、それらを実行しているタスクを認識する必要はありません。実際、タスクは、多くの場合、アプリケーションフレームワーク内、あるいはプログラムのエントリーポイントといった、より高レベルで開始できます。

Task {
    flock.map(Chicken.produce)
}

タスクは、常にある隔離ドメインのなかで実行されます。タスクは、あるアクターインスタンスやグローバルアクターに隔離されることもあれば、非隔離である(グローバルな隔離ドメインを使用する)こともあります。この隔離は、手動で作れますが、コンテキストに基づいて自動的に継承できます。タスクの隔離は、他のすべてのSwiftコードと同様に、タスクがアクセスできる可変状態を決定します。

タスクは同期と非同期の両方のコードを実行できます。しかし、構造やタスクの数に関係なく、同じ隔離ドメイン内の関数は、お互いに同時に実行できません。任意の隔離ドメインで同期コードを実行するタスクは1つだけです。

注記: さらに詳細はThe Swift Programming LanguageのTasksセクションを参照してください。

隔離の推論と継承

隔離を明示的に指定する方法はたくさんあります。しかし、宣言のコンテキストが隔離の継承によって暗黙的に隔離ドメインを構築します。

クラス

サブクラスは常に親クラスと同じ隔離を持ちます。

@MainActor
class Animal {
}

class Chicken: Animal {
}

ChickenAnimalを継承しているため、Animal型の静的な隔離も暗黙的に適用されます。それだけでなく、サブクラスによって変更することもできません。すべてのAnimalインスタンスはMainActorに隔離されていることが宣言されており、すべてのChickenインスタンスもそうでなければならないということです。

型の静的な隔離は、デフォルトでそのプロパティとメソッドに対しても推論されます。

@MainActor
class Animal {
    // all declarations within this type are also
    // implicitly MainActor-isolated
    let name: String

    func eat(food: Pineapple) {
    }
}

注記: さらに詳細については、The Swift Programming LanguageのInheritanceセクションを参照してください。

プロトコル

プロトコルへの準拠は、暗黙的に隔離に影響を与えます。しかし、プロトコルが隔離に与える影響は、その準拠が適用される場所に依ります。

@MainActor
protocol Feedable {
    func eat(food: Pineapple)
}

// 推論された隔離は型全体に適用される
class Chicken: Feedable {
}

// 推論された隔離は、このextensionの中のみに適用される
extension Pirate: Feedable {
}

プロトコルの要件そのものを隔離できます。これにより、準拠する型に対する隔離がどのように推論されるかをより細かく制御できます。

protocol Feedable {
    @MainActor
    func eat(food: Pineapple)
}

プロトコルがどのように定義され、準拠が追加されたかに関わらず、静的な隔離の他のメカニズムを変更できません。つまり、ある型が明示的に、あるいはスーパークラスからの推論によってグローバルに隔離されている場合、プロトコル準拠を使ってそれを変更できないということです。

注記: さらに詳細については、The Swift Programming LanguageのProtocolsセクションを参照してください。

関数型

隔離の推論は、型がそのプロパティとメソッドの隔離を暗黙的に定義することを可能にします。しかし、これらはすべて宣言の例です。隔離の継承であっても、関数値で同様の効果を得られます。

クロージャは、型によって隔離が静的に定義される代わりに、その宣言された場所で隔離をキャプチャできます。このメカニズムは複雑に聞こえるかもしれませんが、実際には非常に自然な振る舞いを可能にします。

@MainActor
func eat(food: Pineapple) {
    // この関数の宣言の静的な隔離は、ここで作成されたクロージャによってキャプチャされる
    Task {
        // クロージャ内はMainActorの隔離を継承できる
        Chicken.prizedHen.eat(food: food)
    }
}

ここでのクロージャの型はTask.initによって定義されています。この宣言はどのアクターにも隔離されていませんが、この新しく作成されたタスクは、それを囲むスコープの MainActorの隔離を継承します。

関数型は、隔離の動作を制御するためのさまざまなメカニズムを提供しますが、デフォルトでは他の型と同じように動作します。

注記: さらに詳細については、The Swift Programming Languageの[Closures][]セクションを参照してください。

隔離境界

隔離ドメインは、可変状態を保護します。しかし、有用なプログラムには、保護以上のものが必要です。多くの場合、データの受け渡しによって通信し、協調する必要があります。隔離ドメインへ値を移動したり、隔離ドメインから値を移動したりすることは、隔離境界を越えると呼ばれます。
値が隔離境界を越えることが許されるのは、共有可変状態への同時アクセスの可能性がない場合のみです。

値は、非同期関数の呼び出しを介して、直接境界を越えることあります。異なる隔離ドメインで非同期関数を呼び出す場合、パラメータと戻り値は、ドメイン間を移動することが必要です。また、値がクロージャによってキャプチャされた場合、間接的に境界を越えることもあります。クロージャは隔離境界を越える多くの潜在的な可能性があります。クロージャは、あるドメインで作成され、別のドメインで実行される可能性があります。さらに、複数の異なるドメインで実行されることさえもあり得るのです。

Sendable型

場合によっては、スレッドセーフは型自体の特性であるため、特定の型のすべての値は、隔離境界を越えて安全に渡せます。これは、Sendableプロトコルに準拠することで表されます。Sendableに準拠している場合、その特定の型がスレッドセーフであり、その型の値をデータ競合のリスクなしに任意の隔離ドメイン間で共有できることを意味します。

Swiftでは、値型(value type)が本質的に安全であるため、その使用が推奨されています。値型を使用すると、プログラムのさまざまな部分で、同じ値への参照を共有できません。値型のインスタンスを関数に渡すと、関数はその値の独立したコピーを保持します。値のセマンティクスによって共有可変状態が存在しないことが保証されるため、Swiftの値型は、格納されているすべてのプロパティもSendableである場合、暗黙的にSendableになります。ただし、この暗黙の準拠は、定義されたモジュールの外部には適用されません。クラスをSendableにすることは、そのパブリックAPIの契約の一部であり、常に明示的に行なう必要があります。

enum Ripeness {
    case hard
    case perfect
    case mushy(daysPast: Int)
}

struct Pineapple {
    var weight: Double
    var ripeness: Ripeness
}

ここで、RipenessPineappleの両型は、Sendableの値型だけで構成されているので、暗黙的にSendableです。

注記: さらに詳細については、The Swift Programming LanguageのSendable Typesセクションを参照してください。

フローセンシティブ(Flow-Sensitive)な隔離の解析

Sendableプロトコルは、型全体のスレッド安全性を表現するために使われます。しかし、Sendableでない型のあるインスタンスが安全な方法で使われている状況もあります。コンパイラは、多くの場合、リージョンベースの隔離として知られるフローセンシティブな解析によってこの安全性を推論できます。

リージョンベースの隔離では、コンパイラがデータ競合を引き起こさないことがわかる場合、Sendableでない型のインスタンスが隔離ドメインを超えることを許可します。

func populate(island: Island) async {
    let chicken = Chicken()

    await island.adopt(chicken)
}

ここで、コンパイラは、たとえchickenSendableでない型を保持していたとしても、islandの隔離ドメインに渡しても安全であると正しく推論できます。しかし、このSendableチェックの違反は、本質的に周囲のコードに依ります。コンパイラは、chicken変数への安全でないアクセスが発生した場合、エラーを発生させます。

func populate(island: Island) async {
    let chicken = Chicken()

    await island.adopt(chicken)

    // エラーになる
    chicken.eat(food: Pineapple())
}

リージョンベースの隔離は、コードを変更せずに機能します。一方で、この仕組みを使って、関数のパラメータと戻り値が、隔離ドメインを超えることを明示できます。

func populate(island: Island, with chicken: sending Chicken) async {
    await island.adopt(chicken)
}

これにより、コンパイラは、すべての呼び出し先でchickenパラメータへの安全でないアクセスができないことを100%保証できます。sendingは、この仕組みがなければ発生している重大な制約を緩和します。つまり、sendingがなければ、この関数は、ChickenがまずSendableに準拠しなれば実装できないということです。

アクター隔離型

アクターは値型ではありません。ただし、アクターは自身の隔離ドメインですべての状態を保護するため、境界を越えて渡しても本質的に安全です。これにより、アクターのプロパティ自体がSendableでなくても、すべてのアクター型は暗黙的にSendableになります。

actor Island {
    var flock: [Chicken]  // non-Sendable
    var food: [Pineapple] // Sendable
}

グローバルアクター隔離型も、同様の理由で暗黙的にSendableになります。プライベートな専用の隔離ドメインはありませんが、その状態はアクターによって保護されています。

@MainActor
class ChickenValley {
    var flock: [Chicken]  // non-Sendable
    var food: [Pineapple] // Sendable
}

参照型

値型とは異なり、参照型は暗黙的にSendableにはできません。明示的にSendableにできますが、それにはいくつかの制約が伴います。クラスをSendableにするためには、可変状態が含まれていてはならず、不変のプロパティもSendableである必要があります。さらに、コンパイラはfinalクラスの実装のみを検証できます。

final class Chicken: Sendable {
    let name: String
}

OS固有の構成要素や、C/C++/Objective-Cで実装されたスレッドセーフな型を使用する場合など、コンパイラが推論できない同期プリミティブを使用して、Sendableのスレッドセーフ要件を満たせます。このような型は、コンパイラにその型がスレッドセーフであることを約束するために、@unchecked Sendableに準拠するとアイコンできます。コンパイラは@unchecked Sendable型に対してチェックを行なわないため、このオプトアウトの使用には注意が必要です。

中断ポイント(Suspension Points)

あるドメインの関数が別のドメインの関数を呼び出すと、タスクは隔離ドメインを切り替えることができます。隔離境界を越える呼び出しは、呼び出し先の隔離ドメインが、他のタスクの実行でビジー状態になっている可能性があるため、非同期で行なう必要があります。その場合、タスクは、呼び出し先の隔離ドメインが使用可能になるまで中断されます。重要なのは、中断ポイントがブロックされないことです。現在の隔離ドメイン(およびそれが実行されているスレッド)は、他の作業をするために解放されます。Swiftの並行処理ランタイムは、システムが常に前進できるように、コードが将来の作業でブロックしないことを期待します。これは並行コードのデッドロックの一般的な原因を取り除きます。

@MainActor
func stockUp() {
    // MainActorで実行開始
    let food = Pineapple()

    // islandアクターのドメインに切り替え
    await island.store(food)
}

潜在的な中断ポイントは、ソースコード内でawaitキーワードを使用してアイコンされます。このキーワードが存在すると、呼び出しが実行時に中断される可能性があることを示します。ただし、awaitは中断を強制するものではなく、呼び出される関数は、特定の動的条件下でのみ中断される可能性があります。awaitでアイコンされた呼び出しは、実際には中断されない可能性もあります。

アトミック性(Atomicity)

アクターはデータ競合からの安全性を保証しますが、中断ポイント間のアトミック性は保証しません。並行コードは、一連の処理をアトミックな単位としてまとめて実行する必要があります。この性質を必要とするコードの単位を*クリティカルセクション(critical section)*と呼びます。

現在の隔離ドメインは、他の作業をするために解放されるため、アクターで隔離されている状態は、非同期呼び出しの後に変更される可能性があります。結果として、潜在的な中断ポイントを明示的にアイコンすることは、クリティカルセクションの終了を示す方法として考えることができます。

func deposit(pineapples: [Pineapple], onto island: Island) async {
   var food = await island.food
   food += pineapples
   await island.store(food)
}

このコードでは、islandアクターのfoodの値が非同期呼び出しの間に変化しないことを、誤って想定しています。クリティカルセクションは、常に同期的に実行されるように構造化されるべきです。

注記: さらに詳細については、The Swift Programming Languageの[Defining and Calling Asynchronous Functions][]セクションを参照してください。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment