Golang에 대한 소개글들을 보면 Golang은 좋은 성능과 뛰어난 동시성을 지원한다 라는 글을 많이 볼 수 있을것이다.
그러면 Golang은 어떻게 동시성을 다룰까??
A goroutine is a lightweight thread managed by the Go runtime.
-go tour-
Golang에서는 goroutine이라는 경량 스레드를 지원한다.
Goroutine의 장점을 이렇게 소개한다.
이 내용만 봐서는 이해하기 어렵다... 차근차근 정리를 시작해 보자
항상 어떤 기술이 좋다고 한다면 비교 대상의 기술, 그리고 왜 더 좋은지가 꼭 필요하다. 이번 비교 대상은 Thread가 될것이다.
Creating and destroying a thread and its associated resources can be an expensive process in terms of time.
-thread_pool wiki -
먼저 스레드를 봐보자
스레드를 생성하는 경우 OS에 스레드 리소스를 요청하여 생성하고 사용을 완료한 경우에는 다시 OS에 반환해야 하는데 이러한 과정에서 오버 헤드가 발생하게 된다.
반면 고루틴은 runtime에 의해 관리되기 때문에 OS에 리소스 요청하는 경우가 스레드에 비해 매우 적다.
runtime에서 고루틴을 어떻게 관리하는 지는 아래에서 다시 확인해 보자.
컨텍스트 스위치는 프로세스 또는 스레드의 상태를 저장하여 나중에 복원 및 실행을 재개할 수 있도록 한 다음 이전에 저장된 다른 상태를 복원하는 프로세스입니다.
-context wsitch-
ContextStiwch는 스레드의 상태 전환을 하는거구나!
그러면 상태 전환은 왜 하는거지?? 라는 의문이 들 수 있다.
CPU코어는 한 번에 한 명령만 수행할 수 있다.
코어가 하나인 컴퓨터에서 웹 페이지를 불러오기를 기다리는 동안 만약 CPU가 다른 프로그램을 작업 하고있다면 브라우저는 멈춰 있는것처럼 보일것이다.
이러한 문제를 해결하고자 리눅스 스케줄러는 아주 짧은 시간 간격으로 스레드를 할당한다.
각 스레드에는 일반적으로 동시에 실행되는 것처럼 보일 정도로 짧은 시간이 할당된다.
os스케쥴링에 따라 스레드를 빠르게 전환해가면서 수행하여 우리에게 마치 동시에 수행하는 것처럼 보인다.
위 스레드를 전환할때 상태를 변경한다. 이것을 ContextSwitch라고 부른다.
위에서 말하는 상태에는 총 3가지가 있다.
문제는 컨텍스트 스위칭 작업 동안 CPU가 실제 작업을 수행하지 않는다는 것이다.
즉 CPU가 일을 제대로 하지 않고 노는 시간이 많아 진다는 뜻이다.
반면 고루틴은 Runtime Scheduler에서 관리된다. 고루틴의 상태 전환이 아예 없다는 것은 아니지만 thread와는 다르며 근본적으로 빠를 수 밖에 없는 이유는 커널(OS) <-> 유저 모드의 전환이 일어나지 않는다는 점이다.
Runtime Scheduler 는 Go 프로그램이 실행되는 시점에 함께 실행되며 고루틴을 효율적으로 스케줄링 시키는 역할을 수행한다.
스케줄러는 커널 스레드는 비싸기 때문에 되도록 적게 사용하며 다수의 고루틴을 하나의 스레드에 할당하여 동시성을 성능을 높인다.
조금 더 자세히 알아보자
Go는 G, M, P 구조체를 가지고 있으며 M:N스레딩 모델을 구현하고 있다.
G(Goroutine)
고루틴을 의미한다.
M(Machine)
작업을 실행하는 OS 스레드를 의미한다.
M은 P의 대기열에 할당되어 있으며 상황에 따라 스피닝 상태로 있을 수 있다.
P(processor)
논리적 프로세스를 의미 한다.
GOMAXPROCS로 개수를 설정할 수 있으며 기본값은 시스템의 CPU Core개수이다.
위에서 말한것처럼 고루틴들은 하나의 스레드에 멀티 플렉싱 되어 있다.
고루틴(G)가 생성되면 P의 큐(LRQ)에 할당되고 큐에 할당되지 못한 고루틴은 GRQ(Global Run Queue)에 할당된다.
M은 P로부터 G를 가져와 실행하지만 P에 G가 없는 경우 무작위로 다른 P의 G를 훔친다.
다른 P에도 훔칠게 없다면 GRQ를 확인하고 Net Poller까지 확인한다.
위 전략을 work stealing 이라고 하는데 이러한 전략을 통해 효율적으로 스케줄링 하며 하나의 P에 대기열이 몰려있는 것을 완화한다.
스레드와 같이 고루틴 실행중 syscall이 발생하게 되면 blocking이 발생하게 된다.
이 경우 스레드에 영향을 주기 때문에 하나의 스레드에 여러개의 고루틴이 대기하고 있는 상황에서 큰 성능 저하를 불러올 수도 있다.
Go의 스케쥴러는 계속해서 멈추지 않고 스케쥴링을 할 수 있도록 syscall이 발생한 고루틴을 다른 스레드로 넘겨 다른 고루틴들이 정상적으로 작동할 수 있도록 보장한다.
이후에 blocekd 되어있던 고루틴이 작업이 완료 되면 다시 LRQ에 들어가 간다.
여기서 의문점이 든다.
고루틴에서 system call사용하여 새로운 스레드 만들만한 작업을 하게되면 고루틴 사용하는 이유가 없는거 아니셈???
꼭 그렇지만은 않다.
go의 라이브러리 중에서는 syscall로 인해 스레드를 블로킹 하게 될만한 작업 들은 select, poll, epoll등을 활용하여 항상 새로운 스레드를 만들어 내지 않는다.
위 Net Poller가 그 예 이다.
(모든 라이브러리들이 그렇지는 않으니 사용전에 확인을 해보자)
the minimum size of the stack when a goroutine is created has been lifted from 4KB to 8KB.
-Go 1.2 Release-
힙과 스택이 서로 덮어쓰면 재앙이 되기 때문에 운영 체제는 일반적으로 스택과 힙 사이에 보호 공간(guard page)을 배치한다.
스레드의 스택 요구 사항은 예측하기 어렵고 가드 페이지와 함께 사용되기 때문에 많은 양의 메모리가 쓰일수밖에 없다.
반면 Go는 고루틴을 생성 할 때 스택의 최소크기를 4KB ~ 8KB 뿐이 사용하지 않는다.
그리고 필요한만큼 스택과 힙을 할당 또는 해제 하며 줄어들거나 커진다.
고루틴과 스레드의 간단하게 비교하며 어떻게 스케줄링 하는지 알아봤다.
위처럼 다중화된 스레드 모델을 제공하여 멀티코어를 제대로 활용하는 장점은 Go가 많은 사랑을 받는데 큰 역할을 하지 않았을까 생각한다.
사실 다루지 못한 내용들이 더 많은것 같다. 기회가 된다면 해당 글에 channel도 같이 추가하는게 좋을듯 싶다.
부족한 부분은 빠르게 공부해서 내용을 추가하도록 해야겠다.