2P by GN⁺ | ★ favorite | 댓글 1개
  • Go에는 goroutine과 채널만으로는 어색한 동시성 패턴이 있으며, 코루틴은 병렬성 없이 실행 흐름을 명시적으로 넘겨 프로그램 구조화를 돕는 제안임
  • 코루틴은 resumeyield로 실행권을 주고받고, 한 번에 하나만 실행되므로 공유 데이터 레이스를 피하면서 전환 지점이 동기화 지점이 됨
  • Python generator와 CLU iterator는 코루틴과 닮았지만 yield 위치가 제한되어, Lua식 중첩 트리 순회를 그대로 옮기면 일부 값이 사라지는 차이가 있음
  • Go의 coro.New는 채널과 goroutine으로도 표현 가능하며, coro.Pullpush iterator를 호출할 때마다 값 하나를 꺼내는 pull iterator로 바꿈
  • 채널 기반 구현은 전환당 약 190ns였고, 런타임 직접 전환은 전환당 약 20ns·coro.Pull 값당 약 40ns까지 줄어 실사용 병목을 피하는 수준을 목표로 함

코루틴의 실행 모델

  • 코루틴은 함수 호출처럼 보이지만 서로 다른 스택에서 실행되며, 동시에 실행되지는 않음
    • FG를 시작해도 G는 즉시 실행되지 않고, F가 명시적으로 resume해야 실행됨
    • G는 실행 중 언제든 yieldF에 실행권을 돌려줄 수 있음
    • G가 반환하면 정리되고, F는 더 이상 Gresume하지 말아야 한다는 신호를 받음
  • 이 모델에서는 한 번에 하나의 코루틴만 실행되고, 호출자는 다른 스택에서 기다림
  • 실행 전환은 프로그램의 특정 지점에서만 일어나므로 여러 흐름이 조율된 방식으로 번갈아 실행됨

Lua 예제로 본 코루틴

  • Lua 5 예제는 구조가 다른 두 이진 트리가 같은 값 시퀀스를 갖는지 비교함
    • t1t2는 1, 2, 3, 4, 5를 포함함
    • t3는 1, 2, 3, 4, 6을 포함함
  • visit(t)는 트리를 중위 순회하며 각 값을 coroutine.yield(t.value)로 내보냄
  • 비교 함수는 두 개의 visit 코루틴을 만들고 번갈아 coroutine.resume해 다음 값을 읽음
    • 두 코루틴의 종료 여부나 값이 다르면 false
    • 둘 다 끝나면 true
  • 더 관용적인 Lua 코드는 coroutine.wrap으로 코루틴 객체를 숨기는 next 함수를 얻음
    • 코루틴이 끝나면 next 함수는 nil을 반환함
    • 전체 코드는 Gist에 있음

Python generator와 CLU iterator의 한계

  • Python generator는 Lua 코루틴처럼 보이지만 같은 모델은 아님
  • Lua 예제를 Python으로 직접 옮기면 visit(t['left'])가 실제 순회를 실행하지 않고 generator 객체만 만들었다가 버림
    • yield가 함수 본문에 있으면 def visit은 일반 함수가 아니라 generator를 정의함
    • 단순 번역 예제는 트리에서 4만 출력하고 1, 2, 3, 5를 잃음
  • 올바른 Python 코드는 중첩 generator를 직접 반복하며 다시 yield해야 함
    • Python 3.3의 yield from은 이 패턴을 간단하게 만들어줌
  • Python generator 객체는 한 번의 visit 호출 상태만 담음
    • 지역 변수 값과 실행 중인 줄이 generator 객체에 저장됨
    • 재개될 때 그 상태가 호출 스택에 올라가고, yield에서 다시 generator 객체로 빠짐
    • yield는 최상위 호출 프레임에서만 가능함
  • CLU는 이 추상화를 iterator라고 불렀고, 정적으로 iterproc를 구분함
    • 타입 정보 덕분에 iterator를 일반 함수처럼 잘못 호출하는 사용을 컴파일러가 진단할 수 있었음
    • Barbara Liskov 등의 1977년 논문 “Abstraction Mechanisms in CLU”는 iterator가 제한된 형태의 코루틴이라 프로그램 스택만으로 구현된다고 설명함

코루틴, 스레드, generator의 차이

  • 세 개념은 모두 어떤 형태의 동시성을 제공하지만, 제공하는 힘과 비용이 다름
  • 코루틴

    • 병렬성 없는 동시성을 제공함
    • 한 코루틴이 실행 중이면 그 코루틴을 재개했거나 그 코루틴에 양보한 코루틴은 실행되지 않음
    • 전환 지점이 명시적이라 데이터 공유에서 레이스가 생기지 않음
    • coroutine.resume이나 next 호출 같은 전환이 동기화 지점이 되어 happens-before edge를 만듦
    • 운영체제 없이 명시적으로 스케줄링되므로 전환은 대략 10ns 이하까지 가능함
  • 스레드

    • 코루틴보다 강력하며, 추가적인 힘은 병렬성임
    • 그 대가는 스케줄링 오버헤드, 더 비싼 컨텍스트 전환, 어떤 형태의 선점 필요성임
    • 일반적인 스레드 전환은 몇 마이크로초 수준임
  • Go goroutine

    • 이 분류에서는 값싼 스레드에 가까움
    • Go 런타임이 스케줄링 일부를 맡아 전환은 몇백 ns에 가까움
    • 스레드처럼 병렬성과 선점을 제공함
    • Java의 새로운 lightweight thread도 기본적으로 goroutine과 같음
  • Generator

Go에서 코루틴이 필요한 사례

  • Go의 기존 동시성 라이브러리는 코루틴 패턴을 직접 제공하지 않음
  • goroutine은 종종 충분히 비슷하지만, 병렬성과 선점 때문에 코루틴과 다른 결과를 낼 수 있음
  • Rob Pike의 2011년 발표 “Lexical Scanning in Go”text/template 패키지의 초기 lexer와 parser 설계를 다룸
    • lexer와 parser는 별도 goroutine에서 실행되고 채널로 연결됨
    • 이는 코루틴 쌍을 불완전하게 흉내 낸 구조였음
    • lexer는 parser가 최근 토큰을 처리하는 동안 다음 토큰을 미리 봤음
    • generator는 여러 함수에서 값을 yield해야 하는 lexer에 충분하지 않았음
    • goroutine의 병렬성은 레이스를 만들었고, 결국 lexer 상태를 객체에 저장하는 설계로 바뀜
    • 적절한 코루틴이 있었다면 레이스를 피하고 goroutine보다 효율적이었을 것임
  • 미래 사용 사례로 generic collection 순회가 있음
    • Go에는 함수에 대한 range 지원이 논의된 바 있음
    • 이는 컬렉션과 추상화 작성자가 CLU식 iterator 함수를 제공하도록 유도할 수 있음
  • Go에서는 현재도 함수 값을 사용해 push iterator를 구현할 수 있음
    • 예: func (t *Tree[V]) All(yield func(v V))
    • 현재는 t.All(func(v V) { fmt.Println(v) })처럼 호출할 수 있음
    • 미래에는 for v := range t.All 형태가 가능할 수 있음
  • 단일 for 루프에 맞지 않는 순회가 문제임
    • 이진 트리 비교처럼 두 순회를 서로 끼워 넣어야 하는 경우가 있음
    • 코루틴은 (*Tree).All 같은 push iterator를 호출마다 값 하나를 반환하는 pull iterator로 바꿀 수 있음

순수 Go로 표현한 coro.New

  • Go에 코루틴을 추가한다면 언어 변경 없이 가능해야 하며, 일반 Go 코드로 이해하고 구현할 수 있어야 함
  • 단순한 coro.New는 채널과 goroutine으로 표현됨
    • cin은 입력 값을 전달함
    • cout은 출력 값을 돌려줌
    • resumecin에 값을 보내고 cout에서 결과를 기다림
    • 새 goroutine은 처음에 <-cin에서 막혀 있으므로 병렬 실행 기회가 없음
  • yield를 추가하면 f가 실행 중 값을 내보내고, 호출자는 다음 resume에서 다시 값을 넣을 수 있음
    • yield(out)cout에 값을 보내고 cin에서 다음 입력을 기다림
    • 이 역시 send-receive 쌍이므로 병렬성은 없음
  • 이 통신 패턴은 goroutine을 코루틴처럼 동작하게 제한함
    • 실제로는 goroutine이지만 resumeyield가 전환 연산 역할을 함

문자열 파서 예제

  • “Storing Data in Control Flow”의 문제는 func parseQuoted(read func() byte) bool를 별도 제어 흐름에서 실행하고, 바이트를 Write 메서드로 하나씩 공급하는 것임
  • coro.New를 쓰면 이전 글의 임시 채널 기반 구현보다 높은 수준으로 작성할 수 있음
    • Initcoparse 함수를 정의함
    • readNeedMoreInputyield한 뒤 호출자가 보낸 바이트를 반환함
    • parseQuoted(read)의 boolean 결과는 BadInput 또는 Success로 바뀜
    • p.resume(0)parseQuoted의 첫 번째 read까지 진행시킴
    • Write(c byte)p.resume(c)를 호출하는 얇은 래퍼가 됨
  • 전체 코드는 Go Playground에 있음

소수 체 예제

  • Doug McIlroy의 동시 소수 체는 각 소수 p마다 하나의 코루틴을 두는 파이프라인임
    • 각 필터는 왼쪽 이웃에서 숫자를 받고, p로 나누어떨어지지 않으면 오른쪽 이웃으로 넘김
    • 왼쪽 끝의 counter는 2, 3, 4, ...를 공급함
    • 오른쪽 끝의 출력 코루틴은 소수를 읽어 출력하고 새 필터 코루틴을 만듦
  • counter는 값을 yield하는 루프를 coro.New로 감싼 함수임
    • more bool이 계속 생성할지 여부를 전달함
    • yield(i)는 값을 내보내고 다음 계속 여부를 받음
  • filter(p, next)는 왼쪽 코루틴의 next(true)에서 값을 가져와, n%p != 0일 때만 yield(n)
  • main은 현재 파이프라인 출력을 next에 유지함
    • 소수 p를 읽음
    • p를 출력함
    • p의 배수를 제거하는 새 필터를 파이프라인 오른쪽에 추가함
  • 코루틴 사이의 호출 관계는 실행 중 바뀔 수 있음
    • counter의 첫 yieldmain으로 가지만 이후 yield들은 2-filter로 감
    • p-filter의 첫 출력은 다음 소수로서 main에 가고, 이후 출력은 다음 필터로 감
  • 전체 코드는 Go Playground에 있음

goroutine과 코루틴의 관계

  • 여기서 만든 제어 흐름은 엄밀히 말하면 goroutine임
    • mutex, channel, system call 대기 등 일반 goroutine이 할 수 있는 일을 모두 할 수 있음
  • coro.Newyieldresume 안에서 코루틴 전환 연산을 사용할 수 있는 goroutine을 만듦
  • go 문은 새로운 동시·병렬 제어 흐름을 만들지만, coro.New는 새로운 동시·비병렬 제어 흐름을 만듦
    • go 문 10개를 실행하면 main을 포함해 11개 goroutine이 동시에 실행될 수 있음
    • coro.New 10번을 호출하면 제어 흐름은 11개지만 프로그램의 병렬성은 그대로이며 한 번에 하나만 실행됨
  • 어떤 goroutine이 “비병렬” 코루틴 역할을 하는지는 실행 중 바뀔 수 있음
    • 이는 실행 중 어떤 goroutine이 channel 송신·수신 중인지 바뀔 수 있는 것과 같음

더 견고한 resume

  • 초기 coro.New는 함수가 끝난 뒤 resume을 호출하면 데드락이 발생함
  • 이를 고치기 위해 resume은 결과와 함께 bool을 반환함
    • true는 결과가 yield에서 왔음을 뜻함
    • 함수가 반환하면 resume은 반환값과 false를 반환함
    • 코루틴이 종료된 뒤 resume을 호출하면 zero value와 false를 반환함
  • running 변수는 f가 실행 중인지 추적함
    • resume과 코루틴은 서로 번갈아 실행되므로 running 공유는 레이스가 아님
  • 예제는 "hello" true, "world" true, "done" false, "" false를 출력함

coro.Pull로 iterator 변환

  • coro.Pull은 push iterator를 pull iterator로 바꿈
  • 입력 push iterator의 형태는 다음과 같음
    • push func(yield func(V) bool)
    • yield의 boolean 반환값은 계속할지 여부를 나타냄
  • 목표 pull iterator의 형태는 다음과 같음
    • pull func() (V, bool)
    • channel receive나 map lookup처럼 값과 반복 종료 여부를 반환함
  • 조기 중단을 위해 Pullpull뿐 아니라 stop도 반환함
  • 구현은 coro.New로 push iterator를 실행하는 작은 wrapper를 만들면 충분함
    • pullresume(true)를 호출함
    • stopresume(false)를 호출함
  • 트리의 All 메서드는 yieldbool 결과를 사용하도록 바뀜
    • 왼쪽 순회, 현재 값 yield, 오른쪽 순회를 &&로 이어 조기 중단을 전파함
  • 트리 비교 함수는 두 개의 coro.Pull을 만들고 값을 하나씩 비교함
    • defer stop1()defer stop2()로 조기 종료 시 코루틴을 멈춤
    • 값이나 종료 상태가 다르면 false
    • 둘 다 끝나면 true
  • 전체 코드는 Go Playground에 있음

panic 전파와 취소

  • 코루틴에서 발생한 panic은 가장 최근에 그 코루틴을 resume한 호출자에게 돌려보낼 수 있음
    • 일반 goroutine에서는 어느 goroutine에 알려야 하는지, 그 goroutine이 받을 준비가 됐는지 알기 어려움
    • 코루틴에서는 호출자가 resume에서 막혀 기다리고 있으므로 panic을 전달할 대상이 명확함
  • 구현은 cout으로 값 또는 panic을 담은 메시지를 전달함
    • 새 코루틴의 defer가 panic을 잡음
    • 기다리는 resume은 같은 panic 값으로 다시 panic함
  • 예제에서는 코루틴이 "hello"를 yield한 뒤 "world"로 panic함
    • panic은 main goroutine으로 전파되고, 스택에서는 resume 호출에서 발생한 것처럼 보임
    • 전체 코드는 Go Playground에 있음
  • 호출자가 조기 종료할 때 코루틴에 알리기 위해 cancel 함수가 추가됨
    • cancelresume과 비슷하지만, yield가 값을 반환하는 대신 panic하게 만듦
    • 취소 panic은 ErrCanceled를 만족하는 고유 error wrapper를 사용함
    • cancel이 유발한 panic은 다시 전파하지 않지만, 취소 중 코루틴이 다른 panic을 내면 전파함
    • resume이 아직 호출되지 않았다면 cancelf가 아예 실행되지 않게 함
  • iterator 중단에는 panic보다 명시적인 bool이 더 명확하므로 Pull은 bool 기반 중단을 유지함

소수 체 재방문: 정리와 오류 전파

  • 새 API에서는 counterfilterresume 함수와 cancel 함수를 함께 반환함
  • primes(n int)는 counter를 만들고 defer cancel()을 등록함
    • 각 소수를 읽고 출력함
    • 새 filter를 추가할 때마다 해당 filter의 canceldefer로 등록함
  • 함수가 n개의 소수를 얻고 반환하면 지연된 cancel 호출들이 생성된 코루틴을 정리함
  • 어떤 코루틴이 panic하면 기다리던 코루틴으로 전파됨
    • primes가 직접 next로 재개한 코루틴이면 panic이 primes로 돌아옴
    • filter가 next로 재개한 코루틴이면 panic이 filter 체인을 따라 primesp := next(true)까지 올라옴
    • 이후 primes의 지연된 cancel들이 나머지 코루틴을 정리함
  • 전체 코드는 Go Playground에 있음

최종 API 형태

  • New는 새로 일시정지된 코루틴을 만들고 함수 f를 실행할 준비를 함
    • 새 코루틴은 goroutine이지만 스스로 실행되지 않음
    • 다른 goroutine이 resume 또는 cancel을 호출하고 기다리는 동안에만 실행됨
  • resume(in)은 호출 goroutine을 멈추고 새 코루틴으로 전환함
    • 첫 호출은 f(in, yield)를 시작함
    • fyield(out)를 호출하거나 out을 반환할 때까지 resume은 막힘
    • yield가 호출되면 resumeout, true를 반환함
    • f가 반환하면 resumeout, false를 반환함
    • 다음 resume(in)은 막혀 있던 yieldin을 반환하게 함
  • cancelf 실행을 멈추고 코루틴을 종료함
    • resume이 한 번도 호출되지 않았다면 f는 실행되지 않음
    • 그렇지 않으면 막혀 있던 yieldErrCanceled를 만족하는 error로 panic함
  • f가 recover하지 못한 panic을 내면, 그 panic은 resume 또는 cancel에서 기다리는 goroutine으로 이동해 같은 값으로 다시 panic함
    • 단, cancel은 자신이 유발한 취소 panic을 다시 panic하지 않음
  • f가 반환하거나 panic하면 코루틴은 더 이상 존재하지 않음
    • 이후 resume 호출은 zero value와 false를 반환함
    • 이후 cancel 호출은 그냥 반환함
  • resume, cancel, yield는 다른 goroutine에 전달되어 사용될 수 있음
    • 그 결과 어느 goroutine이 “코루틴”인지 동적으로 바뀔 수 있음
  • New는 새 goroutine을 만들지만 항상 하나의 goroutine이 resume, cancel, yield 또는 초기 대기 상태에서 막혀 있다는 불변식을 둠
    • 이 불변식은 f가 반환할 때까지 유지됨
    • 결과적으로 coro.New는 새 동시성을 만들지만 새 병렬성은 만들지 않음
  • 최종 시그니처는 다음과 같음
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())

효율성

  • 순수 Go 구현으로 코루틴을 정의할 수 있어야 하지만, 실제 사용에는 최적화된 런타임 구현이 필요함
  • 2019 MacBook Pro에서 채널 기반 coro.New는 값 왕복에 전환당 약 190ns가 걸림
    • coro.Pull에서는 값 하나당 약 380ns
  • coro.Pull은 iterator의 표준 사용 방식이 아님
    • 표준 방식은 iterator를 직접 호출하는 것이며, 이 경우 코루틴 오버헤드가 없음
    • coro.Pull은 단일 for 루프가 아니라 값을 점진적으로 처리해야 할 때 필요함
  • 첫 번째 최적화 시도는 컴파일러가 send-receive 쌍을 표시하고 런타임이 이를 하나의 연산으로 합치도록 힌트를 남기는 방식임
    • 채널 런타임이 스케줄러를 우회하고 다른 코루틴으로 직접 점프할 수 있음
    • 전환당 약 118ns, pulled value당 약 236ns가 걸림
    • 원래 채널 구현보다 38% 빠름
  • 두 번째 구현은 채널을 완전히 피하고 런타임에 직접 코루틴 전환을 추가함
    • 코루틴 전환은 atomic compare-and-swap 3개로 줄어듦
    • 하나는 코루틴 자료구조, 하나는 막히는 코루틴의 scheduler status, 하나는 재개되는 코루틴의 scheduler status에 사용됨
    • 전환당 약 20ns, pulled value당 약 40ns가 걸림
    • 원래 채널 구현보다 약 10배 빠름
  • 값당 40ns는 coro.Pull이 필요한 코드에서 병목이 되지 않을 만큼 작은 절대 비용으로 간주됨

댓글과 토론

Hacker News 의견들
  • 여기서 핵심을 놓치는 사람이 많은 듯함. 코루틴 라이브러리가 go 키워드보다 동시성을 다루는 더 나쁘고 번거로운 방식이라는 건 맞음
    이 복잡성을 끌어오는 실제 사용처는 함수 반복자이며, func() (T, bool) 타입 함수에 range를 쓸 수 있게 하는 것임. Go 커뮤니티에서 오래 논의됐고 대부분의 Go 프로그래머에게 의미가 직관적일 것임
    이 글은 그 다음 문제, 즉 함수 반복자가 언어에 추가된다고 할 때 for 루프에서 쓸 반복자를 어떻게 작성할지 다룸. 푸시 반복자는 작성하기 쉬운 경우가 많다는 점에서 시작해 푸시-풀 어댑터로 확장하고, 그 어댑터가 코루틴 위에 만들어짐
    전부 들어간다면, 반복 이외의 용도로 코루틴을 쓰는 건 뮤텍스로 충분한 곳에 채널/고루틴을 쓰는 것처럼 나쁜 관행이 될 것 같음

    • 특정 사용처에서는 코루틴이 완전한 고루틴보다 훨씬 효율적이라는 점도 말할 만함. 코루틴으로 전환할 때는 문맥 전환이나 재스케줄링이 필요 없기 때문임
      두 작업이 논리적으로 동기적으로 협력하는 경우, 예컨대 반복자라면 같은 CPU에서 전부 실행하는 편이 훨씬 효율적임. 커널이 아무것도 재스케줄링하거나 CPU 코어를 끄고 깨울 필요가 없고, 데이터도 CPU 캐시에 맞게 남아 있어 캐시 지연 시간과 적중률이 좋아짐
      고루틴에서도 우연히 그렇게 될 수는 있지만 보장되지 않고, 최소한 Go 런타임의 고루틴 스케줄러를 거치는 비용이 있음. 빠르긴 해도 같은 고루틴 안에서 다른 코드 문맥을 실행하는 것만큼 빠르진 않음
      코루틴은 작업 A가 작업 B로 직접 전환된다는 걸 알 수 있어 스케줄링 동작이 더 예측 가능함. 글 마지막 부분에서 Russ가 최적화된 런타임 코루틴 구현이 고루틴으로 흉내 낸 것보다 10배 빠르다고 보여줌
      Google에는 내부적으로 이런 협력적 멀티스레딩을 구현한 커널 패치가 있고, 내부에서는 fibers라고 부름. 더 나은 지연 시간과 예측 가능한 스케줄링을 위해 존재함. Paul Turner가 약 10년 전 LPC에서 이 동기를 설명한 발표도 있음: https://www.youtube.com/watch?v=KXuZi9aeGTw
    • for { next := getNext(); ... }로 쓰면 뭐가 문제인지 모르겠음. for next := range getNext { ... }로 쓰는 장점이 무엇인지 궁금함
    • Go의 코루틴은 이산 요소 시뮬레이션을 깔끔하게 정의하는 호스트 언어로 Go를 쓰게 해줄 수 있을 것 같음. 지금은 액터에게 양보하는 방식이 어색함
    • 채널에 대해 range하거나 switch를 쓰고, 값을 채널에 밀어 넣는 고루틴을 하나 돌리면 안 되는 이유를 모르겠음. 여전히 코루틴이 왜 필요한지 납득이 안 됨
    • 그래도 결국 주방 싱크식 추가 아닌가 싶음
      이번에는 신중한 사고와 좋은 80/20 해법이 아니라, “이걸 제대로 하려면 코루틴이 필요하겠네, 그냥 넣자”처럼 보임
      제네릭을 넣을 때는 정말 오래 깊게 고민했고, 균형이 뛰어난 혁신적인 타협안을 내놓았음
      여기서도 “특정 상황에서 제어할 수 있게 고루틴에 기능 하나를 추가한다” 같은 접근을 기대했음. “이 문제에 대해 Rust처럼 전면적으로 가서 그냥 추가한다”보다 나아 보였을 것임
  • Go를 여러 해 전문적으로 써왔지만, Python의 Twisted / Tornado / 기타 프레임워크 같은 모습이 되길 바라지 않음
    go 키워드는 꽤 고통스러운 함수 색칠 문제를 잘 막아줌
    고성능 문맥에서는 CPU 코어별 데이터 샤딩 같은 걸 하고 싶을 때가 있지만, 이 제안은 그런 가려운 곳을 긁어주진 못함

    • 코루틴과 고루틴은 서로 다른 틈새를 채움. 고루틴은 이미 Twisted 같은 것들이 차지하던 영역을 채우고 있음. 여기에는 Go에 async/await 비슷한 걸 끌어오려는 내용이 없음
      코루틴은 Python의 제너레이터에 더 가까운 다른 영역을 채울 것임. 조합 가능한 구성요소 파이프라인을 붙여야 하는 곳에서 메모리 사용량과 코드 복잡도를 크게 줄일 수 있는 경우가 많음. 설계는 전적으로 동기적인 것으로 보임
    • 글 어디에도 함수 색칠 문제에 해당할 만한 제안은 없음
    • 고루틴을 작성하고 Context를 써야 한다면, 그게 말 그대로 함수 색칠임
    • 다른 비동기/스레딩 프레임워크의 “문제”는 스레딩을 반복자/코루틴 위에 쌓는다는 점일 수 있음. 이 경우에는 서로 직교하므로 생각만큼 나쁘지는 않을 듯함
      물론 세상 어딘가의 똑똑한 개발자가 이걸로 멍청한 걸 만들고, 그게 크게 유행할 가능성은 거의 확실함. 내 예상은 고루틴과 코루틴을 하나로 추상화하는 것임
    • 더 나은 채널 관리 패턴이나 프레임워크에 대한 힌트를 공유해줄 수 있는지 궁금함
      보통 고루틴 관련 코드는 채널 때문에 지저분하게 느껴지고, 어떻게 더 “깔끔하게” 만들지 모르겠음. 유지보수 가능하다고 검증된 패턴 힌트가 있으면 정말 감사하겠음
  • 멀티태스킹 시스템은 우리에게 프로세스를 줬음
    하지만 그것은 너무 무거웠음
    그래서 주소 공간, 파일 테이블, 몇 가지를 공유하는 프로세스인 스레드가 나왔음. 스케줄러는 프로세스 사이보다 스레드 사이를 더 쉽게 전환할 수 있고, 스레드 간 데이터 공유에는 직렬화가 필요 없음
    하지만 그것도 너무 무거웠음
    그래서 사용자 공간 스레드가 나왔음. 런타임이 전적으로 사용자 공간에서 구동하는 논리적 실행 스레드임. 런타임은 표준 라이브러리의 모든 입출력 함수에 스케줄링 훅을 넣거나, Unix 시그널 같은 시스템 API로 논리 스레드를 선점함. 시스템 수준 문맥 전환이 필요 없고 아주 작게 만들 수 있음
    하지만 그것도 너무 무거웠음
    그래서 코루틴이 나왔음. 프로그래머가 서로 협력적으로 상호작용하는 논리적 “스레드”를 정의할 수 있게 해줌. 스케줄러가 존재한다고 가정하지 않음. 프로그래머가 직접 이벤트 루프를 쓰거나, 라이브러리의 이벤트 루프를 “진짜” 논리 스레드에서 호출함
    다음에는 무엇이 올지 궁금함. [통신 순차 프로세스][1] 관점에서는 협력적 코루틴이 내려갈 수 있는 가장 낮은 단계일지도 모름
    [1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf

    • 코루틴도 프로그래머가 컴퓨터에 가능한 모든 세부사항을 설명해야 하는 공포에서 구해주지는 못함. 특히 입력이 프로그래머가 생각한 것과 조금만 달라도 실행이 영원히 걸리지 않게 설명해야 함
    • 가능한 한 코드를 자동으로 병렬화하되, 측정 성능이 이득을 보이는 범위까지만 병렬화하는 언어가 별로 없다는 게 조금 의외임
      동시성을 지원하는 의미론이 필요함. 예를 들어 순회는 기본적으로 순서가 지정되지 않아야 하고, 전체 프로그램 흐름 분석에도 의존하지만 원칙적인 장애물은 보이지 않음
      Microsoft가 몇 년 전에 그런 언어를 연구하지 않았나? Ada 커뮤니티 사람이 만든 ParaSail도 있음. 이런 프로젝트들은 어떻게 됐는지, 아무도 쓰지 않는지 궁금함
    • 더 내려갈 곳은 없다고 봄. 대신 분산 컴퓨팅 쪽으로 올라갈 여지는 있을 듯함. 기억이 맞다면 Go 알파 초기 버전 중에는 채널이 머신 간에도 동작했음
    • 다음 단계는 “이 작업 항목 컬렉션을 원하는 방식으로 처리하라” 같은 형태일 수 있음. 눈을 가늘게 뜨고 보면 동시 실행은 모두 작업의 순서열로 볼 수 있고, 작업 자체도 컬렉션이 될 수 있음. 작업이 하나나 둘뿐이어도 마찬가지임
  • 그린 스레드의 핵심은 Python의 yield 같은 키워드를 쓰지 않고도 좋은 협력적 스케줄링을 얻는 것이라고 생각했음
    Go의 호출 지점과 특정 위치에 재개 지점을 삽입한다는 설계 결정은 아주 좋은 타협이라고 봤음
    점점 더 하드웨어에 가까운 제어를 노출하고 있음. 어느 시점부터는 그냥 Zig를 다시 만드는 것인지 모르겠음. 다음은 선택적 가비지 컬렉터인가?

    • 여기서 yieldresume은 키워드가 아니라 일반 변수임. 교육 목적으로 그렇게 이름 붙인 일반 클로저 참조일 뿐임
      특이한 점은 취소 콜백을 만들고 쓰는 부분임. Go를 많이 해보진 않아서, 드롭된 반복자 상태를 더 빨리 수거하려는 성능 개선인지, 아니면 채널에서 기다리는 고루틴이 그 채널의 유일한 참조를 갖고 있을 때 Go가 그 고루틴을 가비지 컬렉션하지 않기 때문에 필요한 것인지는 모르겠음
      Lua라면 이런 것이 필요 없음. 코루틴/스레드도 다른 객체처럼 가비지 컬렉션되므로, 모든 참조가 사라지면 마지막 연산이 엔트리 함수의 반환이 아니라 yield였더라도 수거됨
    • Go의 가비지 컬렉터는 선택적임. 환경 변수 GOGC=off를 설정하면 가비지 컬렉션을 끌 수 있음
      GOGC에 대한 자세한 내용: https://dave.cheney.net/tag/gogc
  • 코루틴은 라이브러리보다 언어 지원으로 들어가는 편을 선호할 것 같음
    x := co func(){ var z int; for { z++; yield z } } 같은 식이나, 그에 준하는 형태를 떠올림
    순수 Go만으로도 가능하다는 건 멋지고, 언어 명세를 복잡하게 하기보다 최적화된 런타임을 붙인 표준 라이브러리 패키지로 제공하려는 매력도 이해함. 결국 순수 Go로 가능하다면 다른 구현도 빠르게 부트스트랩할 수 있음
    Go를 매일 $work에서 쓰는 사람으로서 둘 중 어느 쪽이든 환영하지만, 언어에 내장되는 쪽을 더 선호함. Go의 동시성 기본 요소는 항상 강점이었으니 그 방향을 밀고 나가면 됨

    • 코루틴에는 언어 지원이 필요함. 코루틴 하나에서 스택이 고갈되면 어떻게 할 것인가?
      라이브러리 해법이라면 코루틴을 죽이거나 프로그램을 죽여야 함. 스택이 투명하게 늘어나길 원한다면 생성된 코드만이 할 수 있음. 스택 사용량을 감시하고 필요할 때 늘려야 하기 때문임. 고루틴에는 그런 것이 있는 것으로 알고 있음
      어쩌면 라이브러리 해법도 스택 끝에 가드 페이지를 둘 수는 있음. 거기에 도달하면 오류 처리기에서 스택 확장을 시도할 수 있음. 하지만 스택 변수의 포인터를 잡아둔 경우에는 아마 동작하지 않을 것임
    • 현재 타입 시스템과 더 잘 상호작용하도록 함수에 yield 매개변수를 추가하는 편을 선호함
      함수가 값을 양보하려면 이른바 yield 매개변수를 가져야 하고, 같은 양보 시그니처를 가진 함수나 시그니처가 없는 함수 안에서만 양보할 수 있게 하는 방식임
      예시는 x := func(:z int) { for { z++; :- z } } 같은 형태로 바뀔 수 있음. :는 양보 시그니처를 추가하고, :-는 값을 양보함
      X만 양보하는 함수는 : X 또는 : name X만 가지면 됨. 재개 시 타입 Y 값을 받으려면 시그니처가 :[Y] X 또는 :[Y] name X로 바뀜
      수용은 약하게 해야 함. Y로 재개하고 X를 양보하는 함수를 기대하는 곳에는, 재개 없이 X만 양보하는 함수도 받아들여야 함
      co 패키지의 특수 함수들이 resumeNew 기능을 제공하면 Go 스타일을 유지할 수 있음. range 문법은 -:로 재개 값을 넘기는 식으로 확장할 수 있고, 값을 넘기지 않으면 기본 영값으로 재개하면 됨
    • 이건 정말 끔찍함. 전혀 직관적이지 않고, Go의 강점 중 하나와도 맞지 않음. 이런 코드를 쓰거나 읽으려면 Go의 코루틴 의미론을 따로 배워야 할 것임
  • 별로 마음에 들지 않음. 예시들을 보면 언어를 읽고 따라가기 훨씬 어렵게 만드는 것 같음. 물론 내 머리와 편견 때문일 수도 있음
    게다가 현재의 블로킹 채널이나 상태로 할 수 없는 일을 가능하게 해주는 것 같지도 않음

    • 말한 대로임. 이걸 옹호하는 사람들은 바로 앞밖에 못 보는 것 같음
    • 어떤 언어 변경을 말하는지 모르겠음. 이건 이미 사람들이 “상태”로 하는 일을 정규화하고 효율적으로 만드는 제안일 뿐임
      핵심 코드 경로에서 할당을 피하려고 이 글에 나온 것과 비슷한 반복자를 써본 적이 있음. 이 방식이면 그런 코드가 훨씬 덜 어색해질 것임. 특히 곧 들어올 range 반복자 언어 변경과 함께라면 더 그렇음
    • 현재 표준 라이브러리처럼 서로 호환되지 않는 반복자 구현이 수십 가지 있는 것이 더 쉬운 것도 아님
      채널은 블로킹 연산을 감싸는 게 아닐 때는 별 이유 없이 느림
  • 댓글들을 읽으니 씁쓸함
    많은 사람이 코루틴과 그린 스레드를 거의 같은 것으로 여기지만, 둘 다 장단점이 있음
    Go 커뮤니티에서 반복자가 빠져 있는 것이 받아들여질 수 있다는 사실이 슬픔. 단순성을 명분으로 언어를 조금이라도 복잡하게 만들 수 있는 기능은 일부러 거부하는 듯함. 그래도 최소한 제네릭에 대한 입장은 철회했음
    역시 Go는 내 언어가 아니라는 생각이 다시 듦

    • Go를 매일 쓰지만, 솔직히 제네릭은 내 코드를 별로 바꾸지 않았음
      지금은 조금 쓰지만, 호출 지점에서 문법적 편의가 약간 더 낫기 때문임. 정의 지점에서는 예상대로 여전히 못생겼고, 다만 Go 문법이 다른 언어들에서 본 것 중에는 거의 제일 나은 편임
      결국 “제네릭이 없다”는 불평을 잠재우려고 단순성을 꽤 잃었다고 봄. 좋은 거래는 아니었음
    • HN을 “Go 커뮤니티”와 혼동하면 안 됨. 이 글을 쓴 사람은 Go 팀의 수장임
      글에서 제안한 coro 패키지가 그대로 추가될까? 그럴 수도 있지만 아마 정확히 그대로는 아닐 것임. 비슷한 무언가가 추가될까? 돈을 걸 수 있다면 그렇다고 하겠음
      얼마나 걸릴까? 최소 1년, 즉 2024년 8월의 Go 1.23 릴리스쯤이라고 봄. 조금 더 걸릴 수도 있음. 그보다 훨씬 짧기는 어렵다고 생각함
    • 제네릭에 대한 입장을 철회한 것은 아님
      제네릭이 커뮤니티에서 받아들여진 건 기존 코드와 완전히 하위 호환되고, 필요 없으면 안전하게 무시할 수 있기 때문임
      예상 밖도 아니게, 대부분의 Go 코드는 여전히 그렇게 함. 여러 종류의 “컬렉션”을 제외하면 제네릭의 실용적 사용처는 그리 찾기 쉽지 않음. 애초에 대부분의 코드는 한 타입 이상을 볼 일이 없고, 두 타입을 넘으면 이미 드문 편임
      오히려 제네릭 추가는 사람들이 “필수”라며 요구하는 찬양받는 기능들이 널리 받아들여진 훌륭한 언어에 실제로는 얼마나 덜 필요한지를 더 큰 커뮤니티에 보여줬음
    • 제네릭은 내 코드베이스의 20%도 바꾸지 않았고, 그 20% 중에는 라이브러리도 있었음. Go 습관인지 C 습관인지 모르겠지만, 제네릭은 어딘가의 프로그래머가 어떤 문제를 해결해 둔 또 하나의 라이브러리처럼 보임
    • Go 커뮤니티라면 통일된 반복 인터페이스를 반길 줄 알았음
  • 이제서야 CLU 같은 프로그래밍 언어에 관심을 기울이는 건 좋은 일인 듯함
    반면 .NET과 C++ 코루틴, 그리고 Symbian C++와 Active Oberon의 Active Object를 써본 경험상, 이걸 정말 Go에 추가할 만한지는 확신이 없음
    .NET 팀도 올해 BUILD에서 인정했듯이, 시간을 되돌릴 수 있다면 런타임이 Go 스타일로 처리하게 하는 편이 더 나았을 것임. 많은 개발자가 여전히 async/await를 이해하는 데 어려움을 겪고 있기 때문임

  • 이게 정말 필요한지 모르겠음. Go의 대부분 사례는 고루틴으로 충분히 처리되고, yield/resume 의미론에는 블로킹 채널 2개면 충분함
    복잡성을 위한 복잡성을 더하는 것처럼 보이고, 이미 Go에 없던 새로운 능력을 실제로 추가하는지도 확실치 않음

    • 고루틴과 채널은 엄청난 오버헤드를 추가함. 반복자로 쓰는 건 사실상 말이 안 됨
    • 이 내용은 글에서 다루고 있음
  • 비교 대상으로, 최근 발표에서 Elixir(BEAM VM) 스레드 100만 개를 띄우고 모두에게 "Hello!" 메시지를 보낸 뒤, 각 스레드가 0~2초 사이의 무작위 시간만큼 기다렸다가 "Process received message !"를 돌려보내는 데모를 했음
    동시에 Erlang observer를 옆에 띄워 CPU와 메모리 소비, 그리고 가비지 정리 후 얼마나 빨리 회복되는지 관찰했음
    여기서 가장 큰 병목은 터미널이 따라오는 능력이지만, observer는 실제 상황을 꽤 정확히 반영하는 듯함
    https://www.youtube.com/watch?v=yxyYKnashR0
    사용한 코드: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
    이런 기능은 1980년대부터 Erlang, 따라서 Elixir에도 내장되어 있었음. 액터 모델이나 Erlang의 “전설적인” 구현은 많이 들어봤겠지만, 모니터링 도구를 함께 띄운 상태에서 실제로 본 사람은 얼마나 되는지 모르겠음
    Go가 이런 언어 수준 지원을 제공하면 좋겠지만, BEAM VM의 스레드 구현이 생성과 런타임 소비 양쪽에서 극도로 자원 효율적이고, 불변 값만 허용하는 데서 오는 동시성의 쉬움까지 결합되어 있어 결코 따라잡기 어려울 것 같음