- Go言語
Go goroutine・channelの使い方|並行処理パターン8選と実行結果
goroutineとは、Goのランタイムが管理する軽量な実行単位で、channelとは、goroutine同士が値を安全に受け渡すための通り道です。 関数呼び出しの前に go を付けるだけで並行に動き始め、ch <- v と <-ch で「渡す・受け取る」を同期しながら行えます。スレッドと違って1つあたり約2KBのスタックから始まるので、1万個起動しても数十MBで収まります。
この記事では、バッファなし/あり・close・range・方向付き channel の基本を実行結果で確かめたあと、sync.WaitGroup(Go 1.25 で追加された wg.Go)・select+タイムアウト・context のキャンセル伝播・ワーカープール・fan-out/fan-in・パイプライン・セマフォ・結果とエラーの収集の8パターンを、コピペできる完全コードと出力つきで解説します。さらに、デッドロック(fatal error: all goroutines are asleep - deadlock!)と goroutine リークを実際に再現し、go run -race の出力、Go 1.27 で正式機能になった goroutineleak プロファイル、sync.Mutex/atomic/channel の使い分け表、ループ変数の共有(1.22 で変更)など6つのつまずきの実際のエラー文まで載せました。実行環境はすべて Go 1.27.1(linux/amd64)、出力のパスを短く表示するため go run には -trimpath を付けています。
goroutine・channel の使い方の早見表
| やりたいこと | 書き方 | 補足 |
|---|---|---|
| 並行に実行する | go f(x) / go func() { ... }() |
戻り値は受け取れない。結果は channel か共有変数で |
| 全部終わるまで待つ | var wg sync.WaitGroup; wg.Go(func() { ... }); wg.Wait() |
wg.Go は 1.25〜。1.24 以前は wg.Add(1) + defer wg.Done() |
| channel を作る | ch := make(chan int) / make(chan int, 10) |
第2引数がバッファ長。省略は 0(バッファなし) |
| 送る/受け取る | ch <- v / v := <-ch / v, ok := <-ch |
ok が false なら close 済みで空 |
| 送信を終える | close(ch) |
送信側だけが close する。受信側は close しない |
| 届く限り受け取る | for v := range ch { ... } |
close されると抜ける。close 忘れは永遠に待つ |
| 複数の channel を待つ | select { case v := <-a: ... case b <- x: ... } |
複数が準備できていれば無作為に1つ |
| 待たずに試す | select { case v := <-ch: ... default: ... } |
default があるとブロックしない |
| タイムアウト | case <-time.After(d): / case <-ctx.Done(): |
呼び出し先にも期限を渡すなら context |
| キャンセルを伝える | ctx, cancel := context.WithCancel(parent) → cancel() |
同じ ctx を渡した goroutine 全部に届く |
| 引数の向きを限定する | func f(in <-chan T, out chan<- T) |
受信専用/送信専用。逆の操作はコンパイルエラー |
| 同時実行数を制限する | sem := make(chan struct{}, n) で取得・解放 |
パターン7。x/sync/semaphore も可 |
| 共有変数を守る | sync.Mutex / atomic.Int64 |
「所有者を1つに決めて channel で渡す」も選択肢 |
| 競合を検出する | go run -race . / go test -race ./... |
実行時に検出。CI に入れる |
| 残った goroutine を数える | runtime.NumGoroutine() |
テストの前後で比較するとリークが見える |
| リーク箇所を特定する | pprof.Lookup("goroutineleak") |
Go 1.27〜正式機能。/debug/pprof/goroutineleak も |
channel の状態と操作の組み合わせは、次の9マスを覚えておくと迷いません。 すべて本記事で実際に確認した動作です。
| 操作 | nil channel(var ch chan T) |
開いている channel | close 済み channel |
|---|---|---|---|
送信 ch <- v |
永遠にブロック | 受信側(またはバッファの空き)を待つ | panic: send on closed channel |
受信 <-ch |
永遠にブロック | 値が来るまで待つ | 残りを返し、空ならゼロ値と ok=false |
close(ch) |
panic: close of nil channel |
成功 | panic: close of closed channel |
8パターンは「やりたいこと」から逆引きできます。
| やりたいこと | パターン | 使う道具 |
|---|---|---|
| N 個の処理を並行に走らせて全部待つ | 1. WaitGroup | sync.WaitGroup(wg.Go) |
| 一定時間で諦める | 2. select+タイムアウト | select、time.After、context.WithTimeout |
| 途中でまとめて止める | 3. context キャンセル | context.WithCancel、ctx.Done() |
| 同時実行数を固定して大量のジョブをさばく | 4. ワーカープール | jobs / results channel、WaitGroup |
| 1本の入力を分担し、結果を1本に戻す | 5. fan-out / fan-in | 複数 goroutine + merge |
| 処理を段階に分けて流す | 6. パイプライン | 各段が <-chan T を返す |
| 同時にアクセスする数だけ制限する | 7. セマフォ | バッファ付き channel |
| 結果を集め、最初のエラーで残りを止める | 8. エラー収集 | WaitGroup+sync.Once+context(errgroup 相当) |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Go goroutine・channelの使い方|並行処理パターン8選と実行結果」
goroutine と channel の基本を実行結果で確認する
1. go で起動すると main は待たない → WaitGroup で待つ
go は「起動して先に進む」だけで、終了を待ちません。 main が終わるとプログラム全体が終わるので、次のコードでは goroutine の出力が出ないことがほとんどです。
package main
import "fmt"
func hello(n int) {
fmt.Println("hello from goroutine", n)
}
func main() {
for i := 1; i <= 3; i++ {
go hello(i) // 新しい goroutine で hello を実行
}
fmt.Println("main: 起動した")
// main が終わるとプログラム全体が終了する(goroutine の完了は待たない)
}
$ go run -trimpath .
main: 起動した
→ 待つには sync.WaitGroup を使います。 Go 1.25 で追加された wg.Go(f) は、内部で Add(1) と defer Done() を行い、新しい goroutine で f を実行します(公式ドキュメント:「Go calls f in a new goroutine and adds that task to the WaitGroup. When f returns, the task is removed from the WaitGroup.」)。
package main
import (
"fmt"
"sync"
)
func hello(n int) {
fmt.Println("hello from goroutine", n)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Go(func() { hello(i) }) // Go 1.25〜。Add(1)・Done() を自動で行う
}
fmt.Println("main: 起動した")
wg.Wait() // 3つの goroutine が終わるまで待つ
fmt.Println("main: 全部終わった")
}
$ go run -trimpath .
main: 起動した
hello from goroutine 3
hello from goroutine 1
hello from goroutine 2
main: 全部終わった
出力の順番は実行ごとに変わります。 順番が必要なら、結果を添字つきのスライスに書く(パターン1)か、channel で受け取ってから並べ替えます。
2. バッファなし channel:送信と受信が「同時」に成立する
make(chan T) で作った channel は、受信側が受け取るまで送信側が待ちます。 時刻を付けて観察すると、送信側が 300ms 待たされているのがわかります。
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
ms := func() int64 { return time.Since(start).Milliseconds() }
ch := make(chan string) // バッファなし:受信側が受け取るまで送信側は待つ
go func() {
fmt.Printf("[%3dms] 送信側: 送ります\n", ms())
ch <- "order-1"
fmt.Printf("[%3dms] 送信側: 受け取ってもらえた(ここまで待たされる)\n", ms())
}()
time.Sleep(300 * time.Millisecond) // 受信側はしばらく受け取らない
fmt.Printf("[%3dms] 受信側: これから受け取ります\n", ms())
v := <-ch
fmt.Printf("[%3dms] 受信側: %s を受け取った\n", ms(), v)
time.Sleep(50 * time.Millisecond) // 送信側の最後の出力を待つ
}
$ go run -trimpath .
[ 0ms] 送信側: 送ります
[300ms] 受信側: これから受け取ります
[300ms] 受信側: order-1 を受け取った
[300ms] 送信側: 受け取ってもらえた(ここまで待たされる)
この「待ち合わせ」が同期の役目を果たします。 受信が完了した時点で「相手が確かに受け取った」ことが保証されるので、合図(シグナル)としても使えます。
3. バッファあり channel:空きがある間は送信が待たない
package main
import "fmt"
func main() {
ch := make(chan string, 2) // バッファあり:2件までは受信側がいなくても送れる
ch <- "order-1"
ch <- "order-2"
fmt.Println("送信後 len =", len(ch), "cap =", cap(ch))
// ch <- "order-3" // ← 3件目は空きができるまでブロックする(受信側がいなければデッドロック)
fmt.Println(<-ch)
fmt.Println("1件受信後 len =", len(ch))
fmt.Println(<-ch)
fmt.Println("2件受信後 len =", len(ch))
}
$ go run -trimpath .
送信後 len = 2 cap = 2
order-1
1件受信後 len = 1
order-2
2件受信後 len = 0
バッファは「一時的な受け皿」であり、処理を速くする魔法ではありません。 受信側が追いつかなければいずれ満杯になって送信側が待ちます。「起動する goroutine 数ぶんのバッファを持たせて、受信側が去っても送信側が詰まらないようにする」(後述のリーク対策)のように、目的を持って長さを決めます。
4. close と range、ok による終了判定
close は「もう送らない」という合図で、受信側の range を終わらせます。 close 済みの channel から受信すると、残りの値を返したあと「ゼロ値, false」を返します。
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
close(ch) // 「もう送らない」を受信側に伝える
// 1) range は close されるまで受信し続け、close で抜ける
for v := range ch {
fmt.Println("range で受信:", v)
}
// 2) close 済みで空の channel からの受信は「ゼロ値, false」
v, ok := <-ch
fmt.Printf("close 後の受信: v=%d ok=%v\n", v, ok)
// 3) 値を送らず close だけで合図する(done channel)
done := make(chan struct{})
go func() {
fmt.Println("worker: 処理完了")
close(done) // close されると、待っている全員の受信が即座に返る
}()
<-done
fmt.Println("main: 合図を受け取った")
}
$ go run -trimpath .
range で受信: 10
range で受信: 20
close 後の受信: v=0 ok=false
worker: 処理完了
main: 合図を受け取った
chan struct{} を close だけで使う「done channel」は、複数の受信者に一斉に知らせられるのが利点です。 値を送る方式だと受信者の数だけ送る必要がありますが、close は1回で全員に届きます。
5. 方向付き channel:引数の型で「読むだけ・書くだけ」を約束する
chan<- T は送信専用、<-chan T は受信専用です。 双方向の channel は方向付きの引数に自動で変換されるので、関数の役割が型で明確になり、逆の操作はコンパイル時に止まります。
package main
import "fmt"
// 送信専用(chan<- T):この関数は ch に書くことしかできない
func produce(ch chan<- int) {
for i := 1; i <= 3; i++ {
ch <- i * 100
}
close(ch) // 送信側だけが close できる
}
// 受信専用(<-chan T):この関数は ch を読むことしかできない
func consume(ch <-chan int) int {
sum := 0
for v := range ch {
sum += v
}
return sum
}
func main() {
ch := make(chan int) // 双方向 channel は、方向付きの引数へ自動で変換される
go produce(ch)
fmt.Println("合計 =", consume(ch))
}
$ go run -trimpath .
合計 = 600
受信専用の channel に送信・close しようとすると、実行前に次のエラーになります。
./main.go:4:2: invalid operation: cannot send to receive-only channel <-chan int ch (variable of type <-chan int)
./main.go:5:8: invalid operation: cannot close receive-only channel ch (variable of type <-chan int)
6. select と default:待たずに試す
select は複数の channel 操作のうち準備できたものを1つ選びます。default を付けると、どれも準備できていないときに待たずに抜けます。
package main
import "fmt"
func main() {
ch := make(chan string, 1)
select { // ノンブロッキング受信:値がなければ default へ
case v := <-ch:
fmt.Println("受信:", v)
default:
fmt.Println("まだ何も届いていない")
}
ch <- "msg-1"
select { // ノンブロッキング送信:バッファに空きがなければ default へ
case ch <- "msg-2":
fmt.Println("msg-2 を送信")
default:
fmt.Println("空きがないので msg-2 は送らない")
}
fmt.Println("受信:", <-ch)
}
$ go run -trimpath .
まだ何も届いていない
空きがないので msg-2 は送らない
受信: msg-1
goroutine の軽さも確認しておきます。 合図を待つだけの goroutine を1万個起動して、スタック使用量の増分を runtime.MemStats で測ると、この環境では 19.5MB(1個あたり約2.0KB)、起動は約36ms でした(runtime.NumGoroutine() は 10001)。OS スレッドを1万本立てるのとは桁が違うので、「接続ごと・リクエストごとに goroutine」という設計が現実的になります。
並行処理パターン8選(コピペできる完全コードと出力)
パターン1:sync.WaitGroup で全部終わるまで待つ
「N 個の処理を並行に走らせ、全部終わったら次へ」の基本形です。 結果は添字ごとに別の要素へ書くので、Mutex なしでも競合しません。
package main
import (
"fmt"
"sync"
"time"
)
// 架空の処理:ID ごとに少し時間のかかる集計をする
func summarize(id int) string {
time.Sleep(time.Duration(4-id) * 50 * time.Millisecond) // id が小さいほど遅い
return fmt.Sprintf("item-%d: 集計済み", id)
}
func main() {
start := time.Now()
ids := []int{1, 2, 3}
results := make([]string, len(ids)) // 添字ごとに別の要素へ書くので競合しない
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() { // Go 1.25〜:Add(1) と defer Done() を内部で行う
results[i] = summarize(id)
})
}
wg.Wait() // すべて終わるまでブロック
for _, r := range results {
fmt.Println(r)
}
fmt.Printf("合計 %dms(直列なら 300ms)\n", time.Since(start).Milliseconds())
}
$ go run -trimpath .
item-1: 集計済み
item-2: 集計済み
item-3: 集計済み
合計 150ms(直列なら 300ms)
Go 1.24 以前では次のように書きます(1.25 以降でも動きます)。Add は goroutine を起動する前に呼ぶのが要点です(公式ドキュメント:「Callers should prefer WaitGroup.Go. Note that calls with a positive delta that occur when the counter is zero must happen before a Wait.」)。
for i, id := range ids {
wg.Add(1) // 起動前にカウンタを増やす
go func() {
defer wg.Done() // 終わったら減らす
results[i] = summarize(id)
}()
}
wg.Wait()
パターン2:select でタイムアウトを付ける(time.After / context.WithTimeout)
「一定時間で諦める」には、結果の channel とタイマーを select で同時に待ちます。 その場で区切るだけなら time.After、呼び出し先の関数にも期限を渡したいなら context.WithTimeout を使います。
package main
import (
"context"
"errors"
"fmt"
"time"
)
// 架空の処理:指定時間かかる問い合わせ
func fetch(d time.Duration) <-chan string {
ch := make(chan string, 1) // バッファ 1:受信側が去っても送信側がブロックしない
go func() {
time.Sleep(d)
ch <- fmt.Sprintf("結果(%dms かかった)", d.Milliseconds())
}()
return ch
}
// 方法1:time.After で待ち時間を区切る
func withTimeAfter(d time.Duration) string {
select {
case v := <-fetch(d):
return v
case <-time.After(100 * time.Millisecond):
return "タイムアウト(time.After)"
}
}
// 方法2:context.WithTimeout(呼び出し先にも期限を渡せる)
func withContext(d time.Duration) string {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() // 使い終わったら必ず cancel(リソース解放)
select {
case v := <-fetch(d):
return v
case <-ctx.Done():
err := ctx.Err()
if errors.Is(err, context.DeadlineExceeded) {
return "タイムアウト(context): " + err.Error()
}
return "キャンセル: " + err.Error()
}
}
func main() {
fmt.Println(withTimeAfter(30 * time.Millisecond))
fmt.Println(withTimeAfter(300 * time.Millisecond))
fmt.Println(withContext(30 * time.Millisecond))
fmt.Println(withContext(300 * time.Millisecond))
}
$ go run -trimpath .
結果(30ms かかった)
タイムアウト(time.After)
結果(30ms かかった)
タイムアウト(context): context deadline exceeded
fetch の channel にバッファ1を持たせているのがポイントです。 タイムアウトで受信側が去ったあとも送信側は書き込んで終了でき、goroutine が残りません。ctx.Err() は期限切れなら context.DeadlineExceeded、cancel() で止めたなら context.Canceled を返します。
パターン3:context でキャンセルを伝播する
同じ ctx を渡すだけで、親を止めれば子も孫も止まります。 各 goroutine は select で ctx.Done() を監視し、届いたら片付けて抜けます。
package main
import (
"context"
"fmt"
"runtime"
"sync"
"time"
)
// 親 worker:子 worker を2つ起動し、ctx が終わるまで働く
func parent(ctx context.Context, wg *sync.WaitGroup) {
for i := 1; i <= 2; i++ {
wg.Go(func() { child(ctx, i) }) // 同じ ctx を渡すだけで伝播する
}
<-ctx.Done()
fmt.Println("parent: 終了 (", ctx.Err(), ")")
}
func child(ctx context.Context, id int) {
ticker := time.NewTicker(40 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done(): // キャンセルされたら抜ける
fmt.Printf("child-%d: 終了 ( %v )\n", id, ctx.Err())
return
case <-ticker.C:
fmt.Printf("child-%d: 作業中\n", id)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Go(func() { parent(ctx, &wg) })
time.Sleep(100 * time.Millisecond)
fmt.Println("main: goroutine 数 =", runtime.NumGoroutine())
fmt.Println("main: cancel() を呼ぶ")
cancel() // 親を止めると、同じ ctx を持つ子もすべて止まる
wg.Wait()
fmt.Println("main: goroutine 数 =", runtime.NumGoroutine())
}
$ go run -trimpath .
child-1: 作業中
child-2: 作業中
child-2: 作業中
child-1: 作業中
main: goroutine 数 = 4
main: cancel() を呼ぶ
parent: 終了 ( context canceled )
child-1: 終了 ( context canceled )
child-2: 終了 ( context canceled )
main: goroutine 数 = 1
context は関数の第1引数で明示的に渡します。 公式ドキュメントは「Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.」と定めており、同じ Context は複数の goroutine から同時に使っても安全です。理由つきで止めたいときは context.WithCancelCause(1.20〜)と context.Cause(ctx)、キャンセルを打ち切って後片付けだけ続けたいときは context.WithoutCancel(1.21〜)が使えます。
パターン4:ワーカープールで処理数を一定に保つ
大量のジョブを固定数の worker でさばく形です。 worker は jobs を range で取り続け、jobs が close されると終了します。results は「全 worker が終わったら close」する goroutine を1つ置くのが定石です。
package main
import (
"fmt"
"sort"
"sync"
"time"
)
type job struct {
ID int
Input string
}
type result struct {
JobID int
Worker int
Output string
}
func worker(id int, jobs <-chan job, results chan<- result, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs { // jobs が close されるまで取り続ける
time.Sleep(30 * time.Millisecond) // 架空の処理時間
results <- result{JobID: j.ID, Worker: id, Output: "done:" + j.Input}
}
}
func main() {
const numWorkers = 3
jobs := make(chan job)
results := make(chan result)
// 1) worker を起動
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// 2) ジョブを投入し、投入し終えたら close
go func() {
for i := 1; i <= 9; i++ {
jobs <- job{ID: i, Input: fmt.Sprintf("task-%d", i)}
}
close(jobs)
}()
// 3) 全 worker が終わったら results を close(受信側の range を抜けさせる)
go func() {
wg.Wait()
close(results)
}()
// 4) 結果を集める
var all []result
for r := range results {
all = append(all, r)
}
sort.Slice(all, func(i, j int) bool { return all[i].JobID < all[j].JobID })
for _, r := range all {
fmt.Printf("job %d -> worker %d: %s\n", r.JobID, r.Worker, r.Output)
}
}
$ go run -trimpath .
job 1 -> worker 1: done:task-1
job 2 -> worker 2: done:task-2
job 3 -> worker 3: done:task-3
job 4 -> worker 2: done:task-4
job 5 -> worker 3: done:task-5
job 6 -> worker 1: done:task-6
job 7 -> worker 2: done:task-7
job 8 -> worker 1: done:task-8
job 9 -> worker 3: done:task-9
「結果を集める側」を main に置き、results の close を別 goroutine に任せる形にすると、デッドロックしません。 main で wg.Wait() してから results を読む形にすると、worker が results <- r で main の受信を待ち、main は Wait で worker を待つ、という相互待ちになります(後述のデッドロック例そのものです)。
パターン5:fan-out / fan-in で分担してまとめる
fan-out は1本の入力 channel を複数の goroutine で読んで分担すること、fan-in は複数の出力 channel を1本にまとめることです。 merge の中で「全入力が close されたら出力も close」を WaitGroup で実現します。
package main
import (
"fmt"
"sync"
"time"
)
// 入力を channel に流す(ジェネレータ)
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// fan-out:同じ入力 channel を複数の goroutine で処理する
func square(id int, in <-chan int) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for n := range in {
time.Sleep(20 * time.Millisecond) // 架空の処理時間
out <- fmt.Sprintf("worker-%d: %d^2=%d", id, n, n*n)
}
}()
return out
}
// fan-in:複数の channel を1本にまとめる
func merge(ins ...<-chan string) <-chan string {
out := make(chan string)
var wg sync.WaitGroup
for _, in := range ins {
wg.Go(func() {
for v := range in {
out <- v
}
})
}
go func() {
wg.Wait() // 全入力が close されたら出力も close
close(out)
}()
return out
}
func main() {
start := time.Now()
in := generate(1, 2, 3, 4, 5, 6)
// fan-out:3つの worker が同じ in を分担して読む
w1, w2, w3 := square(1, in), square(2, in), square(3, in)
// fan-in:3本の出力を1本にまとめて受信
count := 0
for v := range merge(w1, w2, w3) {
fmt.Println(v)
count++
}
fmt.Printf("%d 件処理、%dms(直列なら 120ms)\n", count, time.Since(start).Milliseconds())
}
$ go run -trimpath .
worker-1: 1^2=1
worker-3: 3^2=9
worker-2: 2^2=4
worker-2: 6^2=36
worker-3: 5^2=25
worker-1: 4^2=16
6 件処理、40ms(直列なら 120ms)
パターン6:パイプラインで段階処理をつなぐ
各ステージを「受信専用 channel を受け取り、受信専用 channel を返す関数」にそろえると、stage(stage(source(...))) のように関数合成でつなげます。 途中で受信をやめるときは cancel() で上流を止め、goroutine が残らないようにします。
package main
import (
"context"
"fmt"
"runtime"
"strings"
"time"
)
// ステージ1:入力を流す
func source(ctx context.Context, words []string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for _, w := range words {
select {
case out <- w:
case <-ctx.Done(): // 下流が止まったら送信をやめる
return
}
}
}()
return out
}
// 汎用ステージ:「受信専用を受け取り、受信専用を返す」形にそろえる
func stage(ctx context.Context, in <-chan string, f func(string) string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for w := range in {
select {
case out <- f(w):
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
words := []string{" pen ", "note", " ruler", "eraser ", "stapler"}
normalized := stage(ctx, source(ctx, words), func(s string) string { return strings.ToUpper(strings.TrimSpace(s)) })
labeled := stage(ctx, normalized, func(s string) string { return "item:" + s })
for v := range labeled {
fmt.Println(v)
if v == "item:RULER" {
fmt.Println("(3件で打ち切り)")
break
}
}
fmt.Println("打ち切り直後の goroutine 数 =", runtime.NumGoroutine())
cancel() // 途中で抜けたら cancel で上流の3ステージを止める
time.Sleep(10 * time.Millisecond)
fmt.Println("cancel 後の goroutine 数 =", runtime.NumGoroutine())
}
$ go run -trimpath .
item:PEN
item:NOTE
item:RULER
(3件で打ち切り)
打ち切り直後の goroutine 数 = 4
cancel 後の goroutine 数 = 1
打ち切り直後は3ステージの goroutine が送信待ちで残っており(4 = main+3)、cancel() で1に戻っています。 各ステージの送信を select にして ctx.Done() を併記しているからこそ止められます。
パターン7:バッファ付き channel をセマフォにする
「同時に n 個まで」を守るには、長さ n のバッファ付き channel に「入るときに送信、出るときに受信」します。 n 個埋まると n+1 個目の送信が待つので、それがそのまま待機になります。同時実行数を atomic で計測して、上限が守られていることを確かめます。
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
func main() {
const limit = 3 // 同時に動かす上限
sem := make(chan struct{}, limit) // バッファ付き channel をセマフォとして使う
var running, peak atomic.Int32 // 現在の同時実行数と、その最大値(計測用)
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Go(func() {
sem <- struct{}{} // 空きができるまで待つ(取得)
defer func() { <-sem }() // 終わったら返す(解放)
n := running.Add(1)
defer running.Add(-1)
for p := peak.Load(); n > p && !peak.CompareAndSwap(p, n); p = peak.Load() {
}
fmt.Printf("task-%2d 開始(同時実行 %d)\n", i, n)
time.Sleep(50 * time.Millisecond) // 架空の処理
})
}
wg.Wait()
fmt.Println("同時実行の最大 =", peak.Load(), "(上限", limit, ")")
}
$ go run -trimpath .
task-10 開始(同時実行 1)
task- 1 開始(同時実行 2)
task- 2 開始(同時実行 3)
task- 4 開始(同時実行 3)
task- 5 開始(同時実行 3)
task- 3 開始(同時実行 3)
task- 6 開始(同時実行 3)
task- 7 開始(同時実行 3)
task- 8 開始(同時実行 3)
task- 9 開始(同時実行 3)
同時実行の最大 = 3 (上限 3 )
ワーカープール(パターン4)との違いは、goroutine をタスクの数だけ起動する点です。 タスクが数千程度なら書き方が簡単なセマフォ、常時流れ続けるジョブを固定数の worker で処理し続けるならワーカープールが向いています。
パターン8:結果とエラーを集め、最初のエラーで残りを止める(errgroup 相当)
「全タスクの結果を集めたい。ただし1つがエラーになったら残りは中断したい」を、標準ライブラリだけで組みます。 最初のエラーの記録は sync.Once、中断の伝達は context、完了待ちは WaitGroup、という3つの組み合わせです。
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// 架空の処理:id=3 だけエラーを返す
func process(ctx context.Context, id int) (string, error) {
select {
case <-time.After(time.Duration(id) * 40 * time.Millisecond):
case <-ctx.Done():
return "", ctx.Err() // 他のタスクのエラーで中断された
}
if id == 3 {
return "", fmt.Errorf("task-%d: 入力が空です", id)
}
return fmt.Sprintf("task-%d: OK", id), nil
}
// group は errgroup 相当:最初のエラーを保持し、他のタスクをキャンセルする
type group struct {
wg sync.WaitGroup
cancel context.CancelFunc
errOnce sync.Once
err error
}
func newGroup(parent context.Context) (*group, context.Context) {
ctx, cancel := context.WithCancel(parent)
return &group{cancel: cancel}, ctx
}
func (g *group) Go(f func() error) {
g.wg.Go(func() {
if err := f(); err != nil {
g.errOnce.Do(func() { // 最初のエラーだけ記録
g.err = err
g.cancel() // 残りのタスクに中断を伝える
})
}
})
}
func (g *group) Wait() error {
g.wg.Wait()
g.cancel() // 正常終了でも ctx を解放する
return g.err
}
func main() {
g, ctx := newGroup(context.Background())
results := make([]string, 6)
for i := range 6 {
id := i + 1
g.Go(func() error {
r, err := process(ctx, id)
if err != nil {
return err
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("最初のエラー:", err)
}
for i, r := range results {
if r == "" {
r = "(未完了)"
}
fmt.Printf("results[%d] = %s\n", i, r)
}
fmt.Println("ctx.Err() =", ctx.Err(), "/ Canceled?", errors.Is(ctx.Err(), context.Canceled))
}
$ go run -trimpath .
最初のエラー: task-3: 入力が空です
results[0] = task-1: OK
results[1] = task-2: OK
results[2] = (未完了)
results[3] = (未完了)
results[4] = (未完了)
results[5] = (未完了)
ctx.Err() = context canceled / Canceled? true
同じことを golang.org/x/sync/errgroup(v0.23.0、2026年8月)で書くと次のようになります。 WithContext が返す ctx は「Go に渡した関数が最初に non-nil エラーを返したとき、または Wait が最初に返ったとき」にキャンセルされ、Wait は最初の non-nil エラーを返します。SetLimit(n) で同時実行数の上限も付けられます(パターン7のセマフォを内蔵した形です)。なお、この環境では外部モジュールを取得できないため、errgroup 版は公式ドキュメントに沿って記載しており、未実行です。
// go get golang.org/x/sync が必要(未実行)
import "golang.org/x/sync/errgroup"
func runAll(parent context.Context) ([]string, error) {
g, ctx := errgroup.WithContext(parent)
g.SetLimit(3) // 同時に動かすのは最大3つ
results := make([]string, 6)
for i := range 6 {
id := i + 1
g.Go(func() error {
r, err := process(ctx, id) // 上と同じ process
if err != nil {
return err
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil { // 最初の non-nil エラー
return nil, err
}
return results, nil
}
デッドロックと goroutine リークを再現する
デッドロック:fatal error: all goroutines are asleep - deadlock!
すべての goroutine が待ち状態になると、ランタイムがそれを検知してプログラムを止めます。 最小の例は「受信側のいないバッファなし channel への送信」です。
package main
import "fmt"
func main() {
ch := make(chan int) // バッファなし
ch <- 1 // 受信側がいないので、ここで永遠に待つ
fmt.Println(<-ch) // ここには到達しない
}
$ go run -trimpath .
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
example.com/d1_deadlock/main.go:7 +0x36
exit status 2
実務で多いのは「送信側は受信を待ち、main は送信側の終了を待つ」相互待ちです。スタックトレースの [sync.WaitGroup.Wait] と [chan send] を見ると、誰が何を待っているかがわかります。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make(chan int) // バッファなし
for i := 1; i <= 3; i++ {
wg.Go(func() {
results <- i * 10 // 受信側が来るまで待つ
})
}
wg.Wait() // 送信が終わるのを待つが、送信は受信を待っている → 互いに待つ
close(results)
for r := range results {
fmt.Println(r)
}
}
$ go run -trimpath .
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
...
main.main()
example.com/d1b_deadlock_wg/main.go:17 +0xdc
goroutine 19 [chan send]:
main.main.func1()
example.com/d1b_deadlock_wg/main.go:14 +0x2c
...
(goroutine 20・21 も同じ [chan send] で待機。一部の行は省略)
exit status 2
→ 直し方は3通りあります。 (a) results := make(chan int, 3) と起動数ぶんのバッファを持たせる、(b) wg.Wait(); close(results) を別の goroutine に移し、main は先に range results で受信する(パターン4の形)、(c) 結果をスライスに添字で書く(パターン1の形)。
この検知は「全員が眠った」ときだけ働きます。 HTTP サーバーのように他の goroutine が動き続けていると、待ち続ける goroutine は静かに残ります。それが次の goroutine リークです。
goroutine リーク:早期 return で送信側が取り残される
エラー時に受信をやめて return すると、まだ送信していない goroutine は誰も受信しない channel へ送ろうとして永遠に待ちます。 Go 1.26 のリリースノートに載っている典型例を、runtime.NumGoroutine() で観察します。
package main
import (
"errors"
"fmt"
"runtime"
"time"
)
type result struct {
id int
err error
}
// id=2 だけエラーを返す架空の処理
func work(id int) error {
time.Sleep(time.Duration(id) * 10 * time.Millisecond)
if id == 2 {
return errors.New("task-2 でエラー")
}
return nil
}
func runAll(n int) error {
ch := make(chan result) // バッファなし
for i := 1; i <= n; i++ {
go func() { ch <- result{i, work(i)} }()
}
for range n {
r := <-ch
if r.err != nil {
return r.err // 早期 return:残りの goroutine は誰も受信しない ch へ送ろうとして止まったまま
}
}
return nil
}
func main() {
fmt.Println("開始時の goroutine 数 =", runtime.NumGoroutine())
err := runAll(5)
fmt.Println("エラー:", err)
time.Sleep(200 * time.Millisecond) // 全タスクが終わるのに十分な時間を待つ
fmt.Println("終了後の goroutine 数 =", runtime.NumGoroutine(), "(1 に戻らない = リーク)")
}
$ go run -trimpath .
開始時の goroutine 数 = 1
エラー: task-2 でエラー
終了後の goroutine 数 = 4 (1 に戻らない = リーク)
→ ch := make(chan result, n) と起動数ぶんのバッファを持たせるだけで、受信側が去っても送信が完了して goroutine が終わります。 同じコードをこの1行だけ変えて実行すると、終了後の goroutine 数は 1 に戻りました。
$ go run -trimpath .
開始時の goroutine 数 = 1
エラー: task-2 でエラー
終了後の goroutine 数 = 1
テストでは runtime.NumGoroutine() を前後で比べるのが手軽な確認方法です。 「関数を呼ぶ前の数」と「呼んで少し待ったあとの数」が一致しなければ、どこかで goroutine が残っています。
Go 1.27 の goroutineleak プロファイルで「どこで」を突き止める
Go 1.27 では、リークした goroutine(もう起きられない待ち状態の goroutine)を報告する goroutineleak プロファイルが正式機能になりました。 GC の到達可能性を使い、「待っている channel や Mutex に、動いている goroutine からもう到達できない」ものを検出します。runtime/pprof から直接書き出せますし、net/http/pprof を組み込んだサーバーなら /debug/pprof/goroutineleak で取れます。
package main
import (
"fmt"
"os"
"runtime"
"runtime/pprof"
"time"
)
func leaky() {
ch := make(chan int)
go func() {
ch <- 1 // 誰も受信しないので永遠にブロック
}()
// ch は関数を抜けると main 側から到達不能になる
}
func main() {
leaky()
time.Sleep(50 * time.Millisecond)
runtime.GC() // リーク判定は GC の到達可能性を使う
fmt.Println("goroutine 数 =", runtime.NumGoroutine())
// Go 1.27 で正式機能になった goroutineleak プロファイル
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
}
$ go run -trimpath .
goroutine 数 = 2
goroutineleak profile: total 1
1 @ 0x47d82a 0x41421c 0x413e17 0x4deb5e 0x483561
# 0x4deb5d main.leaky.func1+0x1d example.com/d3_leakprofile/main.go:14
main.go:14、つまり ch <- 1 の行がリークの発生箇所だと出力されています。 NumGoroutine() が「残っている数」を教えるのに対し、このプロファイルは「どの行で止まっているか」まで示します。公式ドキュメントにあるとおり、グローバル変数などから到達できる channel で待っているケースは検出されないことがあるので、NumGoroutine() との併用が確実です。
データ競合:go run -race の出力
複数の goroutine が同じ変数を同期なしに読み書きすると、結果が実行ごとに変わります。 100 goroutine × 1000 回の count++ を3回実行した結果と、-race を付けたときの出力です。
package main
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for range 100 { // 100 goroutine × 1000 回
wg.Go(func() {
for range 1000 {
count++ // 複数の goroutine が同じ変数を同時に書き換える
}
})
}
wg.Wait()
fmt.Println("count =", count, "(期待値 100000)")
}
$ go run -trimpath .
count = 94825 (期待値 100000)
$ go run -trimpath .
count = 80407 (期待値 100000)
$ go run -trimpath .
count = 100000 (期待値 100000)
$ go run -race -trimpath .
==================
WARNING: DATA RACE
Read at 0x00c000018128 by goroutine 8:
main.main.func1()
example.com/race/main.go:14 +0x37
Previous write at 0x00c000018128 by goroutine 107:
main.main.func1()
example.com/race/main.go:14 +0x49
Goroutine 8 (running) created at:
sync.(*WaitGroup).Go()
sync/waitgroup.go:238 +0x72
Goroutine 107 (finished) created at:
sync.(*WaitGroup).Go()
sync/waitgroup.go:238 +0x72
==================
count = 100000 (期待値 100000)
Found 1 data race(s)
exit status 66
3回目のように「たまたま正しい値」になることがあるのが、データ競合の見つけにくさです。 -race は実行時に読み書きの履歴を照合して「どの行が、どの goroutine と衝突したか」を示し、終了コード 66 で失敗を知らせます。go test -race ./... を CI に入れておくと、偶然通ってしまうテストを拾えます(-race は gcc などの C コンパイラが必要で、実行速度は数倍遅くなります)。
sync.Mutex / atomic / channel の使い分け
守りたいものが「変数」なら Mutex か atomic、渡したいものが「データや仕事」なら channel、が基本の判断です。 同じ「100 goroutine × 1000 回のカウント」を3通りで書き、-race でも警告が出ないことを確認しました。
| 観点 | sync.Mutex | sync/atomic | channel |
|---|---|---|---|
| 向く場面 | 複数の変数や構造体をまとめて更新 | 1つの整数・フラグ・ポインタの更新 | データや仕事の受け渡し、完了の合図 |
| 保護の単位 | 「Lock〜Unlock の区間」 | 1つの操作(Add・Load・Store・CompareAndSwap) | 「送った値は受け取った側のもの」という所有権の移動 |
| 読み取りが多い | sync.RWMutex の RLock |
Load |
向かない(読むたびに受け渡しが要る) |
| 待ち・タイムアウト | できない(Lock は待つだけ) | 待たない | select でタイムアウト・キャンセルを組める |
| よくあるつまずき | Unlock 忘れ → defer mu.Unlock() で防ぐ |
複数の atomic を組み合わせても全体は atomic にならない | close 忘れ・受信側不在でデッドロックやリーク |
| 1回だけ初期化 | sync.Once / sync.OnceValue(1.21〜) |
— | — |
package main
import (
"fmt"
"sync"
"sync/atomic"
)
const workers, perWorker = 100, 1000 // 100 goroutine × 1000 回 = 100000
// 1) sync.Mutex:複数の変数や複合的な更新を守るとき
func countMutex() int {
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for range perWorker {
mu.Lock()
count++
mu.Unlock()
}
})
}
wg.Wait()
return count
}
// 2) sync/atomic:単一の整数・フラグを更新するだけのとき
func countAtomic() int64 {
var count atomic.Int64
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for range perWorker {
count.Add(1)
}
})
}
wg.Wait()
return count.Load()
}
// 3) channel:データの「所有者」を1つの goroutine に決めて受け渡すとき
func countChannel() int {
inc := make(chan int, 64)
done := make(chan int)
go func() { // この goroutine だけが count を持つ
count := 0
for n := range inc {
count += n
}
done <- count
}()
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for range perWorker {
inc <- 1
}
})
}
wg.Wait()
close(inc)
return <-done
}
func main() {
fmt.Println("Mutex =", countMutex())
fmt.Println("atomic =", countAtomic())
fmt.Println("channel =", countChannel())
}
$ go run -race -trimpath .
Mutex = 100000
atomic = 100000
channel = 100000
カウンタのような単純な用途に channel を使うのは遠回りで、Mutex や atomic のほうが素直です。 逆に「ジョブを渡す」「完了を知らせる」「途中で止める」といった流れの制御は channel と context が得意です。Go の格言「メモリを共有して通信するのではなく、通信によってメモリを共有する」は、この後者の場面を指しています。
よくあるつまずきと実際のエラー文
1. ループ変数の共有(Go 1.22 で変更)
Go 1.21 以前は for のループ変数がループ全体で1つだったため、goroutine の中から参照すると最後の値ばかり見えました。 Go 1.22 からは「反復ごとに新しい変数が作られる」仕様になり(公式リリースノート:「In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.」)、この問題は解消されています。同じコードを go.mod の go 行だけ変えて実行すると、違いがそのまま出ます。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("i =", i) // ループ変数 i をクロージャで参照
}()
}
wg.Wait()
}
$ go run -trimpath . (go.mod が go 1.21 のとき)
i = 3
i = 3
i = 3
$ go run -trimpath . (go.mod が go 1.22 のとき)
i = 2
i = 0
i = 1
新しい仕様が有効になるのは、go.mod の go 行が 1.22 以上のモジュールだけです。 ツールチェーンが 1.27.1 でも go 1.21 のままなら旧動作で、go vet が loop variable i captured by func literal と教えてくれます(go 1.22 以上ではこの警告は出ません)。1.21 以前を使う場合の直し方は、ループ内で i := i と複製するか、go func(i int) { ... }(i) と引数で渡す方法です。
2. close 済み channel への送信・2回目の close・nil channel の close
どれも panic で止まります。 「close は送信側が1回だけ」を守れば、3つとも起きません。
| 操作 | エラー文 |
|---|---|
| close 済みに送信 | panic: send on closed channel |
| close を2回 | panic: close of closed channel |
| nil channel を close | panic: close of nil channel |
package main
import "fmt"
func main() {
ch := make(chan int, 1)
close(ch)
fmt.Println(<-ch) // close 済みからの受信はゼロ値が返る(panic しない)
ch <- 1 // close 済みへの送信は panic
}
$ go run -trimpath .
0
panic: send on closed channel
goroutine 1 [running]:
main.main()
example.com/f2_send_closed/main.go:9 +0x9d
exit status 2
→ 送信者が複数いるときは、送信者側で WaitGroup を使って「全員の送信が終わってから1回 close」します(パターン5の merge がその形です)。受信側が「もう要らない」と伝えたいときは、channel を close するのではなく context でキャンセルします。
3. nil channel は永遠に待つ(select から外す技にも使える)
var ch chan int のように make していない channel は nil で、送信も受信も永遠にブロックします。 単独の goroutine で行うとデッドロック検知に引っかかり、[chan send (nil chan)] と表示されます。
package main
import "fmt"
func main() {
var ch chan int // make していない = nil channel
fmt.Println("ch == nil:", ch == nil)
ch <- 1 // nil channel への送信は永遠にブロックする(受信も同じ)
}
$ go run -trimpath .
ch == nil: true
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send (nil chan)]:
main.main()
example.com/f4_nil_channel/main.go:8 +0x6b
exit status 2
この性質は select で「終わった channel を外す」のに役立ちます。 close された channel は受信が即座に返り続けるので、そのままだと select がその case ばかり選びます。nil を代入すれば、その case は二度と選ばれません。
package main
import "fmt"
func main() {
a := make(chan int)
b := make(chan int)
go func() { a <- 1; a <- 2; close(a) }()
go func() { b <- 10; close(b) }()
// 2本の channel を両方 close されるまで読む。close された側は nil にして select から外す
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // nil channel の case は二度と選ばれない
continue
}
fmt.Println("a:", v)
case v, ok := <-b:
if !ok {
b = nil
continue
}
fmt.Println("b:", v)
}
}
fmt.Println("両方 close された")
}
$ go run -trimpath .
b: 10
a: 1
a: 2
両方 close された
4. WaitGroup の Add 忘れ・goroutine 内の Add・Done 過多
Add は goroutine を起動する前に、起動する側で呼びます。 忘れると Wait は待たずに抜け、goroutine 側の Done がカウンタを負にして panic することもあります(下の出力は実行ごとに変わり、main: 終了 だけが出ることもあります)。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
go func() { // wg.Add(1) を忘れている
defer wg.Done()
fmt.Println("task", i)
}()
}
wg.Wait() // カウンタが 0 なので待たずに抜ける
fmt.Println("main: 終了")
}
$ go run -trimpath .
task 1
panic: sync: negative WaitGroup counter
goroutine 7 [running]:
...
main.main.func1()
example.com/f5_wg_add_forgot/main.go:14 +0xb4
exit status 2
| 書き方 | 起きること | 直し方 |
|---|---|---|
Add を書かない |
Wait が待たない。Done で panic: sync: negative WaitGroup counter |
起動前に wg.Add(1) |
goroutine の中で wg.Add(1) |
Wait のほうが先に走り、待たずに main: 終了(実行して確認) |
起動前に移す |
Add(1) に対して Done を2回 |
panic: sync: negative WaitGroup counter |
defer wg.Done() を1つだけ |
wg.Go を使う(1.25〜) |
上の3つが構造的に起きない | 迷ったら wg.Go |
5. WaitGroup を値渡しする(go vet が教えてくれる)
WaitGroup を引数に値で渡すとコピーが作られ、コピー側の Done は元のカウンタを減らしません。 公式ドキュメントも「A WaitGroup must not be copied after first use.」と明記しています。結果は Wait が永遠に待つデッドロックです。
package main
import (
"fmt"
"sync"
)
func run(wg sync.WaitGroup, id int) { // 値渡し:呼び出し元とは別の WaitGroup になる
defer wg.Done()
fmt.Println("task", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 2; i++ {
wg.Add(1)
go run(wg, i)
}
wg.Wait() // 元の wg のカウンタは減らないので永遠に待つ
}
$ go vet ./...
main.go:8:13: run passes lock by value: sync.WaitGroup contains sync.noCopy
main.go:17:10: call of run copies lock value: sync.WaitGroup contains sync.noCopy
$ go run -trimpath .
task 2
task 1
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
...
main.main()
example.com/f6_wg_copy/main.go:19 +0xaf
exit status 2
→ func run(wg *sync.WaitGroup, id int) とポインタで渡します。 sync.Mutex も同じで、構造体に埋め込んだ場合はその構造体もポインタで扱います。go vet の copylocks 検査が実行前に見つけてくれるので、go vet ./... を習慣にすると安心です。
6. close 忘れで range が終わらない
for v := range ch は close されるまで抜けません。 送信が終わっても close しないと、受信側は「次の値」を待ち続けます。
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 3; i++ {
ch <- i
}
// close(ch) を忘れている
}()
for v := range ch { // close されないので、3件受信した後も待ち続ける
fmt.Println(v)
}
fmt.Println("完了")
}
$ go run -trimpath .
1
2
3
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
example.com/f7_range_noclose/main.go:13 +0xd4
exit status 2
→ 送信側の goroutine の先頭に defer close(ch) を置くのが定番です。[chan receive] で main が止まっているスタックトレースが「close 待ち」の目印になります。
よくある質問
Q. goroutine は何個まで作れますか?
A. 上限は基本的にメモリで決まります。この記事の計測では1万個で約19.5MB(1個あたり約2KB)でした。ただし「作れる」と「効率がよい」は別で、CPU を使う処理なら runtime.NumCPU() 前後の worker(パターン4)、外部への問い合わせなら相手側が受け付けられる数をセマフォ(パターン7)で制限するのが実務的です。
Q. バッファのサイズはいくつにすればよいですか? A. 「理由を言えるサイズ」にします。同期の合図として使うなら 0(バッファなし)、受信側が去っても送信側を終わらせたいなら「送信する goroutine の数」、一時的な受け皿として少し溜めたいなら小さな固定値、が目安です。大きくすると詰まりに気づきにくくなるので、まず 0 か送信者数から始めて、計測して増やします。
Q. time.After をループの中で使うとタイマーがリークしますか?
A. Go 1.23 以降(go.mod の go 行が 1.23.0 以上)では、参照されなくなった Timer と Ticker は Stop を呼ばなくても即座に GC の対象になるため、以前ほど気にする必要はありません。長い間ループ内で待つ処理では、time.NewTimer を1つ作って Reset で使い回すか、context.WithTimeout にまとめる書き方が読みやすくなります。
Q. errgroup は使うべきですか? 標準ライブラリだけで十分ですか?
A. パターン8の形(最初のエラーで残りをキャンセルし、全部の完了を待つ)が必要な場面が複数あるなら、golang.org/x/sync/errgroup を使うほうがコードが短く、SetLimit で同時実行数の制限も付けられます。1か所だけなら、標準ライブラリの WaitGroup+context+sync.Once で十分です。どちらでも、渡した ctx を各タスクが select で監視していなければキャンセルは効かない点は同じです。
まとめ
- goroutine は
go f()で起動し、終了は待たない。 全部待つならsync.WaitGroup、1.25 以降はwg.Go(f)で Add/Done 忘れが構造的になくなる - バッファなし channel は送受信が同時に成立する「待ち合わせ」、バッファありは空きがある間だけ送信が待たない。 長さは「同期なら 0、受信側が去っても送信を終わらせたいなら送信者数」と理由を持って決める
- close は送信側が1回だけ。
rangeは close で抜け、close 済みからの受信はゼロ値とok=false、送信と2回目の close は panic - タイムアウトは
select+time.Afterかcontext.WithTimeout、キャンセルは同じ ctx を渡してctx.Done()を監視する。 呼び出し先にも期限を渡すなら context - ワーカープール・fan-out/fan-in・パイプライン・セマフォは「誰が close するか」「途中で止めたら上流はどう終わるか」を決めてから書く。
resultsの close は「全 worker の完了を待つ goroutine」に任せる - デッドロックは
fatal error: all goroutines are asleep - deadlock!で止まるが、他の goroutine が動いていれば静かなリークになる。runtime.NumGoroutine()の前後比較と、Go 1.27 のgoroutineleakプロファイルで発生行を特定する - 共有変数は Mutex か atomic、データと仕事の受け渡しは channel。
go run -race/go test -raceを CI に入れ、go vetの copylocks と loopvar の警告を見逃さない
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Go goroutine・channelの使い方|並行処理パターン8選と実行結果」
株式会社RJCでは、goroutine と channel を使った並行処理の設計やレビューを、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。