1P by GN⁺ | ★ favorite | 댓글 1개
  • DoltHub은 Go 동시성에서 흔히 쓰는 채널을 일부러 과하게 중첩해, 채널을 채널로 보내는 장난성 예제를 만듦
  • 실제로 물려받은 코드에는 chan chan struct{}가 있었고, 워커 goroutine에 새 채널을 전달하는 팬아웃(fan-out) 패턴에 쓰였지만 추론과 관리가 어려워 다시 작성됨
  • 예제는 C 계열의 int**** “4-star programmer” 농담을 Go의 chan으로 확장해, _4chan:= make(chan chan chan chan int)를 최상위 채널로 사용함
  • factor = 3에서 각 채널 계층마다 producer와 consumer를 분기시키고, 마지막 int 값을 합산해 3의 5제곱인 243을 출력함
  • 실전에서는 구현·디버깅 난이도, 채널 종료 처리, sync.WaitGroup 필요성, goroutine 누수 때문에 부적합하며, 예제는 종료 로직 대신 time.Sleep()에 의존함

Dolt에서 나온 채널 중첩 사례

  • DoltHub은 세계 최초의 버전 관리 SQL 데이터베이스인 Dolt를 Go로 작성하고 있음
  • 일반적인 Go 코드베이스처럼 channelsgoroutines를 동시 실행 구현에 사용함
  • 동시성 프로그래밍은 그 자체로 어렵기 때문에, 보통은 채널과 goroutine을 단순하고 직관적인 방식으로 다룸
  • 한때 다른 오픈소스 프로젝트에서 가져온 코드에는 다음처럼 채널을 보내는 채널이 있었음
    • var c chan chan struct{}
  • 이 구조는 goroutine 사이에서 채널을 전달해 워커 goroutine의 팬아웃 패턴을 구현하는 방식이었음
    • 중간 채널은 새로 생성된 채널을 실제 작업을 하는 워커에게 넘기는 중개자 역할을 함
    • 동작은 했지만, 특히 goroutine 누수까지 고려하면 추론하고 다루기 어려웠음
    • 해당 코드는 다시 작성됐고 chan chan struct{}는 사라짐

“4-star programmer” 농담의 Go 버전

  • C와 그 파생 언어가 널리 쓰이던 시절에는 포인터를 이해하기 어려워하는 초보자를 두고 “4-star programmer”라는 농담이 있었음
  • 대표 예시는 int****처럼 여러 단계의 포인터 간접 참조를 쓰는 코드임
  • Go도 C에서 크게 파생됐기 때문에 포인터로 같은 식의 코드를 작성할 수 있음
    • *int, **int, ***int, ****int를 차례로 넘김
    • 마지막 함수에서 ****i = 100을 수행하면 프로그램은 i is now 100을 출력함
  • Go에는 C에는 없는 chan이 있으므로, 같은 농담을 채널 간접 참조로 확장할 수 있음

4단계 채널로 5제곱 계산하기

  • 최상위 채널은 다음처럼 선언됨
    • _4chan := make(chan chan chan chan int)
  • Go 식별자는 숫자로 시작할 수 없어 예제에서는 _4chan 이름을 사용함
  • _4chan으로 보내는 값은 3단계 채널임
    • _3chan := make(chan chan chan int)
  • 같은 방식으로 계층을 내려가다가 마지막에는 값 채널인 chan int에 도달함
  • 각 간접 참조 계층에서는 상수 factor에 따라 producer를 생성함
    • 예시에서는 const factor = 3
    • sendChanChanChan은 3채널 producer를 goroutine으로 시작함
  • consumer 쪽도 각 계층에서 들어온 채널을 받아 다음 단계 consumer를 factor만큼 시작함
    • receiveChanChanChan_4chan에서 _3chan을 받고, 3채널 consumer를 시작함

마지막 계층의 값 전송과 합산

  • 최하위 계층에서는 더 이상 채널이 아니라 실제 int 값을 전송함
  • send 함수는 _2chan_1chan을 보낸 뒤, factor만큼 int producer를 시작함
  • 각 int producer는 다시 factor만큼 goroutine을 만들어 _1chan <- 1을 실행함
  • consumer는 받은 정수를 전역 sum에 더함
    • sumatomic.Int32로 선언됨
    • receive(c chan int)는 채널에서 값을 받아 sum.Add(int32(s))를 수행함

실행 결과와 분기 수

  • 전체 프로그램은 _4chan을 만들고, 송신 계층과 수신 계층을 각각 goroutine으로 시작한 뒤 500 * time.Millisecond 동안 대기함
  • 예제 출력은 다음과 같음
    • 3 ^ 5: 243
  • 이 프로그램은 숫자의 5제곱을 최대한 분산된 방식으로 계산하는 일반화된 예제임
  • 실행 가능한 예제는 Go Playground에서 볼 수 있고, 문법 강조 버전은 GitHub Gist에 있음
  • 더 큰 factor를 쓰려면 실행이 끝날 수 있도록 Sleep 시간을 늘려야 할 수 있음
  • 로그를 켜면 각 채널 생산·소비 계층의 분기 수를 확인할 수 있음
    • starting 3chan producer: 3회
    • starting 2chan producer: 9회
    • starting 3chan consumer: 9회
    • starting 2chan consumer: 27회
    • starting chan producer: 27회
    • starting 1chan consumer: 81회
    • starting int producer: 81회
    • sending int: 243회
    • received int: 243회

실전 코드에서 피해야 하는 이유

  • 이 방식은 실제 코드에 쓰기에는 구현과 디버깅이 까다로움
  • 채널을 채널로 보내면 각 채널을 언제 닫아야 하는지 판단하기 어려워짐
  • 실제 사용 사례라면 채널을 닫아야 하지만, 종료 로직을 넣으려면 모든 채널 전송이 끝났는지 추적해야 함
  • 종료 처리를 구현하려면 곳곳에 sync.WaitGroup을 추가해야 했고, 그러면 장난성 예제가 읽기 어려워짐
  • 최종 예제는 종료 로직 대신 time.Sleep()을 사용하고, 많은 goroutine 누수를 남기는 방식으로 단순화됨

댓글과 토론

Hacker News 의견들
  • 실제 전문 소프트웨어 엔지니어들과 가까이 일하는 과학자 입장에서는, 그들이 하는 많은 일이 이런 식으로 보여서 왜 그렇게 하는지 도저히 이해하기 어려움
    코드 한 줄이 실제 호출되기 전에 인터페이스 함수 4개를 순서대로 거치고, 그 함수들이 서로 다른 폴더의 서로 다른 파일에 흩어져 있는 걸 봤음
    그래서 코드가 뭘 하는지 읽어내기가 지치고, 몇 단계 들어가다 보면 내가 맞는 곳을 보고 있는지, 언젠가 실제 계산이 나오는 곳에 도달하긴 하는지 의심하게 됨

    • 이건 정말 나쁜 관행이고, 과하게 의욕적인 주니어 엔지니어가 소프트웨어를 쓰는 방식에 가까움
      과도하고 헷갈려 보인다는 감각은 틀리지 않았고, 처음 “흥미로운” 코드를 쓸 때는 기술적으로 복잡하고 심지어 우아해 보일 수 있지만 실제로 커져야 하는 소프트웨어 안에서는 기술적 악몽이 됨
      Go 코드에서 채널 오용을 2년 가까이 정리한 적이 있는데, 채널은 실제로 필요한 경우가 드문데도 초반에는 여러 용도로 쉽게 쓸 수 있어서 문제가 됨
      채널을 제대로 쓰는지 보는 기준은 “직접 함수 호출로 할 수 없나?”, “대기 그룹이나 뮤텍스로 할 수 없나?”에 아니오라고 답하고, “동시성/병렬성 이득이 동시 코드 디버깅 복잡도를 정당화할 만큼 큰가?”에 예라고 답할 수 있는지임
    • 더 심한 것도 있음. 과장이 아주 심한 예는 아님: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...
      이해와 역량이 있어야 할 곳에서 무서울 정도로 부족한 경우가 있음
      학부 때 컴퓨터과학 박사와 30분쯤 페어 프로그래밍을 한 적이 있는데 꽤 깨달음이 있었음
      그는 소프트웨어를 전혀 이해하지 못했고, 표준 라이브러리 자료구조의 크기가 음수가 아닌지 확인하지 않는다고 지적할 정도였음
      다만 어떤 때는 이런 구조에도 이유가 있음. 때로는 실제로 합리적이고, 때로는 앞선 사람들이 만들어놓은 광기의 코드베이스를 다루기 위한 방식이기도 함
    • 코더의 스펙트럼 한쪽 끝에는 과학자, 다른 끝에는 소프트웨어 엔지니어가 있음. 우리를 구할 수 있는 건 균형뿐임
      연구 논문에 쓰인 코드를 읽어본 적이 있는데, 이론 수학은 대개 이해를 넘어서서 논리를 보려고 코드로 들어가면 오히려 훨씬 더 심하고 알아볼 수 없는 경우가 많았음
      결국 우리는 자신이 하던 방식에 익숙하고, 다른 방식은 낯설게 느껴짐
    • 과잉공학이 흔한 원인임. 단순한 해법은 찾기 어렵게 숨어 있을 때가 많음
      그렇다고 해도 추가 간접 계층은 보통 전체 아키텍처 안에서 정당화되며, 합리적인 경우라면 국소적으로만 봐서는 가치를 알아보기 어려울 수 있음
      “초기 프로그래밍 언어의 가벼운 손길과 고요함은 늘 기쁘다. 텍스트는 많지 않은데 많은 일이 된다. 옛 프로그램은 컴파일러와의 논쟁이 아니라, 말 잘하는 연구자와 잘 학습된 기계 동료 사이의 조용한 대화처럼 읽힌다. 정교함이 이런 소음을 사올 줄 누가 알았겠는가?” — Dick Gabriel
    • 반대로, 소프트웨어 엔지니어가 잘 모를 때 추상화를 너무 멀리 가져간다는 데는 동의하지만, 직업적으로 소프트웨어 엔지니어가 아닌 사람들이 만든 코드도 특별히 높게 보지는 않음
      양 극단을 보는 셈임. 추상화가 너무 많아 지나치게 흩어진 코드베이스와, 추상화가 전혀 없어 목적 달성용 스크립트에 가까운 코드베이스 모두 작업하기 어려움
      Python, JS, PHP 스크립트 중에는 “됐고 내가 원하는 결과나 줘”라는 태도로 작성된 것이 많았고, 매일 코드에서 일하는 사람들에게는 협업과 회복력을 돕는 추상화가 필요함
  • 앞부분의 밈은 회복 중인 C 프로그래머로서 실제로 웃겼음
    이런 식으로 언어를 비틀어 쓰는 사례를 보는 건 재미있고, C에는 그런 기회가 넘치는데 Go에서도 보게 되어 흥미로움

  • “C와 그 파생 언어가 지배하던 시절의 오래된 프로그래밍 농담”이라고 하지만, 우리는 아직도 그 시절에 살고 있음

  • 이 스레드에서 이걸 추상화 과잉의 전형이라고 비판하는 게 아이러니함
    C에서 별 세 개짜리 변수를 드물게 보는 이유는 포인터 사슬이 드물어서가 아니라, 동시에 두 단계 넘게 조작할 필요가 흔치 않기 때문임
    Python, Java, JavaScript처럼 대부분이 기본적으로 포인터에 가까운 언어에서는 길이가 4를 훨씬 넘는 포인터 사슬도 분명히 있음
    깊은 부분은 보통 내부를 신경 쓰지 않아도 되는 구조체 안에 숨겨져, 즉 추상화되어 있음
    채널도 순차 코드에서 포인터가 하는 역할을 동시 코드에서 대략 수행하므로, 여기서 치명적 결함이 있다면 추상화 과잉이 아니라 오히려 추상화 부족일 수 있음
    그래도 코드베이스를 모르니, 어쩌면 chan chan이 그들이 작성하려던 것에 정확히 맞는 추상화였을 수도 있음. Erlang처럼 동시성이 강한 언어에서는 응답을 받을 프로세스를 식별하려고 PID를 다른 PID에 보내는 일이 매우 일반적임

    • 커리어 초기에 별 세 개짜리 커밋 때문에 욕먹었던 기억이 남. 누가 포인터의 포인터의 포인터가 필요하냐는 식이었음
      하지만 그건 그런 게 아니라 문자열 배열의 주소였음. C라서 그랬고, 지금도 약 20년이 지난 뒤에도 가장 직관적인 해법이었다고 봄
      동료를 변호하자면, 아마 주석이 없었을 것임. 그 부분은 내 책임이었음
  • Buena Vista Social Club의 시대를 초월한 고전이 떠오름 https://www.youtube.com/watch?v=o5cELP06Mik

  • chan chan Valuechan struct{resp chan Value}는 아주 특정한 상황에서 실제로 써본 패턴
    메시지 버스를 쓸 수도 있었겠지만, 그러면 또 메시지 버스를 다뤄야 함

    • chan chan은 꽤 자주 씀. 내부 서버나 액터에 메시지를 보내면서, 응답을 받을 채널도 메시지에 같이 넣어 보내는 경우임
      실제로는 grep으로 찾을 수 있는 문자 그대로의 chan chan보다는 chan struct { ... 채널이 들어간 무언가 ... } 형태가 되지만 원리는 같음
      매우 유용한 패턴이고 Go의 기본기 중 하나라고 봄
      다만 chan chan chan은 아는 한 써본 적 없음
      이런 걸 전부 메시지 버스로 바꾸는 건 과함. 성능 특성도 매우 다르고, Go의 채널은 운영체제 프로세스 내부 구성물에 가깝기 때문에 프로세스 내부 통신이라면 메시지 버스로 “업그레이드”할 가치가 없음
    • 아마 gopl의 동시 채팅 서버와 비슷한 패턴일 것 같음. 처음에는 조금 놀랍지만 그래도 꽤 읽을 만함
      [0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
    • 나도 써봤음. Go에서 Promise 비슷한 의미론을 구현하는 데 유용함
  • 채널의 채널은 정상적인 패턴이지만, 보통은 구조체 값의 채널 형태로 더 자주 나타나고 그 구조체 타입에 채널 필드가 들어감
    완료될 요청을 보내고, 그 값들을 순서와 상관없이 기다릴 수 있게 해서 선두 차단을 피하는 데 쓸 수 있음
    예를 들면 type request struct { params, reply chan response }처럼 요청을 채널로 보내고, 워커가 일을 처리한 뒤 결과를 reply 채널에 넣는 방식임
    하지만 유용했던 건 두 단계까지였고, chan chan chan이 필요한 사용 사례는 본 적이 없음

  • 채널을 보내는 채널로 동적 디스패치를 구현한 반례 블로그가 있음. Go가 아니라 Limbo지만 개념은 같음. 복잡성이 오히려 논점을 입증하는지도 모르겠음 https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...

    • 궁금한 사람들을 위해 덧붙이면, Limbo는 Go의 선행 격인 언어이고 Plan 9의 후손인 Inferno 운영체제에서 쓰였음
  • Joe Armstrong의 “My favorite Erlang Program”이 떠오름
    https://joearms.github.io/published/2013-11-21-My-favorite-e...