Сегодня я решил перевести для вас небольшую статью о внутренностях реализации так называемых замыканий или closures. В дополнение вы узнаете о том, как Go пытается автоматически определить, нужно ли использовать указатель/ссылку или значение в разных случаях. Понимание этих вещей позволит избежать ошибок. Да и просто все эти внутренности чертовски интересны, как мне кажется!
А еще я хотел бы пригласить вас на Golang Conf 2019, которая пройдет 7 октября в Москве. Я член программного комитета конференции, и мы с коллегами выбрали много не менее хардкорных и очень, очень интересных докладов. То, что я люблю!
Под катом передаю слово автору.
На Go вики есть страничка с названием «Частые ошибки». Любопытно, но в ней только один пример: неправильное использование переменных цикла вместе с горутинами (goroutines):
for _, val := range values {
go func() {
fmt.Println(val)
}()
}
Этот код выведет последнее значение из массива values len(values) раз. Исправить код очень просто:
// assume the type of each value is string
for _, val := range values {
go func(val string) {
fmt.Println(val)
}(val)
}
Этого примера достаточно, чтобы понять проблему и больше никогда не повторять ошибку. Но если вам интересно узнать подробности реализации, эта статья даст вам глубокое понимание как проблемы, так и решения.
Базовые вещи: передача по значению и передача по ссылке
В Go существует различие в передаче объектов по значению и по ссылке [1]. Начнем с примера 1 [2]:
func foobyval(n int) {
fmt.Println(n)
}
func main() {
for i := 0; i < 5; i++ {
go foobyval(i)
}
time.Sleep(100 * time.Millisecond)
}
Ни у кого, скорее всего, нет сомнения, что в результате будут выведены значения от 0 до 4. Вероятно, в каком-то случайном порядке.
Давайте посмотрим на пример 2.
func foobyref(n *int) {
fmt.Println(*n)
}
func main() {
for i := 0; i < 5; i++ {
go foobyref(&i)
}
time.Sleep(100 * time.Millisecond)
}
В результате будет выведено следующее:
5
5
5
5
5
Понимание того, почему результат получился именно такой, даст нам уже 80% понимания сути проблемы. Поэтому давайте уделим некоторое время на поиск причин.
А ответ — он прямо там, в спецификации языка Go. Спецификация гласит:
Переменные, объявленные в инициализирующем операторе, переиспользуются в каждом цикле.
Это означает, что, когда программа запущена, существует только один объект или кусок памяти для переменной i, а не создается новый для каждого цикла. Этот объект принимает новое значение на каждой итерации.
Давайте посмотрим на разницу в сгенерированном машинном коде [3] для цикла в примерах 1 и 2. Начнем с примера 1.
0x0026 00038 (go-func-byval.go:14) MOVL $8, (SP)
0x002d 00045 (go-func-byval.go:14) LEAQ "".foobyval·f(SB), CX
0x0034 00052 (go-func-byval.go:14) MOVQ CX, 8(SP)
0x0039 00057 (go-func-byval.go:14) MOVQ AX, 16(SP)
0x003e 00062 (go-func-byval.go:14) CALL runtime.newproc(SB)
0x0043 00067 (go-func-byval.go:13) MOVQ "".i+24(SP), AX
0x0048 00072 (go-func-byval.go:13) INCQ AX
0x004b 00075 (go-func-byval.go:13) CMPQ AX, $5
0x004f 00079 (go-func-byval.go:13) JLT 33
Go-оператор превращается в вызов функции runtime.newproc. Механика этого процесса очень интересна, но оставим это для следующей статьи. Сейчас нас больше интересует, что происходит с переменной i. Она сохраняется в регистре AX, который затем передается по значению через стек в функцию foobyval [4] в качестве ее аргумента. «По значению» в данном случае выглядит как копирование значения регистра AX на стек. И изменение AX в дальнейшем не влияет на то, что передано в функцию foobyval.
А вот как выглядит пример 2:
0x0040 00064 (go-func-byref.go:14) LEAQ "".foobyref·f(SB), CX
0x0047 00071 (go-func-byref.go:14) MOVQ CX, 8(SP)
0x004c 00076 (go-func-byref.go:14) MOVQ AX, 16(SP)
0x0051 00081 (go-func-byref.go:14) CALL runtime.newproc(SB)
0x0056 00086 (go-func-byref.go:13) MOVQ "".&i+24(SP), AX
0x005b 00091 (go-func-byref.go:13) INCQ (AX)
0x005e 00094 (go-func-byref.go:13) CMPQ (AX), $5
0x0062 00098 (go-func-byref.go:13) JLT 57
Код очень похож — с одной только, но очень важной, разницей. Сейчас в AX находится адрес i, а не её значение. Заметьте еще, что инкремент и сравнение для цикла делаются над (AX), а не AX. И далее, когда мы положим AX на стек, мы, получается, передаем в функцию адрес i. Изменение (AX) будет видно таким образом и в горутине.
Никаких сюрпризов. В конце концов, мы передаем указатель на число в функцию foobyref.
Во время работы цикл заканчивается быстрее, чем любая из созданных горутин начинает работать. Когда они начнут работать, у них будет указатель на ту самую переменную i, а не на копию. И какое же значение у i в этот момент? Значение 5. То самое, на котором у нас остановился цикл. И вот почему все горутины выводят 5.
Методы со значением VS методы с указателем
Похожее поведение можно наблюдать при создании горутин, которые вызывают какие-либо методы. На это указывает та же самая вики-страничка. Посмотрите на пример 3:
type MyInt int
func (mi MyInt) Show() {
fmt.Println(mi)
}
func main() {
ms := []MyInt{50, 60, 70, 80, 90}
for _, m := range ms {
go m.Show()
}
time.Sleep(100 * time.Millisecond)
}
Данный пример выводит элементы массива ms. В рандомном порядке, как мы и ожидали. Очень похожий пример 4 использует метод с указателем для метода Show:
type MyInt int
func (mi *MyInt) Show() {
fmt.Println(*mi)
}
func main() {
ms := []MyInt{50, 60, 70, 80, 90}
for _, m := range ms {
go m.Show()
}
time.Sleep(100 * time.Millisecond)
}
Попробуйте угадать, какой будет вывод: 90, напечатанное пять раз. Причина такая же, как и в более простом примере 2. Здесь проблема менее заметна из-за синтаксического сахара в Go при использовании методов с указателями. Если в примерах при переходе от примера 1 к примеру 2 мы сменили i на &i, здесь же вызов выглядит одинаково! m.Show() в обоих примерах, а поведение разное.
Не очень счастливое стечение двух особенностей Go, как мне кажется. Ничего в месте вызова не указывает на передачу по ссылке. И вам нужно будет посмотреть на реализацию метода Show, чтобы увидеть, как именно будет происходить вызов (а метод, конечно же, может быть в абсолютно другом файле или пакете).
В большинстве случаев данная фича полезна. Мы пишем более чистый код. Но здесь же передача по ссылке приводит к неожиданным эффектам.
Замыкания
Наконец-то мы подошли к замыканиям. Посмотрим на пример 5:
func foobyval(n int) {
fmt.Println(n)
}
func main() {
for i := 0; i < 5; i++ {
go func() {
foobyval(i)
}()
}
time.Sleep(100 * time.Millisecond)
}
Он напечатает следующее:
5
5
5
5
5
И это несмотря на то, что i передается по значению в foobyval в замыкании. Аналогично примеру 1. Но почему? Давайте посмотрим на ассемблерное представление цикла:
0x0040 00064 (go-closure.go:14) LEAQ "".main.func1·f(SB), CX
0x0047 00071 (go-closure.go:14) MOVQ CX, 8(SP)
0x004c 00076 (go-closure.go:14) MOVQ AX, 16(SP)
0x0051 00081 (go-closure.go:14) CALL runtime.newproc(SB)
0x0056 00086 (go-closure.go:13) MOVQ "".&i+24(SP), AX
0x005b 00091 (go-closure.go:13) INCQ (AX)
0x005e 00094 (go-closure.go:13) CMPQ (AX), $5
0x0062 00098 (go-closure.go:13) JLT 57
Код очень похож на пример 2: заметьте, что i представлен адресом в регистре AX. То есть мы передаем i по ссылке. И это несмотря на то, что вызывается foobyval. Тело цикла вызывает функцию используя runtime.newproc, но откуда берется эта функция?
Func1 создана компилятором, и она представляет собой замыкание. Компилятор выделил код замыкания в отдельную функцию и вызывает ее из main. Основная проблема данного выделения в том, как быть с переменными, которые замыкания используют, но которые явно не являются аргументами.
Вот как выглядит тело func1:
0x0000 00000 (go-closure.go:14) MOVQ (TLS), CX
0x0009 00009 (go-closure.go:14) CMPQ SP, 16(CX)
0x000d 00013 (go-closure.go:14) JLS 56
0x000f 00015 (go-closure.go:14) SUBQ $16, SP
0x0013 00019 (go-closure.go:14) MOVQ BP, 8(SP)
0x0018 00024 (go-closure.go:14) LEAQ 8(SP), BP
0x001d 00029 (go-closure.go:15) MOVQ "".&i+24(SP), AX
0x0022 00034 (go-closure.go:15) MOVQ (AX), AX
0x0025 00037 (go-closure.go:15) MOVQ AX, (SP)
0x0029 00041 (go-closure.go:15) CALL "".foobyval(SB)
0x002e 00046 (go-closure.go:16) MOVQ 8(SP), BP
0x0033 00051 (go-closure.go:16) ADDQ $16, SP
0x0037 00055 (go-closure.go:16) RET
Здесь интересно, что у функции есть аргумент в 24(SP), который является указателем на int: взгляните на строчку MOVQ (AX), AX, которая берет значение, прежде чем передать его в foobyval. По сути func1 выглядит как-то так:
func func1(i *int) {
foobyval(*i)
}
И цикл в main преобразуется в что-то такое:
for i := 0; i < 5; i++ {
go func1(&i)
}
Получили эквивалент примеру 2, и это объясняет полученный вывод. Техническим языком мы бы сказали, что i является свободной переменной внутри замыкания и такие переменные захватываются по ссылке в Go.
Но всегда ли это так? На удивление, ответ “нет”. В некоторых случаях свободные переменные захватываются по значению. Вот вариация нашего примера:
for i := 0; i < 5; i++ {
ii := i
go func() {
foobyval(ii)
}()
}
Этот пример выведет 0, 1, 2, 3, 4 в случайном порядке. Но почему поведение здесь отличается от примера 5?
Оказывается, что данное поведение является артефактом эвристики, которую компилятор Go использует, когда работает с замыканиями.
Смотрим под капот
Если вы не знакомы с архитектурой компилятора Go, я рекомендую вам прочитать мои ранние статьи на эту тему: часть 1, часть 2.
Конкретное (в противовес абстрактному) синтаксическое дерево, которое получается при парсинге кода, выглядит так:
0: *syntax.CallStmt {
. Tok: go
. Call: *syntax.CallExpr {
. . Fun: *syntax.FuncLit {
. . . Type: *syntax.FuncType {
. . . . ParamList: nil
. . . . ResultList: nil
. . . }
. . . Body: *syntax.BlockStmt {
. . . . List: []syntax.Stmt (1 entries) {
. . . . . 0: *syntax.ExprStmt {
. . . . . . X: *syntax.CallExpr {
. . . . . . . Fun: foobyval @ go-closure.go:15:4
. . . . . . . ArgList: []syntax.Expr (1 entries) {
. . . . . . . . 0: i @ go-closure.go:15:13
. . . . . . . }
. . . . . . . HasDots: false
. . . . . . }
. . . . . }
. . . . }
. . . . Rbrace: syntax.Pos {}
. . . }
. . }
. . ArgList: nil
. . HasDots: false
. }
}
Вызываемая функция представлена нодой FuncLit — константной функцией. Когда это дерево будет преобразовано в AST (абстрактное синтаксическое дерево), выделение этой константной функции в отдельно стоящую будет результатом. Это происходит в методе noder.funcLit, что живет в gc/closure.go.
Затем тайп чекер завершает трансформацию, и мы получаем следующее представление для функции в AST:
main.func1:
. DCLFUNC l(14) tc(1) FUNC-func()
. DCLFUNC-body
. . CALLFUNC l(15) tc(1)
. . . NAME-main.foobyval a(true) l(8) x(0) class(PFUNC) tc(1) used FUNC-func(int)
. . CALLFUNC-list
. . . NAME-main.i l(15) x(0) class(PAUTOHEAP) tc(1) used int
Обратите внимание, что передаваемое значение в foobyval — это NAME-main.i, то есть мы явно указываем на переменную из функции, которая оборачивает замыкание.
На данном этапе вступает в работу стадия компилятора по имени capturevars, то есть «захват переменных». Ее цель — решить, как захватить «закрытые переменные» (то есть свободные переменные, используемые в замыканиях). Вот комментарий из соответствующей функции компилятора, который также описывает эвристику:
// capturevars вызывается в отдельной фазе после всех проверок типов.
// Он решает, нужно ли захватывать переменную по значению или по ссылке.
// Мы используем захват по значению для значений <= 128 байт, которые больше не меняют значение после захвата (по сути константы).
Когда capturevars вызывается на примере 5, он решает, что переменная цикла i должна быть захвачена по ссылке, и добавляет соответствующий флаг addrtaken к ней. Это видно в AST выводе:
FOR l(13) tc(1)
. LT l(13) tc(1) bool
. . NAME-main.i a(true) g(1) l(13) x(0) class(PAUTOHEAP) esc(h) tc(1) addrtaken assigned used int
Для переменной цикла не срабатывает эвристика выбора «по значению», так как переменная меняет свое значение после вызова (вспомните цитату из спецификации о том что переменная цикла переиспользуется на каждой итерации). Поэтому переменная i захватывается по ссылке.
В той вариации нашего примера, где у нас есть ii := i, ii не используется больше и поэтому захватывается по значению [5].
Таким образом, мы видим потрясающий пример наложения двух разных фич языка неожиданным образом. Вместо использования новой переменной на каждой итерации цикла, Go переиспользует ту же самую. Это, в свою очередь, ведет к срабатыванию эвристики и выбору захвата по ссылке, а это ведет к неожиданному результату. Go FAQ говорит, что данное поведение, возможно, ошибка при проектировании.
Данное поведение (не использовать новую переменную) — наверное, ошибка при проектировании языка. Возможно мы ее починим в следующих версиях, но из-за обратной совместимости мы не можем ничего сделать в Go версии 1.
Если вы в курсе проблемы, вы, скорее всего, не наступите на эти грабли. Но имейте в виду, что свободные переменные всегда могут быть захвачены по ссылке. Чтобы избежать ошибок, убедитесь, что только read-only переменные захватываются при использовании горутин. Это также важно из-за потенциальных проблем с дата-рейсами.
[1] Некоторые читатели заметили что, строго говоря, не существует понятия «передача по ссылке» в Go, т. к. все передается по значению, в том числе указатели. В данной статье, когда вы видите «передача по ссылке», я имею в виду «передача по адресу» и она в некоторых случаях явная (как например передача &n в функцию, которая ожидает *int), а в некоторых неявная, как в поздних частях статьи.
[2] Здесь и далее я использую time.Sleep как быстрый и грязный способ подождать завершения всех горутин. Без этого main завершится до того, как горутины начнут работать. Правильным способом сделать это было бы использование чего-нибудь типа WaitGroup или done канала.
[3] Ассемблерное представление для всех примеров из данной статьи получено с использованием команды go tool compile -l -S. Флаг -l отключает инлайнинг функций и делает ассемблерный код более читаемым.
[4] Foobyval не вызывется напрямую, так как вызов идет через go. Вместо этого адрес передается как второй аргумент (16(SP)) функции runtime.newproc, а аргументу для foobyval (i в данном случае) идут выше по стеку.
[5] В качестве упражнения добавьте ii = 10 в качестве последней строчки for цикла (после вызова go). Какой вы получили вывод? Почему?
Комментарии (8)
RISENT
26.09.2019 17:37Оказывается, что данное поведение является артефактом эвристики, которую компилятор Go использует, когда работает с замыканиями.
Скорее артифактом спецификации языка, а не эвристики использованной для конкретной реализации компилятора.
HEKET313
28.09.2019 11:30Спасибо за статью. С примером
m.Show()
пока не сталкивался, и это было неожиданно узнать. Я бы на подобный баг мог бы в итоге убить ни один час.
negasus
Прямо отличная статья, спасибо за перевод!
А скажи пожалуйста, где можно почитать базовые вещи по современному ассемблеру, примеры которого есть в статье? Чтобы чуть лучше понимать написанное. А то я последний раз ассемблер трогал в конце девяностых — начале нулевых)
mkevac Автор
Привет! Ассемблер Go отличается от стандартного. Если говорить об обычном, то я бы порекомендовал, наверное, Introduction to 64 Bit Assembly Programming for Linux and OS X: For Linux and OS X. А вот по Go-шному не так много хороших источников. Придется вбивать go assembly в поисковике и читать все, вытаскивая куски информации отовсюду. Увы, лучше ничего не могу посоветовать :-(
mkevac Автор
Можно начать с официальной статьи и с видео о том почему собственно ассемблер отличается. Я делал перевод этого доклада здесь.
negasus
Спасибо. Да, этот перевод доклада видел, вернусь к нему еще)
quasilyte
Добавлю ещё:
Не для начинающих, но может быть полезно и Go-specific:
negasus
Спасибо!