Goルーチン, スレッド, Async/Awaitの違いを整理
はじめに
こんにちは。株式会社AI Shiftの木村です。
本記事はのAI Shift Advent Calendar2024の19日目の記事となります。
今年を振り返ってみると仕事ではgolandのGoルーチン、個人開発ではDart/FlutterでAsync/Awaitで非同期処理を書く機会がありました。今まで自分が慣れていたJavaのスレッドによる並行処理とは少しアプローチが違っていたので今回の記事ではそれぞれを比較、整理してみたいと思います。
1. スレッド
スレッドは、OSによって管理される実行単位であり、多くのプログラミング言語でサポートされています。
特徴
- OS管理: スレッドはOSによって管理され、カーネルスケジューリングによって実行されます。
- メモリコスト: 各スレッドにはスタックが割り当てられるため、メモリ使用量が多くなる傾向があります。
- 高い制御性: 低レベルでの制御が可能で、複雑な並行処理にも対応できます。
メリット
- マルチコアCPUをフル活用できる。
- 他の並行処理手法よりも制御が詳細に行える。
デメリット
- オーバーヘッドが大きい: コンテキストスイッチによる性能低下が起こりやすい。
- デッドロックやレースコンディションのリスク: スレッド間で共有リソースを扱う際に注意が必要。
サンプルコード(Java)
public class ThreadExample {
public static void printMessage(String message) {
for (int i = 0; i < 5; i++) {
System.out.println(message);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
printMessage("Thread 1");
});
Thread thread2 = new Thread(() -> {
printMessage("Thread 2");
});
thread1.start();
thread2.start();
}
}
2. Goルーチン
Goルーチンは、名前の通りGo言語特有の並行処理の基本単位です。スレッドに比べて非常に軽量で、Goランタイムがその実行を管理します。
複数のCPUコアがある場合は、複数のOSスレッドを使用して並列に実行される可能性が高まります。そうでない場合も、ネットワーク通信などのブロッキング操作が発生するとGoランタイムは他のGoルーチンが実行できるように効率的に切り替えを行います。
特徴
- 軽量: 数キロバイトのメモリしか使用せず、数千ものGoルーチンを同時に実行可能。
- ランタイム管理: Goランタイムがスレッドプールを使用してGoルーチンをスケジューリングします。
- チャネル: Go特有の構文でスレッドセーフな通信が可能です。
メリット
- スレッドよりもオーバーヘッドが少ない。
- 記述がシンプルで、並行処理が簡単に実装できる。
デメリット
- ランタイム依存のため、低レベルな制御は難しい。
- 大量のGoルーチンが誤って生成されると、メモリ不足に陥る可能性がある。
サンプルコード(Go)
package main
import (
"fmt"
"time"
)
func say(message string) {
for i := 0; i < 5; i++ {
fmt.Println(message)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("Hello")
go say("World")
time.Sleep(1 * time.Second)
fmt.Println("Done")
}
3. Async/Await
Async/Awaitは、非同期プログラミングをシンプルかつ直感的に記述するための構文です。イベントループを基盤とした非同期処理に適しています。JavaScript, Python, Dart/Flutterなどのプログラミング言語で採用されています。
特徴
- 非同期I/O: 非同期タスクの実行に最適化されており、主にI/Oバウンドの処理で使用されます。
- イベントループ: メインスレッドで非同期タスクを管理します。
- 直感的な構文: プロミスチェーンを使った記述に比べ、コードの可読性が向上します。
メリット
- 非同期処理を同期処理のように記述できる
- I/O待ちの効率的な処理
- 単一スレッドでの非同期実行
デメリット
- CPU負荷の高い処理に不向き
- 非同期処理の伝播性(async/awaitの呼び出し元も非同期になる)
サンプルコード(JavaScript)
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function say(message) {
for (let i = 0; i < 5; i++) {
console.log(message);
await wait(100);
}
}
(async () => {
say("Hello");
say("World");
})();
比較表
手法 | 主な用途 | メリット | デメリット |
---|---|---|---|
スレッド | CPUバウンドタスク | 高い制御性 | 高いオーバーヘッドと競合リスク |
Goルーチン | 高並列処理 | 軽量でスケーラブル | ランタイム依存で制御性が低い |
Async/Await | 非同期I/O処理 | 読みやすいコード | CPUバウンドには非効率的 |
排他制御について
並行処理自体の実装は、各アプローチとも比較的簡単です。複数の処理を並行して実行することで、プログラムの実行速度を向上させることができ、特にI/O処理や独立した計算処理では、顕著な性能向上が期待できます。
しかし、複数の処理が同じリソースにアクセスする場合、一転して意図した通りに動作させる難易度が上がります。この問題は「競合状態(Race Condition)」と呼ばれ、データの整合性を損なう可能性があります。
排他制御なしの場合の問題例
// 問題のあるコード例
type Counter struct {
count int
}
func (c *Counter) Increment() {
current := c.count // 読み取り
c.count = current + 1 // 更新
}
func main() {
counter := &Counter{}
// 複数のGoルーチンから同時にIncrementを呼び出す
for i := 0; i < 1000; i++ {
go counter.Increment()
}
// 実行結果を確認するため少し待つ
time.Sleep(time.Second)
fmt.Println("Final count:", counter.count)
// 1000を期待するが、実際にはそれより小さい値になる
}
このコードでは以下のような問題が発生する可能性があります:
- GoルーチンA: current := c.count (値: 0)
- GoルーチンB: current := c.count (値: 0)
- GoルーチンA: c.count = current + 1 (結果: 1)
- GoルーチンB: c.count = current + 1 (結果: 1)
期待値は2ですが、実際の結果は1となってしまいます。
Goルーチンの例を挙げましたが、スレッドやAsync/Await(シングルスレッドでも)でも同様の問題が起こります。
a. ロックによる排他制御
type Counter struct {
mu sync.Mutex // カウンターへのアクセスを保護するためのミューテックス
count int
}
func (c *Counter) Increment() {
c.mu.Lock() // ロックを取得
defer c.mu.Unlock() // 関数終了時にロックを解放
current := c.count // 読み取り
c.count = current + 1 // 更新
}
func main() {
counter := &Counter{}
// 複数のGoルーチンから同時にIncrementを呼び出す
for i := 0; i < 1000; i++ {
go counter.Increment()
}
// 実行結果を確認するため少し待つ
time.Sleep(time.Second)
fmt.Println("Final count:", counter.count) // 1000が出力される
}
b. Goのチャネルによる排他制御
package main
import (
"fmt"
"time"
)
type Counter struct {
value int
inc chan bool
}
func NewCounter() *Counter {
c := &Counter{
value: 0,
inc: make(chan bool),
}
go c.run()
return c
}
func (c *Counter) run() {
for {
select {
case <-c.inc:
c.value++
}
}
}
func (c *Counter) Inc() {
c.inc <- true
}
func (c *Counter) Value() int {
return c.value
}
func main() {
counter := NewCounter()
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 1000; j++ {
counter.Inc()
}
}()
}
time.Sleep(time.Millisecond * 100)
fmt.Println("Counter:", counter.Value()) // Counter: 10000
}
コードは少し複雑になってしまいますが、チャネルからテータを受信してカウントアップする部分は c.run()
単一のGoルーチンに集約されて処理されるためデータ競合(race condition)が発生せず、排他的に処理が行われます。
まとめ
並行処理、排他制御の手段は言語を選択した時点である程度決まってきますが、他の手段を知り相対的に捉えることで、その特性や適用範囲をより深く理解できるようになるのではないかと思います。
最後に
AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
Discussion