TCAのv1.1でスレッドに関する潜在的なissueが解決されたというのを調べた
はじめに
The Composable Architecture(TCA)のv1.1で興味深いPRがあって、どうやら非同期処理の不具合を修正するためMainActorをTaskに指定してるんですよね。その修正がどういう意味を持つのかを考えてみました。
最初に結論
おそらく
-
globalActorが指定されていない型またはglobalActorが指定されていないメソッドでMainActorでないTaskを作成するとそのTask実行は別スレッドに切り替わる
- だからStoreかsendメソッドもしくはTask自体にMainActor指定する必要があった
PRでは何をやっているのか
不具合修正自体は何をやっているのかの差分は次の通りです。
guard !tasks.wrappedValue.isEmpty else { return nil }
- return Task {
+ return Task { @MainActor in
await withTaskCancellationHandler {
var index = tasks.wrappedValue.startIndex
while index < tasks.wrappedValue.endIndex {
defer { index += 1 }
await tasks.wrappedValue[index].value
}
} onCancel: {
var index = tasks.wrappedValue.startIndex
while index < tasks.wrappedValue.endIndex {
defer { index += 1 }
tasks.wrappedValue[index].cancel()
}
}
}
}
ここでMainActor指定をしないとindexの操作やindexを使ったアクセスが別スレッドになってしまっているということでしょう(そうなると並行する処理が同時にindexを足したりしてしまうのでデータ競合が起こりEXC_BAD_ACCESSとなるはずです)。
このコードはStore型のsendメソッドであり、Actionを処理してEffectを実行し、さらに次のActionを呼び出すためのコードです。この部分は本来メインスレッドのみで実行されて正しいはずです。
ではなぜMainActor指定が必要かを考えてみます。
なぜMainActor指定が必要?
実験するために小さなReduerとStoreを作ってみる
なるべくTCAがやっていることを真似たいので、SendをTCAからそのまま流用、あとは自作でReducer(struct)とStore(class)、Action(enum)を作り、副作用実行の日同期処理を担当するSideEffectClientを作成します。
SwiftUIからStoreに対してメソッドを呼び出しているようにします。実際はもっとTCAに近づけるためBox型なども使ったりしましたが、そこは今回とは関係ないので省略しています。
import SwiftUI
struct ContentView: View {
@State var store: Store
init(store: Store) {
self.store = store
}
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.task {
// ここのダメさはあるが、
// Mainで実行していることを見やすくしてるだけ
Task { @MainActor in
print("View Task: mainThread=\(Thread.isMainThread)")
store.send()
}
}
}
}
// MARK: - TCAっぽい型を用意
struct SideEffectClient {
func sideEffect() async -> Int {
// スレッドを切り替えずにそのまま値を返してみる
return 100
}
}
struct Reducer {
let client = SideEffectClient()
func operation() -> @MainActor (_ send: Send<EffectAction>) async -> Void {
let operation: (@MainActor (_ send: Send<EffectAction>) async -> Void) = { send in
print("Reducer operation1: mainThread=\(Thread.isMainThread)")
let value = await client.sideEffect()
// ここでスレッドが切り替わったままなはず
print("Reducer operation2: mainThread=\(Thread.isMainThread)")
// SendはMainActorだからawait
await send(EffectAction.power(value))
}
return operation
}
}
class Store {
func send() -> Task<Void, Never> {
print("Store send: mainThread=\(Thread.isMainThread)")
let operation = Reducer().operation()
return .init {
let task = Task {
print("Store Task.init: mainThread=\(Thread.isMainThread)")
await operation(
.init(
send: { action in
// TCAではsendは次のActionを呼び出しているが今回はなにもしない
switch action {
case .power(let value):
print("send power(\(value)) : mainThread=\(Thread.isMainThread)")
}
}
)
)
}
await task.value
// ここでスレッドが切り替わったまま
print("Store task.value: mainThread=\(Thread.isMainThread)")
}
}
}
enum EffectAction: Sendable {
case power(Int)
}
// MARK: - これはTCAのコードそのまま
@MainActor
public struct Send<Action>: Sendable {
let send: @MainActor @Sendable (Action) -> Void
public init(send: @escaping @MainActor @Sendable (Action) -> Void) {
self.send = send
}
/// Sends an action back into the system from an effect.
///
/// - Parameter action: An action.
public func callAsFunction(_ action: Action) {
guard !Task.isCancelled else { return }
self.send(action)
}
/// Sends an action back into the system from an effect with animation.
///
/// - Parameters:
/// - action: An action.
/// - animation: An animation.
public func callAsFunction(_ action: Action, animation: SwiftUI.Animation?) {
callAsFunction(action, transaction: Transaction(animation: animation))
}
/// Sends an action back into the system from an effect with transaction.
///
/// - Parameters:
/// - action: An action.
/// - transaction: A transaction.
public func callAsFunction(_ action: Action, transaction: Transaction) {
guard !Task.isCancelled else { return }
withTransaction(transaction) {
self(action)
}
}
}
printの出力は次のようになります。mainThread=false
になっている行に注目
View Task: mainThread=true
Store send: mainThread=true
Store Task.init: mainThread=false
Reducer operation1: mainThread=true
Reducer operation2: mainThread=true
send power(100) : mainThread=true
Store task.value: mainThread=false
わかりますか。
- Task.initでメインスレッドとは別のスレッドになってる(
Store Task.init: mainThread=false
) - それ以降の処理はMainには戻らない(
Store task.value: mainThread=false
) - 副作用実行でスレッドの切り替えは関係がない
結局何が問題なのかというと下記だと思います
- globalActorが指定されていない型でかつglobalActorが指定されていないメソッドでTaskを作成するとそのTask実行は別スレッドに切り替わる
これを避けるためにはPRのようにTask自体にMainActor指定をする以外に、1か2のどちらかもしくは両方で対応できるはずです(上のコードに追加してみればわかる)。
- StoreにMainActor指定する
@MainActor class Store { ... }
- StoreのsendにMainActor指定する
@MainActor func send() -> Task<Void, Never> { ... }
さらに小さいコード
TCAとか関係なくSwiftUIも関係ないんですが、もう少し小さなコードで示してみます。
import SwiftUI
struct ContentView: View {
@State var store: Store
init(store: Store) {
self.store = store
}
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.task {
// ここのダメさはあるが、
// Mainで実行していることを見やすくしてるだけ
Task { @MainActor in
method()
}
}
}
}
func method() {
Task {
print("method Task.init: mainThread=\(Thread.isMainThread)")
}
}
出力は下記の通り
method Task.init: mainThread=false
呼び出し元のTaskがMainActor指定されていても、method()
メソッドはglobalActorの指定がないのでTask初期化で別スレッドを作成しています(どっかにこの仕様が整理されてないかな、知ってる人いたら教えてください)。
おわりに
TCAを作ってるPoint-Freeのお2人は私からしたら天才に見えるけど、こういうこともあるのでコードを読んで実験をしておくのが重要に思えます。
Discussion