[golang] 채널 (channel) 2탄 - buffered channel, unbuffered channel 차이 (버퍼가 있는 채널과 없는 채널 차이)
오랜 만에 채널과 관련된 글을 쓴다.
이전에 go에서 concurrency programming을 위해 필수로 알아두어야 할 개념으로 channel
을 꼽으며 작성한 글이 있다. 일단 channel이 무엇인지 모르는 분들은 글을 먼저 읽고 오는 것을 추천한다.
https://etloveguitar.tistory.com/40?category=902018
channel
은 필자가 golang에 빠져들게 만든 너무 신기하면서도 강력한 mechanism이다. 앞으로도 channel
에 대한 글을 쓸 것이고, 본 글에서는 channel
의 디테일을 알아보는 것의 일환으로 unbuffered channel
(버퍼가 없는 채널)과 buffered channel
(버퍼가 있는 채널)의 차이에 대해 알아보고자 한다. (버퍼가 있는 채널과 버퍼가 없는 채널의 차이).
먼저 unbuffered channel
과 buffered channel
은 코드에서도 아래와 같이 생성할 때 차이가 있다.
unBuffered := make(chan int) // unbuffered channel of integer type
buffered := make(chan int, 10) // buffered channel of integer type
둘다 built-in 함수인 make을 사용하는건 동일하지만 unbuffered
는 argument가 1개이고, buffered
는 두 번째 argument로 버퍼의 크기를 입력한다.
요약
요약 개념만 알아가고 싶으면 아래만 읽어도 된다.
차이점
(1) unbuffered channel은 capacity가 없는 반면 buffered channel은 capacity가 있다.
(2) unbuffered channel은 capacity가 없기 때문에 send와 receive가 동시에 발생해야 하지만, buffered channel은 capacity까지는 send는 할 수 있다.
(3) unbuffered channel은 channel을 공유하는 두 개의 고루틴에서 send와 receive가 동시에 발생하는 것을 보장하지만, buffered channel은 이를 보장하지 않는다.
Channel의 type
그리고 위 차이에 따라 channel의 type도 아래와 같이 정의할 수 있다.
(1) asynchronous
= buffered channel
(2) synchrnous
= unbuffered channel
(3) zero sized async channel
(chan struct{}) : nil
buffer와 O(1)
memory를 가지는 semaphore임
본론
이미 알고 있는 분들은 위 글만으로도 이해가 될 거라 생각하지만, 잘 이해가 되지 않는다면 낙담하지 말고 아래 글을 읽어보자
Unbuffered Channel
unbuffered channel
의 주요 특징은 value가 수신(receive)될 때 까지 channel을 들고 있을 수 없다는 점이다. 이게 무슨 말이냐면 send가 있을 때까지 receive할 수 없으며, receive가 있을 때까지 send할 수 없다는 말이다. 이를 다르게 말하면 send와 receive가 동시에 일어난다. 따라서 unbuffered channel
은 주로 동기화가 필요한 로직에 사용된다. 이게 무슨 말인지 잘 이해가 안된다면 아래 코드를 보자
package main
import "fmt"
func main() {
intChannelZero := make(chan int)
go func() {
fmt.Println("haha")
}()
// not gorouting receiving from this channel
intChannelZero <- 1
}
위 프로그램을 실행하면 deadlock이 발생된다. channel을 receive하지 않기 때문이다.
즉, unbuffered channel
은 받아주는애가 없으면 deadlock 발생지점까지 끝까지 대기하게 만든다.
- 참고로 deadlock의 발생 원인은, channel은 send하려고 하는데 main루틴 외에 실행중인 고루틴이 없을 때 발생한다. (받아주는 고루틴이 없기 때문) 확인해보고 싶다면 goroutine에 원하는 시간 만큼 sleep을 걸어보면 goroutine이 살아있을 때 까지는 deadlock이 발생하지 않는걸 확인할 수 있다.
Buffered channel
buffered channel
이란 채널을 생성할 때 지정했던 갯수(capacity)만큼 값을 들고 있을 수 있는 채널을 의미한다.
buffered channel
은 unbuffered channel
과는 다르게 send와 receive가 동시에 이뤄질 필요가 없다. receive의 경우는 channel에 값이 없을 때 block되고, send의 경우는 channel buffer가 다 찼을 때 block된다.
위에서 unbuffered channel
에 사용했던 예제 프로그램에서 채널만 buffered channel
로 바꿔주면
package main
import "fmt"
func main() {
intChannelZero := make(chan int, 1)
go func() {
fmt.Println("haha")
}()
intChannelZero <- 1
}
deadlock없이 정상 실행된다.
위와 같은 동작방식이 왜 그런지 알고싶다면 아래 글들을 좀 더 읽어보자
Internals of channel
channel의 struct는 runtime
package의 chan.go
안에 있는 hchan
을 통해서 확인할 수 있다.
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // pointer to an array(queue)
elemsize uint16
closed uint32 // if channel is closed
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
lock mutex // mutex for concurrent access to the channel
}
- 참고로 channel을 사용할 땐 객체를 copy하는 방식으로 memory safety를 보장한다. 그리고 두 개의 고루틴이 접근하는
hchan
의 shared memory는mutex
로 보호된다.
먼저 unbuffered channel
를 알애보기 위해 주요 요소들을 시각화한 아래 그림을 보자.
각 컴포넌트들을 정의해보면 아래와 같다
(1) recvq
: receiver의 리스트를 가리키는 포인터
(2) sendq
: sender의 리스트를 가리키는 포인터
(3) waitq
: go에서 linked list type
type waitq struct {
first *sudog
last *sudog
}
(4) sudog
: pseudo-G
, G의 list를 의미. = 고루틴과 관련된 정보를 들고 있다고 생각하면됨. 이걸 가지고 waiting하고 있는 고루틴을 가져올 수 있다.
type sudog struct {
g *g //goroutine
elem unsafe.Pointer // data element
...
}
Channel의 세부 동작
package main
import (
"sync"
"time"
)
func main() {
c := make(chan string)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
c <- `foo`
}()
go func() {
defer wg.Done()
time.Sleep(time.Second * 1)
println(`Message: `+ <-c)
}()
wg.Wait()
}
위 코드를 기준으로 채널이 어떻게 동작하는지 세부적으로 알아보자.
(1) channel과 receiver(recvq
), sender(sendq
) 관련 empty list가 생성된다.
(2) 첫 번째 goroutine이 “foo
”를 channel에 전송한다.
(3) channel은 pool에서 sender를 represent하는 sudog
을 받는다.
(4) sender가 sendq
에 enqueue된다.
(5) goroutine은 chan send로 인해 waiting status
로 바뀐다
(6) 두 번째 goroutine은 해당 channel로부터 message를 read
한다.
(7) channel은 현재 대기하고 있는 sender를 sendq
로부터 dequeue한다.
(8) channel은 memmove 함수를 통해 sudog
struct에 wrap돼있는 sender가 보낸 값을 복사해서 channel를 읽으려고 하는 변수에 복사한다.
(9) block돼 있던 첫 번째 goroutine은 이제 다시 resume되고 (runnable status
로 변경) step3에 acquire했던 sudog
를 release한다.
Bufferen channel 예시
unbuffered channel은 buf가 없기 때문에 send가 발생하자마자 고루틴이 waiting status로 바뀌지만, buffered channel의 경우는 얘기가 다르다.
hchan
struct를 좀 더 자세히 살펴보자
qcount
: 현재 buffer에 있는 element 갯수dataqsize
: buffer가 담을 수 있는 element 최대 갯수buf
: buffer가 담을 수 있는 maximum갯수만큼의 memory segment space를 가리키는 포인터sendx
: buffer에서 다음으로 channel을 통해 receive될 element의 position을 저장recvx
: buffer에서 다음으로 channel을 통해 return될 element의 position을 저장.
그리고 recvx와 sendx덕분에 buffer는 circular queue처럼 동작할 수 있다.
위 그림에서 보이듯 circular queue를 사용하면 element가 pop되더라도 element를 옮길 필요 없이 buffer내 element들의 순서를 보장할 수 있다.
만약 buffer에 element들이 다 채워지면, buffer에 element를 채우려고 하는 고루틴은 waiting status로 바뀌게된다. 그리고 만약 program이 buffer에서 element를 읽으면(pop from queue), buffer에서 recvx에 있던 element가 리턴되고, 대기하고 있든 goroutine이 다시 resume되면서 buffer에 값을 push한다. 이러한 방식을 통해 channel이 FIFO 방식으로 동작할 수 있다. 그리고 이 방식을 활용해서 buffered channel datqsiz
만큼만 element를 받을 수 있게 동작한다.
자 이렇게 buffered channel과 unubffered channel의 내부 구조와 차이를 간단하게 살펴보았다. 여기까지 읽었다면 그래도 얼핏 감은 잡혔을 거라 생각한다. 필자도 더 공부하기 위해 앞으로 GMP 스케줄링에 대해 공부하고, sudog
같은 term들이 진짜 의미하는바가 무엇인지 더 세부적으로 공부할 계획이다. 하게되면 또 공유하도록 하겠다.
https://clavinjune.dev/en/blogs/buffered-vs-unbuffered-channel-in-golang/
https://www.golangprograms.com/go-language/channels.html
https://medium.com/a-journey-with-go/go-buffered-and-unbuffered-channels-29a107c00268
https://shubhagr.medium.com/internals-of-go-channels-cf5eb15858fc