고(Go)가 실행할 수 있는 최대 Goroutine 수는 얼마일까요? 리소스 제한에 대한 심층 분석
Takashi Yamamoto
Infrastructure Engineer · Leapcell

고(Go)가 실행할 수 있는 최대 Goroutine 수는 얼마일까요? 리소스 제한에 대한 심층 분석
최대로 생성할 수 있는 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. 하나의 Goroutine만 작업을 실행하도록 보장
인터페이스에서 동시성이 필요한 경우 Goroutine 수는 애플리케이션 수준에서 관리해야 합니다. 예를 들어 Goroutine이 한 번만 초기화해야 하는 리소스를 초기화하는 데 사용되는 경우 여러 Goroutine이 동시에 이 작업을 수행하도록 허용할 필요가 없습니다. running
플래그를 사용하여 초기화가 이미 진행 중인지 여부를 확인할 수 있습니다.
// SingerConcurrencyRunner는 하나의 작업만 실행되도록 보장합니다. 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에 불과하며 이는 기본적으로 무시할 수 있습니다.
Leapcell은 Rust 프로젝트 호스팅을 위한 최고의 선택입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하세요.
무료로 무제한 프로젝트 배포
- 사용량에 대해서만 지불하세요. 요청도 없고 수수료도 없습니다.
타의 추종을 불허하는 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불하세요.
- 예: $25는 평균 응답 시간 60ms에서 694만 개의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장이 가능합니다.
- 운영 오버헤드가 전혀 없습니다. 빌드에만 집중하세요.
설명서에서 자세히 알아보세요!
X에서 저희를 팔로우하세요: @LeapcellHQ