🚴‍♀️

TCAのv1.1でスレッドに関する潜在的なissueが解決されたというのを調べた

2023/08/27に公開

はじめに

The Composable Architecture(TCA)のv1.1で興味深いPRがあって、どうやら非同期処理の不具合を修正するためMainActorをTaskに指定してるんですよね。その修正がどういう意味を持つのかを考えてみました。

https://github.com/pointfreeco/swift-composable-architecture/pull/2382/files

最初に結論

おそらく

  • 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のどちらかもしくは両方で対応できるはずです(上のコードに追加してみればわかる)。

  1. StoreにMainActor指定する
    • @MainActor class Store { ... }
  2. 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