개요

이 글의 목표는 Go 채널을 사용법만 나열하는 것이 아니라, 런타임 내부 구조를 따라가며 왜 그렇게 동작하는지 이해하는 것입니다. 앞부분에서는 chan.go의 생성, 송수신, 버퍼 구조를 읽고, 뒷부분에서는 내부 스케줄 정책을 바꾸지 않은 채 관측 훅을 붙여 채널이 GMP 대기 경로와 실제 로직이 어떻게 맞닿는지 실험으로 확인합니다.

런타임 코드 인용 기준은 go1.26.5src/runtime/chan.go입니다.
send/recv의 큰 흐름은 이전 버전과 같되, hchantimer 채널과 synctest용 필드가 추가된 점만 구분해서 보면 됩니다.

Go Chan

Go에서 채널은 고루틴(Goroutine) 간에 데이터를 주고받고 실행을 동기화하기 위한 파이프로, 통신/차단/동기화 등을 할 수 있습니다. 채널의 철학은 “통신을 통한 메모리 공유(Do not communicate by sharing memory; instead, share memory by communicating)“로 정리되죠.

이는 전통적인 Lock 기반 메모리 공유 방식이 아닌, goroutinechannel을 이용한 CSP 메시지 전달 모델을 의미합니다.

  • Lock: 2개의 고루틴이 하나의 메모리에 접근할 때 mu.Lock()을 통해 메모리를 보호합니다.
  • CSP: 2개의 고루틴은 메시지만 보내고 실제 메모리는 별도의 고루틴이 담당할 수 있게 하는 방식으로 메모리를 보호합니다.

Go는 두 가지 모델을 지원해 보다 편리한 메모리 관리가 가능합니다.

항목Lock 모델CSP 모델
기본 개념공유 메모리를 동기화메시지 전달
상태 소유자여러 goroutine특정 goroutine
주요 도구Mutex, RWMutex, atomicgoroutine, channel, select
코드 구조공유 객체 중심이벤트와 파이프라인 중심
데이터 접근직접 접근메시지로 요청
Race 위험Lock 누락 시 높음소유권이 명확하면 낮음
Deadlock 위험Lock 순서 문제channel 송수신 구조 문제
성능짧은 상태 보호에 유리워크플로우 조정에 유리
복잡한 흐름Lock 관리가 어려워질 수 있음흐름을 명시적으로 표현 가능
단순 카운터적합과도할 수 있음
작업 파이프라인다소 부자연스러움매우 적합

buffered/unbuffered 채널

map과 마찬가지로 make로 만들며 반환값은 원시 자료구조를 가리키는 참조처럼 동작합니다.
이때 버퍼 크기를 주는 걸로 unbufferedbuffered를 구분해서 채널을 생성할 수 있습니다.

ci := make(chan int)            // unbuffered channel of integers
cj := make(chan int, 0)         // unbuffered channel of integers
cs := make(chan *os.File, 100)  // buffered channel of pointers to Files

unbuffered

unbuffered 채널은 값 교환(communication)과 동기화(synchronization)를 한 번에 묶어 주는 역할로 쓰이며, 주로 어느 정도 시간이 소요되는 백그라운드 작업이 끝났을 때 신호로 사용될 수 있습니다.

  • 채널이 비어 있으면 receiver는 받을 데이터가 생길 때까지 block합니다. (buffered도 동일)
  • unbuffered이면 sender는 receiver가 값을 받을 때까지 block합니다.

그래서 예시처럼 버퍼 없이 사용해 백그라운드 작업의 처리 완료를 표현할 수 있습니다.

c := make(chan int)  // Allocate a channel.
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
    list.Sort()
    c <- 1  // Send a signal; value does not matter.
}()
doSomethingForAWhile()
<-c   // Wait for sort to finish; discard sent value.

buffered

버퍼가 생기면 sender는 receiver를 직접 기다리지 않아도 됩니다. 빈 칸이 있으면 값을 버퍼에 복사하는 시점에 send가 끝나고, 버퍼가 가득 차면 receiver가 값을 꺼낼 때까지 기다립니다. 이런 점을 이용해 buffered channel을 세마포어처럼 쓸 수 있다고 설명합니다.

세마포어: 여러 프로세스나 스레드가 공유 자원에 동시에 접근하는 것을 조절하는 정수 변수 기반의 동기화 신호 장치

아래처럼 채널을 만들어 한 번에 처리할 수 있는 개수를 제한하는 로직으로 세마포어 개념을 보여줄 수 있죠.

  • MaxOutstanding는 동시 process 수 상한.
  • Serve가 만드는 고루틴 수 자체는 막지 않음.

예제 설명

  1. Serve: 요청이 오면 go handle(req)로 고루틴을 바로 띄웁니다. 여기에는 세마포어가 없습니다.
  2. handle: sem <- 1에서 버퍼 칸 하나를 채웁니다.
    • 아직 채워진 칸이 MaxOutstanding 미만이면 바로 process로 들어갑니다.
    • 이미 MaxOutstanding개가 차 있으면, 누군가 <-sem으로 칸을 비울 때까지 block합니다.
    • process가 끝나면 <-sem으로 칸을 비워, 막혀 있던 다른 handle이 진행할 수 있습니다.
var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
    sem <- 1    // Wait for active queue to drain.
    process(r)  // May take a long time.
    <-sem       // Done; enable next request to run.
}

func Serve(queue chan *Request) {
    for {
        req := <-queue
        go handle(req)  // Don't wait for handle to finish.
    }
}

Channel 내부 구조

채널의 코드를 다음 구조체로 큰 골조를 파악할 수 있습니다. Go 런타임은 채널의 구체적인 상태를 hchan 구조체에 저장합니다.

  • hchan인 이유는 Go 런타임 관례로 hheader를 의미합니다, 이를 통해 채널 구조의 전체적인 흐름을 파악할 수 있습니다.
// 채널 생성 시 실질적으로 참고하는 구조체
// 실제 채널 구현 구조체 (go1.26.5)
type hchan struct {
	qcount   uint           // 버퍼에 들어 있는 원소 수
	dataqsiz uint           // 버퍼 용량 (0이면 unbuffered)
	buf      unsafe.Pointer // 원형 큐
	elemsize uint16         // 원소의 바이트 크기
	closed   uint32         // 닫힘 여부
	timer    *timer         // time.Ticker/After 등이 이 채널에 연결될 때
	elemtype *_type         // 원소 타입 정보
	sendx    uint           // 다음에 쓸 칸의 인덱스
	recvx    uint           // 다음에 읽을 칸의 인덱스
	recvq    waitq          // 수신 대기 고루틴 큐
	sendq    waitq          // 송신 대기 고루틴 큐
	bubble   *synctestBubble // testing/synctest 버블(격리된 공간)일 때
	lock     mutex
}
// 고루틴 대기 큐
type waitq struct {
	first, last *sudog
}
// park된 고루틴을 가리키는 대기 큐 노드 하나
// 런타임 주석에서는 sudog를 pseudo-g라고 설명
// 큐에 고루틴 본체가 아닌 포인터, 데이터 위치, 채널을 담은 sudog로 대기시켜 관계를 유지함
type sudog struct {
	// g: 고루틴 런타임 객체로, 스케줄러가 실행/대기/재개를 다루는 단위
	// 대기 중인 고루틴
	g *g

	// linked list로 연결
	next *sudog
	prev *sudog

	elem unsafe.Pointer // 송수신 데이터의 메모리 위치 (실제 타입은 maybeTraceablePtr)
	c    *hchan         // 현재 sudog가 대기 중인 채널 (실제 타입은 maybeTraceableChan)
}

채널 생성 (makechan)

make(chan T, n)은 런타임에서 makechan으로 실행됩니다.
여기서 hchan을 할당하고, buffered면 원형 버퍼(buf)까지 붙인 뒤 elemsize/elemtype/dataqsiz 등을 채워주죠.

func makechan(t *chantype, size int) *hchan {
	elem := t.Elem
    // 버퍼에 필요한 바이트 수 계산
	mem, overflow := math.MulUintptr(elem.Size_, uintptr(size))
	// ... size / alignment 검사 생략 ...

	var c *hchan
	switch {
	case mem == 0:
		// unbuffered이거나 원소 크기가 0
		c = (*hchan)(mallocgc(hchanSize, nil, true))
		c.buf = c.raceaddr()
	case !elem.Pointers():
		// 포인터 없는 원소: hchan + buf를 한 번에 할당
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
		c.buf = add(unsafe.Pointer(c), hchanSize)
	default:
		// 포인터 있는 원소: hchan과 buf를 따로 할당
		c = new(hchan)
		c.buf = mallocgc(mem, elem, true)
	}

	c.elemsize = uint16(elem.Size_)
	c.elemtype = elem
	c.dataqsiz = uint(size)
	if b := getg().bubble; b != nil {
		c.bubble = b
	}
	lockInit(&c.lock, lockRankHchan)
	return c
}

할당 직후 qcount/sendx/recvx/closed는 0이 되고 dataqsizn으로 고정됩니다.
go1.26부터는 생성 고루틴이 synctest 버블 안에 있으면 bubble도 같이 기록됩니다. 일반 채널에서 해당 필드는 nil입니다.
이후 send/recv는 이 헤더의 인덱스와 버퍼 칸만 바꿉니다.

채널 메모리

hchan 앞부분만 같은 레이아웃으로 읽어 보면, send/recv 전후에 필드가 어떻게 바뀌는지 직접 확인할 수 있습니다.

--- make(chan int, 4) ---
  qcount=0 dataqsiz=4 elemsize=8
  sendx=0 recvx=0 buf=0x140000be070 closed=0
--- send 10, 20 ---
  qcount=2 dataqsiz=4 elemsize=8
  sendx=2 recvx=0 buf=0x140000be070 closed=0
--- recv one ---
  qcount=1 dataqsiz=4 elemsize=8
  sendx=2 recvx=1 buf=0x140000be070 closed=0
--- fill near wrap ---
  qcount=4 dataqsiz=4 elemsize=8
  sendx=1 recvx=1 buf=0x140000be070 closed=0

--- make(chan int) unbuffered ---
  qcount=0 dataqsiz=0 elemsize=8
  sendx=0 recvx=0 buf=0x1400009a080 closed=0
recv: 99
--- after unbuffered rendezvous ---
  qcount=0 dataqsiz=0 elemsize=8
  sendx=0 recvx=0 buf=0x1400009a080 closed=0
  • dataqsiz는 버퍼 용량이고 make 때 고정됩니다. 들어 있는 개수는 qcount입니다.
  • sendx/recvxbuf에서 다음에 쓸/읽을 칸이며, send와 recv마다 각각만 전진합니다.
  • 인덱스가 같아도 빈 상태와 가득 찬 상태는 qcount로 구분합니다.
  • 같은 채널에서 buf 주소는 바뀌지 않고, 칸의 값과 인덱스만 바뀝니다.
  • unbuffered(dataqsiz=0)는 맞교환 후에도 qcount가 0입니다.

메시지 송신 (chansend)

c <- v는 런타임에서 chansend로 들어가며, lock을 잡은 뒤 3가지 동작 중 하나를 진행합니다.

  1. recvq에 대기 receiver가 있으면 버퍼를 거치지 않고 바로 복사(sendsendDirect)한 뒤 goready로 깨웁니다.
  2. 버퍼에 빈 칸이 있으면 buf[sendx]에 넣고 sendx/qcount를 갱신합니다.
  3. 둘 다 아니면 버퍼가 가득 찼거나 버퍼가 없는 경우로 sudogsendq에 넣고 gopark로 잠듭니다.
flowchart LR txVal["송신: c <- v"] subgraph Buffered[버퍼에 빈 칸] txVal --> tm["typedmemmove(복사)"] tm --> slot["버퍼 원형 큐 슬롯"] slot --> upd["인덱스/개수 갱신"] end subgraph Rendezvous[receiver 대기 중] txVal --> sd["sendDirect(직접 복사)"] sd --> rxDest["수신: v := <-c"] rxDest --> ready["receiver 깨움 (goready)"] end
  • typedmemmove: 런타임이 타입 크기만큼 메모리를 복사하는 연산.
  • sendDirect: 수신 대기 고루틴의 목적지로 직접 복사하는 경로.
  • goready: 대기 중인 고루틴을 깨워 실행 가능 상태로 만드는 호출.
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	lock(&c.lock)
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}

	// 1) 대기 중인 receiver가 있으면 직접 전달
	if sg := c.recvq.dequeue(); sg != nil {
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}

	// 2) 버퍼에 빈 칸이 있으면 원형 큐에 enqueue
	if c.qcount < c.dataqsiz {
		qp := chanbuf(c, c.sendx)
		typedmemmove(c.elemtype, qp, ep)
		c.sendx++
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		c.qcount++
		unlock(&c.lock)
		return true
	}

	// 3) 막힘: sudog를 sendq에 걸고 park
	gp := getg()
	mysg := acquireSudog()
	mysg.elem = ep
	mysg.g = gp
	mysg.c = c
	c.sendq.enqueue(mysg)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceBlockChanSend, 2)
	// ... 깨어난 뒤 sudog 정리 ...
	return true
}

메시지 수신 (chanrecv)

<-cchanrecv로 들어갑니다. 송신과 마찬가지로 3가지 동작으로 나뉩니다.

  1. sendq에 대기 sender가 있으면 recv로 값을 받아 오고 sender를 goready합니다.
  2. 버퍼에 값이 있으면 buf[recvx]에서 읽고 슬롯을 비운 뒤 recvx/qcount를 갱신합니다.
  3. 둘 다 아니면 sudogrecvq에 넣고 gopark로 잠듭니다.
flowchart LR bufSlot["버퍼 원형 큐 슬롯"] rxDest["수신: v := <-c"] subgraph Buffered[버퍼에 값이 있음] bufSlot --> copyRx["typedmemmove(슬롯 -> 수신)"] copyRx --> rxDest copyRx --> clrSlot["typedmemclr(슬롯 비움)"] clrSlot --> updBuf["recvx 전진, qcount 감소(인덱스/개수 갱신)"] end subgraph Rendezvous[sender 대기 중, 버퍼 full] bufSlot --> copyRx2["typedmemmove(큐 -> 수신)"] --> rxDest senderVal["대기 sender 값"] --> copyToSlot["typedmemmove(송신값 -> 같은 슬롯)"] --> bufSlot updRz["recvx/sendx 갱신"] --> wakeSender["sender 깨움(goready)"] end
  • typedmemmove: 런타임이 타입 크기만큼 메모리를 복사하는 연산.
  • typedmemclr: 런타임이 타입 단위로 메모리를 지워 버퍼 슬롯을 비우는 연산.
  • goready: 대기 중인 고루틴을 깨워 실행 가능 상태로 만드는 호출.
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	lock(&c.lock)

	// 1) 대기 중인 sender가 있으면 직접/버퍼 경유로 수신
	if c.closed == 0 {
		if sg := c.sendq.dequeue(); sg != nil {
			recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
			return true, true
		}
	}

	// 2) 버퍼에 값이 있으면 dequeue
	if c.qcount > 0 {
		qp := chanbuf(c, c.recvx)
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		typedmemclr(c.elemtype, qp)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
		unlock(&c.lock)
		return true, true
	}

	// 3) 막힘: sudog를 recvq에 걸고 park
	gp := getg()
	mysg := acquireSudog()
	mysg.elem = ep
	mysg.g = gp
	mysg.c = c
	c.recvq.enqueue(mysg)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceBlockChanRecv, 2)
	// ... 깨어난 뒤 sudog 정리 ...
	return true, mysg.success
}

GMP

GMP는 Go 런타임이 고루틴(G)을 논리 프로세서(P)의 실행 큐에 두고, OS 스레드(M)가 P를 통해 실행하는 스케줄링 메커니즘입니다. 채널은 sudog로 대기 중인 G를 연결하고 goready로 깨우므로 이 스케줄러와 맞닿아 있습니다.

gmp

  • G (Goroutine): 고루틴 런타임 객체로, 스택과 실행 상태를 담습니다.
  • P (Processor): Go 코드를 실행하기 위한 논리적 실행 자원으로, 각각의 P마다 로컬 run queue를 가집니다.
  • M (Machine): OS 스레드를 대표하며, P를 가진 상태에서 실제로 명령을 실행합니다.

주의

P는 OS 프로세스가 아니며 Processor, 즉 Go 런타임 안의 논리 프로세서입니다.
M은 물리 머신이 아니고 Machine이라는 이름일 뿐, 실제로는 OS 스레드를 가리킵니다.

LRQ와 GRQ

고루틴은 바로 CPU에서 실행되지 않고, 내부 큐에 의해 실행 순서가 배정됩니다.

  • LRQ (Local Run Queue): 각 P가 가진 로컬 실행 큐입니다. 그 P에서 만든 고루틴이나 최근에 돌던 고루틴이 주로 여기 들어갑니다.
  • GRQ (Global Run Queue): 모든 P가 공유하는 전역 실행 큐입니다. 로컬 큐가 가득 차 일부 작업을 넘기거나 전역 실행 대기 작업을 둘 때 사용합니다.

실행 흐름은 대략 이렇습니다.

flowchart TD start["M이 P를 붙잡음"] --> lrq{"자기 runnext/LRQ에 G가 있나?"} lrq -->|있음| run["G 실행"] lrq -->|없음| grq{"GRQ에 G가 있나?"} grq -->|있음| run grq -->|없음| steal["다른 P의 LRQ에서 훔침<br/>work stealing"] steal --> run park["채널에서 gopark로 잠든 G"] -->|goready로 깨어남| enqueue["현재 P의 runnext/LRQ<br/>로컬 큐가 넘치면 GRQ"] enqueue --> lrq

위 그림은 실행 큐 탐색을 단순화한 흐름입니다. 실제 스케줄러는 공정성을 위해 GRQ를 주기적으로 확인하고, 로컬 작업이 없으면 GRQ와 다른 PLRQ에서 실행할 G를 찾습니다.

채널에서 block되면 해당 Ggopark로 실행 큐에서 빠집니다. 상대방이 오면 sudog를 통해 goready되고, readyrunqput을 거쳐 일반적으로 현재 PrunnextLRQ에 등록됩니다. 로컬 큐가 넘치면 일부 작업이 GRQ로 이동할 수 있습니다.

다음 절의 관측 훅은 채널 ID, 완료 이벤트와 park 시간을 기록합니다. 실행 큐 배치 자체는 기록하지 않으며, goparkgoready 이후의 연결은 기존 런타임 코드 경로를 근거로 해석합니다.

Go 런타임 관측해보기

애플리케이션에서 큐를 만들어 GMP식 대기와 실행을 흉내 낼 수도 있습니다.

애플리케이션 채널 구현하기

flowchart LR lock["Mutex로 큐 보호"] --> push["대기자 등록<br/>큐.push"] push --> wait["Cond.Wait로 상대 대기<br/>Wait 중 Unlock 후 재Lock"] wait --> signal["상대가 Cond.Signal"] signal --> pop["큐.pop 후 실행 재개"]

Mutex는 큐를 동시에 만지지 않게 막는 잠금이고, 상대를 기다리는 대기는 Cond.Wait가 담당합니다.
하지만 Go 런타임이라는 좋은 자료가 있으니, 그 로직을 따라가며 이해해 봅시다.

런타임 채널 흐름

flowchart LR noPeer["상대 없음"] --> sudog["sudog를<br/>sendq/recvq에 걸어둠"] sudog --> park["gopark로 G를<br/>실행 큐에서 뺌"] park --> peer["상대 도착"] peer --> ready["sudog로 짝을 찾아<br/>goready"] ready --> enqueue["ready/runqput으로<br/>실행 가능 상태 등록"] enqueue --> run["실행 재개"]

의도

스케줄 정책과 채널 의미론은 기존 로직을 유지하며 채널의 생성과 완료된 send/recv, park 시간, deadlock 순간의 대기 관계를 관측하는 것이 목표입니다. 이를 기존 sudog, gopark, goready 코드 경로와 연결해 채널이 GMP와 맞닿는 지점을 해석합니다.

로직

환경 변수 GOCHANTRACE(off / on / sample)로 채널의 핵심 경로에 기록을 붙였습니다. 새 파일은 runtime/chantrace.go이고, 호출은 기존 chan.goproc.go에 최소로 넣었습니다.

기록하는 지점은 다음과 같습니다.

  • makechan: hchan.id 할당 후 create 이벤트
  • chansend / chanrecv: 완료 시점에 send/recv, park했다면 blocked_ns, handoff면 peer goid
  • closechan: close 이벤트

fmt/JSON/파일 I/O 없이 고정 크기 ring과 atomic만 사용합니다.

핵심 수정은 아래와 같습니다.
hchan에 ID를 추가하고 makechan에서 할당해 추적이 가능하게 합니다.

// runtime/chan.go — hchan
bubble   *synctestBubble
id       uint64 // chantrace channel ID

// makechan 끝부분
c.id = chantraceNextID.Add(1)
if chantraceEnabled() {
    chantraceRecord(chanEvent{
        kind:     chanEvCreate,
        chanID:   c.id,
        goid:     gp.goid,
        dataqsiz: uint32(c.dataqsiz),
        qcount:   0,
    })
}

이벤트는 고정 크기 레코드로 ring에만 쌓습니다.

// runtime/chantrace.go
type chanEvent struct {
    kind      chanEventKind
    chanID    uint64
    goid      uint64
    peerGoid  uint64
    ts        int64
    qcount    uint32
    dataqsiz  uint32
    blockedNS int64
}

이번에 관측을 위해 chantraceRing을 추가합니다. bufsendq/recvq는 기존 런타임 구조로 참고합니다.

구조위치하는 일
hchan.buf 원형 큐채널 데이터 버퍼값 저장. q/util이 여기 점유율을 봄
sendq / recvq (sudog)채널 대기자 리스트상대가 없어 gopark한 G를 매달아 둠
chantraceRingchantrace.go 고정 배열create/send/recv/close 이벤트를 관측용으로 쌓기 위해 추가
// 데이터 버퍼 (점유율 = util)
buf      unsafe.Pointer
qcount   uint
dataqsiz uint

// 대기자 큐 (GMP park 대상)
recvq waitq // *sudog 리스트
sendq waitq

// 관측 ring (채널 대기열이 아님)
chantraceRing [65536]chanEvent

상대가 없으면 sudogsendq/recvq에 걸리고 gopark로 실행 큐에서 빠집니다. 그 대기는 완료 이벤트가 생기기 전까지 chantraceRing에 send/recv로 안 남습니다. park 구간만 blocked_ns로 잽니다. send/recv 모두 같은 패턴입니다.

// runtime/chan.go — chansend / chanrecv block 경로
var blockStart int64
if chantraceEnabled() {
    blockStart = nanotime()
}
gopark(chanparkcommit, unsafe.Pointer(&c.lock), reason, ...)

var blockedNS int64
if blockStart != 0 {
    blockedNS = nanotime() - blockStart
}
chantraceRecordOp(chanEvSend /* 또는 chanEvRecv */, c, blockedNS)

proc.go는 스케줄러와 프로세스 수명 주기를 담당하는 파일입니다.
채널 연산 자체는 chan.go에서 담당하지만, 종료와 deadlock처럼 덤프 타이밍은 proc.go에 걸었습니다. schedinit에서 chantraceInit으로 모드를 읽고, 정상 종료 경로에서 ring을 출력하며, checkdead가 전역 deadlock을 확정한 뒤 fatal 직전에 wait graph를 남깁니다.

// runtime/proc.go

// schedinit: GOCHANTRACE 모드 초기화
chantraceInit()

// 정상 종료: ring → 이벤트/util/digraph 덤프
chantraceMaybeDumpOnExit() // GOCHANTRACE_DUMP=0 이면 no-op

// checkdead, fatal 직전: 아직 park 중인 G의 대기 채널
chantraceDumpWaitGraph() // waitreason + sudog.c
if chantraceEnabled() {
    chantraceDump() // 이벤트, util, digraph
}
fatal("all goroutines are asleep - deadlock!")

send/recv 의미와 work stealing 같은 큐 정책은 바꾸지 않았습니다.

추적

실측 환경은 darwin/arm64, 툴체인 기준 go1.26.5입니다.
dump에는 런타임 내부 채널도 섞이므로, 아래는 사용자 시나리오의 id=3 전후만 골랐습니다. 이 실행에서는 id=1이 GC 기동용, id=2main_init_done, 첫 사용자 채널이 id=3으로 생성됐습니다. ID는 실행 중 채널 생성 순서에 따라 달라지는 값이며 고정값이 아닙니다.

같은 실행에서 동일한 ID로 send/recv/close가 이어지면 한 채널의 타임라인으로 읽을 수 있습니다. 아래 unbuffered와 buffered 묶음은 각각 별도 실행에서 얻은 결과이므로 두 실행의 첫 사용자 채널이 모두 id=3입니다.

# unbuffered, park 대기 (cap=0)
chan create id=3 cap=0 goid=1
chan send id=3 goid=19 q=0/0 blocked_ns=0
chan recv id=3 goid=1 q=0/0 blocked_ns=51049333

# buffered 즉시 완료 (cap=1)
chan send id=3 goid=1 q=1/1 blocked_ns=0
chan recv id=3 goid=1 q=0/1 blocked_ns=0

한 줄 포맷은 chan <종류> id=... goid=... q=... blocked_ns=...입니다. 위 buffered 두 줄은 이렇게 읽습니다.

키워드의미예시 (q=1/1 줄)
chan채널 관측 이벤트 접두사chan
send / recv / create / close이벤트 종류send (송신 완료)
id채널 전역 ID (hchan.id)id=3
goid이 연산을 수행한 고루틴 IDgoid=1
capcreate 때만. dataqsiz(make 때 정한 버퍼 용량)create의 cap=0
q완료 직후 qcount/dataqsiz. qcount는 버퍼에 들어 있는 원소 수q=1/1 → 1칸 중 1칸 사용
blocked_nsgopark로 대기한 나노초. 즉시 완료면 0blocked_ns=0
q 예시의미
q=1/1send 직후 버퍼가 가득 참
q=0/1recv 직후 버퍼가 비어 있음
q=0/0unbuffered. 용량 0

receive가 약 50ms sleep을 기다리는 block 실험에서 blocked_ns=51049333(약 51.05ms)가 나왔습니다.
이미 대기 중인 receiver에게 바로 전달한 send와, 버퍼만 쓰는 즉시 완료 경로는 blocked_ns=0이었습니다. 채널 연산이 얼마나 기다렸는지에 대한 park 구간을 수치로 파악할 수 있게 되었죠.

버퍼 사용률

버퍼 사용률(util)은 앞의 hchan.buf 원형 큐가 얼마나 찼는지를 dump 때 요약한 값입니다.
이벤트마다 q만 보면 타임라인은 보이지만 채널별 점유 상태는 한눈에 파악하기 어렵습니다. 그래서 ring에 기록된 이벤트 시점의 qcur/avg/max로 요약합니다. avg는 시간 가중 평균이 아니라 관측된 이벤트 표본의 평균입니다.

chan create id=3 cap=2 goid=1
chan send id=3 goid=1 q=1/2 blocked_ns=0
chan send id=3 goid=1 q=2/2 blocked_ns=0
chan recv id=3 goid=1 q=1/2 blocked_ns=0
chan create id=4 cap=0 goid=1
chantrace util summary
chan id=3 util cur=0.50 avg=0.50 max=1.00 q=1/2
chan id=4 util n/a (unbuffered)
util 항목의미
cur마지막 샘플의 qcount / dataqsiz
avg이벤트 표본의 qcount 합 / (표본 수 × 용량). create의 q=0도 포함
max관측 구간에서 본 최대 qcount / dataqsiz (한 번이라도 가득 찼으면 1.00)
n/adataqsiz==0 (unbuffered)

id=3은 create(0) + send(1) + send(2) + recv(1) 표본입니다. 마지막은 q=1/2cur=0.50이고, 중간에 q=2/2가 있어 관측 중 가장 큰 값은 max=1.00입니다. 이벤트 표본 기준 avg(0+1+2+1)/(4×2)=0.50입니다.

미완료 대기와 deadlock (wait graph)

wait graph는 deadlock 순간에 goroutine과 대기 중인 채널의 관계를 나타낸 그래프입니다.
아직 park 중인 send/recv는 완료되지 않았으므로 앞에서 본 이벤트 ring에 남지 않습니다. 그래서 checkdead가 전역 deadlock을 확정하면 waitreasonsudog.c를 읽어 미완료 대기를 별도로 출력하도록 했습니다.

일반적인 goroutine 스택의 [chan send][chan receive]만으로는 어느 hchan을 기다리는지 알 수 없습니다. wait graph는 여기에 hchan.idqcount/dataqsiz를 더해 같은 채널에서 막혔는지 구분합니다.

첫 번째 검증인 deadlock-send는 unbuffered 채널 하나에 sender만 두고 receiver는 만들지 않았습니다.

ch := make(chan int)
go func() {
    ch <- 1
}()
ch <- 2

시스템 Go의 출력에서는 두 goroutine이 send 중 멈췄다는 사실만 확인할 수 있습니다. 같은 채널을 기다리는지와 해당 채널의 용량은 스택에 나오지 않으므로 코드 위치를 따라가며 추론해야 합니다.

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.runDeadlockSend()
	.../main.go:172 +0x...

goroutine 7 [chan send]:
main.runDeadlockSend.func1()
	.../main.go:170 +0x...

커스텀 런타임은 fatal 메시지 직전에 다음 wait graph를 출력했습니다.

chantrace wait graph begin
  G1 waits send on chan=3 q=0/0
  G6 waits send on chan=3 q=0/0
digraph chanwait {
  G1 -> C3 [label="wait send"];
  G6 -> C3 [label="wait send"];
}
chantrace wait graph end
fatal error: all goroutines are asleep - deadlock!

두 goroutine은 모두 chan=3에 send 대기 중이므로 서로 다른 채널이 아니라 같은 채널에서 막혔습니다. q=0/0은 현재 원소 수와 용량이 모두 0인 unbuffered 채널이라는 뜻입니다. receiver가 없어서 직접 전달할 수 없고 버퍼에도 저장할 수 없으므로 두 sender는 sendq에서 대기합니다. 수정한 런타임에서는 이 대기 관계를 채널 단위로 확인할 수 있습니다.

두 번째 검증인 deadlock-cycle은 unbuffered 채널 두 개로 상호 대기를 만듭니다.
worker는 a를 받아야 b로 보낼 수 있고, main은 b를 받아야 a로 보낼 수 있습니다. 두 goroutine 모두 첫 receive를 끝내지 못하므로 뒤의 send까지 진행할 수 없습니다.

a := make(chan int)
b := make(chan int)
go func() {
    <-a
    b <- 1
}()
<-b
a <- 1

시스템 Go로 실행하면 두 goroutine이 [chan receive] 상태라는 것과 멈춘 코드 위치만 출력됩니다.

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.runDeadlockCycle()
	.../main.go:185 +0x...

goroutine 3 [chan receive]:
main.runDeadlockCycle.func1()
	.../main.go:182 +0x...

같은 코드를 커스텀 Go로 실행하면 fatal과 goroutine 스택 앞에 wait graph가 추가됩니다.

chantrace wait graph begin
  G1 waits recv on chan=4 q=0/0
  G19 waits recv on chan=3 q=0/0
digraph chanwait {
  C4 -> G1 [label="wait recv"];
  C3 -> G19 [label="wait recv"];
}
chantrace wait graph end
fatal error: all goroutines are asleep - deadlock!

시스템 Go의 스택에서는 둘 다 [chan receive]로 표시되지만, 커스텀 Go에서는 main이 chan=4, worker가 chan=3을 기다린다는 차이까지 드러납니다. C → G 방향은 해당 goroutine이 채널에서 receive할 수 있을 때까지 기다린다는 뜻입니다.

이 실행에서 chan=3chan=4는 각각 ab가 생성된 순서와 일치합니다. 다만 채널 ID는 실행 중 생성 순서에 따라 정해지므로, wait graph만으로 임의의 프로그램에 있는 소스 변수명까지 확정할 수는 없습니다. 두 goroutine이 같은 채널이 아니라 서로 다른 채널에서 상호 대기한다는 사실을 식별하는 용도로 쓸 수 있겠네요.

구분관계 그래프wait graph
시점send/recv 완료 후아직 gopark로 대기 중
근거chantraceRing 이벤트waitreason + sudog
화살표완료 이벤트를 채널별로 집계한 G → C, C → G대기 중인 G → C 또는 C → G
goroutine 상태send/recv가 완료된 상태실행 큐에서 빠져 채널을 기다리는 상태
채널 식별완료된 id= 타임라인deadlock 순간의 chan= / q=

지표

GOCHANTRACE_DUMP=0, GOMAXPROCS=1, -benchtime=500ms, -count=5 조건에서 측정한 중앙값입니다. 단위는 ns/op이며 모든 구성에서 메모리 할당은 없었습니다.

  • 괄호는 시스템 Go 대비 증가율입니다.
벤치마크특징시스템 Go커스텀 Go 추적 끔샘플링전체 추적
UnbufferedPingPong두 goroutine이 unbuffered 채널로 값을 왕복342.4352.1 (+2.8%)419.0 (+22.4%)506.6 (+48.0%)
BufferedSendRecv한 goroutine이 buffered 채널에 즉시 send/recv32.6033.52 (+2.8%)42.53 (+30.5%)95.85 (+194.0%)
FanOutFanInworker 고루틴을 거쳐 입력과 결과 채널을 왕복338.0357.0 (+5.6%)423.5 (+25.3%)511.6 (+51.4%)

추적을 끈 커스텀 Go는 채널 ID 할당과 모드 확인 분기 때문에 시스템 Go보다 2.8%에서 5.6% 느렸습니다. 샘플링은 22.4%에서 30.5%, 전체 추적은 48.0%에서 194.0%의 비용이 발생했습니다.

샘플링도 매 이벤트마다 atomic 카운터로 기록 여부를 판단하므로 비용이 남습니다. 전체 추적은 nanotime(), ring 위치 증가, 이벤트 쓰기를 모두 수행해 더 느립니다. 특히 BufferedSendRecv는 기본 연산이 32.60ns/op으로 짧아 고정 추적 비용의 비율이 크게 나타났습니다.

가능성

Go의 runtime/trace는 goroutine 스케줄링과 채널 차단을 추적하고, pprof는 메모리 할당과 동기화 차단을 프로파일링합니다. 하지만 어떤 채널에서 대기하는지, 버퍼가 얼마나 찼는지, 여러 goroutine이 같은 채널을 기다리는지는 직접 보여주지 않죠.

이번에는 이를 시각화하기 위해 채널 ID, 이벤트 표본 기준 버퍼 점유율, 대기 시간과 wait graph를 함께 기록했습니다. 시작은 단순한 호기심이었지만, 이를 이용하면 채널별 병목과 과도한 버퍼 사용 가능성을 좁히고, deadlock에서 goroutine과 채널의 대기 관계를 확인할 수 있을 것 같습니다.

채널 추적 로그를 토대로 Go의 철학인 메시지 전달을 통한 데이터 공유를 시각화했습니다. 아래 배치는 3개 레인과 8개 스테이지의 파이프라인이며, 격자 칸과 collect/out 노드가 각각 채널이고 끝에서 Fan-in으로 합쳐집니다.