GoでいくつのGoroutineを実行できるか?リソース制限への深い冒険
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Goroutineを最大でいくつ作成できるかを理解するために、まず以下の質問を明確にする必要があります。
- Goroutineとは何か?
- どのようなリソースを消費するのか?
Goroutineとは何か?
Goroutineは、Goによって抽象化された軽量スレッドです。アプリケーションレベルでスケジューリングを実行し、並行プログラミングを容易に行えるようにします。
Goroutineはgo
キーワードを使用して起動できます。コンパイラは、このキーワードをcmd/compile/internal/gc.state.stmt
およびcmd/compile/internal/gc.state.call
メソッドを使用してruntime.newproc
関数呼び出しに変換します。
タスクを実行するために新しいGoroutineを起動するとき、runtime.newproc
を使用してコルーチンを実行するためのg
を初期化します。
Goroutineはどのくらいのリソースを消費するのか?
メモリ消費量
Goroutineを起動してブロックすることで、消費量を評価するために前後でメモリの変化を観察できます。
func getGoroutineMemConsume() { var c chan int var wg sync.WaitGroup const goroutineNum = 1000000 memConsumed := func() uint64 { runtime.GC() // オブジェクトの影響を除外するためにGCをトリガー var memStat runtime.MemStats runtime.ReadMemStats(&memStat) return memStat.Sys } noop := func() { wg.Done() <-c // Goroutineが終了してメモリを解放するのを防ぐ } wg.Add(goroutineNum) before := memConsumed() // Goroutineを作成する前のメモリ for i := 0; i < goroutineNum; i++ { go noop() } wg.Wait() after := memConsumed() // Goroutineを作成した後のメモリ fmt.Println(runtime.NumGoroutine()) fmt.Printf("%.3f KB bytes\n", float64(after-before)/goroutineNum/1024) }
結果分析:
各Goroutineは少なくとも2KBのスペースを消費します。コンピュータに2GBのメモリがあると仮定すると、同時に存在できるGoroutineの最大数は2GB / 2KB = 100万です。
CPU消費量
Goroutineが使用するCPUの量は、実行する関数のロジックに大きく依存します。関数がCPUを大量に消費する計算を含み、長時間実行される場合、CPUはすぐにボトルネックになります。
同時に実行できるGoroutineの数は、プログラムが行っていることによって異なります。タスクがメモリを大量に消費するネットワーク操作である場合、ほんの少数のGoroutineでプログラムがクラッシュする可能性があります。
結論
実行できるGoroutineの数は、それらの中で実行される操作のCPUとメモリの消費量に依存します。操作が最小限である場合(つまり、何もしない場合)、最初にメモリがボトルネックになります。この場合、2GBのメモリが使い果たされると、プログラムはエラーをスローします。操作がCPUを大量に消費する場合、わずか2つまたは3つのGoroutineでプログラムが失敗する可能性があります。
過剰なGoroutineによって引き起こされる一般的な問題
- too many open files – ファイルまたはソケットハンドルが開きすぎているために発生します。
- out of memory
ビジネスシナリオでの応用
同時実行Goroutineの数を制御する方法は?
runtime.NumGoroutine()
を使用して、アクティブなGoroutineの数を監視できます。
1. 1つのGoroutineのみがタスクを実行するようにする
インターフェースで同時実行が必要な場合、Goroutineの数はアプリケーションレベルで管理する必要があります。たとえば、Goroutineが一度だけ初期化する必要があるリソースを初期化するために使用される場合、複数のGoroutineがこれを同時に実行できるようにする必要はありません。 running
フラグを使用して、初期化がすでに進行中かどうかを判断できます。
// SingerConcurrencyRunnerは、1つのタスクのみが実行されていることを保証します type SingerConcurrencyRunner struct { isRunning bool sync.Mutex } func NewSingerConcurrencyRunner() *SingerConcurrencyRunner { return &SingerConcurrencyRunner{} } func (c *SingerConcurrencyRunner) markRunning() (ok bool) { c.Lock() defer c.Unlock() // レースコンディションを回避するための二重チェック if c.isRunning { return false } c.isRunning = true return true } func (c *SingerConcurrencyRunner) unmarkRunning() (ok bool) { c.Lock() defer c.Unlock() if !c.isRunning { return false } c.isRunning = false return true } func (c *SingerConcurrencyRunner) Run(f func()) { // すでに実行中の場合はすぐに戻り、メモリの過剰使用を回避します if c.isRunning { return } if !c.markRunning() { // 実行フラグを取得できない場合は戻ります return } // 実際のロジックを実行します go func() { defer func() { if err := recover(); err != nil { // エラーをログに記録します } }() f() c.unmarkRunning() }() }
信頼性テスト:2つ以上のGoroutineが実行されているかどうかを確認します
func TestConcurrency(t *testing.T) { runner := NewConcurrencyRunner() for i := 0; i < 100000; i++ { runner.Run(f) } } func f() { // これが許可されたGoroutineの数を超えることはありません if runtime.NumGoroutine() > 3 { fmt.Println(">3", runtime.NumGoroutine()) } }
2. 同時実行Goroutineの数を指定する
他のGoroutineは、タイムアウトで待機するように設定するか、待機する代わりに古いデータを使用するようにフォールバックできます。
Tunnyを使用すると、Goroutineの数を制御できます。すべてのWorker
がビジーの場合、WorkRequest
はすぐに処理されませんが、reqChan
にキューイングされて可用性を待ちます。
func (w *workerWrapper) run() { //... for { // 注:ここでブロックすると、ワーカーがシャットダウンできなくなります。 w.worker.BlockUntilReady() select { case w.reqChan <- workRequest{ jobChan: jobChan, retChan: retChan, interruptFunc: w.interrupt, }: select { case payload := <-jobChan: result := w.worker.Process(payload) select { case retChan <- result: case <-w.interruptChan: w.interruptChan = make(chan struct{}){} } //... } } //... }
ここでの実装では、常駐Goroutineを使用します。 Size
が変更されると、タスクを処理するために新しいWorker
が作成されます。別の実装アプローチは、chan
を使用してGoroutineを開始できるかどうかを制御することです。バッファがいっぱいの場合、タスクを処理するために新しいGoroutineは開始されません。
type ProcessFunc func(ctx context.Context, param interface{}) type MultiConcurrency struct { ch chan struct{} f ProcessFunc } func NewMultiConcurrency(size int, f ProcessFunc) *MultiConcurrency { return &MultiConcurrency{ ch: make(chan struct{}, size), f: f, } } func (m *MultiConcurrency) Run(ctx context.Context, param interface{}) { // バッファがいっぱいの場合は入らないでください m.ch <- struct{}{} go func() { defer func() { // バッファ内のスロットを解放します <-m.ch if err := recover(); err != nil { fmt.Println(err) } }() m.f(ctx, param) }() }
Goroutineの数が13を超えないようにするためのテスト:
func mockFunc(ctx context.Context, param interface{}) { fmt.Println(param) } func TestNewMultiConcurrency_Run(t *testing.T) { concurrency := NewMultiConcurrency(10, mockFunc) for i := 0; i < 1000; i++ { concurrency.Run(context.Background(), i) if runtime.NumGoroutine() > 13 { fmt.Println("goroutine", runtime.NumGoroutine()) } } }
このアプローチでは、システムはメモリ内に多くの実行中のGoroutineを保持する必要はありません。ただし、100個のGoroutineが常駐している場合でも、メモリ使用量はわずか2KB×100 = 200KBであり、これは基本的に無視できます。
We are Leapcell, your top choice for hosting Rust projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ