Go (고) 언어 채널, 속 시원히 알려줄게!: 작동 방식부터 활용법까지 완벽 분석
1. 고루틴 (Goroutine)과 채널 (Channel), 무슨 관계일까요?
채널 (Channel)은 Go (고) 언어에서 정말 중요한 기능 중 하나인데요.
Go (고) 언어의 동시성 모델인 CSP (Communicating Sequential Processes)를 제대로 보여주는 핵심 기능이기도 합니다.
쉽게 말해서, 채널 (Channel)을 통하면 고루틴 (Goroutine)끼리 데이터를 주고받으면서 통신할 수 있다는 거죠.
마치 고루틴 (Goroutine)들끼리 이야기하는 통로를 만들어주는 것과 같아요.
Go (고) 언어에서 채널 (Channel)이 워낙 중요하고 코드에서도 엄청 자주 쓰이다 보니까, 채널 (Channel)이 속으로는 어떻게 돌아가는지 궁금해지지 않으세요?
그래서 이번 글에서는 Go (고) 1.13 버전 소스 코드를 바탕으로 채널 (Channel) 내부 구현 원리를 한번 샅샅이 파헤쳐 볼까 합니다.
채널 (Channel)의 모든 것을 낱낱이 알려드릴게요.
2. 채널 (Channel) 기본 사용법, 먼저 알아볼까요?
본격적으로 채널 (Channel) 속을 들여다보기 전에, 채널 (Channel)을 어떻게 사용하는지 기본적인 사용법부터 먼저 알아봐야겠죠?
아주 간단한 예제 코드를 한번 보겠습니다.
package main
import "fmt"
func main() {
c := make(chan int) // int 타입 채널 (Channel) 만들기
go func() {
c <- 1 // 채널 (Channel)로 데이터 보내기
}()
x := <-c // 채널 (Channel)에서 데이터 받기
fmt.Println(x)
}
위 코드에서는 make(chan int)
를 사용해서 int
타입의 채널 (Channel) c
를 만들었습니다.
그리고 고루틴 (Goroutine) 안에서 c <- 1
코드를 써서 채널 (Channel)로 데이터를 보냈고요.
메인 고루틴 (Goroutine)에서는 x := <-c
코드를 사용해서 채널 (Channel)에서 데이터를 받아서 변수 x
에 넣어줬습니다.
이 코드에는 채널 (Channel)의 가장 기본적인 두 가지 기능이 다 들어있는데요.
c <- 1
처럼 채널 (Channel)로 데이터를 보내는 send (샌드, 보내기) 연산x := <- c
처럼 채널 (Channel)에서 데이터를 받는 recv (리시브, 받기) 연산
그리고 채널 (Channel)은 버퍼 (buffer) 유무에 따라 버퍼 채널 (buffered channel) 과 비버퍼 채널 (unbuffered channel) 로 나뉘는데요.
위 코드에서는 버퍼 (buffer)가 없는 비버퍼 채널 (unbuffered channel)을 사용했습니다.
비버퍼 채널 (unbuffered channel)은 현재 채널 (Channel)에서 데이터를 받으려고 기다리는 다른 고루틴 (Goroutine)이 없으면, 데이터를 보내는 쪽에서 send (샌드, 보내기) 연산을 할 때까지 멈춰서 기다리게 됩니다.
마치 전화했는데 상대방이 안 받으면 계속 기다려야 하는 것처럼요.
채널 (Channel)을 만들 때 버퍼 (buffer) 크기를 지정할 수도 있는데요.
예를 들어 make(chan int, 2)
처럼 만들면 버퍼 (buffer) 크기가 2인 채널 (Channel)이 됩니다.
버퍼 (buffer)가 꽉 차기 전까지는 데이터를 보내는 쪽에서 블록 (block, 멈춤)되지 않고 계속 데이터를 보낼 수 있고, 데이터를 받는 쪽이 준비될 때까지 기다릴 필요도 없습니다.
하지만 버퍼 (buffer)가 꽉 차면, 데이터를 보내는 쪽도 멈춰서 기다려야 합니다.
마치 우체통에 편지가 가득 차면 더 이상 편지를 넣을 수 없는 것과 같아요.
3. 채널 (Channel) 속 구조, 뜯어볼까요?
채널 (Channel) 소스 코드를 본격적으로 탐험하기 전에, Go (고) 언어에서 채널 (Channel)이 실제로 어떻게 구현되어 있는지 먼저 알아봐야 합니다.
왜냐하면 우리가 채널 (Channel)을 사용할 때는 <-
기호만 쓰지, Go (고) 소스 코드에서 직접 구현체를 찾을 수는 없거든요.
하지만 Go (고) 컴파일러는 분명히 <-
기호를 실제 채널 (Channel) 구현체로 바꿔줄 겁니다.
Go (고)에서 제공하는 go tool compile -N -l -S hello.go
명령어를 사용하면 코드를 어셈블리 instructions (인스트럭션, 명령어)로 바꿔볼 수 있습니다.
아니면 Compiler Explorer (컴파일러 익스플로러)라는 온라인 도구를 사용해도 되는데요
위 예제 코드에 해당하는 어셈블리 instructions (인스트럭션, 명령어)를 자세히 살펴보면, 다음과 같은 관계를 찾을 수 있습니다.
- 채널 (Channel) 생성 구문
make(chan int)
는runtime.makechan
함수에 해당합니다. - 데이터 전송 구문
c <- 1
은runtime.chansend1
함수에 해당합니다. - 데이터 수신 구문
x := <- c
는runtime.chanrecv1
함수에 해당합니다.
이 함수들은 모두 Go (고) 소스 코드 안에 있는 runtime/chan.go
파일에서 구현되어 있습니다.
이제부터 이 함수들을 중심으로 채널 (Channel) 구현 방식을 하나씩 알아볼까요?
4. 채널 (Channel) 만들기, 어떻게 할까요?
채널 (Channel) 생성 구문 make(chan int)
는 Go (고) 컴파일러에 의해서 runtime.makechan
함수로 바뀌는데요.
함수 형태는 다음과 같습니다.
func makechan(t *chantype, size int) *hchan
여기서 t *chantype
는 채널 (Channel)을 만들 때 지정한 요소 타입입니다.
size int
는 사용자가 지정한 채널 (Channel) 버퍼 (buffer) 크기이고, 버퍼 (buffer) 크기를 따로 지정하지 않으면 0이 됩니다.
이 함수의 반환 값은 *hchan
인데요.
hchan
이 바로 Go (고)에서 채널 (Channel)을 실제로 구현한 구조체입니다.
hchan
구조체 정의는 다음과 같습니다.
type hchan struct {
qcount uint // 버퍼 (buffer) 안에 들어있는 요소 개수
dataqsiz uint // 사용자가 지정한 버퍼 (buffer) 크기
buf unsafe.Pointer // 버퍼 (buffer) 포인터
elemsize uint16 // 버퍼 (buffer) 요소 크기
closed uint32 // 채널 (Channel) 닫힘 여부 (0: 안 닫힘)
elemtype *_type // 채널 (Channel) 요소 타입 정보
sendx uint // 버퍼 (buffer) send (샌드, 보내기) 인덱스 (데이터를 쓸 위치)
recvx uint // 버퍼 (buffer) recv (리시브, 받기) 인덱스 (데이터를 읽을 위치)
recvq waitq // recv (리시브, 받기) 대기 고루틴 (Goroutine) 큐 (큐에서 데이터 받기를 기다리는 고루틴 (Goroutine) 리스트)
sendq waitq // send (샌드, 보내기) 대기 고루틴 (Goroutine) 큐 (큐에 데이터 보내기를 기다리는 고루틴 (Goroutine) 리스트)
lock mutex // 뮤텍스 락 (Mutex lock), 채널 (Channel) Lock (락)
}
hchan
구조체 안에 있는 속성들은 크게 세 가지로 나눌 수 있습니다.
- 버퍼 (buffer) 관련 속성:
buf
,dataqsiz
,qcount
같은 속성들인데요. 채널 (Channel) 버퍼 (buffer) 크기가 0이 아닐 때, 버퍼 (buffer)는 받을 데이터를 저장하는 공간으로 링 버퍼 (ring buffer) 형태로 구현되어 있습니다. 🔄 - waitq (웨이트큐) 관련 속성: FIFO (선입선출) 큐라고 생각하면 쉬운데요.
recvq
는 데이터를 받기 위해 대기하는 고루틴 (Goroutine)들을 담고 있고,sendq
는 데이터를 보내기 위해 대기하는 고루틴 (Goroutine)들을 담고 있습니다.waitq
는 Doubly Linked List (양방향 연결 리스트)를 사용해서 구현했습니다. 🔗 - 기타 속성:
lock
,elemtype
,closed
같은 속성들이 있습니다.
makechan
함수 전체 과정은 간단하게 말하면 몇 가지 유효성 검사를 하고, 버퍼 (buffer), hchan
구조체, 기타 속성들을 위한 메모리를 할당하는 건데요.
여기서는 자세히 다루지는 않겠습니다.
더 궁금하신 분들은 소스 코드를 직접 찾아보시는 것을 추천합니다.
hchan
구조체 속성들을 간단하게 분석해본 결과, 버퍼 (buffer)와 waitq
가 채널 (Channel)의 핵심 부품이라는 것을 알 수 있습니다.
hchan
의 모든 동작과 구현은 이 두 가지 부품을 중심으로 이루어진다고 해도 과언이 아닙니다.
5. 채널 (Channel)로 데이터 보내기, 어떻게 작동할까요?
채널 (Channel)로 데이터를 보내고 받는 과정은 꽤 비슷한데요.
먼저 채널 (Channel)로 데이터를 보내는 과정 (c <- 1
같은 코드)부터 알아볼까요?
이 과정은 runtime.chansend
함수를 통해 구현됩니다.
채널 (Channel)로 데이터를 보내려고 할 때, recvq
큐가 비어있지 않으면 recvq
맨 앞에서 데이터 받기를 기다리는 고루틴 (Goroutine)을 꺼내옵니다.
그리고 데이터를 바로 이 고루틴 (Goroutine)에게 전달합니다.
코드를 보면 다음과 같습니다.
if sg := c.recvq.dequeue(); sg!= nil {
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
recvq
는 데이터를 받기 위해 대기하는 고루틴 (Goroutine)들을 담고 있는 큐인데요.
고루틴 (Goroutine)이 recv (리시브, 받기) 연산 (x := <- c
같은 코드)을 사용할 때, 채널 (Channel) 버퍼 (buffer)에 데이터가 없고, 데이터를 보내려고 대기하는 다른 고루틴 (Goroutine)도 없으면 (sendq
가 비어있으면), 해당 고루틴 (Goroutine)과 받을 데이터 주소를 sudog
객체로 만들어서 recvq
에 넣어둡니다.
위 코드에서 recvq
가 비어있지 않다면, send
함수를 호출해서 데이터를 해당 고루틴 (Goroutine) 스택에 복사합니다.
send
함수 구현에서 중요한 부분은 크게 두 가지입니다.
memmove(dst, src, t.size)
: 데이터를 복사하는 부분입니다. 메모리 복사라고 생각하면 됩니다. 💾goready(gp, skip+1)
:goready
함수는 해당 고루틴 (Goroutine)을 깨우는 역할을 합니다. ⏰
만약 recvq
큐가 비어있다면, 현재 데이터를 받으려고 기다리는 고루틴 (Goroutine)이 없다는 뜻이니까, 채널 (Channel) 버퍼 (buffer)에 데이터를 넣어보려고 시도합니다.
코드는 다음과 같습니다.
if c.qcount < c.dataqsiz {
// Equivalent to c.buf[c.sendx]
qp := chanbuf(c, c.sendx)
// Copy the data into the buffer
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}
위 코드는 버퍼 (buffer)에 데이터를 넣는 아주 간단한 코드입니다.
여기서 링 버퍼 (ring buffer) 연산이 사용되는데요.
dataqsiz
는 사용자가 지정한 채널 (Channel) 버퍼 (buffer) 크기를 나타내고, 따로 지정하지 않으면 기본값은 0입니다.
링 버퍼 (ring buffer) 관련 자세한 내용은 뒤에서 다시 자세히 설명하겠습니다.
만약 사용자가 비버퍼 채널 (unbuffered channel)을 사용하거나, 버퍼 (buffer)가 꽉 차 있다면 c.qcount < c.dataqsiz
조건이 충족되지 않아서 위 코드는 실행되지 않습니다.
이 경우에는 현재 고루틴 (Goroutine)과 보낼 데이터를 sendq
큐에 넣고, 현재 고루틴 (Goroutine)은 멈춰서 대기 상태가 됩니다.
전체 과정은 다음 코드에 해당합니다.
gp := getg()
mysg := acquireSudog()
mysg.releasetime = 0
if t0!= 0 {
mysg.releasetime = -1
}
mysg.elem = ep
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
c.sendq.enqueue(mysg)
// 고루틴 (Goroutine)을 대기 상태로 바꾸고 Lock (락)을 풉니다.
goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)
위 코드에서 goparkunlock
은 입력 뮤텍스 (mutex)를 unlock (언락, 해제)하고 현재 고루틴 (Goroutine)을 멈춰서 대기 상태로 만듭니다.
gopark
와 goready
는 서로 짝꿍처럼 붙어 다니면서 반대 역할을 하는데요.
gopark
와 goready
는 런타임 소스 코드에서 자주 볼 수 있고, 고루틴 (Goroutine) 스케줄링 과정과 관련이 있지만, 이번 글에서는 자세히 다루지 않고 나중에 따로 자세히 다뤄볼 예정입니다.
gopark
를 호출한 후에는 사용자 입장에서 채널 (Channel)로 데이터를 보내는 코드에서 프로그램이 블록 (block, 멈춤)됩니다.
지금까지 채널 (Channel) send (샌드, 보내기) 구문 (c <- 1
같은 코드) 내부 작동 방식에 대해 알아봤는데요.
send (샌드, 보내기) 과정 전체에서 동시성 안전을 보장하기 위해 c.lock
을 사용해서 Lock (락)을 걸어줍니다.
간단하게 정리하면, 전체 과정은 다음과 같습니다.
recvq
가 비어 있는지 확인합니다. 비어있지 않다면recvq
맨 앞에서 고루틴 (Goroutine)을 하나 꺼내서 데이터를 보내고, 해당 고루틴 (Goroutine)을 깨웁니다.recvq
가 비어 있다면 버퍼 (buffer)에 데이터를 넣습니다.- 버퍼 (buffer)가 꽉 찼다면, 보낼 데이터와 현재 고루틴 (Goroutine)을
sudog
객체에 담아서sendq
에 넣습니다. 그리고 현재 고루틴 (Goroutine)을 대기 상태로 설정합니다.
6. 채널 (Channel)에서 데이터 받기, 어떻게 다를까요?
채널 (Channel)에서 데이터를 받는 과정은 데이터를 보내는 과정과 거의 비슷해서 여기서는 자세히 설명하지 않겠습니다.
데이터를 받는 과정에서 사용되는 버퍼 (buffer) 관련 연산은 뒤에서 자세히 다뤄볼 예정입니다.
여기서 꼭 알아둬야 할 점은 채널 (Channel) send (샌드, 보내기)와 recv (리시브, 받기) 과정 전체에서 runtime.mutex
를 사용해서 Lock (락)을 건다는 건데요.
runtime.mutex
는 런타임 관련 소스 코드에서 흔히 볼 수 있는 Lightweight Lock (경량 락)입니다.
채널 (Channel) 전체 과정이 락-프리 (lock-free) 방식처럼 엄청나게 효율적인 방식은 아니라는 점, 참고해주세요.
Go (고) 언어 이슈 중 go/issues#8899 이슈에서 락-프리 (lock-free) 채널 (Channel) 솔루션을 다루고 있으니, 더 궁금하신 분들은 한번 찾아보시는 것을 추천합니다.
7. 채널 (Channel) 링 버퍼 (ring buffer) 구현 방식, 꼼꼼히 알아볼까요?
채널 (Channel)은 링 버퍼 (ring buffer)를 사용해서 데이터를 임시 저장 공간인 버퍼 (buffer)에 넣어두는데요.
링 버퍼 (ring buffer)는 장점이 많아서 고정 길이 FIFO (선입선출) 큐를 구현하는 데 아주 적합합니다.
채널 (Channel)에서 링 버퍼 (ring buffer)는 다음과 같이 구현되어 있습니다.
hchan
구조체에는 버퍼 (buffer) 관련 변수가 두 개 있는데요.
바로 recvx
와 sendx
입니다.
sendx
는 버퍼 (buffer)에서 데이터를 쓸 수 있는 위치 (write index, 쓰기 인덱스)를 나타내고, recvx
는 버퍼 (buffer)에서 데이터를 읽을 수 있는 위치 (read index, 읽기 인덱스)를 나타냅니다.
recvx
와 sendx
사이의 요소들이 버퍼 (buffer)에 제대로 들어간 데이터를 의미합니다.
큐의 첫 번째 요소를 읽을 때는 buf[recvx]
를 사용하고, 큐의 마지막 위치에 요소를 넣을 때는 buf[sendx] = x
를 사용하면 됩니다.
버퍼 (buffer) 쓰기
버퍼 (buffer)가 꽉 차지 않았을 때 버퍼 (buffer)에 데이터를 넣는 과정은 다음과 같습니다.
qp := chanbuf(c, c.sendx)
// 데이터를 버퍼 (buffer)에 복사합니다.
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
여기서 chanbuf(c, c.sendx)
는 c.buf[c.sendx]
와 같은 의미입니다.
위 과정은 데이터를 버퍼 (buffer)의 sendx
위치에 복사하는 아주 간단한 작업입니다.
그다음 sendx
를 다음 위치로 옮겨줍니다.
sendx
가 마지막 위치에 도달했다면 0으로 설정해서 링 버퍼 (ring buffer)처럼 처음과 끝을 연결하는 방식을 사용합니다.
버퍼 (buffer) 읽기
버퍼 (buffer)가 꽉 차지 않았을 때는 sendq
도 비어 있어야 합니다.
(왜냐하면 버퍼 (buffer)가 꽉 차지 않았는데 sendq
에 고루틴 (Goroutine)이 대기하고 있을 리는 없으니까요.
자세한 내용은 위에서 채널 (Channel)로 데이터 보내는 부분 설명 참고해주세요.) 이때 채널 (Channel) recv (리시브, 받기) 과정은 비교적 간단해서 버퍼 (buffer)에서 데이터를 바로 읽어오기만 하면 됩니다.
이 과정 역시 recvx
를 옮기는 과정인데요.
버퍼 (buffer) 쓰기 과정과 거의 똑같습니다.
sendq
에 대기 중인 고루틴 (Goroutine)이 있다면 버퍼 (buffer)는 꽉 찬 상태여야 합니다.
이때 채널 (Channel) 읽기 로직은 다음과 같습니다.
// Equivalent to c.buf[c.recvx]
qp := chanbuf(c, c.recvx)
// 큐에서 receiver (리시버, 수신자)로 데이터 복사
if ep!= nil {
typedmemmove(c.elemtype, ep, qp)
}
// Sender (샌더, 발신자)에서 큐로 데이터 복사
typedmemmove(c.elemtype, qp, sg.elem)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
위 코드에서 ep
는 데이터를 받을 변수에 해당하는 주소입니다.
예를 들어 x := <- c
코드에서 ep
는 변수 x
의 주소를 나타냅니다.
그리고 sg
는 sendq
에서 꺼낸 첫 번째 sudog
를 나타냅니다.
코드를 좀 더 자세히 뜯어볼까요?
typedmemmove(c.elemtype, ep, qp)
: 현재 버퍼 (buffer)에서 읽을 수 있는 요소를 데이터를 받을 변수 주소로 복사합니다.typedmemmove(c.elemtype, qp, sg.elem)
:sendq
에 있는 고루틴 (Goroutine)이 보내려고 대기 중인 데이터를 버퍼 (buffer)로 복사합니다.recvx++
가 나중에 실행되기 때문에sendq
에 있는 데이터를 큐의 맨 뒤에 넣는 것과 같습니다.
쉽게 말해서, 채널 (Channel)은 버퍼 (buffer)의 첫 번째 데이터를 받으려는 변수에 복사하고, 동시에 sendq
에 있던 요소들을 큐의 맨 뒤로 복사해서 FIFO (선입선출) 방식으로 데이터를 처리하는 거죠.
8. 마무리
Go (고) 언어에서 정말 많이 쓰이는 채널 (Channel) 기능!
이번 글에서는 채널 (Channel) 소스 코드를 꼼꼼히 뜯어보면서 채널 (Channel) 작동 원리를 자세히 알아봤는데요.
채널 (Channel) 내부 구조를 이해하는 것은 채널 (Channel)을 더 잘 활용하는 데 도움이 될 뿐만 아니라, 채널 (Channel) 성능에 대한 맹목적인 믿음을 버리고 개선점을 찾아보는 계기가 될 수도 있습니다.
아직까지 채널 (Channel) 설계에는 최적화할 부분이 많이 남아있다는 점, 잊지 말아주세요!
'Go' 카테고리의 다른 글
Golang 웹 프레임워크 7종 비교분석 (Gin, Echo, Beego, Revel, Fiber, Gorilla Mux, go-zero/rest) (0) | 2025.03.29 |
---|---|
Go 언어의 난수, 왜 예측 가능할까요? (math/rand vs crypto/rand 깊이 파헤치기) (0) | 2025.03.24 |
Go (고) 언어 동시성의 비밀, 고루틴 스케줄링 (0) | 2025.03.22 |
Go의 Structs와 Interfaces, 객체지향 프로그래밍을 넘어서 (0) | 2025.03.19 |
Go 언어 구조체(Struct) 완벽 정복: 기본부터 메모리 최적화, 활용 팁까지 (0) | 2025.03.15 |