Golang은 뛰어난 동시성 지원을 장점으로 자주 언급된다. 다른 프로그래밍 언어에서는 쓰레드를 활용하여 동시성 프로그래밍을 하지만 Golang에서는 고루틴을 활용하여 동시성 프로그래밍 개발을 하게 된다. 그렇다면 고루틴이 무엇이고 고루틴은 쓰레드와 무엇이 다르며 어떻게 동작하기에 동시성 처리 작업에 장점으로 언급되는 걸까?
Go 언어에서는 고루틴을 다음과 같이 소개한다
“lightweight thread managed by the Go runtime”
한 마디로 Go 런타임에서 관리되는 경량 쓰레드
라고 소개한다.
여기서 말하는 경량 쓰레드는 OS 레벨의 쓰레드와는 다른 개념이고 Go 언어 런타임에서 관리되는 논리적(혹은 가상적) 쓰레드라고 한다. 고루틴은 OS 쓰레드보다 더 가볍고 비동기적인 동시성 처리를 위해서 만들어진 것으로 Go 프로그램 런타임시에 생성하여 사용하고 소거하는 형식이기 때문에 Go 런타임이 관리하는 경량 쓰레드라고 하는 것이다.
아래는 Go에서 고루틴을 활용해본 예시 코드이다.
package main
import (
"fmt"
"sync"
)
func main() {
wg := sync.WaitGroup{}
wg.Add(1)
counter := 0
// goroutine 생성 및 다음 익명 함수의 작업을 할당
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
counter++
}
}()
wg.Wait() // goroutine 작업의 완료까지 대기
fmt.Println("counter :", counter)
}
이전에 쓰레드를 자바를 이용해 학습하고 연습해본 경험이 있는데, 간단한 예시이지만 샘플 코드만 보더라도 확실히 Go에서 쓰레드를 활용하는 코딩이 훨씬 간편하다는 것을 알 수 있었다.
고루틴과 쓰레드의 차이점은 크게 3가지의 차이점으로 분류할 수 있다. 즉 아래 차이점이 고루틴이 경량 쓰레드
라고 불리우게 된 이유이자 장점과 같은 요소들로 볼 수 있다.
Go에서 고루틴을 생성할 때 많은 메모리를 필요로 하지 않는다.
OS 쓰레드는 생성/소거 시 많은 비용이 들어가게 된다. OS로 부터 쓰레드 리소스를 요청을 통해 생성하고 쓰레드 작업 완료 시 해당 리소스를 OS에 다시 반환해야하기 때문이다. 그래서 OS레벨의 쓰레드를 직접 Call하여 쓰레드를 생성/소거하는 언어들은 이 문제를 풀어내기 위해서 쓰레드 Pool을 활용하여 이러한 비용 문제를 해소하려는 노력이 있다. 반면에 Go의 고루틴은 런타임에서 논리적(즉, OS레벨 쓰레드와 달리 하드웨어에 의존적이지 않음)으로 생성되고 소거되기 때문에 상대적으로 해당 작업들에 소모되는 비용이 저렴하다. 따라서 Go언어에서는 이러한 고루틴을 수동 관리 하는 매뉴얼을 제공하지 않는다고 하고, 고루틴은 OS 쓰레드와 상대적으로 앞선 OOM과 같은 문제에 대한 걱정/부담없이 생성하여 사용해도 된다고 표현하곤 한다 (그렇다고 막 사용해선 안됨..).
하나의 쓰레드가 특정 작업을 처리하기 위해서 Blocking된다면 다른 쓰레드가 그 대신하여 처리하도록 스케줄링되어 있다. 쓰레드가 스케줄링되고 쓰레드가 교체되는 동안에 스케줄러에서는 모든 레지스터들을 save/restore해야 한다. 일반적인 쓰레드 Context Switching 작업 시 16개의 범용 레지스터, PC(Program Counter), SP(Stack Pointer), Segment 레지스터, 16개의 XMM레지스터, FP coprocessor state, 16개의 AVX 레지스터, 모든 MSR들 등을 save/restore 작업을 진행해야 한다. 따라서 이와 같은 작업을 처리하기 때문에 생각보다 Context Switching 시 많은 비용을 소모하게 된다고 말하는 것이다. 하지만, 고루틴은 3개의 레지스터(PC,SP,DX)만 save/restore 작업을 하기 때문에 상대적으로 쓰레드보다 Context Switching 비용을 적게 소모한다.
위 내용을 통해 고루틴이 무엇이고 쓰레드와의 차이점을 보았고, OS 쓰레드와는 달리 고루틴은 Go 언어 자체적으로 구현화된 쓰레드라 볼 수 있으며 종합했을 때 OS 쓰레드에 비해 상대적으로 메모리 비용이 적게 소모되기 때문에 경량 쓰레드
라고 부르게된 이유를 알 수 있었다.
동시성 개발 측면에서 충분히 장점 중 하나로 볼 수 있을 것 같고, 추가적으로 Go언어는 런타임에서 스케줄러가 이러한 고루틴들을 스케줄링하고 최적화하는 기법을 통해 시스템 리소스를 효율적으로 활용할 수 있다고 한다. 그래서 Go 런타임 환경에서의 스케줄러에 대하여 알아보려고 한다.
Go언어는 프로그램 시작과 끝나는 시점까지 런타임 내내 고루틴들을 관리한다. 또한 고루틴은 M:N 쓰레드 모델(LWP)을 채택하고 있어 기존의 쓰레드/쓰레드Pool를 활용하는 방식보다 더 가볍고 빠른 특성을 지니고 있다 한다. 고루틴 스케줄러를 알아가기 앞서 쓰레드 타입별 특징에 대해서 알아보자.
User-Level 쓰레드
: User-Level의 영역인 사용자 라이브러리를 통해 쓰레드 관리 기능이 제공되고, 여러 User-Level 쓰레드가 1개의 OS 쓰레드 위에서 동작하는 형태(즉, 1:N)Kernel-Level 쓰레드
: Kernel-Level 쓰레드는 운영체제가 지원하는 기능으로 구현되어 즉 Kernel이 쓰레드의 생성, 스케줄링을 담당하게 된다. 1개의 OS 쓰레드에 1개의 User-Level 쓰레드를 할당(즉, 1:1)Combined
: Kernel-Level 쓰레드와 User-Level 쓰레드를 혼합하여 사용하는 방식, 위 두 방식의 장점을 혼합한 방식이라 할 수 있음(즉, M:N)Go 언어는 앞서 언급한 것 처럼 Combined(M:N)모델을 활용하고 있다. 즉, 고루틴(User-Level 쓰레드)-OS 쓰레드(Kernel-Level 쓰레드)를 M:N 맵핑하는 형태로 스케줄링된다. Context Switching, 멀티 코어에 대한 장점을 누리고 있고 또한 구현이 어렵다는 단점을 언어적 차원에서 해당 단점을 해소 시키고 있다.
+) 위 내용은 이 링크의 글을 통해 학습하게 되었고 간략히 언급하고 넘어가겠습니다. 자세한 내용에 대해서는 위 링크 참조를 부탁드립니다.
Go 언어 런타임에 Golang 스케줄러가 포함되어 있다. golang 스케줄러는 고루틴의 스케줄링 작업을 수행하고 다수의 고루틴들이 소수의 쓰레드 위에서 동작하게 되며, Go의 스케줄러는 G-M-P 모델
로 표현되어 스케줄링이 처리되는 구조이다. 여기서 G-M-P 모델
이란 무엇일까?
G(Goroutine)
: Goroutine는 말그대로 고루틴 의미하며, 고루틴을 구성하는 논리적 구조체의 구현체를 말한다
G
는 LRQ
에서 대기하고 있다M(Machine)
: Machine는 OS 쓰레드를 의미하며, 실제 OS 쓰레드가 아닌 논리적 구현체로 표준 POSIX 쓰레드를 따른다고 한다.
M
은 P
로 부터 G
를 할당받아 실행한다P
의 포인터를 가지고 있다P(Processor)
: Processor는 프로세서를 의미하며, 실제 물리적 프로세서를 말하는게 아니라 논리적인 프로세서로 정확히는 스케줄링과 관련된 Context 정보를 가지고 있다
P
는 컨텍스트 정보를 담고 있으며, 1개의 P
당 1개의 LRQ
를 가지고 있다. 그래서 G
를 M
에 할당하는 역할을 수행LRQ(LocalRunQueue)
: P
에 종속되어 있는 Run Queue, 이 LRQ
에 실행 가능한 고루틴들이 적재된다
P
는 LRQ
로 부터 고루틴을 하나씩 Pop하고 M
에 할당하여 고루틴을 실행시킨다P
마다 하나의 LRQ
를 가지고 있기 때문에 레이스 컨디션을 줄일 수 있는 효과가 있다고 한다LRQ
가 M
이 가지고 있지 않은 이유는 M
의 개수가 증가할수록 LRQ
의 수도 비례적으로 증가하여 오버헤드가 커지는 구조이기 때문이다GRQ(GlobalRunQueue)
: LRQ
에 할당되지 못한 고루틴을 관리하는 Run Queue, LRQ
적재되지 못한 고루틴들이 이 GRQ
에 들어가 관리된다고 보면 된다
GRQ
로 적재된다GRQ
에 고루틴이 적재된다+) 이 블로그 글을 참고했습니다.
아시다시피 하나의 쓰레드는 하나의 작업을 실행할 수 있다. 각각의 작업들이 수행되는 간략한 과정을 설명하자면 하나의 M
이 하나의 고루틴(작업)을 실행하고, 이미 실행중이던 고루틴이 작업을 마치거나 syscall을 했을 경우 Go 스케줄러는LRQ
에서 대기중인 고루틴을 꺼내 다음 작업을 실행하고 새로 추가되는 작업(이미 돌아가고 있는 작업 혹은 새로운 고루틴)을 LRQ
에 추가한다. 또한 스케줄러는 성능 향상을 위해서 아래와 같은 상황에서 스케줄링을 통해 최적화하는 작업이 있다.
로직을 실행하던 도중에 syscall(주로 I/O 작업 같은 행위 등)이 발생하게 되면 blocking이 발생되는데, 이 현상이 해당 작업을 처리하던 쓰레드에 영향을 주어 다음 작업을 처리할 수 없기 때문에 성능 저하의 원인이 될 수 있다. Go에서는 스케줄러가 작업을 멈추지 않고 계속 진행할 수 있도록 syscall이 발생한 고루틴을 다른 쓰레드로 넘기고 P
의 LRQ
에 적재되어 있던 다음 고루틴이 정상적으로 처리될 수 있도록 보장한다. 이후 syscall 처리가 끝난 고루틴은 잠시 넘겨주었던 P
로 다시 적재되거나 GRQ
에 적재된다.
다수의 고루틴은 소수의 쓰레드 위에서 동작한다. 즉 다중화(Multiplexing)되어 돌아간다고 앞서 언급 했었는데, 바로 위에서 설명한 과정의 Go 스케줄러의 스케줄링 덕분에 가능해진 것이다. 따라서 M
에 바로 P(Context)
가 붙어서 고루틴 간에 Context Switching이 발생하게 되는 구조로 여기서 P(Context)
의 개수는 Go의 GOMAXPROCS라는 환경변수 값을 통해 조정이 가능하다.
M
, P(Context)
가 모든 작업을 마치게 되면 먼저 GRQ
에 쌓여있는 고루틴을 가져오려 시도하고 이마저도 없으면 다른 P
의 LRQ
에서 절반의 고루틴(작업)을 가져온다(Steal). 작업의 불균형으로 인한 병목현상을 이러한 Work Stealing 기법을 통해 리소스를 더 효율적으로 사용하게 된다.
앞선 내용에서 syscall과 같은 현상이 발생하게 되면 Context Switching이 이루어지는 것을 알 수 있었지만, 정확히 고루틴은 어떤 시점에서 Context Switching 작업이 발생하게 될까?
time.Sleep()
Function 같은 해당 문맥에서 sleep 처리하는 로직이 실행될 때runtime.Gosched()
Function 같은 로직이 실행될 때다루지 못한 부족한 내용도 있고 고루틴과 고루틴의 스케줄링 기법에 관하여 확실하게 이해가 된 것은 아니지만 전체적인 흐름을 파악할 수 있었다. Go 언어가 동시성 개발에 대한 장점 요소들을 모두 다루진 못했지만 그 장점 중 한가지인 고루틴에 대하여 학습하면서 왜 고루틴을 사용하면 좋은지에 대한 이유를 알 수 있었고, Go 스케줄러를 학습하면서 운영체제와 연관된 내용이 포함되어 있어 같이 공부할 수 있던 계기였다.