Коллега давеча показал любопытный "фокус", который вызвал изрядный спор в рабочем канальчике посвящённом Golang. Сначала обозвали коллегу двоечником, мол синхронизацию забыл - но выходит что дело тоньше и выдаёт небольшую неконсистентность эволюции средств языка, в частности каналов.
Вот он код - он использует новую фичу из 1.24, synctest.Wait() - дело не в ней самой, но кажется на текущий момент это единственный способ "вскрыть" проблему (UPD - нет не единственный, смотри примечание в конце):
i := 13 // сделали переменную
ch := make(chan int) // создали канал (небуфферизированный)
go func() {
ch <- i // отправим значение в канал внутри горутины
}()
synctest.Wait() // ждёт пока горутина заблокируется (на отправке)
i += 15 // значение переменной увеличим
println(<-ch) // читаем из канала - что будет, 13 или 28?
Целиком этот код можно посмотреть (и запустить) в плейграунде: https://go.dev/play/p/xRX8NR44xhW
Фокус-то в чём?
А попробуйте заменить строчку ch < -i на ch <- i+0 - в примере в "песочнице" достаточно раскомментировать "хвост" этой строки.
Получается так:
без добавления нуля печатает 28
с добавлением нуля печатает 13
Иными словами, если не добавлять 0 (или другое значение) то в канал отправится значение переменной после i+=15 - и уж точно после synctest.Wait() - то есть после того как канал разблокируется... разблокируется чтением из него!
Тут оставим в стороне соображение что добавление нуля ещё со времен турбо-паскаля наверное оптимизировалось компилятором (и удалялось).
В чём причина такого поведения?
Запустив отладчик (хотя бы gdb) можно быстро найти (если нам это неизвестно) что отправка в канал выполняется функцией runtime.chansend из файла https://go.dev/src/runtime/chan.go - и сигнатура её принимает указатель (ep) на отправляемое значение:
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool
Это ожидаемо, конечно, на нижнем уровне мы не можем оперировать произвольными типами "по значению".
Но что в функции происходит - догадаться несложно - блокировка отрабатывается сначала, а "дереференс" указателя, то есть непосредственно копирование данных - потом (в функции sendDirect).
Само по себе это еще не повлекло бы такого "фокуса". Для того чтобы получить то "чудесное" поведение, которое демонстрирует пример, необходимо чтобы вызов chansend получал указатель именно на оригинальную переменную (которая позже поменяет значение).
Опять же воспользовавшись отладчиком можно убедиться что именно это и происходит. К сожалению компилятор генерирует достаточно замысловатый код с учётом создания "замыкания" переменной для горутины - и мы его разбирать тут не будем - но трассировкой по шагам проверить несложно. Поскольку в Go замыкания "недозамкнутые" (переменные не изолируются полностью), контекст этого замыкания (на amd64 архитектуре его адрес в регистре RDX) совпадает с контекстом внешней функции в данном примере.
Итак получается:
если ноль не прибавлять
chansendполучает указатель на саму внешнюю переменную, а поскольку используется указатель уже только при отправке (фактически, при вычислении параметраprintln) то значение её уже измененоесли ноль прибавить, генерируемый перед вызовом код создаёт временную переменную (безымянную ячейку) в контексте самой горутины - и
chansendполучает указатель уже на эту ячейку, которая, конечно, не поменяется.
Заключение
Как было упомянуто, сперва мы дружно заявили что "здесь гонка", но по зрелом размышлении это заявление оказывается проблемным. Действительно, Go memory model определяет так:
A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the
sync/atomicpackage.
Здесь доступ к переменной не происходит конкурентно. Наоборот, всё очень последовательно:
попытались записать переменную в канал
горутина заблокировалась
synctest.Wait() дождался этого момента
перезаписали переменную (увеличили значение)
прочли значение из канала (и напечатали)
Как видно, две записи строго разделены (happens before / after). Нюанс возник из-за сочетания двух (даже трех) особенностей:
запись в канал оказалась не вполне атомарной из-за того что она начинается (берется указатель на значение) до блокировки, а завершается (копируется значение) после
однако до synctest.Wait() не было (кажется) механизма позволяющего "опереть" логику работы на состояние заблокированности чужой горутины, поэтому такое поведение канала не вызывало подобных фокусов (или их сложно было обнаружить)
это поведение канала не проявилось бы если бы компилятор, например, по-честному создавал в контексте горутины копию значения для отправки в канал
Остаётся вопрос - считать ли "data race" само "неатомарное" поведение канала - именно что запись как бы растягивается во времени - но это уже чревато пустопорожними спорами. Может быть дождёмся обновления Go Memory Model соответствующего... Или правки в компиляторе? не знаю... :)
Примечание
Конечно я поторопился сказав что synctest.Wait() единственный способ вскрыть это поведение. Вот вариант в котором мы блокируем канал записав в него предыдущий элемент - а с помощью чтения этого предыдущего элемента разблокируем:
i := 13
ch := make(chan int, 1)
ch <- 5
go func() {
ch <- i // + 0
}()
time.Sleep(100 * time.Millisecond)
i += 15
println(<-ch)
println(<-ch) // печатает 28 или 13 если раскомментировать выше
https://go.dev/play/p/PQTDStrcT6v
Здесь если смотреть очень строго условие для race condition есть (нет гарантии что горутина выполнится когда мы уходим в Sleep) - но в жизни это, понятно, ожидаемо работает. Фактически запись в канал начинается в строчке 5 а заканчивается в строчке 10, так что тут даже о синхронизации и data race рассуждать сложно, их смысл размазывается по этим строкам...
Комментарии (3)

kozlyuk
26.11.2025 06:53Здесь доступ к переменной не происходит конкурентно. Наоборот, всё очень последовательно:
попытались записать переменную в канал
горутина заблокировалась
synctest.Wait() дождался этого момента
перезаписали переменную (увеличили значение)
прочли значение из канала (и напечатали)
Как видно, две записи строго разделены (happens before / after).
Конкуретность означает любое выполнение в разных горутинах. Модель памяти гарантирует, что "a send on a channel is synchronized before the completion of the corresponding receive from that channel", но это относится только к самим операциям с каналом, а не к операциям над другими переменными, каковые операции в каждой из горутин как-то упорядочены относительно операций с каналом в ней же. Иначе говоря, на каналах невозможно реализовать
sync.Mutex, так как операция с каналом не является барьером для операций с другими переменными. Так что в вашем случае операции надiконкуретны и не синхронизированы ничем, это data race. Хотя и неочевидная, спасибо за разбор.
megadrugo2009
Мне кажется это известная проблема, поэтому гороутины надо запускать так:
https://go.dev/play/p/mwKKilTx04Y
eCringe
Если учитывать тот факт, что механизм захвата переменной цикла изменили (привет, go 1.22), то, вероятнее всего, здесь тоже подшаманят