
Сегодня я решил перевести для вас небольшую статью о внутренностях реализации так называемых замыканий или 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). Какой вы получили вывод? Почему?
