Go가 기존 스레딩 모델보다 동시성을 더 잘 처리하는 방법
현대 소프트웨어 엔지니어링에서 여러 작업을 동시에 수행할 수 있는 애플리케이션을 구축하는 것은 더 이상 사치가 아니라 핵심 요구 사항입니다. 처리량이 높은 웹 서버부터 실시간 스트리밍 서비스까지, 동시성은 성능의 핵심입니다.
수십 년 동안 C++, Java, Python과 같은 기존 프로그래밍 언어는 운영 체제의 기본 스레딩 모델을 사용하여 동시 작업을 처리했습니다. 그러나 구글이 2000년대 후반에 Go(Golang)를 디자인했을 때 그들은 근본적으로 다른 길을 택했습니다. Go는 원시 OS 스레드를 노출하는 대신 Goroutines 및 특수한 M:N Scheduler를 도입했습니다.
이 기사에서는 기존 스레딩 모델의 아키텍처적 한계를 검토하고 Go의 동시성 설계가 훨씬 더 효율적이고 확장 가능하며 개발자 친화적인 이유를 살펴보겠습니다.
1. 기존 스레딩의 병목 현상(1:1 모델)
대부분의 기존 런타임 시스템은 1:1 스레딩 모델을 사용합니다. 이 모델에서는 사용자 공간 코드에서 생성된 모든 스레드가 운영 체제(OS)에서 관리하는 하나의 커널 공간 스레드에 직접 매핑됩니다.
이 1:1 매핑은 간단하지만 세 가지 중요한 병목 현상을 발생시킵니다.
A. 메모리 오버헤드(대형 스택 크기)
OS 스레드는 무거운 리소스입니다. 기본적으로 운영 체제는 고정된 연속 스택 크기를 각 스레드에 할당합니다(일반적으로 1MB~8MB).
- 10,000개의 동시 연결을 처리하려면 10,000개의 OS 스레드를 할당하려면 스레드 스택 메모리에만 10GB~80GB의 RAM이 필요합니다.
- 이로 인해 높은 연결 수에서 높은 동시성을 처리하는 것은 매우 비용이 많이 들고 표준 서버 하드웨어에서는 사실상 불가능합니다.
B. 높은 컨텍스트 전환 비용
OS가 한 스레드에서 다른 스레드로 실행을 전환할 때 컨텍스트 전환을 수행합니다. OS 스레드는 커널에 의해 관리되므로 컨텍스트 전환을 위해서는 사용자-커널 경계를 넘어야 합니다. CPU는 다음을 수행해야 합니다.
- 현재 레지스터의 상태를 저장합니다.
- CPU 캐시 라인을 플러시하고 페이지 테이블(변환 참조 버퍼)을 업데이트합니다.
- 커널 모드로 이동하여 다음 스레드를 선택하고 저장된 상태를 로드합니다.
- 사용자 모드로 다시 전환합니다.
이 전체 왕복에는 약 1~2마이크로초가 소요됩니다. 이는 실제 비즈니스 논리를 실행하는 대신 관리 오버헤드에만 소비되는 수백 또는 수천 개의 CPU 주기를 나타냅니다.
C. OS 스케줄링 제한
OS 스케줄러는 범용입니다. 동일한 일정 휴리스틱을 사용하여 데이터베이스 스레드, UI 스레드 및 경량 네트워크 연결을 처리합니다. 애플리케이션 수준 컨텍스트를 이해하지 못하기 때문에 스레드가 네트워크 소켓에서 차단되었는지, 내부 잠금을 기다리는지 또는 활성 계산을 수행하는지 여부를 기반으로 예약을 최적화할 수 없습니다.
2. Go의 솔루션: M:N 스케줄러(고루틴)
Go는 Goroutines를 도입하고 자체 런타임 스케줄러를 구현하여 OS 스레딩의 한계를 우회합니다. Go는 스레드를 1:1로 매핑하는 대신 M 경량 고루틴이 N 물리적 OS 스레드에 다중화되는 M:N 스케줄링 모델을 사용합니다.
이 아키텍처는 GMP 모델이라고도 하는 세 가지 기본 구조 요소로 관리됩니다.
- G(고루틴): 단일 고루틴을 나타냅니다. 여기에는 고루틴의 실행 스택, 프로그램 카운터 및 상태가 포함됩니다.
G은 OS 스레드가 아닙니다. 이는 초기화하는 데 약 2KB의 메모리만 사용하는 간단한 Go 구조체입니다. - M(머신): 물리적 OS/커널 스레드를 나타냅니다. OS 스케줄러에 의해 관리되며 고루틴의 기계어 명령 실행을 담당합니다.
- P(프로세서): 논리적 프로세서 또는 실행 컨텍스트를 나타냅니다.
P인스턴스 수는 기본적으로 호스트 시스템의 논리적 CPU 코어 수(GOMAXPROCS에 의해 제어됨)로 설정됩니다. Go 코드를 실행하려면M머신이 논리 프로세서P을(를) 획득해야 합니다.
GMP 모델이 우수한 이유
고루틴은 Go 런타임에 의해 사용자 공간에서 완전히 관리되기 때문에:
- 동적 스택: 고루틴은 2KB의 작은 스택으로 시작됩니다. 실행 요구에 따라 스택은 동적으로 증가하고(힙에 더 큰 연속 메모리 세그먼트 할당) 축소됩니다. 이를 통해 Go는 단일 노트북에서 수십만 개의 고루틴을 동시에 실행할 수 있습니다.
- 빠른 컨텍스트 전환: 고루틴 간 전환은 커널 컨텍스트 전환을 호출하지 않고 전적으로 사용자 공간에서 발생합니다. 프로그램 카운터와 일부 CPU 레지스터만 저장됩니다. 이 사용자 공간 컨텍스트 전환에는 10~100나노초만 소요됩니다. 이는 OS 스레드 전환보다 대략 10배~100배 빠릅니다.
3. 작업 도용 및 논블로킹 I/O
Go 런타임은 물리적 CPU 코어의 활용도가 낮아지지 않도록 두 가지 고급 예약 메커니즘, 즉 작업 도용 및 Syscall Hand-off을 사용합니다.
A. 작업 훔치기 알고리즘
각 논리 프로세서 P은 고루틴의 자체 로컬 실행 큐를 유지합니다. 또한 오버플로를 위한 전역 실행 큐가 있습니다.
프로세서 P이 로컬 실행 큐의 모든 고루틴을 소진하면 절전 모드로 전환되지 않습니다. 대신 작업 도용을 수행합니다. 즉, 다른 논리 프로세서를 확인하고 대기열에 있는 고루틴의 절반을 도용하여 모든 CPU 코어에 걸쳐 작업 부하의 균형을 맞춥니다.
Local Queue P1: [ G1, G2, G3 ] ---> Running on Thread M1
Local Queue P2: [ ] ---> Running on Thread M2 (Idle)
P2 steals G3 and G2 from P1!
B. 네트워크 폴러 및 논블로킹 I/O
고루틴이 네트워크 I/O를 수행할 때(예: 데이터베이스 연결에서 읽거나 HTTP 호출하기) Go는 기본 OS 스레드 M을 차단하지 않습니다.
대신 Go 런타임은 전용 Network Poller(Linux의 epoll, macOS의 kqueue, Windows의 IOCP와 같은 효율적인 OS별 멀티플렉싱 API를 사용함)로 블록을 등록합니다. 차단된 고루틴 G은 스레드 M에서 분리되어 네트워크 폴러에 보관됩니다.
스레드 M은 대기열에서 실행 가능한 또 다른 고루틴을 즉시 선택합니다. I/O 이벤트가 완료되면 네트워크 폴러는 G을(를) 활성 실행 대기열로 다시 이동하여 실행을 재개합니다.
4. 채널과 공유 메모리(CSP 모델)
기존 스레딩 모델은 메모리 공유(예: 스레드 간에 포인터 전달)를 통해 동시 작업을 조정합니다. 데이터 경합을 방지하려면 개발자가 잠금, 뮤텍스 및 조건 변수를 수동으로 관리해야 합니다.
// Traditional Java Shared Memory Approach
synchronized(lock) {
sharedResource.updateState();
}
이 모델은 오류가 발생하기 쉬우며 교착 상태, 경쟁 조건 및 캐시 일관성 병목 현상이 자주 발생합니다.
Go는 유명한 Golang 속담으로 요약된 CSP(Communicating Sequential Processes) 공식 모델을 구현합니다.
“메모리를 공유하여 통신하지 말고, 대신 통신으로 메모리를 공유하세요.”
Go는 채널을 최고 수준의 프리미티브로 제공합니다. 채널은 고루틴이 실행을 동기화하고 데이터 소유권을 이전하기 위해 메시지를 보내고 받을 수 있도록 하는 유형 안전 대기열 역할을 합니다.
5. 실제 구현: 고루틴 및 채널 작동
다음은 여러 작업자 고루틴이 작업을 동시에 처리하고 채널을 통해 결과를 반환하여 잠금 없이 안전하게 데이터 흐름을 관리하는 방법을 보여주는 실용적인 Go 프로그램입니다.
package main
import (
"fmt"
"time"
)
// Task represents a unit of work
type Task struct {
ID int
Duration time.Duration
}
// Result represents the outcome of a processed task
type Result struct {
TaskID int
Value string
}
// worker processes incoming tasks from the jobs channel and sends results
func worker(id int, jobs <-chan Task, results chan<- Result) {
for job := range jobs {
fmt.Printf("Worker %d: Started processing job %d\n", id, job.ID)
time.Sleep(job.Duration) // Simulate CPU/IO delay
results <- Result{
TaskID: job.ID,
Value: fmt.Sprintf("Processed by worker %d in %v", id, job.Duration),
}
}
}
func main() {
// Create tasks
tasks := []Task{
{ID: 101, Duration: 200 * time.Millisecond},
{ID: 102, Duration: 400 * time.Millisecond},
{ID: 103, Duration: 100 * time.Millisecond},
{ID: 104, Duration: 300 * time.Millisecond},
}
// Buffered channels prevent sender blocking
jobs := make(chan Task, len(tasks))
results := make(chan Result, len(tasks))
// Spawn 3 concurrent worker goroutines
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Feed tasks into the job channel
for _, task := range tasks {
jobs <- task
}
close(jobs) // Closing tells workers no more jobs are coming
// Collect and aggregate results
for i := 0; i < len(tasks); i++ {
res := <-results
fmt.Printf("[RESULT] Job %d: %s\n", res.TaskID, res.Value)
}
close(results)
}
6. 아키텍처 비교: OS 스레드와 고루틴
| 측정항목/기능 | 기존 OS 스레드(1:1 모델) | Go 고루틴(M:N 모델) |
|---|---|---|
| 시작 메모리 | 고정(일반적으로 1MB - 8MB) | 동적(~2KB에서 시작) |
| 컨텍스트 전환 시간 | 느림(1,000 - 2,000나노초) | 빠름(10~100나노초) |
| 스위칭 공간 | 커널 공간(과중한 컨텍스트 전환) | 사용자 공간(런타임 스케줄러) |
| 제작 비용 | 비용이 많이 든다(OS 시스템 호출 포함) | 매우 저렴함(간단 할당) |
| 커뮤니케이션 | 공유 메모리(뮤텍스, 세마포어) | 채널(CSP 메시지 전달) |
| 교착상태 위험 | 높음(수동으로 추적이 어려움) | 채널 디자인 및 컴파일 런타임 감지로 완화됨 |
결론
Go는 운영 체제의 원시 스레딩 모델에서 동시성을 분리함으로써 최신 백엔드 아키텍처의 근본적인 확장 문제를 해결했습니다.
고루틴은 최소한의 메모리 오버헤드로 대규모 동시성을 가능하게 하고, M:N 스케줄러는 커널 컨텍스트 전환 비용 없이 작업 도용을 통해 CPU 활용도를 최적화하며, 채널은 안전하고 표현력이 풍부한 동시성 패러다임을 제공합니다.
마이크로서비스를 구축하든 대규모 분산 시스템을 구축하든 Go에 내장된 동시성 아키텍처는 애플리케이션의 응답성, 리소스 효율성 및 유지 관리 용이성을 보장합니다.