Deep Dive to Golang Channel

타미·2021년 9월 28일
3

Hello Golang

목록 보기
7/7
post-thumbnail

고랭 채널의 내부 구조와 동작 방식에 대해 살펴보자!


Channel의 특징

1. FIFO


채널은 send한 순서대로 데이터를 receive하게 된다.

2. goroutine-safe

thread-safe란 멀티 thread 프로그래밍에서 여러 쓰레드로부터 변수에 동시에 접근이 이뤄져도 프로그램의 실행에 문제가 없음을 뜻한다.
goroutine으로 치환하면, 여러 고루틴이 하나의 채널에 동시에 접근해도 프로그램 실행에 문제가 없다는 얘기다.

  • 1) 여러 고루틴이 동시에 하나의 채널에 여러 데이터를 send할 때 누락되는 데이터가 존재하지 않는다.
  • 2) 여러 고루틴이 동시에 하나의 채널에서 receive를 하려고 할 때 중복으로 receive하는 데이터가 존재하지 않는다.

3. 서로 다른 goroutine에서 send/receive할 수 있다.

size := 10
ch := make(chan int, size)

// goroutine1
go func() {
	for i := 0; i < size; i++ {
		ch <- i
	}
}()
	
// goroutine2
go func() {
	for {
		select {
		case value := <-ch:
		}
	}
}()

1번 goroutine에서 send한 데이터를 2번 goroutine에서 receive할 수 있다.

4. block, unblock

채널을 사용하다 보면 어떤 시점에 goroutine이 block되기도, unblock되기도 한다.

ch := make(chan int, 3)
go func() {
  ch <- 1
  ch <- 2
  ch <- 3
  ch <- 4 // block
  // do something
}

이미 채널 버퍼가 꽉 찬 상태에서 데이터를 receive하려고 하면 위 goroutine은 block되어서 그 후의 dosomething을 수행할 수가 없다.

ch := make(chan int, 3)
go func() {
	ch <- 1
    ch <- 2
    ch <- 3 
    ch <- 4 // 2) unblock
    // 3) do something
}

go func() {
	<-ch // 1) 이게 실행되면
}

위 고루틴이 block된 시점에, 또 다른 고루틴이 데이터를 receive하면 채널에 자리가 생기면서,
block이 풀리고 (unblock) ch <- 4 , do something이 동작하게 된다.

내부 구조와 동작 방식을 통해 어떻게 위 4가지 특징이 구현됐는지 알아보자~


Channel의 생성

ch := make(chan int, 3)

make 명령어를 통해 channel을 생성하면 어떤 일이 생길까?

hchan struct

위와 같은 명령어를 입력하면 내부적으로 hchan struct가 만들어진다.

type hchan struct {
	qcount   uint           // total data in the queue
	dataqsiz uint           // size of the circular queue
	buf      unsafe.Pointer // points to an array of dataqsiz elements
	elemsize uint16
	closed   uint32
	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 protects all fields in hchan, as well as several
	// fields in sudogs blocked on this channel.
	//
	// Do not change another G's status while holding this lock
	// (in particular, do not ready a G), as this can deadlock
	// with stack shrinking.
	lock mutex
}

이 hchan은 고랭 내부에 정의된 것으로 여기에 위치한다.
여러가지 필드와 변수가 있지만 기본적인 것은 아래와 같다.


buf

  • buf unsafe.Pointer
  • 데이터가 저장되는 array가 존재하고, 해당 array를 가르키는 포인터
  • 원형 큐의 형태로 동작한다.

sendx

  • 데이터를 send할 array의 index
  • 데이터를 send한다면 이 index에 넣는다.


데이터가 하나도 없을 때, sendx: 0
내가 이 상태에서 데이터를 하나 send 한다면, 0번 index에 데이터가 들어간다.


데이터가 하나 추가됐을 때, sendx: 1
이 상태에서 데이터를 하나 send하려고 한다면, 1번 index에 데이터가 들어간다.


recvx

  • 데이터를 receive할 array의 index
  • 데이터를 receive한다면 이 index에 넣는다.


데이터가 한번도 receive한 적이 없을 때, recvx: 0
이 상태에서 데이터를 하나 receive하려면 0번 index에 있는 데이터를 receive하게 될 것이다.


데이터를 하나 receive한다면, recvx: 1


+a)

  • 그림상으로는 데이터가 사라졌지만 물론 실제 코드에서는 데이터가 사라지지 않는다. sendx를 통해 데이터를 덮어쓰면서 돌아간다.
  • 만약 qSize만큼 sendx, recvx가 된다면 0으로 다시 돌아가게 된다. -> circular queue


hstruct는 heap에 위치하고, ch은 hstruct에 대한 포인터이다.
그래서 "서로 다른 goroutine에서 send/receive할 수 있다."가 가능하다.

💡 FIFO: sendx, recvx가 원형큐 형태로 동작하여 구현
💡 서로 다른 goroutine에서 send/receive할 수 있다: hchan이 heap에 위치하기 때문


Send, Receive 로직

send

ch <- task

  1. mutex로 lock한다.
  1. send할 데이터를 copy해서 buffer에 넣어준다.

  1. mutex lock을 풀어준다.
  • mutex를 사용하여 goroutine-safe를 보장
  • data를 copy하여 전달
    • Do not communicate by sharing memory. Instead, share memory by communicating.
      • hchan 객체를 제외하고 직접적으로 메모리를 공유하지 않는다.
      • share memory by communicating. : 데이터를 복사하여 사용한다.

이 2가지 특징을 알 수 있다.


receive

t := <-ch

  1. mutex lock

  2. data copy

    t 변수에 data를 copy한다.

  3. mutex lock을 풀어준다.

💡 goroutine-safe: mutex가 있기 에 가능


+) 주의해야할 점

채널 자체는 goroutine-safe하다. 하지만 채널에 데이터를 send/receive하는 변수는 goroutine-safe를 보장할 수 없다!

var globalNumber int

func Send() {
	globalNumber++
	ch <- globalNumber
}

채널 내부에서는 goroutine-safe를 보장하지만, "globalNumber"의 goroutine-safe는 보장되지 않는다. receive도 마찬가지다.


block, unblock

block, unblock이 어떻게 구현되는지에 앞서 고루틴에 대해 간략한 이해가 필요하다.

goroutine

  • user-space thread
    • os thread보다 더 상위 레벨의 thread이다.
    • 여기서 user는 개발자가 아니라 golang이라고 보면 된다.
    • goruntime이 관리하는 스레드이다.
  • 그림을 보면 OS thread 1개를 여러 개의 고루틴이 나눠쓰는 것을 알 수 있다.
    • lightwieght
      • goroutine(8kbyte) <<< os thread (1MB)
    • overhead가 적다.
      • g1에서 g2로 context switch를 해도 os thread는 context switch를 하지 않게 된다.

go 스케줄러의 동작 방식 (M:N Scheduling)

기본적인 구조는 위와 같다.

  • M (OS Thread) 위에 실제로 돌아가고 있는 고루틴이 존재한다.
  • runQ에는 실행 대기중인 고루틴이 존재한다.
  • Processor는 실행중인 고루틴을 중단하기도 하고, runQ에 있던 고루틴을 실행시켜주기도 한다.

goroutine이 block되는 과정

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
ch <- 4 // block!

1. 실행중이던 고루틴의 상태: executing -> waiting

2. 연관관계 제거

3. runQ에서 대기중이던 다른 고루틴 실행


channel에서의 block, unblock

이 과정을 알기 위해서 기본적으로 sudog라는 객체를 알아야 한다.

sudog

type sudog struct {
	g *g
	elem unsafe.Pointer // data element (may point to stack)
	// and more
}

sudog도 go 내부에 존재하는 객체이다.
여러가지 필드가 있지만 기본적으로 g와 elem을 알아야 한다.

  • g: 고루틴
  • elem: 해당 고루틴의 stack에 있는 변수를 가르키는 pointer

고루틴과 해당 고루틴이 쓰는 변수 정보를 가지고 있는 구조체가 sudog이다.


  • sendq: send를 대기하고 있는 sudog 목록 (대기중인 고루틴과 해당 고루틴이 사용한 변수)
  • recvq: receive를 대기하고 있는 sudog 목록 (대기중인 고루틴과 해당 고루틴이 사용한 변수)

이런 식으로 대기하고 있는 고루틴 정보를 저장하고 있다.


type hchan struct {
	recvq    waitq  // list of recv waiters
	sendq    waitq  // list of send waiters
	// ... and more
}

이쯤에서 다시보는 hchan. hchan 안에는 recvq와 sendq 필드가 존재하고 요놈들의 데이터 타입은 waitq이다.

type waitq struct {
	first *sudog
	last  *sudog
}

waitq는 이전 sudog, 다음 sudog를 저장하고 있어 즉 recvq, sendq는 연결리스트로 sudog 정보를 저장한다.


channel에서 block되는 과정

먼저 block되는 상황이 발생한다.

ch := make(chan int, 3)
go func() {
	ch <- 1
    ch <- 2
    ch <- 3
    ch <- 4 // block
    // do something
}

1. sudog 구조체 생성


block되는 상황이 발생하면 해당 고루틴의 sudog 구조체를 생성한다.

  • task4는 숫자 4의 값과 같다.
    • 더 정확하게는 숫자 4를 저장하고 있는 stack의 pointer

2. sendq에 추가

send를 대기중인 sudog 목록인 sendq (linkedlist)에 추가한다.

3. 해당 고루틴을 block 처리한다.

gopark이라는 함수를 사용하여, processor가 해당 고루틴을 block 처리하게 한다.


channel에서 unblock되는 과정

ch := make(chan int, 3)
go func() {
	ch <- 1
    ch <- 2
    ch <- 3 
    ch <- 4 // 2) unblock
    // 3) do something
}

go func() {
	t := <-ch // 1) 이게 실행되면
}

1. sendq 목록에 있던 sudog 연결을 제거한다.

2. buf에서 receive할 데이터를 copy한다.

recvx (receive할 차례)에 해당하는 데이터를 복사한다.

3. buf에 send하려던 값을 copy한다.

sudog에 있는 elem (send하려던 값)을 buf에 copy해서 channel에 send한다.

4. 대기중이었던 고루틴 running 상태로 바꾼다.

goready라는 함수를 통해 대기중이었던 고루틴을 실행할 수 있게 한다.


+a)

ch := make(chan int, 3)
// g1
go func() {
	ch <- 1 // 2) send
}

// g2
go func() {
	t := <-ch // 1) block
	// dosomething 3) unblock
}

위와 같이, 비어있던 채널에 대해 block을 하다가 데이터가 send되면서 receive하는 과정은 어떻게 될까?

block

  • sudog 생성
  • recvq에 추가
  • 고루틴 waiting 상태로 변경되어 block된다.

데이터 전달

  • recvq 목록 제거
  • buf에 send할 데이터 복사
  • buf에서 receive할 데이터 복사
    • g2 sudog.elem에 복사
  • block되어있던 고루틴(g2) running 상태로 변경

위 설명대로라면 다음과 같이 실행되겠지만 조금 비효율적이다.

  • recvq 목록 제거
  • buf에 send할 데이터 복사
  • buf에서 receive할 데이터 복사
  • send할 데이터를 block되어있던 고루틴의 stack에 복사
  • block되어있던 고루틴(g2) running 상태로 변경


즉 g1 stack에 있던 데이터가 바로 g2 stack으로 복사된다!
buffer가 없는 고루틴도 마찬가지 방식으로 돌아간다.

💡 block, unblock
1. hchan 내부에 send 대기중인 고루틴 목록/receive 대기중인 고루틴 목록을 저장하고 있는다.
2. sudog라는 구조체로 고루틴 정보와 channel에 보낼 데이터 (stack에 대한 포인터) 정보를 가지고 있는다.
3. 고루틴은 상태가 변경됨에 따라 block, unblock되며, 이는 모두 고루틴 런타임 레벨에서 동작한다.

profile
IT's 호기심 천국

4개의 댓글

comment-user-thumbnail
2021년 11월 22일

혹시 고랭하게 된 계기가 있나요????

1개의 답글
comment-user-thumbnail
2024년 6월 25일

안녕하세요 글 재미있게 잘 읽었습니다 ㅎㅎ
혹시 래퍼런스가 있을까요?

1개의 답글