고루틴
고친 사람 github-actions[bot]
고루틴은 Go 프로그램에서 여러 함수를 겹쳐 진행시켜 줍니다. 함수를 부를 때 앞에 go 한 낱말만 붙이면 됩니다. 운영체제 스레드보다 메모리를 훨씬 적게 씁니다. 그래서 한 프로그램에 수만 개를 띄울 수도 있습니다.
쉽고 빠른 이해
고루틴은 Go 에서 여러 함수를 겹쳐 진행시키는 방법입니다. Go 표준 라이브러리의 net/http 서버가
들어오는 연결마다 고루틴을 하나씩 띄우는 것이 흔한 쓰임입니다.
운영체제 스레드는 하나가 먹는 메모리가 큽니다. 연결마다 하나씩 붙이면 수만 개를 버티기 어렵습니다. 고루틴은 작게 시작합니다. 여러 고루틴이 스레드 몇 개를 돌아가며 나눠 씁니다.
어떻게 도는가:
- 함수 호출 앞에 go 를 붙이면 그 함수가 새 고루틴으로 떠납니다
- Go 런타임(프로그램 안에서 고루틴을 관리하는 코드)이 고루틴들을 적은 수의 스레드에 나눠 돌립니다
- 기다리는 고루틴은 스레드를 놓고 잠듭니다. 그 틈에 다른 고루틴이 돕니다
대가가 있습니다. 고루틴은 밖에서 끌 수 없습니다. 끝내는 길은 코드가 마련해야 합니다. 같은 값을 여럿이 만지면 값이 엉킵니다. 이를 막는 장치도 따로 둬야 합니다. 덧셈처럼 작은 일에는 띄우지 않습니다. 띄우는 비용이 일보다 커집니다.
상세
주방에 주문서가 수백 장 걸려 있습니다. 요리사는 몇 명뿐입니다. 주문서가 아무리 늘어도 요리사를 더 뽑지 않습니다.
요리사는 주문서 하나를 집어 요리합니다. 오븐을 기다려야 하면 그 주문서를 다시 걸어 둡니다. 그사이 다음 주문서를 집습니다.
고루틴이 주문서입니다. 운영체제 스레드는 요리사입니다. 스레드는 운영체제가 CPU(Central Processing Unit, 중앙 처리 장치) 시간을 돌아가며 나눠 주는 실행 흐름입니다.
고루틴은 그 스레드 위에서 도는 실행 흐름입니다. 스레드보다 메모리를 적게 씁니다. 만들거나 전환하는 데 드는 비용도 작습니다.
고루틴은 Go 언어에 들어 있는 장치입니다. 고루틴을 만들고 돌리고 치우는 일은 Go 런타임이 맡습니다. 런타임은 Go 프로그램 안에 같이 들어가는 코드입니다. 프로그램이 도는 동안 뒤에서 메모리와 실행 흐름을 관리합니다.
스레드 대신 고루틴을 쓰는 까닭
서버는 요청 여러 개를 한꺼번에 받습니다. 요청 하나를 처리하는 동안 데이터베이스 응답을 기다리는 시간이 깁니다. 그 사이에 다른 요청도 진행돼야 합니다. 이렇게 여러 일을 겹쳐 진행하는 것을 동시성이라고 합니다.
요청마다 스레드를 하나씩 붙이면 동시성은 얻습니다. 문제는 스레드 하나가 차지하는 메모리입니다. 그 메모리의 큰 몫은 스택입니다.
스택은 함수를 부를 때마다 지역 변수와 돌아갈 곳을 쌓는 메모리 구역입니다. 운영체제 스레드는 이 스택을 대개 메가바이트 단위로 미리 잡습니다.
스레드를 전환하는 데도 비용이 듭니다. 운영체제는 멈출 스레드의 상태를 저장합니다. 그리고 다음 스레드의 상태를 되살립니다. 이 일을 문맥 교환이라고 부릅니다.
스레드 사이의 문맥 교환은 커널을 거칩니다. 커널은 스레드와 메모리를 직접 다루는 운영체제의 핵심부입니다. 프로그램이 커널에 일을 맡기면 실행 모드를 바꿔 커널로 들어갔다가 나와야 합니다. 이 드나듦에 시간이 듭니다.
고루틴은 두 부담을 다 덜어 냅니다. 스택을 몇 킬로바이트로 작게 잡고 시작합니다. 모자라면 런타임이 더 큰 스택으로 옮겨 늘립니다.
고루틴끼리 전환할 때도 상태를 저장하고 되살립니다. 이 일은 런타임이 프로그램 안에서 처리합니다. 커널을 드나드는 시간이 들지 않습니다.
그래서 들어오는 연결마다 고루틴을 하나씩 붙여도 수만 개가 버틸 만합니다. Go 표준 라이브러리의 net/http
서버가 이렇게 연결마다 고루틴을 띄웁니다.
go 문으로 띄우기
고루틴을 만드는 문법은 하나뿐입니다. 함수 호출 앞에 go 를 붙입니다. 보통 호출은 함수가 끝날 때까지
기다립니다. go 를 붙인 호출은 기다리지 않고 바로 다음 줄로 넘어갑니다.
main 함수가 끝나면 프로그램이 끝납니다. 그때 아직 돌던 고루틴은 마무리할 기회 없이 함께 사라집니다.
아래 코드가 그 모습입니다.
func main() {
go fmt.Println("hi") // 안 찍힐 수 있다
fmt.Println("bye") // bye
}
hi 를 찍는 고루틴이 차례를 얻기 전에 main 이 먼저 끝날 수 있습니다. 그러면 hi 는 안 찍힙니다.
어느 쪽이 먼저 도는지는 정해져 있지 않습니다.
go 로 부른 함수가 돌려주는 값은 버려집니다. 결과를 받으려면 고루틴끼리 값을 넘기는 통로를 따로
씁니다. 이 통로가 채널입니다. 채널은 아래 「채널과 뮤텍스」에서 봅니다.
스케줄러와 대기열
고루틴 수만큼 스레드를 만들지 않습니다. 런타임은 스레드를 몇 개만 둡니다. 고루틴 여럿이 그 스레드를 돌아가며 씁니다.
이 배분은 런타임 안의 스케줄러가 맡습니다. 스케줄러는 차례를 기다리는 고루틴을 대기열에 줄 세웁니다. 스레드는 대기열에서 고루틴을 하나 꺼내 돌립니다. 그 고루틴이 멈추면 다음 고루틴을 꺼냅니다.
flowchart TD
S["스케줄러"]
subgraph Q["대기열 — 고루틴이 많다"]
G1["고루틴 1"]
G2["고루틴 2"]
G3["고루틴 3"]
G4["고루틴 4"]
end
subgraph T["운영체제 스레드 — 적다"]
T1["스레드 1"]
T2["스레드 2"]
end
S -->|줄 세운다| Q
Q -->|스레드가 꺼내 간다| T1
Q -->|스레드가 꺼내 간다| T2
그림은 고루틴 넷이 스레드 둘을 나눠 쓰는 모습입니다. 스케줄러는 줄만 세웁니다. 꺼내 돌리는 쪽은 스레드입니다. 코드를 쓰는 사람은 이 배분에 손대지 않고 고루틴을 띄우기만 합니다.
고루틴이 병렬성까지 얻는지는 따로 따져야 합니다. 병렬성은 같은 순간에 여러 일이 나란히 도는 성질입니다. 동시성은 이보다 느슨합니다. CPU 하나가 여러 일을 조금씩 돌아가며 해도 동시성은 성립합니다.
Go 코드를 같은 순간에 돌리는 스레드 수는 GOMAXPROCS 라는 설정이 정합니다. 보통은 CPU 코어 수에
맞춰져 있습니다. 코어는 CPU 안에서 명령을 따로 실행하는 단위입니다. 코어가 넷이면 고루틴이 넷까지
같은 순간에 돕니다.
기다림과 선점
고루틴 코드는 평범한 함수처럼 씁니다. 응답을 기다리는 줄에서는 멈춰 기다립니다. 그래도 스레드까지 붙잡지는 않습니다. 기다리는 방식은 운영체제에 일을 어떻게 맡기느냐에 따라 갈립니다.
네트워크와 파일은 운영체제가 쥐고 있습니다. 프로그램은 이런 일을 운영체제에 맡겨야 합니다. 프로그램이 운영체제에 일을 맡기는 호출을 시스템 콜이라고 합니다.
네트워크 응답을 기다릴 때 런타임은 운영체제에 「오면 알려 달라」고만 걸어 둡니다. 이 시스템 콜은 곧바로 돌아옵니다. 그래서 고루틴만 잠들고 스레드는 풀려납니다. 응답이 오면 런타임이 그 고루틴을 다시 대기열에 넣습니다.
파일 읽기처럼 일이 끝날 때까지 돌아오지 않는 시스템 콜도 있습니다. 그 호출에 들어간 스레드는 끝날 때까지 못 움직입니다. 그러면 런타임은 대기열의 나머지 고루틴을 다른 스레드에 넘겨 계속 돌립니다.
계산만 오래 하는 고루틴도 스레드를 혼자 붙잡지 못합니다. 런타임이 그런 고루틴을 중간에 끊어 대기열 뒤로 돌려보냅니다. 밖에서 끊어 차례를 넘기는 이 방식이 선점형 멀티태스킹입니다.
한 고루틴이 지나는 상태를 그리면 이렇습니다.
stateDiagram-v2
state "실행 중" as 실행중
[*] --> 대기열: go 로 띄운다
대기열 --> 실행중: 스레드가 꺼낸다
실행중 --> 잠듦: 응답을 기다린다
잠듦 --> 대기열: 응답이 왔다
실행중 --> 대기열: 런타임이 끊는다
실행중 --> [*]: 함수가 끝난다
잠든 고루틴은 스레드를 쓰지 않습니다. 그래서 응답을 기다리는 고루틴이 수만 개여도 스레드는 몇 개로 충분합니다.
코루틴과 견주면 차이가 보입니다. 코루틴은 코드가 스스로 양보하는 줄에서만 차례를 넘깁니다. 그래서
양보할 줄마다 await 같은 표시를 적어야 합니다. 고루틴은 그런 표시 없이 런타임이 멈출 곳을 찾습니다.
채널과 뮤텍스
고루틴들은 한 프로그램의 메모리를 같이 봅니다. 두 고루틴이 같은 변수를 동시에 고치면 한쪽이 쓴 값을 다른 쪽이 덮어씁니다. 값이 엉키는 이런 오류가 경쟁 상태입니다.
막는 도구는 채널과 뮤텍스 둘입니다. 채널은 값을 넘겨줍니다. 뮤텍스는 값을 잠급니다.
채널은 한 고루틴이 값을 넣고 다른 고루틴이 꺼내는 통로입니다. 꺼내는 쪽은 값이 들어올 때까지 잠들어 기다립니다. 값을 넘긴 쪽이 더 만지지 않으면 그 값을 만지는 고루틴은 받은 쪽 하나뿐입니다.
뮤텍스는 한 번에 한 고루틴만 어떤 값을 만지게 하는 잠금입니다. 먼저 잠근 고루틴이 풀 때까지 나머지는 기다립니다.
값을 주고받을 때는 채널을 먼저 쓰기를 권합니다. 캐시처럼 여러 고루틴이 한 값을 오래 나눠 읽고 쓰는 경우에는 뮤텍스를 많이 씁니다.
끝나기를 기다리는 법
고루틴에는 밖에서 붙잡을 핸들이 없습니다. 이름도 번호도 돌려받지 않습니다. 그래서 「저 고루틴이 끝났나」를 물을 방법도 따로 마련해야 합니다.
흔히 쓰는 도구가 Go 표준 라이브러리의 sync.WaitGroup 입니다. 띄울 고루틴 수를 먼저 셉니다. 고루틴이
끝날 때마다 그 수를 하나씩 줄입니다. 기다리는 쪽은 수가 0 이 될 때까지 멈춰 있습니다.
아래 코드의 go func(n int) { ... }(i) 는 이름 없는 함수를 만들어 곧바로 부르는 문법입니다. 끝의
(i) 가 그 호출입니다. 지금의 i 값이 n 으로 들어갑니다. 그래서 고루틴마다 제 숫자를 쥐고 떠납니다.
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 수를 하나 늘린다
go func(n int) {
fmt.Println(n) // 1·2·3 섞인 순서
wg.Done() // 수를 하나 줄인다
}(i)
}
wg.Wait() // 셋이 끝날 때까지
fmt.Println("done") // 맨 끝에 찍힌다
고루틴 셋이 각자 숫자를 찍습니다. 찍히는 순서는 돌릴 때마다 달라질 수 있습니다. done 은 wg.Wait()
가 셋을 다 기다린 뒤라 언제나 맨 끝에 찍힙니다.
고루틴을 쓸 때 치르는 것
고루틴은 밖에서 끌 수 없습니다. 멈추게 하려면 멈추라는 신호를 받을 채널을 미리 넘겨 둡니다. 고루틴은 그 채널을 살피다가 신호가 오면 스스로 돌아옵니다.
요청을 보낸 쪽이 연결을 끊으면 그 요청을 처리하던 고루틴도 멈춰야 합니다. 이렇게 하던 일을 중간에
그만두게 하는 것을 취소라고 합니다. Go 표준 라이브러리의 context 패키지가 이 취소 신호를 나릅니다.
context 가 내주는 채널이 고루틴이 살필 바로 그 채널입니다.
끝나지 못한 고루틴은 치워지지 않습니다. 아무도 값을 넣지 않는 채널에서 기다리는 고루틴은 영영 잠든 채 남습니다. 이런 고루틴이 요청마다 하나씩 쌓이면 메모리가 계속 늘어납니다. 이 현상이 고루틴 누수입니다.
한 고루틴의 사고가 프로그램 전체를 멈추기도 합니다. Go 에서 더 진행할 수 없는 오류가 나면 panic 이 일어납니다. 고루틴 안에서 난 panic 을 그 고루틴 안에서 처리하지 않으면 프로그램이 통째로 끝납니다. 다른 고루틴이 대신 받아 주지 못합니다.
고루틴을 띄우는 데도 비용이 듭니다. 덧셈 한 번처럼 작은 일마다 고루틴을 띄우면 만들고 줄 세우는 비용이 일보다 커집니다. 고루틴은 기다림이 긴 일이나 코어 여럿에 나눠 돌릴 만큼 큰 일에 붙일 때 값을 합니다.
요청이 몰릴 때 고루틴을 제한 없이 띄우면 메모리가 바닥날 수 있습니다. 그래서 동시에 도는 고루틴 수를 정해 두기도 합니다. 미리 띄운 고루틴 몇 개가 일을 나눠 받는 식입니다. 이 구성을 워커 풀이라고 합니다.
모든 고루틴이 서로를 기다리며 잠들 수도 있습니다. 누구도 깨워 줄 수 없습니다. 프로그램은 더 나아가지 못합니다. 이 상태가 데드락입니다.
Go 런타임은 모든 고루틴이 잠든 것을 알아챕니다. 그러면 프로그램을 멈추고 그 사실을 알립니다.
관련 항목
고루틴과 맞세워지는 실행 단위
스레드 · 프로세스 · 코루틴 · 그린 스레드 · 가상 스레드 · 파이버 · 태스크
고루틴을 돌리는 Go 런타임 부품
Go · 런타임 · 스케줄러 · GOMAXPROCS · 네트워크 폴러 · 가비지 컬렉션 · 스택
고루틴끼리 주고받고 기다리는 도구
채널 · select 문 · 뮤텍스 · 잠금 · WaitGroup · context 패키지 · 취소 · 워커 풀
고루틴이 기대는 실행 방식
동시성 · 병렬성 · 선점형 멀티태스킹 · 협력형 멀티태스킹 · 문맥 교환 · 스케줄링 · 시스템 콜
고루틴과 견주는 동시성 모델
CSP · 액터 모델 · 메시지 전달 · 이벤트 루프 · async-await · 비동기 프로그래밍
고루틴에서 자주 터지는 오류
다른 이름: goroutine · goroutines