Коллега давеча показал любопытный "фокус", который вызвал изрядный спор в рабочем канальчике посвящённом 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 определяет так:

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/atomic package.

Здесь доступ к переменной не происходит конкурентно. Наоборот, всё очень последовательно:

  • попытались записать переменную в канал

  • горутина заблокировалась

  • 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)


  1. megadrugo2009
    26.11.2025 06:53

    Мне кажется это известная проблема, поэтому гороутины надо запускать так:

    go func(i int) {
    	ch <- i 
    }(i)

    https://go.dev/play/p/mwKKilTx04Y


    1. eCringe
      26.11.2025 06:53

      Если учитывать тот факт, что механизм захвата переменной цикла изменили (привет, go 1.22), то, вероятнее всего, здесь тоже подшаманят


  1. 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. Хотя и неочевидная, спасибо за разбор.