쿼카러버의 기술 블로그

[golang] 채널 (channel) 2탄 - buffered channel, unbuffered channel 차이 (버퍼가 있는 채널과 없는 채널 차이) 본문

[Golang]

[golang] 채널 (channel) 2탄 - buffered channel, unbuffered channel 차이 (버퍼가 있는 채널과 없는 채널 차이)

quokkalover 2022. 9. 14. 22:08

오랜 만에 채널과 관련된 글을 쓴다.

 

이전에 go에서 concurrency programming을 위해 필수로 알아두어야 할 개념으로 channel을 꼽으며 작성한 글이 있다. 일단 channel이 무엇인지 모르는 분들은  글을 먼저 읽고 오는 것을 추천한다.

 

https://etloveguitar.tistory.com/40?category=902018 

 

[golang] 채널 (channel)이란? - 1탄 간단한 소개

채널에 대한 설명을 읽기 전에 이전 포스팅인 고루틴 (go routine)에 대한 간단한 이해를 하길 바란다. 링크 : https://etloveguitar.tistory.com/39 [golang] 고루틴(go routine)이란? - 1탄 간단한 소개 공식 도..

etloveguitar.tistory.com

 

 

channel은 필자가 golang에 빠져들게 만든 너무 신기하면서도 강력한 mechanism이다. 앞으로도 channel에 대한 글을 쓸 것이고, 본 글에서는 channel의 디테일을 알아보는 것의 일환으로 unbuffered channel(버퍼가 없는 채널)과 buffered channel(버퍼가 있는 채널)의 차이에 대해 알아보고자 한다. (버퍼가 있는 채널과 버퍼가 없는 채널의 차이).

 

 

먼저 unbuffered channelbuffered 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 channelunbuffered 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을 저장.

 

그리고 recvxsendx덕분에 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

Comments