Go

Go (고) 언어 채널, 속 시원히 알려줄게!: 작동 방식부터 활용법까지 완벽 분석

드리프트2 2025. 3. 22. 17:51

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 <- 1runtime.chansend1 함수에 해당합니다.
  • 데이터 수신 구문 x := <- cruntime.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)을 멈춰서 대기 상태로 만듭니다.

goparkgoready는 서로 짝꿍처럼 붙어 다니면서 반대 역할을 하는데요.

goparkgoready는 런타임 소스 코드에서 자주 볼 수 있고, 고루틴 (Goroutine) 스케줄링 과정과 관련이 있지만, 이번 글에서는 자세히 다루지 않고 나중에 따로 자세히 다뤄볼 예정입니다.

gopark를 호출한 후에는 사용자 입장에서 채널 (Channel)로 데이터를 보내는 코드에서 프로그램이 블록 (block, 멈춤)됩니다.

지금까지 채널 (Channel) send (샌드, 보내기) 구문 (c <- 1 같은 코드) 내부 작동 방식에 대해 알아봤는데요.

send (샌드, 보내기) 과정 전체에서 동시성 안전을 보장하기 위해 c.lock을 사용해서 Lock (락)을 걸어줍니다.

간단하게 정리하면, 전체 과정은 다음과 같습니다.

  1. recvq가 비어 있는지 확인합니다. 비어있지 않다면 recvq 맨 앞에서 고루틴 (Goroutine)을 하나 꺼내서 데이터를 보내고, 해당 고루틴 (Goroutine)을 깨웁니다.
  2. recvq가 비어 있다면 버퍼 (buffer)에 데이터를 넣습니다.
  3. 버퍼 (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) 관련 변수가 두 개 있는데요.

바로 recvxsendx입니다.

sendx는 버퍼 (buffer)에서 데이터를 쓸 수 있는 위치 (write index, 쓰기 인덱스)를 나타내고, recvx는 버퍼 (buffer)에서 데이터를 읽을 수 있는 위치 (read index, 읽기 인덱스)를 나타냅니다.

recvxsendx 사이의 요소들이 버퍼 (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의 주소를 나타냅니다.

그리고 sgsendq에서 꺼낸 첫 번째 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) 설계에는 최적화할 부분이 많이 남아있다는 점, 잊지 말아주세요!