Комментарии 30
Мне кажется это известная проблема, поэтому гороутины надо запускать так:
go func(i int) {
ch <- i
}(i)Если учитывать тот факт, что механизм захвата переменной цикла изменили (привет, go 1.22), то, вероятнее всего, здесь тоже подшаманят
да не, это вы про "недозамкнутость" замыканий - это даже не проблема, это просто "ну такая реализация" - действительно известная спокон веку. языки примерно поровну поделились на те которые замыкают полностью и неполностью (в основном ради соображений эффективности)
но здесь речь именно о реализации отправки в канал.
чтобы не конфузить читателей, заменил на глобальную переменную, тогда про замыкание уже речь не идёт - а проблема остаётся :) спасибо
чтобы не конфузить читателей, заменил на глобальную переменную, тогда про замыкание уже речь не идёт - а проблема остаётся :) спасибо
в смысле не идёт, а в контекст горутины она как попадает? Просто логика по-умолчанию там copy on write.
глобальная переменная не в контексте горутины (или любой другой функции), ей не нужно туда попадать. можете скомпилировать такой код и убедиться отладчиком:
var a = 5
func main() {
var b = 7
go func() {
a += 5
b += 7
}()
//...
}В 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 не замыкается. Глобальные переменные всем функциям видны без ограничений и живут все время жизни программы - замыкать их не требуется...
Здесь доступ к переменной не происходит конкурентно. Наоборот, всё очень последовательно:
попытались записать переменную в канал
горутина заблокировалась
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. Хотя и неочевидная, спасибо за разбор.
Конкуретность означает любое выполнение в разных горутинах.
вы можете предложить ссылку на это определение в какой либо документации по Go?
тут есть такая проблема что упомянутая выше цитата из Go Memory Model оставляет возможность для двоякой трактовки, сводящейся к тому что:
data race это именно ситуация когда две операции доступа к переменной происходят с пересечением по времени
data race это потенциальная возможность возникновения такой ситуации исходя из организации кода
Я (как и race detector в go) использую первую трактовку. Спорить тут вроде бессмысленно - если определение нечёткое, можно до опупения защищать каждый свою точку зрения :)
По-моему, определение четко значит первую трактовку. Но на чем основана ваша точка зрения, что между операциями над i есть отношение "happens before/after", о котором вы пишете? Определение конкуретности я искал, еще когда писал первый комментарий, но его в спецификации нет :(
После чтения спецификации мне кажется, что поведение в статье противоречит описанию операции отправки:
Both the channel and the value expression are evaluated before communication begins.
Что бы ни значило "communication begins", раз под капотом передается указатель, то значение по нему может быть изменено вообще всегда, в том числе в тот промежуток времени, пока рантайм будит ожидающую горутину (если значение менят третья). По-хорошему, рантайм должен всегда создавать локальную переменную, копировать туда значение и только потом отправлять, как в варианте с i+0.
Но на чем основана ваша точка зрения, что между операциями над
iесть отношение "happens before/after", о котором вы пишете?
тут опять всё упирается в то хотим ли мы чтобы это отношение было доказуемо "статически" (из наличия мьютексов например) или динамически (тогда любой способ который позволит физически разнести операции во времени - годится). Go Memory Model по-моему нигде точно не говорит какие способы (механизмы) обеспечения happens before/after мы признаём. В частности synctest.Wait() является ли оным
Отношения существуют в рамках модели, в которой нет времени. В конечном счете мы хотим уверенности, что код будет вести себя корректно при любом program execution. На входе — гарантированные спецификацией отношения, на выходе — доказательство без необходимости перебрать все program executions. Рассуждать наоборот ("динамически") означает на входе вообразить program executions, которые не будут иметь формального обоснования, а на выходе получить отношения, которые сами по себе не нужны.
не совсем так, "статически" и "динамически" я соотношу строго с двумя вышеупомянутыми трактовками "data race". наверное не очень внятно это выразил. если мы трактуем "data race" как "потенциальную возможность" а не как факт, то race detector уместно было бы сделать синтаксическим анализатором (он однако вместо этого воплощён только как "динамическая" проверка - что соответствует первой трактовке).
Гонка случается по факту. Программа некорректна, если гонка может случиться согласно математической модели. Но реальную программу с её размером, а еще внешними по отношению к go операциями, моделировать непрактично. Динамический анализатор проще, быстрее, надежнее в том смысле, что нет процесса моделирования и упущения важных деталей. Но, как любой тест, он может доказать только наличие проблем, а не их отсутствие.
поведение в статье противоречит описанию операции отправки:
спасибо за ссылку на спецификацию - действительно как будто нарушается, если только не считается что communication begins - это только после разблокировки
попробую из любопытства тикет создать
Откуда информация про "под капотом передается указатель"?
Нет в спецификации такого
По ссылке написано:
"The channel expression must be of channel type, the channel direction must permit send operations, and the type of the value to be sent must be assignable to the channel's element type."
То есть если переводить на текущий пример,то:
слева должна быть переменная типа chan<- T или chan T а справа должны быть некие данные с типом T
то есть в текущем примере слева chan int справа должно быть что угодно с типом int это может быть переменная или не переменная
Далее i + 0 это не тип int это выражение, арифметическая операция, результатом который будет значение int, но само по себе выражение не имеет тип int, как если бы мы определил функцию sum(ia, b int) int и вызывали ее вместо i + 0 то функция не имеет тип int она имеет тип func(int, int) int и результатом вызова этой функции будет значение с типом int
и дальше Both the channel and the value expression are evaluated before communication begins. то есть любые выражения должны быть приведены к нужным типам, поэтому i + 0 вычисляется еще до того как вообще началось какая либо история с каналом, и результат этого вычисления помещается на стек во временную локальную переменную. Дальше, у нас слева chan int справа результат арифм. операции сохраненный в локальной переменной и имеющий тип int и вот теперь все готово к тому чтобы communication. begins. Но, мы не можем выполнить передачу, потому что канал еще не готов, нет читателя, либо буфер заполнен, поэтому горутина блокируется не выполнив эту операцию. И вот когда появился читатель либо освободился буфер, горутина разблокируется и операция осуществляется и в этот момент в буфер канала (для буферизированных) или на стек горутины (для небуферизированных) помещается копия значения передаваемого в канал.
Для случае когда у нас нет сложение переменной i + 0 все обстоит ровно по такому же принципу как описано в спецификации.
В этот момент мы имеет слева chan int а справа мы имеем переменную i типа int то есть все готово для того чтобы communication begins. Но, канал не. готов потому что нет читателя, либо буфер заполнен, поэтому горутина блокируется не выполнив эту операцию.
Далее когда произошло чтение из канала и освободилось место в буфере, горутина разблокируется и операция выполняется, то есть в этот момент берется значение переменной i и копируется в буфер (для буферизированных) или на стек горутины читающей (для небуферизированных)
Никаких упоминаний о "под капотом передается указатель" нет в спецификации
Вот еще небольшой пример на схожую тему
var i = 0
f := func() int {
i = 15
return i
}
fmt.Println(i*i + 15) // 15
fmt.Println(i*i + f()) // 240казалось бы сначала выполняется умножение и только потом сложение, но мы можем использовать в выражении только то что подходит по типу, а f() не подходит по типу, так как это не int это функция результатом выполнения который будет значение с типом int, поэтому прежде, чем приступить к разбору арифметической операции и ее вычислению, мы выполняем функцию, которая замкнула переменную i и изменяет ее внутри тела, поэтому когда дело дойдет до самой арифметической операции, то значение переменной i уже изменилось
точно также в примере из статьи, когда дело доходит до `ch <-` там уже нет переменной i там есть результат выражения i + 0 сохраненный в локальную переменную, и поэтому никакое дальнейшее изменение i не влияет на то, что уйдет в канал
Откуда информация про "под капотом передается указатель"?
в статье ссылка на исходник реализации функции отправки - посмотрите пожалуйста внимательно. она указатель принимает.
ниже в комментариях есть и дизассемблированный код.
мы выполняем функцию, которая замкнула переменную i и изменяет ее внутри тела
в ваших размышлениях отсутствует какое-либо логическое объяснение того почему переменная либо "замыкается" либо нет в зависимости от того прибавляется к ней ноль или нет.
замкнула переменную i и изменяет ее внутри тела
какая функция "замкнула" переменную и "изменяет" её? вы о чём? внешняя её изменяет но не замыкает (т.к. переменная глобальная) а внутренняя замыкает но не изменяет
Вы путаете понятия. Есть спецификация языка, вы можете с ней ознакомиться по ссылке и в ней вы не найдете про "ссылку под капотом" потому что этого нет. Далее есть реализация, которая своим поведением полностью соответствует спецификации. А вот далее произошло то что вы не разобрались в той реализации которую увидели в искодниках и решили, что есть какая то магия. Но магии нет, ссылка там по вполне очевидным причинам и эти причины не "под капотом передается указатель". В горутину читатель, либо в буфер канала передается не ссылка а копия значения, и это можно увидеть в исходниках если заглянуть чуть дальше на несколько строчек (см. memmove в функциях "отправки"). В chansend передается по указателю потому что мы должны получить значение переменной в момент "отправки значения" а не в момент вызова функции chansend, момент отправки происходит когда мы кладем значение отправляемое в канал либо на стек горутины-читателя, либо в буфер канала. И вот если бы мы в chansend передавали не ссылку а значение, то при блокировке горутины, например канал не готов принять (нет читателя или буфер заполнен) мы должны остановить выполнение и к моменту когда горутина разблокируется (появится читатель или место в буфере) переменная может быть изменена другими горутинами, но мы от нее ужа отвязались так как взяли значение а не ссылку и как результат мы положим на стек горутины-читателя или в буфер канала старое значение. И это будет нарушением спецификации языка, то есть то самое явление когда реализация не соответствует спецификации. Но, нам повезло, в go team достаточно хорошие инженеры, чтобы не допустить такой ошибки.
> в ваших размышлениях отсутствует какое-либо логическое объяснение того почему переменная либо "замыкается" либо нет в зависимости от того прибавляется к ней ноль или нет.
В "моих размышлениях" есть не более чем указание на спецификацию языка, которой в данном случае поведение полностью соответствует. Вот если бы не соответствовало, то был бы повод в подозрении на баг, но в текущей ситуации поведение полностью соответствует спецификации. И пока все говорит лишь о том, что вы не разобрались в реализации с одной стороны и не прочитали спецификацию с другой. Поэтому для вас это выглядит странным
ну и по поводу вашего примера, я если честно даже не знаю как еще показать простейшее элементарное поведение
ch <- i + 0
это 2 операции
сначала выполняется i + 0
значение кладется на стек (можно по другому сказать создается временная переменная в которой хранится значение операции i + 0)
далее выполняется ch <- (та самая временная переменная) И уже в этот момент тут нет никакой переменной i, тут есть канал оператор отправки в канал и временная переменная в которой содержится значение предыдущей операции. Но продолжим и в момент когда эта операция может быть исполнена (напомню это либо есть читатель, либо есть место в буфере) передается значение той самой временной переменной и тут нет никакой переменной i здесь есть "временная переменная" результат предыдущей операции (i + 0)
в ситуации ch <- i
есть только одна операция, передача в канал, и когда эта операция может быть выполнена (а это напомню либо есть читатель, либо есть место в буфере) берется значение из переменной i
Далее по поводу дизассемблированного кода, это кстати возможно даже лучше будет моих объяснений. Вот здесь https://godbolt.org/z/5dKKrnGxW предлагяю поиграться с разными значениями, попробуйте разные варианты
ch <- i
ch <- i + 0
ch <- i + 1
ch <- i + 2
вы увидете что во всех этих вариантах код разный и даже варианты +0 +1 +2 различаются между собой. Но также вы увидите то о чем собственно я и говорю, что в случаях + 0, + 1, + 2 появляется дополнительная операция и результат этой операции используется уже в отправке в канал, а при отсутствии арифметической операции работа идет непосредственно с переменной.
ch <- i
PCDATA $1, $0
CALL runtime.chansend1(SB)
ch <- i + 0
MOVQ (CX), CX
MOVQ CX, command-line-arguments..autotmp_0+16(SP)
LEAQ command-line-arguments..autotmp_0+16(SP), BX
PCDATA $1, $0
CALL runtime.chansend1(SB)
ch <- i + 1
MOVQ (CX), CX
INCQ CX
MOVQ CX, command-line-arguments..autotmp_0+16(SP)
LEAQ command-line-arguments..autotmp_0+16(SP), BX
PCDATA $1, $0
CALL runtime.chansend1(SB)
ch <- i + 2
MOVQ (CX), CX
ADDQ $2, CX
MOVQ CX, command-line-arguments..autotmp_0+16(SP)
LEAQ command-line-arguments..autotmp_0+16(SP), BX
PCDATA $1, $0
CALL runtime.chansend1(SB)
но лучше сами попробуйте, разные варианты, если дружите с ассемблером
> какая функция "замкнула" переменную и "изменяет" её? вы о чём? внешняя её изменяет но не замыкает (т.к. переменная глобальная) а внутренняя замыкает но не изменяет
хм, я честно говоря думал, что вы сможете сообразить, что это не полный пример а его основная часть, демонсрация поведения так сказать
Ну хорошо вот полный пример
package main
import "fmt"
func main() {
example()
}
func example() {
var i = 0
f := func() int {
i = 15
return i
}
fmt.Println(i*i + 15) // 15
fmt.Println(i*i + f()) // 240
}
переменная i здесь не глобальная, а локальная для функции example, функция которую мы присвоили переменной f замкнула переменую i и... ну а далее надеюсь вы помните, и возможно даже увидите сходство примеров, что здесь f() выполняется до того как будет выполнена арифметическая операция, так как если бы было иначе, то первым должно было бы выполниться умножение в соответствии с приоритетом над +
но на всякий случай допишу - сходство примера здесь в том, что казалось бы на одной строчке кода, есть разные две операции имеющие разный приоритет выполнения, в этом примере функция выполнится до того как начнет выполняться арифметическая операция `i * i + (значение временной переменной хранящее значение функции f)` так же и в вашем примере ch <- i + 0 есть две совершенно разные операции, с разным приоритетом выполнения и арифметическая операция i + 0 будет выполнена первой и ее значение будет "записано во временную переменную" (сохранено на стек) и вот когда дело дойдет до кода `ch <- ...` то здесь уже нет переменной i здесь есть "временная переменная хранящая полученное значение операции которая уже завершилась" (значение хранящееся на стеке)
Честно говоря если после такой попытки разжевать все до элементарных вещей вы по прежнему останетесь при своем, то я умываю руки :)
go func() {
ch <- i + 0
}()Спасибо за статью. Думаю, можно тег "ненормальное программирование" добавить.
Я снова убедился, что не стоит полагаться на субъективное восприятие и действовать по принципу "я художник, я так вижу". Вместо этого лучше следовать общепринятым подходам. В противном случае есть риск неявного несоответствия между фактическим выполнением инструкциями по сравнению с кодом, который представлен в виде текста.
Вы абсолютно правы, по крайней мере с "формальной" кочки зрения :)
Но с точки зрения практической получается такая шляпа: Go сейчас занял нишу языка на который "несложно быстро мигрировать" например с Java или C++ (и получить от этого некоторые преимущества). Это влечёт за собой стремление сделать язык достаточно предсказуемым для новичков и "защищённым от дураков". Во многих отношениях эта "политика" действительно прослеживается. Например Go Memory Model настойчиво просит чтобы записи чисел и указателей влезающих в машинное слово были атомарны, и дата рейс на них никогда не приводил бы к проблемам (если бы не это, количество багов в существующем go-шном коде было бы на порядки больше).
С этой точки зрения проектировать язык так чтобы он работал ожидаемо только в расчете на очень грамотного и внимательного программиста - явное отступление от этой стратегии. Тем более здесь фикс возможен тривиальный (как коллеги упомянули выше).
А в чем странность поведения?
Отправка в канал происходит не в момент инструкции <- а в момент когда есть либо читатель либо буфер.
При прибавлении 0 к переменной тоже вполне себе все стандартно, так как тут две операции, первая это сложение и вторая отправка в канал, сначала выполняется сложение, а потом блокируется горутина в ожидании когда у канала появится читатель для того чтобы передать значение.
в том что отправляемое выражение вычисляется в разные моменты в зависимости от того что в этом выражении написано - либо до инструкции <- либо уже при вычитывании (и тут даже буфер не при чем)
Так нет же, одинаково оно вычисляется
просто при ch <- i + 0 здесь не одна операция отправки в канал, а две операции: ch <- и i + 0, и первая из них это сложение i + 0 и она вычисляется первой потому что исполнение в этом сулчае справа налево, до того как перейти к следующей, и для вычисления этой операции необходимо получить значение i и сложить с 0 и получившееся значение передать в следующую операцию, поэтому когда дело доходит до ch <- то здесь уже не ch <- i а ch <- (значение получившееся от предыдущей операции сложения)
в случае когда у нас нет сложения, то у нас одна операция
все согласно спецификации языка, нет никаких странностей
вы то ли не прочли статью, то ли прочли как-то не так. непонятно что случилось, простите :)
для облегчения понимания, можно заменить i + 0 на get(i) где func get(i int) int { return i }
и в обоих случаях и get(i) иi + 0 это отдельные операции, которые выполняются до того, как дошло дело до ch <-
и в обоих этих случаях значение переменной i было вычислено до того как дошло до ch <-
Все станет гораздо проще понять, если пойти в код Go и почитать непосредственно там что происходит под капотом при передаче сообщения. В статье не упомянут важный момент про поток исполнения. И synctest тут вообще не понятно зачем, ещё и без полного кода с вводящим в заблуждение комментарием.
запись в канал оказалась не вполне атомарной из-за того что она начинается (берется указатель на значение) до блокировки, а завершается (копируется значение) после
Не совсем понял, как вы пришли к таким выводам.
У вас во всех примерах в канал уходит ровно копия значения, которое лежит в переменной. Просто i - это замкнутая переменная, а i+0 - это результат выполнения арифметической операции, т.е. некая локальная для горутины переменная.

Забавный парадокс отправки в канал в Go