Swift Digest
SE-0340 | Swift Evolution

Unavailable From Async Attribute

Proposal
SE-0340
Authors
Evan Wilde
Review Manager
Joe Groff
Status
Implemented (Swift 5.7)

01 何が問題だったのか

Swift の並行処理モデルでは、async な関数が suspension point(await)の前後で別のスレッドに移って再開する可能性があります。この挙動は計算資源を有効に使ううえで重要ですが、スレッドに強く紐づく API を async 関数から素朴に呼び出すと、容易に未定義動作を引き起こします。

代表的な例が pthread_mutex_t です。pthread_mutex_t は「ロックしたスレッドと同じスレッドからアンロックしなければならない」という制約を持っています。しかし次のコードでは、await op() の前後でスレッドが変わる可能性があり、アンロックが別スレッドから行われて未定義動作となります。

func badAsyncFunc(_ mutex: UnsafeMutablePointer<pthread_mutex_t>, _ op: () async -> ()) async {
  pthread_mutex_lock(mutex)
  await op()
  pthread_mutex_unlock(mutex) // Bad! 別スレッドでアンロックされるかもしれない
}

同様の問題は、スレッドローカルストレージへの読み書きや、pthread 系のセマフォ・ロックを suspension point をまたいで保持するケースでも起こります。デッドロックや再現困難なバグの原因になりやすく、静かに壊れるため非常に厄介です。

これまでの Swift には、こうした「async から直接呼ぶべきでない API」を API 側でマークし、呼び出し側にコンパイル時に警告する手段がありませんでした。ライブラリ作者にできるのはコメントやドキュメントでの注意喚起だけで、利用者は落とし穴を踏んでから気付くしかない状況でした。

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

本Proposalは、@available 属性に新しい kind として noasync を追加し、「非同期コンテキストからは直接呼べない API」を宣言できるようにします。既存の @available の仕組みに乗せることで、messagerenamed といった付加情報もそのまま使えます。

@available(*, noasync)
func doSomethingNefariousWithNoOtherOptions() { }

@available(*, noasync, message: "use our other shnazzy API instead!")
func doSomethingNefariousWithLocks() { }

func asyncFun() async {
  // Error: doSomethingNefariousWithNoOtherOptions is unavailable from
  //        asynchronous contexts
  doSomethingNefariousWithNoOtherOptions()

  // Error: doSomethingNefariousWithLocks is unavailable from asynchronous
  //        contexts; use our other shnazzy API instead!
  doSomethingNefariousWithLocks()
}

noasync はほとんどの宣言に付けられますが、デストラクタには付けられません。デストラクタは明示的に呼ばれず、どこからでも呼べる必要があるためです。

チェックは「直接呼び出し」のみ(thin checking)

noasync のチェックは弱く、「async コンテキストから直接呼んでいるか」だけを見ます。同期関数でラップしてしまえば、その同期関数を async コンテキストから呼ぶことは許されます。これは、pthread_mutex_lock のように「suspension point をまたがないなら async からでも安全に使える」ケースに道を残すためです。

次の with_pthread_mutex_lock は、ロックの取得と解放が同じ同期スコープ内で完結する安全なラッパーで、async 関数から問題なく呼べます。

func goodAsyncFunc(_ mutex: UnsafeMutablePointer<pthread_mutex_t>, _ op: () -> ()) async {
  // OK: pthread_mutex_lock を別の関数でラップしている
  with_pthread_mutex_lock(mutex, do: op)
}

func with_pthread_mutex_lock<R>(
    _ mutex: UnsafeMutablePointer<pthread_mutex_t>,
    do op: () throws -> R) rethrows -> R {
  switch pthread_mutex_lock(mutex) {
    case 0:
      defer { pthread_mutex_unlock(mutex) }
      return try op()
    case EINVAL:
      preconditionFailure("Invalid Mutex")
    case EDEADLK:
      fatalError("Locking would cause a deadlock")
    case let value:
      fatalError("Unknown pthread_mutex_lock() return value: '\(value)'")
  }
}

この「弱いチェック」には抜け道もあります。async コンテキスト内でも、同期クロージャでくるんでしまえば noasync の API をそのまま呼び出せます。これは意図的な仕様で、特殊な事情でどうしても呼びたい利用者にエスケープハッチを残しています。

@available(*, noasync)
func pthread_mutex_lock(_ lock: UnsafeMutablePointer<pthread_mutex_t>) {}

func asyncFun(_ mutex: UnsafeMutablePointer<pthread_mutex_t>) async {
  // Error: pthread_mutex_lock is unavailable from async contexts
  pthread_mutex_lock(mutex)

  // OK: async コンテキストから「直接」呼んではいない
  _ = { pthread_mutex_lock(mutex) }()

  await someAsyncOp()
}

一方、同期関数の中に書かれた async クロージャの中身はきちんとチェックされます。noasync は「囲むコンテキストが async かどうか」を見るため、同期関数の本体そのものは走査せずに済み、コンパイル時間への影響を抑えつつ必要な場所だけ診断できます。

@available(*, noasync)
func bad2TheBone() {}

func makeABadAsyncClosure() -> () async -> Void {
  return { () async -> Void in
    bad2TheBone() // Error: Unavailable from asynchronous contexts
  }
}

代替 API への誘導

noasync は単に使用を禁じるだけでなく、安全な代替 API に誘導する手段としても使えます。同期的に使うぶんには安全だが、async からは直接使わせたくない API を、特定のアクター経由でのみ露出するパターンが典型です。

たとえば、スレッドローカルストレージから値を読む関数は一般の async 関数からは安全に使えませんが、メインスレッドでしか走らない @MainActor 上でなら安全です。元の同期関数には noasync を付け、@MainActor の関数として薄いラッパーを用意します。

@available(*, noasync, renamed: "mainactorReadID()", message: "use mainactorReadID instead")
func readIDFromThreadLocal() -> Int { /* ... */ }

@MainActor
func mainactorReadID() -> Int { readIDFromThreadLocal() }

func asyncFunc() async {
  // Bad: どのスレッドで動いているかわからない
  let id = readIDFromThreadLocal()

  // Good: メインアクターに hop したうえで、メインスレッド上で実行される
  let id = await mainactorReadID()
}

renamed: を併せて指定しておくと、診断メッセージと fix-it で代替 API の名前を提示できます。公開済みの同期 API をアクター内に取り込めば(ソース互換のため)元の関数は残したまま、async からの呼び出しだけをアクター越しに絞り込む、といった運用も自然に書けます。

@available(*, noasync, renamed: "DataStore.save()")
public func save(_ line: String) { }

public actor DataStore { }

public extension DataStore {
  func save(_ line: String) {
    save(line)
  }
}

ソース互換への配慮

既存の async コードに後から noasync を付けた API を読み込ませると、新たに診断が出る可能性があります。移行を緩やかにするため、Swift 5.6 では警告として報告し、Swift 6 で完全なエラーに昇格させる方針が採られます。どうしても回避したい場面では、前述のとおり同期クロージャでくるんで呼び出せます。

今後の方向性

以下は speculative な見通しで、実現を約束するものではありません。

カスタムエグゼキュータが言語機能として入ると、アクターに代替 API を生やすパターンをさらに発展させ、「特定のエグゼキュータ上でのみ呼べる API」を noasync と組み合わせて表現できるようになることが期待されています。たとえば I/O 専用のエグゼキュータを unownedExecutor として持つアクター IOActor を定義し、noasync の付いた readIntFromIOIOActor のメソッド越しにだけ露出する、といった構成です。

protocol IOActor: Actor { }

extension IOActor {
  nonisolated var unownedExecutor: UnownedSerialExecutor {
    return getMyCustomIOExecutor()
  }
}

@available(*, noasync, renamed: "IOActor.readInt()")
func readIntFromIO() -> String { /* ... */ }

extension IOActor {
  func readInt() -> String { readIntFromIO() }
}

こうした構成により、readIntFromIO が一般の async コンテキストから直接呼ばれないことを保証しつつ、適切なエグゼキュータを介した安全な経路だけを開けておけます。