Asynchronous Main Semantics
01 何が問題だったのか
SE-0281 で導入された @main と Swift 並行性により、非同期な main 関数を書けるようになりました。通常、プログラムの main 関数は起動時のセットアップを担い、他のコードが走り出す前に必要な初期化を済ませる場所として期待されています。
しかし非同期な main 関数は、その前提を崩してしまう挙動を持っていました。非同期 main 関数は暗黙にタスクに包まれ、メインキューにエンキューされてから実行されます。一方で、Objective-C・C・C++ のイニシャライザ(グローバル変数の動的初期化など)は main エントリポイントより前に走り、その中でメインキューにタスクを積むこともできます。その結果、イニシャライザが積んだタスクが、ユーザが書いた main 関数より先に実行されてしまう という順序逆転が起こり得ました。
同期な main 関数であれば、イニシャライザの直後、まだどのタスクも実行されていない時点で main 本体が動くため、このようなセットアップ処理を安全に書けていました。非同期 main ではそれが失われており、Swift/C++ 相互運用などで「main の先頭で初期化しておきたいグローバル状態が、別のタスクから先に参照されてしまう」という事故が発生しやすい状態でした。
概念的には次のような構図です。C++ 側のグローバル変数のコンストラクタがメインキューに処理を積み、Swift 側の main() ではその処理が依存する状態を初期化する、というコードを書いても、順序の保証がないため assert が発火してしまいます。
struct MyAudioManager {
int deviceHandle = 0;
MyAudioManager() {
// 2. グローバル変数のコンストラクタがメインキューにタスクを積む
dispatch_async(dispatch_get_main_queue(), ^{
// 4. main() がまだ走っていないので deviceHandle は 0 のまま
assert(deviceHandle != 0 && "Device handle not initialized!");
});
}
};
// 1. main エントリポイントより前にグローバル変数が動的初期化される
MyAudioManager AudioManager;
@main struct Main {
// 3. main エントリポイントはこの関数をタスクに包んでエンキューする
static func main() async {
// 本来ここで deviceHandle を初期化したいが、上のタスクが先に走ってしまう
AudioManager.deviceHandle = getAudioDevice()
}
}
加えて、非同期 main 関数は「メインスレッド上で動くこと」を期待されるのが自然ですが、仕様上はそれが保証されておらず、@MainActor で保護された状態へのアクセスのたびに不要な await が必要になる、といった不便もありました。
02 どのように解決されるのか
非同期 main 関数の意味論を次の 2 点で調整します。
- main 関数を 最初の suspension point までは同期的に実行 する
- main 関数を暗黙に
@MainActorで保護する
最初の suspension point までは同期実行
非同期 main 関数は、本体の先頭から最初の await に到達するまでは、main エントリポイントから直接(タスクとしてエンキューされずに)同期的に実行されます。この間、メインキューに積まれた他のタスクは走りません。最初の suspension point に到達すると、その先の継続(continuation)がメインキューにエンキューされ、以降は通常どおり他のタスクと協調して動きます。これは await 本来の意味論(他のタスクに実行を譲る)と一貫しています。
これにより、イニシャライザがメインキューに積んだタスクが走り始める前に、main 関数の先頭で必要なセットアップを済ませることができます。
@main struct Main {
static func main() async {
// イニシャライザが積んだタスクよりも前に、同期的に実行される
AudioManager.device = getAudioDevice()
// ここで継続がメインキューにエンキューされる。
// 以降はメインキュー上の他のタスクも実行され得る。
await doSomethingCool()
}
}
main は暗黙に @MainActor
main エントリポイントはメインスレッド上で始まります。スレッドを跨ぐ suspension を生じさせないため、main 関数自体が MainActor 上で動くものとして扱われるようになります。これにより、@MainActor で保護された状態や関数へのアクセスは同期的に行え、不要な await を書かなくて済みます。
@MainActor
var variable: Int = 32
@main struct Main {
static func main() async {
// main は暗黙に MainActor 上で動くため、ここは suspension point ではない
print(variable)
}
}
なお、main 関数はメインスレッド上で動く必要があるため、MainActor 以外のグローバルアクターを main 関数に付けることはできなくなります。従来これが通っていたコードでは新しくエラーになります。また、@MainActor で保護された対象へのアクセスに付いていた不要な await には警告が出ます。呼び出し側(main 関数を他の関数から呼ぶ側)の書き方は変わりませんが、その suspension にはメインアクターへのホップが含まれ得る、という点だけ変化します。