1P by neo 3달전 | favorite | 댓글 1개

소개

  • 우리는 세계 최초의 버전 관리 SQL 데이터베이스인 Dolt를 Go 언어로 작성하고 있음
  • 대부분의 Go 코드베이스처럼 채널과 고루틴을 사용하여 동시 실행을 구현함
  • 일반적으로 동시 프로그래밍은 어렵기 때문에 간단하고 직관적인 방법을 사용함
  • 그러나 다른 오픈 소스 프로젝트에서 채널을 매우 독창적으로 사용하는 코드를 상속받았음
var c chan chan struct{}
  • 이는 다른 고루틴 간에 채널을 전달하는 방식으로, 작업자 고루틴 간의 팬아웃 패턴을 구현함
  • 이러한 방식은 이해하기 어렵고 고루틴 누수를 고려할 때 작업하기 어려웠음
  • 결국 이 코드를 다시 작성하여 chan chan struct{}를 제거함

왜 이런 일을 하는가

  • C 언어와 그 파생 언어가 지배적이던 시절의 오래된 프로그래밍 농담이 있음
  • 많은 사람들이 포인터를 이해하는 데 어려움을 겪었음
  • Go도 C에서 파생된 언어이기 때문에 동일한 작업을 수행할 수 있음
func main() {
  i := 1
  setInt(&i)
  fmt.Printf("i is now %d", i)
}

func setInt(i *int) {
  setInt2(&i)
}

func setInt2(i **int) {
  setInt3(&i)
}

func setInt3(i ***int) {
  setInt4(&i)
}

func setInt4(i ****int) {
  ****i = 100
}
  • 이 코드는 컴파일되어 i is now 100을 출력함
  • Go에서 채널을 사용하여 동일한 작업을 수행할 수 있음

4-chan Go 프로그래머

  • 4단계의 채널 간접 참조를 사용하는 프로그램을 작성할 것임
  • 최상위 채널은 4-chan으로 선언됨
_4chan := make(chan chan chan chan int)
  • 이 채널에 보내는 값은 3-chan임
_3chan := make(chan chan chan int)
  • 각 간접 참조 단계에서 일정한 분기 요소에 따라 생산자를 생성함
func sendChanChanChan(c chan chan chan chan int) {
  for range factor {
    go func() {
      logrus.Debug("starting 3chan producer")
      _3chan := make(chan chan chan int)
      sendChanChan(c, _3chan)
    }()
  }
}
  • 소비자도 동일하게 처리함
func receiveChanChanChan(c chan chan chan chan int) {
  for _3chan := range c {
    logrus.Debug("got message from 4chan")
    for range factor {
      logrus.Debug("starting 3chan consumer")
      go receiveChanChan(_3chan)
    }
  }
}
  • 마지막으로 실제 값을 보내는 단계에 도달함
func send(_2chan chan chan int, _1chan chan int) {
  _2chan <- _1chan
  for range factor {
    go func() {
      logrus.Debug("starting int producer")
      for range factor {
        go func() {
          logrus.Debug("sending int")
          _1chan <- 1
        }()
      }
    }()
  }
}
  • 소비자는 받은 값을 합산함
var sum = &atomic.Int32{}

func receive(c chan int) {
  for s := range c {
    logrus.Debug("received int")
    sum.Add(int32(s))
  }
}
  • 모든 것을 합쳐서 실행함
const factor = 3
var sum = &atomic.Int32{}

func main() {
  // logrus.SetLevel(logrus.DebugLevel)
  _4chan := make(chan chan chan chan int)
  go sendChanChanChan(_4chan)
  go receiveChanChanChan(_4chan)
  time.Sleep(500 * time.Millisecond)
  fmt.Printf("%d ^ 5: %d", factor, sum.Load())
}
  • 이 프로그램은 숫자의 5제곱을 최대한 분산된 방식으로 계산함

논평

  • 실제 코드에서 이렇게 하지 말아야 할 이유가 많음: 구현 및 디버깅의 어려움, 자존심 문제, 동료들의 비난 등
  • 그러나 매우 재미있고 작동한다는 점에서 흥미로움
  • 실용적인 이유 중 하나는 채널을 채널로 보내면 닫기가 매우 어려워짐

결론

  • Go에서의 재미있는 동시성 패턴에 대해 질문이나 의견이 있으면 Discord에서 우리 팀과 다른 Dolt 사용자들과 대화할 수 있음

GN⁺의 정리

  • 이 글은 Go 언어에서 채널을 사용한 독창적인 동시성 패턴을 다루고 있음
  • 실제 코드에서 사용하기에는 비효율적이지만, 개념적으로 흥미로움
  • Dolt와 같은 프로젝트에서 Go의 동시성 기능을 어떻게 활용할 수 있는지 보여줌
  • 유사한 기능을 가진 프로젝트로는 PostgreSQL, MySQL 등이 있음
Hacker News 의견
  • 과학자로서 전문 소프트웨어 엔지니어와 함께 일할 때, 그들이 하는 많은 일이 이해되지 않음

    • 코드 한 줄이 4개의 "인터페이스 함수"를 거쳐 호출되는 것을 본 적이 있음
    • 각 함수는 서로 다른 파일과 폴더에 있어 코드 읽기가 매우 피곤해짐
    • 몇 단계 들어가면 실제로 계산하는 부분에 도달할지 의문이 생김
  • 저조한 노력의 비실질적인 댓글을 남기고 싶음

    • 첫 몇 단락의 밈이 C 프로그래머로서 웃겼음
    • 언어의 이상한 변형을 보는 것을 좋아하며, Go에서 이를 보는 것이 흥미로움
  • C와 그 파생 언어가 지배하던 시절의 오래된 프로그래밍 농담이 여전히 유효함

  • Buena Vista Social Club의 클래식 음악이 떠오름

  • "chan chan Value" 또는 "chan struct{resp chan Value}" 패턴을 특정 상황에서 사용한 적이 있음

    • 메시지 버스를 대신 사용할 수 있었지만, 메시지 버스를 처리해야 하는 상황이 됨
  • 채널의 채널은 일반적인 패턴이며, 보통 구조체 타입의 필드가 채널인 형태로 나타남

    • 요청을 보내고, 작업자가 작업을 완료한 후 결과를 응답 채널에 넣는 방식
    • type request struct { params, reply chan response }와 같은 형태
    • 두 개의 채널이 유용하며, 세 개 이상의 채널은 본 적이 없음
  • 동적 디스패치 메커니즘을 구현하기 위해 채널을 사용하는 반대 의견 블로그

  • Joe Armstrong의 "My favorite Erlang Program"을 떠올리게 함

  • 링크를 클릭했을 때 다른 것을 기대했음

    • Go 프로그래머가 아니어서 농담을 바로 알아차리지 못함
  • LabVIEW 코드에서 비동기 응답 데이터를 받기 위해 유사한 방식 사용

    • 응답을 큐에 덤프하는 대신, 콜백 이벤트 채널을 포함한 메시지를 전달
    • 메모리 낭비가 있지만, 단일 사용 후 응답 시 닫히므로 효율적임