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


  1. negasus
    26.09.2019 14:36

    Прямо отличная статья, спасибо за перевод!

    А скажи пожалуйста, где можно почитать базовые вещи по современному ассемблеру, примеры которого есть в статье? Чтобы чуть лучше понимать написанное. А то я последний раз ассемблер трогал в конце девяностых — начале нулевых)


    1. mkevac Автор
      26.09.2019 14:42
      +3

      Привет! Ассемблер Go отличается от стандартного. Если говорить об обычном, то я бы порекомендовал, наверное, Introduction to 64 Bit Assembly Programming for Linux and OS X: For Linux and OS X. А вот по Go-шному не так много хороших источников. Придется вбивать go assembly в поисковике и читать все, вытаскивая куски информации отовсюду. Увы, лучше ничего не могу посоветовать :-(


      1. mkevac Автор
        26.09.2019 14:51
        +1

        Можно начать с официальной статьи и с видео о том почему собственно ассемблер отличается. Я делал перевод этого доклада здесь.


        1. negasus
          26.09.2019 16:02

          Спасибо. Да, этот перевод доклада видел, вернусь к нему еще)


    1. quasilyte
      26.09.2019 15:54
      +1

      Добавлю ещё:



      Не для начинающих, но может быть полезно и Go-specific:



      1. negasus
        26.09.2019 16:03

        Спасибо!


  1. RISENT
    26.09.2019 17:37

    Оказывается, что данное поведение является артефактом эвристики, которую компилятор Go использует, когда работает с замыканиями.

    Скорее артифактом спецификации языка, а не эвристики использованной для конкретной реализации компилятора.


  1. HEKET313
    28.09.2019 11:30

    Спасибо за статью. С примером m.Show() пока не сталкивался, и это было неожиданно узнать. Я бы на подобный баг мог бы в итоге убить ни один час.