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