Swift Digest
SE-0434 | Swift Evolution

Usability of global-actor-isolated types

Proposal
SE-0434
Authors
Sima Nerush, Matt Massicotte, Holly Borla
Review Manager
John McCall
Status
Implemented (Swift 6.0)

01 何が問題だったのか

@MainActor のようなグローバルアクターに isolate された型は、SwiftUIのようにUI主体のコードで頻繁に使われます。ところが、ここまでの並行モデルでは、グローバルアクター isolate された型を普通に書こうとするとあちこちで摩擦が発生していました。このProposalは、そうした細々とした使いづらさをまとめて解消することを目的としています。

var の stored property と nonisolated

グローバルアクター isolate された値型(struct)の stored property は、Sendable 型であれば let については暗黙的に nonisolated として扱われます。しかし var は同じ条件でも isolate されたままで、プロトコルに適合させるときなどに困ります。

@MainActor struct S {
  nonisolated(unsafe) var x: Int = 0
}

extension S: Equatable {
  static nonisolated func ==(lhs: S, rhs: S) -> Bool {
    return lhs.x == rhs.x
  }
}

回避策は nonisolated(unsafe) を付けることでしたが、実際には何も unsafe ではありません。xSendable 型で、なおかつ値型の一部です。値型の property への同期的なアクセスは必ず外側の値全体へのアクセスでもあり、外側に対するデータ競合がSwiftによって防がれている限り、内側の x だけで競合が起きることはありません。つまり、(unsafe) を要求すること自体が過剰でした。

グローバルアクター isolate された関数型と @Sendable

関数型の世界にも、実用上ほぼ無意味な組み合わせが残っていました。グローバルアクターに isolate されているのに @Sendable ではない関数型です。

func test(globallyIsolated: @escaping @MainActor () -> Void) {
  Task {
    // error: capture of 'globallyIsolated' with non-sendable type
    //        '@MainActor () -> Void' in a `@Sendable` closure
    await globallyIsolated()
  }
}

このような関数は、呼び出し側が同じグローバルアクターに isolate されていなければ呼べません。しかしその文脈にいるなら non-Sendable な関数でもグローバルアクター上で動くわけで、@MainActor 注釈を付ける意味がほとんどありません。結果として「@MainActor を付けたのに Task { ... } に渡せない」という一見不可解な挙動が発生していました。

さらに、グローバルアクター isolate されたクロージャは並行に呼び出されないため、@Sendable として振る舞わせても non-Sendable な値を安全にキャプチャできるはずです。既存ルールはこの点も厳しすぎました。

非 isolate・非 Sendable なスーパークラスの isolate サブクラス

次のように、非 isolate で non-Sendable なクラスをスーパークラスとして @MainActor なサブクラスを作ることは、Swift 5.10以降は警告・エラーになっていました。

class NotSendable {}

@MainActor
class Subclass: NotSendable {}
// error: main actor-isolated class 'Subclass' has different actor
//        isolation from nonisolated superclass 'NotSendable'

この制約には理由があります。クラスに対するグローバルアクター isolation は暗黙の Sendable 適合を意味するため、保護されていないミュータブル状態を持つスーパークラスを isolate サブクラスでくるむと、その Sendable 適合を通じてアクター境界を越えて共有できてしまい、結果としてデータ競合の経路になり得ます。ただし、この問題を避けるために「サブクラスを作ること自体を禁止する」のは過剰で、実務では正当なユースケース(たとえば NSViewNSViewController のような non-Sendable なUIフレームワーク基底クラスをメインアクター上で継承する、など)を阻害していました。

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

グローバルアクター isolate された型にまつわる3つの使いづらさを、それぞれ次のように緩和します。

  • 値型の Sendable な stored property について、モジュール内では var でも暗黙的に nonisolated として扱う。明示的な nonisolated(unsafe) なしで書ける。
  • グローバルアクター isolate された関数・クロージャには @Sendable を暗黙的に推論する。さらに isolate されたクロージャは non-Sendable 値をキャプチャしてよい。
  • 非 isolate・非 Sendable なスーパークラスに対して、グローバルアクター isolate されたサブクラスを許可する。ただしそのサブクラスは Sendable にしない。

この変更はupcoming feature flag GlobalActorIsolatedTypesUsability の下で導入され、Swift 6言語モードで既定で有効になります。

値型の var stored property に対する nonisolated 推論

グローバルアクター isolate された値型の Sendable 型 stored property は、同一モジュール内では var であっても暗黙的に nonisolated として扱われます。

@MainActor
struct S {
  var x: Int = 0 // モジュール内では nonisolated として扱える
}

extension S: Equatable {
  static nonisolated func ==(lhs: S, rhs: S) -> Bool {
    return lhs.x == rhs.x // OK
  }
}

モジュールをまたいで同期的にアクセスさせたい場合は、これまで通り nonisolated を明示します。nonisolated(unsafe) は不要です。ただし、一度 nonisolated を公開すると、後から取り除くとクライアントを壊す可能性があるため、将来 computed property に置き換える場合もその accessor は nonisolated にする必要があります。

この推論は stored property にのみ適用されます。property wrapper が付いた property や lazy property は実体が computed property なので、従来通りグローバルアクターに isolate されたままです。

@propertyWrapper
struct MyWrapper<T> { ... }

@MainActor
struct S {
  @MyWrapper var x: Int = 0
}

extension S: Equatable {
  static nonisolated func ==(lhs: S, rhs: S) -> Bool {
    return lhs.x == rhs.x // error
  }
}

グローバルアクター isolate 関数・クロージャへの @Sendable 推論

@MainActor などグローバルアクター属性が付いた関数型・クロージャ型は、自動的に @Sendable として扱われます。

func test(globallyIsolated: @escaping @MainActor () -> Void) {
  Task {
    await globallyIsolated() // OK
  }
}

isolate されたクロージャは常にそのアクター上で実行されるため、キャプチャした値を並行に使うことがなく、別の isolation domain に渡しても安全だからです。

この変更により、@MainActor の付いた関数型は名前マングリングが変わります。影響を受けるのは、グローバルアクター isolate されていて @Sendable ではないAPIを公開している resilient ライブラリです。ただし、そのような型はそもそも実用上ほぼ無意味な組み合わせで、こうしたAPIは @Sendable を付与すべきか、すでに @preconcurrency で互換性を取っている想定です。

non-Sendable 値のキャプチャ

isolate されたクロージャは、暗黙的に @Sendable であっても non-Sendable 値をキャプチャできます。

class NonSendable {}

func test() {
  let ns = NonSendable()

  let closure = { @MainActor in
    print(ns)
  }

  Task {
    await closure() // OK
  }
}

SE-0414 の isolation region に基づく解析により、ns は closure の形成時にメインアクターの region へ transfer されたとみなされ、以後外側で並行アクセスされることはないため安全です。逆に、別の isolation domain(たとえば task-isolated な関数引数)からキャプチャした non-Sendable 値を isolate クロージャに引き込むことはできません。

class NonSendable {}

func test(ns: NonSendable) async {
  let closure = { @MainActor in
    print(ns) // error: task-isolated value 'ns' can't become isolated
              //        to the main actor
  }
  await closure()
}

Sendable スーパークラスの isolate サブクラス

非 isolate・非 Sendable なスーパークラスに対して、サブクラスにグローバルアクター isolation を付けることが許可されます。ただし、その場合サブクラスに Sendable 適合は自動付与されず、明示的に Sendable を書くとエラーです。

class NonSendable {
  func test() {}
}

@MainActor
class IsolatedSubclass: NonSendable {
  func trySendableCapture() {
    Task.detached {
      self.test()
      // error: Capture of 'self' with non-sendable type
      //        'IsolatedSubclass' in a `@Sendable` closure
    }
  }
}

スーパークラス由来の非 isolate なミュータブル状態が残っている以上、インスタンス全体を Sendable と名乗らせるわけにはいかないからです。一方、継承・オーバーライドしたメソッドは、スーパークラス側のメソッドの isolation に合わせる必要があります。

class NonSendable {
  func test() { ... }
}

@MainActor
class IsolatedSubclass: NonSendable {
  var mutable = 0
  override func test() {
    super.test()
    mutable += 0
    // error: Main actor-isolated property 'mutable' can not be
    //        referenced from a non-isolated context
  }
}

これは、スーパークラス側の実装が自身の静的な isolation を前提に動いている可能性があるのに加え、サブクラスのインスタンスがスーパークラス型へのアップキャストや existential、あるいはスーパークラスの適合を要求する型パラメータ経由で、isolation を保たない形で呼び出されうるためです。