Наткнулся на простую вещь, но провозился с ней дольше, чем хотелось бы.

Пул из восьми воркеров читает задачи из 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.

Кажется, правило тут простое. Каждое место, где воркер может ждать, слушает и отмену.