Go技術面接:Goroutine、Channel、並行処理パターン完全ガイド

Go技術面接で頻出のgoroutine、channel、並行処理に関する質問を網羅。本番レベルのコード例と各回答の背景にある設計思想を2026年の面接対策として解説します。

Go技術面接対策 goroutine channel 並行処理パターン

Goの技術面接において、goroutine、channel、並行処理に関する質問は最も難度が高いトピックとして知られています。これらの概念を深く理解していることが、シニアGoエンジニアと学習段階の開発者を分ける決定的な要素です。本ガイドでは、2026年の面接で実際に問われる質問を、本番品質のコード例と回答の根拠とともに解説します。

面接官が実際にテストするポイント

Goの並行処理面接では、goroutineのライフサイクル管理、channelのセマンティクス(バッファ付き vs バッファなし、方向型)、パターンの組み合わせ(fan-out/fan-in、ワーカープール、contextによるキャンセル)の3つの領域が重点的に評価されます。構文の暗記では不十分であり、レースコンディションやデッドロックについて論理的に推論できることが求められます。

Goroutineの基礎:面接で必ず問われる質問

面接の最初の段階では、goroutineが実際に何であるかを候補者が理解しているかどうかが確認されます。単に起動方法を知っているだけでは不十分です。

Q:goroutineとは何ですか。OSスレッドとの違いを説明してください。

goroutineは、OSではなくGoランタイムスケジューラによって管理される軽量な並行関数です。Goランタイムは、M:Nスケジューリングモデル(M個のgoroutineをN個のOSスレッドにマッピング)を使用して、数千のgoroutineを少数のOSスレッド上で多重化します。

goroutine_basics.gogo
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	// Print the number of OS threads available
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

	var wg sync.WaitGroup

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// Each goroutine starts with ~2-8KB stack
			// OS threads typically start with 1-8MB
			_ = id
		}(i)
	}

	wg.Wait()
	fmt.Println("All 10,000 goroutines completed")
}

面接で言及すべき主要な違いは以下の通りです。goroutineは2-8KBの動的に成長するスタックで開始されるのに対し、OSスレッドは1-8MBの固定スタックを持ちます。goroutine間のコンテキストスイッチはGoスケジューラによってユーザー空間で処理され、OSスレッドのカーネルレベルのコンテキストスイッチを回避します。このため、10万のgoroutineを起動することは現実的ですが、10万のOSスレッドではシステムリソースが枯渇します。

Q:goroutineがpanicした場合、何が起きますか。

goroutine内で回復されないpanicは、プログラム全体をクラッシュさせます。JavaやPythonの例外とは異なり、panicはそのgoroutine自身のコールスタックを遡ります(起動元のgoroutineのスタックではありません)。これを捕捉する唯一の方法は、同じgoroutine内のdeferred関数内でrecover()を使用することです。

panic_recovery.gogo
package main

import "fmt"

func safeGo(fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				fmt.Println("recovered from panic:", r)
			}
		}()
		fn() // execute the actual work
	}()
}

func main() {
	safeGo(func() {
		panic("something went wrong")
	})

	// Give goroutine time to complete
	select {}
}

面接官は、本番環境のGoサービスではgoroutineの起動をリカバリパターンでラップしていることへの認識を求めます。errgroupのようなライブラリを使用すれば、より洗練された処理が可能です。

Channelのセマンティクス:バッファ付き、バッファなし、方向型

channelに関する質問は、候補者がGoの並行処理モデルを本当に理解しているのか、単にパターンを暗記しているだけなのかを明らかにします。

Q:バッファ付きchannelとバッファなしchannelの違いは何ですか。

バッファなしchannel(make(chan T))は、送信側と受信側が同時に準備完了している必要があります。送信は、別のgoroutineが受信するまでブロックされます。バッファ付きchannel(make(chan T, n))は、ブロックされることなく最大n個の値を送信できます。

channel_semantics.gogo
package main

import "fmt"

func main() {
	// Unbuffered: send blocks until receive is ready
	ch := make(chan string)
	go func() {
		ch <- "hello" // blocks here until main reads
	}()
	msg := <-ch
	fmt.Println(msg)

	// Buffered: send does not block until buffer is full
	buf := make(chan int, 3)
	buf <- 1 // does not block (buffer has space)
	buf <- 2 // does not block
	buf <- 3 // does not block
	// buf <- 4 would block — buffer is full

	fmt.Println(<-buf, <-buf, <-buf) // 1 2 3
}

面接官がよく追加で尋ねるのは「どちらをいつ選択しますか」という質問です。バッファなしchannelは同期を強制するため、送信側が受信側の処理完了を確認する必要がある場合に適しています。バッファ付きchannelは送信側と受信側のタイミングを分離するため、ワークキューやレート制限など多少の遊びが許容される場面に適しています。

Q:channelをcloseすると何が起きますか。

channelをcloseすると、これ以上値が送信されないことを通知します。closeされたchannelからの受信はゼロ値を即座に返します。closeされたchannelへの送信はpanicを引き起こします。rangeループはchannelがcloseされると終了します。

close_channel.gogo
package main

import "fmt"

func producer(ch chan<- int, count int) {
	for i := 0; i < count; i++ {
		ch <- i
	}
	close(ch) // signal: no more values
}

func main() {
	ch := make(chan int, 5)
	go producer(ch, 5)

	// range exits automatically when channel closes
	for val := range ch {
		fmt.Println("received:", val)
	}

	// Reading from closed channel returns zero value + false
	val, ok := <-ch
	fmt.Printf("after close: val=%d, ok=%v\n", val, ok)
}

重要な点として、channelをcloseするのは送信側のみであり、受信側がcloseしてはいけません。別のgoroutineがまだ書き込み中のchannelをcloseすると、panicが発生します。

Goの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

select文:channelの多重化

Q:selectはどのように動作しますか。複数のcaseが同時に準備完了した場合はどうなりますか。

select文は、いずれかのchannel操作が実行可能になるまでブロックします。複数のcaseが同時に準備完了している場合、Goはランダムに1つを選択します。これにより、特定のcaseが枯渇することを防ぎます。

select_multiplex.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

func fetchFromAPI(ctx context.Context, url string) (string, error) {
	resultCh := make(chan string, 1)
	errCh := make(chan error, 1)

	go func() {
		// Simulate API call
		time.Sleep(200 * time.Millisecond)
		resultCh <- fmt.Sprintf("data from %s", url)
	}()

	select {
	case result := <-resultCh:
		return result, nil
	case err := <-errCh:
		return "", err
	case <-ctx.Done():
		// Context cancelled or timed out
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	result, err := fetchFromAPI(ctx, "https://api.example.com/data")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(result)
}

面接官がselectでテストするのは2点です。ランダム選択ルールの理解と、channelをcontext.Contextと組み合わせてタイムアウトやキャンセルパターンを実装する能力です。

面接で問われる並行処理パターン

シニアレベルのGo面接では、以下のパターンのいずれかをゼロから実装する問題がほぼ必ず出題されます。

Fan-Out/Fan-Inパターン

Q:アイテムを並行処理するfan-out/fan-inパイプラインを実装してください。

fan-outは複数のgoroutineに作業を分散します。fan-inは複数のgoroutineからの結果を1つのchannelに集約します。

fanout_fanin.gogo
package main

import (
	"fmt"
	"sync"
)

// generator produces values on a channel
func generator(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

// square reads from input, squares each value
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n
		}
		close(out)
	}()
	return out
}

// fanIn merges multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	merged := make(chan int)

	for _, ch := range channels {
		wg.Add(1)
		go func(c <-chan int) {
			defer wg.Done()
			for val := range c {
				merged <- val
			}
		}(ch)
	}

	go func() {
		wg.Wait()
		close(merged) // close after all inputs are drained
	}()

	return merged
}

func main() {
	in := generator(2, 3, 4, 5, 6)

	// Fan out: two goroutines reading from same channel
	c1 := square(in)
	c2 := square(in)

	// Fan in: merge results
	for result := range fanIn(c1, c2) {
		fmt.Println(result)
	}
}

面接官が求める重要な洞察は次の点です。generatorのchannelはc1c2の間で共有されるため、各値は正確に1つのワーカーによって処理されます(重複しません)。fanIn関数はWaitGroupを使用して、全入力channelが排出されたことを確認してからマージされたchannelをcloseします。

errgroupによるワーカープール

Q:エラーハンドリング付きの境界付きワーカープールをどのように実装しますか。

golang.org/x/sync/errgroupパッケージ(Go拡張標準ライブラリの一部)は、この問題を簡潔に解決します。goroutineのライフサイクル管理、最初のエラーの収集、contextとの統合によるキャンセルを処理します。

worker_pool.gogo
package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
)

func processItem(ctx context.Context, id int) error {
	// Check for cancellation before heavy work
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if id == 7 {
		return fmt.Errorf("failed to process item %d", id)
	}
	fmt.Printf("processed item %d\n", id)
	return nil
}

func main() {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(3) // maximum 3 concurrent goroutines

	for i := 0; i < 10; i++ {
		id := i
		g.Go(func() error {
			return processItem(ctx, id)
		})
	}

	// Wait blocks until all goroutines finish
	// Returns the first non-nil error
	if err := g.Wait(); err != nil {
		fmt.Println("pipeline error:", err)
	}
}

2026年初頭時点の現行安定版であるGo 1.24でも、このパターンが推奨アプローチです。SetLimitメソッドはGo 1.20で追加され、セマフォベースの並行性制限を手動で実装する必要がなくなりました。

レースコンディションとsyncプリミティブ

Q:Goでレースコンディションをどのように検出・防止しますか。

Goには、-raceフラグで有効化される組み込みのレースディテクタが用意されています。実行時に共有メモリへの非同期アクセスを検出します。

race_condition.gogo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// BAD: race condition — do not use in production
func unsafeCounter() int {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // DATA RACE: concurrent read/write
		}()
	}
	wg.Wait()
	return counter // result is non-deterministic
}

// GOOD: atomic operations for simple counters
func atomicCounter() int64 {
	var counter atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add(1) // thread-safe atomic increment
		}()
	}
	wg.Wait()
	return counter.Load() // always 1000
}

// GOOD: mutex for complex shared state
type SafeMap struct {
	mu sync.RWMutex
	data map[string]int
}

func (m *SafeMap) Set(key string, val int) {
	m.mu.Lock()         // exclusive lock for writes
	defer m.mu.Unlock()
	m.data[key] = val
}

func (m *SafeMap) Get(key string) (int, bool) {
	m.mu.RLock()         // shared lock for reads
	defer m.mu.RUnlock()
	v, ok := m.data[key]
	return v, ok
}

func main() {
	fmt.Println("unsafe:", unsafeCounter())  // unpredictable
	fmt.Println("atomic:", atomicCounter())  // always 1000
}

面接での回答は3つの同期戦略をカバーする必要があります。複雑な共有状態にはsync.Mutex/sync.RWMutex、単純なカウンタやフラグにはsync/atomic、goroutine間の通信にはchannel(「メモリを共有して通信するのではなく、通信によってメモリを共有する」)です。go test -race ./...の実行は、すべてのCIパイプラインに含まれるべきです。

Contextとキャンセルパターン

Q:context.Contextがgoroutineのライフサイクルをどのように制御するか説明してください。

contextパッケージは、キャンセルシグナル、デッドライン、リクエストスコープの値をgoroutine間で伝播するメカニズムを提供します。長時間実行されるすべてのgoroutineは、最初のパラメータとしてcontext.Contextを受け取る必要があります。

context_cancellation.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

// worker simulates a long-running task
func worker(ctx context.Context, id int, results chan<- string) {
	select {
	case <-time.After(time.Duration(id*100) * time.Millisecond):
		results <- fmt.Sprintf("worker %d: done", id)
	case <-ctx.Done():
		results <- fmt.Sprintf("worker %d: cancelled (%v)", id, ctx.Err())
	}
}

func main() {
	// Parent context with 250ms deadline
	ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
	defer cancel()

	results := make(chan string, 5)

	// Launch 5 workers with increasing durations
	for i := 1; i <= 5; i++ {
		go worker(ctx, i, results)
	}

	// Collect all results
	for i := 0; i < 5; i++ {
		fmt.Println(<-results)
	}
}

ワーカー1と2は250msのデッドライン内に完了します。ワーカー3、4、5はctx.Done()を通じてキャンセルシグナルを受信します。このパターンは、GoでレジリエントなHTTPサーバーやマイクロサービスを構築する上で基本となるものです。すべてのリクエストハンドラは、クライアントが切断した際にキャンセルが伝播されるcontextを受け取ります。

面接でよくある落とし穴

context.Contextを構造体のフィールドに格納してはいけません。Go公式ドキュメントには「Contextを構造体型の内部に格納しないでください。代わりに、Contextを必要とする各関数に明示的に渡してください」と明記されています。面接官はこの質問で候補者がGoの慣例に従っているかどうかを確認します。

デッドロック検出:面接の難問

Q:このコードはデッドロックしますか。なぜですか。

デッドロックの質問は、候補者がgoroutineのスケジューリングとchannel操作について推論する能力をテストするため、頻出です。

deadlock_example.gogo
package main

func main() {
	ch := make(chan int)
	ch <- 42 // DEADLOCK: unbuffered send with no receiver
	// The main goroutine blocks here forever
	// Go runtime detects this: "fatal error: all goroutines are asleep"
}

修正は単純です。channelをバッファ付きにする(make(chan int, 1))か、送信前に受信用のgoroutineを起動します。Goランタイムは全goroutineがブロックされた場合にデッドロックを検出しますが、goroutineがスリープ状態の場合のみです。1つでもgoroutineが動作中(例:バックグラウンドHTTPサーバー)であれば、ランタイムは部分的なデッドロックを検出しません。

部分的なデッドロックは検出されない

Goランタイムがデッドロックを検出するのは、プログラム内の全goroutineがブロックされた場合のみです。HTTPサーバーやバックグラウンドワーカーを持つ実際のアプリケーションでは、デッドロック状態のgoroutineリークはランタイムディテクタでは検出されません。本番環境でこれらの問題を診断するには、pprofやgoroutineダンプ(runtime.Stack)などのツールが必要です。

応用パターン:レート制限付き並行処理

Q:レート制限付きの並行APIコールをどのように実装しますか。

この質問は、複数の並行処理プリミティブを統合的なソリューションに組み合わせる能力をテストします。

rate_limited.gogo
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

// RateLimiter controls concurrent and temporal access
type RateLimiter struct {
	semaphore chan struct{}   // limits concurrency
	ticker    *time.Ticker    // limits rate
}

func NewRateLimiter(maxConcurrent int, interval time.Duration) *RateLimiter {
	return &RateLimiter{
		semaphore: make(chan struct{}, maxConcurrent),
		ticker:    time.NewTicker(interval),
	}
}

func (rl *RateLimiter) Execute(ctx context.Context, fn func() error) error {
	// Wait for rate limit tick
	select {
	case <-rl.ticker.C:
	case <-ctx.Done():
		return ctx.Err()
	}

	// Acquire concurrency slot
	select {
	case rl.semaphore <- struct{}{}:
	case <-ctx.Done():
		return ctx.Err()
	}

	defer func() { <-rl.semaphore }() // release slot
	return fn()
}

func main() {
	rl := NewRateLimiter(3, 100*time.Millisecond)
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			err := rl.Execute(ctx, func() error {
				fmt.Printf("[%v] processing %d\n", time.Now().Format("04:05.000"), id)
				time.Sleep(150 * time.Millisecond) // simulate work
				return nil
			})
			if err != nil {
				fmt.Printf("item %d: %v\n", id, err)
			}
		}(i)
	}
	wg.Wait()
}

このパターンは、channelベースのセマフォ(並行数制限)とticker(レート制限)を組み合わせています。contextチェック付きの二重selectがグレースフルシャットダウンを保証します。このような本番対応の回答が、シニア候補者を際立たせます。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

まとめ

  • goroutineはM:Nスケジューリングを持つGoランタイム管理のユーザー空間スレッドであり、起動されたgoroutine内では必ずpanicを回復すべきである
  • バッファなしchannelは送信側と受信側を同期させ、バッファ付きchannelはタイミングを分離する。送信側が確認を必要とするかどうかで選択する
  • select文はランダム選択によりchannel操作を多重化する。タイムアウト処理にはcontext.Contextと組み合わせて使用する
  • fan-out/fan-inとワーカープール(errgroup.SetLimitによる)が、面接で最も頻出の並行処理パターンである
  • 複雑な共有状態にはsync.Mutex、単純なカウンタにはsync/atomic、goroutine間通信にはchannelを使用する
  • データレースの検出にはCIでgo test -raceを必ず実行する。部分的なデッドロックの診断にはpprofが必要である
  • context.Contextを構造体に格納せず、最初の関数パラメータとして渡す
  • Goのレート制限はchannelセマフォとtickerを組み合わせ、context対応のselect文でラップする

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#go
#golang
#interview
#goroutines
#channels
#concurrency

共有

関連記事