Go Channel 이해를 풀다: 그들이 작동하는 방법
James Reed
Infrastructure Engineer · Leapcell

Channel: Golang의 중요한 기능이자 Golang CSP 동시성 모델의 중요한 구현
채널은 Golang에서 매우 중요한 기능이며 Golang CSP 동시성 모델의 중요한 구현이기도 합니다. 간단히 말해서, 고루틴 간의 통신은 채널을 통해 수행될 수 있습니다.
채널은 Golang에서 매우 중요하며 코드에서 매우 자주 사용되므로 내부 구현에 대해 궁금할 수밖에 없습니다. 이 기사에서는 Go 1.13의 소스 코드를 기반으로 채널의 내부 구현 원리를 분석합니다.
채널의 기본 사용법
채널 구현을 공식적으로 분석하기 전에 채널의 가장 기본적인 사용법을 먼저 살펴보겠습니다. 코드는 다음과 같습니다.
package main import "fmt" func main() { c := make(chan int) go func() { c <- 1 // 채널로 보내기 }() x := <-c // 채널에서 받기 fmt.Println(x) }
위의 코드에서 make(chan int)
를 통해 int
타입의 채널을 생성합니다.
하나의 고루틴에서 c <- 1
을 사용하여 채널로 데이터를 보냅니다. 메인 고루틴에서는 x := <- c
를 통해 채널에서 데이터를 읽어 x
에 할당합니다.
위의 코드는 채널의 두 가지 기본 작업에 해당합니다.
send
작업c <- 1
: 채널로 데이터를 보내는 것을 의미합니다.recv
작업x := <- c
: 채널에서 데이터를 받는 것을 의미합니다.
또한 채널은 버퍼링된 채널과 버퍼링되지 않은 채널로 나뉩니다. 위의 코드에서는 버퍼링되지 않은 채널을 사용합니다. 버퍼링되지 않은 채널의 경우, 현재 채널에서 데이터를 수신하는 다른 고루틴이 없으면 발신자는 전송 구문에서 차단됩니다.
채널을 초기화할 때 버퍼 크기를 지정할 수 있습니다. 예를 들어 make(chan int, 2)
는 버퍼 크기를 2로 지정합니다. 버퍼가 가득 차기 전에는 발신자가 차단 없이 채널로 데이터를 보낼 수 있으며 수신자가 준비될 때까지 기다릴 필요가 없습니다. 그러나 버퍼가 가득 차면 발신자는 여전히 차단됩니다.
채널의 기본 구현 함수
채널의 소스 코드를 탐색하기 전에 Golang에서 채널의 특정 구현이 어디에 있는지 먼저 알아내야 합니다. 채널을 사용할 때 <-
기호를 사용하고 Go 소스 코드에서 직접 구현을 찾을 수 없기 때문입니다. 그러나 Golang 컴파일러는 <-
기호를 해당 기본 구현으로 변환합니다.
Go의 내장 명령인 go tool compile -N -l -S hello.go
를 사용하여 코드를 해당 어셈블리 명령어로 변환할 수 있습니다.
또는 온라인 도구인 Compiler Explorer를 직접 사용할 수도 있습니다. 위의 예제 코드의 경우 이 링크에서 어셈블리 결과를 직접 볼 수 있습니다. go.godbolt.org/z/3xw5Cj. 다음 그림과 같이:
채널 어셈블리 명령어
위의 예제 코드에 해당하는 어셈블리 명령어를 주의 깊게 검사하면 다음과 같은 대응 관계를 찾을 수 있습니다.
- 채널 생성 구문
make(chan int)
는runtime.makechan
함수에 해당합니다. - 전송 구문
c <- 1
은runtime.chansend1
함수에 해당합니다. - 수신 구문
x := <- c
는runtime.chanrecv1
함수에 해당합니다.
위 함수의 구현은 모두 Go 소스 코드의 runtime/chan.go
코드 파일에 있습니다. 다음으로 이러한 함수를 대상으로 채널 구현을 탐색합니다.
채널 생성
채널 생성 구문 make(chan int)
는 Golang 컴파일러에 의해 runtime.makechan
함수로 변환되며, 함수 서명은 다음과 같습니다.
func makechan(t *chantype, size int) *hchan
여기서 t *chantype
는 채널을 생성할 때 전달되는 요소 유형입니다. size int
는 사용자가 지정한 채널의 버퍼 크기이며 지정하지 않으면 0입니다. 이 함수의 반환 값은 *hchan
입니다. hchan
은 Golang의 채널 내부 구현입니다. 정의는 다음과 같습니다.
type hchan struct { qcount uint // 버퍼에 이미 배치된 요소 수 dataqsiz uint // 사용자가 채널을 생성할 때 지정한 버퍼 크기 buf unsafe.Pointer // 버퍼 elemsize uint16 // 버퍼의 각 요소 크기 closed uint32 // 채널이 닫혔는지 여부, == 0은 닫히지 않았음을 의미 elemtype *_type // 채널 요소의 유형 정보 sendx uint // 버퍼 전송 인덱스에서 보낸 요소의 인덱스 위치 recvx uint // 버퍼 수신 인덱스에서 수신된 요소의 인덱스 위치 recvq waitq // recv waiters를 수신하기 위해 대기하는 고루틴 목록 sendq waitq // send waiters를 보내기 위해 대기하는 고루틴 목록 lock mutex }
hchan
의 모든 속성은 대략 세 가지 범주로 나눌 수 있습니다.
- 버퍼 관련 속성:
buf
,dataqsiz
,qcount
등. 채널의 버퍼 크기가 0이 아니면 버퍼는 수신할 데이터를 저장합니다. 링 버퍼를 사용하여 구현됩니다. - waitq 관련 속성: 표준 FIFO 큐로 이해할 수 있습니다. 그중
recvq
는 데이터를 수신하기 위해 대기하는 고루틴을 포함하고,sendq
는 데이터를 보내기 위해 대기하는 고루틴을 포함합니다.waitq
는 이중 연결 목록을 사용하여 구현됩니다. - 기타 속성:
lock
,elemtype
,closed
등.
makechan
의 전체 프로세스는 기본적으로 버퍼, hchan
및 기타 속성에 대한 일부 적법성 검사 및 메모리 할당이며 여기서는 자세히 논의하지 않습니다. 관심 있는 분들은 여기에서 소스 코드를 직접 볼 수 있습니다.
hchan
의 속성을 간단히 분석하면 두 가지 중요한 구성 요소인 buffer
와 waitq
가 있음을 알 수 있습니다. hchan
의 모든 동작과 구현은 이러한 두 구성 요소를 중심으로 이루어집니다.
채널로 데이터 보내기
채널의 보내기 및 받기 프로세스는 매우 유사합니다. 먼저 채널의 보내기 프로세스(c <- 1
과 같은)를 분석해 보겠습니다. 이는 runtime.chansend
함수의 구현에 해당합니다.
채널로 데이터를 보내려고 할 때 recvq
큐가 비어 있지 않으면 데이터를 수신하기 위해 대기하는 고루틴이 먼저 recvq
의 헤드에서 추출됩니다. 그리고 데이터는 이 고루틴으로 직접 전송됩니다. 코드는 다음과 같습니다.
if sg := c.recvq.dequeue(); sg!= nil { send(c, sg, ep, func() { unlock(&c.lock) }, 3) return true }
recvq
에는 데이터를 수신하기 위해 대기하는 고루틴이 포함되어 있습니다. 고루틴이 recv
작업(예: x := <- c
)을 사용하는 경우, 이때 채널의 캐시에 데이터가 없고 데이터를 보내기 위해 대기하는 다른 고루틴이 없으면(즉, sendq
가 비어 있으면) 이 고루틴과 수신할 데이터의 주소가 sudog
객체로 패키지되어 recvq
에 배치됩니다.
위의 코드를 계속 진행하면, 이때 recvq
가 비어 있지 않으면 send
함수가 호출되어 해당 고루틴의 스택에 데이터를 복사합니다.
send
함수의 구현에는 주로 두 가지 사항이 포함됩니다.
memmove(dst, src, t.size)
는 데이터 전송을 수행하며, 이는 본질적으로 메모리 복사입니다.goready(gp, skip+1)
goready
의 기능은 해당 고루틴을 깨우는 것입니다.
recvq
큐가 비어 있으면, 이때 데이터를 수신하기 위해 대기하는 고루틴이 없음을 의미하며, 채널은 버퍼에 데이터를 넣으려고 시도합니다. 코드는 다음과 같습니다.
if c.qcount < c.dataqsiz { // c.buf[c.sendx]와 동일 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 }
위 코드의 기능은 실제로 매우 간단합니다. 즉, 데이터를 버퍼에 넣는 것입니다. 이 프로세스에는 링 버퍼의 작업이 포함되며, 여기서 dataqsiz
는 사용자가 지정한 채널의 버퍼 크기를 나타내며 지정하지 않으면 기본값은 0입니다. 다른 특정 세부 작업은 나중에 링 버퍼 섹션에서 자세히 설명됩니다.
사용자가 버퍼링되지 않은 채널을 사용하거나 이때 버퍼가 가득 차면 조건 c.qcount < c.dataqsiz
가 충족되지 않고 위의 프로세스가 실행되지 않습니다. 이때 현재 고루틴과 보낼 데이터가 sendq
큐에 배치되고 이 고루틴은 동시에 컷아웃됩니다. 전체 프로세스는 다음 코드에 해당합니다.
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) // 고루틴을 대기 상태로 전환하고 잠금 해제 goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)
위 코드에서 goparkunlock
은 입력 뮤텍스를 잠금 해제하고 이 고루틴을 컷아웃하여 이 고루틴을 대기 상태로 설정합니다. 위의 gopark
와 goready
는 서로 대응하며 역방향 작업입니다. gopark
와 goready
는 런타임 소스 코드에서 자주 발생하며 고루틴의 스케줄링 프로세스와 관련이 있으며 여기서는 자세히 논의하지 않고 별도의 기사에서 나중에 다룰 것입니다.
gopark
를 호출한 후 사용자의 관점에서 채널로 데이터를 보내는 코드 구문이 차단됩니다.
위의 프로세스는 채널의 보내기 구문(c <- 1
과 같은)의 내부 워크플로이며 전체 보내기 프로세스는 동시성 보안을 보장하기 위해 c.lock
을 사용하여 잠급니다.
간단히 말해서, 전체 프로세스는 다음과 같습니다.
recvq
가 비어 있는지 확인합니다. 비어 있지 않으면recvq
의 헤드에서 고루틴을 가져오고 데이터를 보내 깨웁니다.recvq
가 비어 있으면 데이터를 버퍼에 넣습니다.- 버퍼가 가득 차면 보낼 데이터와 현재 고루틴을
sudog
객체로 패키지하여sendq
에 넣습니다. 그리고 현재 고루틴을 대기 상태로 설정합니다.
채널에서 데이터 받기 프로세스
채널에서 데이터를 받는 프로세스는 기본적으로 보내기 프로세스와 유사하며 여기서는 반복하지 않습니다. 받기 프로세스와 관련된 특정 버퍼 관련 작업은 나중에 자세히 설명합니다.
여기서 주의해야 할 점은 채널의 전체 보내기 및 받기 프로세스는 잠금에 runtime.mutex
를 사용한다는 것입니다. runtime.mutex
는 런타임 관련 소스 코드에서 일반적으로 사용되는 경량 잠금입니다. 전체 프로세스는 가장 효율적인 잠금 없는 접근 방식이 아닙니다. Golang에는 문제가 있습니다. go/issues#8899, 잠금 없는 채널 솔루션을 제공합니다.
채널 링 버퍼 구현
채널은 링 버퍼를 사용하여 작성된 데이터를 캐시합니다. 링 버퍼는 많은 장점이 있으며 고정 길이 FIFO 큐를 구현하는 데 매우 적합합니다.
채널에서 링 버퍼의 구현은 다음과 같습니다.
채널의 링 버퍼 구현
hchan
에는 버퍼와 관련된 두 개의 변수 recvx
와 sendx
가 있습니다. 그중 sendx
는 버퍼에서 쓸 수 있는 인덱스를 나타내고 recvx
는 버퍼에서 읽을 수 있는 인덱스를 나타냅니다. recvx
와 sendx
사이의 요소는 버퍼에 정상적으로 배치된 데이터를 나타냅니다.
buf[recvx]
를 직접 사용하여 큐의 첫 번째 요소를 읽고 buf[sendx] = x
를 사용하여 큐의 끝에 요소를 배치할 수 있습니다.
버퍼 쓰기
버퍼가 가득 차 있지 않으면 버퍼에 데이터를 넣는 작업은 다음과 같습니다.
qp := chanbuf(c, c.sendx) // 데이터를 버퍼에 복사 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]
와 같습니다. 위의 프로세스는 매우 간단합니다. 즉, 데이터를 버퍼에서 sendx
위치에 복사하는 것입니다.
그런 다음 sendx
를 다음 위치로 이동합니다. sendx
가 마지막 위치에 도달하면 0으로 설정합니다. 이는 일반적인 헤드 투 테일 연결 방법입니다.
버퍼 읽기
버퍼가 가득 차 있지 않으면 이때 sendq
도 비어 있어야 합니다(버퍼가 가득 차 있지 않으면 데이터를 보내는 데 사용되는 고루틴이 큐에 대기하지 않고 버퍼에 직접 데이터를 넣기 때문입니다. 특정 논리는 위의 채널로 데이터 보내기 섹션을 참조하십시오). 이때 채널의 읽기 프로세스인 chanrecv
는 비교적 간단하며 버퍼에서 직접 데이터를 읽을 수 있습니다. 이는 또한 recvx
를 이동하는 프로세스입니다. 기본적으로 위의 버퍼 쓰기와 동일합니다.
sendq
에 대기 중인 고루틴이 있으면 버퍼는 이때 가득 차 있어야 합니다. 이때 채널의 읽기 논리는 다음과 같습니다.
// c.buf[c.recvx]와 동일 qp := chanbuf(c, c.recvx) // 큐에서 수신기로 데이터 복사 if ep!= nil { typedmemmove(c.elemtype, ep, qp) } // 발신자에서 큐로 데이터 복사 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)
는 현재 읽을 수 있는 버퍼의 요소를 수신 변수의 주소로 복사하는 것을 의미합니다.typedmemmove(c.elemtype, qp, sg.elem)
는sendq
에서 고루틴이 보내기를 기다리는 데이터를 버퍼에 복사하는 것을 의미합니다. 나중에recv++
가 수행되기 때문에 큐의 끝에sendq
에 데이터를 넣는 것과 같습니다.
간단히 말해서 여기에서 채널은 버퍼의 첫 번째 데이터를 해당 수신 변수로 복사하고 동시에 sendq
의 요소를 큐의 끝으로 복사하여 데이터를 FIFO(First In First Out)로 처리할 수 있습니다.
요약
Golang에서 가장 일반적으로 사용되는 기능 중 하나인 채널의 소스 코드를 이해하면 채널을 더 잘 이해하고 사용할 수 있습니다. 동시에 채널의 성능에 대해 지나치게 미신적이거나 의존적이지 않을 것입니다. 채널의 현재 설계에는 여전히 최적화할 여지가 충분합니다.
최적화 참고 사항:
- 제목(
#
및##
등 사용)을 사용하여 기사 내용을 계층화하여 구조를 더 명확하게 합니다. - 코드 블록은 명확하게 표시되어(
go
사용) 코드의 가독성을 향상시킵니다. - 코드 블록의 주석은 별도로 나열되어 코드 논리에 대한 설명을 더 명확하게 하고 코드 블록의 주석이 읽기 경험에 미치는 영향을 방지합니다.
- 몇 가지 주요 부품은 요점으로 제시되어 채널의 전송 프로세스와 같이 복잡한 논리를 더 쉽게 이해할 수 있습니다.
- 독자가 관련 자료를 참조할 수 있도록 일부 내용에 하이퍼링크가 추가되었습니다.
Leapcell: Golang 웹 호스팅을 위한 최고의 서버리스 플랫폼
마지막으로 Go 서비스를 배포하기에 가장 적합한 플랫폼인 Leapcell을 추천합니다.
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발하십시오.
2. 무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하십시오. 요청도 없고 요금도 부과되지 않습니다.
3. 최고의 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불하십시오.
- 예: $25는 60ms 평균 응답 시간으로 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 손쉬운 설정을 위한 직관적인 UI
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅
5. 손쉬운 확장성 및 고성능
- 고도의 동시성을 쉽게 처리하기 위한 자동 크기 조정
- 운영 오버헤드가 전혀 없습니다. 구축에만 집중하십시오.
Leapcell Twitter: https://x.com/LeapcellHQ