Go言語のgoによるゴルーチン(Goroutine)を使った並行処理をわかりやすく解説

スポンサーリンク
スポンサーリンク
amazon
スマイルSALE
--:--:--
ad. 価格範囲を指定して商品を探せます

goの概要

別のゴルーチンで関数や処理を並行実行するGoの予約語

go

概要 goは、Goランタイム(実行環境)の多重化スケジューラに対し、直後に記述された関数呼び出しを新しい独立した並行処理の単位「ゴルーチン(Goroutine)」として切り離し、非同期かつ非ブロッキングで即座に実行を開始させるために使用します。

  • OSレベルのネイティブスレッドを直接生成するのではなく、Goランタイムが管理する軽量な仮想スレッド(グリーンスレッド)を割り当てる。
  • 1つのゴルーチンが消費する初期スタックメモリはわずか数KB(キロバイト)程度であるため、同一システム内で数万〜数十万個の並行タスクをリソース枯渇を起こさずに同時生成できる。
  • go ステートメント自体は関数の完了を待価せずに、瞬時に次の行コードへ制御を戻す(フォークする)非同期特性を持つ。

go(ゴルーチン)の考え方をイメージで理解

基本的なgoの使い方(非同期タスクのフォーク)

通常の関数呼び出しの先頭に go キーワードを添えるだけで、処理のタイムラインがメインのフローから分岐します。

package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello, Goroutine!")
}

func main() {
    // hello()関数を新しいゴルーチンとしてバックグラウンドで起動
    go hello() 
    
    // メインゴルーチンが即死してプログラム全体が終了するのを防ぐ暫定措置
    time.Sleep(100 * time.Millisecond) 
    fmt.Println("メイン処理が終了しました")
}

構造解説:

  • go hello() が実行された瞬間、hello 関数のロジックは別のタイムライン(ゴルーチン)へ移譲され、main 関数は hello の中身が実行されるのを待たずに次の行へ進みます。
  • Go言語の仕様上、main 関数(メインゴルーチン)が終端に達すると、バックグラウンドで動いている他のすべてのゴルーチンは処理の途中であっても強制破棄・一斉シャットダウンされます。そのため、ここでは一時的に time.Sleep を挟んで生存時間を引き延ばしています。

実行結果:

Hello, Goroutine!
メイン処理が終了しました

複数のゴルーチンを実行(非決定的並行処理の観察)

ループ処理と組み合わせることで、複数の独立した処理を同時に走らせることができます。

package main

import (
    "fmt"
    "time"
)

func printNumber(num int) {
    fmt.Println("タスク番号:", num)
}

func main() {
    for i := 1; i <= 5; i++ {
        go printNumber(i) // 5つの並行タスクを一斉に射出
    }
    
    time.Sleep(100 * time.Millisecond) // 全タスクの着火・処理を待つ
}

構造解説:

  • 5回のループによって5つの独立した printNumber ゴルーチンが生成されます。
  • これらは完全に並行して動作し、Goランタイムのスケジューラが空いているCPUコアへ動的にタスクを割り振るため、出力される順番は実行するたびに毎回ランダムに入れ替わります(非決定的な挙動)。

実行結果(順不同):

タスク番号: 3
タスク番号: 1
タスク番号: 5
タスク番号: 2
タスク番号: 4

sync.WaitGroupを使った確実な同期制御(Sleepの完全代替)

実務の製品コードにおいて、ゴルーチンの終了待ちに time.Sleep(秒数あてずっぽう待機)を使用することは厳禁です。代わりに標準パッケージのカウンタ型同期機構 sync.WaitGroup を使用して、すべての処理完了を安全に検知します。

package main

import (
    "fmt"
    "sync"
)

func printMessage(msg string, wg *sync.WaitGroup) {
    // 関数の離脱直前に、WaitGroupのカウンタを必ず1減らす(Done)
    defer wg.Done() 
    fmt.Println(msg)
}

func main() {
    var wg sync.WaitGroup
    messages := []string{"Go", "並行処理", "同期完了"}

    for _, msg := range messages {
        wg.Add(1) // 新しいゴルーチンを立ち上げる前に、待ちカウンタを+1する
        go printMessage(msg, &wg) // ポインタ経由で同一のWaitGroupインスタンスを渡す
    }

    // 内部カウンタが「0」になるまで、メインゴルーチンの進行をここで完全にロック(待機)する
    wg.Wait() 
    fmt.Println("すべての並行タスクが安全に終了しました。")
}

構造解説:

  • wg.Add(1) でタスクの登録件数をインクリメントし、ゴルーチン内の wg.Done() で完了報告を行います。
  • 最末尾の wg.Wait() は、登録されたすべてのタスクが Done() を叫ぶまでメインスレッドの防波堤として機能するため、無駄な待機時間を発生させることなく、かつ確実にリソースのリークを防いで安全に関数をクローズできます。

実行結果(順不同):

並行処理
Go
同期完了
すべての並行タスクが安全に終了しました。

チャネル(channel)を介したゴルーチン間の安全なメッセージ伝播

Go言語の格言に「メモリを共有することで通信するな、通信することでメモリを共有せよ」というものがあります。複数のゴルーチン間で安全にデータをリレーするための専用パイプラインが「チャネル(chan)」です。

package main

import (
    "fmt"
)

func fetchRemoteData(ch chan string) {
    // 矢印演算子「<-」を用いてチャネルのパイプへ文字列データを流し込む(送信)
    ch <- "バックグラウンドから取得したデータ"
}

func main() {
    // 文字列を通すためのチャネルを生成
    ch := make(chan string)

    // データの取得タスクを非同期で開始
    go fetchRemoteData(ch)

    // チャネルからデータが流れてくるまで、メイン側はここで自動的に一時停止(ブロッキング待機)する
    received := <-ch 
    
    fmt.Println("受信成功:", received)
}

構造解説:

  • ch := make(chan string) で生成されたチャネルは、送信と受信が揃って初めて処理が前進する「同期型」の挙動を示します。
  • received := <-ch のコード行は、別ゴルーチン側で ch <- ... の送信が行われるまで実行をブロックするため、WaitGroup を使わなくても自然にデータ到着と同期制御を同時に達成できます。

実行結果:

受信成功: バックグラウンドから取得したデータ

学術的・設計的注意点

  • M:Nスケジューラモデル(Goランタイムの内部構造): Go言語の go キーワードが超軽量に動作する理由は、内部の「M:Nスケジューラ(G-M-Pモデル)」にあります。OSのネイティブスレッド(M)の枚数をCPUの物理コア数程度に抑えつつ、その上で数万個のゴルーチン(G)を、論理プロセッサ(P)がミリ秒単位で超高速に融通・再配置(マルチプレクス)します。これにより、あるゴルーチンがネットワークI/Oなどで停止(ブロック)しても、別のゴルーチンが即座にOSスレッドへと自動的に乗せ替えられる(Work-Stealingアルゴリズム)ため、コンテキストスイッチのオーバーヘッドが他言語に比べて極限まで低く抑えられています。
  • データ競合(Data Race)の発生と検出方法: 複数のゴルーチンが、適切な同期制御(チャネルや sync.Mutex による排他ロック)を行わずに、同一のメモリ空間(グローバル変数やスライスの同一要素など)に対して「少なくとも一方が書き込み」を同時に行うと、データ競合(Data Race)が発生します。これはデータの破損や予期せぬクラッシュを招く非常に危険な状態です。Go言語はこの問題を検知するため、コンパイル時に go test -race または go run -race というオプションを提供しており、ランタイム中にデータ競合の予兆があるコードを自動検知してレポートする強力な静的・動的解析機構がビルトインされています。

よくある質問(FAQ)

Q: ゴルーチン(Goroutine)と、他言語のコルーチン(Coroutine)や非同期構文(Async/Await)は何が違うのですか?
A: 制御の自動化レベルが根本から異なります。一般的なコルーチンや JavaScript/C# などの async/await は、開発者がどこで処理を中断・再開するかをコード上に明示(協調的マルチタスク)する必要があります。これに対し、Go言語のゴルーチンはランタイム側が割り込み(プリエンプティブ・スケジューリング)を強制管理するため、開発者は単に go を付与して通常通り関数を書くだけで、システムが最適な非同期・並行計算へと勝手に昇華させてくれます。
Q: goで呼び出す関数の戻り値を、そのまま変数で受け取ることはできないのですか?
A: できません。result := go calculate() のような記述は文法エラー(コンパイルエラー)となります。go キーワードは関数を「投げっぱなし(フォーク)」にする命令であり、その場での戻り値の確定という概念が存在しないためです。並行実行した関数から結果を回収したい場合は、前述した「チャネル(channel)」を経由して結果を送信・受信させる設計パターンを必ず採用する必要があります。
Q: 大量に go を連発してゴルーチンを作った場合、メモリリーク(Goroutine Leak)は起きますか?
A: 発生します。ゴルーチン自体は軽量ですが、リソースの塊です。「チャネルの受信を永久に待ち続けているゴルーチン」や「無限ループから抜け出せなくなったゴルーチン」は、呼び出し元の処理が終わってもメモリ空間に残り続けます。これをゴルーチンリークと呼び、長時間稼働するサーバーアプリケーションにおいてメモリを徐々に圧迫する重大なバグの原因となります。必ずコンテキスト(context.Context)等を利用して、外側から処理のタイムアウトやキャンセルを伝播させて確実に殺し切る設計が必要です。

まとめ

  • go は、関数呼び出しをGoランタイム直轄の超軽量スレッド(ゴルーチン)へ引き渡し、バックグラウンドでの並行処理を開始する予約語。
  • OSスレッドを直接操作しない「M:Nスケジューラ」によって駆動するため、リソース消費が極めて少なく、大量の多重コネクションを同時に裁く構造に最適。
  • 並行実行された処理の完了タイミングやデータ回収を制御するために、sync.WaitGroupchannel(チャネル)といった同期・通信プリミティブが言語レベルで密接にデザインされている。
  • 実行順序の非決定性やデータ競合、ゴルーチンリークといった並行処理特有のリスクを認識し、-race 検出器などを活用した安全な排他制御・ライフサイクル管理の設計が求められる。