動作確認済み環境
Go 1.22.12(golang:1.22-bullseye Docker)および Ubuntu 24.04 LTS(Noble)で 2026-06-13 に検証しています。goroutine・channel の基本動作は Ubuntu 22.04 でも同様に動作します。
「goroutine って、スレッドとどう違うの?」「channel の使い方が分からない」——Go を学び始めて最初の壁がここです。
本記事では、golang:1.22-bullseye Docker コンテナで実際に Go プログラムを動かし、goroutine と channel の動作を実出力つきで解説します。理論だけでなく「ターミナルで実際にこう動く」という一次データをもとに説明するので、初学者でも動作イメージが湧きます。
この記事のポイント
goキーワードひとつで goroutine が起動する——5 つの goroutine が 直列 50ms → 並行 10.6ms に短縮されることを実測- channel はパイプのようなもの——producer が送り、consumer が受け取る構造を実コードで確認
- Worker Pool パターンで重いタスクを並行分散——ただし goroutine を増やしても必ず速くなるわけではない(mutex 競合に注意)
selectで複数チャネルをまとめて待つ——time.Afterのタイムアウト動作を実測で確認- goroutine リークを防ぐ done channel パターン——
runtime.NumGoroutine()で cleanup を実測 - Ubuntu 24.04 は apt で
golang-go 1.22が入る(22.04 は 1.18)
目次
- Go と goroutine の基礎知識
- Ubuntu 24.04 に Go 環境を準備する
- goroutine の基本 — sync.WaitGroup で並行実行
- channel でデータを渡す — producer/consumer パターン
- Worker Pool パターン — タスクを並行分散処理する
- select で複数チャネルを扱う
- goroutine リークを防ぐ — done channel パターン
- よくあるエラーと解決策
- まとめ
Go と goroutine の基礎知識
Go の並行処理の核心は「goroutine」と「channel」の 2 つです。
goroutine は Go ランタイムが管理する軽量スレッドです。OS スレッドと違い、起動コストが数 KB で済み、1 つのアプリに何千・何万個でも起動できます。go キーワードを関数呼び出しの前につけるだけで、その関数は goroutine として非同期実行されます。
channel は goroutine 間でデータを安全にやり取りするパイプです。Go の設計思想に「共有メモリで通信するな、通信でメモリを共有せよ(Do not communicate by sharing memory; instead, share memory by communicating)」という言葉があり、その核心が channel です。
goroutine はスレッドではない
goroutine は OS スレッドよりはるかに軽量(初期スタック 2〜8KB)。Go ランタイムが M:N スケジューリングで複数の goroutine を OS スレッドにマッピングします。並行(concurrent)と並列(parallel)の違いを意識しておくと理解が深まります。
Ubuntu 24.04 に Go 環境を準備する
①apt で Go をインストールする
Ubuntu 24.04 LTS では apt で Go 1.22 が入ります。

Reading package lists… Done
The following NEW packages will be installed:
golang-go golang-src …
$ go version
go version go1.22.2 linux/amd64
実測では Ubuntu 24.04 の apt で golang-go 2:1.22~2build1(Go 1.22)が入ります。Ubuntu 22.04 では 2:1.18~0ubuntu2(Go 1.18)です。goroutine/channel の基本機能はどちらでも動作しますが、新規開発は Ubuntu 24.04 を推奨します。
②最新バイナリ(公式推奨)
最新の Go 1.24.x が必要な場合は go.dev 公式からバイナリを取得します。
$ sudo tar -C /usr/local -xzf /tmp/go.tar.gz
$ echo ‘export PATH=$PATH:/usr/local/go/bin’ >> ~/.bashrc
$ source ~/.bashrc
$ go version
go version go1.24.4 linux/amd64
詳しいインストール手順は を参照してください。
goroutine の基本 — sync.WaitGroup で並行実行
まず最もシンプルな例から見ていきます。5 つの goroutine を起動して sync.WaitGroup で全員の終了を待ちます。
package main
import (
“fmt”
“sync”
“time”
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 終了時に wg に通知
fmt.Printf(“Worker %d: start\n”, id)
time.Sleep(10 * time.Millisecond)
fmt.Printf(“Worker %d: done\n”, id)
}
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg) // goroutine を起動
}
wg.Wait()
fmt.Printf(“All done in %v\n”, time.Since(start))
}
これを実際に実行した結果がこちらです。

注目してほしいのは 2 点です。
- Worker の開始順序が 5→1→2→3→4、完了順序が 4→5→1→2→3 とランダムです。goroutine はスケジューラに任せて並行実行されるため、順番は保証されません
- 直列実行なら 5 × 10ms = 50ms のはずが、実測では 10.568583ms——約 4.7 倍速くなっています
これが goroutine の基本的な威力です。正直、このシンプルさが Go の魅力だと思います。

channel でデータを渡す — producer/consumer パターン
goroutine 間でデータを安全に受け渡す仕組みが channel です。Go の標準的なパターンとして「producer が channel に値を送り、consumer が受け取る」構造があります。
for i := 0; i < count; i++ {
ch <- i // channel に送信
time.Sleep(2 * time.Millisecond)
}
close(ch) // 送信完了 → channel を閉じる
}
func consumer(id int, ch <-chan int, results chan<- string, done chan<- struct{}) {
for v := range ch { // channel が閉じるまでループ
results <- fmt.Sprintf(“consumer-%d got %d”, id, v)
}
done <- struct{}{} // 完了通知
}

実測結果のポイント:
- 1 つの producer が 10 アイテムを送信し、3 つの consumer が分担して処理
- この実行では consumer-2 が 4 個(0,3,6,9)、consumer-1 と consumer-3 が 3 個ずつを担当(割り当ては実行ごとに変化)
close(ch)するとfor v := range chのループが全 consumer で自動終了する
channel の方向型(双方向・送信専用・受信専用)
chan int(双方向)、chan<- int(送信専用)、<-chan int(受信専用)の 3 種類があります。関数の引数で方向を指定することで、誤った使い方をコンパイル時に弾けます。
Worker Pool パターン — タスクを並行分散処理する
「goroutine を増やせば速くなる」と思いがちですが、実測してみると必ずしもそうではありません。実際に素数カウント(50,000 以下の素数を探す)でベンチマークを取りました。
jobs := make(chan int, 1000)
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := range jobs {
if isPrime(n) {
mu.Lock()
count++
mu.Unlock()
}
}
}()
}
// … jobs を送信して close(jobs), wg.Wait()
}
$ go run main.go
GOMAXPROCS=5 NumCPU=5
Task: count primes up to 200000 (median of 5 runs)
workers | primes | median elapsed
——–|——–|—————-
1 | 17984 | 28.6ms
2 | 17984 | 34.6ms
4 | 17984 | 30.8ms
8 | 17984 | 49.4ms

この結果は予想を裏切ります。workers=1(直列)の 28.6ms が最速で、goroutine を増やすほど遅くなり、workers=8 は 49.4ms と約 1.7 倍も遅いのです。理由は sync.Mutex の競合です。素数チェックはヒットするたびに count++ を 1 つずつ更新するため、goroutine が増えると mutex の取得待ち(ロック競合)が増え、並行化のメリットを食いつぶして逆効果になります。50,000 件ではノイズに埋もれがちなので、200,000 件・5 回中央値で計測したところ、この傾向がはっきり再現しました。
ポイントは 2 つです:
- 細粒度タスク(1 件ずつ共有変数を mutex 更新するような処理)では、goroutine を増やすほど速くなるどころか遅くなることがある
- ボトルネックがどこにあるか(CPU / メモリ / I/O / mutex)を測定してから並行化する。「とりあえず goroutine を増やす」は危険
mutex を使わない設計が望ましい場合も
上記の例では sync.Mutex で count を保護していますが、channel を使ってカウント結果を送信し、受信側で集計する設計にすれば mutex を排除できます。Channel で通信し、共有メモリを避けるのが Go らしいスタイルです。
select で複数チャネルを扱う
select は複数の channel の送受信を同時に待つ構文です。switch に似ていますが、channel 専用です。典型的な使い方がタイムアウト処理です。
timeout := 150 * time.Millisecond
go slowQuery(results, 80*time.Millisecond, “DBクエリ結果(高速)”)
go slowQuery(results, 200*time.Millisecond, “キャッシュ結果(遅すぎ)”)
for received := 0; received < 2; {
select {
case r := <-results:
fmt.Printf(“[受信] %s\n”, r)
received++
case <-time.After(timeout):
fmt.Println(“[タイムアウト]”)
received = 2 // ループを抜ける
}
}

実測結果を見てください。timeout=150ms を設定したのに、遅い方(200ms)の goroutine も 201ms で受信されています(実測ログ:[受信 80ms] → [受信 201ms])。タイムアウトは一度も発火しませんでした。
これは time.After の動作に起因します。select ループ内の time.After(timeout) はイテレーションごとに新しいタイマーを生成します。80ms で 1 件受信した後、次のイテレーションで time.After が再び 150ms タイマーを作成するため、実質的には 80 + 150 = 230ms まで待機します。201ms で到着した goroutine は 230ms より前に届くため、タイムアウトしません。
全体タイムアウトが必要なら context.WithTimeout を使う
time.After はループ内でリセットされます。「最初の受信から 150ms 以内」ではなく「処理全体を N 秒以内に」したい場合は context.WithTimeout または context.WithDeadline を使ってください。
goroutine リークを防ぐ — done channel パターン
goroutine を起動したまま終了できない状態を「goroutine リーク」と呼びます。メモリを食い続け、長時間稼働するサーバーでは致命的な問題になります。
最もシンプルな解決策が done channel パターンです。
for {
select {
case <-done:
fmt.Printf(“safeWorker %d: 正常終了\n”, id)
return // goroutine を終了
default:
time.Sleep(5 * time.Millisecond)
}
}
}
done := make(chan struct{})
for i := 1; i <= 3; i++ {
go safeWorker(i, done)
}
// … 処理が終わったら
close(done) // 全 goroutine に終了シグナルを送る

runtime.NumGoroutine() で goroutine 数を実測すると:
- 起動前:1(main goroutine のみ)
- 3 workers 起動後:4(3 workers + main)
close(done)後:1 に戻る——リークなし
close(done) は channel を受信待ち中の全 goroutine に同時に通知できます。これが done channel の強みです。
よくあるエラーと解決策
①デッドロック: all goroutines are asleep
fatal error: all goroutines are asleep – deadlock!
goroutine 1 [chan receive]:
main.main()
/app/main.go:15 +0x5c
原因と対処:
- channel にデータを送信する goroutine がいないのに
<-chで受信を待っている wg.Add(1)したがwg.Done()を呼んでいない- バッファなし channel で送信側と受信側が同時に存在しない
解決策:defer wg.Done() を goroutine の先頭に必ず書く。channel はバッファあり(make(chan int, 10))にするか、goroutine を別途用意する。
②goroutine のクロージャで変数が共有される
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // i が共有される → 全部 5 になる
}()
}
// OK: 引数で値をコピーして渡す
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // コピーされた n → 0, 1, 2, 3, 4
}(i)
}
ループ変数をクロージャに直接キャプチャすると、goroutine 実行時には i がすでに 5 になっています。引数でコピーして渡す習慣をつけてください(Go 1.22 ではループ変数のセマンティクスが変わり、この問題が解消されています)。
③channel に送信後に close を忘れる
for v := range ch は channel が close されるまで永遠にブロックします。producer 側で close(ch) を忘れると consumer の goroutine がリークします。producer 側に defer close(ch) を書くのが定番です。
まとめ
goroutine と channel の実践パターンをまとめます。
goroutine/channel パターン早見表
go func()+sync.WaitGroup— 複数タスクを並行実行して全完了を待つ基本形- producer/consumer +
close(ch)— 1 対多のデータ分配。channel を閉じると全 consumer が自動終了 - Worker Pool — goroutine 数 = CPU コア数前後が目安。mutex 競合に注意、測定してチューニング
select + time.After— 単一タイムアウトには有効。全体タイムアウトにはcontextを使う- done channel — goroutine に終了を伝える最シンプルな方法。
close(done)で全 goroutine に一斉通知 - goroutine リーク防止 — done channel または context を必ず設計に含める
goroutine と channel を使いこなすことで、Go での並行プログラミングが大きく変わります。次のステップとして、context.WithTimeout や sync.Pool、errgroup なども学ぶと実践的なコードが書けるようになります。
実際に Go アプリを VPS で本番運用する場合は、Vultr や DigitalOcean の東京リージョンが低遅延でおすすめです。



コメント