Go 동시성 해부: 고루틴 스케줄링
Lukas Schneider
DevOps Engineer · Leapcell

I. 고루틴 소개
고루틴은 Go 프로그래밍 언어의 매우 독특한 설계이며 주요 특징 중 하나입니다. 본질적으로 코루틴으로서 병렬 컴퓨팅을 달성하는 열쇠입니다. 고루틴을 사용하는 것은 매우 간단합니다. go 키워드를 사용하여 간단하게 코루틴을 시작할 수 있으며, 비동기적으로 실행됩니다. 프로그램은 고루틴이 완료될 때까지 기다리지 않고 후속 코드를 계속 실행할 수 있습니다.
go func() // go 키워드를 사용하여 함수를 실행할 코루틴 시작
II. 고루틴의 내부 원리
개념 소개
동시성 (Concurrency)
단일 CPU에서 여러 작업을 동시에 실행할 수 있습니다. 극히 짧은 시간 동안 CPU는 작업 간에 빠르게 전환합니다(예: 프로그램 A를 잠시 실행하고 빠르게 프로그램 B로 전환). 시간의 중첩이 있습니다(거시적 관점에서 동시적으로 보이지만, 미시적 수준에서는 여전히 순차적 실행입니다). 이것은 여러 작업이 동시에 실행되는 듯한 착각을 주며, 이것이 동시성입니다.
병렬성 (Parallelism)
시스템에 여러 CPU가 있을 때, 각 CPU는 자신의 CPU 리소스를 경쟁하지 않고 동시에 작업을 실행할 수 있습니다. 그들은 동시에 작동하며, 이것을 병렬성이라고 합니다.
프로세스 (Process)
CPU가 프로그램 간에 전환할 때, 이전 프로그램의 상태(소위 컨텍스트)를 저장하지 않고 다음 프로그램으로 직접 전환하면 이전 프로그램의 일련의 상태가 손실됩니다. 이 문제를 해결하기 위해 프로그램 실행에 필요한 리소스를 할당하기 위해 프로세스 개념이 도입되었습니다. 따라서 프로세스는 프로그램이 실행되는 데 필요한 기본 리소스 단위입니다(프로그램 실행의 엔티티로 간주될 수도 있습니다). 예를 들어, 텍스트 편집 애플리케이션을 실행할 때, 이 애플리케이션의 프로세스는 텍스트 버퍼의 메모리 공간, 파일 처리 리소스 등 모든 리소스를 관리합니다.
스레드 (Thread)
CPU가 여러 프로세스 간에 전환하면 많은 시간이 소요됩니다. 프로세스 전환은 커널 모드로의 전환이 필요하며, 각 스케줄링은 사용자 모드 데이터를 읽어야 하기 때문입니다. 프로세스가 증가함에 따라 CPU 스케줄링은 많은 리소스를 소비합니다. 따라서 스레드 개념이 도입되었습니다. 스레드 자체는 리소스를 거의 소비하지 않으며, 프로세스 내의 리소스를 공유합니다. 커널이 스레드를 스케줄링할 때 프로세스를 스케줄링할 때만큼 많은 리소스를 소비하지 않습니다. 예를 들어, 웹 서버 애플리케이션에서 여러 스레드를 사용하여 네트워크 연결 및 메모리 캐시와 같은 서버 프로세스의 리소스를 공유하면서 다른 클라이언트 요청을 동시에 처리할 수 있습니다.
코루틴 (Coroutine)
코루틴은 자체 레지스터 컨텍스트와 스택을 가지고 있습니다. 코루틴이 스케줄링되어 전환될 때, 레지스터 컨텍스트와 스택을 다른 위치에 저장합니다. 다시 전환할 때는 이전에 저장된 레지스터 컨텍스트와 스택을 복원합니다. 따라서 코루틴은 이전 호출의 상태(즉, 모든 로컬 상태의 특정 조합)를 유지할 수 있습니다. 프로세스에 다시 진입할 때마다 이전 호출의 상태로 돌아가는 것에 해당합니다. 즉, 마지막으로 떠났던 논리 흐름상의 위치로 돌아가는 것입니다. 스레드와 프로세스의 작업은 시스템 인터페이스를 통해 프로그램에 의해 트리거되며, 최종 실행자는 시스템입니다. 그러나 코루틴의 작업은 사용자 자신의 프로그램에 의해 실행되며, 고루틴은 코루틴의 한 유형입니다.
스케줄링 모델 소개
고루틴의 강력한 동시성 구현은 GPM 스케줄링 모델을 통해 이루어집니다. 다음은 고루틴 스케줄링 모델을 설명합니다.
Go 스케줄러 내부에는 네 가지 중요한 구조가 있습니다: M, P, G, Sched (Sched는 다이어그램에 표시되지 않음).
- M: 커널 수준 스레드를 나타냅니다. M 하나는 스레드 하나이며, 고루틴은 M에서 실행됩니다. 예를 들어, 복잡한 계산을 수행하기 위해 고루틴이 시작되면, 이 고루틴은 실행을 위해 M에 할당됩니다. M은 작은 객체 메모리 캐시(mcache), 현재 실행 중인 고루틴, 난수 생성기 및 기타 많은 정보를 유지하는 큰 구조체입니다.
- G: 고루틴을 나타냅니다. 함수 호출 정보를 저장하는 자체 스택, 실행 위치를 지정하는 명령 포인터, 채널 대기 정보 등 스케줄링에 사용되는 기타 정보를 가지고 있습니다. 예를 들어, 고루틴이 채널에서 데이터를 수신 대기하고 있다면, 이 정보는 G 구조체에 저장됩니다.
- P: 전체 이름은 Processor입니다. 주로 고루틴을 실행하는 데 사용됩니다. 작업 디스패처로 생각할 수 있습니다. 또한 실행해야 할 모든 고루틴을 저장하는 고루틴 큐를 유지합니다. 예를 들어, 여러 고루틴이 생성되면, 스케줄링을 위해 P가 유지하는 큐에 추가됩니다.
- Sched: 스케줄러를 나타냅니다. 중앙 스케줄링 센터로 간주될 수 있습니다. M과 G의 큐, 그리고 스케줄러의 일부 상태 정보를 유지하여 전체 시스템의 효율적인 스케줄링을 보장합니다.
스케줄링 구현

그림에서 볼 수 있듯이 2개의 물리적 스레드 M이 있으며, 각 M에는 프로세서 P가 있고 실행 중인 고루틴이 있습니다.
- P의 수는
GOMAXPROCS()를 통해 설정할 수 있습니다. 이는 실제로 동시성 수준, 즉 동시에 실행될 수 있는 고루틴의 수를 나타냅니다. - 그림의 회색 고루틴은 실행 중이 아니며 준비 상태로 스케줄링을 대기하고 있습니다. P는 이 큐(runqueue라고 함)를 유지합니다.
- Go 언어에서 고루틴을 시작하는 것은 매우 간단합니다.
go를 사용하기만 하면 됩니다. 따라서go문이 실행될 때마다 고루틴이 런큐의 끝에 추가됩니다. 다음 스케줄링 시점에서 런큐에서 고루틴이 하나 나와 실행됩니다(하지만 어떤 고루틴을 선택할지는 어떻게 결정될까요?).
OS 스레드 M0이 차단되면(아래 그림 참조), P는 M1을 실행하도록 전환합니다. 그림의 M1은 생성 중이거나 스레드 캐시에서 가져온 것일 수 있습니다.

M0이 반환되면, 고루틴을 실행하기 위해 P를 얻으려고 시도해야 합니다. 일반적으로 다른 OS 스레드에서 P를 얻으려고 합니다. 하나를 얻지 못하면, 고루틴을 전역 런큐에 넣고 스스로 잠듭니다(스레드 캐시로 이동). 모든 P는 주기적으로 전역 런큐를 확인하고 그 안의 고루틴을 실행합니다. 그렇지 않으면 전역 런큐의 고루틴은 절대 실행되지 않습니다.
또 다른 상황은 P에 할당된 작업 G가 빠르게 완료되는 것입니다(불균등 분배). 이로 인해 해당 프로세스 P가 유휴 상태가 되지만 다른 P는 여전히 작업이 있을 수 있습니다. 전역 런큐에 작업 G가 없으면 P는 다른 P로부터 일부 G를 가져와 실행해야 합니다. 일반적으로 P가 다른 P에서 작업을 가져올 때는 런 큐의 절반을 가져와 각 OS 스레드가 완전히 활용되도록 합니다. 아래 그림 참조:

III. 고루틴 사용
기본 사용법
고루틴 실행을 위한 CPU 수를 설정합니다. 최신 버전의 Go에는 기본 설정이 있습니다.
num := runtime.NumCPU() // 호스트의 논리 CPU 수를 가져와 나중에 동시성 수준 설정을 준비 runtime.GOMAXPROCS(num) // 호스트 CPU 수에 따라 동시에 실행될 수 있는 최대 CPU 수를 설정하여 고루틴의 동시성 수준 제어
사용 예시
예시 1: 간단한 고루틴 계산
package main import ( "fmt" "time" ) // cal 함수는 두 정수의 합을 계산하고 결과를 출력하는 데 사용됩니다 func cal(a int, b int) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) } func main() { for i := 0; i < 10; i++ { go cal(i, i + 1) // 10개의 고루틴을 시작하여 계산 수행 } time.Sleep(time.Second * 2) // 모든 작업이 완료될 때까지 기다리는 데 사용되는 슬립 }
결과:
8 + 9 = 17
9 + 10 = 19
4 + 5 = 9
5 + 6 = 11
0 + 1 = 1
1 + 2 = 3
2 + 3 = 5
3 + 4 = 7
7 + 8 = 15
6 + 7 = 13
고루틴 예외 처리
여러 고루틴을 시작할 때, 그 중 하나가 예외를 만나고 예외 처리가 이루어지지 않으면 전체 프로그램이 종료됩니다. 따라서 프로그램을 작성할 때는 각 고루틴에서 실행되는 함수에 예외 처리를 추가하는 것이 좋습니다. recover 함수는 예외 처리에 사용할 수 있습니다.
package main import ( "fmt" "time" ) func addele(a []int, i int) { // defer를 사용하여 익명 함수 실행을 지연시키고, 이는 가능한 예외를 감지하는 데 사용됩니다 defer func() { // recover 함수를 호출하여 예외 정보를 얻습니다 err := recover() if err!= nil { // 예외 정보를 출력합니다 fmt.Println("add ele fail") } }() a[i] = i fmt.Println(a) } func main() { Arry := make([]int, 4) for i := 0; i < 10; i++ { go addele(Arry, i) } time.Sleep(time.Second * 2) }
결과:
add ele fail
[0 0 0 0]
[0 1 0 0]
[0 1 2 0]
[0 1 2 3]
add ele fail
add ele fail
add ele fail
add ele fail
add ele fail
동기화된 고루틴
고루틴은 비동기적으로 실행되므로, 메인 프로그램이 종료될 때 일부 고루틴이 완료되지 않았을 수 있고, 이러한 고루틴도 종료됩니다. 모든 고루틴 작업이 완료될 때까지 기다리려면 Go는 sync 패키지와 channel을 사용하여 동기화 문제를 해결합니다. 물론 각 고루틴의 실행 시간을 예측할 수 있다면 time.Sleep을 사용하여 프로그램 종료 전에 완료될 때까지 기다릴 수도 있습니다(위 예시 참조).
예시 1: sync 패키지를 사용하여 고루틴 동기화
WaitGroup은 고루틴 그룹 완료를 기다리는 데 사용됩니다. 메인 프로그램은 Add를 호출하여 기다릴 고루틴 수를 추가합니다. 각 고루틴은 완료 시 Done을 호출하고, 대기열의 수는 1씩 감소합니다. 메인 프로그램은 대기열이 0이 될 때까지 Wait에서 차단됩니다.
package main import ( "fmt" "sync" ) func cal(a int, b int, n *sync.WaitGroup) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) // 고루틴이 완료되면 Done 메서드를 호출하여 WaitGroup의 카운트를 1씩 감소시킵니다 defer n.Done() } func main() { var go_sync sync.WaitGroup // WaitGroup 변수 선언 for i := 0; i < 10; i++ { // 고루틴 시작 전에 WaitGroup 카운트를 1씩 증가시킵니다 go_sync.Add(1) go cal(i, i + 1, &go_sync) } // WaitGroup 카운트가 0이 될 때까지, 즉 모든 고루틴이 완료될 때까지 블록하고 기다립니다 go_sync.Wait() }
결과:
9 + 10 = 19
2 + 3 = 5
3 + 4 = 7
4 + 5 = 9
5 + 6 = 11
1 + 2 = 3
6 + 7 = 13
7 + 8 = 15
0 + 1 = 1
8 + 9 = 17
예시 2: 채널을 통한 고루틴 동기화 구현
구현 방법: channel을 통해 여러 고루틴 간에 통신할 수 있습니다. 고루틴이 완료되면 종료 신호를 channel로 보냅니다. 모든 고루틴이 종료되면 for 루프를 사용하여 channel에서 신호를 받습니다. 데이터를 얻을 수 없으면 모든 고루틴이 완료될 때까지 차단됩니다. 이 방법을 사용하려면 시작된 고루틴 수를 알아야 합니다.
package main import ( "fmt" "time" ) func cal(a int, b int, Exitchan chan bool) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) time.Sleep(time.Second * 2) // 고루틴이 완료되었음을 나타내기 위해 채널로 신호를 보냅니다 Exitchan <- true } func main() { // 고루틴 완료 신호를 저장하기 위해 용량 10개의 bool 타입 채널 생성 Exitchan := make(chan bool, 10) for i := 0; i < 10; i++ { go cal(i, i + 1, Exitchan) } for j := 0; j < 10; j++ { // 채널에서 신호를 받습니다. 신호가 없으면 고루틴이 완료되어 신호를 보낼 때까지 차단됩니다 <-Exitchan } // 채널 닫기 close(Exitchan) }
고루틴 간 통신
고루틴은 본질적으로 코루틴으로, 커널이 아닌 Go 스케줄러에 의해 관리되는 스레드로 이해할 수 있습니다. 고루틴 간의 통신 또는 데이터 공유는 channel을 통해 달성될 수 있습니다. 물론 전역 변수를 사용하여 데이터를 공유할 수도 있습니다.
예시: 채널을 사용하여 생산자-소비자 패턴 시뮬레이션
package main import ( "fmt" "sync" ) func Productor(mychan chan int, data int, wait *sync.WaitGroup) { // 채널에 데이터 전송 mychan <- data fmt.Println("product data:", data) // 생산자 완료 표시 및 WaitGroup 카운트 1 감소 wait.Done() } func Consumer(mychan chan int, wait *sync.WaitGroup) { // 채널에서 데이터 받기 a := <-mychan fmt.Println("consumer data:", a) // 소비자 완료 표시 및 WaitGroup 카운트 1 감소 wait.Done() } func main() { // 생산자와 소비자 간의 데이터 전송을 위해 용량 100개의 int 타입 채널 생성 datachan := make(chan int, 100) var wg sync.WaitGroup for i := 0; i < 10; i++ { // 생산자 고루틴을 시작하여 채널에 데이터 전송 go Productor(datachan, i, &wg) // WaitGroup 카운트 증가 wg.Add(1) } for j := 0; j < 10; j++ { // 소비자 고루틴을 시작하여 채널에서 데이터 받기 go Consumer(datachan, &wg) // WaitGroup 카운트 증가 wg.Add(1) } // 생산자와 소비자 모두 작업 완료될 때까지 블록하고 기다림 wg.Wait() }
결과:
consumer data: 4
product data: 5
product data: 6
product data: 7
product data: 8
product data: 9
consumer data: 1
consumer data: 5
consumer data: 6
consumer data: 7
consumer data: 8
consumer data: 9
product data: 2
consumer data: 2
product data: 3
consumer data: 3
product data: 4
consumer data: 0
product data: 0
product data: 1
Leapcell: 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼
마지막으로 Go 서비스를 배포하는 데 가장 적합한 플랫폼인 Leapcell을 추천합니다.
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발하세요.
2. 무제한 무료 프로젝트 배포
- 사용한 만큼만 지불하세요. 요청 수 기준 없음, 요금 없음.
3. 뛰어난 비용 효율성
- 사용량 기반 지불, 유휴 시간 요금 없음.
- 예: $25로 평균 응답 시간 60ms에서 694만 건의 요청 처리 가능.
4. 간소화된 개발자 경험
- 사용자 친화적인 UI로 쉬운 설정.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실시간 지표 및 로깅으로 실행 가능한 통찰력 확보.
5. 쉬운 확장성 및 고성능
- 높은 동시성을 쉽게 처리할 수 있는 자동 확장.
- 운영 오버헤드 제로 - 빌드에만 집중하세요.
Leapcell 트위터: https://x.com/LeapcellHQ

