단순한 건데 생각보다 오래 붙잡고 있었어요.
워커 여덟 개짜리 풀이 jobs에서 작업을 읽고, 버퍼 없는 results에 결과를 써요. 소비자는 results를 루프로 읽다가 첫 에러가 나면 ctx.Done()으로 돌아가요. 취소는 전달됐고 소비자는 떠났어요. 그런데 그 시점에 이미 결과를 계산해 둔 워커들은 results <- r에 그대로 서 있어요. 이 채널을 읽을 쪽이 없으니 프로세스가 끝날 때까지 그렇게 서 있게 돼요.
취소 자체는 여기서 아무 일도 안 해요. ctx.Done()은 닫히는 채널일 뿐이에요. 고루틴이 기다리는 바로 그 자리에서 이걸 듣고 있지 않으면 취소됐다는 걸 알 길이 없어요.
고치는 건 짧아요. 전송을 select로 감싸서 case results <- r와 case <-ctx.Done() 두 갈래를 두고, 두 번째에서 돌아가면 돼요. context 문서의 Stream 예제가 이렇게 되어 있는데 전에는 눈여겨보지 않았어요.
Java에서는 SynchronousQueue.put이 비슷한데, 거기서는 적어도 interrupt로 스레드를 깨울 수 있고 put이 InterruptedException을 던져요. Go에는 그런 바깥 손잡이가 없어서 고루틴이 직접 ctx를 봐야 해요.
규칙은 간단한 것 같아요. 워커가 기다릴 수 있는 자리마다 취소도 같이 듣게 두면 돼요.