Коллега давеча показал любопытный "фокус", который вызвал изрядный спор в рабочем канальчике посвящённом 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/ZdgAuApl-Mi
Фокус-то в чём?
А попробуйте заменить строчку 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 получал указатель именно на оригинальную переменную (которая позже поменяет значение).
Опять же воспользовавшись отладчиком можно убедиться что именно это и происходит. К сожалению компилятор генерирует достаточно замысловатый код - и мы его разбирать тут не будем - но трассировкой по шагам проверить несложно.
Итак получается:
если ноль не прибавлять
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 рассуждать сложно, их смысл размазывается по этим строкам...
Комментарии (16)

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. Хотя и неочевидная, спасибо за разбор.
RodionGork Автор
26.11.2025 06:53Конкуретность означает любое выполнение в разных горутинах.
вы можете предложить ссылку на это определение в какой либо документации по Go?
тут есть такая проблема что упомянутая выше цитата из Go Memory Model оставляет возможность для двоякой трактовки, сводящейся к тому что:
data race это именно ситуация когда две операции доступа к переменной происходят с пересечением по времени
data race это потенциальная возможность возникновения такой ситуации исходя из организации кода
Я (как и race detector в go) использую первую трактовку. Спорить тут вроде бессмысленно - если определение нечёткое, можно до опупения защищать каждый свою точку зрения :)

kozlyuk
26.11.2025 06:53По-моему, определение четко значит первую трактовку. Но на чем основана ваша точка зрения, что между операциями над
iесть отношение "happens before/after", о котором вы пишете? Определение конкуретности я искал, еще когда писал первый комментарий, но его в спецификации нет :(После чтения спецификации мне кажется, что поведение в статье противоречит описанию операции отправки:
Both the channel and the value expression are evaluated before communication begins.
Что бы ни значило "communication begins", раз под капотом передается указатель, то значение по нему может быть изменено вообще всегда, в том числе в тот промежуток времени, пока рантайм будит ожидающую горутину (если значение менят третья). По-хорошему, рантайм должен всегда создавать локальную переменную, копировать туда значение и только потом отправлять, как в варианте с
i+0.
RodionGork Автор
26.11.2025 06:53Но на чем основана ваша точка зрения, что между операциями над
iесть отношение "happens before/after", о котором вы пишете?тут опять всё упирается в то хотим ли мы чтобы это отношение было доказуемо "статически" (из наличия мьютексов например) или динамически (тогда любой способ который позволит физически разнести операции во времени - годится). Go Memory Model по-моему нигде точно не говорит какие способы (механизмы) обеспечения happens before/after мы признаём. В частности
synctest.Wait()является ли оным
kozlyuk
26.11.2025 06:53Отношения существуют в рамках модели, в которой нет времени. В конечном счете мы хотим уверенности, что код будет вести себя корректно при любом program execution. На входе — гарантированные спецификацией отношения, на выходе — доказательство без необходимости перебрать все program executions. Рассуждать наоборот ("динамически") означает на входе вообразить program executions, которые не будут иметь формального обоснования, а на выходе получить отношения, которые сами по себе не нужны.

RodionGork Автор
26.11.2025 06:53не совсем так, "статически" и "динамически" я соотношу строго с двумя вышеупомянутыми трактовками "data race". наверное не очень внятно это выразил. если мы трактуем "data race" как "потенциальную возможность" а не как факт, то race detector уместно было бы сделать синтаксическим анализатором (он однако вместо этого воплощён только как "динамическая" проверка - что соответствует первой трактовке).

kozlyuk
26.11.2025 06:53Гонка случается по факту. Программа некорректна, если гонка может случиться согласно математической модели. Но реальную программу с её размером, а еще внешними по отношению к go операциями, моделировать непрактично. Динамический анализатор проще, быстрее, надежнее в том смысле, что нет процесса моделирования и упущения важных деталей. Но, как любой тест, он может доказать только наличие проблем, а не их отсутствие.

RodionGork Автор
26.11.2025 06:53поведение в статье противоречит описанию операции отправки:
спасибо за ссылку на спецификацию - действительно как будто нарушается, если только не считается что communication begins - это только после разблокировки
попробую из любопытства тикет создать

evgeniy_kudinov
26.11.2025 06:53go func() { ch <- i + 0 }()Спасибо за статью. Думаю, можно тег "ненормальное программирование" добавить.
Я снова убедился, что не стоит полагаться на субъективное восприятие и действовать по принципу "я художник, я так вижу". Вместо этого лучше следовать общепринятым подходам. В противном случае есть риск неявного несоответствия между фактическим выполнением инструкциями по сравнению с кодом, который представлен в виде текста.
RodionGork Автор
26.11.2025 06:53Вы абсолютно правы, по крайней мере с "формальной" кочки зрения :)
Но с точки зрения практической получается такая шляпа: Go сейчас занял нишу языка на который "несложно быстро мигрировать" например с Java или C++ (и получить от этого некоторые преимущества). Это влечёт за собой стремление сделать язык достаточно предсказуемым для новичков и "защищённым от дураков". Во многих отношениях эта "политика" действительно прослеживается. Например Go Memory Model настойчиво просит чтобы записи чисел и указателей влезающих в машинное слово были атомарны, и дата рейс на них никогда не приводил бы к проблемам (если бы не это, количество багов в существующем go-шном коде было бы на порядки больше).
С этой точки зрения проектировать язык так чтобы он работал ожидаемо только в расчете на очень грамотного и внимательного программиста - явное отступление от этой стратегии. Тем более здесь фикс возможен тривиальный (как коллеги упомянули выше).
megadrugo2009
Мне кажется это известная проблема, поэтому гороутины надо запускать так:
https://go.dev/play/p/mwKKilTx04Y
eCringe
Если учитывать тот факт, что механизм захвата переменной цикла изменили (привет, go 1.22), то, вероятнее всего, здесь тоже подшаманят
RodionGork Автор
да не, это вы про "недозамкнутость" замыканий - это даже не проблема, это просто "ну такая реализация" - действительно известная спокон веку. языки примерно поровну поделились на те которые замыкают полностью и неполностью (в основном ради соображений эффективности)
но здесь речь именно о реализации отправки в канал.
чтобы не конфузить читателей, заменил на глобальную переменную, тогда про замыкание уже речь не идёт - а проблема остаётся :) спасибо
domix32
в смысле не идёт, а в контекст горутины она как попадает? Просто логика по-умолчанию там copy on write.
RodionGork Автор
глобальная переменная не в контексте горутины (или любой другой функции), ей не нужно туда попадать. можете скомпилировать такой код и убедиться отладчиком:
В gdb если дизассемблировать горутину, получаются вот такие 3 строчки:
0x0000000000471620 <+0>: mov 0x8(%rdx),%rax
0x0000000000471624 <+4>: addq $0x5,0x96b44(%rip) # 0x508170
0x000000000047162c <+12>: addq $0x7,(%rax)
первая грузит в RAX адрес контекста горутины со смещением 8 - очевидно адрес переменной B как он в контексте горутины виден (RDX хранит указатель на этот контекст)
вторая добавляет число 5 в переменную A адресуемую непосредственно относительно счетчика команд, то бишь по абсолютному адресу в коде
третья добавляет число 7 в переменную B (адрес её в RAX)
Так что B замыкается, а A не замыкается. Глобальные переменные всем функциям видны без ограничений и живут все время жизни программы - замыкать их не требуется...
domix32
Есть же godbolt для такого.
ну да, только сейчас понял что любой статично определённому куску памяти нет смысла куда-то там что-то перемещаться/захватываться.