Две горутины должны обменяться значениями через два небуферизованных канала, но каждая сначала выполняет от...

Две горутины должны обменяться значениями через два небуферизованных канала, но каждая сначала выполняет отправку. Какой результат следует ожидать и почему?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Программа взаимно заблокируется: обе горутины будут ждать получателя для своей первой отправки, но до получения дело не дойдёт. Это классический deadlock, вызванный циклическим ожиданием.

Исторический контекст

Каналы в Go основаны на модели передачи сообщений, где синхронизация совмещена с обменом данными. Для небуферизованного канала отправка является rendezvous-операцией: она завершается только при встрече с операцией получения.

Такой подход упрощает безопасную передачу данных между горутинами, но требует учитывать порядок операций. Если несколько участников ждут друг друга в замкнутом цикле, канал сам по себе не разрывает этот цикл.

Постановка проблемы

Пусть первая горутина отправляет значение в канал A, а вторая одновременно отправляет значение в канал B. Если обе операции выполняются первыми, каждая горутина блокируется на своей отправке.

Получение из каналов не выполняется, поэтому ни одна отправка не может завершиться. Если других готовых к работе горутин нет, среда выполнения Go обнаружит глобальную блокировку и завершит программу с сообщением о deadlock. Если другие горутины продолжают работать, программа может не завершиться, но заблокированные горутины останутся утёкшими.

Подробное решение

Для небуферизованного канала операция отправки требует одновременно существующего получателя. Размер буфера равен нулю, поэтому значение нельзя временно разместить внутри канала.

package main func main() { a := make(chan int) b := make(chan int) go func() { a <- 1 <-b }() go func() { b <- 2 <-a }() select {} }

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

Буфер — не универсальное исправление. Он может убрать блокировку для ограниченного числа сообщений, но при заполнении снова заблокирует отправителя; кроме того, он меняет момент синхронизации и может скрыть ошибку протокола. Надёжнее явно определить порядок обмена или использовать select с отменой и тайм-аутом, если ожидание не должно быть бесконечным.

Важно отличать deadlock от обычной блокировки: блокировка допустима, если другая горутина гарантированно выполнит соответствующую операцию. Проблема возникает, когда такой операции не будет или она сама зависит от завершения первой.

Ситуация из практики

В сервисе две горутины обрабатывали запрос и ответ: одна отправляла метаданные в канал A и затем ждала результат из канала B, другая отправляла результат в B и затем ждала метаданные из A. На тестовых данных с буферизованными каналами ошибка не проявлялась, но после замены каналов на небуферизованные запросы зависали.

Рассматривались три варианта. Увеличить буфер можно было быстро, но это лишь маскировало зависимость от порядка и не гарантировало работу при большем числе сообщений. Добавить тайм-аут снижало риск вечного ожидания, но не устраняло ошибочный протокол. Перестроить обмен так, чтобы каждая сторона сначала принимала обязательное входное сообщение, оказалось наиболее надёжным решением.

После явного описания порядка «получить запрос — обработать — отправить результат» циклическое ожидание исчезло. Тайм-аут и отмену при этом оставили как защиту от остановки внешнего участника.

Что кандидаты часто упускают

  1. Спасёт ли ситуацию буфер размером один?

Иногда да: первая отправка сможет записать значение в буфер и завершиться, после чего горутина перейдёт к получению, а вторая сторона сможет сделать то же самое. Но это зависит от числа сообщений и точного протокола. Если каждая сторона отправляет больше одного значения, буфер снова может заполниться; поэтому буфер нельзя считать доказательством корректности.

  1. Всегда ли Go сразу сообщает о таком deadlock?

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

  1. Может ли select устранить циклическое ожидание сам по себе?

Нет. select полезен, если в него включена альтернативная ветвь, например получение сигнала отмены или таймера. Без такой альтернативы выбор между несколькими заблокированными операциями не создаёт прогресса; с default можно избежать блокировки, но тогда нужно корректно обработать ситуацию, когда обмен пока невозможен.