メインコンテンツまでスキップ

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) 上で多重化して実行します。

項目goroutineOS スレッド (.NET Thread)
初期スタックサイズ約 2KB (必要に応じて伸縮)通常 1MB 以上 (OS・設定による)
生成コスト非常に小さい大きい
スケジューラGo ランタイム (協調 + プリエンプション)OS
ブロッキング I/Oランタイムが他の goroutine へ切り替えるスレッドを占有する

I/O 待ちで goroutine がブロックしても、ランタイムが別の goroutine を実行するため、「ブロッキング呼び出しを避けるための async/await」自体が不要です。

2. .NET との対応表​

.NET (C#)Go
Task / async / awaitgoroutine (同期的な書き方のまま並行実行)
Task.Run(() => ...)go func() { ... }()
Task<T> の結果取得チャネルで結果を受け取る / sync.WaitGroup
Task.WhenAllsync.WaitGroup / errgroup.Group
Task.WhenAnyselect
Channel<T> (System.Threading.Channels)chan T
lock / Monitorsync.Mutex
ReaderWriterLockSlimsync.RWMutex
Interlockedsync/atomic
CancellationTokencontext.Context
Task.Delaytime.Sleep / time.After / time.NewTimer
Parallel.ForEachgoroutine + 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# 開発者がつまずきやすいポイント​

  1. goroutine に戻り値はない: Task<T> のように結果を持つ型はありません。結果はチャネルや共有変数 (排他制御付き) で受け取ります。
  2. goroutine は勝手に待ってくれない: main が終了すると、実行中の goroutine は即座に強制終了されます。WaitGroup などで明示的に待ちます。
  3. 例外は goroutine をまたいで伝わらない: goroutine 内の panic は recover されない限りプロセス全体を落とします。エラーはチャネルや errgroup で集約します。
  4. goroutine リーク: 受信者のいないチャネルへ送信し続ける、永遠に閉じられないチャネルを待ち続ける、といったケースで goroutine が終了せず残り続けます。.NET の Task と違い GC されません。
  5. デッドロック検出: 全 goroutine がブロックすると、ランタイムが fatal error: all goroutines are asleep - deadlock! で落ちます。ただし HTTP サーバーなど他の goroutine が動いていると検出されません。
  6. ループ変数のキャプチャ: Go 1.21 以前は全反復で同じ変数を共有し、意図しない値を参照するバグがよくありました。go.mod の go ディレクティブが 1.22 以上なら解消されています。
  7. データ競合: 複数の goroutine から排他なしで読み書きすると未定義動作になります。go test -race / go run -race で必ず検出する習慣をつけます。
  8. async の伝染がない: 関数に色がないため、同期関数をそのまま goroutine で呼べます。逆にライブラリ側が「非同期版」を用意する必要もありません。
  9. 取り消しは自分で実装する: 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 でデータ競合を検出する。

参考資料​