👻

【翻訳】Swift Concurrency Deep Dive [1] — GCD vs async/await

2023/07/22に公開

この投稿は、 Swift の並行処理、つまり async/await をより深く理解するために書かれています。

私はいつも WWDC のセッションから iOS の情報を得ることが多いのですが、セッションでカバーされていないことが多いように感じることがあります。今回、 Swift の並行処理を勉強しているときに、また同じような課題に出くわしたので、今後の参考のために役立つ情報を一箇所に集めることにしました。それがこの記事を書き始めた理由です。

できるだけ Apple Developer's Document、 Swift-evolution リポジトリ、 Swift Language Guide のような信頼できるソースから情報を集めましたが、間違った情報が含まれているかもしれません。その場合は、コメントで教えてください。

キーワード

GCD, full thread context switching, forward progress, continuation

GCD の代替としての Swift 並行処理

WWDC のいくつかのセッションで、アップルは GCD を Swift Concurrency に置き換える意向を示しています。それは Swift concurrency のような WWDC のセッションで非常に明白でした:Behind the scenes - WWDC21」や「Meet async/await in Swift - WWDC21」のような WWDC のセッションで非常に顕著でした。それらによると、 GCD が生成する可能性のあるエラーは以下のようなものです。

GCD の欠点 1:スレッドが爆発しやすい

スレッドの爆発は主に、並行キューで長時間実行されるメソッドをディスパッチするときに発生します。以下は、この問題を引き起こすコードの例です。

import Foundation

let queue = DispatchQueue(label: "test", attributes: .concurrent)
for i in 0..<100 {
    queue.async {
        print(i, Thread.current)
        sleep(5)
    }
}

上記のコードでは、各ループが5秒以上消費するタスクをキューに追加すると仮定しています。これは、大きなファイルのロードやネットワークからのデータのダウンロードなどのタスクです。

キューは直列ではなく、ディスパッチ時に sync メソッドで前のブロックの戻りを待たないので、 OS はできるだけ多くのタスクを同時に処理しようとします。その結果、 OS は次のような判断を下します。

// Logs from console
0 <NSThread: 0x600003938000>{number = 5, name = (null)}
5 <NSThread: 0x60000392d380>{number = 6, name = (null)}
6 <NSThread: 0x60000392c540>{number = 7, name = (null)}
2 <NSThread: 0x60000392cbc0>{number = 8, name = (null)}
...
95 <NSThread: 0x600003938540>{number = 42, name = (null)}
98 <NSThread: 0x60000392c740>{number = 50, name = (null)}
97 <NSThread: 0x600003920740>{number = 46, name = (null)}
99 <NSThread: 0x60000392c800>{number = 25, name = (null)}

できるだけ多くのスレッドを作成します!

その数が100を超え、1000のように増えても、システムがクラッシュするのは明らかです。

GCD の欠点2:頻繁なコンテキスト切り替えによるオーバーヘッド

運がよければ、システムがクラッシュする直前にOSがスレッドの作成を停止するかもしれません。しかしこの場合、システムがクラッシュしなかったとしても、全体的なパフォーマンスが低下する可能性があります。

スレッドが増えれば増えるほど、 CPU でフルスレッドのコンテキスト・スイッチングが頻繁に発生するからです。フルスレッドのコンテキスト・スイッチがどれだけシステムのリソースを浪費するかは、よく知られた事実です。

Swift の並行処理はこれらの問題を解決できるのでしょうか?

答えはイエスです。

もしそうなら、 Swift の並行処理はどのように問題を解決できるのでしょうか?

それは、 Swift の並行処理が、アイドル状態のスレッドをブロックする代わりに再利用するように設計されているからです。

また、そこから Swift の並行処理によって処理されるタスクは、以前のように特定のスレッドに属するとは限りません。言い換えれば、タスクが残りの操作を処理するためにスレッドに戻ったとき、そのスレッドは元々タスクを開始したスレッドではないかもしれないということです。

これは、 OS がアイドル状態のスレッドにタスクを割り当てて実行できるようになったことを意味します。

GCD では、 suspended(sync) タスクは、現在実行中のタスクの復帰を待つ必要があります。しかし、Swift の並行処理では、そのタスクをスレッドから削除し、空いたスペースを埋めるために実行待ちの別のタスクを割り当てます。

したがって、アイドルスレッドの最小数を維持することが可能になり、その結果、 Swift の並行処理の目標である「1コア1スレッド」に一歩近づくことができます。

from WWDC Session

これは、私たちが今、代わりに関数呼び出しのコストだけを支払うことを意味します。ですから、 Swift の並行処理に必要な実行時の動作は、 CPU コアの数だけスレッドを作成し、スレッドがブロックされたときに、安価で効率的に作業項目を切り替えることができるようにすることです。

しかし、 GCD の2つ目の欠点である「頻繁なコンテキストの切り替え問題」を繰り返しているようです。

実は、 Swift の並行処理では、完全なスレッドのコンテキスト切り替えは起こらないので、これとは無縁です。 WWDC のセッションを引用すると、 Swift がタスクを交換するのにかかるコストは「関数を呼び出すコスト」だけであり、その結果、コンテキスト切り替えのコストは消えています。

簡単な実験で、それを簡単に把握することができます。以下は、先ほどの GCD の例と同じように100回表示する数行のコードです。

import Foundation

Task {
    await withThrowingTaskGroup(of: Int.self) { group in
        for i in 0..<100 {
            group.addTask {
                try await Task.sleep(nanoseconds: 1_000_000_000)
                print(i, Thread.current)
                return i
            }
        }
    }
}

これは、print文を1秒遅れで実行するタスク群です。try await Task.sleep(nanoseconds: 1_000_000_000) は、10⁹ナノ秒、つまり対応するサスペンドポイントで1秒間サスペンドすることを意味します。

TaskGroup は、動的に設定可能なタスクのグループです。 TaskGroup を使用すると、子タスクを並行して実行し、実行中の子タスクが全て完了した後に完了時のコールバックを受け取ることができます。

上記のステートメントの実行結果は次のようになります。

0 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}
1 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}
3 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}
2 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}
...
96 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}
99 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}
98 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}
92 <NSThread: 0x6000011e88c0>{number = 7, name = (null)}

上記の GCD の例と比較すると、全ての演算が同じスレッド上で並列に実行されています。

もう1つ気にしなければならないことがあります。ここで、 GCD のように sleep(1) を使うと、 sync 関数を使ったように各タスクがシリアルに実行されることが分かります。これは、 sleep がスレッドをブロックするメソッドだからです。

Apple は sleep のようなスレッドをブロックするアクションを非フォワードプログレスとして定義しており、 Swift の並行処理のコンテキストで使用することを推奨していません。

スレッドを恣意的にブロックすることは、デッドロックのような問題を引き起こす可能性があり、システムの予期せぬ崩壊をもたらす可能性があるからです。他の例としては、 NSLock や DispatchSemaphore があります。

私たちは、 Swift の並行処理が、 GCD の2つの主な欠点である、スレッドの爆発に対する脆弱性と、頻繁なコンテキストの切り替えによるオーバーヘッドをどのように解決するかを調べました。

では、どんな魔法がこの全てを可能にしたのか、不思議に思うかもしれません。それは、 Swift の並行処理が continuation を実装しているからです。

【翻訳元の記事】

Swift Concurrency Deep Dive [1] — GCD vs async/await
https://medium.com/towardsdev/swift-concurrency-deep-dive-1-gcd-vs-async-await-280ac5df7c76

Discussion