История: До Go 1.18 язык не имел параметрического полиморфизма, принуждая разработчиков выбирать между interface{} (что приводит к аллокациям в куче и накладным расходам на упаковку) или генерацией кода (что приводит к увеличению размера бинарного файла). При проектировании обобщений команда Go сознательно отвергла модель шаблонов C++ полной мономорфизации, при которой каждая отдельная типизация производит дублирующий машинный код, из-за опасений по поводу взрыва размера бинарного файла в крупных облачных приложениях, связывающих тысячи пакетов.
Проблема: Чистая мономорфизация генерировала бы отдельные блоки сборки для Process[int] и Process[uint], несмотря на то, что оба являются 64-битными целыми числами, что приводило бы к растрате кеша инструкций и дискового пространства. В то же время реализация обобщений через упаковку (как в Java) заставила бы типы значений попасть в кучу, что уничтожает характеристики нулевых аллокаций, имеющие важное значение для нишевого системного программирования Go. Проблема заключалась в том, чтобы сохранить безопасность типов на этапе компиляции и семантику значений без затрат при этом избегая проблемы дублирования кода N раз.
Решение: Go использует трафареты форм GC в сочетании со словари времени выполнения. Компилятор группирует типы по форме GC, определяемой размерами, выравниванием и битовой картой указателей, а не по точной идентичности типов. Типы с идентичными макетами в памяти (например, []int и []string, оба являясь заголовочными структурами с указателем, длиной и емкостью) делят один и тот же трафарет машинного кода. Для операций, специфичных для типов, таких как вызов методов или утверждения типов, компилятор передает скрытый словарь времени выполнения, содержащий смещения метаданных. Это гарантирует, что Point{X:1, Y:2} и Vector{X:1, Y:2} разделяют код, при этом сохраняя типы значений в стеке.
Мы разрабатывали высокопроизводительный колоночный движок хранения, требующий реализации обобщенного SkipList для индексирования как int64 временных меток, так и пользовательских Decimal128 структур (16 байтов, два поля uint64). Первоначальные тесты с использованием interface{} показали, что 35% времени ЦПУ уходит на аллокации памяти в куче и индирекцию интерфейса, что неприемлемо для наших требований по задержке менее микросекунды.
Мы рассмотрели три архитектурных подхода. Первым была полная мономорфизация с использованием go generate и text/template для создания специализированных реализаций SkipListInt64 и SkipListDecimal. Это устранило аллокации, но увеличило размер нашего бинарного файла на 22 МБ при поддержке двенадцати различных числовых типов, что нарушило наши ограничения по безсерверной развертке. Вторым подходом была унифицированная реализация с использованием unsafe.Pointer и рефлексии для ручного управления памятью. Это сохранило минимальный размер бинарного файла, но ввело катастрофическую сложность, требуя ручной арифметики указателей, что нарушило инварианты сборщика мусора Go во время тестирования.
Мы выбрали третий подход: оригинальные обобщения Go с тщательным вниманием к группировке форм GC. Мы выровняли нашу структуру Decimal128, чтобы она соответствовала макету памяти [2]uint64, гарантируя, что она делит трафаретный код с другими 16-байтовыми типами значений. Анализируя вывод компилятора с помощью go tool objdump, мы подтвердили, что SkipList[int64] и SkipList[uint64] используют идентичные блоки сборки, в то время как SkipList[string] корректно использует отдельный трафарет из-за своей битовой карты, содержащей указатели. Этот гибридный подход снизил размер бинарного файла на 58% по сравнению с генерацией кода, сохраняя при этом производительность с нулевыми аллокациями. Результатом стало улучшение задержки в 4 раза по сравнению с версией interface{} и размер бинарного файла менее 30 МБ.
Почему два разных типа структуры с одинаковыми типами полей иногда генерируют отдельные обобщенные инстанциации, в то время как структура и псевдоним типа примитива могут делить код?
Это происходит потому, что группировка форм GC зависит от полного дескриптора типа времени выполнения, включая битовые карты указателей и выравнивание, а не просто от поверхностных типов полей. Если type A struct { x, y int } и type B struct { x, y int } определены в разных пакетах, они делят одну и ту же форму GC и трафарет. Однако *type C struct { x int; y int } имеет другую битовую карту указателей, чем type D struct { x, y int }, что вынуждает генерировать отдельный машинный код. Напротив, type MyInt int и int делят формы, но struct { _ int; x int } и struct { x int } могут различаться из-за выравнивания. Понимание того, что сборщик мусора требует точных карт стека для каждой живой переменной, объясняет, почему идентичность макета важнее номинальной идентичности типа.
Как вызов метода с обобщенными параметрами типа отличается от прямых вызовов для конкретных типов и почему этот накладной расход неизбежен без полной мономорфизации?
При вызове метода для обобщенного параметра типа T компилятор генерирует косвенный вызов через словарь времени выполнения, а не прямой адрес функции. В отличие от вызовов интерфейса, которые разрешают методы через itab во время выполнения, записи обобщенного словаря разрешаются на этапе компиляции, но передаются как скрытые параметры. Это вводит один уровень индирекции (обычно 2-5 наносекунд) по сравнению с бесстоимостным мономорфизированным кодом. Кандидаты часто предполагают, что обобщения полностью без накладных расходов по сравнению с ручным специализированным кодом; на самом деле поиск в словаре предотвращает определенные оптимизации инлайнинга, которые позволила бы мономорфизация, хотя это остается порядками быстрее, чем reflect.Value.Call.
Почему инстанцирование обобщенного типа с пустым идентификатором поля (например, struct { _ int64; x int64 }) потенциально может заставить компилятор сгенерировать уникальный трафарет, увеличивая размер бинарного файла?
Пустые поля занимают место и вносят вклад в битовую карту указателей структуры, даже будучи безымянными, что потенциально изменяет форму GC. struct { _ int64; x int64 } имеет другой размер и выравнивание, чем struct { x int64 } на некоторых архитектурах, заставляя компилятор назначить ее в отдельную группу трафаретов. Более того, если пустое поле является указателем (**_ int*), это изменяет требования сборщика мусора к трассировке для этого типа, требуя отдельных карт стека. Разработчики, оптимизирующие размер бинарного файла, должны понимать, что форма GC определяется полным макетом памяти, включая выравнивание и пустые поля, а не только семантически релевантными членами данных.