Swift Digest
SE-0343 | Swift Evolution

Concurrency in Top-level Code

Proposal
SE-0343
Authors
Evan Wilde
Review Manager
Saleem Abdulrasool
Status
Implemented (Swift 5.7)

01 何が問題だったのか

Swift の top-level code(main.swift やスクリプト的に書くファイルのトップレベル)は、他の宣言空間と少し違う振る舞いをします。トップレベルに書いた変数はグローバル変数としてモジュール全体から参照できますが、初期化はローカル変数と同じく逐次的に行われる、というハイブリッドな存在です。そしてグローバル変数は、並行処理と組み合わせたときに最も危険な要素の一つです。何のisolationも保証されないまま複数スレッドから触られればデータ競合が起きてしまいます。

一方で top-level code は、ちょっとした実験や手軽なスクリプトを書くための場として位置づけられています。そこに並行処理機能を持ち込むのであれば、データ競合の穴を開けたままにはできません。

もう一つの難しさは、top-level code が同期コンテキストか非同期コンテキストか という点です。これを切り替えると関数のオーバーロード解決に影響が出るため、スイッチを無邪気に切り替えると既存のスクリプトに思わぬ意味変化をもたらしかねません。たとえば以下のようなコードはこれまで書けませんでした。

func doAsyncStuff() async {
  // ...
}

await doAsyncStuff() // top-level に await は書けない

top-level に awaitasync let を書けるようにするためにも、「いつ非同期コンテキストとして扱うか」「そのときトップレベル変数をどうデータ競合から守るか」を丁寧に決める必要がありました。

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

本Proposalは、top-level code に await / async let / for await といった suspension point が現れた場合に、top-level を非同期コンテキストとして扱うルールを定めます。同時にトップレベル変数を @MainActor で暗黙的に保護することで、データ競合の穴を塞ぎます。

非同期 top-level の判定

top-level code を非同期コンテキストとみなす条件は、匿名クロージャのそれ(SE-0296)と同じです。トップレベルの直下に suspension point が一つでもあれば、top-level は非同期コンテキストになります。

func theAnswer() async -> Int { 42 }

async let a = theAnswer() // 暗黙の await で非同期

await theAnswer() // 明示の await で非同期

for await number in numbers { // for await も非同期のトリガー
  print(number)
}

一方、関数本体やクロージャ本体の中にある awaitカウントされません。それらは別の独立した(非)同期コンテキストだからです。

func doAsyncStuff() async { /* ... */ }

var countCall = 0

let myClosure = {
  await doAsyncStuff() // この await は top-level を非同期にしない
  countCall += 1
}

await myClosure() // この await が top-level を非同期にする

top-level が非同期コンテキストになると、@main を使わずに書かれたスクリプトの入口にも暗黙の非同期エントリポイントが用意され、トップレベル全体が async な文脈として順次実行されるようになります。

トップレベル変数は暗黙に @MainActor

トップレベル変数は暗黙的に @MainActor に isolateされます。top-level code はもともとメインスレッド上で走るため、トップレベルのコード自身も暗黙的に main-actor-isolated として扱われます。これにより、トップレベルで変数を読み書きするために毎回 await を書く必要はありません。

ただし、トップレベルに書かれた同期的なグローバル関数は main-actor-isolated ではないので、そこから変数にアクセスすると本来はデータ競合のエラーになります。これが既存コードを壊しすぎないよう、トップレベル変数には暗黙的に @preconcurrency も付与されます。Swift 5 では警告に留まり、Swift 6 以降は通常どおりハードエラーになります。

var a = 10 // 実質 @MainActor @preconcurrency var a = 10

func bar() {
  print(a) // Swift 5: 警告 / Swift 6: エラー(bar は MainActor ではない)
}

bar()

await something() // これで top-level が非同期コンテキストになる

-warn-concurrency フラグを付けた場合、await があるときは Swift 5 でも警告がハードエラーに格上げされます。await が無いときでもトップレベル変数は main actor で保護され、データ競合のチェックは厳格に行われます(ただし top-level 自体は同期のままなのでオーバーロード解決の挙動は変わりません)。

トップレベル変数にグローバルアクターを明示指定するのは禁止

トップレベル変数に対して、ユーザーが自分で @MainActor や他のグローバルアクターを明示的に付けることはできなくなります。以下のような、グローバル変数の「宣言は見えているが初期化はまだ」という順序依存のバグ(クラス型なら segmentation fault になりうる)を将来塞ぐために、トップレベル変数を暗黙の main 関数のローカル変数として扱う方向へ進めたい、という事情があります。

print(a) // "0" が出る(クラス型なら segfault のおそれ)
let a = 10

将来この挙動を改めてもソース破壊を最小にできるよう、今のうちから明示的なグローバルアクター指定は禁じておく、という判断です。

ソース互換性

現状の top-level code では await を書くこと自体ができないため、本Proposalで追加される挙動によって影響を受ける既存スクリプトはありません。新しく await を書いたときから、非同期 top-level と main-actor 保護がまとめて有効になります。