Golang의 Goroutine
Goroutine
- Golang에서
Goroutine
은 경량 스레드의 일종으로, 함수나 메서드를 동시에 실행하기 위해 사용됩니다. - 일반적인 스레드와는 다르게, 매우 가볍고 고효율적으로 동작하기 때문에 많은 수의
Goroutine
을 동시에 실행할 수 있습니다.Goroutine
은 Go 런타임이 내부적으로 관리하는 작은 작업 단위입니다.
Goroutine 사용 방법
다음과 같이 go
키워드를 사용하여 함수를 goroutine으로 실행할 수 있습니다:
go someFunction()
이 코드를 실행하면 someFunction()
은 별도의 goroutine으로 실행되며, 메인 프로그램과 병렬로 동작하게 됩니다. 중요한 점은, go
키워드로 실행된 함수는 비동기적으로 동작하기 때문에 해당 함수가 언제 완료되는지는 보장되지 않습니다.
Goroutine이 왜 스레드보다 훨씬 가볍다고 하는가?
- Go의
Goroutine
이 스레드보다 훨씬 가볍다고 하는 이유는Goroutine
의 구조와 관리 방식이 운영체제(OS)에서 제공하는 스레드와 다르기 때문입니다. `Goroutine`은 Go 런타임이 관리하는 User **레벨의 경량 스레드로, 여러 가지 기술적 이유로 인해 스레드보다 훨씬 적은 리소스를 사용하고, 더 효율적으로 동작합니다. 그 근거는 다음과 같습니다.
1. 스택 크기의 유연성 (Dynamic Stack Sizing)
- OS 스레드는 초기화될 때 고정된 크기의 스택을 할당받습니다. 일반적으로 1MB 이상의 메모리를 사용하는데, 이 크기는 변경할 수 없습니다. 만약 스레드가 더 많은 스택을 필요로 하면, 새로운 스택을 재할당하거나 크래시가 발생할 수 있습니다.
- 반면에,
Goroutine
은 시작 시 매우 작은 스택(2KB)** 만을 할당받습니다. 필요에 따라 Go 런타임이 스택의 크기를 동적으로 조정하며, 이 과정은 개발자가 직접 관리할 필요가 없습니다. 작은 스택으로 시작해 작업이 커지면 런타임이 스택 크기를 확장하고, 다시 축소하기도 합니다. - 이 유연한 스택 관리 방식 덕분에
Goroutine
은 메모리 사용량을 크게 절감할 수 있습니다. 작은 메모리로 수많은Goroutine
을 실행하는 것이 가능하며, 특히 많은 수의 동시 작업이 필요한 경우Goroutine
의 효율성이 극대화됩니다.
2. Go 런타임에서의 M스케줄링
- OS 스레드는 운영체제가 직접 관리하는 1:1 스케줄링 모델을 따릅니다. 즉, 각 스레드는 하나의 하드웨어 스레드(또는 CPU 코어)에 대응되며, 운영체제가 스레드 간의 스케줄링을 관리합니다. 이 방식은 스레드가 많아질수록 스케줄링 오버헤드가 커지고, 문맥 전환(context switching)이 빈번하게 발생해 성능에 영향을 미칩니다.
- 반면, Go는 자체 런타임에서 M스케줄링을 구현하여 여러
Goroutine
(M)을 소수의 OS 스레드(N)에서 실행합니다. 즉, 많은 수의Goroutine
이 소수의 OS 스레드에서 효율적으로 병렬 처리됩니다.- Go의 스케줄러는
Goroutine
을 효율적으로 관리하며, 필요할 때 적절히 OS 스레드에 매핑합니다. 이로 인해, Goroutine을 많이 생성해도 OS 스레드의 수를 급격히 늘릴 필요가 없으므로 스레드 오버헤드를 크게 줄일 수 있습니다. - OS 스레드 간의 문맥 전환에 비해
Goroutine
간의 문맥 전환은 비용이 훨씬 적습니다**. Go 런타임 내에서 이루어지는 문맥 전환은 운영체제에서 이루어지는 문맥 전환보다 경량이며, 더 적은 자원을 소모합니다.
- Go의 스케줄러는
3. 빠른 생성 및 종료
- OS 스레드의 생성과 소멸은 비교적 무겁습니다. 운영체제가 스레드를 만들 때는 다양한 초기화 작업을 처리하고, 스택을 할당하며, 컨텍스트 정보를 준비합니다. 이런 작업은 운영체제 차원에서 처리되므로 시간이 오래 걸리고, 자원도 더 많이 소모됩니다.
- 반면,
Goroutine
은 매우 빠르게 생성되고 **비용이 적습니다. Go 런타임이 유저 레벨에서 Goroutine을 관리하므로, 운영체제가 개입하지 않아도 되며, 런타임 내부에서 바로 가벼운 작업으로 처리할 수 있습니다. 이는 많은 수의 Goroutine을 생성하고 관리할 때 큰 이점이 됩니다.
4. 비동기 I/O와 블로킹 관리
- OS 스레드는 기본적으로 블로킹 I/O를 처리할 때, 해당 스레드는 작업이 완료될 때까지 대기 상태에 놓입니다. 이를 효율적으로 처리하기 위해서는 비동기 I/O를 사용하는 별도의 처리 메커니즘이 필요합니다.
- Go의
Goroutine
은 블로킹 I/O 호출을 하더라도 Go 런타임이 다른Goroutine
으로 작업을 스위치합니다. Go 런타임은 자동으로 **비동기 I/O를 관리하며, Goroutine이 블로킹 상태에 놓여도 전체 시스템의 성능에 미치는 영향을 최소화합니다. 이를 통해 OS 스레드에 대한 추가적인 복잡성을 줄일 수 있습니다.
5. 간단한 동시성 모델 (Channels)
Goroutine
은 OS 스레드와 달리 Go의 채널(Channel
)을 통해 안전하고 간편한 통신을 할 수 있습니다. 채널을 통해 Goroutine 간 데이터를 주고받을 수 있으며, 이를 통해 데이터를 안전하게 공유하거나 동기화할 수 있습니다.- OS 스레드는 공유 메모리를 사용하며, 이를 동기화하기 위해 락(
Mutex
) 같은 복잡한 동기화 메커니즘이 필요합니다. 락을 잘못 사용하면 교착 상태(deadlock
)나 경합 상태(race condition
)가 발생할 수 있으며, 관리도 어렵습니다. Go는 이러한 문제를 Goroutine과 채널을 통해 더 간단하고 안전하게 해결합니다.
Goroutine의 내부 관리
- Goroutine은 Go 런타임이 관리하는 GMP스케줄링 모델을 사용합니다.
- 이 모델은 여러 개의 goroutine을 몇 개의 운영체제(OS) 스레드에 매핑하는 방식으로 동작합니다.
1. Goroutine, OS 스레드, 그리고 프로세서(P):
Go 런타임에는 3개의 중요한 개념이 있습니다:
- G (Goroutine):
Goroutine
은 함수의 호출 스택, 스레드 지역 데이터, 그리고 실행 컨텍스트를 포함하는 경량의 작업 단위입니다. - M (Machine): M은 OS 스레드입니다. Go 런타임은 여러 개의 OS 스레드를 생성하고, 필요에 따라 goroutine을 이 스레드 위에 실행시킵니다.
- P (Processor): P는 Go 스케줄러가 goroutine을 M에 할당할 때 사용하는 논리적 프로세서입니다. 기본적으로
GOMAXPROCS
환경 변수를 통해 프로세서 수를 설정할 수 있습니다. 기본 값은 현재 CPU 코어의 수와 동일합니다.
2. 스케줄링:
Go는 프리엠티브 스케줄링과 협력적 스케줄링을 결합하여 Goroutine
을 관리합니다.
- 프리엠티브 스케줄링: Go 런타임은 특정 지점에서 goroutine을 중단하고 다른 goroutine을 실행할 수 있습니다. 예를 들어, 시스템 호출이나 채널 통신, I/O 작업이 발생하면 런타임이 개입하여 스레드가 다른 작업을 처리하도록 할 수 있습니다.
- 협력적 스케줄링: 일반적인 코드 실행 중에는
Goroutine
이 주기적으로 런타임의 개입 없이 계속 실행됩니다. 이 과정에서 런타임은 함수 호출 또는 메모리 할당과 같은 특정 지점에서 중단점을 설정하고 필요시 스케줄러가 개입할 수 있게 합니다.
3. Goroutine의 스택:
Goroutine
의 스택은 처음에 매우 작게(약 2KB) 시작하며, 필요한 경우 동적으로 증가합니다. 이는 스레드 기반의 프로그램에서 흔히 사용되는 고정 크기의 스택보다 메모리를 효율적으로 사용할 수 있게 해 줍니다. 스택이 커지면 Go 런타임은 자동으로 스택 크기를 조정하며, 필요하지 않을 때 다시 축소할 수 있습니다.
4. Work Stealing:
Go의 스케줄러는 Work Stealing 알고리즘을 사용합니다. 이는 각 P에 큐(queue)를 유지하며, P에 할당된 goroutine을 M에 실행시킵니다. 만약 하나의 P가 모든 goroutine을 처리하고 남는다면, 다른 P로부터 작업을 훔쳐와 스케줄링하는 방식으로 성능을 극대화합니다.
5. GOMAXPROCS:
Go 런타임에서 사용할 수 있는 논리적 CPU의 수는 GOMAXPROCS
로 결정됩니다. 기본적으로 이 값은 시스템의 CPU 코어 수와 동일하게 설정되지만, 명시적으로 설정할 수도 있습니다. 이는 여러 코어에서 goroutine이 병렬로 실행되는 정도를 제어합니다.
runtime.GOMAXPROCS(4) // 최대 4개의 프로세서를 사용
Goroutin의 M
Go 런타임에서 M
(Machine)은 실제로 OS 스레드를 나타내며, OS 스레드를 생성하고 관리하는 역할을 합니다. Goroutine(G
)은 M
에 의해 실행되며, 여러 M을 통해 실제 CPU 코어에서 병렬로 동작합니다. OS 스레드 생성은 런타임이 자동으로 처리하는데, 이 과정은 동시성과 성능을 최적화하기 위해 매우 중요한 역할을 합니다. 이제 M에서 OS 스레드를 생성하는 과정을 자세히 설명하겠습니다.
1. M의 역할과 OS 스레드 생성
Go 프로그램이 실행될 때, Go 런타임은 M
을 이용해 OS 스레드를 생성합니다. 이 M은 goroutine을 실제 CPU 위에서 실행하는 주체입니다. M은 운영체제의 커널과 상호작용하여 OS 스레드를 생성하고, 각 OS 스레드에서 하나 이상의 goroutine을 실행할 수 있습니다.
OS 스레드 생성 과정:
- Go 프로그램이 시작되면 최소 1개의
M
이 OS 스레드를 생성합니다. 이 스레드는 메인 goroutine(즉,main
함수)을 실행합니다. - goroutine이 많아지고, 추가적인
M
이 필요하면 런타임은 새로운M
을 생성하고, 그M
이 또 다른 OS 스레드를 생성해 CPU 자원을 활용할 수 있게 만듭니다.
일반적으로 OS 스레드는 운영체제의 커널이 관리하며, Go 런타임은 직접적으로 OS 스레드를 생성하고 해제하는 API를 호출합니다. 이는 고정된 스레드 수에 의존하지 않고 동적으로 OS 스레드를 관리할 수 있게 해줍니다.
2. 스레드 생성의 동기:
새로운 OS 스레드를 생성하는 주요 이유는 다음과 같습니다:
(1) 블로킹 작업 처리:
- Go의 기본적인 철학은 Non-blocking I/O이지만, 때로는 블로킹 시스템 호출이나 블로킹 작업이 발생할 수 있습니다. 예를 들어, 네트워크 호출이나 파일 시스템 I/O 같은 경우입니다.
- Go 런타임은 goroutine에서 블로킹 작업이 발생하면, 그 작업이 실행 중인
M
(OS 스레드)을 다른 작업으로부터 차단하지 않고 새로운M
을 만들어서 다른 goroutine이 병목 없이 계속 실행될 수 있게 합니다.- 예: 한 goroutine이 파일을 읽기 위해 블로킹 시스템 호출을 수행하면, Go 런타임은 현재 OS 스레드를 다른 작업에 사용할 수 있게 새로운 OS 스레드를 생성하고, 블로킹된 작업을 다른 스레드로 넘깁니다.
(2) Goroutine이 프로세서를 기다릴 때:
- Go 런타임은
GOMAXPROCS
로 설정된 CPU 코어 수 만큼 P (Processor)를 관리합니다. 각 P는 하나의 M(OS 스레드)을 할당받아 goroutine을 실행합니다. P에 할당된 M이 없거나 다른 M이 바쁜 경우, 새로운 M을 생성해 실행할 수 있습니다.
3. M과 P (Processor)의 관계
- M과 P는 1:1로 연결:
M
은P
와 연결된 상태에서만Goroutine
을 실행할 수 있습니다. 이는 Go 스케줄러가 goroutine을 OS 스레드에서 실행하기 위한 구조입니다. - M과 P의 분리: P는 실제 CPU 코어와 연결된 프로세서 단위입니다. Go 스케줄러는 P가 작업을 관리하는 동안, M(OS 스레드)은 그 위에서 실행됩니다.
- 즉, M은 P 위에서 goroutine을 스케줄링하여 실행하는 주체이기 때문에, M이 P 없이 독립적으로 작업을 수행할 수는 없습니다. P가 없으면 M은 놀고 있는 상태가 됩니다. 이 경우, 추가적인 P가 필요할 때 새로운 M을 생성해 OS 스레드를 할당할 수 있습니다.
4. OS 스레드 해제
Go는 필요에 따라 OS 스레드를 해제하거나 다시 사용할 수 있습니다. 예를 들어, 오랜 시간 동안 사용되지 않거나 블로킹된 OS 스레드는 런타임이 해제할 수 있습니다. 이는 시스템 자원을 효율적으로 관리하고 메모리와 CPU 사용을 최적화하는 데 도움이 됩니다.
- Idle M: 사용하지 않는
M
은idle
상태가 됩니다. 이 M은 여전히 OS 스레드를 유지하지만, 다른 작업을 기다립니다. 일정 시간이 지나면 이러한 idle M은 해제되어 시스템 리소스를 줄입니다. - OS 스레드 재사용: Go 런타임은 OS 스레드를 재사용할 수 있습니다. 만약 새로운
M
이 필요하다면, 이미 사용했던 OS 스레드를 재활용할 수 있어 성능에 도움이 됩니다.
5. 실제 OS 스레드의 효율적 관리
Go의 런타임은 매우 많은 goroutine을 처리하는 데 있어 OS 스레드의 수를 최소화하려고 노력합니다. 그 이유는 OS 스레드를 많이 생성하면 문맥 전환 비용(context switch)이 증가하고, 운영체제 자원이 낭비될 수 있기 때문입니다.
Goroutine 관리의 장점
- 경량성: Goroutine은 매우 가벼워서 수십만 개의 goroutine을 생성하더라도 메모리 및 성능에 큰 부담을 주지 않습니다.
- 자동 관리: OS 스레드에 대한 복잡한 관리를 개발자가 직접 할 필요 없이, Go 런타임이 자동으로 goroutine을 OS 스레드에 매핑하고 스케줄링합니다.
- 효율적 스케줄링: Go의 M모델은 goroutine을 스케줄링하는 데 있어 CPU와 메모리 자원을 효율적으로 사용하여, 성능을 극대화할 수 있습니다.
결론
Goroutine은 Go에서 동시성을 효율적으로 관리하는 중요한 기능으로, Go 런타임이 스레드와 스케줄링 작업을 관리합니다. 개발자는 복잡한 스레드 관리 없이 goroutine을 사용하여 고성능, 병렬 프로그램을 쉽게 작성할 수 있습니다.