この記事で学ぶこと
#17 の最後にこう書きました。サーバーが遅いリクエストを1本処理している間、他のリクエストはなぜ待たされずに済むのか。実は、これまで作ってきたサーバーはとっくに並行で動いています。#17 の実験で「リクエストごとの context」が自然に成立していたのも、リクエストごとに独立した実行の流れが割り当てられていたからです。
今日はその裏側にいる goroutine を、自分の手で起動するところから始めます。そして並行処理の光と影を両方体験します。光は「3秒かかる仕事×3件が1秒で終わる」こと。影は「1000回足したはずのカウンタが986になる」ことです。
この記事を終えると以下ができるようになります。
-
goキーワードで関数を並行に実行し、sync.WaitGroupで完了を待てる - main が終わると全 goroutine が消える理由を説明できる
- データ競合がなぜ起きるかを「読む・足す・書き戻す」の分解で説明し、
-raceで検出してsync.Mutexで直せる - net/http が1リクエスト1goroutineで動いている事実と、それが handler の書き方に課す制約を説明できる
この回はGo編です。goroutine はGo固有の仕組みですが、「複数の処理を同時に進めると速くなる。ただし共有データを同時に触ると壊れる」という主題は言語を問いません。Python編では asyncio と concurrent.futures で同じ内容を扱う予定です。
見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。
準備
今回は PostgreSQL を使いません。Goだけで完結します。
mkdir goroutine-practice && cd goroutine-practice
go mod init goroutine-practice
なお、この記事のコードはループ変数の扱いが新しくなった Go 1.22 以降を前提にしています。go version で確認しておいてください(私の環境は go1.22.2 です)。古いGoでの違いは後半で補足します。
サーバーは1本ずつ処理していない
レストランの喩えを続けます。もしキッチンがワンオペで、注文を1件ずつ順番に調理していたらどうなるか。煮込み料理の注文が1件入った瞬間、後ろに並んだサラダの客まで全員が煮込みの完成を待つことになります。現実のレストランがそうならないのは、注文ごとに調理の担当を割り当てて、同時に複数の料理を進めているからです。
バックエンドも同じです。#17 で pg_sleep(3) 入りの遅いエンドポイントを作りました。もしサーバーが1本ずつ処理する作りだったら、その3秒間、他の全リクエストが行列を作ります。実際にはそうなりません。今日はまず「同時に進める」仕組みそのものを最小の形で動かし、最後にサーバーへ戻ってきて、この行列が起きないことを実測します。
【手を動かす①】go を付けて関数を呼ぶ
goroutine-practice/main.go を作ります。関数呼び出しの前に go と書くだけです。
package main
import "fmt"
func say(name string) {
fmt.Println("こんにちは、" + name + "です")
}
func main() {
go say("goroutine")
say("main")
}
go say("goroutine") は「この仕事は別の担当に任せて、自分はすぐ次の行へ進む」という意味です。実行します。
go run .
こんにちは、mainです
goroutine 側の挨拶が出ません。私の環境では5回実行して5回とも同じでした。
種明かしをすると、go を付けた呼び出しは「担当を割り当てた」時点で次の行へ進みます。main は自分の say("main") を表示し終えるとプログラムごと終了します。このとき、まだ仕事中の goroutine は結果を待ってもらえず全員道連れで消えます。任せた側の main が先に帰ってしまい、店が閉まったわけです。
では、少し待ってみます。main の最後に1行足します。
func main() {
go say("goroutine")
say("main")
time.Sleep(100 * time.Millisecond)
}
こんにちは、mainです
こんにちは、goroutineです
今度は表示されました。ただしこの time.Sleep は理解のための仮の道具で、実務では使いません。「100ミリ秒あれば終わるだろう」という祈りであって、保証がないからです。相手が重い処理なら待ち時間が足りず、軽い処理なら無駄に待ちます。正しい待ち方はこの後すぐ扱います。
🔴 goroutine は「もう1本の実行の流れ」
ここまでの観察を整理します。goroutine は、Goのランタイムが管理する軽量な実行の流れです。プログラムには最初から1本だけ流れがあり、それが main です(main も goroutine の一員です)。go f() と書くたびに流れがもう1本増え、それぞれが独立に進みます。
押さえるべき性質は2つです。
-
go f()は f の完了を待たない。起動だけして即座に次の行へ進む - main の goroutine が終わると、他の goroutine は仕事の途中でもプログラムごと消える
だから並行処理には「起動する道具」と「完了を待つ道具」が必ずセットで要ります。go キーワードが前者、次に学ぶ sync.WaitGroup が後者です。
🟡 なぜ10万個も起動できるのか
「担当を増やす」と言うと、OSのスレッドを思い浮かべた人もいるはずです。goroutine が普通のスレッドと違うのは、その軽さです。実際に10万個起動してみました。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(1 * time.Second)
}()
}
fmt.Println("起動中のgoroutine数:", runtime.NumGoroutine())
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("使用メモリ: %dMB\n", m.Sys/1024/1024)
wg.Wait()
fmt.Printf("全員完了まで: %.1f秒\n", time.Since(start).Seconds())
}
起動中のgoroutine数: 100001
使用メモリ: 261MB
全員完了まで: 1.2秒
10万個(+ main の1個)を起動してもメモリは261MB、1秒待つ仕事が全員分で1.2秒しかかかっていません。goroutine は数KBの小さなスタックで生まれ、必要になったら伸びる作りだからです。OSスレッドを10万本作る方式では、こうはいきません。Goのランタイムが厨房のリーダー役で、少数のOSスレッド(だいたいCPUコア数)の上に大量の goroutine を乗せ替えながら回しています。「リクエストごとに goroutine を1個割り当てる」という net/http の贅沢な設計は、この軽さがあって初めて成立します。
🟡 並行と並列の違い
用語を1つだけ区別しておきます。並行(concurrency)は、1人の担当が複数の鍋を切り替えながら面倒を見ることです。片方の煮込みを待つ間に別の炒め物を進める。同時に「進行中」ですが、手を動かしているのは常に1つです。並列(parallelism)は、担当が複数人いて文字どおり同時に手を動かすことです。
goroutine で書くのは「並行」の構造です。それが実際に並列実行されるかは、CPUコア数に応じてランタイムが決めます。書き手としては「切り替えながらでも同時進行できる構造」を書き、並列にできるかどうかの判断は任せる。この分担だけ頭に入れておいてください。
【手を動かす②】WaitGroup で「全員の完了」を待つ
祈りの time.Sleep を卒業します。sync.WaitGroup は出発前のバスの点呼のような道具で、3つのメソッドを使います。
| メソッド | 役割 |
|---|---|
wg.Add(1) |
「待つべき人数」を1増やす。goroutine を起動する前に呼ぶ |
wg.Done() |
「1人戻りました」の報告。goroutine の仕事の最後に呼ぶ |
wg.Wait() |
人数が0に戻るまでその場で待つ |
題材も実務に寄せます。商品ページを開くと、在庫・価格・レビューをそれぞれ別のサービスへ問い合わせる場面を想像してください。1件1秒かかるとして、順番に呼ぶと3秒。これを並行にします。
package main
import (
"fmt"
"sync"
"time"
)
// 外部APIの呼び出しを模した、1秒かかる仕事
func fetch(name string) {
time.Sleep(1 * time.Second)
fmt.Println(name, "完了")
}
func main() {
start := time.Now()
// 直列版
fetch("在庫サービス")
fetch("価格サービス")
fetch("レビューサービス")
fmt.Printf("直列: %.1f秒\n\n", time.Since(start).Seconds())
// 並行版
start = time.Now()
var wg sync.WaitGroup
for _, name := range []string{"在庫サービス", "価格サービス", "レビューサービス"} {
wg.Add(1)
go func() {
defer wg.Done()
fetch(name)
}()
}
wg.Wait()
fmt.Printf("並行: %.1f秒\n", time.Since(start).Seconds())
}
在庫サービス 完了
価格サービス 完了
レビューサービス 完了
直列: 3.0秒
レビューサービス 完了
価格サービス 完了
在庫サービス 完了
並行: 1.0秒
3秒が1秒になりました。3つの1秒待ちが重なって進んだからです。待ち時間が支配的な処理(DB、外部API、ファイルI/O)は、並行にした分だけ素直に速くなります。これが光の側です。
出力の順序にも注目してください。並行版は「レビュー→価格→在庫」の順で完了しています。もう一度実行したら「価格→在庫→レビュー」でした。並行処理に実行順の保証はありません。どの goroutine がいつ進むかはランタイム次第で、実行のたびに変わります。今は無害な順序の揺れですが、この「タイミングが毎回違う」性質が、次の節で牙を剥きます。
書き方の注意も2つ。wg.Add(1) は go の前に書きます。goroutine の中に書くと、Add が実行される前に main の wg.Wait() が「待つ人数0」と判断して通過してしまう恐れがあります。点呼は乗客が走り出す前に済ませるものです。defer wg.Done() としているのは #11 の defer tx.Rollback() と同じ発想で、途中で return やパニックがあっても報告だけは確実に実行させるためです。
⚪ 1つ補足。ループ変数 name を goroutine の中からそのまま使っていますが、これが安全なのは Go 1.22 以降です。1.21 以前はループ変数が全周回で共有されていたため、goroutine が動き出す頃には name が別の値に変わっている有名な罠がありました(対策としてループ内で name := name と書き写す慣習があった)。古いコードでこの1行を見かけたら、その名残です。
🔴 共有データへの同時書き込みは壊れる
ここからが影の側です。並行に動く goroutine たちが同じ変数に書き込むとどうなるか。1000個の goroutine に、共有のカウンタを1ずつ増やしてもらいます。
【手を動かす③】1000回足したのに1000にならない
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("counter =", counter)
}
5回実行しました。
counter = 1000
counter = 990
counter = 1000
counter = 986
counter = 988
1000にならない回があります。しかも毎回違う値です。さらに恐いのは、5回中2回はちょうど1000だったことです。もしテストでその2回だけを見ていたら「動いている」と判断して出荷してしまいます。
なぜ数が消えるのか。counter++ は1行ですが、機械の目線では3手順に分かれます。
- counter の今の値を読む
- 1を足す
- 結果を書き戻す
文化祭の模擬店で、売上を1枚の紙に正の字で記録している場面を考えてください。記録係が2人いて、それぞれ「今の数を見る→1増やした数に書き直す」をやるとします。2人が同時に「12」を見たら、2人とも「13」と書きます。売上は2件あったのに、記録は1しか増えません。上のプログラムでは、これが1000人の記録係で同時に起きています。読んでから書き戻すまでの隙間に他人が割り込むと、その人の加算が上書きで消えるわけです。
これをデータ競合(data race)と呼びます。タチの悪さは、実行のたびに起きたり起きなかったりすることです。②で見た「実行順は毎回変わる」性質のせいで、再現条件を固定できません。本番でだけ、負荷が高い日にだけ、たまに数字がずれる。そういう最悪の顔で現れます。
-race で機械に見つけさせる
再現任せにできないバグは、検出器で狩ります。Goにはレース検出器が標準で付いていて、-race フラグを付けるだけで動きます。
go run -race .
==================
WARNING: DATA RACE
Read at 0x00c000014118 by goroutine 11:
main.main.func1()
/tmp/goroutine-practice/main.go:16 +0x84
Previous write at 0x00c000014118 by goroutine 10:
main.main.func1()
/tmp/goroutine-practice/main.go:16 +0x96
(中略)
counter = 713
Found 2 data race(s)
exit status 66
「goroutine 10 が書いた場所を、goroutine 11 が同期なしに読んだ」と、ファイル名と行番号(main.go:16 はまさに counter++ の行)付きで報告されました。実行が偶然うまくいったかどうかに関係なく、危ない読み書きのペアそのものを検出してくれるのが強みです。
Mutex で直す
修正には sync.Mutex(相互排他ロック)を使います。紙の前に「記入中」の札を置き、札を取った人しか読み書きできないルールにする道具です。
counter := 0
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
mu.Lock() から mu.Unlock() までの区間に入れる goroutine は常に1つだけです。誰かが区間内にいる間、他の goroutine は Lock() の行で順番待ちをします。3手順の途中に割り込まれることがなくなるので、加算はもう消えません。
counter = 1000
counter = 1000
counter = 1000
3回実行して3回とも1000、go run -race . も警告なしになりました。
仕組みに見覚えがあるはずです。#9 でDBの行ロックを学びました。あれは「同じ行を同時に更新すると壊れるから、片方を待たせる」仕組みでした。Mutex はそのメモリ版です。守る対象がDBの行かGoの変数かの違いだけで、「共有物への同時変更は、順番待ちにして直列化する」という解決の型は同じです。そしてロックには待ちが生まれるという代償も同じです。ロック区間は必要最小限に絞ります。
【手を動かす④】サーバーはとっくに並行だった
冒頭の伏線を回収します。3秒かかるエンドポイントを持つ最小のサーバーを書きます。ポイントは、このコードのどこにも go が出てこないことです。
package main
import (
"fmt"
"log"
"net/http"
"runtime"
"time"
)
func slowHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("処理開始 (いまのgoroutine数: %d)", runtime.NumGoroutine())
time.Sleep(3 * time.Second) // 重い処理のかわり
fmt.Fprintln(w, "done")
}
func main() {
http.HandleFunc("/slow", slowHandler)
log.Println("起動しました :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
起動して、まず1本だけ叩きます。
curl -s -o /dev/null -w "1本だけ: %{time_total}s\n" localhost:8080/slow
1本だけ: 3.011953s
3秒です。ではサーバーが1本ずつ処理する作りなら、3本同時に投げると最後の1本は9秒待つはずです。& で3本のcurlを同時に走らせます。
for i in 1 2 3; do
curl -s -o /dev/null -w "リクエスト$i: %{time_total}s\n" localhost:8080/slow &
done
wait
リクエスト2: 3.081695s
リクエスト1: 3.082443s
リクエスト3: 3.082306s
3本とも約3秒で返りました。行列は起きていません。サーバー側のログも見ます。
2026/08/26 17:26:21 起動しました :8080
2026/08/26 17:26:22 処理開始 (いまのgoroutine数: 3)
2026/08/26 17:26:22 処理開始 (いまのgoroutine数: 6)
2026/08/26 17:26:22 処理開始 (いまのgoroutine数: 7)
同じ1秒の間に3件の「処理開始」が並び、goroutine の数が増えています(net/http は接続の裏方にも goroutine を使うので、数字はリクエスト数ぴったりにはなりません。増えていることだけ見てください)。
種明かしはシンプルで、http.ListenAndServe が接続を受けるたびに go を付けて handler を呼んでいます。自分で書かなくても、handler は最初からリクエストごとに別の goroutine で実行されていたわけです。#3 で初めてサーバーを書いた日から、私たちはずっと並行処理の恩恵を受けていました。
そしてこの事実は、#17 までの内容と一気につながります。
- リクエストごとに goroutine が割り当てられるから、「リクエストごとの連絡線」である
r.Context()が成立する。goroutine と context は1つの依頼の実行と生死をそれぞれ担う相棒です - handler が別々の goroutine で同時に走るということは、handler たちが共有するものに書き込めば③の事故がサーバーで起きるということです
実務での判断基準
🔴 handler は「同時に呼ばれる」前提で書く
④の帰結です。handler・usecase・repository の構造体は全リクエストで共有される1個ずつしかいません(#16 で main が1回だけ組み立てました)。そこに h.count++ のような可変の状態を持たせた瞬間、③のカウンタ事故がサーバーで再現します。
#14〜#16 で作った各層の構造体を思い出してください。持っているのは *sql.DB や下の層への参照だけで、リクエストごとに変わるデータはすべて引数と戻り値で受け渡しました。あれは行儀の問題ではなく、並行安全のための設計です。リクエスト固有のデータを構造体のフィールドに置かない。この規律さえ守れば、handler は何本同時に呼ばれても安全です。なお *sql.DB 自体は複数 goroutine から同時に使ってよいと公式に保証されています(内部でロックとコネクションプールが面倒を見ています)。共有してよいのは、こういう「並行安全と明言されたもの」と「読み取り専用のもの」だけです。
🔴 テストは -race 付きで回す
データ競合は再現が運任せなので、人間のレビューとふつうのテストでは網から漏れます。go test -race ./... を普段の実行方法にして、CIにもこのフラグで載せてください。検出器は実行が遅くなる代償がありますが、テストでの遅さは事故の値段に比べれば無料同然です。③で見たとおり、-race は「たまたま成功した実行」からでも危ない読み書きを報告してくれます。数字がずれてから探すのではなく、書いた日に機械に見つけさせるのが正解です。
🟡 goroutine は起動より「終わり方」を先に決める
go と書くのは一瞬ですが、書く前に自問すべきは「この goroutine はいつ、どうやって終わるのか」です。終わり方を決めずに起動した goroutine は、エラーで早々に詰まっても誰にも気づかれず、メモリと接続を握ったまま溜まり続けます。goroutine リークと呼ばれ、「再起動すると直るんですが数日で重くなるんです」という障害の定番の正体です。
今日の道具立てなら、完了を WaitGroup で待ち、打ち切りを #17 の context で伝える。この2つを添えられない goroutine は、まだ起動してはいけない goroutine です。
⚪ 今日は流してよいもの
goroutine を何本のOSスレッドで回すかは GOMAXPROCS という設定で決まり、既定でCPUコア数です。ランタイムのスケジューラがどう乗せ替えるかも含め、チューニングが必要になる日まで調べなくて大丈夫です。また、読み取りが多い共有データには sync.RWMutex という読み書きを区別するロックもあります。存在だけ覚えておいてください。
確認テスト
知識問題
Q1. ①の実験で、go say("goroutine") の挨拶は表示されませんでした。理由を説明してください。また time.Sleep を足すと表示されるようになりましたが、この方法が実務で許されないのはなぜですか。
Q2. sync.WaitGroup の Add / Done / Wait の役割をそれぞれ説明してください。また、wg.Add(1) を goroutine の中に書いてはいけない理由を述べてください。
Q3. ③の実験で、1000個の goroutine が counter++ したのに結果は986や990になりました。counter++ を3手順に分解して、数が消える仕組みを説明してください。また「5回中2回は1000だった」ことが、このバグのタチの悪さとどう関係するかも述べてください。
Q4. ④の実験で、3秒かかるエンドポイントに3本同時にリクエストを投げたら、3本とも約3秒で返りました。サーバーのコードに go を1つも書いていないのに並行処理された理由を説明してください。また、この事実が handler の書き方に課す制約を1つ挙げてください。
実技問題
Q5. 1秒かかる次の関数を5回呼び、結果をすべて集めるプログラムを書いてください。直列なら5秒かかるところを約1秒で終わらせ、5件の結果を取りこぼさないこと。書けたら go run -race . で競合がないことも確認してください。
func fetch(id int) string {
time.Sleep(1 * time.Second)
return fmt.Sprintf("結果%d", id)
}
ヒント:結果を集める入れ物への書き込み方が焦点です。append を安易に使うと何が起きるか、-race で確かめながら進めてください。
確認テスト 解答・解説
Q1
go f() は f の完了を待たず、起動だけして次の行へ進みます。main は自分の表示を終えるとプログラムを終了させ、その時点で仕事中の goroutine は途中でも全部消えます。goroutine の say は、実行される前にプログラムごと終わってしまったわけです。
time.Sleep で表示されるのは「100ミリ秒あれば終わるだろう」という賭けに勝ったからにすぎません。相手の処理が重ければ待ち時間が足りずまた消えますし、軽ければ無駄に待ちます。「終わったこと」を確認せず「時間が経ったこと」で代用しているのが本質的な欠陥で、完了を確実に待つには WaitGroup を使います。
Q2
Add(1) は待つべき仕事の数を増やす申告、Done() は仕事1件の完了報告(内部では数を1減らす)、Wait() は数が0になるまで呼び出し元を止める待機です。バスの点呼でいえば、乗る予定の人数を数え、戻った人を数え、全員揃うまで出発しない、に対応します。
Add(1) を goroutine の中に書くと、goroutine が実際に動き出すまで人数が増えません。②で見たとおり goroutine の実行タイミングは保証がないので、どの Add よりも先に main の Wait() が実行される可能性があります。そのとき数は0なので、Wait は「全員完了」と誤解してすり抜けます。点呼の数え上げは、goroutine を起動する側が起動前に済ませる必要があります。
Q3
counter++ は「①今の値を読む ②1を足す ③書き戻す」の3手順です。2つの goroutine が同時に①を実行して同じ値(例えば12)を読むと、両方が③で13を書き戻します。2回加算したのに結果は1しか増えず、片方の仕事が消えます。1000個で同時多発するので、消えた回数の分だけ986や990になりました。
「時々1000になる」のは、手順の重なりが起きるかどうかが実行のたびのタイミング次第だからです。これがタチの悪さの核心で、テストで数回成功しても正しさの証拠になりません。負荷の高い本番でだけ、たまに現れるバグになります。だから再現待ちではなく -race による検出で狩ります。検出器はたまたま成功した実行からでも、同期なしの読み書きペアを行番号付きで報告してくれます。
Q4
http.ListenAndServe の内部が、接続を受けるたびに go を付けて handler を呼んでいるからです。並行化のコードはライブラリの側に書かれていて、私たちの handler は最初から1リクエスト1goroutineで実行されています。3本のリクエストには3個の goroutine が割り当てられ、3秒の待ちが重なって進んだので、全員が約3秒で返りました。
制約は、handler が複数の goroutine から同時に呼ばれる前提で書くことです。全リクエストで共有される handler の構造体に可変の状態(カウンタやリクエスト固有のデータ)を持たせると、③のデータ競合がサーバーで起きます。リクエストごとのデータは引数・ローカル変数・戻り値で受け渡し、共有してよいのは並行安全が保証されたもの(*sql.DB など)と読み取り専用のものに限ります。
Q5
package main
import (
"fmt"
"sync"
"time"
)
// 外部サービスへの問い合わせを模した、1秒かかる仕事
func fetch(id int) string {
time.Sleep(1 * time.Second)
return fmt.Sprintf("結果%d", id)
}
func main() {
start := time.Now()
results := make([]string, 5)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = fetch(i)
}()
}
wg.Wait()
fmt.Println(results)
fmt.Printf("%.1f秒\n", time.Since(start).Seconds())
}
[結果0 結果1 結果2 結果3 結果4]
1.0秒
5件揃って約1秒、go run -race . も警告なしでした。
ポイント
- 先に
make([]string, 5)で5席分を確保し、各 goroutine は自分の席results[i]だけに書く。書き込み先が1つも重ならないので、ロックなしでも競合しない -
var results []stringにしてresults = append(results, ...)と書くと壊れる。appendは全員が同じスライス本体(長さの情報と書き込み位置)を読んで更新するので、③のカウンタと同じ構図になる。実際に試すと-raceがDATA RACEを報告し、ふつうの実行でも[結果3 結果0 結果4]のように5件中3件しか残らない回があった - 完了待ちは②と同じ型どおり、
Addはgoの前、Doneはdefer、最後にWait
「共有の1か所に順番に書く」から競合するのであって、「各自の席に書く」なら共有自体が起きない。ロックで守る以前に、競合しない形にデータを設計するのが一番きれいな解決です。
まとめ
-
go f()は関数をもう1本の実行の流れで起動し、完了を待たずに進む。main が終われば全 goroutine は道連れで消えるので、完了はWaitGroup(Add は起動前、Done は defer、最後に Wait)で待つ - 待ち時間が支配的な仕事は並行化で素直に速くなる。実測では1秒の仕事×3件が直列3.0秒、並行1.0秒だった
- 実行順に保証はなく、共有変数への同時書き込みは加算が消える形で壊れる。しかも壊れたり壊れなかったりする。検出は
go test -race、修正はsync.Mutexか「各自の席に書く」設計 - net/http は最初から1リクエスト1goroutine。handler は同時に呼ばれる前提で、リクエスト固有のデータを共有の構造体に置かない
- goroutine は起動より終わり方が難しい。完了は WaitGroup、打ち切りは context。終わり方を決めてから
goと書く
次回は #19 並行処理② channel です。今日は結果を「各自の席」に書いて集めましたが、goroutine 同士が値を直接手渡しできる channel を使うと、集計も待ち合わせもより自然に書けます。#17 で先送りにした Done() の戻り値の正体も、そこで明かします。