✍️

Swift Concurrencyでの並行処理

2024/09/30に公開

概要

Swift Concurrencyにより、非同期処理や同時並行処理を安全に書くことができるようになりました。Swift Concurrencyを用いてデータ競合のない並行処理がどのように実現されているのかについての理解が曖昧であったため、自身の理解・整理をしたものが本記事になります。

間違いなどありましたら、是非コメントください。

Task

A unit of asynchronous work.

Taskは、非同期処理の単位を指し、Swift Concurrencyでは、並行処理をTaskという単位で実行します。
すべてのasync関数の実行はTaskの一部として実行され、また明示的にTaskを作成してasync処理を実行することも可能です。

https://developer.apple.com/videos/play/wwdc2022/110350/

各Taskには独自のリソースがあり、それぞれのTaskが独自して作業を行うため、完全に独立している場合は、データ競合することなく並行処理を行うことができます。
しかし、それ以外の場合はデータ競合が発生する可能性があります。

データ競合は、2つ以上のスレッドが並行にメモリ上の場所にアクセスし、少なくとも1つのスレッドが書き込み処理を行う際に発生します。
以下のSwiftコードは、データ競合を引き起こします。

class Counter {
    var value = 0

    func increment() -> Int {
        value = value + 1
        return value
    }
}

let counter = Counter()

Task.detached {
    print(counter.increment()) // ①
}

Task.detached {
    print(counter.increment()) // ②
}

上記のコードは、①と②のどちらも1または2を出力するようなデータ競合を引き起こします。

データ競合を防ぐ方法の1つとして、値型であるstructを使用する方法があります。
以下のコードはCounterをstructで書き換えたものです。

struct Counter {
    var value = 0

    mutating func increment() -> Int {
        value = value + 1
        return value
    }
}

var counter = Counter()

Task.detached {
    print(counter.increment()) // Mutation of captured var `counter` is concurrently-executing code
}

Task.detached {
    print(counter.increment()) // Mutation of captured var `counter` is concurrently-executing code
}

しかし、この状態ではcounterインスタンスが両方のTaskから参照されるため、データ競合が発生するためコンパイルエラーになります。

- var counter = Counter()
+ let counter = Counter()

Task.detached {
+   var counter = counter
    print(counter.increment()) // Mutation of captured var `counter` is concurrently-executing code
}

Task.detached {
+   var counter = counter
    print(counter.increment()) // Mutation of captured var `counter` is concurrently-executing code
}

上記の変更により、データ競合の発生を防ぐことができます。しかし、この変更を加えたことによりもともとClassを用いて実装をしていたように、Task間でcounterの状態を共有することができなくなってしまいます。
データ競合を発生させることなく、Task間で可変状態を共有するための方法としてActorがあります。

Actor

Actors coordinate multiple tasks that need to access shared data. Actors isolate data from the outside, allow only one task at a time to manipulate their internal state avoiding data races from concurrent mutation.

actorは、actor外から隔離され独自の状態を保持します。actorの状態を更新するためには、actor自身で状態の更新を行う必要があります。
一度に一つのTaskのみがActorの状態にアクセスすることできます。

https://developer.apple.com/videos/play/wwdc2022/110350/

import Foundation

actor Counter {
    private var value = 0
    
    func increment() -> Int {
        value += 1
        return value
    }
}

let counter = Counter()

Task.detached {
    print(await counter.increment()) 
}

Task.detached {
    print(await counter.increment()) 
}

先ほどのCounterをactorへ変更することにより、actorに複数のTaskが同時にアクセスすることはできなくなり、必ず一つのTaskのみがactorにアクセスすることが保証されます。そのため、可変状態をデータ競合なしにTask間で共有することができるようになります。

GlobalActorは、通常のactorが有するすべての特性を持ち合わせ、グローバルに一意なアクターです。
アクター名と一致するアノテーションの付与のより、GlobalActorを宣言することができます。
MainActorはGlobalActorの一種であり、UIに関わるさまざまな状態を管理しています。MainActorの関数などは、メインスレッドで処理されます。

データ隔離・隔離ドメイン

Swiftの世界では、すべての関数や変数宣言には、隔離ドメインとよばれるものが存在し、それらは次の3つに分類されます:

  1. 非隔離(Non-isolated)
  2. actor
  3. GlobalActor

非隔離ドメインがデフォルトであり、非隔離ドメインのコードは、他の非隔離の関数呼び出しや非隔離の変数にアクセスすることができます。

// トップレベルの関数は非隔離
func hoge() {
}

// classやstructの型宣言も非隔離
class Apple {
}

struct Pineapple {
}

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

    // nonisolatedキーワードにより、非隔離ドメインに分類される。
    nonisolated func canGrow() -> PlantSpecies {
        // flock、food、その他のMainActorに隔離された状態にはアクセスすることはできなくなる。
    }
}

Swiftの並行処理システムは、データ隔離と呼ばれる仕組みにより、コンパイラがすべての可変状態の安全性を検証できるようにしています。
データ隔離のルールの適用により、非隔離ドメインが、他のドメイン(actorまたはGlobalActor)の可変状態にアクセスできないこと(データ競合が起きないこと)を保証しています。
また、これによりactorまたはGlobalActorの隔離ドメインは、非隔離ドメインの関数や変数には常に安全にアクセスすることができます。
隔離ドメインにより、可変状態は保護されていますが、実際にアプリケーション開発を行う際は、隔離ドメインで可変状態を保護するだけではなく、隔離ドメイン間でデータのやり取りを行う必要が発生します。
隔離ドメイン間で値のやり取りを行う場合、値の共有可変状態への同時アクセスの可能性がない場合のみ、隔離ドメインを超えて値のやり取りを行うことができます。
この隔離ドメイン間でのやり取りを可能にするのがSendableです。

Sendable

A thread-safe type whose values can be shared across arbitrary concurrent contexts without introducing a risk of data races.

Sendableプロトコルは、隔離ドメイン間にてデータ競合なしに安全に可変状態のやり取りをすることができることを保証するものです。
struct、enum、tupleは値型であり、Sendableとなります。
classの場合は、ほとんどの場合でSendableになることはありませんが、設計次第では、Sendableとすることも可能です。
actorは値型ではなく参照型であるが、actorは自身の隔離ドメインで自身のすべての可変状態を保護するため、隔離ドメインを超えて安全に共有することができます。そのため、actor内のプロパティ自体がSendableではなくても、actorに属するプロパティの型は暗黙的にSendableになります。同様に、GlobalActorに隔離された型も暗黙的にすべてのプロパティがSendableになります。

Taskと隔離ドメイン

Taskは、常に特定の隔離ドメインの中で実行されます。非隔離ドメインの場合もあれば、actorやGlobalActorに隔離される場合もあり、これはコンテキストに基づいて自動で継承されます。(手動で隔離を構築することも可能)
個々のTaskは、同期と非同期両方のコードを実行することができますが、同じ隔離ドメイン内の関数は互いに同時実行することはできず、任意の隔離ドメインで同期コードを実行するタスクは、一度に一つのみとなります。

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

  func advanceTime() {
    let totalSlices = food.indices.reduce(0) { (total, nextIndex) in
      total + food[nextIndex].slice()
    }

    Task {
      flock.map(Chicken.produce)
    }

    // 隔離ドメインとしてactorは継承されない
    Task.detached {
      // 隔離ドメインがactorであるfoodプロパティにアクセスするために
      // awaitキーワードが必要になる
      let ripePineapples = await food.filter { $0.ripeness == .perfect }
      print("There are \(ripePineapples.count) ripe pineapples on the island")
    }
  }
}

参考

Discussion