고루틴 — Go 언어의 동시성 모델
동시성을 지원하는 언어들이 많지만, 고루틴 만한게 없었다.
오늘은 많은 블록체인의 코어 로직으로 선택된 언어인 Golang의 하부구조에 대해 다루고자 한다. 요즘에는 블록체인 개발 트렌드가 Rust로 많이 옮겨간 것 같은데 Rust가 인기를 얻기 이전에는 블록체인 개발하면 코어 로직으로 Go언어가 많이 선택되었으며 대표적으로 이더리움, 코스모스, IPFS 등이 있다.
그래서 궁금해졌다. Golang의 장점이 무엇일까? 왜 블록체인 뿐 아니라 Docker, k8s 등 많은 프로젝트에서 고 언어를 선택했을까? 오늘은 그 핵심 컴포넌트 중 하나인 고루틴에 대해서 알아본다.
Golang은 미니멀리즘 언어이다.
Golang 공식 홈페이지를 보면 Golang을 easy to build simple, reliable and efficient software 라고 소개한다. 사실 “easy to -” 라는 문구는 많은 프로그래밍 언어가 마케팅용 문구로 사용하고 있다. 그런데 Golang의 설계를 보면 easy를 넘어서 미니멀리즘을 추구하는 언어라고 부를만 하다.
Golang 에는 아래 항목이 없다.
- 메모리 관리 : 가비지 컬렉터가 메모리를 관리한다
- Class : 객체지향 개념이 없다 (구조체만을 이용하고 다만 OOP 스타일로 코드는 작성할 수 있다.) 객체지향 개념을 제거했으니 당연히 상속, 생성자 등의 개념이 존재하지 않는다.
- Try-Catch-Finally
- Generic Type : 지금은 없지만 Go 1.18 버전에서 추가 될 예정이다
- While, Do-While : for 구문 하나만을 이용한다.
- 동적 타입으로 인한 고통
마지막으로 오늘 이야기 할 주제인 쓰레드(Thread)를 다루는 방법이 Golang에는 존재하지 않는다.
쓰레드가 문제야 문제 — 고루틴?, 코루틴?, 파이버?
고 언어는 태생부터 언어의 미니멀리즘을 추구했다. 그 흔한 객체지향 개념도 도입하지 않았고 고 언어에서 복잡한 문법은 오직 구조체 뿐이다. 그리고 또한 고 언어에는 쓰레드를 수동으로 생성하고 제어하는방법이 없다! C언어에는 pthread 패키지와 자바에서는 Thread 클래스를 통해 쓰레드를 생성하여 동시성 프로그래밍을 하는데, Go언어에서는 쓰레드를 다루는 방식이 존재하지 않고 go 키워드를 이용한 고루틴 생성 로직만 존재한다. 쓰레드가 동시성을 다루는데 가장 기본이 되는 모듈인걸 감안하면 꽤 파격적이다.
언어가 이렇게 발전된 계기에는 쓰레드가 문제였다는 인식이 배경이 된다. Goroutine, Coroutine, Fiber, Akka와 같은 경량 쓰레드(Micro-Thread | Light-weight Thread)동시성 모델들과 Node.js와 같은 Event-Driven 모델이 탄생하고 주목받은 배경을 공통적으로 추려보면 쓰레드가 오늘날의 서버가 요구하는 스펙과는 맞지 않았기 때문이다.
현대 서버 프로그래밍의 발전 과정을 보면, 서버의 성능이 낮은 이유를 대부분 CPU/GPU같은 연산 하드웨어 스펙이 아닌 I/O Time에서 찾을 수 있다. I/O Time이란 외부 리소스에서 결과를 받아오기 까지의 시간을 말하며 쓰레드는 이 시간 동안 대기 상태에 빠져있다.
쓰레드 T와 I/O에 대한 설명
I/O 종류에는 파일 입출력을 위한 시스템 콜부터 다른 서버로부터 결과를 얻기 위한 통신 비용, 데이터베이스가 메모리에서 하드웨어로 데이터를 적재하는 비용, 데이터가 여러 데이터베이스에 존재하면 2PC 분산 트랜잭션을 처리하는 비용이 I/O Time에 해당한다. 이 I/O가 발생하는 동안에는 Thread는 결과를 받기 까지 대기 상태에 들어가게 된다.
모던 서버 시스템에서 대부분의 병목은 연산 시간 보다 I/O Time이 훨씬 길고 자주 발생하기 때문에 발생한다. 즉, 이 병목을 해결하는 방법은 프로세스 혹은 쓰레드를 대기 상태로 두지 않고 계속 일을 시키는 방법인데 대표적으로 Node.js에 적용된 이벤트-루프와 고 언어의 고루틴이 있다. 근데 여기서 우리는 한 가지 궁금증이 생긴다. 아니 원래 쓰레드가 컨텍스트-스위칭 하면서 작업을 번갈아 가면서 하는건데 그게 병목에 걸리는게 큰 문제인가? 사실 쓰레드는 상당히 비싼 자원이다.
쓰레드도 비싸더라..
사실 쓰레드도 비싼 자원이다.
- Thread (T)는 보통 256kb~2MB 사이의 스택 크기를 가진다. 전통적인 서버 구성에서는 유저 당 쓰레드 하나에 할당하는게 일반적이었는데, Thread의 스택 사이즈를 1MB라고 한다면 4GB 메모리를 가진 서버에서는 4096개의 쓰레드만을 동시에 생성해서 사용할 수 있다.
- 컨텍스트 스위칭이란 하나의 작업 상태를 저장하고 다른 작업으로 전환하여 수행하는 것을 말한다. 프로세스를 스위칭 할때는 메모리 상태를 직접 PCB에 저장했어야 해서 비용이 굉장히 비쌌는데, 사실 쓰레드는 이를 해결하기 위해 공유 메모리를 사용한다는 개념이었다. 그래서 쓰레드를 스위칭하는게 프로세스를 스위칭 하는 것 보다 저렴한 것은 사실이지만, 쓰레드도 각종 레지스터 정보들은 전부 저장해야 한다.
(e.g. AVX, SSE, FP, PC, SP 등) - 쓰레드를 생성하는 것도 비용이 많이 나간다. 생성은 시스템 콜(System call)을 요구 하는데 이 때 OS 레벨에서 컨텍스트 스위칭이 발생하고 OS로부터 사용 가능한 메모리 영역대를 할당 받게 되서 레이턴시가 높아 효율적이지 않다.
고루틴
Goroutine은 Go runtime환경에서 동작하는 쓰레드보다 굉장히 가벼운 실행 조각이다. 초기에 생성할 때 2KB의 매우 작은 스택 크기만을 필요로 하며 가지고 있는 레지스터도 프로그램 카운터(PC)와 스택 포인터(SP) 정도가 전부이다. 고루틴은 함수 단위로 실행할 수 있으며 함수 실행 앞에 go 라는 키워드를 붙여서 생성한다.
go func() {
// Do some work.
}()
원래 Go runtime은 Go 1.14 이전 버전에는 C언어로 작성되어 있었다. Go 1.14부터 runtime을 Go언어로 작성하게 되면서 코어 코드 해석이 한결 편해졌다. [3] 링크에는 고루틴의 구조체와 스케쥴러가 고루틴을 실행할 때 다루는 구조체인 gobuf가 정의되어 있다.
type g struct {
stack stack // 스택
stackguard0 uintptr
stackguard1 uintptr
m *m // m (고루틴이 실행될 쓰레드)
sched gobuf
// ...
}
type gobuf struct {
sp uintptr // 스택 포인터
pc uintptr // 프로그램 카운터
g guintptr // 고루틴 번호
ctxt unsafe.Pointer
ret uintptr // 리턴 포인터
lr uintptr // 리턴 레지스터 (고루틴 종료 후 돌아갈 장소)
bp uintptr
}
GMP Model과 Golang의 동시성 모델
Golang의 동시성 모델의 기원은 1978년 Hoare가 저술한 “Communicating Sequential Process (이하 CSP)”논문[1]에 기반한다고 알려져 있다. CSP는 필자가 이해한 바로 따르면 단순히 공유 메모리를 사용하지 않고 채널(Channel)을 통해 데이터를 공유해서 뮤텍스, 세마포어를 사용하지 말자는 개념이다. 많은 병렬 프로그램이 공유 메모리로 인해 문제가 발생하기 때문에 이를 자제하자는 뜻인데 이런 모델은 Akka의 Actor Model과 많이 유사하다.
다음으로 고루틴에 대해 설명해보고자 한다. 고루틴은 실행흐름을 함수로 표현하는 논리적 단위인데 Go runtime에서 생성된 고루틴의 스케쥴링을 담당한다. Go runtime의 코어 코드를 보면 친절하게 주석으로 런타임 환경의 개념어들이 정리되어 있다.[2] Processor P는 논리적 프로세서를 의미하며 고 언어 코드를 실행하기 위한 정보를 가지고 있다. M은 실제 Worker Thread를 의미하며 M을 실행하려면 반드시 하나의 P와 연관되어야 한다. G는 고루틴을 의미한다.
[그림 1] M은 고루틴을 실행하기 위해서 하나의 P와 연관되어 있어야한다.
필자는 처음 Go runtime을 이해할 때 P를 프로세스로 잘못 이해해서 어려움을 경험했는데, 여기서 P는 논리적인 프로세서를 의미한다. [3] 링크에 보면 구조체 P가 가지고 있는 정보 중에는 GC가 발생할 때 쓰기 장벽을 지원하기 위한 변수와 고루틴 리스트를 가지고 있는 작업 큐를 가지고 있다. M의 경우에는 실제 OS 쓰레드와 관련된 변수가 많이 보인다.
type p struct {
id uint32
// ...
runqhead uint32
runqtail uint32
runq [256]guintptr // 로컬 고루틴 큐
// ...
gcw gcWork
}
Go 런타임 스케쥴러
고루틴은 Go runtime에 포함되어 있는 Golang Scheulder가 실행하는 쓰레드 스케쥴링에 의해서 실행된다. 고루틴은 쓰레드의 상태와 무관하게 항상 생성될 수 있으며 고루틴은 소수의 스레드 위에서 실행된다. [그림 2]는 Golang Scheduler의 대략적인 구성도를 보여준다.
[그림 2] Go runtime 스케쥴러의 구성
① (LRQ) 프로세스 P는 내부의 로컬 실행 큐 (Local Run Queue)를 가지고 있는데 큐에는 실행 가능한 고루틴이 저장되어 있다. M은 프로세스 P의 작업 큐를 가져와서 순차적으로 실행하게 된다.
② (GRQ) Global Run Queue는 LRQ에 속해있지 않은 고루틴이 저장되게 된다. 고루틴은 최대 10ms 까지 동작한다. 10ms가 지난 후에는 대기 상태에 들어가 GRQ에 들어가게 된다. 만약 하나의 P의 LRQ가 다 소진된다면 P는 먼저GRQ로부터 고루틴을 가져오고 GRQ도 비어있다면 다른 P의 LRQ로부터 작업을 뺏어온다(Work Stealing).
③ (Scheduling) 원래 고 런타임 스케쥴러는 일정 시간 마다 작업을 중단하는 선점 스케쥴링(Preemptive Scheduling) 방식이 아니라 작업 하고싶은대로 다 한다음에 쓰레드 사용 할 자리를 넘겨주는 협력적 스케쥴링(Cooperative Scheduling) 방식이었다. Go 1.14[5] 에서 선점 스케쥴링 개념이 추가되었는데 기존 고루틴에서 연산이 오래걸리는 작업을 할당해두면 GC가 계속 딜레이 되어서 10ms 제한 규칙을 추가했다고 한다. Go 언어에서 GC는 최대 가능하면 10ms 이내에 끝내도록 하기 때문에 GC가 자주 수행되도록 변경되었다.
④ (왜 Processor P가 필요한가?) 이 질문에 대한 답은 이 링크에서 구했다. 우선 첫 번째로 지역성을 부여하기 위해서인데 동일한 프로세서 생성된 고루틴은 같은 프로세서에 할당되어 실행되는게 좋다. Go 언어의 메모리 레이아웃을 설명한 글에서는 그 이유를 캐시 때문에 Lock을 들고 있지 않아도 된다고 한다. 두 번째로 쓰레드는 어떠한 이유에서든 Blocking될 수 있는데 이 때 쓰레드에서는 어떠한 코드도 실행되지 않기 때문에 LRQ 에 있는 고루틴들이 계속 대기상태에 빠지게 된다. 따라서, 실행 컨텍스트를 다른 쓰레드에게 넘겨주기 위해서 논리적 개념인 P를 도입했다.
[그림 3] Net poller와 Blocking Goroutines
⑤ (Sync/Async System Call) 시스템 콜은 동기/비동기 방식으로 동작할 수 있다. 예를 들어 파일-시스템 같은 경우에는 대부분 동기방식으로 동작하고 네트워크 I/O 같은 경우에는 비동기 방식으로 동작한다. 만약 고루틴이 비동기 시스템 콜을 발생시키고 대기 상태에 들어간 경우에는 이를 독립된 쓰레드인 Net poller에서 관리한다. Net poller는 비동기 시스템 콜이 작업이 끝났다는 이벤트를 수신하고 이를 다시 LRQ에 넣어서 실행될 수 있도록한다. 이렇게 Net poller를 별도로 분리해서 이미 P와 연관된 쓰레드 M이 재사용될 수 있도록한다. 반면, 동기 시스템 콜의 경우에는 쓰레드도 같이 블럭킹 시키기 때문에 G와 M을 같이 묶어서 블럭킹 시키고 새로운 M을 생성해서 P에 연관시킨다. (쓰레드 M을 재사용하는게 목적이기 때문에 비동기 파일 시스템 콜을 지원하는 OS에서는 이 또한 Net poller에서 처리될 수 있다)
마무리
Go언어는 지금도 발전하고 있는 언어이기 때문에 위 내용이 미래에 봤을 때 달라질 수 있다. 하지만, 쓰레드가 문제라는 점과 이를 재사용하기 위한 다양한 설계 고민이라는 것을 이해한다면 새로운 내용도 무리없이 이해할 수 있을 거라고 생각한다.
다양한 언어로 동시성을 직접 제어하는 코딩을 해봤는데, 고루틴과 채널을 이용한 개념은 내가 현재 시스템에서 무슨 일이 발생하고 있는지 이해할 수 있는 미시적 시각을 제공한다. 예를 들어, 자바와 같은 언어의 경우 대부분의 시스템 동작을 프레임워크에 이관하고 개발자는 추상화된 개념 위에서 로직을 만든다는 느낌이 강한 반면에 Go언어는 직관적이고 무슨 일이 발생했을 때 이를 바로 이해하기 쉽다고 느꼈다.
참고자료
[1] https://dl.acm.org/doi/abs/10.1145/359576.359585
[2] Go runtime — proc.go
[3] Go runtime — runtime2.go
[4] Scalable Go scheduler
[5] Go 1.14 Release Notes
[6] https://www.youtube.com/watch?v=-K11rY57K7k
[7] https://rakyll.org/scheduler/
[8] https://developpaper.com/gmp-principle-and-scheduling-analysis-of-golang-scheduler/
[9] http://www1.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf