はじめに
TCAを使ってアプリを実装しているときによく出くわすあの書き方は、Sendable
を理解することでよく理解できるようになります。
本記事ではまずSendable
について解説し、その後TCAでSendable
に出くわすポイントについて解説してみたいと思います。
※説明不足、間違い等々ありましたら、是非コメントください
Sendableとは?
準拠する型がデータ競合が発生せず安全に渡せるデータであることを表すものです。
Swiftのドキュメントでは、ある同時実行ドメインから別の同時実行ドメインに共有できる型と表現されています。
データ競合(Data Races)とは?
複数のスレッドが同時に同じデータにアクセスし、かつそのうちの少なくとも一つのスレッドが書き込み処理を行うことにより、データが不整合な状態になる現象のことです。
データ競合を発生させてみる
以下のコードを実行すると、出力結果は1000にはなりません。
そして実行するたびに結果が異なります。
class Counter {
var count = 0
func increment() {
count += 1
}
}
let counter = Counter()
for _ in 0..<1000 {
Task {
counter.increment()
}
}
Thread.sleep(forTimeInterval: 2)
print(counter.count)
Sendableの必要性とは?
上記コードにおけるCounterクラスはデータ競合が発生するので、同時実行ドメイン間で安全に共有することができません。
にもかかわらず、コンパイルは問題なく通ってしまいます。
Sendable
はこれを解決するために導入されました。
Sendable
はデータ競合を発生させる可能性のあるコードをコンパイル時に検出することを可能にします。
問題を検出できるようにする
我々にはSendable
対応のための猶予が与えられているので、現状では上記のようなコードを書いても警告も出ないしエラーにもなりません。
しかし、Swift6からはエラーになってコンパイルできなくなります。
いますぐに問題を検出できるようにするには、Swiftコンパイラにフラグを渡してあげます。
swiftSettings: [
.unsafeFlags(["-Xfrontend", "-warn-concurrency"])
]
こうすると、該当箇所に警告が表示されるようになります。
Capture of 'counter' with non-sendable type 'Counter' in a
@Sendable
closure
Taskのクロージャは@Sendable
Task.initのシグネチャはこうなっていて、クロージャが@Sendable
になっていることがわかります。
public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async throws -> Success)
クロージャが@Sendable
とはどういうことか?
関数またはクロージャに@Sendable
をつけると、以下が適用されます。
- キャプチャする型は
Sendable
への準拠が必要 - 変数はキャプチャできない
前述のCounterクラスは非Sendable
なため、1つ目の要件に合致せず警告が出ていたことがわかります。
Sendable
な型であっても、クロージャ内で変数をキャプチャしている以下のコードはコンパイルエラーになります。
これについては-warn-concurrencyフラグをつけていなくてもエラーになります。
var count = 0
Task {
print(count) // Reference to captured var 'count' in concurrently-executing code
}
キャプチャリストを使用することで、変数の値を利用することができます。
以下のクロージャ内のcountはクロージャの外にあるcountとは別の新しい不変変数だからです。
var count = 0
Task { [count] in
print(count)
}
TCAでSendableを意識するポイント
ここまで理解すると、TCAのあの書き方の意味がわかります。
EffectTask.task / run / fireAndForgetに渡すクロージャも@Sendable
.taskに渡すクロージャは@Sendable
であり、stateはinoutで渡されるために可変であるため、クロージャ内でキャプチャすることができません。
そこで、stateの値をキャプチャリストでコピーし、クロージャ内で参照可能にしています。
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .onAppear:
return .task { [state] in
await fetchResponse(TaskResult {
try await repository.fetch(.init(newsId: state.newsId))
})
}
}
}
依存のプロパティを@Sendable
にしている理由
TCAで推奨されている依存(XxxClientとかXxxRepositoryとか)のインタフェース(プロパティ)はクロージャになっていて、@Sendable
をつけています。
public struct XxxClient {
public var fetch: @Sendable () async throws -> Entity
}
依存はEffectTask.taskなど並行コンテキストで利用されるため、データ競合を引き起こす可能性があります。
例えば、カウント値を保持し、increment()を呼ぶごとにカウントアップした値を返す機能を持つClientを考えます。
struct CountClient {
var increment: () -> Int
}
このClientを以下のように実装するとしましょう。
extension CountClient {
static func live() -> Self {
var count = 0
return .init(increment: {
count += 1
return count
})
}
}
Counterクラスの例と同様に以下のような処理を実装すると、データ競合が発生してしまいます。
最終的な出力は1001を期待したいですが、実際はそうなりません。
let client = CountClient.live()
for _ in 0..<1000 {
Task {
_ = client.increment()
}
}
Thread.sleep(forTimeInterval: 2)
print(client.increment())
インタフェースに@Sendable
をつけることで、データ競合が発生するコードを実装できないようにすることができます(-warn-concurrencyをつけている場合)。
具体的には、以下のようにコンパイルエラーになります。
struct Repository {
var increment: @Sendable () -> Int
}
extension Repository {
static func live() -> Self {
var count = 0
return .init(increment: {
count += 1 // Mutation of captured var 'count' in concurrently-executing code
return count // Reference to captured var 'count' in concurrently-executing code
})
}
}
TCAのドキュメントで以下のように言及されています。
This will restrict the kinds of closures that can be used when constructing FactClient values, thus making the entire FactClient sendable itself.
インタフェースを@Sendable
にすると表現しましたが、DependencyValuesに登録するためにはClient自体をSendable
にする必要があります。
参考