티스토리 뷰
이번엔 Go의 동시성의 가장 기초가 되는 블록을 소개하려 한다.
이름하여 Goroutines, 고루틴이라고 하겠다.
아주 쉽다. Let it "go"
// Declare your function
func WhateverYouWant() {...}
// Let it "Go"
go WhateverYouWant()
고루틴이라 불리는 이것들은 마치 스레드, 프로세스 같은 것을 떠올리면 비슷한 느낌이지만, 엄연히 다르다. 이름이 다르지 않은가?
스레드와 같이 하나의 함수를 다른 고루틴들과 함께 동일한 주소 공간에서 실행한다.
단 스레드의 관리를 OS가 한다면, 고루틴은 Go런타임이 관리한다. 여기서 말하는 "관리"는 실행, 생성 등의 Scheduling, 이에 동반되는 Context Switch 그에 필요한 레지스터, 메모리 처리 등 거의 모든 것을 말한다.
이 것이 의미하는 바가 무엇일까?
그전에 context switch에 대해 잠깐 짚고 가자. 먼저 멀티 프로세스 환경부터 가보자.
하나의 CPU를 통해 동시에 웹서핑도 하고, 메신저도 할 수 있는 소위 멀티태스킹은 놀라운 기술이다. 이를 뒷받침하기 위해서는 프로세스 간 스위칭이 필수적이다.
인터넷 쇼핑하다 메신저를 잠깐 보고 왔더니 홈 화면으로 돌아가 있으면 상당히 당황스러울 것이다. 이에 등장한 것인 process switching이다. 현재 실행 중인 프로세스의 상태를 보관하고 스위칭할 프로세스의 상태를 올리는 것이다.
일반적으로 다음을 스위칭한다
- registers
- stack pointer
- program counter
- address space
프로세스는 물리 메모리 주소를 직접 사용하지 않고, 이를 매핑한 가상 메모리 주소 공간을 사용하기 때문에 address space switching도 발생하게 된다.
레지스터는 서로 다른 정보를 사용하기 위해 보관하는 게 어쩔 수 없다면, 주소 공간은 같이 쓰면 어떨까?
그래서 스레드가 등장했다. 프로세스 내의 스레드들은 주소 공간을 공유한다. 즉 context switch overhead나 memory management overhead 등이 줄어들게 되는 것이다.
여전히 오버헤드는 존재한다. 레지스터 스위칭 그리고 스케줄링.
커널은 스레드가 무슨 일을 하려는지 알 수 없으니 많은 레지스터를 switch 해야 하고, 스레드의 개별 정보가 스케줄링에 모두 반영되기 어렵다.
예상이 가는가? 고루틴은 한 발짝 더 나아갔다.
기존에 커널이 처리하던 일들을 이제 컴파일러와 Go런타임이 처리한다. 어마어마한 장점이 존재하게 된다. 어떤 것이 있을지 잠시 생각해보자.
먼저 커널이 모르는 수많은 정보들을 컴파일러&런타임이 알고 있는 것이다.
잘 알려져 있듯 Linux를 비롯한 대다수의 커널은 Preemptive Scheduling (선점 스케줄링)을 택하고 있다. 쉽게 말해 프로세스(물론 스레드도 동일)는 커널에 의해서 원치 않게 (예측이 불가능하게) 자원(CPU)을 회수당할 수 있는 방식의 스케줄링이다. 이는 스레드의 자원 독점을 방지하기 위한 목적이 크다.
하지만 Go런타임은 coopertive scheduling을 통해 고루틴들을 관리한다. 즉 특정 고루틴의 실행을 스케줄러가 중지시키고 자원을 선점하기보다는, 고 루틴이 자발적으로 실행은 종료하거나 대기상태로 전환되기를 기다리는 것이다. 어떻게 가능할까?
재밌는 것은 이렇게 coopertive한 서브루틴들을 Co-routine이라고 하는데, 이에 영감을 받아 Go 개발자들은 Go-routine이라고 이름을 지은 것이다.
고루틴은 자원이 불필요한 시점이 되면 자발적으로 스케줄러에게 자신의 상태를 전환시키기를 요구한다. 다음의 function call points에서 자원을 양보한다.
- go 키워드
- garbage collection
- syscall
- primitive 한 blocking들 (channel, mutex 등)
좀 더 풀어보면,
1. go 키워드를 통해 새로운 고루틴을 시작할 때, starvation을 방지하기 위해 스케줄링을 요청한다. 단 새로운 고루틴이 스케줄링 되는 것을 보장하지는 않는다.
2. 보통의 GC라면 STW(stop-the-world) gc를 하겠지만, 즉 모든 고루틴이 멈춰야하겠지만, 우리의 go런타임은 누가 heap을 건드는지 안건드는지 알 수 있으므로 해당하는 고루틴들에 대해 GC전후로 복잡한 스케줄링을 진행한다.
3. 고루틴이 syscall을 발생시키는 경우, 스케줄링을 요청한다. 특히 cgocall (go에선 직접 c 코드를 실행시킬수있다)에서도 발생한다. I/O를 비롯해 이런 경우 고루틴이 직접 CPU를 사용하지 않기에 자원을 양보한다. 계속 강조하지만, Go언어가 모든 걸 하고 있다. 즉, 컴파일 타임에 Go런타임이 고루틴의 syscall을 후킹하도록 하는 느낌으로 생각하면 좋다. 실제 코드도 다음과 같다
// https://github.com/golang/go/blob/master/src/syscall/asm_linux_amd64.s#L17
TEXT ·Syscall(SB),NOSPLIT,$0-56
CALL runtime·entersyscall(SB)
...
CALL runtime·exitsyscall(SB)
RET
4. 유저 코드로 발생하는 blocking이다. 다양하게 존재한다.
- 채널 send/receive가 block 되는 경우
- atomic, sync 패키지들의 blocking operation들
- time.Sleep
- runtime.Gosched (https://golang.org/pkg/runtime/#Gosched)
자 그럼 다른 장점은 어떤 것이 있을까? context switch에서도 강한 이점을 갖게 된다.
스레드 스위칭에서는 SP (Stack Pointer), PC (Program Counter)와 같은 TCB(Thread Control Block)를 저장 후 로드해야 한다. 이때 오버헤드는 PCB (Process Control Block)에 비해서는 훨씬 적다.
하지만, 고루틴에게는 그 것마저 불필요하다. 앞서 말한 바와 같이 well-defined safe-point function call 에서만 고루틴은 프로세서를 양보한다. 그리고 이러한 points들은 보다시피 blocking에 관한 operation들이므로 구동에 필요한 레지스터이외에는 사용하지 않는 상태이다. 따라서 스케줄러는 goroutine switch 에는 단 3개 (PC, SP, DX)만 store/restore 하면 된다.
더 없나? 있다. OS가 할당해주는 stack 대신에 Go런타임이 스택을 고루틴에게 할당해준다.
OS스레드는 시작되기 전에 스택을 할당받고 실행 중에 이 크기를 조정할 수 없기에, 커널은 보수적으로 스택 크기를 제공할 수밖에 없다. 하지만 Go런타임에선 전혀 다른 이야기다.
Go런타임은 유동적으로 고루틴의 스택 사이즈를 조절할 수 있기 때문에, 대단히 타이트한 스택으로 고루틴을 시작시킬 수 있다. 필요하면 더 할당해주면 그만이다.
하나의 스레드의 스택이 수십에서 수천 KB의 크기를 차지한다면, 고루틴의 스택은 단 2KB부터 시작한다. 나이브하게 1 스레드=1MB라고 가정해봐도, 512배의 차이가 난다. (ref. #7514)
이는 여러분이 수십만 개의 고루틴을 생성해도 별 문제가 안된다는 것이다. :)
그 외에도, 커널이 개입하게 되면 유저공간과 커널공간을 넘나드는 오버헤드가 있을 수 있지만, Go런타임&고루틴은 유저공간내에서 동작하므로 그러한 오버헤드가 사라지는 장점도 있다.
사실 단점도 존재한다. 고루틴이 의도적으로 well-define points에 도달하지 않으면 자원을 독점하는 것이 가능하고, 이는 앞서 말한대로 GC에도 영향을 주게된다. 이러한 문제점을 해결하기 위해 non-cooperative scheduling proposal이 등장했고, 이는 Go 1.14에 반영될 예정이라고 한다. 관련해서 문서를 상세히 읽거나 릴리즈가 되면 상세히 다뤄보도록 하겠다. (#24543 참고)
요약해보자.
유저공간에서 동작하는 Go런타임은 극히 가벼운 고루틴들을 적절하게 정의된 시점에서 적은 오버헤드를 가진 context swtiching을 하게끔 cooperative scheduling 한다.
그렇다. 성능이 안 나올래야 안나올 수 없다. CPU를 신나게 굴려보자.
이 정도로 해두고, 예제를 한번 살펴보자.
그전에 짧게 알고 가면 좋을 것은, 고루틴과 스레드는 M:N으로 스케줄링되는데, 다시 말해 하나의 스레드에는 여러 고루틴이 돌아가면서 실행될 수 있고 그 반대도 성립한다는 것이다. 특히 여러분의 go 애플리케이션은 사실 스레드에 바인딩된 하나의 고루틴(main.main을 실행하는)이다. 자세한 내용은 추후에 고루틴-스케줄링 편에서 상세히 다뤄보겠다. (굳은 다짐).
그리고 runtime.GOMAXPROCS 는 사용할 프로세서의 개수를 지정한다. (https://golang.org/pkg/runtime/#GOMAXPROCS)
다음 코드의 실행결과는 어떻게 될까? (https://play.golang.org/p/2Ts3hOcS2pc)
두번째 라인에서 runtime.GOMAXPROCS(1)을 통해 1개의 프로세서를 사용한다고 명시하고있다.
그럼 프로세서 2개를 쓰면 어떻게 될까?
func main() {
runtime.GOMAXPROCS(1)
go func() {
fmt.Println("sub go routine")
for {
}
}()
fmt.Println("going sleep")
time.Sleep(1)
fmt.Println("wake up")
}
이는 Cooperative scheduling을 보여주는 좋은 예시이다.
프로그램은 종료되지 않는다. (물론 playground에서는 알아서 종료시켜준다)
지금까지 배운 걸로 한번 읽어보자. line-by-line으로
0. func main() { : 메인 고루틴이 시작한다
1. runtime.GOMAXPROCS(1) : 1개 프로세서만 사용한다고 알린다
2. go func() {} : 새로운 고루틴을 시작한다. 이 고루틴은 바로 스케줄링이 안된다. 즉 여전히 메인고루틴이 프로세서를 사용한다
3. fmt.Println("going sleep") : going sleep
4. time.Sleep(1) : 메인 고루틴은 1초간 sleep 한다. 이때 자원 양보가 발생한다.
다른 고루틴이 존재하기 때문에, 프로세서의 자원은 다른 고루틴에게 양보된다.
다시 위로 올라가서
5. fmt.Println("sub go routine") : sub goroutine
6. for {} : 무한루프에 빠진다
선점 스케줄링이 아니기 때문에, 메인 고루틴은 sleep에서 깨어나도 실행시킬 프로세서를 받을 수 없어, 마지막 라인은 실행되지 않고 프로그램은 종료되지 않는다.
조금 극단적인 예시이지만, 충분히 cooperative scheduling에 대해서 감이 왔으리라 생각한다.
그럼 어떻게 해야 무한루프에 안 빠질까?
두 고루틴 모두 CPU를 할당받을 수 있도록 코어를 2개 쓰면 된다. runtime.GOMAXPROCS(2)
뭐 싱글코어라고요? (Go playground는 여러분에게 싱글코어만 제공합니다.)
그렇다면 앞서 나온 것처럼 합의된 yield operation을 실행하면 된다.
채널 송수신 등으로 고루틴을 block 시키거나, runtime.Gosched()등으로 명시적으로 양보하면 된다.
다음에는 이러한 고루틴들의 스케줄링을 자세히 살펴보도록 하자
References
https://tip.golang.org/doc/go1.2
https://golang.org/pkg/runtime
https://golang.org/doc/effective_go.html
https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw
https://medium.com/@riteeksrivastava/a-complete-journey-with-goroutines-8472630c7f5c
https://codeburst.io/why-goroutines-are-not-lightweight-threads-7c460 c1 f155 f
https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast
'Let it GO > Go 동시성' 카테고리의 다른 글
Let it GO. Go의 동시성 - 문법 (0) | 2019.11.07 |
---|---|
Let it GO. Go의 동시성 (0) | 2019.11.06 |