이번 스터디에서는 고루틴의 동작 방법에 대해서 조사했습니다. 좀 더 많은 자료가 있지만 자세한 내용은 정리해서 올릴 예정입니다.
출처: http://blog.nindalf.com/how-goroutines-work/
서론
만약 당신이 Go언어를 배우는 것이 처음이거나 “동시성은 병렬 구조가 아니다”라는 문장이 당신에게 의미가 없다면, Rob Pike의 그 주제에 대한 뛰어난 논의를 확인해보세요. 이 영상을 보는데 30분 정도 걸리지만 이 영상을 보면 절대 후회하지 않을 것이라고 생각합니다.
동시성과 병렬 구조의 차이점을 요약하자면, 사람들이 동시성이라는 단어를 들었을 때 흔히 동시성과 관련된 단어로 병렬 구조를 떠올립니다. 하지만 둘은 서로 아주 분명히 다른 개념입니다. 프로그래밍에서 동시성은 독립적으로 과정들을 수행하는 구성 요소입니다. 반면에, 병렬 구조는 가능한 한 관련된 계산의 동시 실행입니다. 동시성은 많은 것들을 한꺼번에 다루지만 병렬 구조는 한 번에 여러 가지 일을 합니다.
우리는 Go에서 동시성 프로그래밍을 사용할 수 있습니다. Go는 고루틴과 고루틴 사이에서 데이터를 전달하는 방법을 제공합니다. 그 중에서 우리는 고루틴에 대해서 자세히 알아보겠습니다.
고루틴과 쓰레드의 차이점
Go는 고루틴을 사용하고 자바, C++과 같은 언어는 쓰레드를 사용합니다. 둘 간의 차이점은 무엇일까요? 우리는 3가지 요소를 통해서 확인 할 수 있습니다. 바로 메모리 소비, 설치와 철거 비용, Context Switching 비용입니다.
메모리 소비
고루틴은 생성하는데에 많은 메모리를 필요로 하지 않습니다. 오직 2kB의 스택 공간만 필요로 합니다. 고루틴을 할당하고 필요에 따라 힙 저장 공간을 확보하여 사용합니다. 반대로 쓰레드는 쓰레드의 메모리와 다른 쓰레드의 메모리 간의 경비 역할을 하는 Guard page라고 불리는 메모리 영역과 함께 1Mb(500배 더 큼)로 시작합니다.
따라서 수신 요청을 처리하는 서버는 문제 없이 요청 한 건 당 하나의 고루틴을 만들 수 있지만, 요청 한 건 당 하나의 쓰레드는 결과적으로 OutOfMemoryError가 일어나게 될 것 입니다. 이런 일은 자바에 한정되어 있는 것이 아닙니다. 동시성의 주요 수단으로 OS 쓰레드를 사용하는 언어라면 언젠가는 이 문제에 대면할 것입니다.
설치와 철거 비용
쓰레드는 거대한 설치와 철거 비용을 가집니다. 왜냐하면 쓰레드는 OS로부터 리소스를 요청해야 하고 작업이 끝나면 리소스를 돌려줘야 하기 때문입니다. 이 문제의 차선책으로는 쓰레드의 Pool을 유지하는 것입니다. 대조적으로, 고루틴은 런타임에서 만들어지고 파괴되는 작업들이 매우 저렴하다. 그렇기 때문에 Go는 고루틴의 메뉴얼 관리를 지원하지 않습니다.
Context Switching 비용
만약 쓰레드가 Blocking된다면 다른 쓰레드가 그 자리를 스케쥴링해야 합니다. 쓰레드들은 우선적으로 스케쥴링되고, 쓰레드가 바뀔 동안, 스케쥴러는 모든 레지스터들을 save/restore해야 합니다. 즉, 16개의 범용 레지스터, PC(Program Counter), SP(Stack Pointer), segment 레지스터, 16개의 XMM 레지스터, FP coprocessor state, 16개의 AVX 레지스터, 모든 MSR들 등을 save/restore해야 합니다. 이것은 쓰레드들 간의 빠른 전환이 있을 때 매우 중요합니다.
고루틴은 협조적으로 스케쥴링되고 교체가 일어날 때, 오직 3개의 레지스터만이 save/restore되기 위해 필요합니다. 바로 Program Counter, Stack Pointer 그리고 DX입니다. 비용은 훨씬 덜 듭니다.
일찍이 논의했던 바와 같이, 고루틴의 갯수는 일반적으로 훨씬 많지만, 2가지 이유에 의해 교체 시간에 차이점을 낳지 않습니다. 오직 동작하는 고루틴들만이 고려되고, Blocking된 것들을 고려되지 않습니다. 또한, 현대의 스케쥴러들은 O(1) complexity를 가집니다. 즉 교체 시간이 고루틴의 수에 의해 영향을 받지 않는다는 의미입니다.
어떻게 고루틴이 실행되는가
일찍이 언급했던 바와 같이, 런타임은 프로그램의 시작부터 끝나는 시점까지 내내 고루틴을 관리합니다. 런타임은 모든 고루틴을 다중화되어 있는 조금의 쓰레드들에 할당됩니다. 어느 시점에서, 각각의 쓰레드는 하나의 고루틴을 실행할 것입니다. 만약 그 고루틴이 Blocking되었다면, 그 쓰레드는 자기 대신에 실행할 다른 고루틴으로 교체할 것입니다.
고루틴들이 협조적으로 스케쥴링되기 때문에, 계속해서 고리모양을 만드는(loop하는) 고루틴은 동일한 쓰레드에서 다른 고루틴들을 굶겨 죽일 수 있습니다. Go 1.2에서, 이 문제는 function을 입력할 때 Go 스케쥴러가 가끔 작동함으로써 다소 완화되고, 따라서 non-inlined function 명령을 포함하는 루프를 선점합니다.
고루틴 Blocking
고루틴은 저렴합니다. 또한 고루틴은
- 네트워크 입력
- sleeping
- 채널 오퍼레이션
- sync 패키지 primitive들로 인한 블로킹
때문에 블록되었을 때, 자신이 multiplex된 쓰레드를 블록하지 않습니다.
수많은 고루틴들이 만들어짐에도 불구하고, 만약 대부분의 고루틴들이 위 목록들 중 하나에 Blocking당한다면 런타임이 대신에 또다른 고루틴을 스케쥴링하기 때문에 이는 시스템 리소스들의 낭비가 아닙니다.
간단히 말해서, 고루틴들은 쓰레드보다 가벼운 관념입니다. Go 프로그래머는 쓰레드를 다루지 않고, 유사하게 OS는 고루틴의 존재를 알지 못합니다. OS의 관점에서, Go 프로그램은 이벤트 기반 C 프로그램과 같이 행동할 것입니다.
쓰레드와 프로세서
비록 당신이 런타임이 만들 쓰레드들의 수를 직접적으로 조정할 수 없더라도, 프로그램에 의해 이용되는 프로세서 코어들의 수를 조정하는 것은 가능합니다. 이것은 runtime.GOMAXPROCS(n)를 호출하는것과 함께 GOMAXPROCS 변수를 설정하는 것으로 이루어집니다. 코어들의 개수를 증가하는 것은 아마 당신의 프로그램의 성능을 증가시키는 것에 필수적이지는 않을 것입니다. 프로파일링 툴들은 당신의 프로그램을 위한 이상적인 코어의 개수들을 찾는 데 사용될 수 있습니다.
결론
다른 언어과 마찬가지로 하나의 고루틴보다 더 많은 고루틴들에 의해 공유된 리소스의 동시적인 접근을 방지하는 것은 중요합니다. 여기서 Channel을 사용해서 고루틴들 간에 데이터를 전송하는 것이 가장 좋습니다. 즉, 메모리 공유에 의해 소통하지 마세요. 대신에, 소통에 의해 메모리를 공유하세요. (왠만하면 Channel을 사용하라는 뜻)
마지막으로, 저는 강력히 당신이 C. A. R. Hoare가 쓴 Communicating Sequential Processes를 보기를 추천합니다. 이 사람은 정말 천재입니다. (1978년 출판된)이 책에서 그는 어떻게 프로세서의 단일한 핵심 성능(core performance)이 마침내 고원(plateau)이 되고 chipmaker가 대신에 코어들의 수를 증가시킬 것이라는 걸 예측했습니다. 이것을 이용한 그의 제안은 Go의 디자인에 깊은 영향을 주었습니다.