SwiftのActorについての備忘録

最近、本業の組織変更でスマホアプリとWebのハイブリッド担当になりました。というわけでSwiftを書く機会が多くなってきたものの、仕様を理解しきれてない部分があったのでメモです。

SwiftでiOSアプリを書いているとよく @MainActor というコードを書くことがあります。このアノテーションを指定した型や関数はメインスレッドで動きます。と、いうところまでは簡単なのですが、このようなアノテーションを指定することで、具体的にどういう動作をするのか、どのような制約が生まれるのか、が若干複雑だったので整理していきます。

Actorとは何か

@MainActorアノテーションの説明の前に、actorというもの自体の説明をしましょう。

操作の直列化

actorはほぼclassのようなものですが、必ず処理が直列化されるという特徴があります。

例えば、次のMyActorはfooというメソッドを持っています。

actor MyActor {
  func foo() {
    // do something
  }
}

同一のMyActorインスタンスに対して、複数スレッドから同時にfooメソッドを呼び出すとどうなるでしょうか?

通常のクラスであれば、何も排他制御が行われず、呼び出し元スレッドで普通にfooメソッドが呼び出されます。

一方、actorの場合は、呼び出しがキューに積まれて、順番に処理されます。これがひとつのactorインスタンスに対する操作は直列化されるという意味です。

awaitの強制

キューに積まれて順番に処理されるということは、完了を待機する必要があるということです。しかし、完了を待機する間に呼び出し元スレッドをブロックすることはありません。代わりにawaitを使用することが強制されます。

次の例では、トップレベルからMyActorのメソッドを呼び出すときにawaitを指定しています。awaitを外すとコンパイルエラーになります。

actor MyActor {
  func foo() {
    // do something
  }

  func bar() {
    // 同一actor内なのでawaitが不要
    foo()
  }
}

let myActor = MyActor()

// actorの外なのでawaitが必要
await myActor.foo()

このように、actorのメソッドをactor外から呼び出すにはawaitが必要になります。

また、actorのメソッドが別のactorの処理を待機している間は、別の処理を進めることができます。例えば、Actor1がActor2のメソッドを呼び出している間に、Actor1の別のメソッドが呼ばれたならば、待機中にそのメソッドの処理を進めることができます。

import Foundation

actor Actor1 {
  let actor2 = Actor2()

  func work1() async {
    // actor2を呼び出して待機。ここで1秒かかる。
    await actor2.work()
    print("work1")
  }

  func work2() {
    print("work2")
  }
}

actor Actor2 {
  func work() {
    Thread.sleep(forTimeInterval: 1)
  }
}

let actor1 = Actor1()
// work1とwork2を同時に呼び出し
async let task1: () = actor1.work1()
async let task2: () = actor1.work2()
_ = await (task1, task2)
work2
work1

actorの処理はどのスレッドで動いている?

デフォルトでは、標準のスレッドプール上で処理が実行されます。actorインスタンスそれぞれにスレッドが割り当てられているわけではありません。標準のスレッドプール上で、それぞれのactorが直列になるようにスケジュールされて実行されています。

詳しくは、こちらの記事が参考になります: Limit Swift Concurrency's cooperative pool | Alejandro M. P.

標準のスレッドプール以外を使うようにカスタマイズすることもできます。unownedExecutorプロパティをオーバーライドすることで、自由に実行方法を指定することができます。

次の例では、独自のスレッドを起動して、そのスレッド上で処理を実行するactorを実装しています。SerialExecutorプロトコルを実装したオブジェクトを作成し、unownedExecutorプロパティでその参照を返しています。

import Foundation

// ジョブとロックを管理するオブジェクト。ThreadとExecutorで共有する。
final class MyExecutorQueue: @unchecked Sendable {
  private var queue: [UnownedJob] = []
  private let cond = NSCondition()
  private(set) var isQuitted = false

  func enqueue(_ job: UnownedJob) {
    cond.lock()
    queue.append(job)
    cond.signal()
    cond.unlock()
  }

  func dequeueAll() -> [UnownedJob] {
    cond.lock()
    if queue.isEmpty { cond.wait() }
    let jobs = queue // copy
    queue.removeAll(keepingCapacity: true)
    cond.unlock()
    return jobs
  }

  func quit() {
    cond.lock()
    isQuitted = true
    cond.signal()
    cond.unlock()
  }
}

// ワーカースレッド
final class MyExecutorThread: Thread {
  private let queue: MyExecutorQueue
  private let executor: UnownedSerialExecutor

  init(queue: MyExecutorQueue, executor: UnownedSerialExecutor) {
    self.queue = queue
    self.executor = executor
  }

  override func main() {
    print("MyExecutorThread started")

    while !queue.isQuitted {
      for job in queue.dequeueAll() {
        job.runSynchronously(on: executor)
      }
    }

    print("MyExecutorThread finished")
  }
}

final class MyExecutor {
  private let queue = MyExecutorQueue()

  func startThread() {
    let thread = MyExecutorThread(queue: queue, executor: asUnownedSerialExecutor())
    thread.name = "MyExecutorThread"
    thread.start()
  }

  deinit {
    queue.quit()
  }
}

// SerialExecutorプロトコルの実装
extension MyExecutor: SerialExecutor {
  func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }

  func enqueue(_ job: UnownedJob) {
    queue.enqueue(job)
  }
}

actor MyActor {
  let executor: MyExecutor

  init(executor: MyExecutor) {
    self.executor = executor
  }

  // このプロパティをデフォルト実装からオーバーライドすることで、独自の方法で処理を実行できる
  nonisolated var unownedExecutor: UnownedSerialExecutor {
    executor.asUnownedSerialExecutor()
  }

  func work() {
    print("MyActor is working!")
    print(Thread.current)
  }
}

let executor = MyExecutor()
let myActor = MyActor(executor: executor)
executor.startThread()
await myActor.work()
MyExecutorThread started
MyActor is working!
<customthread.MyExecutorThread: 0x7f9af1804230>{number = 2, name = MyExecutorThread}

MainActorとはGlobalActorのひとつである

actorの挙動が理解できたところで、@MainActorの謎に迫っていきましょう。

@MainActorという構文があるのではなく、実際にはMainActorというactorが標準ライブラリに存在しています。ドキュメントには次のように定義されています。

@globalActor
final actor MainActor

この@globalActorが肝です。@globalActorGlobalActorプロトコルに準拠した型(actorである必要はありません)に付与することができるアノテーションです。

GlobalActorプロトコルは、次の定義を要求します。

protocol GlobalActor {
  associatedtype ActorType: Actor
  static var shared: Self.ActorType
}

この定義から、シングルトンのactorを提供するのがGlobalActorだといえます。

そして、@globalActorを付与された型は、@MainActorのように@をつけることでアノテーションとして使用することができます。

GlobalActorの効果

まずは適当なGlobalActor @MyActorを定義します。

@globalActor
actor MyActor {
  // ActorTypeは型推論されるので明示的に書かなくてもOK
  static let shared = MyActor()
}

@MyActorを付与した関数を定義すると、actorと同じようなawaitの強制ルールが適用されるようになります。

@MyActor
func work1() {
  print("work1")
}

@MyActor
func work2() {
  // 同じ@MyActorの中なので、awaitなしで呼び出せる
  work1()
  print("work2")
}

// @MyActorの外なのでawaitが必要
await work1()

また、actorと同じように@MyActorを指定した処理同士で直列化されます。

次の例ではちょっと意地悪なことをしてみます。@MyActorを指定した関数同士(work1, work3)が直列に実行されるのは簡単に予想できます。ではMyActor.sharedのメソッドを呼び出した場合はどうなるでしょうか?

import Foundation

@globalActor
actor MyActor {
  static let shared = MyActor()

  func work2() {
    Thread.sleep(forTimeInterval: 2)
    print("MyActor.shared.work2")
  }
}

@MyActor
func work1() {
  Thread.sleep(forTimeInterval: 3)
  print("work1")
}

@MyActor
func work3() {
  Thread.sleep(forTimeInterval: 1)
  print("work3")
}

// 順番に同時に呼び出し。work2は@MyActorではなくMyActor.sharedのメソッド。
async let task1: () = work1()
async let task2: () = MyActor.shared.work2()
async let task3: () = work3()
_ = await (task1, task2, task3)
work1
MyActor.shared.work2
work3

結果は、@MyActorを指定した関数とMyActor.sharedのメソッドは全部混ぜて直列化されました。このことから、@MyActorを指定すると、MyActor.sharedと同じコンテキストで実行される(MyActor.shared.unownedExecutorを使って実行される)といえます。

つまり@MainActorとは何であったか

ここまでの内容から@MainActorは次のような動作をするものだといえそうです。

  • @MainActorを付与した型や関数は、MainActor.shared.unownedExecutorを使って実行される → このExecutorの実装がDispatchQueueを使うものになっている(ExecutorはコンパイラのBuiltinで実装されている)
  • actorのルールに従って、awaitを強制されたりされなかったりする

おまけ: GlobalActorの指定はObjective-Cからの呼び出しには効果がない

Objective-Cから呼び出される関数にGlobalActorアノテーションをつけたらどうなるでしょうか? 当然、指定したGlobalActorのコンテキストで呼び出されると思いきや、完全に無視されます。

次の例を見てください。ObjcClass.foo@MainActorが指定されているので、必ずメインスレッドで実行されるはずです。しかし、callObjCの実行結果を見ると、ワーカースレッドがprintされています。このようにObjective-Cから呼び出されるときは、GlobalActorは効かないです。Swiftコンパイラにも守ってもらえなくなるので注意しましょう。

import Foundation

class ObjcClass: NSObject {
  @objc @MainActor
  func foo() {
      print("foo")
      print(Thread.current)
  }
}

func callNormally() async {
  await Task.yield() // スレッドプールで実行する
  let obj = ObjcClass()
  await obj.foo() // 通常の呼び出し
}

func callObjC() async {
  await Task.yield() // スレッドプールで実行する
  let obj = ObjcClass()
  obj.perform(#selector(ObjcClass.foo)) // Objective-Cのメッセージで呼び出し
}

await callNormally()
await callObjC()
foo
<_NSMainThread: 0x7fe28b7085d0>{number = 1, name = main}
foo
<NSThread: 0x7fe28c005910>{number = 2, name = (null)}