goroutineとチャネル
.NET (C#) の経験がある開発者向けに、goroutine、チャネル、select、そして async / await との考え方の違いを解説します。
Goの並行処理は言語機能として組み込まれており、「同期的なコードをそのまま書き、go キーワードで並行実行する」点が C# と大きく異なります。
1. goroutine とは
goroutine は Go ランタイムが管理する軽量な実行単位です。関数呼び出しの前に go を付けるだけで、新しい goroutine で実行されます。
func main() {
go fmt.Println("別の goroutine で実行")
fmt.Println("main")
time.Sleep(100 * time.Millisecond) // 本来は WaitGroup などで待つ (後述)
}
スケジューリング (M:N モデル)
Go ランタイムは多数の goroutine (G) を少数の OS スレッド (M) 上で多重化して実行します。
| 項目 | goroutine | OS スレッド (.NET Thread) |
|---|---|---|
| 初期スタックサイズ | 約 2KB (必要に応じて伸縮) | 通常 1MB 以上 (OS・設定による) |
| 生成コスト | 非常に小さい | 大きい |
| スケジューラ | Go ランタイム (協調 + プリエンプション) | OS |
| ブロッキング I/O | ランタイムが他の goroutine へ切り替える | スレッドを占有する |
I/O 待ちで goroutine がブロックしても、ランタイムが別の goroutine を実行するため、「ブロッキング呼び出しを避けるための async/await」自体が不要です。
2. .NET との対応表
| .NET (C#) | Go |
|---|---|
Task / async / await | goroutine (同期的な書き方のまま並行実行) |
Task.Run(() => ...) | go func() { ... }() |
Task<T> の結果取得 | チャネルで結果を受け取る / sync.WaitGroup |
Task.WhenAll | sync.WaitGroup / errgroup.Group |
Task.WhenAny | select |
Channel<T> (System.Threading.Channels) | chan T |
lock / Monitor | sync.Mutex |
ReaderWriterLockSlim | sync.RWMutex |
Interlocked | sync/atomic |
CancellationToken | context.Context |
Task.Delay | time.Sleep / time.After / time.NewTimer |
Parallel.ForEach | goroutine + WaitGroup (または errgroup) |
3. チャネル (channel)
チャネルは goroutine 間で値を受け渡すための型付きパイプです。Go の格言は次のとおりです。
メモリを共有して通信するのではなく、通信によってメモリを共有せよ。
基本操作
ch := make(chan int) // バッファなしチャネル
go func() {
ch <- 42 // 送信
}()
v := <-ch // 受信
fmt.Println(v) // 42
| 操作 | 構文 |
|---|---|
| 作成 | make(chan T) / make(chan T, n) |
| 送信 | ch <- v |
| 受信 | v := <-ch / v, ok := <-ch |
| クローズ | close(ch) |
| 受信が終わるまでループ | for v := range ch { ... } |
バッファなし vs バッファ付き
| 種類 | 作成 | 挙動 |
|---|---|---|
| バッファなし | make(chan T) | 送信と受信が同時に揃うまで両方ブロックする (同期点になる) |
| バッファ付き | make(chan T, n) | バッファに空きがあれば送信はブロックしない。満杯で送信側、空で受信側がブロック |
ch := make(chan string, 2)
ch <- "a" // ブロックしない
ch <- "b" // ブロックしない
// ch <- "c" // バッファ満杯なので、誰かが受信するまでブロックする
バッファ付きチャネルは System.Threading.Channels の Channel.CreateBounded<T>(n) に近い存在です。
クローズのルール
- クローズするのは送信側です。受信側はクローズしません。
- クローズ済みチャネルへの送信は panic します。
- クローズ済みチャネルからの受信は、残りの値を受け取ったあとゼロ値と
ok == falseを返します。 - 複数の送信者がいる場合は、全員の完了を
WaitGroupで待ってから 1 か所でクローズします。 - クローズは必須ではありません。
rangeで受信を終了させたい場合などに使います。
チャネルの方向
関数の引数でチャネルの方向を限定すると、誤用をコンパイル時に防げます。
func producer(out chan<- int) { /* 送信専用 */ }
func consumer(in <-chan int) { /* 受信専用 */ }
4. select
select は複数のチャネル操作のうち、準備ができたものを 1 つ実行します。Task.WhenAny に近いですが、チャネルの送受信やタイムアウト、キャンセルを統一的に扱える点が特徴です。
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case ch2 <- 10:
fmt.Println("ch2 に送信")
case <-time.After(1 * time.Second):
fmt.Println("タイムアウト")
case <-ctx.Done():
fmt.Println("キャンセル:", ctx.Err())
default:
// どれも準備できていないときに即座に実行される (ノンブロッキング)
}
- 複数が同時に準備できている場合はランダムに 1 つが選ばれます。
defaultがあるとブロックしません。- nil チャネルへの送受信は永遠にブロックするため、
select内で特定の case を無効化する用途に使えます。
タイムアウトとキャンセル
func fetch(ctx context.Context, ch <-chan string) (string, error) {
select {
case s := <-ch:
return s, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
time.After はループ内で毎回呼ぶとタイマーを大量に生成します (Go 1.23 以降は未参照のタイマーが GC されますが、無駄な割り当ては残ります)。ループ内では time.NewTimer を再利用するか、context.WithTimeout を使います。context の詳細はcontextを参照してください。
5. 待ち合わせと排他
sync.WaitGroup
Task.WhenAll 相当です。
var wg sync.WaitGroup
for i := range 3 {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker", i)
}()
}
wg.Wait()
Addは goroutine を起動する前に呼びます (goroutine 内で呼ぶとWaitが先に抜ける可能性がある)。- Go 1.22 以降は、ループ変数
iが反復ごとに別の変数になるため、上記のようにクロージャで直接参照しても安全です。それ以前のバージョンではi := iが必要でした。 - 新しい Go のバージョンでは
wg.Go(func() { ... })でAdd/Doneを省略できます (Go 1.25 以降)。
sync.Mutex
共有状態を保護します。lock ステートメント相当です。
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
sync.Mutexはコピーしてはいけません (ポインタレシーバーで使う)。- ロックは再入不可です。同じ goroutine で二重に
Lockするとデッドロックします (C# のlockは再入可能なので注意)。
errgroup
複数の処理を並行実行し、最初のエラーで全体をキャンセルする用途には golang.org/x/sync/errgroup が便利です。
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
g.Go(func() error {
return fetchURL(ctx, url)
})
}
if err := g.Wait(); err != nil {
return err
}
6. C# 開発者がつまずきやすいポイント
- goroutine に戻り値はない:
Task<T>のように結果を持つ型はありません。結果はチャネルや共有変数 (排他制御付き) で受け取ります。 - goroutine は勝手に待ってくれない:
mainが終了すると、実行中の goroutine は即座に強制終了されます。WaitGroupなどで明示的に待ちます。 - 例外は goroutine をまたいで伝わらない: goroutine 内の panic は
recoverされない限りプロセス全体を落とします。エラーはチャネルやerrgroupで集約します。 - goroutine リーク: 受信者のいないチャネルへ送信し続ける、永遠に閉じられないチャネルを待ち続ける、といったケースで goroutine が終了せず残り続けます。.NET の
Taskと違い GC されません。 - デッドロック検出: 全 goroutine がブロックすると、ランタイムが
fatal error: all goroutines are asleep - deadlock!で落ちます。ただし HTTP サーバーなど他の goroutine が動いていると検出されません。 - ループ変数のキャプチャ: Go 1.21 以前は全反復で同じ変数を共有し、意図しない値を参照するバグがよくありました。
go.modのgoディレクティブが 1.22 以上なら解消されています。 - データ競合: 複数の goroutine から排他なしで読み書きすると未定義動作になります。
go test -race/go run -raceで必ず検出する習慣をつけます。 asyncの伝染がない: 関数に色がないため、同期関数をそのまま goroutine で呼べます。逆にライブラリ側が「非同期版」を用意する必要もありません。- 取り消しは自分で実装する: goroutine を外部から強制停止する手段はありません。
context.Contextや専用のdoneチャネルを監視して自分で終了させます。
goroutine リークの防止
// 悪い例: ctx がキャンセルされても、送信先が詰まると goroutine が残る
func leak(ctx context.Context, out chan<- int) {
go func() {
out <- compute() // 受信者がいなければ永遠にブロック
}()
}
// 良い例: 送信も select でキャンセルに対応させる
func noLeak(ctx context.Context, out chan<- int) {
go func() {
select {
case out <- compute():
case <-ctx.Done():
}
}()
}
指針:
- goroutine を起動するときは「いつ・どうやって終了するか」を必ず決めておく
- 終了条件 (チャネルのクローズ、
ctx.Done()) をselectで監視する - 結果を 1 つだけ返す場合は、バッファ 1 のチャネルにして送信側が詰まらないようにする
7. コード例
C# の async/await
public async Task<IReadOnlyList<string>> FetchAllAsync(
IEnumerable<string> urls, CancellationToken ct)
{
var tasks = urls.Select(url => _http.GetStringAsync(url, ct));
return await Task.WhenAll(tasks);
}
Go: WaitGroup と結果の集約
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
results := make([]string, len(urls))
g, ctx := errgroup.WithContext(ctx)
for i, url := range urls {
g.Go(func() error {
body, err := fetch(ctx, url)
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
}
results[i] = body // 各 goroutine が別のインデックスにだけ書き込むので競合しない
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
func fetch(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return string(b), err
}
Go: チャネルによるパイプライン (Channel<T> の生産者/消費者)
func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // 送信側がクローズする
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for v := range square(ctx, generate(ctx, 1, 2, 3, 4)) {
fmt.Println(v)
}
}
C# では Channel.CreateUnbounded<int>() と await foreach (var x in reader.ReadAllAsync()) で実現する構造と同じですが、Go では async や await の記述は不要で、for ... range がそのまま使えます。
C# の Channel<T> との比較
var channel = Channel.CreateBounded<int>(10);
var producer = Task.Run(async () =>
{
for (var i = 0; i < 5; i++)
await channel.Writer.WriteAsync(i);
channel.Writer.Complete();
});
await foreach (var item in channel.Reader.ReadAllAsync())
Console.WriteLine(item);
ch := make(chan int, 10)
go func() {
defer close(ch)
for i := range 5 {
ch <- i
}
}()
for item := range ch {
fmt.Println(item)
}
8. 使い分けの指針
| 状況 | 推奨 |
|---|---|
| 共有カウンタ・キャッシュなど単純な状態保護 | sync.Mutex / sync/atomic |
| 値の受け渡し、パイプライン、ワーカー間の協調 | チャネル |
| 複数処理の待ち合わせ | sync.WaitGroup / errgroup |
| タイムアウト・キャンセル | context + select |
| 1 回だけ実行 | sync.Once |
「チャネルを使わなければならない」わけではありません。状態を守るだけなら Mutex のほうが単純なことも多いです。より実践的なパターンは並行処理パターンを参照してください。
9. まとめ
- goroutine は
goを付けるだけで起動できる軽量な実行単位で、数万個規模でも扱える。async/awaitは不要で、同期的なコードのまま並行化できる。 - チャネルは goroutine 間の通信手段。バッファなしは同期点、バッファ付きはキューとして働く。クローズは送信側が行う。
selectで複数のチャネル操作、タイムアウト、ctx.Done()によるキャンセルを統一的に扱える。- goroutine は自動では待ってくれず、外部から停止もできない。終了条件を設計し、
WaitGroup/errgroupで待ち、-raceでデータ競合を検出する。