Статьи

Golang Internals (часть 2): погружение в компилятор Go

[Это сообщение в блоге написано Сергеем Матюкевичем. Другие части серии:  Часть 1  | Часть 2  |  Часть 3 ]

Знаете ли вы, что именно происходит во время выполнения Go, когда вы используете переменную через ссылку на интерфейс? Это не тривиальный вопрос, потому что в Go тип, реализующий интерфейс, вообще не содержит ссылок на этот интерфейс. Тем не менее, мы можем попытаться ответить на него, используя наши знания компилятора Go, который обсуждался в предыдущем посте.

Итак, давайте углубимся в компилятор Go: создадим простую программу Go и посмотрим внутреннюю работу приведения типов в Go. Используя его в качестве примера, я объясню, как генерируется и используется дерево узлов. Таким образом, вы можете в дальнейшем применить эти знания к функциям других компиляторов Go.

Прежде чем ты начнешь

Для проведения эксперимента нам нужно будет работать напрямую с компилятором Go (а не с инструментом Go). Вы можете получить к нему доступ с помощью команды:

go tool 6g test.go

Он скомпилирует исходный файл test.go и создаст объектный файл. Здесь 6g — это имя компилятора на моей машине с архитектурой AMD64. Обратите внимание, что вы должны использовать разные компиляторы для разных архитектур. Когда мы работаем напрямую с компилятором, мы можем использовать несколько удобных аргументов командной строки (более подробно здесь ). Для целей этого эксперимента нам понадобится флаг -W, который будет печатать макет дерева узлов.

Создание простой программы Go

Прежде всего, мы собираемся создать пример программы Go. Что-то вроде этого:

  package main

  type I interface {
          DoSomeWork()
  }
 
  type T struct {
          a int
  }
 
  func (t *T) DoSomeWork() {
  }
 
  func main() {
          t := &T{}
          i := I(t)
          print(i)
  }

Действительно просто, не правда ли? Единственное, что может показаться ненужным, это 17-я строка, где мы печатаем переменную i . Тем не менее, без него я останусь неиспользованным и программа не будет скомпилирована. Следующим шагом является компиляция нашей программы с помощью ключа -W:

go tool 6g -W test.go

После этого вы увидите выходные данные, которые содержат деревья узлов для каждого метода, определенного в программе. В нашем случае это основной метод и метод init . Метод init здесь, потому что он неявно определен для всех программ, но на самом деле нас это сейчас не волнует. Для каждого метода компилятор печатает две версии дерева узлов. Первый — это исходное дерево узлов, которое мы получаем после анализа исходного файла. Вторая версия — это версия, которую мы получаем после проверки типа и применения всех необходимых изменений.

Понимание дерева узлов основного метода

Давайте подробнее рассмотрим исходную версию дерева узлов из метода main и попытаемся понять, что именно происходит.

DCL l(15)
.   NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T

AS l(15) colas(1) tc(1)
.   NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T
.   PTRLIT l(15) esc(no) ld(1) tc(1) PTR64-*main.T
.   .   STRUCTLIT l(15) tc(1) main.T
.   .   .   TYPE <S> l(15) tc(1) implicit(1) type=PTR64-*main.T PTR64-*main.T

DCL l(16)
.   NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I

AS l(16) tc(1)
.   NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T
.   NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T

AS l(16) colas(1) tc(1)
.   NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I
.   CONVIFACE l(16) tc(1) main.I
.   .   NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T

VARKILL l(16) tc(1)
.   NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T

PRINT l(17) tc(1)
PRINT-list
.   NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I

В объяснении ниже я буду использовать сокращенную версию, из которой я удалил все ненужные детали. Первый узел довольно прост:

DCL l(15)
.   NAME-main.t l(15) PTR64-*main.T

Первый узел является узлом объявления. l (15) говорит нам, что этот узел определен в строке 15. Узел объявления ссылается на узел имени, который представляет переменную main.t. Эта переменная определена в основном пакете и на самом деле является 64-битным указателем на тип main.T. Вы можете взглянуть на строку 15 и легко понять, какое объявление там представлено. Следующий немного сложнее.

AS l(15) 
.   NAME-main.t l(15) PTR64-*main.T
.   PTRLIT l(15) PTR64-*main.T
.   .   STRUCTLIT l(15) main.T
.   .   .   TYPE l(15) type=PTR64-*main.T PTR64-*main.T

Корневой узел является узлом назначения. Его первый дочерний элемент — это узел имени, представляющий переменную main.t. Второй дочерний элемент — это узел, который мы назначаем main.t — литеральный узел указателя (&). У него есть дочерний литерал структуры, который, в свою очередь, указывает на узел типа, представляющий фактический тип ( main.T ). Следующий узел — это другое объявление. На этот раз это объявление переменной main.i, которая принадлежит типу main.I.

DCL l(16)
.   NAME-main.i l(16) main.I

Затем компилятор создает другую переменную autotmp_0000 и назначает ей переменную main.t.

AS l(16) tc(1)
.   NAME-main.autotmp_0000 l(16) PTR64-*main.T
.   NAME-main.t l(15) PTR64-*main.T

Наконец, мы подошли к узлам, в которых мы на самом деле пересекались.

AS l(16) 
.   NAME-main.i l(16)main.I
.   CONVIFACE l(16) main.I
.   .   NAME-main.autotmp_0000 PTR64-*main.T

Здесь мы можем видеть , что компилятор назначил специальный узел , называемый CONVIFACE к main.i переменной. Но это не дает нам много информации о том, что происходит под капотом. Чтобы выяснить, что происходит, нам нужно заглянуть в дерево узлов основного метода после того, как все модификации дерева узлов были применены (вы можете найти эту информацию в разделе «after walk main» вашего вывода).  

Как компилятор переводит узел присваивания

Ниже вы можете увидеть, как компилятор переводит наш узел присваивания:

AS-init
.   AS l(16) 
.   .   NAME-main.autotmp_0003 l(16) PTR64-*uint8
.   .   NAME-go.itab.*"".T."".I l(16) PTR64-*uint8

.   IF l(16) 
.   IF-test
.   .   EQ l(16) bool
.   .   .   NAME-main.autotmp_0003 l(16) PTR64-*uint8
.   .   .   LITERAL-nil I(16) PTR64-*uint8
.   IF-body
.   .   AS l(16)
.   .   .   NAME-main.autotmp_0003 l(16) PTR64-*uint8
.   .   .   CALLFUNC l(16) PTR64-*byte
.   .   .   .   NAME-runtime.typ2Itab l(2) FUNC-funcSTRUCT-(FIELD-
.   .   .   .   .   NAME-runtime.typ·2 l(2) PTR64-*byte, FIELD-
.   .   .   .   .   NAME-runtime.typ2·3 l(2) PTR64-*byte PTR64-*byte, FIELD-
.   .   .   .   .   NAME-runtime.cache·4 l(2) PTR64-*PTR64-*byte PTR64-*PTR64-*byte) PTR64-*byte
.   .   .   CALLFUNC-list
.   .   .   .   AS l(16) 
.   .   .   .   .   INDREG-SP l(16) runtime.typ·2 G0 PTR64-*byte
.   .   .   .   .   ADDR l(16) PTR64-*uint8
.   .   .   .   .   .   NAME-type.*"".T l(11) uint8

.   .   .   .   AS l(16)
.   .   .   .   .   INDREG-SP l(16) runtime.typ2·3 G0 PTR64-*byte
.   .   .   .   .   ADDR l(16) PTR64-*uint8
.   .   .   .   .   .   NAME-type."".I l(16) uint8

.   .   .   .   AS l(16) 
.   .   .   .   .   INDREG-SP l(16) runtime.cache·4 G0 PTR64-*PTR64-*byte
.   .   .   .   .   ADDR l(16) PTR64-*PTR64-*uint8
.   .   .   .   .   .   NAME-go.itab.*"".T."".I l(16) PTR64-*uint8
AS l(16) 
.   NAME-main.i l(16) main.I
.   EFACE l(16) main.I
.   .   NAME-main.autotmp_0003 l(16) PTR64-*uint8
.   .   NAME-main.autotmp_0000 l(16) PTR64-*main.T

Как видно из выходных данных, компилятор сначала добавляет список узлов инициализации ( AS-init ) к узлу назначения. Внутри АС-инициализации узла, он создает новую переменную, main.autotmp_0003 и присваивает значение go.itab. * «». Т «». Я переменной к нему. После этого он проверяет, равна ли эта переменная нулю. Если переменная равна nil, компилятор вызывает функцию runtime.typ2Itab и передает ей следующее: указатель на тип main.T , указатель на тип интерфейса main.I и указатель на go.itab. * «.T.» «. Я переменная. Из этого кодасовершенно очевидно, что эта переменная предназначена для кэширования результата преобразования типа изmain.T к main.I .

Внутри метода getitab

Следующий логический шаг — найти runtime.typ2Itab . Ниже приведен список этой функции:

func typ2Itab(t *_type, inter *interfacetype, cache **itab) *itab {
	tab := getitab(inter, t, false)
	atomicstorep(unsafe.Pointer(cache), unsafe.Pointer(tab))
	return tab
}

Совершенно очевидно, что фактическая работа выполняется внутри метода getitab , потому что вторая строка просто хранит созданную переменную табуляции в кэше. Итак, давайте посмотрим внутрь getitab . Поскольку он довольно большой, я скопировал только самую ценную часть.

m = 
    (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0,
    &memstats.other_sys))
    m.inter = interm._type = typ

ni := len(inter.mhdr)
nt := len(x.mhdr)
j := 0
for k := 0; k < ni; k++ {
	i := &inter.mhdr[k]
	iname := i.name
	ipkgpath := i.pkgpath
	itype := i._type
	for ; j < nt; j++ {
		t := &x.mhdr[j]
		if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath {
			if m != nil {
				*(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn
			}
		}
	}
}

Сначала мы выделяем память для результата:

(*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys))

Почему мы должны выделять память в Go и почему это делается таким странным образом? Чтобы ответить на этот вопрос, нам нужно взглянуть на определение структуры itab .

type itab struct {
	inter  *interfacetype
	_type  *_type
	link   *itab
	bad    int32
	unused int32
	fun    [1]uintptr // variable sized
}

Последнее свойство, fun , определяется как массив из одного элемента, но на самом деле оно имеет переменный размер. Позже мы увидим, что это свойство содержит массив указателей на методы, определенные в определенном типе. Эти методы соответствуют методам в типе интерфейса. Авторы Go используют динамическое выделение памяти для этого свойства (да, такие вещи возможны, когда вы используете небезопасный пакет). Объем выделяемой памяти рассчитывается путем добавления размера самой структуры к числу методов в интерфейсе, умноженному на размер указателя.

unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize

Далее вы можете увидеть два вложенных цикла. Сначала мы перебираем все методы интерфейса. Для каждого метода в интерфейсе мы пытаемся найти соответствующий метод в определенном типе (методы хранятся в коллекции mhdr ). Процесс проверки того, равны ли два метода, совершенно очевиден.

if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath

Если мы находим совпадение, мы сохраняем указатель на метод в свойстве fun результата:

*(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn

Небольшое замечание о производительности: поскольку методы сортируются в алфавитном порядке для интерфейса и заданных определений типов, этот вложенный цикл может повторяться O (n + m) раз вместо O (n * m) раз, где n и m соответствуют числу методов. Наконец, вы помните последнюю часть задания?

AS l(16) 
.   NAME-main.i l(16) main.I
.   EFACE l(16) main.I
.   .   NAME-main.autotmp_0003 l(16) PTR64-*uint8
.   .   NAME-main.autotmp_0000 l(16) PTR64-*main.T

Здесь мы относим EFACE узел к переменной main.i. Этот узел ( EFACE ) содержит ссылки на переменную main.autotmp_0003 — указатель на структуру itab, возвращенную методом runtime.typ2Itab, — и на переменную autotmp_0000 , содержащую то же значение, что и переменная main.t. Это все, что нам нужно для вызова методов по интерфейсным ссылкам. Итак, переменная main.i содержит экземпляр структуры iface, определенной в пакете времени выполнения:

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

Что дальше?

Я понимаю, что пока что рассмотрел только очень небольшую часть компилятора Go и среды выполнения Go. Есть еще много интересных вещей для обсуждения, таких как объектные файлы, компоновщик, перемещения и т. Д. — они будут рассмотрены в следующих публикациях в блоге.

Читать все части серии: Часть 1 | Часть 2 |  Часть 3 

Об авторе: Сергей Матюкевич — облачный инженер и разработчик Go в Altoros . С более чем 6-летним опытом в разработке программного обеспечения, он является экспертом в области облачной автоматизации и проектирования архитектур для сложных облачных систем. Активный участник сообщества Go, Сергей часто участвует в проектах с открытым исходным кодом, таких как Ubuntu и Juju Charms.