Swift Digest
SE-0327 | Swift Evolution

On Actors and Initialization

Proposal
SE-0327
Authors
Kavon Farvardin, John McCall, Konrad Malawski
Review Manager
Doug Gregor
Status
Implemented (Swift 5.10)

01 何が問題だったのか

アクターを導入した SE-0306 は、アクターのメソッド呼び出し時のisolationは詳細に定めていた一方で、インスタンスを作る・壊す瞬間、すなわち initdeinit の最中 に何が安全で何が安全でないかを十分には定義していませんでした。その結果、Swift 5.5 の実装には次のような穴やぎこちなさがありました。

非asyncな init でデータ競合が起きる

アクターのエグゼキュータに「ホップ」するには本来 await が必要です。ところが非asyncな initawait できないので、エグゼキュータの保護がないまま本体が走ります。その状態で self を外へ逃がすと、まだ初期化中のインスタンスにタスクから同時アクセスが入り得ます。

actor Clicker {
  var count: Int
  func click() { self.count += 1 }

  init(bad: Void) {
    self.count = 0
    // 非async init なのでエグゼキュータにホップしない

    Task { await self.click() }

    self.click() // 💥 タスクの変更と競合
    print(self.count) // 💥 1にも2にもなり得る
  }
}

Swift 5.5 はこの競合を避けるため、非asyncな init の本体で self を一切 escape させない という強い制限(escaping-use restriction)をかけていました。しかしこの制限は過剰で、以下のように明らかに安全なコードまで弾いてしまいます。

actor Clicker {
  var count: Int
  func click() { self.count += 1 }
  nonisolated func announce() { print("performing a click!") }

  init(ok: Void) {
    self.count = 0
    Task { await self.click() }
    self.announce() // Swift 5.5 ではエラー。しかし `announce` は nonisolated で count に触れないので安全
  }
}

announcenonisolated なので count を読み書きできず、ここには競合はありません。にもかかわらず「self の escape だから」という理由だけで拒否されていました。

deinit でもデータ競合が起きる

deinit にも init と同様の制限がなく、self を別タスクへ渡して deinit のあともアクターの状態を触り続ける、というコードがそのまま書けてしまっていました。

actor Clicker {
  var count: Int = 0
  func click(_ times: Int) {
    for _ in 0..<times { self.count += 1 }
  }

  deinit {
    let old = count
    Task { await self.click(10000) } // ❌ deinit 後まで self を生かしてしまう

    for _ in 0..<10000 {
      self.count += 1 // 💥 タスク内の変更と競合
    }
    assert(count == old + 10000) // 💥 競合で失敗し得る
  }
}

さらに、グローバルアクターで隔離された型(以下 GAIT: global-actor-isolated type)の deinit では、もう一段面倒な競合が起こり得ます。GAIT はエグゼキュータをインスタンスごとではなく複数インスタンスで共有しているため、deinit から non-Sendable な stored property を触ると、同じ実体を参照している別インスタンスからのアクセスと競合する可能性があります。

class NonSendableAhmed { var state: Int = 0 }

@MainActor
class Maria {
  let friend: NonSendableAhmed

  init() { self.friend = NonSendableAhmed() }
  init(sharingFriendOf other: Maria) {
    self.friend = other.friend // 同じ MainActor 上なので OK
  }

  deinit {
    friend.state += 1 // 💥 deinit は `@MainActor` 上で走っていない。
                      // 別 Maria 経由で同じ friend に並行アクセスされ得る
  }
}

stored property ごとのグローバルアクターisolationと init の矛盾

Swift 5.5 は、クラス/構造体/enum の 個々の stored property に独立してグローバルアクターisolation を付けることを許していました。しかし stored property のデフォルト値は非asyncな init から呼び出されるため、ひとつの型に複数の異なるグローバルアクターで隔離された stored property があると、init にどのisolationを要求すべきか決まらなくなります。

@MainActor func getStatus() -> Int { /* ... */ }
@PIDActor  func genPID() -> ProcessID { /* ... */ }

class Process {
  @MainActor var status: Int = getStatus()
  @PIDActor  var pid: ProcessID = genPID()

  init() {} // `@MainActor` と `@PIDActor` の両方を要求することになり、実現不能
}

このコードは Swift 5.5 では受け入れられていましたが、実装上は適切なエグゼキュータにホップしないまま getStatus / genPID を呼んでいる不健全な状態でした。

アクターの delegating init に convenience が必要

値型の delegating init には convenience は要りません。一方クラスは継承があるため convenience が必要です。アクターは継承を持たないのに、Swift 5.5 ではクラスと同様に convenience を要求していました。継承のない型にとってこのキーワードはノイズで、学習コストを無駄に増やしていました。

02 どのように解決されるのか

アクターと GAIT の init / deinit について、「self のisolationがどこからどこまで効いているか」を制御フローに沿ってきちんと定義し直します。ポイントは次の5点です。

  1. initisolated selfnonisolated self の2種類に整理する。
  2. isolated self な(= asyncな)init は、self が完全に初期化された直後に暗黙的にエグゼキュータへホップする。
  3. nonisolated selfinit(非asyncまたはグローバルアクター隔離、あるいは nonisolatedinit)には、escape禁止の一律ルックではなく flow-sensitive なルール(isolation decay)を適用する。
  4. deinit にも同じ flow-sensitive ルールを適用し、加えて Sendable な stored property しか触れない ようにする。
  5. アクターの delegating init では convenience を不要にし、self.init(...) を呼ぶかどうかだけで判定する。

以下、順に見ていきます。

init のisolation分類

init は次のいずれかに必ず分類されます。

  • isolated self: asyncなアクターの initself はアクターにisolatedな参照として扱われる。
  • nonisolated self: 次のいずれか。
    • 非asyncなアクター init
    • グローバルアクターで隔離された init
    • nonisolated と明示された init

self がどちらに属するかは、プログラマ側から見ればメソッドのisolationルールと同じ基準で判定できます。

isolated selfinit: 完全初期化直後に暗黙ホップ

asyncな init の場合、self が完全に初期化された直後の位置でコンパイラが暗黙にエグゼキュータへホップします。以降は通常のasyncメソッドと同じく、self がisolatedな状態でコードが続きます。

actor Bob {
  var x: Int
  var y: Int = 2
  func f() {}

  init(_ cond: Bool) async {
    if cond { self.x = 1 } // 初期化ストア
    self.x = 2             // 初期化ストア

    f() // OK: ここではもうエグゼキュータ上にいる
  }
}

暗黙ホップの位置は「どこで self が完全初期化されるか」で決まり、stored property の数やデフォルト値の有無でふらつきます。これを都度 await で書かせるのは保守性を損なうため、async let のケースと同様に暗黙の suspension として扱うことにしました。プログラマから見れば、asyncな init は「最初から最後まで isolated」として読んで構いません。

唯一、asyncな init を呼ぶ側には await が必要なので、init が途中で一度中断し得ることは呼び出し元側にはちゃんと見えています。

nonisolated selfinit: isolation decay

nonisolated selfinit は、エグゼキュータの保護なしで本体を走らせるため、self の扱いに工夫が要ります。このカテゴリでは、self のisolationを次のように flow-sensitive に扱います。

  • 最初は self排他的な参照を持っているとみなし、stored property には自由にアクセスできる。
  • しかし、selfstored property への直接アクセス以外で使った瞬間 にisolationが nonisolated に decay する。一度 decay したら、そのコントロールフロー上で元には戻らない。
  • decay 後に触れる stored property は、Sendable かつ let バインド のものだけに制限される。

decay を引き起こす self の使い方には以下が含まれます。

  • 関数呼び出しや computed property / observed property へのアクセスなど、self引数として渡す操作
  • クロージャ(autoclosure 含む)への self のキャプチャ
  • self をメモリに格納する操作
class NotSendableString { /* ... */ }
class Address: Sendable { /* ... */ }
func greetCharlie(_ c: Charlie) {}

actor Charlie {
  var score: Int
  let fixedNonSendable: NotSendableString
  let fixedSendable: Address
  var me: Self? = nil

  func incrementScore() { self.score += 1 }
  nonisolated func nonisolatedMethod() {}

  init(_ initialScore: Int) {
    self.score = initialScore
    self.fixedNonSendable = NotSendableString("Charlie")
    self.fixedSendable = Address("123 Main St.")

    if score > 50 {
      nonisolatedMethod() // ここで decay
      greetCharlie(self)  // nonisolated な use
      self.me = self      // nonisolated な use
    } else if score < 50 {
      score = 50 // decay 前なので OK
    }

    // 上のブロックを通った経路では decay しているので、
    // 以降は stored property への広いアクセスが禁じられる
    assert(score >= 50)        // ❌ decay 後は isolated な変数にアクセス不可
    _ = self.fixedNonSendable  // ❌ decay 後は non-Sendable にアクセス不可
    _ = self.fixedSendable     // ✅ Sendable な let は OK

    Task { await self.incrementScore() } // OK: すでに decay 済み
  }
}

decay は「コントロールフロー上のどこかで一度でも nonisolated な使い方をしたら、その合流点以降は decay 済み」という判定です。ソース上の行順で見るとあとから書かれた nonisolated use が、前の stored property アクセスをエラーにするケースもあります(ループや defer を経由する場合がそれにあたります)。

init(hasADefer: Void) {
  self.score = 0
  defer {
    print(self.score) // ❌ decay 後のアクセス
  }
  Task { await self.incrementScore() } // ここで decay
}

init(hasALoop: Void) {
  self.score = 0
  for i in 0..<10 {
    self.score += i    // ❌ 2周目以降は decay 済みの経路から来る
    greetCharlie(self) // ここで decay
  }
}

この decay のおかげで、Swift 5.5 の escaping-use restriction では弾かれていた「安全な nonisolated メソッド呼び出し」などが自然に書けるようになりつつ、危険なパターンは引き続き静的に検出できます。

グローバルアクター隔離の非async init や、あえて nonisolated と宣言した async init も同じ枠組みで扱われます。

actor Status {
  var valid: Bool
  func exchange(with new: Bool) -> Bool { let old = valid; valid = new; return old }
  func isValid() -> Bool { self.valid }

  @MainActor init(_ val: Bool) async {
    self.valid = val

    let old = await self.exchange(with: false) // ここで decay(awaitを伴うisolated メソッド呼び出し)
    assert(old == val)

    _ = self.valid // ❌ decay 後のアクセス
    let isValid = await self.isValid() // ✅ メソッド経由なら OK
    assert(isValid == false)
  }
}

GAIT の init: isolatedなら保護あり、nonisolated なら decay 適用

GAIT のエグゼキュータはインスタンス生成前から存在するため、isolated な init は呼び出し側で await してエグゼキュータを取ってから入り、返るまで保持する形になります。したがって isolated GAIT init の中は通常のメソッドと同じく安全で、flow-sensitive な decay は不要です。

@MainActor
class ProtectedByExecutor<T: Equatable> {
  var item: T
  func mutateItem() { /* ... */ }

  init(with t: T) {
    self.item = t
    Task { self.mutateItem() } // ✅ ここでは MainActor 上
    assert(self.item == t)     // ✅ 保護されている
  }
}

一方、GAIT の nonisolatedinit はアクターの非async init と同じ立場なので、flow-sensitive な decay ルールが適用されます。

呼び出し時の Sendable ルール

アクターの initアクター外から呼ぶときは、引数が Sendable でなければなりません。アクターの「境界」は init の入り口から始まる、と考えます。

actor Greg {
  var ns: NotSendableType

  // 引数が Sendable なので、どこからでも呼べる。
  init(fromPieces ps: (Piece, Piece)) { self.ns = NotSendableType(ps) }

  // 引数が non-Sendable なので、delegation 専用(別 init からしか呼べない)。
  init(with ns: NotSendableType) { self.init(fromPieces: ns.getPieces()) }
}

一度 init の中に入ってしまえば、そこから別の init へ delegate する際やメソッド呼び出しに non-Sendable な値を渡すのは自由です。GAIT の nonisolatedinit から isolated な init へ delegate する場合はグローバルアクター境界をまたぐので、引数の Sendable 性が再び問われます。

delegating init: convenience 不要

アクターの delegating init は、本体のどこかに self.init(...) の呼び出しがあるかで判定します。値型と同じ扱いで、convenience キーワードは書きません。delegating init では stored property 初期化責任がないので、decay のような flow-sensitive ルールではなく、通常のメソッドと同じく一律のisolationで本体を書けます。

GAIT の delegating init は従来どおりクラスの構文規則に従います(convenience は引き続き使います)。

deinit: flow-sensitive decay + Sendable 限定

deinit は「初期化の逆向きの非asyncな init」とみなし、nonisolated self 系の flow-sensitive ルールをそのまま適用します。加えて、deinit では decay する前であっても、Sendable な stored property しか触れません

actor A {
  let immutableSendable = SendableType()
  var mutableSendable = SendableType()
  let nonSendable = NonSendableType()

  deinit {
    _ = self.immutableSendable  // ✅
    _ = self.mutableSendable    // ✅(decay 前、Sendable なので OK)
    _ = self.nonSendable        // ❌ deinit では non-Sendable は不可

    f(self) // ここで decay

    _ = self.immutableSendable  // ✅
    _ = self.mutableSendable    // ❌ decay 後は mutable 不可
    _ = self.nonSendable        // ❌
  }
}

これは GAIT の deinit にも同様に適用されます。deinit の呼び出しタイミングは静的に特定できないため、init より強い制約が必要、という判断です。

なお、Swift 6 以降では別提案(SE-0371 など)によって deinit 自体をisolatedにする方向で整理が進みますが、本提案の段階では deinitnonisolated self 扱いで、上記のルールで守るというスタンスです。

GAIT の stored property isolation 整理

  • アクターの stored property にグローバルアクターisolationを付けることは禁止されます(アクターの全storageはそのアクターインスタンスに属する、と統一)。
  • クラス/構造体/enum の stored property については、デフォルト値の式を nonisolated として扱うように変更されます。これにより「複数のグローバルアクターで隔離された stored property があると init のisolationが決まらない」問題が解消されます。
  • 値型(構造体・enum)の stored property にグローバルアクターisolationを付けても、値のコピーで共有が起きる以上、そこに並行アクセスは生じません。そのため冗長なisolation指定は取り除かれ、アクセスに await は不要になります。

※ ここでのデフォルト値を nonisolated として扱うルールは、既存の @MainActor を使うコードで負担が大きすぎることが分かり、後の SE-0411 (Isolated default values) に置き換えられました。本提案を読むときは、「stored property のデフォルト値のisolationをどう扱うかは後続で整理される」程度に理解しておけば十分です。

移行上の影響

  • 非asyncな init で許されるコードは Swift 5.5 より広がります(escape-use の警告が通るようになる)。
  • 一方で、deinit と GAIT の nonisolated init、アクターの stored property への global actor 付与、などは厳しくなります。特に deinit は既存コードで通っていたものが通らなくなる可能性があります。
  • アクターの delegating init に付いていた convenience は無視/fix-it で外せます。
  • Objective-C から取り込んだ @MainActor クラスは、ここでの新しい GAIT 扱いの対象外です(データ競合は無いと仮定)。