🧗

メソッドがMainActorでも別スレッドで動作することはある

2022/08/15に公開

背景

Swift 5.5あたりからUIViewControllerはMainActorとなっていて、自作する型にもglobalActorを設定することが当たり前になってきた。そんななか、リリース済みのアプリでの不具合を調査する際に、「これMainActorなんだから別スレッドで動作してるってこたーねーよな」という思い込みで可能性を排除していたが、そんなことはない。という話を書いておきます。

私の勘違いかもしれないのでもしなんかあればコメントをお願いします。

要点

メソッドにMainActorを設定しても、非asyncなメソッド はレガシーなDispatchQueue.global()など別スレッドを利用するキューから実行すれば、それはその任意の別スレッドでメソッドを実行させられる。

つまりTaskを代表とするSwift Concurrencyだけを使っている場合はMainActor指定されたメソッドはメインスレッドで実行される。しかし、レガシーな方法ではglobalActorなどの制約に対してあまり意味がない。

結論として言いたいこと

  • なるべく新規のコードではSwift Concurrencyを使ってほしい
    • Swift Concurrencyを使うことで、コンパイラの指摘に従うようにしてほしい
  • つまり、レガシーな方法であるDispatchQueue, Operation, Threadを使わないでほしい
    • どうしても使う場合はprivateなメソッド内で行い、Swift Conccurencyで扱えるように変換してほしい
      • 正直、OSSライブラリでスレッドが切り替わるものも、Taskに変換しないで使うのはコンパイラの助けが受けられないので良いとは 私は 思わない

MainActorでも別スレッドで動くよね、な具体例をあげる

ViewControllerからMainActorを呼び出す

  • MyViewControllerからMainActor指定してあるCounterのメソッドを呼び出す
    • DispatchQueue.global()でグローバルキューから実行
      • Counterのメソッドは別スレッドで呼び出されてしまう
import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // 下記はもちろんMainActorじゃないのでコンパイルエラー。
	// 我々はコンパイラの能力を享受できる。
//        Task.detached {
//            let counter = Counter()
//            counter.increment()
//        }

        // 下記はメインスレッド外だがコンパイルエラーにはならない
        DispatchQueue.global().async {
            let counter = Counter()
            counter.increment()
        }
    }
}

@MainActor
class Counter {
    // 暗黙的にMainActor
    var count = 0 {
        didSet {
            print("didSet", Thread.isMainThread) // => false
        }
    }

    // 暗黙的にMainActorだが非asyncメソッド
    func increment() {
        // DispatchQueue.main.asyncなどで呼ぶとメインスレッドで動作しない
        print("viewDidLoad", Thread.isMainThread) // => false
        count += 1
    }
}

let viewController = MyViewController()
PlaygroundPage.current.liveView = viewController

本来MainActor指定されたCounterはメインスレッドでしか操作されない。そう思って定義を眺めていても、実際に呼び出す側の都合によって変わる。

UIViewControllerもMainActorだが、OSSライブラリからコールバックなどで非メインスレッドから呼び出された場合にはそのスレッドで動作してしまう。

警告もしくはエラーについて

TODO: あとで追記。コンパイラフラグを追加してDispatchQueue.global().asyncから呼び出されづらいようにするんや!

https://twitter.com/k_katsumi/status/1559145275628871680?s=20&t=W0KNf6339sg4n8-mvCdM0g

参考文献

型にglobalActor指定

型にglobalActorを指定すると暗黙的にプロパティやメソッドにそれが引き継がれる件についてはプロポーザルに書いてある。

プロポーザル

https://github.com/apple/swift-evolution/blob/main/proposals/0316-global-actors.md#using-global-actors-on-a-type

CofeeData

WWDC21でのAppleのサンプルで型にMainActorを指定してる箇所のはなし

https://developer.apple.com/documentation/swift/updating_an_app_to_use_swift_concurrency

Put the Coffee Data Class on the Main Actor

The CoffeeData class implements ObservableObject and has an @Published property to feed the SwiftUI views. To ensure that all updates to this property are made on the main thread, place the type on the main actor:

話が脱線しそう

UIViewControllerがMainActorな件

UIViewControllerがMainActorな件は下記の公式リファレンスに書いてあると教えていただいた

https://developer.apple.com/documentation/uikit/uiviewcontroller

蛇足

  • Xcode 14より前はSwiftのヘッダーみても書いてなかったが、Xcode 14からはSwiftのヘッダーにもMainActorが書かれてる
    • Xcodeのエディタ上でUIViewControllerをJump to Definitionしたらわかる

その他

  • WWDCですでに話されてたり、当たり前のことかもしれない
  • 解釈を間違われないように念の為
    • DispatchQueue.globalをすべて滅ぼせというわけじゃなくしょうがないときは変換すればいいと思う
    • 中途半端に移行するのは良くない、なんて言ってない

Discussion