본문 바로가기

[Go] 고루틴(Goroutine) & 채널(Channel) 본문

Dev

[Go] 고루틴(Goroutine) & 채널(Channel)

겨울바람_ 2026. 3. 27. 10:50

Go의 동시성이 OS 스레드 기반의 다른 프로그래밍 언어들보다 훨씬 가벼운 이유는 런타임이 자체 스케줄러를 갖고 있기 때문이다.

OS 스레드를 Go가 직접 사용하는게 아니라, G(goroutine) -> P(processor) -> M(Machine-OS Thread)의 3계층 구조로 고루틴을 관리한다.

고루틴은 Go의 경량 실행 단위(Thread)로 go 키워드를 통해 사용할 수 있다. OS 스레드가 환경에 따라 1~8MB의 스택 크기를 갖는 반면, 고루틴은 그에 비해 최대 500배 작은 2KB의 초기 스택을 갖는다. 항상 2KB로 고정되어 있는건 아니고 64bit 환경 기준 필요에 따라 최대 1GB까지 확장이 가능하다.

 

또 OS 스레드가 일반적으로 컨텍스트 스위치에 ~1μs가 걸리는 반면 고루틴은 일반적으로 컨텍스트 스위치에 ~100ns의 시간이 걸리는데, OS 스레드와 비교했을 때 10배 빠른 속도로 전환이 가능하다.

 

프로세서는 고루틴을 실제 OS 스레드에 올려 실행시키는 역할을 맡는다. 프로세서가 없으면 OS 스레드는 고루틴을 실행시킬 수 없기 때문에 Go 런타임의 핵심 자원이라고 할 수 있다. 프로세서는 기본적으로 GOMAXPROCS 의 설정 값만큼 생성되며 기본적으로 CPU 코어 수만큼 생성된다.

 

각각의 프로세서는 Local Run Queue(LRQ)를 가지고 있는데, 프로그램이 실행되면 프로세서(P)가 할당되고 각 프로세서의 LRQ에는 실행할 고루틴(G)이 배치된다. 이때 OS 스레드(M)은 직접 고루틴을 실행시키는게 아니라 프로세서의 LRQ로부터 고루틴을 가져온 후 실행한다.

 

LRQ에는 최대 256개의 고루틴이 배치될 수 있으며, 그 숫자를 초과할 시 Global Queue로 이동된다. 만약 OS 스레드가 syscall로 인해 블로킹 상태가 되면 Handoff 메커니즘에 의해 프로세서는 동작 가능한 OS 스레드에 재배치 된다.

 

OS 스레드(M)는 OS가 실제로 스케줄링 하는 커널 스레드로 프로세서 없이 고루틴을 직접 실행할 수 없다. 블로킹 상태가 되어 프로세서와 분리된 후 다시 동작 가능한 상태가 되면 비어있는 프로세서를 찾거나 Global Queue에서 대기한다.

 

GPM 모델에서 스케줄러의 효율을 높이는 가장 중요한 메커니즘이 바로 Work-Stealing인데, 특정 프로세서의 LRQ가 비어버리는

경우, 해당 프로세서와 OS 스레드는 유휴 상태가 된다.

 

다른 프로세서에 고루틴이 쌓여있음에도 놀고 있는 자원이 생기게 되기 때문에 이를 방지하기 위해서 LRQ가 비어버린 프로세서는 다른 프로세서의 LRQ에서 고루틴을 절반 가져와 자신의 비어버린 LRQ에 채운 후 실행을 이어간다.

 

만약, 다른 프로세서의 LRQ에도 훔쳐올 고루틴이 없다면 Global Queue를 확인하여 대기 중인 고루틴을 가져온다.

 

고루틴을 사용하다보면 결과를 돌려받아야 할 때가 있는데, 이를 위해 채널을 사용한다. 채널은 송신 대기열(send Queue), 링 버퍼(Ring Buffer), 수신 대기열(Recv Queue)로 구성되어 있다.

 

채널은 고루틴이 데이터를 주고받는 파이프라고 생각하면 된다. 채널은 Buffered 채널과 Unbuffered 라는 두 개의 특성으로 구분할 수 있는데, Unbuffered 채널의 경우 하나의 수신자가 데이터를 받을 때까지 송신자가 데이터를 보내는 채널에 묶여 있게 된다.

 

하지만, Buffered 채널을 사용하면 수신자가 받을 준비가 되어 있지 않더라도 지정된 버퍼의 크기만큼 데이터를 보내고 계속 다른 일을 수행할 수 있다.

 

채널은 close() 함수를 통해 닫을 수 있는데, 닫게 되면 해당 채널로는 데이터를 송신할 수는 없지만 수신은 가능하다.

 

채널을 복수 사용할 경우 select문을 통해 여러 채널을 동시에 감시할 수 있다. select문은 여러 개의 case에서 각각 다른 채널을 기다리다가 준비가 된 채널의 case를 실행한다. 만약 복수 채널에서 신호가 오면 무작위로 그 중 하나를 선택한다.

 

대표적으로 자주 사용되는 타임아웃 패턴은 실행이 오래 걸리는 프로세스를 호출하고 정해진 시간까지 응답이 반환되지 않으면, 실패로 간주하고 강제적으로 에러를 반환한다.

무한 루프를 활용한 이벤트 루프 패턴은 close(quit) 명령이 올 때까지 루프를 돌며 작업이 오면 해당 작업을 처리한다. close(quit) 명령을 채널을 통해 수신하면 모든 worker가 루프를 종료한다.

만약 default문이 있으면, case문 채널이 준비되지 않더라도 계속 대기하지 않고 바로 default문을 실행한다. for ch range문을 사용하면 채널이 종료 신호를 받을 때까지 무한 루프가 진행되며, 채널이 비어있을 경우 블로킹 상태로 전환된다.

 

default문을 활용하는 대표적인 예제가 바로 아래의 로그 처리다.

로그의 경우 일부가 드랍되어도 서비스의 성능에 크게 영향을 미치지 않는다.

로그를 전송하는 Send() 메소드는 default를 사용한 논블로킹 방식으로 값을 보낼 수 없는 상황 즉, 채널의 버퍼가 가득 차면 로그를 드랍한다. 로그를 수집하는 Cousume() 메소드는 default를 사용하지 않는 블로킹 방식으로 값이 수신될 때까지 대기한다.

 

정리해보자면 select문을 사용했을 때 준비된 케이스가 하나면 그것을 실행, 준비된 케이스가 여러 개면 특정 채널이 굶지 않도록 무작위로 하나 선택. 만약 준비된 케이스가 없다면 default를 실행하는데, default문이 없으면 블로킹 된다.

 

이번 주제는 난이도가 있어 며칠 동안 나눠쓰는 바람에 주기가 좀 길어졌다. 다음 주제는 context인데 이것도 어려워서 좀 시간이 걸릴 것 같다.

'Dev' 카테고리의 다른 글

[Go] 컨텍스트(Context)  (0) 2026.04.07
[Go] 메모리 관리에 대해서 알아보자  (0) 2026.03.18
단일 트랜잭션 유지 그리고 PGMQ  (2) 2024.12.15
[Go] 함수 고급  (0) 2024.12.02
[Go] 인터페이스  (5) 2024.11.10
Comments