Interface Internals
Интерфейсы в Go — это не просто контракты. Это сложные runtime-структуры с vtable-подобным dispatch, кешированием itab и умным boxing. В этой статье разберём, как именно компилятор и runtime представляют интерфейсы в памяти.
TL;DR
| Структура | Размер | Назначение |
|---|---|---|
| iface | 16 байт (2 указателя, 64-bit) | Интерфейс с методами |
| eface | 16 байт (2 указателя, 64-bit) | Пустой интерфейс any / interface{} |
| itab | 32+ байта | Таблица методов + метаданные типов |
| _type | ~48 байт (порядок, 64-bit) | Type descriptor (размер, hash, методы) |
| Операция | Стоимость |
|---|---|
| Interface assignment | O(1), возможна аллокация для boxing |
| Method dispatch | Indirect call через itab.fun[] (обычно единицы ns) |
| Type assertion (попадание в кеш) | O(1) в среднем, проверка указателя itab |
| Type assertion (промах кеша) | O(1) в среднем, O(n) в худшем случае |
iface vs eface
Go использует две разные структуры для представления интерфейсов:
iface — интерфейс с методами eface — пустой интерфейс (any)
┌──────────────────────┐ ┌──────────────────────┐
│ tab *itab │──────┐ │ _type *_type │──────┐
├──────────────────────┤ │ ├──────────────────────┤ │
│ data unsafe.Pointer │──┐ │ │ data unsafe.Pointer│──┐ │
└──────────────────────┘ │ │ └──────────────────────┘ │ │
│ │ │ │
┌──────────────────┘ │ ┌────────────────────┘ │
│ │ │ │
▼ ▼ ▼ ▼
┌───────┐ ┌───────────┐ ┌───────┐ ┌───────────┐
│ value │ │ itab │ │ value │ │ _type │
│ (T) │ │ (методы) │ │ (T) │ │(type info)│
└───────┘ └───────────┘ └───────┘ └───────────┘Почему две структуры?
ifaceхранит*itab— таблицу методов для конкретной пары (interface type, concrete type)efaceне имеет методов, поэтому хранит только*_type— информацию о типе значения- Оптимизация: для
anyне нужно создавать itab
// runtime/runtime2.go
// Интерфейс с методами: io.Reader, fmt.Stringer, etc.
type iface struct {
tab *itab
data unsafe.Pointer
}
// Пустой интерфейс: any, interface{}
type eface struct {
_type *_type
data unsafe.Pointer
}Когда какая используется
var w io.Writer = os.Stdout // iface: есть метод Write
var r io.Reader = f // iface: есть метод Read
var s fmt.Stringer = t // iface: есть метод String
var x any = 42 // eface: нет методов
var y interface{} = "hello" // eface: нет методовРазница определяется типом слева. Один и тот же объект можно упаковать по-разному:
var buf bytes.Buffer
var a any = &buf // eface: только _type + data
var b io.Writer = &buf // iface: tab + dataiface нужен для методов (адреса лежат в itab), eface — без методов.
iface Memory Layout
Рассмотрим подробнее структуру iface:
Stack Heap / Static
┌─────────────────────┐
│ var w Writer │
│ ┌───────────────┐ │ ┌─────────────────────────┐
│ │ tab *itab │──┼─────▶│ itab │
│ ├───────────────┤ │ │ ┌─────────────────────┐ │
│ │ data *T │──┼──┐ │ │ inter *interfacetype│ │───▶ Writer type info
│ └───────────────┘ │ │ │ ├─────────────────────┤ │
└─────────────────────┘ │ │ │ _type *_type │ │───▶ *os.File type info
│ │ ├─────────────────────┤ │
│ │ │ hash uint32 │ │
│ │ ├─────────────────────┤ │
│ │ │ fun [1]uintptr │ │───▶ (*os.File).Write
│ │ └─────────────────────┘ │
│ └─────────────────────────┘
│
│ ┌────────────────────────┐
└──▶│ *os.File value │
│ (конкретное значение) │
└────────────────────────┘Поле data
data указывает на конкретное значение. Важные нюансы:
// 1. Указатель — data содержит сам указатель (без дополнительной аллокации)
var w io.Writer = &Buffer{} // data = адрес Buffer на heap
// 2. Маленькое значение (≤ pointer size) — может быть inline
var s fmt.Stringer = mySmallStruct{} // data может содержать само значение
// 3. Большое значение — аллокация на heap
var s fmt.Stringer = myBigStruct{} // data = указатель на копию в heapВажно: интерфейс хранит копию значения, а не ссылку (если это не pointer).
type S struct{ n int }
s := S{n: 1}
var i any = s
s.n = 2
fmt.Println(i.(S).n) // 1: в интерфейсе лежит копияПоэтому присваивание в интерфейс может приводить к аллокации: нужна копия.
itab Deep Dive
itab — ключевая структура для method dispatch:
// internal/abi/iface.go
type ITab struct {
Inter *InterfaceType // описание интерфейса
Type *Type // описание конкретного типа
Hash uint32 // копия Type.Hash для быстрого сравнения
_ [4]byte // padding
Fun [1]uintptr // variable-size array указателей на методы
}Структура itab в памяти
itab для (io.Writer, *os.File)
┌─────────────────────────────────────────────────────────┐
│ Inter ───────────▶ interfacetype { │
│ Type: _type для io.Writer │
│ PkgPath: "io" │
│ Methods: [{Name: "Write", ...}] │
│ } │
├─────────────────────────────────────────────────────────┤
│ Type ───────────▶ _type { │
│ Size: 8 │
│ Hash: 0x12345678 │
│ Kind: Ptr │
│ ... │
│ } │
├─────────────────────────────────────────────────────────┤
│ Hash = 0x12345678 (копия Type.Hash) │
├─────────────────────────────────────────────────────────┤
│ Fun[0] = (*os.File).Write ◀── метод #0 │
│ Fun[1] = ... (если бы были другие) │
│ ... │
└─────────────────────────────────────────────────────────┘Fun[] — массив указателей на методы
Размер Fun[] определяется количеством методов в интерфейсе:
// io.Writer имеет 1 метод → Fun[1]
type Writer interface {
Write(p []byte) (n int, err error)
}
// io.ReadWriter имеет 2 метода → Fun[2]
type ReadWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}
// io.ReadWriteCloser имеет 3 метода → Fun[3]
type ReadWriteCloser interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
Close() error
}Компилятор при вызове w.Write заранее знает индекс метода в интерфейсе и генерирует обращение к itab.Fun[idx]. Поэтому порядок методов фиксируется на этапе компиляции и одинаков для всех типов.
Порядок методов в Fun[] определяется алфавитным порядком имён методов:
// io.ReadWriteCloser
// Fun[0] = Close (C < R < W)
// Fun[1] = Read
// Fun[2] = Writeitab Caching
Создание itab требует поиска методов — O(n×m) операция. Go кеширует все созданные itab в глобальной hash table.
Почему это важно: повторный поиск методов дорог, а itab переиспользуется для пары (interface type, concrete type).
itab lookup flow
┌──────────────┐
│ getitab() │
└──────┬───────┘
│ hash
▼
[таблица itab]
│ hit? │
├──────┼───────────────┐
│ yes │ return itab │
│ no │ createItab() │
└──────┴───────┬───────┘
│ insert
▼
return itabHash Table структура
// runtime/iface.go
const itabInitSize = 512
// Глобальная таблица itab
var (
itabLock mutex
itabTable = &itabTableType{
size: itabInitSize,
count: 0,
entries: make([]unsafe.Pointer, itabInitSize),
}
)itab Hash Table
┌────────────────────────────────────────────────────────────┐
│ size: 512 count: 47 │
├────────────────────────────────────────────────────────────┤
│ entries[] │
│ ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ ... │ 511 │ │
│ │ nil │ itab │ nil │ itab │ nil │ itab │ │ nil │ │
│ │ │ ↓ │ │ ↓ │ │ ↓ │ │ │ │
│ └──────┴──┼───┴──────┴──┼───┴──────┴──┼───┴──────┴──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ itab │ │ itab │ │ itab │ │
│ │Writer │ │Reader │ │Stringer │
│ │*File │ │*Buffer│ │myType │ │
│ └───────┘ └───────┘ └───────┘ │
└────────────────────────────────────────────────────────────┘Lookup алгоритм
// runtime/iface.go (упрощённо)
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
// Вычисляем hash из пары (inter, typ)
h := itabHashFunc(inter, typ) % len(itabTable.entries)
// Ищем в таблице (linear probing при коллизиях)
for {
p := atomic.Loadp(&itabTable.entries[h])
if p == nil {
break // не найден
}
t := (*itab)(p)
if t.Inter == inter && t.Type == typ {
return t // найден!
}
h = (h + 1) % len(itabTable.entries)
}
// Не найден — создаём новый
return createItab(inter, typ)
}Упрощённо:
- Посчитать hash пары (interface type, concrete type)
- Linear probing при коллизиях
- Если не найдено → создать itab
Средний случай — O(1), потому что таблица держится с невысокой загрузкой.
Создание itab
func createItab(inter *interfacetype, typ *_type) *itab {
// 1. Аллоцируем itab (размер зависит от количества методов)
m := &itab{
Inter: inter,
Type: typ,
Hash: typ.Hash,
}
// 2. Заполняем Fun[] — ищем методы типа по именам из интерфейса
methods := typ.Methods()
for i, im := range inter.Methods {
// Бинарный поиск метода в отсортированном списке методов типа
j := sort.Search(len(methods), func(j int) bool {
return methods[j].Name >= im.Name
})
if j < len(methods) && methods[j].Name == im.Name {
m.Fun[i] = methods[j].Fn
} else {
// Метод не найден — тип не реализует интерфейс
return nil
}
}
// 3. Добавляем в кеш
itabAdd(m)
return m
}На этом шаге происходит проверка реализации интерфейса: если метод отсутствует, создание itab завершается ошибкой. Это то место, где runtime решает, что тип не реализует интерфейс при assertion или при приведении.
itab Table Growth
При заполнении таблицы на ~75% происходит рост:
func itabAdd(m *itab) {
if itabTable.count >= itabTable.size * 3 / 4 {
// Удваиваем размер таблицы
grow := itabTable.size * 2
newEntries := make([]unsafe.Pointer, grow)
// Рехешируем все существующие itab
for _, p := range itabTable.entries {
if p != nil {
h := itabHashFunc((*itab)(p).Inter, (*itab)(p).Type) % grow
// ... вставляем в новую позицию
}
}
itabTable.entries = newEntries
itabTable.size = grow
}
// Вставляем новый itab
h := itabHashFunc(m.Inter, m.Type) % itabTable.size
// ... linear probing для поиска свободного слота
atomic.StorepNoWB(&itabTable.entries[h], unsafe.Pointer(m))
itabTable.count++
}Рост уменьшает коллизии и держит поиск в среднем O(1); дорогие вставки только при расширении.
_type Structure (Type Descriptor)
_type содержит всю информацию о типе, необходимую runtime:
// internal/abi/type.go
type Type struct {
Size_ uintptr // размер значения типа в байтах
PtrBytes uintptr // размер prefix с указателями
Hash uint32 // hash типа для быстрого сравнения
TFlag TFlag // флаги (extraStar, named, etc.)
Align_ uint8 // выравнивание значения
FieldAlign_ uint8 // выравнивание поля структуры
Kind_ Kind // вид типа (Int, Struct, Ptr, etc.)
// ...
}Ключевые поля:
Size_,Align_,FieldAlign_— размещение и копированиеPtrBytes— сколько байт содержат указатели (важно для GC)Hash,Kind_— сравнение, type switch, reflect
Type Kinds
const (
Invalid Kind = iota
Bool
Int
Int8
Int16
Int32
Int64
Uint
// ... числовые типы
String
UnsafePointer
Array
Chan
Func
Interface
Map
Pointer
Slice
Struct
)Kind — быстрый категоризатор типа для ветвлений runtime.
_type для разных типов
_type для int _type для *os.File
┌─────────────────────┐ ┌─────────────────────┐
│ Size_: 8 │ │ Size_: 8 │
│ Hash: 0x... │ │ Hash: 0x... │
│ Kind_: Int │ │ Kind_: Pointer │
│ Align_: 8 │ │ Elem: ───────────▶ _type для os.File
└─────────────────────┘ └─────────────────────┘
_type для []byte _type для struct{x int; y string}
┌─────────────────────┐ ┌──────────────────────────────────┐
│ Size_: 24 │ │ Size_: 24 │
│ Kind_: Slice │ │ Kind_: Struct │
│ Elem: ───────────▶ │ │ Fields: [ │
│ _type для uint8 │ │ {Name: "x", Type: *int, ...} │
└─────────────────────┘ │ {Name: "y", Type: *string, ...}│
│ ] │
└──────────────────────────────────┘Составные типы ссылаются на другие _type:
Pointerхранит ссылку наElemSliceхранитElemи размер заголовкаStructхранит массив полей (имя, тип, offset)
Эта информация нужна для reflect, сравнения и GC‑сканирования.
Interface Value Boxing
При присваивании значения интерфейсу происходит "boxing" — упаковка значения:
convT функции
Runtime имеет оптимизированные функции для boxing распространённых типов:
// runtime/iface.go
func convT(t *_type, v unsafe.Pointer) unsafe.Pointer // generic
func convTnoptr(t *_type, v unsafe.Pointer) unsafe.Pointer // без указателей
func convT16(val uint16) unsafe.Pointer
func convT32(val uint32) unsafe.Pointer
func convT64(val uint64) unsafe.Pointer
func convTstring(val string) unsafe.Pointer
func convTslice(val []byte) unsafe.PointerИдея: для частых типов runtime упаковывает значение быстрее, чем общий convT.
Boxing сценарии
// 1. Указатель — без аллокации, data = сам указатель
var w io.Writer = &buf // data = &buf
// 2. Маленькие значения — оптимизация "direct iface"
// Если значение помещается в указатель и не содержит указателей:
var x any = int64(42) // может быть без аллокации
// 3. Нулевые значения — статический zeroVal
var x any = 0 // data указывает на runtime.zeroVal
var y any = "" // data указывает на runtime.zeroVal
var z any = false // data указывает на runtime.zeroVal
// 4. Большие или со ссылками — аллокация на heap
var s any = MyStruct{} // копия на heapПрактически: boxing копирует значение и может аллоцировать в циклах.
convT64 пример
// runtime/iface.go
func convT64(val uint64) unsafe.Pointer {
// Оптимизация: статический пул для маленьких значений
if val < uint64(len(staticuint64s)) {
return unsafe.Pointer(&staticuint64s[val])
}
// Иначе аллокация
p := mallocgc(8, uint64Type, false)
*(*uint64)(p) = val
return p
}
// Статический пул для значений 0-255
var staticuint64s = [256]uint64{0, 1, 2, 3, ..., 255}Смысл: маленькие числовые значения можно хранить в статическом массиве без аллокации.
Direct Interface Optimization
Для типов, значение которых помещается в указатель:
// Компилятор проверяет:
// 1. Size <= PtrSize (8 байт на 64-bit)
// 2. Тип не содержит указателей (чтобы GC не сканировал)
// Direct iface возможен для:
// - int8, int16, int32, int64
// - uint8, uint16, uint32, uint64
// - float32, float64
// - bool
// - маленькие struct без указателей: struct{a, b int32}
type tinyStruct struct {
a int32
b int32
}
var x any = tinyStruct{1, 2}
// x.data содержит само значение (packed в 8 байт)
// НЕТ аллокации на heap!Это работает только для pointer-free значений. Если есть указатели — нужна отдельная аллокация, иначе GC не увидит ссылки.
Method Dispatch
Вызов метода через интерфейс — это indirect call через itab.fun[]:
var w io.Writer = &buf
w.Write(data) // Как это работает?Шаги dispatch:
- Берём
w.tab(itab) - Достаём адрес нужного метода из
itab.Fun[i] - Передаём
w.dataкак receiver - Делаем indirect call
Скомпилированный код (псевдо-asm)
; w.Write(data)
; 1. Загрузить itab из iface
MOVQ w+0(SP), AX ; AX = w.tab (*itab)
; 2. Проверка на nil (panic если nil)
TESTQ AX, AX
JZ panic_nil_interface
; 3. Загрузить адрес метода из itab.fun[0]
MOVQ 24(AX), CX ; CX = itab.fun[0] = (*T).Write
; 4. Загрузить data (receiver)
MOVQ w+8(SP), DX ; DX = w.data
; 5. Вызвать метод
CALL CX ; (*T).Write(DX, data)Важно: если w = nil интерфейс, то tab будет nil и произойдёт panic ещё до вызова метода.
Стоимость dispatch
Direct call: CALL target ; 1 инструкция
Interface call: MOVQ (iface), AX ; загрузить itab
MOVQ 24(AX), CX ; загрузить fun[i]
CALL CX ; indirect callOverhead:
- 2 дополнительных load из памяти
- Indirect call менее предсказуем для CPU branch predictor
- Но современные CPU хорошо справляются с предсказанием indirect calls
На практике это означает: overhead заметен в tight loops, но в IO‑коде часто теряется на фоне системных вызовов.
Interface Memory Layout
Interface Comparison
Интерфейсы можно сравнивать через ==:
var a, b io.Reader
a = &buf1
b = &buf2
// Сравнение интерфейсов
if a == b { ... }Алгоритм сравнения
// runtime/iface.go
func ifaceeq(t *_type, x, y unsafe.Pointer) bool {
if t == nil {
// Оба nil
return true
}
// Используем equal function из type descriptor
return t.Equal(x, y)
}Сравнение интерфейсов делает две вещи:
- Сравнивает динамические типы
- Если типы равны — сравнивает значения через функцию
Equalу_type
Правила:
- Оба nil → равны
- Один nil, другой нет → не равны
- Разные dynamic types → не равны
- Одинаковые types → сравниваем значения через
_type.Equal
Panic при сравнении
type MySlice []int
var a, b any
a = MySlice{1, 2}
b = MySlice{1, 2}
if a == b { } // 💥 PANIC: comparing uncomparable type MySliceSlices, maps, functions — нельзя сравнивать, даже через интерфейс.
Причина: для таких типов нет операции ==, поэтому Equal не определён — runtime падает.
Производительность
Escape Analysis влияние
// Не escape — аллокация на стеке (быстро)
func process(r io.Reader) {
// r используется только локально
buf := make([]byte, 1024)
r.Read(buf)
}
// Escape — аллокация на heap (медленнее)
func getReader() io.Reader {
buf := &bytes.Buffer{} // escape на heap
return buf // сохраняется в интерфейс
}Почему это происходит: когда значение возвращается как интерфейс, компилятор часто вынужден переместить его в heap, чтобы интерфейс хранил стабильный адрес.
Devirtualization
Компилятор может убрать indirect call, если знает конкретный тип:
func process(w io.Writer) {
w.Write(data)
}
// Инлайнинг + devirtualization:
func main() {
var buf bytes.Buffer
process(&buf) // компилятор может инлайнить Write напрямую
}Если компилятор видит конкретный тип в месте вызова, он может превратить w.Write в прямой вызов и убрать overhead интерфейса.
Benchmark: interface vs direct call
func BenchmarkDirectCall(b *testing.B) {
buf := &bytes.Buffer{}
data := []byte("hello")
for i := 0; i < b.N; i++ {
buf.Write(data) // direct call
}
}
func BenchmarkInterfaceCall(b *testing.B) {
var w io.Writer = &bytes.Buffer{}
data := []byte("hello")
for i := 0; i < b.N; i++ {
w.Write(data) // interface call
}
}
// Результаты (примерные):
// BenchmarkDirectCall-8 100000000 11.2 ns/op
// BenchmarkInterfaceCall-8 100000000 12.8 ns/op
// Разница в этом примере: ~15%
// Цифры зависят от CPU, версии Go и оптимизаций компилятораТакие бенчмарки полезны как ориентир, но всегда измеряйте свои реальные hot paths.
Файлы runtime/
| Файл | Назначение |
|---|---|
runtime/iface.go | Операции с интерфейсами: getitab, convT*, assertI2I |
runtime/runtime2.go | Определения iface, eface, itab |
internal/abi/iface.go | ITab структура (экспортируемая) |
internal/abi/type.go | _type и связанные структуры |
cmd/compile/internal/ir/func.go | Генерация method dispatch кода |
Выводы
iface vs eface — два разных представления для оптимизации пустого интерфейса
itab — центральная структура для method dispatch, кешируется глобально
Boxing имеет оптимизации для маленьких значений и статический пул для частых значений
Method dispatch — indirect call через itab.fun[], overhead обычно небольшой, измеряйте в своём коде
Escape analysis критична — присваивание интерфейсу часто вызывает escape на heap