Go goroutine/channel on Ubuntu — 並行処理パターンの完全実践

開発環境

動作確認済み環境

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)

目次

  1. Go と goroutine の基礎知識
  2. Ubuntu 24.04 に Go 環境を準備する
  3. goroutine の基本 — sync.WaitGroup で並行実行
  4. channel でデータを渡す — producer/consumer パターン
  5. Worker Pool パターン — タスクを並行分散処理する
  6. select で複数チャネルを扱う
  7. goroutine リークを防ぐ — done channel パターン
  8. よくあるエラーと解決策
  9. まとめ

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 が入ります。

Ubuntu 22.04 vs 24.04 の Go バージョン比較(apt-cache policy 実取得)
Ubuntu 22.04 vs 24.04 の Go バージョン比較(apt-cache policy 実取得)



ubuntu@vps: ~
$ sudo apt update && sudo apt install -y golang-go
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 の aptgolang-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 公式からバイナリを取得します。




ubuntu@vps: ~
$ wget https://go.dev/dl/go1.24.4.linux-amd64.tar.gz -O /tmp/go.tar.gz
$ 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 で全員の終了を待ちます。




ubuntu@linuxlab: ~/goproject
// main.go
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))
}

これを実際に実行した結果がこちらです。

goroutine 基本動作(sync.WaitGroup 実測)
goroutine 基本動作(sync.WaitGroup 実測)

注目してほしいのは 2 点です。

  • Worker の開始順序が 5→1→2→3→4、完了順序が 4→5→1→2→3 とランダムです。goroutine はスケジューラに任せて並行実行されるため、順番は保証されません
  • 直列実行なら 5 × 10ms = 50ms のはずが、実測では 10.568583ms——約 4.7 倍速くなっています

これが goroutine の基本的な威力です。正直、このシンプルさが Go の魅力だと思います。

著者アイコン
著者アイコン

defer wg.Done() を書き忘れると wg.Wait() が永遠に待ち続けてデッドロックになります。wg.Add(1)wg.Done() はセットで覚えておきましょう。

Playwright 実撮影:goroutine 並行動作のターミナル出力
Playwright 実撮影:goroutine 並行動作のターミナル出力

channel でデータを渡す — producer/consumer パターン

goroutine 間でデータを安全に受け渡す仕組みが channel です。Go の標準的なパターンとして「producer が channel に値を送り、consumer が受け取る」構造があります。




ubuntu@linuxlab: ~/goproject
func producer(ch chan<- int, count int) {
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{}{} // 完了通知
}
channel producer/consumer 実測出力
channel producer/consumer 実測出力

実測結果のポイント:

  • 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 以下の素数を探す)でベンチマークを取りました。




ubuntu@linuxlab: ~/goproject — Worker Pool ベンチマーク
func countPrimes(numWorkers, upTo int) (int, time.Duration) {
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
Worker Pool 処理時間実測バー(goroutine 数 vs elapsed)
Worker Pool 処理時間実測バー(goroutine 数 vs elapsed)

この結果は予想を裏切ります。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.Mutexcount を保護していますが、channel を使ってカウント結果を送信し、受信側で集計する設計にすれば mutex を排除できます。Channel で通信し、共有メモリを避けるのが Go らしいスタイルです。

select で複数チャネルを扱う

select は複数の channel の送受信を同時に待つ構文です。switch に似ていますが、channel 専用です。典型的な使い方がタイムアウト処理です。




ubuntu@linuxlab: ~/goproject — select + time.After
results := make(chan string, 2)
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 // ループを抜ける
}
}
select + time.After タイムアウト実測出力
select + time.After タイムアウト実測出力

実測結果を見てください。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 パターンです。




ubuntu@linuxlab: ~/goproject — done channel パターン
func safeWorker(id int, done <-chan struct{}) {
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 に終了シグナルを送る
done channel goroutine 管理実測(NumGoroutine 変化)
done channel goroutine 管理実測(NumGoroutine 変化)

runtime.NumGoroutine() で goroutine 数を実測すると:

  • 起動前:1(main goroutine のみ)
  • 3 workers 起動後:4(3 workers + main)
  • close(done) 後:1 に戻る——リークなし

close(done) は channel を受信待ち中の全 goroutine に同時に通知できます。これが done channel の強みです。

著者アイコン
著者アイコン

実際のプロダクションコードでは context.WithCancelcontext.WithTimeout を使うことが多いです。done channel はその仕組みを自前で実装したもので、context を理解するための良いステップになります。

よくあるエラーと解決策

①デッドロック: all goroutines are asleep




ubuntu@linuxlab: ~/goproject
$ go run main.go
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 のクロージャで変数が共有される




ubuntu@linuxlab: ~/goproject
// NG: ループ変数を 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.WithTimeoutsync.Poolerrgroup なども学ぶと実践的なコードが書けるようになります。

実際に Go アプリを VPS で本番運用する場合は、Vultr や DigitalOcean の東京リージョンが低遅延でおすすめです。

コメント

タイトルとURLをコピーしました