Go 인터페이스의 내부 동작을 다루며, 함수 및 메서드 호출, 인터페이스의 구성, 동적 디스패치, 특수 사례, 인터페이스 합성, 타입 단언의 비용과 구현을 설명합니다.
$ go version
go version go1.10 linux/amd64
이 챕터에서는 Go 인터페이스의 내부 동작을 다룹니다.
구체적으로는 다음을 살펴봅니다:
더 깊이 파고들면서, 현대 CPU의 일부 구현 세부사항과 Go 컴파일러가 사용하는 다양한 최적화 기법 등 여러 저수준 주제도 함께 건드릴 것입니다.
목차
linux/amd64를 기준으로 생각하세요.이 챕터의 끝부분에 링크된 함수 호출에 관한 설계 문서에서 Russ Cox가 지적했듯, Go에는 다음이 있습니다..:
..4가지 서로 다른 함수 종류..:
- 최상위 func
- 값 리시버를 가지는 메서드
- 포인터 리시버를 가지는 메서드
- func 리터럴
..그리고 5가지 서로 다른 호출 종류:
- 최상위 func의 직접 호출 (
func TopLevel(x int) {})- 값 리시버 메서드의 직접 호출 (
func (Value) M(int) {})- 포인터 리시버 메서드의 직접 호출 (
func (*Pointer) M(int) {})- 인터페이스 위에서의 메서드 간접 호출 (
type Interface interface { M(int) })- func 값의 간접 호출 (
var literal = func(x int) {})
이들을 섞으면 함수와 호출 유형의 가능한 조합은 총 10가지가 됩니다:
- 최상위 func의 직접 호출 /
- 값 리시버 메서드의 직접 호출 /
- 포인터 리시버 메서드의 직접 호출 /
- 인터페이스 위의 메서드 간접 호출 / 값 메서드를 가진 값을 포함
- 인터페이스 위의 메서드 간접 호출 / 값 메서드를 가진 포인터를 포함
- 인터페이스 위의 메서드 간접 호출 / 포인터 메서드를 가진 포인터를 포함
- func 값의 간접 호출 / 최상위 func로 설정됨
- func 값의 간접 호출 / 값 메서드로 설정됨
- func 값의 간접 호출 / 포인터 메서드로 설정됨
- func 값의 간접 호출 / func 리터럴로 설정됨
(슬래시는 컴파일 시점에 알려진 것과 런타임에야 밝혀지는 것을 구분합니다.)
먼저 세 종류의 직접 호출을 몇 분간 복습한 뒤, 이 챕터의 나머지에서는 인터페이스와 간접 메서드 호출에 초점을 옮기겠습니다.
함수 리터럴은 이 챕터에서 다루지 않겠습니다. 그것을 다루려면 먼저 클로저의 메커니즘에 익숙해져야 하기 때문입니다.. 그리고 그건 언젠가 반드시 하게 될 것입니다.
다음 코드를 봅시다(direct_calls.go):
//go:noinline
func Add(a, b int32) int32 { return a + b }
type Adder struct{ id int32 }
//go:noinline
func (adder *Adder) AddPtr(a, b int32) int32 { return a + b }
//go:noinline
func (adder Adder) AddVal(a, b int32) int32 { return a + b }
func main() {
Add(10, 32) // 최상위 함수의 직접 호출
adder := Adder{id: 6754}
adder.AddPtr(10, 32) // 포인터 리시버 메서드의 직접 호출
adder.AddVal(10, 32) // 값 리시버 메서드의 직접 호출
(&adder).AddVal(10, 32) // 암시적 역참조
}
이 4가지 호출 각각에 대해 생성된 코드를 빠르게 살펴봅시다.
최상위 함수의 직접 호출
Add(10, 32)에 대한 어셈블리 출력을 보면:
0x0000 TEXT "".main(SB), $40-0
;; ...실제 함수 호출 외에는 모두 생략...
0x0021 MOVQ $137438953482, AX
0x002b MOVQ AX, (SP)
0x002f CALL "".Add(SB)
;; ...실제 함수 호출 외에는 모두 생략...
우리는 이미 챕터 I에서 보았듯, 이것이 .text 섹션의 전역 함수 심볼로의 직접 점프로 번역되며, 인자와 반환값은 호출자의 스택 프레임에 저장된다는 것을 볼 수 있습니다.
더없이 단순합니다.
Russ Cox는 문서에서 이를 다음과 같이 정리합니다:
최상위 func의 직접 호출: 최상위 func의 직접 호출은 모든 인자를 스택으로 전달하며, 결과는 그 뒤따르는 스택 위치를 차지할 것이라고 기대한다.
포인터 리시버 메서드의 직접 호출
우선 리시버는 adder := Adder{id: 6754}를 통해 초기화됩니다:
0x0034 MOVL $6754, "".adder+28(SP)
(스택 프레임의 추가 공간은 프레임 포인터 프롤로그의 일부로 미리 할당되어 있었지만, 여기서는 간결함을 위해 보여주지 않았습니다.)
그 다음 실제 메서드 호출 adder.AddPtr(10, 32)가 나옵니다:
0x0057 LEAQ "".adder+28(SP), AX ;; &adder를..
0x005c MOVQ AX, (SP) ;; ..스택 맨 위로 이동 (인자 #1)
0x0060 MOVQ $137438953482, AX ;; (32,10)을..
0x006a MOVQ AX, 8(SP) ;; ..스택 맨 위로 이동 (인자 #3 및 #2)
0x006f CALL "".(*Adder).AddPtr(SB)
어셈블리 출력을 보면, 메서드 호출(값 리시버든 포인터 리시버든)은 함수 호출과 거의 동일하고, 유일한 차이는 리시버가 첫 번째 인자로 전달된다는 점임을 분명히 알 수 있습니다.
이 경우에는 프레임의 맨 위에 있는 "".adder+28(SP)의 유효 주소를 LEAQ로 적재하여 인자 #1이 &adder가 되게 합니다 (LEA와 MOV의 의미 차이가 조금 헷갈린다면, 이 챕터 끝부분의 링크를 참고하면 도움이 될 것입니다).
컴파일러가 심볼 이름에 리시버의 타입과 값/포인터 여부를 직접 인코딩한다는 점에 주목하세요: "".(*Adder).AddPtr.
메서드의 직접 호출: func 값의 간접 호출과 직접 호출 모두에서 동일한 생성 코드를 사용하기 위해, 메서드(값 리시버와 포인터 리시버 모두)에 대해 생성되는 코드는 리시버를 선행 인자로 받는 최상위 함수와 동일한 호출 규약을 갖도록 선택된다.
값 리시버 메서드의 직접 호출
예상대로 값 리시버를 사용하면 위와 매우 유사한 코드가 생성됩니다.
adder.AddVal(10, 32)를 봅시다:
0x003c MOVQ $42949679714, AX ;; (10,6754)을..
0x0046 MOVQ AX, (SP) ;; ..스택 맨 위로 이동 (인자 #2 및 #1)
0x004a MOVL $32, 8(SP) ;; 32를 스택 맨 위로 이동 (인자 #3)
0x0052 CALL "".Adder.AddVal(SB)
여기서는 약간 더 미묘한 일이 벌어지는 것처럼 보입니다. 생성된 어셈블리는 현재 리시버가 있는 "".adder+28(SP)를 전혀 참조하지 않기 때문입니다.
그럼 실제로는 무슨 일이 일어나는 걸까요? 리시버가 값이고, 컴파일러가 그 값을 정적으로 추론할 수 있기 때문에 현재 위치(28(SP))에서 기존 값을 복사하는 수고를 하지 않습니다. 대신 동일한 Adder 값을 스택 위에 직접 새로 만들고, 한 단계 더 명령어를 아끼기 위해 이 동작을 두 번째 인자의 생성과 합쳐버립니다.
다시 한 번, 메서드 심볼 이름이 값 리시버를 기대한다는 사실을 명시적으로 보여준다는 점에 주목하세요.
아직 보지 않은 마지막 호출이 하나 있습니다: (&adder).AddVal(10, 32).
이 경우 우리는 포인터 변수를 사용해서 값 리시버를 기대하는 메서드를 호출합니다. Go는 somehow 포인터를 자동으로 역참조해서 호출을 성사시킵니다. 어떻게 그럴까요?
컴파일러가 이런 상황을 처리하는 방식은 리시버가 힙으로 탈출했는지 여부에 따라 달라집니다.
경우 A: 리시버가 스택에 있는 경우
리시버가 아직 스택에 있고, 여기처럼 크기가 충분히 작아서 몇 개의 명령어로 복사할 수 있다면, 컴파일러는 그 값을 스택 맨 위로 복사한 뒤 "".Adder.AddVal(즉 값 리시버를 가진 메서드)을 곧바로 호출합니다.
이 상황에서 (&adder).AddVal(10, 32)는 다음과 같이 보입니다:
0x0074 MOVL "".adder+28(SP), AX ;; adder를 (즉 복사하여) 이동(MOV에 주목, LEA 아님)..
0x0078 MOVL AX, (SP) ;; ..스택 맨 위로 (인자 #1)
0x007b MOVQ $137438953482, AX ;; (32,10)을..
0x0085 MOVQ AX, 4(SP) ;; ..스택 맨 위로 (인자 #3 및 #2)
0x008a CALL "".Adder.AddVal(SB)
지루할 정도로 단순합니다(물론 효율적이기도 합니다). 이제 경우 B로 넘어갑시다.
경우 B: 리시버가 힙에 있는 경우
리시버가 힙으로 탈출했다면 컴파일러는 더 영리한 경로를 택해야 합니다. 즉, 이번에는 포인터 리시버를 가지는 새 메서드를 하나 생성해서 "".Adder.AddVal을 감싸고, 원래의 "".Adder.AddVal 호출(피호출 대상)을 "".(*Adder).AddVal 호출(래퍼)로 바꿉니다.
그 래퍼의 유일한 임무는 리시버가 적절히 역참조된 뒤 피호출 대상에게 전달되도록 하고, 관련된 인자와 반환값이 호출자와 피호출 대상 사이에서 올바르게 복사되도록 보장하는 것입니다.
(참고: 어셈블리 출력에서 이런 래퍼 메서드는 ``. 로 표시됩니다.) 다음은 생성된 래퍼의 주석 달린 목록입니다. 이것이 상황을 좀 더 명확하게 해주길 바랍니다: ```Assembly 0x0000 TEXT "".(*Adder).AddVal(SB), DUPOK|WRAPPER, $32-24 ;; ...프롤로그 생략... 0x0026 MOVQ ""..this+40(SP), AX ;; 리시버가.. 0x002b TESTQ AX, AX ;; ..nil인지 검사 0x002e JEQ 92 ;; nil이면 0x005c로 점프 (panic) 0x0030 MOVL (AX), AX ;; 포인터 리시버를 역참조.. 0x0032 MOVL AX, (SP) ;; ..해서 얻은 값을 인자 #1로 이동(즉 복사) ;; 인자 #2 및 #3을 전달(복사)한 뒤 피호출 대상 호출 0x0035 MOVL "".a+48(SP), AX 0x0039 MOVL AX, 4(SP) 0x003d MOVL "".b+52(SP), AX 0x0041 MOVL AX, 8(SP) 0x0045 CALL "".Adder.AddVal(SB) ;; 감싼 메서드 호출 ;; 감싼 메서드의 반환값을 복사한 뒤 반환 0x004a MOVL 16(SP), AX 0x004e MOVL AX, "".~r2+56(SP) ;; ...프레임 포인터 관련 생략... 0x005b RET ;; 자세한 오류와 함께 panic 발생 0x005c CALL runtime.panicwrap(SB) ;; ...에필로그 생략...
분명히 이런 래퍼는 인자를 앞뒤로 전달하기 위해 많은 복사가 필요하므로 상당한 오버헤드를 유발할 수 있습니다. 특히 피호출 대상이 몇 개 안 되는 명령어로 이루어져 있다면 더 그렇습니다. 다행히 실제로는 컴파일러가 가능한 경우 피호출 대상을 래퍼 안으로 직접 인라인해서 이러한 비용을 상쇄했을 것입니다. 심볼 정의에 있는 `WRAPPER` 지시어는 이 메서드가 백트레이스에 나타나지 않아야 하고(최종 사용자를 혼란스럽게 하지 않기 위해), 피호출 대상이 던질 수 있는 panic에서 recover할 수도 없어야 한다는 뜻입니다.
> WRAPPER: 이 함수는 래퍼 함수이며 recover를 비활성화한 것으로 간주되어서는 안 된다.
래퍼의 리시버가 `nil`일 때 panic을 발생시키는 `runtime.panicwrap` 함수는 이름 그대로입니다. 참고용으로 전체 목록을 보겠습니다([src/runtime/error.go](https://github.com/golang/go/blob/bf86aec25972f3a100c3aa58a6abcbcc35bdea49/src/runtime/error.go#L132-L157)):
```Go
// panicwrap generates a panic for a call to a wrapped value method
// with a nil pointer receiver.
//
// It is called from the generated wrapper code.
func panicwrap() {
pc := getcallerpc()
name := funcname(findfunc(pc))
// name is something like "main.(*T).F".
// We want to extract pkg ("main"), typ ("T"), and meth ("F").
// Do it by finding the parens.
i := stringsIndexByte(name, '(')
if i < 0 {
throw("panicwrap: no ( in " + name)
}
pkg := name[:i-1]
if i+2 >= len(name) || name[i-1:i+2] != ".(*" {
throw("panicwrap: unexpected string after package name: " + name)
}
name = name[i+2:]
i = stringsIndexByte(name, ')')
if i < 0 {
throw("panicwrap: no ) in " + name)
}
if i+2 >= len(name) || name[i:i+2] != ")." {
throw("panicwrap: unexpected string after type name: " + name)
}
typ := name[:i]
meth := name[i+2:]
panic(plainError("value method " + pkg + "." + typ + "." + meth + " called using nil *" + typ + " pointer"))
}
이것으로 함수와 메서드 호출은 끝입니다. 이제 본론인 인터페이스로 넘어가겠습니다.
인터페이스가 어떻게 동작하는지 이해하려면, 먼저 인터페이스를 구성하는 데이터 구조들과 그것들이 메모리에 어떻게 배치되는지에 대한 정신 모델을 만들어야 합니다. 이를 위해 runtime 패키지를 잠깐 들여다보며 Go 구현의 관점에서 인터페이스가 실제로 어떤 모습인지 보겠습니다.
iface 구조체
iface는 runtime 내부에서 인터페이스를 표현하는 루트 타입입니다(src/runtime/runtime2.go). 정의는 다음과 같습니다:
type iface struct { // 64비트 아키텍처에서 16바이트
tab *itab
data unsafe.Pointer
}
즉 인터페이스는 2개의 포인터를 유지하는 매우 단순한 구조체입니다:
tab은 인터페이스의 타입과 그것이 가리키는 데이터의 타입을 모두 설명하는 데이터 구조를 담고 있는 itab 객체의 주소를 보관합니다.data는 인터페이스가 보유한 값을 가리키는 원시(즉 unsafe) 포인터입니다.정의 자체는 매우 단순하지만, 이미 중요한 정보를 줍니다. 인터페이스는 포인터만 저장할 수 있으므로, 인터페이스에 감싸 넣는 모든 구체 값은 반드시 주소를 가져야 합니다. 대부분의 경우 컴파일러는 보수적으로 접근하여 리시버를 탈출시키므로 힙 할당이 발생합니다. 이는 스칼라 타입에도 해당합니다! 몇 줄의 코드로 이를 증명할 수 있습니다(escape.go):
type Addifier interface{ Add(a, b int32) int32 }
type Adder struct{ name string }
//go:noinline
func (adder Adder) Add(a, b int32) int32 { return a + b }
func main() {
adder := Adder{name: "myAdder"}
adder.Add(10, 32) // 탈출하지 않음
Addifier(adder).Add(10, 32) // 탈출함
}
$ GOOS=linux GOARCH=amd64 go tool compile -m escape.go
escape.go:13:10: Addifier(adder) escapes to heap
# ...
단순한 벤치마크를 통해 실제 힙 할당을 시각화할 수도 있습니다(escape_test.go):
func BenchmarkDirect(b *testing.B) {
adder := Adder{id: 6754}
for i := 0; i < b.N; i++ {
adder.Add(10, 32)
}
}
func BenchmarkInterface(b *testing.B) {
adder := Adder{id: 6754}
for i := 0; i < b.N; i++ {
Addifier(adder).Add(10, 32)
}
}
$ GOOS=linux GOARCH=amd64 go tool compile -m escape_test.go
# ...
escape_test.go:22:11: Addifier(adder) escapes to heap
# ...
$ GOOS=linux GOARCH=amd64 go test -bench=. -benchmem ./escape_test.go
BenchmarkDirect-8 2000000000 1.60 ns/op 0 B/op 0 allocs/op
BenchmarkInterface-8 100000000 15.0 ns/op 4 B/op 1 allocs/op
새 Addifier 인터페이스를 만들고 adder 변수로 초기화할 때마다 실제로 sizeof(Adder) 크기의 힙 할당이 일어나는 것을 분명히 볼 수 있습니다. 이 챕터 뒤쪽에서는 단순한 스칼라 타입도 인터페이스와 함께 쓰일 때 힙 할당을 유발할 수 있음을 보게 될 것입니다. 이제 다음 데이터 구조인 itab으로 넘어가겠습니다.
itab 구조체
itab은 다음과 같이 정의됩니다(src/runtime/runtime2.go):
type itab struct { // 64비트 아키텍처에서 40바이트
inter *interfacetype
_type *_type
hash uint32 // _type.hash의 복사본. 타입 스위치에 사용됨.
_ [4]byte
fun [1]uintptr // 가변 크기. fun[0]==0이면 _type이 inter를 구현하지 않음.
}
itab은 인터페이스의 심장이자 두뇌입니다. 먼저 _type이 포함되어 있는데, 이는 runtime 내부에서 임의의 Go 타입을 표현하는 내부 표현입니다. _type은 타입의 이름, 특성(예: 크기, 정렬), 그리고 어느 정도는 동작 방식(예: 비교, 해싱)까지 설명합니다! 여기서 _type 필드는 인터페이스가 보유한 값의 타입, 즉 data 포인터가 가리키는 값의 타입을 설명합니다.
둘째로 interfacetype에 대한 포인터가 있습니다. 이는 인터페이스에 특화된 몇 가지 추가 정보를 가진 _type의 래퍼일 뿐입니다. 예상대로 inter 필드는 인터페이스 자체의 타입을 설명합니다.
마지막으로 fun 배열은 인터페이스의 가상/디스패치 테이블을 구성하는 함수 포인터들을 담습니다. // variable sized라는 주석에 주목하세요. 즉 이 배열이 여기서 선언된 크기는 의미가 없습니다. 이 챕터 뒤에서 보겠지만, 이 배열을 뒷받침하는 메모리는 컴파일러가 할당하며, 여기 적힌 크기와는 무관하게 처리됩니다. 마찬가지로 runtime은 이 배열에 항상 원시 포인터로 접근하므로 경계 검사 역시 적용되지 않습니다.
_type 구조체
앞서 말했듯 _type 구조체는 Go 타입에 대한 완전한 설명을 제공합니다. 정의는 다음과 같습니다(src/runtime/type.go):
type _type struct { // 64비트 아키텍처에서 48바이트
size uintptr
ptrdata uintptr // 모든 포인터를 담는 메모리 접두부의 크기
hash uint32
tflag tflag
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
// gcdata stores the GC type data for the garbage collector.
// If the KindGCProg bit is set in kind, gcdata is a GC program.
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
gcdata *byte
str nameOff
ptrToThis typeOff
}
다행히 대부분의 필드는 이름만 봐도 이해가 됩니다. nameOff와 typeOff 타입은 링커가 최종 실행 파일에 삽입한 메타데이터에 대한 int32 오프셋입니다. 이 메타데이터는 런타임에 runtime.moduledata 구조체들로 로드되며(src/runtime/symtab.go), ELF 파일 내용을 들여다본 적이 있다면 꽤 익숙해 보일 것입니다.
runtime은 moduledata 구조체를 통해 이런 오프셋을 따라가는 데 필요한 로직을 구현한 헬퍼들을 제공합니다. 예를 들어 resolveNameOff(src/runtime/type.go)와 resolveTypeOff(src/runtime/type.go)가 있습니다:
func resolveNameOff(ptrInModule unsafe.Pointer, off nameOff) name {}
func resolveTypeOff(ptrInModule unsafe.Pointer, off typeOff) *_type {}
즉, t가 _type이라면 resolveTypeOff(t, t.ptrToThis)를 호출하면 t의 복사본을 얻습니다.
interfacetype 구조체
마지막으로 interfacetype 구조체입니다(src/runtime/type.go):
type interfacetype struct { // 64비트 아키텍처에서 80바이트
typ _type
pkgpath name
mhdr []imethod
}
type imethod struct {
name nameOff
typ typeOff
}
말했듯 interfacetype은 _type에 인터페이스 전용 메타데이터를 덧붙인 래퍼일 뿐입니다. 현재 구현에서 이 메타데이터는 주로 인터페이스가 노출하는 메서드 각각의 이름과 타입을 가리키는 오프셋 목록([]imethod)으로 구성됩니다.
결론
다음은 모든 하위 타입을 펼쳐 넣은 형태로 표현한 iface의 개요입니다. 이제 전체 그림이 좀 더 연결되기를 바랍니다:
type iface struct { // `iface`
tab *struct { // `itab`
inter *struct { // `interfacetype`
typ struct { // `_type`
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
gcdata *byte
str nameOff
ptrToThis typeOff
}
pkgpath name
mhdr []struct { // `imethod`
name nameOff
typ typeOff
}
}
_type *struct { // `_type`
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
gcdata *byte
str nameOff
ptrToThis typeOff
}
hash uint32
_ [4]byte
fun [1]uintptr
}
data unsafe.Pointer
}
이 절에서는 인터페이스를 구성하는 여러 데이터 타입을 빠르게 훑으며, 전체 기계장치 안의 다양한 톱니바퀴와 그것들이 서로 어떻게 맞물리는지에 대한 정신 모델을 세우는 데 집중했습니다. 다음 절에서는 이 데이터 구조들이 실제로 어떻게 계산되는지 배워보겠습니다.
관련된 데이터 구조들을 빠르게 훑어봤으니, 이제 그것들이 실제로 어떻게 할당되고 초기화되는지에 집중하겠습니다. 다음 프로그램을 봅시다(iface.go):
type Mather interface {
Add(a, b int32) int32
Sub(a, b int64) int64
}
type Adder struct{ id int32 }
//go:noinline
func (adder Adder) Add(a, b int32) int32 { return a + b }
//go:noinline
func (adder Adder) Sub(a, b int64) int64 { return a - b }
func main() {
m := Mather(Adder{id: 6754})
// 이 호출은 인터페이스가 실제로 사용되도록 하기 위한 것입니다.
// 이 호출이 없으면 링커는 위에서 정의한 인터페이스가
// 실제로 전혀 사용되지 않는다고 보고 최종 실행 파일에서
// 최적화해 버릴 것입니다.
m.Add(10, 32)
}
참고: 이 챕터의 나머지에서는 타입 T를 보유하는 인터페이스 I를 ``로 표기하겠습니다. 예를 들어 Mather(Adder{id: 6754})는 iface를 인스턴스화합니다.
iface의 인스턴스화에 집중해 봅시다:
m := Mather(Adder{id: 6754})
이 한 줄의 Go 코드는 실제로 꽤 많은 메커니즘을 작동시킵니다. 컴파일러가 생성한 어셈블리 목록이 이를 잘 보여줍니다:
;; part 1: 리시버 할당
0x001d MOVL $6754, ""..autotmp_1+36(SP)
;; part 2: itab 설정
0x0025 LEAQ go.itab."".Adder,"".Mather(SB), AX
0x002c MOVQ AX, (SP)
;; part 3: data 설정
0x0030 LEAQ ""..autotmp_1+36(SP), AX
0x0035 MOVQ AX, 8(SP)
0x003a CALL runtime.convT2I32(SB)
0x003f MOVQ 16(SP), AX
0x0044 MOVQ 24(SP), CX
보시다시피, 출력을 세 개의 논리적 부분으로 나누었습니다.
파트 1: 리시버 할당
0x001d MOVL $6754, ""..autotmp_1+36(SP)
Adder의 ID에 해당하는 10진수 상수 6754가 현재 스택 프레임의 시작 부분에 저장됩니다. 이렇게 저장하는 이유는 파트 3에서 보겠지만 컴파일러가 나중에 이 값을 주소로 참조할 수 있게 하기 위해서입니다.
파트 2: itab 설정
0x0025 LEAQ go.itab."".Adder,"".Mather(SB), AX
0x002c MOVQ AX, (SP)
컴파일러는 이미 우리의 iface 인터페이스를 표현하는 데 필요한 itab을 만들어 두었고, 이를 전역 심볼 go.itab."".Adder,"".Mather를 통해 사용할 수 있게 해둔 것으로 보입니다. 우리는 iface 인터페이스를 구성하는 중이고, 그렇게 하기 위해 현재 스택 프레임의 맨 위에 이 전역 go.itab."".Adder,"".Mather 심볼의 유효 주소를 적재하고 있습니다. 다시 말하지만, 왜 이렇게 하는지는 파트 3에서 보게 됩니다.
의미적으로는 다음과 같은 의사 코드와 비슷합니다:
tab := getSymAddr(`go.itab.main.Adder,main.Mather`).(*itab)
이것으로 인터페이스의 절반이 준비됐습니다! 이왕 여기까지 왔으니 go.itab."".Adder,"".Mather 심볼 자체도 좀 더 들여다봅시다. 늘 그렇듯 컴파일러의 -S 플래그가 많은 것을 말해줍니다:
$ GOOS=linux GOARCH=amd64 go tool compile -S iface.go | grep -A 7 '^go.itab."".Adder,"".Mather'
go.itab."".Adder,"".Mather SRODATA dupok size=40
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x0010 8a 3d 5f 61 00 00 00 00 00 00 00 00 00 00 00 00 .=_a............
0x0020 00 00 00 00 00 00 00 00 ........
rel 0+8 t=1 type."".Mather+0
rel 8+8 t=1 type."".Adder+0
rel 24+8 t=1 "".(*Adder).Add+0
rel 32+8 t=1 "".(*Adder).Sub+0
좋습니다. 하나씩 분석해 봅시다.
첫 번째 부분은 심볼과 그 속성을 선언합니다:
go.itab."".Adder,"".Mather SRODATA dupok size=40
늘 그렇듯, 우리가 보고 있는 것은 컴파일러가 생성한 중간 오브젝트 파일이므로(즉, 아직 링커가 실행되기 전이므로) 심볼 이름에는 패키지 이름이 빠져 있습니다. 이 부분은 새로울 것이 없습니다.
그 외에 여기서 알 수 있는 것은, 이것이 .rodata 섹션에 저장될 40바이트짜리 전역 오브젝트 심볼이라는 점입니다. dupok 지시어에 주목하세요. 이는 링크 시점에 이 심볼이 여러 번 나타나더라도 괜찮다는 뜻이며, 링커는 그중 하나를 임의로 선택해야 합니다. 왜 Go 작성자들이 이 심볼이 중복될 수 있다고 생각했는지는 잘 모르겠습니다. 더 아는 것이 있다면 이슈를 올려 주세요.
[업데이트: 이 문제는 issue #7: How you can get duplicated go.itab interface definitions에서 논의했습니다.]
두 번째 부분은 심볼에 연결된 40바이트 데이터의 헥스덤프입니다. 즉, itab 구조체의 직렬화된 표현입니다:
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x0010 8a 3d 5f 61 00 00 00 00 00 00 00 00 00 00 00 00 .=_a............
0x0020 00 00 00 00 00 00 00 00 ........
보다시피, 현재 이 데이터 대부분은 0들로 채워져 있습니다. 잠시 후 보겠지만, 이 부분은 링커가 채워 넣습니다. 다만 모든 0 사이에서 실제로 설정된 4바이트가 하나 있다는 점에 주목하세요. 오프셋 0x10+4에 있습니다. itab 구조체 선언을 다시 보고 각 필드의 오프셋을 주석으로 달아보면:
type itab struct { // 64비트 아키텍처에서 40바이트
inter *interfacetype // offset 0x00 ($00)
_type *_type // offset 0x08 ($08)
hash uint32 // offset 0x10 ($16)
_ [4]byte // offset 0x14 ($20)
fun [1]uintptr // offset 0x18 ($24)
// offset 0x20 ($32)
}
오프셋 0x10+4가 hash uint32 필드와 일치함을 알 수 있습니다. 즉 main.Adder 타입에 해당하는 해시 값이 이미 오브젝트 파일 안에 들어 있습니다.
세 번째이자 마지막 부분은 링커를 위한 여러 재배치 지시를 나열합니다:
rel 0+8 t=1 type."".Mather+0
rel 8+8 t=1 type."".Adder+0
rel 24+8 t=1 "".(*Adder).Add+0
rel 32+8 t=1 "".(*Adder).Sub+0
rel 0+8 t=1 type."".Mather+0는 내용의 첫 8바이트(0+8)를 전역 오브젝트 심볼 type."".Mather의 주소로 채우라는 뜻입니다. rel 8+8 t=1 type."".Adder+0는 다음 8바이트를 type."".Adder의 주소로 채우고, 나머지도 같은 식입니다. 링커가 이 지시를 모두 처리하고 나면 40바이트 직렬화 itab이 완성됩니다.
전체적으로 보면 지금 우리는 다음과 같은 의사 코드를 보고 있는 셈입니다:
tab := getSymAddr(`go.itab.main.Adder,main.Mather`).(*itab)
// 참고: 실행 파일을 만들 때 링커는 이 심볼들에서 `type.` 접두어를 제거하므로
// 바이너리의 .rodata 섹션에 있는 최종 심볼 이름은 실제로
// `type.main.Mather`, `type.main.Adder`가 아니라
// `main.Mather`, `main.Adder`가 됩니다.
// objdump를 가지고 놀 때 이 점에 헷갈리지 마세요.
tab.inter = getSymAddr(`type.main.Mather`).(*interfacetype)
tab._type = getSymAddr(`type.main.Adder`).(*_type)
tab.fun[0] = getSymAddr(`main.(*Adder).Add`).(uintptr)
tab.fun[1] = getSymAddr(`main.(*Adder).Sub`).(uintptr)
이제 사용할 준비가 된 itab을 얻었습니다. 여기에 붙일 데이터만 있으면 완전한 인터페이스가 됩니다.
파트 3: data 설정
0x0030 LEAQ ""..autotmp_1+36(SP), AX
0x0035 MOVQ AX, 8(SP)
0x003a CALL runtime.convT2I32(SB)
0x003f MOVQ 16(SP), AX
0x0044 MOVQ 24(SP), CX
파트 1에서 스택 맨 위 (SP)에는 현재 go.itab."".Adder,"".Mather의 주소가 들어 있다는 것을 기억하세요(인자 #1). 또 파트 2에서 10진수 상수 $6754를 ""..autotmp_1+36(SP)에 저장해 두었다는 것도 기억하세요. 이제 이 상수의 유효 주소를 스택 프레임의 맨 위 바로 아래, 즉 8(SP)(인자 #2)에 적재합니다. 이 두 포인터가 runtime.convT2I32에 전달하는 두 인자이며, 이 함수가 최종적인 접착 작업을 해서 완전한 인터페이스를 만들고 반환합니다. 자세히 들여다봅시다(src/runtime/iface.go):
func convT2I32(tab *itab, elem unsafe.Pointer) (i iface) {
t := tab._type
/* ...디버그 관련 생략... */
var x unsafe.Pointer
if *(*uint32)(elem) == 0 {
x = unsafe.Pointer(&zeroVal[0])
} else {
x = mallocgc(4, t, false)
*(*uint32)(x) = *(*uint32)(elem)
}
i.tab = tab
i.data = x
return
}
즉 runtime.convT2I32는 4가지 일을 합니다:
iface 구조체 i를 만듭니다 (정확히 말하면 호출자가 만듭니다.. 사실상 같은 말입니다).itab 포인터를 i.tab에 대입합니다.i.tab._type의 새 객체를 힙에 할당한 뒤, 두 번째 인자 elem이 가리키는 값을 그 새 객체에 복사합니다.전체 과정은 꽤 직관적이지만, 이 경우 3번째 단계에는 우리의 Adder 타입이 사실상 스칼라 타입이기 때문에 약간 까다로운 구현 세부사항이 섞여 있습니다. 스칼라 타입과 인터페이스의 상호작용은 인터페이스의 특수 사례 절에서 더 자세히 보겠습니다.
개념적으로는 이제 다음을 수행한 것입니다(의사 코드):
tab := getSymAddr(`go.itab.main.Adder,main.Mather`).(*itab)
elem := getSymAddr(`""..autotmp_1+36(SP)`).(*int32)
i := runtime.convTI32(tab, unsafe.Pointer(elem))
assert(i.tab == tab)
assert(*(*int32)(i.data) == 6754) // 값은 같지만..
assert((*int32)(i.data) != elem) // ..위치는 다르다!
지금까지 벌어진 일을 요약하면, 세 파트를 모두 포함한 완전한 주석 달린 어셈블리 코드는 다음과 같습니다:
0x001d MOVL $6754, ""..autotmp_1+36(SP) ;; 주소를 가질 수 있는 $6754 값을 36(SP)에 생성
0x0025 LEAQ go.itab."".Adder,"".Mather(SB), AX ;; go.itab."".Adder,"".Mather를 설정..
0x002c MOVQ AX, (SP) ;; ..첫 번째 인자(tab *itab)로
0x0030 LEAQ ""..autotmp_1+36(SP), AX ;; &36(SP)를 설정..
0x0035 MOVQ AX, 8(SP) ;; ..두 번째 인자(elem unsafe.Pointer)로
0x003a CALL runtime.convT2I32(SB) ;; convT2I32(go.itab."".Adder,"".Mather, &$6754) 호출
0x003f MOVQ 16(SP), AX ;; 이제 AX는 i.tab(go.itab."".Adder,"".Mather)을 가짐
0x0044 MOVQ 24(SP), CX ;; 이제 CX는 i.data(힙 어딘가의 &$6754)를 가짐
이 모든 것이 단 한 줄, m := Mather(Adder{id: 6754})에서 시작되었다는 점을 기억하세요. 이제 마침내 완전한, 동작하는 인터페이스를 손에 넣었습니다.
itab 재구성하기이전 절에서는 컴파일러가 생성한 오브젝트 파일에서 go.itab."".Adder,"".Mather의 내용을 직접 덤프했고, 대부분이 0으로 채워진 덩어리(해시 값만 예외)라는 것을 보았습니다:
$ GOOS=linux GOARCH=amd64 go tool compile -S iface.go | grep -A 3 '^go.itab."".Adder,"".Mather'
go.itab."".Adder,"".Mather SRODATA dupok size=40
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x0010 8a 3d 5f 61 00 00 00 00 00 00 00 00 00 00 00 00 .=_a............
0x0020 00 00 00 00 00 00 00 00 ........
링커가 만든 최종 실행 파일에서 이 데이터가 어떻게 배치되는지 더 잘 보기 위해, 생성된 ELF 파일을 따라가며 iface의 itab을 이루는 바이트를 수동으로 재구성해 보겠습니다. 이렇게 하면 링커가 작업을 끝낸 뒤 itab이 어떤 모습인지 관찰할 수 있을 것입니다.
우선 iface 바이너리를 빌드합시다: GOOS=linux GOARCH=amd64 go build -o iface.bin iface.go.
1단계: .rodata 찾기
섹션 헤더를 출력해 .rodata를 찾겠습니다. readelf가 도움이 됩니다:
$ readelf -St -W iface.bin
There are 22 section headers, starting at offset 0x190:
Section Headers:
[Nr] Name Type Address Off Size ES Lk Inf Al Flags
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 0000000000401000 001000 04b3cf 00 0 0 16 [0000000000000006]: ALLOC, EXEC
[ 2] .rodata PROGBITS 000000000044d000 04d000 028ac4 00 0 0 32 [0000000000000002]: ALLOC
## ...나머지 출력 생략...
여기서 정말 필요한 것은 섹션의 10진수 오프셋이므로, 약간의 파이프 마술을 써보겠습니다:
$ readelf -St -W iface.bin | \
grep -A 1 .rodata | \
tail -n +2 | \
awk '{print "ibase=16;"toupper($3)}' | \
bc
315392
즉 바이너리에서 315392바이트만큼 fseek하면 .rodata 섹션의 시작에 도달하게 됩니다. 이제 이 파일 위치를 가상 메모리 주소에 매핑해야 합니다.
2단계: .rodata의 가상 메모리 주소(VMA) 찾기
VMA는 운영체제가 바이너리를 메모리에 로드했을 때 해당 섹션이 매핑될 가상 주소입니다. 즉 런타임에 심볼을 참조할 때 사용하는 주소입니다. 여기서 우리가 VMA에 관심이 있는 이유는 readelf나 objdump에 특정 심볼의 오프셋을 직접 물을 수는 없기 때문입니다(적어도 제가 아는 한). 하지만 특정 심볼의 VMA는 물을 수 있습니다. 거기에 간단한 수학을 결합하면 VMA와 오프셋의 매핑을 만들고, 결국 원하는 심볼의 오프셋을 찾을 수 있습니다.
.rodata의 VMA를 찾는 일은 오프셋을 찾는 것과 다르지 않습니다. 열만 다를 뿐입니다:
$ readelf -St -W iface.bin | \
grep -A 1 .rodata | \
tail -n +2 | \
awk '{print "ibase=16;"toupper($2)}' | \
bc
4509696
지금까지 우리가 아는 것은 다음과 같습니다: .rodata 섹션은 ELF 파일의 오프셋 $315392(= 0x04d000)에 있으며, 런타임에는 가상 주소 $4509696(= 0x44d000)에 매핑됩니다. 이제 우리가 찾는 심볼의 VMA와 크기도 필요합니다:
3단계: go.itab.main.Adder,main.Mather의 VMA와 크기 찾기
objdump가 이 정보를 제공합니다. 먼저 심볼을 찾습니다:
$ objdump -t -j .rodata iface.bin | grep "go.itab.main.Adder,main.Mather"
0000000000475140 g O .rodata 0000000000000028 go.itab.main.Adder,main.Mather
그 다음 VMA를 10진수로 바꿉니다:
$ objdump -t -j .rodata iface.bin | \
grep "go.itab.main.Adder,main.Mather" | \
awk '{print "ibase=16;"toupper($1)}' | \
bc
4673856
마지막으로 크기를 10진수로 구합니다:
$ objdump -t -j .rodata iface.bin | \
grep "go.itab.main.Adder,main.Mather" | \
awk '{print "ibase=16;"toupper($5)}' | \
bc
40
즉 go.itab.main.Adder,main.Mather는 런타임에 가상 주소 $4673856(= 0x475140)에 매핑되며, 크기는 40바이트입니다(itab 구조체의 크기이므로 이미 알고 있던 사실이기도 합니다).
4단계: go.itab.main.Adder,main.Mather 찾고 추출하기
이제 실행 파일 안에서 go.itab.main.Adder,main.Mather를 찾는 데 필요한 모든 요소를 갖췄습니다. 지금까지의 정보를 다시 정리하면:
.rodata offset: 0x04d000 == $315392
.rodata VMA: 0x44d000 == $4509696
go.itab.main.Adder,main.Mather VMA: 0x475140 == $4673856
go.itab.main.Adder,main.Mather size: 0x24 = $40
$315392(.rodata의 오프셋)이 $4509696(.rodata의 VMA)에 매핑되고, go.itab.main.Adder,main.Mather의 VMA가 $4673856이라면, 실행 파일 안에서 go.itab.main.Adder,main.Mather의 오프셋은 다음과 같습니다: sym.offset = sym.vma - section.vma + section.offset = $4673856 - $4509696 + $315392 = $479552. 이제 데이터의 오프셋과 크기를 모두 알았으니, 믿음직한 dd를 꺼내 실행 파일에서 바로 바이트를 뽑아낼 수 있습니다:
$ dd if=iface.bin of=/dev/stdout bs=1 count=40 skip=479552 2>/dev/null | hexdump
0000000 bd20 0045 0000 0000 ed40 0045 0000 0000
0000010 3d8a 615f 0000 0000 c2d0 0044 0000 0000
0000020 c350 0044 0000 0000
0000028
확실한 승리처럼 보입니다.. 하지만 정말 그럴까요? 어쩌면 그저 전혀 무관한 40바이트를 우연히 덤프한 것일지도 모릅니다. 확실히 검증하는 방법은 적어도 하나 있습니다. 바이너리 덤프에서 찾은 타입 해시(오프셋 0x10+4 -> 0x615f3d8a)를 runtime이 로드한 해시와 비교하는 것입니다(iface_type_hash.go):
// runtime의 iface와 itab 타입을 단순화한 정의
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct {
inter uintptr
_type uintptr
hash uint32
_ [4]byte
fun [1]uintptr
}
func main() {
m := Mather(Adder{id: 6754})
iface := (*iface)(unsafe.Pointer(&m))
fmt.Printf("iface.tab.hash = %#x\n", iface.tab.hash) // 0x615f3d8a
}
일치합니다! fmt.Printf("iface.tab.hash = %#x\n", iface.tab.hash)는 0x615f3d8a를 출력하며, 이는 우리가 ELF 파일 내용에서 추출한 값과 동일합니다.
결론
우리는 iface 인터페이스를 위한 완전한 itab을 재구성했습니다. 이것은 실행 파일 안에 모두 들어 있으며, 사용되기만을 기다리고 있고, runtime이 인터페이스를 우리가 기대하는 대로 동작시키는 데 필요한 모든 정보를 이미 담고 있습니다.
물론 itab은 대부분 다른 데이터 구조에 대한 포인터들로 구성되어 있으므로, 전체 그림을 재구성하려면 dd로 추출한 내용 속 가상 주소들을 따라가야 합니다. 포인터 얘기가 나온 김에 이제 iface의 가상 테이블을 명확히 볼 수 있습니다. 다음은 go.itab.main.Adder,main.Mather 내용의 주석 달린 버전입니다:
$ dd if=iface.bin of=/dev/stdout bs=1 count=40 skip=479552 2>/dev/null | hexdump
0000000 bd20 0045 0000 0000 ed40 0045 0000 0000
0000010 3d8a 615f 0000 0000 c2d0 0044 0000 0000
# ^^^^^^^^^^^^^^^^^^^
# offset 0x18+8: itab.fun[0]
0000020 c350 0044 0000 0000
# ^^^^^^^^^^^^^^^^^^^
# offset 0x20+8: itab.fun[1]
0000028
$ objdump -t -j .text iface.bin | grep 000000000044c2d0
000000000044c2d0 g F .text 0000000000000079 main.(*Adder).Add
$ objdump -t -j .text iface.bin | grep 000000000044c350
000000000044c350 g F .text 000000000000007f main.(*Adder).Sub
예상대로 iface의 가상 테이블은 두 개의 메서드 포인터, main.(*Adder).add와 main.(*Adder).sub를 담고 있습니다. 하지만 사실 이건 약간 놀랍습니다. 우리는 이 두 메서드를 포인터 리시버로 정의한 적이 없기 때문입니다. 컴파일러는 앞서 "암시적 역참조" 절에서 설명했듯, 이 래퍼 메서드들을 대신 생성해 주었습니다. 인터페이스는 포인터만 저장할 수 있고, Adder 구현은 값 리시버 메서드만 제공하므로, 인터페이스의 가상 테이블을 통해 이 메서드를 호출하려면 결국 어느 시점엔가 래퍼를 거쳐야 한다는 사실을 컴파일러가 알고 있기 때문입니다.
이것만으로도 런타임에서 동적 디스패치가 어떻게 처리되는지 꽤 잘 짐작할 수 있습니다. 다음 절에서는 바로 그것을 살펴보겠습니다.
보너스
모든 ELF 파일의 모든 섹션에서 임의의 심볼 내용을 덤프할 수 있는 범용 bash 스크립트를 하나 급히 만들어 봤습니다(dump_sym.sh):
# ./dump_sym.sh bin_path section_name sym_name
$ ./dump_sym.sh iface.bin .rodata go.itab.main.Adder,main.Mather
.rodata file-offset: 315392
.rodata VMA: 4509696
go.itab.main.Adder,main.Mather VMA: 4673856
go.itab.main.Adder,main.Mather SIZE: 40
0000000 bd20 0045 0000 0000 ed40 0045 0000 0000
0000010 3d8a 615f 0000 0000 c2d0 0044 0000 0000
0000020 c350 0044 0000 0000
0000028
이 스크립트가 하는 일을 더 쉽게 해주는 방법이 분명 있을 것 같긴 합니다. 어쩌면 binutils 배포판 어딘가에 숨은 신비한 플래그가 있을지도 모르죠.. 누가 알겠습니까. 힌트가 있다면 이슈에 남겨 주세요.
이 절에서는 마침내 인터페이스의 핵심 기능인 동적 디스패치를 다룹니다. 구체적으로는 동적 디스패치가 내부적으로 어떻게 동작하는지, 그리고 그 대가가 얼마나 되는지를 보겠습니다.
조금 전에 보았던 코드(iface.go)를 다시 봅시다:
type Mather interface {
Add(a, b int32) int32
Sub(a, b int64) int64
}
type Adder struct{ id int32 }
//go:noinline
func (adder Adder) Add(a, b int32) int32 { return a + b }
//go:noinline
func (adder Adder) Sub(a, b int64) int64 { return a - b }
func main() {
m := Mather(Adder{id: 6754})
m.Add(10, 32)
}
이 코드에서 일어나는 대부분의 일은 이미 자세히 보았습니다. iface 인터페이스가 어떻게 생성되는지, 최종 실행 파일에서 어떻게 배치되는지, runtime에 의해 어떻게 로드되는지 말입니다. 이제 남은 것은 실제 간접 메서드 호출 m.Add(10, 32)뿐입니다.
기억을 되살리기 위해 인터페이스 생성과 메서드 호출 부분을 함께 확대해 보겠습니다:
m := Mather(Adder{id: 6754})
m.Add(10, 32)
다행히 첫 줄의 인스턴스화(m := Mather(Adder{id: 6754}))에 대해 생성된 어셈블리에는 이미 완전한 주석이 달려 있었습니다:
;; m := Mather(Adder{id: 6754})
0x001d MOVL $6754, ""..autotmp_1+36(SP) ;; 주소를 가질 수 있는 $6754 값을 36(SP)에 생성
0x0025 LEAQ go.itab."".Adder,"".Mather(SB), AX ;; go.itab."".Adder,"".Mather를 설정..
0x002c MOVQ AX, (SP) ;; ..첫 번째 인자(tab *itab)로
0x0030 LEAQ ""..autotmp_1+36(SP), AX ;; &36(SP)를 설정..
0x0035 MOVQ AX, 8(SP) ;; ..두 번째 인자(elem unsafe.Pointer)로
0x003a CALL runtime.convT2I32(SB) ;; runtime.convT2I32(go.itab."".Adder,"".Mather, &$6754)
0x003f MOVQ 16(SP), AX ;; 이제 AX는 i.tab(go.itab."".Adder,"".Mather)을 가짐
0x0044 MOVQ 24(SP), CX ;; 이제 CX는 i.data(힙 어딘가의 &$6754)를 가짐
그리고 이어지는 간접 메서드 호출(m.Add(10, 32))의 어셈블리 목록은 다음과 같습니다:
;; m.Add(10, 32)
0x0049 MOVQ 24(AX), AX
0x004d MOVQ $137438953482, DX
0x0057 MOVQ DX, 8(SP)
0x005c MOVQ CX, (SP)
0x0060 CALL AX
앞 절들에서 쌓은 지식을 바탕으로 보면, 이 몇 개의 명령어는 이해하기 쉽습니다.
0x0049 MOVQ 24(AX), AX
runtime.convT2I32가 반환된 뒤 AX는 i.tab을 가지고 있는데, 알다시피 이는 itab을 가리키는 포인터입니다. 이 경우 더 구체적으로는 go.itab."".Adder,"".Mather를 가리킵니다. AX를 역참조하고 24바이트 앞으로 이동하면 i.tab.fun에 도달하는데, 이는 가상 테이블의 첫 번째 엔트리입니다. itab의 오프셋 표를 다시 보면:
type itab struct { // 64비트 아키텍처에서 32바이트
inter *interfacetype // offset 0x00 ($00)
_type *_type // offset 0x08 ($08)
hash uint32 // offset 0x10 ($16)
_ [4]byte // offset 0x14 ($20)
fun [1]uintptr // offset 0x18 ($24)
// offset 0x20 ($32)
}
앞 절에서 최종 itab을 실행 파일에서 직접 재구성하면서 본 것처럼, iface.tab.fun[0]은 main.(*Adder).add에 대한 포인터이며, 이는 원래 값 리시버 메서드 main.Adder.add를 감싸는 컴파일러 생성 래퍼 메서드입니다.
0x004d MOVQ $137438953482, DX
0x0057 MOVQ DX, 8(SP)
우리는 10과 32를 스택 맨 위에 인자 #2와 #3으로 저장합니다.
0x005c MOVQ CX, (SP)
0x0060 CALL AX
runtime.convT2I32가 반환된 뒤 CX는 i.data, 즉 Adder 인스턴스를 가리키는 포인터를 가지고 있습니다. 이 포인터를 스택 맨 위로 옮겨 인자 #1로 넣어 호출 규약을 만족시킵니다. 메서드의 리시버는 항상 첫 번째 인자로 전달되어야 하기 때문입니다. 마지막으로 스택 준비가 끝났으니 실제 호출을 수행할 수 있습니다.
이 절을 마무리하며 전체 과정의 완전한 주석 달린 어셈블리 목록을 다시 적어보겠습니다:
;; m := Mather(Adder{id: 6754})
0x001d MOVL $6754, ""..autotmp_1+36(SP) ;; 주소를 가질 수 있는 $6754 값을 36(SP)에 생성
0x0025 LEAQ go.itab."".Adder,"".Mather(SB), AX ;; go.itab."".Adder,"".Mather를 설정..
0x002c MOVQ AX, (SP) ;; ..첫 번째 인자(tab *itab)로
0x0030 LEAQ ""..autotmp_1+36(SP), AX ;; &36(SP)를 설정..
0x0035 MOVQ AX, 8(SP) ;; ..두 번째 인자(elem unsafe.Pointer)로
0x003a CALL runtime.convT2I32(SB) ;; runtime.convT2I32(go.itab."".Adder,"".Mather, &$6754)
0x003f MOVQ 16(SP), AX ;; 이제 AX는 i.tab(go.itab."".Adder,"".Mather)을 가짐
0x0044 MOVQ 24(SP), CX ;; 이제 CX는 i.data(힙 어딘가의 &$6754)를 가짐
;; m.Add(10, 32)
0x0049 MOVQ 24(AX), AX ;; 이제 AX는 (*iface.tab)+0x18, 즉 iface.tab.fun[0]을 가짐
0x004d MOVQ $137438953482, DX ;; (32,10)을..
0x0057 MOVQ DX, 8(SP) ;; ..스택 맨 위로 (인자 #3 및 #2)
0x005c MOVQ CX, (SP) ;; &$6754를 가진 CX(즉 우리의 리시버)를
;; ..스택 맨 위로 이동 (인자 #1 -> 리시버)
0x0060 CALL AX ;; 늘 하던 그 호출입니다
이제 인터페이스와 가상 메서드 호출이 동작하는 데 필요한 전체 기계장치를 명확히 그릴 수 있습니다. 다음 절에서는 이 기계장치의 실제 비용을 이론과 실전 양쪽에서 측정해 보겠습니다.
앞서 보았듯, 인터페이스 구현은 대부분의 일을 컴파일러와 링커에 맡깁니다. 성능 관점에서 이는 분명 좋은 소식입니다. 우리는 가능한 한 런타임의 일을 줄이고 싶기 때문입니다. 인터페이스를 인스턴스화할 때 런타임이 개입해야 하는 특정 사례도 존재합니다(예: runtime.convT2* 계열 함수). 하지만 실제로 그렇게 흔한 편은 아닙니다. 이런 경계 사례들은 인터페이스의 특수 사례를 다루는 절에서 더 알아보겠습니다.
그동안은 인터페이스 인스턴스화와 관련된 일회성 비용은 무시하고, 순수하게 가상 메서드 호출의 오버헤드에만 집중하겠습니다. 인터페이스가 제대로 인스턴스화된 뒤, 그 위에서 메서드를 호출하는 일은 일반적인 정적 디스패치 호출에 비해 한 단계 더 간접 참조를 거치는 것에 지나지 않습니다(즉 원하는 인덱스의 itab.fun을 역참조하는 것). 따라서 이 과정은 사실상 공짜일 것처럼 보이는데.. 반쯤은 맞고 반쯤은 아닙니다. 이론은 꽤 까다롭고, 현실은 그보다 더 까다롭습니다.
가상 호출에 내재된 추가 간접 참조는, CPU 입장에서 어느 정도 예측 가능하기만 하다면 사실상 공짜입니다. 현대 CPU는 매우 공격적인 괴물입니다. 공격적으로 캐시하고, 명령어와 데이터를 공격적으로 프리패치하고, 코드를 공격적으로 미리 실행하고, 심지어 필요하다고 생각하면 재정렬과 병렬화까지 합니다. 이 모든 추가 작업은 우리가 원하든 원하지 않든 수행되므로, CPU가 똑똑하게 굴려는 노력을 방해하지 않도록 항상 주의해야 합니다. 그렇지 않으면 소중한 사이클이 불필요하게 낭비됩니다.
바로 이 지점에서 가상 메서드 호출이 빠르게 문제가 될 수 있습니다. 정적 디스패치 호출의 경우 CPU는 프로그램의 다음 분기를 미리 알고 있으며, 그에 맞춰 필요한 명령어를 프리패치합니다. 그 결과 성능 측면에서 볼 때 프로그램의 한 분기에서 다른 분기로의 전환이 매끄럽고 투명하게 일어납니다.
반면 동적 디스패치에서는 CPU가 프로그램이 어디로 갈지 미리 알 수 없습니다. 정의상 그 결과는 런타임 전에는 알 수 없는 계산에 달려 있기 때문입니다. 이를 상쇄하기 위해 CPU는 다음 분기 방향을 추측하기 위한 다양한 알고리즘과 휴리스틱을 적용합니다(즉 "분기 예측"). 프로세서가 올바르게 추측하면 동적 분기도 정적 분기와 거의 같은 효율을 낼 수 있습니다. 목적지의 명령어가 이미 프로세서의 캐시에 프리패치되어 있기 때문입니다.
하지만 틀리면 상황이 좀 거칠어질 수 있습니다. 우선 추가 간접 참조 비용과, 올바른 명령어를 L1i 캐시에 적재하기 위해 메인 메모리에서 느린 로드를 수행하는 비용을 치러야 합니다(즉 CPU가 사실상 멈춥니다). 더 나쁜 것은 분기 예측 실패 후 자신의 실수를 되짚고 명령어 파이프라인을 비우는 비용까지 치러야 한다는 점입니다.
동적 디스패치의 또 다른 중요한 단점은 정의상 인라이닝을 불가능하게 만든다는 점입니다. 무엇이 올지 모르면 인라인할 수도 없기 때문입니다.
결국 적어도 이론상으로는, 인라인된 함수 F에 대한 직접 호출과, 인라인되지 못하고 몇 단계의 간접 참조를 거쳐야 했으며 심지어 그 길에서 분기 예측 실패까지 맞은 동일한 함수 호출 사이에 엄청난 성능 차이가 나는 것이 충분히 가능해 보입니다.
이론은 이 정도면 충분합니다. 하지만 현대 하드웨어를 다룰 때는 언제나 이론을 경계해야 합니다. 직접 측정해 봅시다.
우선 우리가 사용하는 CPU에 대한 정보부터 보겠습니다:
$ lscpu | sed -nr '/Model name/ s/.*:\s*(.* @ .*)/\1/p'
Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz
벤치마크에 사용할 인터페이스는 다음과 같이 정의하겠습니다(iface_bench_test.go):
type identifier interface {
idInline() int32
idNoInline() int32
}
type id32 struct{ id int32 }
// 참고: 포인터 리시버를 사용해서 자동 생성 래퍼로 인한 추가 오버헤드가
// 결과에 섞이지 않게 한다.
func (id *id32) idInline() int32 { return id.id }
//go:noinline
func (id *id32) idNoInline() int32 { return id.id }
벤치마크 모음 A: 단일 인스턴스, 많은 호출, 인라인/비인라인
첫 두 벤치마크에서는 바쁜 루프 안에서 비인라인 메서드를 *Adder 값과 iface 인터페이스 양쪽에 대해 호출해 보겠습니다:
var escapeMePlease *id32
// escapeToHeap은 `id`가 힙으로 탈출하도록 보장한다.
//
// 이 파일의 일부 벤치마크처럼 단순한 상황에서는,
// 컴파일러가 인터페이스의 실제 타입(정확히는 그것이 가리키는 데이터 타입)을
// 정적으로 추론할 수 있어서 원래 동적 메서드 호출이었어야 할 것을
// 정적 호출로 바꿔 버리기도 한다.
// 이 역최적화는 그런 과도한 영리함을 막는다.
//
//go:noinline
func escapeToHeap(id *id32) identifier {
escapeMePlease = id
return escapeMePlease
}
var myID int32
func BenchmarkMethodCall_direct(b *testing.B) {
b.Run("single/noinline", func(b *testing.B) {
m := escapeToHeap(&id32{id: 6754}).(*id32)
for i := 0; i < b.N; i++ {
// CALL "".(*id32).idNoInline(SB)
// MOVL 8(SP), AX
// MOVQ "".&myID+40(SP), CX
// MOVL AX, (CX)
myID = m.idNoInline()
}
})
}
func BenchmarkMethodCall_interface(b *testing.B) {
b.Run("single/noinline", func(b *testing.B) {
m := escapeToHeap(&id32{id: 6754})
for i := 0; i < b.N; i++ {
// MOVQ 32(AX), CX
// MOVQ "".m.data+40(SP), DX
// MOVQ DX, (SP)
// CALL CX
// MOVL 8(SP), AX
// MOVQ "".&myID+48(SP), CX
// MOVL AX, (CX)
myID = m.idNoInline()
}
})
}
우리는 두 벤치마크가 A) 매우 빠르게, 그리고 B) 거의 같은 속도로 실행되리라 예상합니다. 루프가 매우 빡빡하므로, 두 벤치마크 모두 루프 각 반복에서 데이터(리시버와 vtable)와 명령어("".(*id32).idNoInline)가 이미 CPU의 L1d/L1i 캐시에 존재한다고 기대할 수 있습니다. 즉 성능은 순전히 CPU에 의해 제한됩니다.
다만 BenchmarkMethodCall_interface는 약간 더 느릴 것입니다(나노초 수준에서). 올바른 포인터를 가상 테이블에서 찾아 복사하는 오버헤드를 처리해야 하기 때문입니다(그래도 가상 테이블은 이미 L1 캐시에 있습니다). CALL CX 명령은 이 추가 명령어들의 출력에 강하게 의존하므로, 프로세서는 이 추가 로직 전체를 순차적인 흐름으로 실행할 수밖에 없습니다. 명령어 수준 병렬화의 기회를 포기하는 셈입니다. 결국 이것이 인터페이스 버전이 약간 더 느릴 것이라 예상하는 주된 이유입니다.
"직접" 버전의 결과는 다음과 같습니다:
$ go test -run=NONE -o iface_bench_test.bin iface_bench_test.go && \
perf stat --cpu=1 \
taskset 2 \
./iface_bench_test.bin -test.cpu=1 -test.benchtime=1s -test.count=3 \
-test.bench='BenchmarkMethodCall_direct/single/noinline'
BenchmarkMethodCall_direct/single/noinline 2000000000 1.81 ns/op
BenchmarkMethodCall_direct/single/noinline 2000000000 1.80 ns/op
BenchmarkMethodCall_direct/single/noinline 2000000000 1.80 ns/op
Performance counter stats for 'CPU(s) 1':
11702.303843 cpu-clock (msec) # 1.000 CPUs utilized
2,481 context-switches # 0.212 K/sec
1 cpu-migrations # 0.000 K/sec
7,349 page-faults # 0.628 K/sec
43,726,491,825 cycles # 3.737 GHz
110,979,100,648 instructions # 2.54 insn per cycle
19,646,440,556 branches # 1678.852 M/sec
566,424 branch-misses # 0.00% of all branches
11.702332281 seconds time elapsed
그리고 "인터페이스" 버전은 다음과 같습니다:
$ go test -run=NONE -o iface_bench_test.bin iface_bench_test.go && \
perf stat --cpu=1 \
taskset 2 \
./iface_bench_test.bin -test.cpu=1 -test.benchtime=1s -test.count=3 \
-test.bench='BenchmarkMethodCall_interface/single/noinline'
BenchmarkMethodCall_interface/single/noinline 2000000000 1.95 ns/op
BenchmarkMethodCall_interface/single/noinline 2000000000 1.96 ns/op
BenchmarkMethodCall_interface/single/noinline 2000000000 1.96 ns/op
Performance counter stats for 'CPU(s) 1':
12709.383862 cpu-clock (msec) # 1.000 CPUs utilized
3,003 context-switches # 0.236 K/sec
1 cpu-migrations # 0.000 K/sec
10,524 page-faults # 0.828 K/sec
47,301,533,147 cycles # 3.722 GHz
124,467,105,161 instructions # 2.63 insn per cycle
19,878,711,448 branches # 1564.097 M/sec
761,899 branch-misses # 0.00% of all branches
12.709412950 seconds time elapsed
결과는 예상과 일치합니다. "인터페이스" 버전은 실제로 약간 더 느리며, 반복당 약 0.15나노초가 추가로 들고, 대략 ~8% 느려졌습니다.
8%라는 수치는 언뜻 눈에 띄어 보일 수 있지만, A) 이 값들이 나노초 단위 측정치라는 점, 그리고 B) 호출되는 메서드가 하는 일이 너무 적어서 호출 오버헤드가 더 과장된다는 점을 기억해야 합니다.
벤치마크당 명령어 수를 보면, 인터페이스 기반 버전은 직접 버전에 비해 약 140억 개 더 많은 명령어를 실행해야 했습니다(110,979,100,648 대 124,467,105,161). 두 벤치마크 모두 6,000,000,000(2,000,000,000*3)번 반복되었음에도 말입니다. 앞서 언급했듯 CPU는 CALL이 이 추가 명령어들에 의존하기 때문에 이를 병렬화할 수 없습니다. 이는 instruction-per-cycle 비율에 그대로 반영됩니다. 두 벤치마크는 IPC가 비슷합니다(2.54 대 2.63). 인터페이스 버전이 전체적으로 훨씬 더 많은 일을 했음에도 말입니다. 이러한 병렬성 부족이 약 35억 CPU 사이클의 추가 비용으로 쌓여, 우리가 측정한 추가 0.15ns로 이어집니다.
그렇다면 컴파일러가 메서드 호출을 인라인하도록 허용하면 어떻게 될까요?
var myID int32
func BenchmarkMethodCall_direct(b *testing.B) {
b.Run("single/inline", func(b *testing.B) {
m := escapeToHeap(&id32{id: 6754}).(*id32)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// MOVL (DX), SI
// MOVL SI, (CX)
myID = m.idInline()
}
})
}
func BenchmarkMethodCall_interface(b *testing.B) {
b.Run("single/inline", func(b *testing.B) {
m := escapeToHeap(&id32{id: 6754})
b.ResetTimer()
for i := 0; i < b.N; i++ {
// MOVQ 32(AX), CX
// MOVQ "".m.data+40(SP), DX
// MOVQ DX, (SP)
// CALL CX
// MOVL 8(SP), AX
// MOVQ "".&myID+48(SP), CX
// MOVL AX, (CX)
myID = m.idNoInline()
}
})
}
두 가지가 눈에 띕니다:
BenchmarkMethodCall_direct: 인라이닝 덕분에 호출은 단순한 두 개의 메모리 이동으로 축소되었습니다.BenchmarkMethodCall_interface: 동적 디스패치 때문에 컴파일러가 호출을 인라인할 수 없었고, 따라서 생성된 어셈블리는 이전과 완전히 동일합니다.코드가 전혀 바뀌지 않았으므로 BenchmarkMethodCall_interface는 굳이 다시 돌려보지 않겠습니다. 대신 "직접" 버전만 잠깐 보죠:
$ go test -run=NONE -o iface_bench_test.bin iface_bench_test.go && \
perf stat --cpu=1 \
taskset 2 \
./iface_bench_test.bin -test.cpu=1 -test.benchtime=1s -test.count=3 \
-test.bench='BenchmarkMethodCall_direct/single/inline'
BenchmarkMethodCall_direct/single/inline 2000000000 0.35 ns/op
BenchmarkMethodCall_direct/single/inline 2000000000 0.34 ns/op
BenchmarkMethodCall_direct/single/inline 2000000000 0.34 ns/op
Performance counter stats for 'CPU(s) 1':
2464.353001 cpu-clock (msec) # 1.000 CPUs utilized
629 context-switches # 0.255 K/sec
1 cpu-migrations # 0.000 K/sec
7,322 page-faults # 0.003 M/sec
9,026,867,915 cycles # 3.663 GHz
41,580,825,875 instructions # 4.61 insn per cycle
7,027,066,264 branches # 2851.485 M/sec
1,134,955 branch-misses # 0.02% of all branches
2.464386341 seconds time elapsed
예상대로 호출 오버헤드가 사라지자 엄청나게 빨라졌습니다. 직접 인라인 버전이 연산당 ~0.34ns이므로, 인터페이스 버전은 이제 ~475% 더 느립니다. 인라이닝을 끈 상태에서 측정했던 ~8% 차이와는 극적인 대비입니다. 메서드 호출에 내재된 분기가 사라졌기 때문에 CPU는 남은 명령어를 훨씬 더 효율적으로 병렬화하고 추측 실행할 수 있게 되어 IPC가 4.61까지 올라갑니다.
벤치마크 모음 B: 많은 인스턴스, 많은 비인라인 호출, 작은/큰/의사난수 증가폭
두 번째 벤치마크 모음에서는, 공통 메서드를 노출하는 객체들의 슬라이스를 반복자가 순회하며 각 객체에 대해 메서드를 호출하는 좀 더 현실적인 상황을 보겠습니다. 현실성을 높이기 위해 인라이닝은 끄겠습니다. 실제 프로그램에서 이렇게 호출되는 메서드는 대개 컴파일러가 인라인하기엔 충분히 복잡할 가능성이 높기 때문입니다(YMMV; 표준 라이브러리의 sort.Interface는 좋은 반례입니다).
세 개의 유사한 벤치마크를 정의할 텐데, 차이는 슬라이스를 접근하는 방식뿐입니다. 목표는 점점 캐시에 덜 친화적인 상황을 흉내 내는 것입니다:
세 경우 모두, 아주 바쁜 서버처럼 CPU 캐시와 메인 메모리에 큰 압박이 걸린 상황을 (정확하진 않게) 시뮬레이션하기 위해 배열이 프로세서의 어느 캐시에도 전부 들어가지 않도록 충분히 크게 만들 것입니다.
프로세서 속성을 다시 정리해 보겠습니다. 이 기준에 맞춰 벤치마크를 설계합니다:
$ lscpu | sed -nr '/Model name/ s/.*:\s*(.* @ .*)/\1/p'
Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz
$ lscpu | grep cache
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 6144K
$ getconf LEVEL1_DCACHE_LINESIZE
64
$ getconf LEVEL1_ICACHE_LINESIZE
64
$ find /sys/devices/system/cpu/cpu0/cache/index{1,2,3} -name "shared_cpu_list" -exec cat {} \;
# (주석은 제가 달았습니다)
0,4 # L1 (하이퍼스레딩)
0,4 # L2 (하이퍼스레딩)
0-7 # L3 (공유 + 하이퍼스레딩)
"직접" 버전에 대한 벤치마크 모음은 다음과 같습니다(baseline으로 표시된 벤치마크는 리시버를 가져오는 비용만 따로 계산해서 최종 측정치에서 뺄 수 있게 합니다):
const _maxSize = 2097152 // 2^21
const _maxSizeModMask = _maxSize - 1 // 핫패스에서 mod(%)를 피한다.
var _randIndexes = [_maxSize]int{}
func init() {
rand.Seed(42)
for i := range _randIndexes {
_randIndexes[i] = rand.Intn(_maxSize)
}
}
func BenchmarkMethodCall_direct(b *testing.B) {
adders := make([]*id32, _maxSize)
for i := range adders {
adders[i] = &id32{id: int32(i)}
}
runtime.GC()
var myID int32
b.Run("many/noinline/small_incr", func(b *testing.B) {
var m *id32
b.Run("baseline", func(b *testing.B) {
for i := 0; i < b.N; i++ {
m = adders[i&_maxSizeModMask]
}
})
b.Run("call", func(b *testing.B) {
for i := 0; i < b.N; i++ {
m = adders[i&_maxSizeModMask]
myID = m.idNoInline()
}
})
})
b.Run("many/noinline/big_incr", func(b *testing.B) {
var m *id32
b.Run("baseline", func(b *testing.B) {
j := 0
for i := 0; i < b.N; i++ {
m = adders[j&_maxSizeModMask]
j += 32
}
})
b.Run("call", func(b *testing.B) {
j := 0
for i := 0; i < b.N; i++ {
m = adders[j&_maxSizeModMask]
myID = m.idNoInline()
j += 32
}
})
})
b.Run("many/noinline/random_incr", func(b *testing.B) {
var m *id32
b.Run("baseline", func(b *testing.B) {
for i := 0; i < b.N; i++ {
m = adders[_randIndexes[i&_maxSizeModMask]]
}
})
b.Run("call", func(b *testing.B) {
for i := 0; i < b.N; i++ {
m = adders[_randIndexes[i&_maxSizeModMask]]
myID = m.idNoInline()
}
})
})
}
"인터페이스" 버전의 벤치마크 모음은 거의 동일하며, 예상대로 배열이 구체 타입 포인터 대신 인터페이스 값으로 초기화된다는 점만 다릅니다:
func BenchmarkMethodCall_interface(b *testing.B) {
adders := make([]identifier, _maxSize)
for i := range adders {
adders[i] = identifier(&id32{id: int32(i)})
}
runtime.GC()
/* ... */
}
"직접" 모음의 결과는 다음과 같습니다:
$ go test -run=NONE -o iface_bench_test.bin iface_bench_test.go && \
benchstat <( taskset 2 ./iface_bench_test.bin -test.cpu=1 -test.benchtime=1s -test.count=3 \
-test.bench='BenchmarkMethodCall_direct/many/noinline')
name time/op
MethodCall_direct/many/noinline/small_incr/baseline 0.99ns ± 3%
MethodCall_direct/many/noinline/small_incr/call 2.32ns ± 1% # 2.32 - 0.99 = 1.33ns
MethodCall_direct/many/noinline/big_incr/baseline 5.86ns ± 0%
MethodCall_direct/many/noinline/big_incr/call 17.1ns ± 1% # 17.1 - 5.86 = 11.24ns
MethodCall_direct/many/noinline/random_incr/baseline 8.80ns ± 0%
MethodCall_direct/many/noinline/random_incr/call 30.8ns ± 0% # 30.8 - 8.8 = 22ns
여기에는 놀랄 만한 점이 없습니다:
small_incr: 극도로 캐시 친화적이므로, 단일 인스턴스를 돌렸던 이전 벤치마크와 비슷한 결과가 나옵니다.big_incr: 매 반복마다 CPU가 새로운 캐시 라인을 가져오도록 강제하므로 지연 시간이 눈에 띄게 증가합니다. 하지만 이는 호출 자체의 비용과는 무관합니다. 약 6ns는 baseline 때문이고, 나머지는 리시버를 역참조하여 id 필드에 도달하는 비용과 반환값을 복사하는 비용의 조합입니다.random_incr: big_incr와 같은 이야기지만, A) 의사난수 접근과 B) 미리 계산한 인덱스 배열에서 다음 인덱스를 가져오는 비용(그 자체로 캐시 미스를 유발함) 때문에 지연 시간이 더 증가합니다.논리적으로 예상되듯 CPU의 데이터 캐시를 마구 흔드는 일은 직접 메서드 호출(인라인 여부와 무관함)의 지연 시간 자체에는 별 영향을 주지 않는 것처럼 보입니다. 다만 주변의 모든 작업은 느려집니다.
그렇다면 동적 디스패치는 어떨까요?
$ go test -run=NONE -o iface_bench_test.bin iface_bench_test.go && \
benchstat <( taskset 2 ./iface_bench_test.bin -test.cpu=1 -test.benchtime=1s -test.count=3 \
-test.bench='BenchmarkMethodCall_interface/many/inline')
name time/op
MethodCall_interface/many/noinline/small_incr/baseline 1.38ns ± 0%
MethodCall_interface/many/noinline/small_incr/call 3.48ns ± 0% # 3.48 - 1.38 = 2.1ns
MethodCall_interface/many/noinline/big_incr/baseline 6.86ns ± 0%
MethodCall_interface/many/noinline/big_incr/call 19.6ns ± 1% # 19.6 - 6.86 = 12.74ns
MethodCall_interface/many/noinline/random_incr/baseline 11.0ns ± 0%
MethodCall_interface/many/noinline/random_incr/call 34.7ns ± 0% # 34.7 - 11.0 = 23.7ns
결과는 극도로 비슷하며, 각 반복에서 슬라이스에서 꺼내는 것이 구체 타입 포인터 하나가 아니라 identifier 인터페이스의 두 개의 워드이기 때문에 전반적으로 아주 약간 더 느릴 뿐입니다. 이것이 직접 버전과 거의 비슷한 속도로 동작하는 이유는 슬라이스 안의 모든 인터페이스가 공통 itab을 공유하기 때문입니다(즉 전부 iface 인터페이스입니다). 따라서 이들의 가상 테이블은 L1d 캐시에서 떠나지 않으며, 각 반복마다 올바른 메서드 포인터를 가져오는 비용은 사실상 공짜입니다. 마찬가지로 main.(*id32).idNoInline 메서드의 본문을 이루는 명령어들도 L1i 캐시를 떠나지 않습니다.
실제로는 인터페이스 슬라이스가 다양한 실제 타입(따라서 다양한 vtable)을 담고 있을 테니, 서로 다른 vtable이 L1i와 L1d 캐시를 밀어내면서 캐시가 흔들리지 않겠느냐고 생각할 수도 있습니다. 이론적으로는 맞는 말입니다. 하지만 이런 생각은 대개 C++ 같은 오래된 객체지향 언어를 오래 써온 경험에서 나오는 경우가 많습니다. 그런 언어들은(적어도 예전에는) 깊게 중첩된 클래스 상속 계층과 가상 호출을 핵심 추상화 도구로 장려했습니다.
계층이 충분히 크면, 가상 클래스의 여러 구현을 담는 자료구조를 순회할 때 CPU 캐시를 흔들 만큼 많은 vtable이 생기곤 했습니다(예를 들어 모든 것이 그래프형 자료구조 안의 Widget인 GUI 프레임워크를 생각해 보세요). 특히 C++에서는 가상 클래스가 종종 수십 개의 메서드를 가진 꽤 복잡한 동작을 정의하곤 했기 때문에 vtable도 커졌고, L1d 캐시에 더 많은 압박을 주었습니다.
반면 Go는 관용구가 매우 다릅니다. 객체지향은 사실상 창밖으로 던져졌고, 타입 시스템은 평평하며, 인터페이스는 대개 암묵적으로 만족된다는 특징의 도움을 받아, 복잡하고 층이 많은 타입 계층 위의 추상화보다는 최소한의 제한된 동작(평균적으로는 몇 개 안 되는 메서드)을 설명하는 데 더 자주 사용됩니다. 실제로 Go에서 다양한 실제 타입을 많이 담은 인터페이스 집합을 순회해야 하는 경우는 매우 드문 편이라고 느꼈습니다. 물론 상황에 따라 다를 수 있습니다.
호기심 많은 독자를 위해, 인라이닝이 활성화된 "직접" 버전의 결과가 어땠을지도 적어 두겠습니다:
name time/op
MethodCall_direct/many/inline/small_incr 0.97ns ± 1% # 0.97ns
MethodCall_direct/many/inline/big_incr/baseline 5.96ns ± 1%
MethodCall_direct/many/inline/big_incr/call 11.9ns ± 1% # 11.9 - 5.96 = 5.94ns
MethodCall_direct/many/inline/random_incr/baseline 9.20ns ± 1%
MethodCall_direct/many/inline/random_incr/call 16.9ns ± 1% # 16.9 - 9.2 = 7.7ns
이 경우 직접 버전은, 컴파일러가 호출을 인라인할 수 있는 상황에서는 인터페이스 버전보다 약 2~3배 빨랐을 것입니다. 그렇다 해도 앞서 말했듯 현재 컴파일러의 인라이닝 능력이 제한적이기 때문에, 실제로 이런 승리를 자주 보게 되지는 않을 것입니다. 물론 어쨌든 가상 호출을 써야만 하는 경우도 많습니다.
결론
가상 호출의 지연 시간을 제대로 측정하는 일은 매우 복잡한 작업임이 드러났습니다. 그 대부분이 현대 하드웨어의 복잡한 구현 세부사항에서 비롯되는 여러 상호 얽힌 부작용들의 직접적인 결과이기 때문입니다.
Go에서는, 언어 설계가 장려하는 관용구와 컴파일러의 현재 인라이닝 한계를 고려하면, 동적 디스패치는 사실상 공짜라고 생각해도 될 정도입니다. 그래도 의심이 든다면 항상 핫패스를 직접 측정하고 관련 성능 카운터를 확인하여, 동적 디스패치가 실제로 문제인지 아닌지 확실히 판단해야 합니다.
(참고: 이 책의 나중 챕터에서 컴파일러의 인라이닝 능력 자체를 따로 다룰 예정입니다.)
이 절에서는 인터페이스를 다룰 때 일상적으로 마주치는 가장 흔한 특수 사례들을 살펴보겠습니다. 이제 인터페이스가 어떻게 동작하는지 꽤 명확한 그림이 머릿속에 있으리라 생각하므로, 여기서는 간결함을 목표로 하겠습니다.
빈 인터페이스의 데이터 구조는 직관 그대로입니다. itab이 없는 iface라고 생각하면 됩니다. 이유는 두 가지입니다:
참고: iface에서 썼던 표기와 비슷하게, 타입 T를 담는 빈 인터페이스는 eface로 표기하겠습니다.
eface는 runtime 내부에서 빈 인터페이스를 표현하는 루트 타입입니다(src/runtime/runtime2.go). 정의는 다음과 같습니다:
type eface struct { // 64비트 아키텍처에서 16바이트
_type *_type
data unsafe.Pointer
}
여기서 _type은 data가 가리키는 값의 타입 정보를 담습니다. 예상대로 itab은 완전히 제거되었습니다.
빈 인터페이스가 iface 데이터 구조를 그대로 재사용할 수도 있었을 텐데(eface의 상위집합이니까), runtime은 공간 효율성과 코드 명확성이라는 두 가지 이유로 둘을 구분합니다.
앞서 이 챕터의 인터페이스의 해부 절에서, 정수처럼 단순한 스칼라 타입을 인터페이스에 저장하는 것조차 힙 할당을 유발한다고 언급했습니다. 이제 왜 그런지, 그리고 어떻게 그런지를 보겠습니다.
다음 두 벤치마크를 봅시다(eface_scalar_test.go):
func BenchmarkEfaceScalar(b *testing.B) {
var Uint uint32
b.Run("uint32", func(b *testing.B) {
for i := 0; i < b.N; i++ {
Uint = uint32(i)
}
})
var Eface interface{}
b.Run("eface32", func(b *testing.B) {
for i := 0; i < b.N; i++ {
Eface = uint32(i)
}
})
}
$ go test -benchmem -bench=. ./eface_scalar_test.go
BenchmarkEfaceScalar/uint32-8 2000000000 0.54 ns/op 0 B/op 0 allocs/op
BenchmarkEfaceScalar/eface32-8 100000000 12.3 ns/op 4 B/op 1 allocs/op
분명히 두 번째 경우에는 무거운 숨겨진 기계장치가 작동하고 있습니다. 생성된 어셈블리를 봐야 합니다.
첫 번째 벤치마크에서, 대입 연산에 대해 컴파일러는 예상 그대로의 코드를 생성합니다:
;; Uint = uint32(i)
0x000d MOVL DX, (AX)
하지만 두 번째 벤치마크에서는 상황이 훨씬 복잡합니다:
;; Eface = uint32(i)
0x0050 MOVL CX, ""..autotmp_3+36(SP)
0x0054 LEAQ type.uint32(SB), AX
0x005b MOVQ AX, (SP)
0x005f LEAQ ""..autotmp_3+36(SP), DX
0x0064 MOVQ DX, 8(SP)
0x0069 CALL runtime.convT2E32(SB)
0x006e MOVQ 24(SP), AX
0x0073 MOVQ 16(SP), CX
0x0078 MOVQ "".&Eface+48(SP), DX
0x007d MOVQ CX, (DX)
0x0080 MOVL runtime.writeBarrier(SB), CX
0x0086 LEAQ 8(DX), DI
0x008a TESTL CX, CX
0x008c JNE 148
0x008e MOVQ AX, 8(DX)
0x0092 JMP 46
0x0094 CALL runtime.gcWriteBarrier(SB)
0x0099 JMP 46
이건 오직 대입 연산만의 코드입니다. 벤치마크 전체가 아닙니다! 이 코드를 부분별로 나누어 봐야 합니다.
1단계: 인터페이스 생성
0x0050 MOVL CX, ""..autotmp_3+36(SP)
0x0054 LEAQ type.uint32(SB), AX
0x005b MOVQ AX, (SP)
0x005f LEAQ ""..autotmp_3+36(SP), DX
0x0064 MOVQ DX, 8(SP)
0x0069 CALL runtime.convT2E32(SB)
0x006e MOVQ 24(SP), AX
0x0073 MOVQ 16(SP), CX
이 첫 부분은 나중에 Eface에 대입할 빈 인터페이스 eface를 인스턴스화합니다. 인터페이스 생성 절에서 이미 비슷한 코드를 보았습니다. 그때는 runtime.convT2E32 대신 runtime.convT2I32를 호출했을 뿐입니다. 그럼에도 익숙하게 보일 것입니다.
사실 runtime.convT2I32와 runtime.convT2E32는 더 큰 함수 계열의 일부입니다. 이 함수들의 일은 스칼라 값(또는 특수 사례인 string이나 slice)으로부터 특정 인터페이스 또는 빈 인터페이스를 인스턴스화하는 것입니다. 이 계열은 (eface/iface, 16/32/64/string/slice)의 조합마다 하나씩 총 10개의 심볼로 이루어져 있습니다:
// 스칼라 값으로부터 빈 인터페이스
func convT2E16(t *_type, elem unsafe.Pointer) (e eface) {}
func convT2E32(t *_type, elem unsafe.Pointer) (e eface) {}
func convT2E64(t *_type, elem unsafe.Pointer) (e eface) {}
func convT2Estring(t *_type, elem unsafe.Pointer) (e eface) {}
func convT2Eslice(t *_type, elem unsafe.Pointer) (e eface) {}
// 스칼라 값으로부터 인터페이스
func convT2I16(tab *itab, elem unsafe.Pointer) (i iface) {}
func convT2I32(tab *itab, elem unsafe.Pointer) (i iface) {}
func convT2I64(tab *itab, elem unsafe.Pointer) (i iface) {}
func convT2Istring(tab *itab, elem unsafe.Pointer) (i iface) {}
func convT2Islice(tab *itab, elem unsafe.Pointer) (i iface) {}
(convT2E8이나 convT2I8 함수가 없다는 점을 눈치챘을 것입니다. 이는 이 절 끝에서 살펴볼 컴파일러 최적화 때문입니다.)
이 함수들은 반환 타입(iface 대 eface)과 힙에 할당하는 메모리 크기만 다를 뿐, 거의 같은 일을 합니다. 예를 들어 runtime.convT2E32를 자세히 봅시다(src/runtime/iface.go):
func convT2E32(t *_type, elem unsafe.Pointer) (e eface) {
/* ...디버그 관련 생략... */
var x unsafe.Pointer
if *(*uint32)(elem) == 0 {
x = unsafe.Pointer(&zeroVal[0])
} else {
x = mallocgc(4, t, false)
*(*uint32)(x) = *(*uint32)(elem)
}
e._type = t
e.data = x
return
}
이 함수는 호출자가 "전달한" eface 구조체의 _type 필드를 첫 번째 파라미터로 받은 _type으로 초기화합니다(반환값은 호출자가 자신의 스택 프레임 위에 할당한다는 점을 기억하세요). eface의 data 필드는 두 번째 파라미터 elem의 값에 따라 달라집니다:
elem이 0이면 e.data는 runtime.zeroVal을 가리키도록 초기화됩니다. 이는 runtime이 정의한 특수 전역 변수로, 제로값을 나타냅니다. 다음 절에서 이 특수 변수에 대해 조금 더 이야기하겠습니다.elem이 0이 아니면, 함수는 힙에 4바이트를 할당하고(x = mallocgc(4, t, false)), 그 4바이트의 내용을 elem이 가리키는 값으로 초기화한 뒤(*(*uint32)(x) = *(*uint32)(elem)), 그 결과 포인터를 e.data에 넣습니다.이 경우 e._type은 type.uint32의 주소를 담고 있는데(LEAQ type.uint32(SB), AX), 이 심볼은 표준 라이브러리에 의해 제공되며 그 주소는 표준 라이브러리와 링크할 때에야 결정됩니다:
$ go tool nm eface_scalar_test.o | grep 'type\.uint32'
U type.uint32
(U는 이 심볼이 현재 오브젝트 파일에는 정의되어 있지 않으며, 링크 시점에 다른 오브젝트(이 경우 표준 라이브러리)가 제공해 줄 것이라는 뜻입니다.)
2단계: 결과 대입(파트 1)
0x0078 MOVQ "".&Eface+48(SP), DX
0x007d MOVQ CX, (DX) ;; Eface._type = ret._type
runtime.convT2E32의 결과가 Eface 변수에 대입됩니다.. 라고 할 수 있을까요? 사실 지금은 반환값의 _type 필드만 Eface._type에 대입되고 있습니다. data 필드는 아직 복사할 수 없습니다.
3단계: 결과 대입(파트 2), 또는 가비지 컬렉터에게 맡기기
0x0080 MOVL runtime.writeBarrier(SB), CX
0x0086 LEAQ 8(DX), DI ;; Eface.data = ret.data (간접적으로 runtime.gcWriteBarrier를 통해)
0x008a TESTL CX, CX
0x008c JNE 148
0x008e MOVQ AX, 8(DX) ;; Eface.data = ret.data (직접)
0x0092 JMP 46
0x0094 CALL runtime.gcWriteBarrier(SB)
0x0099 JMP 46
이 마지막 부분이 복잡해 보이는 이유는, 반환된 eface의 data 포인터를 Eface.data에 대입하는 행위가 우리 프로그램의 메모리 그래프(어느 메모리 영역이 어느 메모리를 참조하는지)를 바꾸는 일이기 때문입니다. 혹시 백그라운드에서 가비지 컬렉션이 진행 중이라면, 이 변화를 가비지 컬렉터에게 알려야 할 수도 있습니다. 이것이 바로 write barrier이며, Go의 동시성 가비지 컬렉터의 직접적인 결과입니다.
지금은 조금 모호하게 들려도 걱정하지 마세요. 이 책의 다음 챕터에서 Go의 가비지 컬렉션을 자세히 다룰 예정입니다. 지금은 어셈블리 코드가 runtime.gcWriteBarrier를 호출하면 그것이 포인터 조작과 가비지 컬렉터 통지와 관련 있다는 정도만 기억하면 충분합니다.
결국 이 마지막 코드는 두 가지 중 하나를 합니다:
ret.data를 Eface.data에 직접 대입합니다(MOVQ AX, 8(DX)).LEAQ 8(DX), DI + CALL runtime.gcWriteBarrier(SB)).(다시 말하지만 지금은 이 부분에 너무 신경 쓰지 마세요.)
짜잔, 이제 단순한 스칼라 타입(uint32)을 담은 완전한 인터페이스를 얻었습니다.
결론
스칼라 값을 인터페이스에 넣는 일은 실무에서 그리 자주 일어나지 않을 수 있지만, 여러 이유로 비용이 클 수 있으며, 따라서 그 뒤의 기계장치를 이해하는 것은 중요합니다. 비용 이야기가 나온 김에, 컴파일러가 특정 상황에서 할당을 피하기 위해 여러 트릭을 쓴다고 언급했었죠. 이 절은 그중 3가지를 빠르게 훑는 것으로 마무리하겠습니다.
인터페이스 트릭 1: 바이트 크기 값
다음은 eface를 인스턴스화하는 벤치마크입니다(eface_scalar_test.go):
func BenchmarkEfaceScalar(b *testing.B) {
b.Run("eface8", func(b *testing.B) {
for i := 0; i < b.N; i++ {
// LEAQ type.uint8(SB), BX
// MOVQ BX, (CX)
// MOVBLZX AL, SI
// LEAQ runtime.staticbytes(SB), R8
// ADDQ R8, SI
// MOVL runtime.writeBarrier(SB), R9
// LEAQ 8(CX), DI
// TESTL R9, R9
// JNE 100
// MOVQ SI, 8(CX)
// JMP 40
// MOVQ AX, R9
// MOVQ SI, AX
// CALL runtime.gcWriteBarrier(SB)
// MOVQ R9, AX
// JMP 40
Eface = uint8(i)
}
})
}
$ go test -benchmem -bench=BenchmarkEfaceScalar/eface8 ./eface_scalar_test.go
BenchmarkEfaceScalar/eface8-8 2000000000 0.88 ns/op 0 B/op 0 allocs/op
바이트 크기 값의 경우 컴파일러는 runtime.convT2E/runtime.convT2I 호출과 그에 따른 힙 할당을 피하고, 대신 runtime이 노출하는 전역 변수의 주소를 재사용합니다. 그 전역 변수는 이미 우리가 원하는 1바이트 값을 담고 있습니다: LEAQ runtime.staticbytes(SB), R8.
runtime.staticbytes는 src/runtime/iface.go에서 찾을 수 있으며 다음과 같이 생겼습니다:
// staticbytes is used to avoid convT2E for byte-sized values.
var staticbytes = [...]byte{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f,
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f,
0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f,
0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57,
0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f,
0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67,
0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f,
0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77,
0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87,
0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f,
0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f,
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7,
0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf,
0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7,
0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf,
0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7,
0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf,
0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf,
0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7,
0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef,
0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7,
0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff,
}
이 배열에서 적절한 오프셋을 사용하면 컴파일러는 추가 힙 할당을 피하면서도 1바이트로 표현 가능한 어떤 값이든 참조할 수 있습니다.
그런데 여기에는 어딘가 찜찜한 점이 있습니다.. 눈치채셨나요? 생성된 코드는 여전히 write barrier 관련 기계장치를 그대로 담고 있습니다. 하지만 우리가 조작하는 포인터는 프로그램 전체와 동일한 수명을 가진 전역 변수의 주소를 가리킵니다. 즉 runtime.staticbytes는 참조를 누가 가지든 절대 가비지 컬렉션 대상이 될 수 없습니다. 이런 경우라면 write barrier 오버헤드를 치를 필요가 없어야 합니다.
인터페이스 트릭 2: 정적 추론
다음은 컴파일 시점에 이미 알려진 값으로부터 eface를 인스턴스화하는 벤치마크입니다(eface_scalar_test.go):
func BenchmarkEfaceScalar(b *testing.B) {
b.Run("eface-static", func(b *testing.B) {
for i := 0; i < b.N; i++ {
// LEAQ type.uint64(SB), BX
// MOVQ BX, (CX)
// MOVL runtime.writeBarrier(SB), SI
// LEAQ 8(CX), DI
// TESTL SI, SI
// JNE 92
// LEAQ "".statictmp_0(SB), SI
// MOVQ SI, 8(CX)
// JMP 40
// MOVQ AX, SI
// LEAQ "".statictmp_0(SB), AX
// CALL runtime.gcWriteBarrier(SB)
// MOVQ SI, AX
// LEAQ "".statictmp_0(SB), SI
// JMP 40
Eface = uint64(42)
}
})
}
$ go test -benchmem -bench=BenchmarkEfaceScalar/eface-static ./eface_scalar_test.go
BenchmarkEfaceScalar/eface-static-8 2000000000 0.81 ns/op 0 B/op 0 allocs/op
생성된 어셈블리를 보면, 컴파일러는 runtime.convT2E64 호출을 완전히 최적화해 없애고, 우리가 원하는 값을 이미 담고 있는 자동 생성 전역 변수의 주소를 적재해서 빈 인터페이스를 직접 구성합니다: LEAQ "".statictmp_0(SB), SI ((SB) 부분은 전역 변수라는 뜻입니다).
앞서 만들었던 dump_sym.sh 스크립트를 사용하면 무슨 일이 일어나는지 더 잘 볼 수 있습니다.
$ GOOS=linux GOARCH=amd64 go tool compile eface_scalar_test.go
$ GOOS=linux GOARCH=amd64 go tool link -o eface_scalar_test.bin eface_scalar_test.o
$ ./dump_sym.sh eface_scalar_test.bin .rodata main.statictmp_0
.rodata file-offset: 655360
.rodata VMA: 4849664
main.statictmp_0 VMA: 5145768
main.statictmp_0 SIZE: 8
0000000 002a 0000 0000 0000
0000008
예상대로 main.statictmp_0는 값이 0x000000000000002a, 즉 $42인 8바이트 변수입니다.
인터페이스 트릭 3: 제로값
마지막 트릭으로, 제로값에서 eface를 인스턴스화하는 다음 벤치마크를 봅시다(eface_scalar_test.go):
func BenchmarkEfaceScalar(b *testing.B) {
b.Run("eface-zeroval", func(b *testing.B) {
for i := 0; i < b.N; i++ {
// MOVL $0, ""..autotmp_3+36(SP)
// LEAQ type.uint32(SB), AX
// MOVQ AX, (SP)
// LEAQ ""..autotmp_3+36(SP), CX
// MOVQ CX, 8(SP)
// CALL runtime.convT2E32(SB)
// MOVQ 16(SP), AX
// MOVQ 24(SP), CX
// MOVQ "".&Eface+48(SP), DX
// MOVQ AX, (DX)
// MOVL runtime.writeBarrier(SB), AX
// LEAQ 8(DX), DI
// TESTL AX, AX
// JNE 152
// MOVQ CX, 8(DX)
// JMP 46
// MOVQ CX, AX
// CALL runtime.gcWriteBarrier(SB)
// JMP 46
Eface = uint32(i - i) // 컴파일러를 속임 (정적 추론 회피)
}
})
}
$ go test -benchmem -bench=BenchmarkEfaceScalar/eface-zero ./eface_scalar_test.go
BenchmarkEfaceScalar/eface-zeroval-8 500000000 3.14 ns/op 0 B/op 0 allocs/op
먼저 uint32(0) 대신 uint32(i - i)를 사용하여 최적화 #2(정적 추론)를 피한다는 점을 주목하세요.
(물론 전역 제로 변수를 하나 선언해도 컴파일러가 보수적 경로를 택하도록 강제할 수 있었겠죠.. 하지만 우리는 여기서 조금 즐기고 있는 겁니다. 너무 딱딱하게 굴지 마세요.)
생성된 코드는 이제 평범한, 할당이 발생하는 경우와 정확히 똑같아 보입니다. 그런데도 할당이 일어나지 않습니다. 무슨 일이 벌어지는 걸까요?
앞서 runtime.convT2E32를 해부할 때 언급했듯, 이 경우 할당은 트릭 #1(바이트 크기 값)과 비슷한 방식으로 최적화될 수 있습니다. 어떤 코드가 제로값을 담은 변수를 참조해야 하면, 컴파일러는 단순히 runtime이 노출하는 항상 0인 전역 변수의 주소를 넘겨줍니다. runtime.staticbytes와 비슷하게, 이 변수는 runtime 코드에서 찾을 수 있습니다(src/runtime/hashmap.go):
const maxZero = 1024 // must match value in ../cmd/compile/internal/gc/walk.go
var zeroVal [maxZero]byte
이것으로 작은 최적화 여행은 끝입니다. 지금까지 살펴본 모든 벤치마크를 요약하며 이 절을 마무리하겠습니다:
$ go test -benchmem -bench=. ./eface_scalar_test.go
BenchmarkEfaceScalar/uint32-8 2000000000 0.54 ns/op 0 B/op 0 allocs/op
BenchmarkEfaceScalar/eface32-8 100000000 12.3 ns/op 4 B/op 1 allocs/op
BenchmarkEfaceScalar/eface8-8 2000000000 0.88 ns/op 0 B/op 0 allocs/op
BenchmarkEfaceScalar/eface-zeroval-8 500000000 3.14 ns/op 0 B/op 0 allocs/op
BenchmarkEfaceScalar/eface-static-8 2000000000 0.81 ns/op 0 B/op 0 allocs/op
방금 보았듯, runtime.convT2* 계열 함수는 결과 인터페이스가 보유하게 될 데이터가 제로값을 참조하는 경우 힙 할당을 피합니다. 이 최적화는 인터페이스에만 국한되지 않으며, 포인터가 제로값을 가리켜야 할 때 불필요한 할당을 피하기 위해 runtime이 노출하는 특별한, 항상 0인 변수의 주소를 사용하는 더 넓은 노력의 일부입니다.
간단한 프로그램으로 이를 확인할 수 있습니다(zeroval.go):
//go:linkname zeroVal runtime.zeroVal
var zeroVal uintptr
type eface struct{ _type, data unsafe.Pointer }
func main() {
x := 42
var i interface{} = x - x // 컴파일러를 속임 (정적 추론 회피)
fmt.Printf("zeroVal = %p\n", &zeroVal)
fmt.Printf(" i = %p\n", ((*eface)(unsafe.Pointer(&i))).data)
}
$ go run zeroval.go
zeroVal = 0x5458e0
i = 0x5458e0
예상대로입니다. //go:linkname 지시어가 외부 심볼을 참조할 수 있게 해준다는 점도 주목하세요:
//go:linkname 지시어는 소스 코드에서 “localname”으로 선언된 변수 또는 함수에 대해, 컴파일러가 오브젝트 파일 심볼 이름으로 “importpath.name”을 사용하도록 지시한다. 이 지시어는 타입 시스템과 패키지 모듈성을 무력화할 수 있으므로, "unsafe"를 import한 파일에서만 활성화된다.
제로값과 비슷한 맥락에서, Go 프로그램에서 매우 흔한 트릭 중 하나는 struct{}{} 같은 크기 0인 객체를 인스턴스화해도 할당이 일어나지 않는다는 사실을 이용하는 것입니다. 공식 Go 명세(이 챕터 끝에 링크되어 있습니다)는 이를 설명하는 메모로 끝납니다:
어떤 struct 또는 array 타입이 크기가 0보다 큰 필드(또는 원소)를 전혀 포함하지 않으면 그 타입의 크기는 0이다.
서로 다른 두 개의 크기 0 변수는 메모리에서 같은 주소를 가질 수 있다.
"같은 주소를 가질 수 있다"에서 "수 있다"는, 컴파일러가 이 사실을 보장하지는 않음을 의미합니다. 하지만 공식 Go 컴파일러(gc)의 현재 구현에서는 늘 그랬고 지금도 그렇습니다.
늘 하듯 간단한 프로그램으로 확인해 볼 수 있습니다(zerobase.go):
func main() {
var s struct{}
var a [42]struct{}
fmt.Printf("s = % p\n", &s)
fmt.Printf("a = % p\n", &a)
}
$ go run zerobase.go
s = 0x546fa8
a = 0x546fa8
이 주소 뒤에 무엇이 숨어 있는지 알고 싶다면, 바이너리 안을 보면 됩니다:
$ go build -o zerobase.bin zerobase.go && objdump -t zerobase.bin | grep 546fa8
0000000000546fa8 g O .noptrbss 0000000000000008 runtime.zerobase
그 다음은 runtime 소스 코드에서 runtime.zerobase 변수를 찾는 것뿐입니다(src/runtime/malloc.go):
// 모든 0바이트 할당의 기준 주소
var zerobase uintptr
정말 정말 확실히 하고 싶다면 이렇게도 할 수 있습니다:
//go:linkname zerobase runtime.zerobase
var zerobase uintptr
func main() {
var s struct{}
var a [42]struct{}
fmt.Printf("zerobase = %p\n", &zerobase)
fmt.Printf(" s = %p\n", &s)
fmt.Printf(" a = %p\n", &a)
}
$ go run zerobase.go
zerobase = 0x546fa8
s = 0x546fa8
a = 0x546fa8
인터페이스 합성에는 사실 특별한 것이 전혀 없습니다. 그것은 단지 컴파일러가 제공하는 문법적 설탕일 뿐입니다. 다음 프로그램을 봅시다(compound_interface.go):
type Adder interface{ Add(a, b int32) int32 }
type Subber interface{ Sub(a, b int32) int32 }
type Mather interface {
Adder
Subber
}
type Calculator struct{ id int32 }
func (c *Calculator) Add(a, b int32) int32 { return a + b }
func (c *Calculator) Sub(a, b int32) int32 { return a - b }
func main() {
calc := Calculator{id: 6754}
var m Mather = &calc
m.Sub(10, 32)
}
늘 그렇듯, 컴파일러는 iface에 대응하는 itab을 생성합니다:
$ GOOS=linux GOARCH=amd64 go tool compile -S compound_interface.go | \
grep -A 7 '^go.itab.\*"".Calculator,"".Mather'
go.itab.*"".Calculator,"".Mather SRODATA dupok size=40
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x0010 5e 33 ca c8 00 00 00 00 00 00 00 00 00 00 00 00 ^3..............
0x0020 00 00 00 00 00 00 00 00 ........
rel 0+8 t=1 type."".Mather+0
rel 8+8 t=1 type.*"".Calculator+0
rel 24+8 t=1 "".(*Calculator).Add+0
rel 32+8 t=1 "".(*Calculator).Sub+0
재배치 지시를 보면, 컴파일러가 생성한 가상 테이블이 예상대로 Adder의 메서드와 Subber의 메서드를 모두 담고 있음을 알 수 있습니다:
rel 24+8 t=1 "".(*Calculator).Add+0
rel 32+8 t=1 "".(*Calculator).Sub+0
말했듯 인터페이스 합성에는 비밀 소스가 없습니다.
덧붙여, 이 작은 프로그램은 지금까지 보지 않았던 한 가지를 보여줍니다. 생성된 itab이 구체 값이 아니라 Calculator에 대한 포인터에 맞춰져 있기 때문에, 이 사실은 심볼 이름(go.itab.*"".Calculator,"".Mather)에도, 그것이 포함하는 _type(type.*"".Calculator)에도 그대로 반영됩니다. 이는 이 챕터 앞부분에서 보았던 메서드 심볼 이름 규칙과 일관됩니다.
이 챕터의 마지막으로 타입 단언을 구현과 비용 양쪽 측면에서 살펴보겠습니다.
다음 짧은 프로그램을 봅시다(eface_to_type.go):
var j uint32
var Eface interface{}
// 컴파일러를 속임 (정적 추론 회피)
func assertion() {
i := uint64(42)
Eface = i
j = Eface.(uint32)
}
j = Eface.(uint32)에 대한 주석 달린 어셈블리 목록은 다음과 같습니다:
0x0065 00101 MOVQ "".Eface(SB), AX ;; AX = Eface._type
0x006c 00108 MOVQ "".Eface+8(SB), CX ;; CX = Eface.data
0x0073 00115 LEAQ type.uint32(SB), DX ;; DX = type.uint32
0x007a 00122 CMPQ AX, DX ;; Eface._type == type.uint32 ?
0x007d 00125 JNE 162 ;; 아니면 panic 경로로
0x007f 00127 MOVL (CX), AX ;; AX = *Eface.data
0x0081 00129 MOVL AX, "".j(SB) ;; j = AX = *Eface.data
;; 종료
0x0087 00135 MOVQ 40(SP), BP
0x008c 00140 ADDQ $48, SP
0x0090 00144 RET
;; panic: interface conversion: is , not
0x00a2 00162 MOVQ AX, (SP) ;; have: Eface._type
0x00a6 00166 MOVQ DX, 8(SP) ;; want: type.uint32
0x00ab 00171 LEAQ type.interface {}(SB), AX ;; AX = type.interface{} (eface)
0x00b2 00178 MOVQ AX, 16(SP) ;; iface: AX
0x00b7 00183 CALL runtime.panicdottypeE(SB) ;; func panicdottypeE(have, want, iface *_type)
0x00bc 00188 UNDEF
0x00be 00190 NOP
놀라운 것은 없습니다. 코드는 Eface._type이 가진 주소와 type.uint32의 주소를 비교합니다. 앞서 보았듯 type.uint32는 uint32를 설명하는 _type 구조체의 내용을 담고 있는 전역 심볼로 표준 라이브러리에 의해 제공됩니다. _type 포인터가 일치하면 문제없이 *Eface.data를 j에 대입할 수 있고, 그렇지 않으면 runtime.panicdottypeE를 호출해 어떤 타입 불일치가 있었는지 정확히 설명하는 panic 메시지를 던집니다.
runtime.panicdottypeE는 기대한 그대로의 아주 단순한 함수입니다(src/runtime/iface.go):
// panicdottypeE is called when doing an e.(T) conversion and the conversion fails.
// have = the dynamic type we have.
// want = the static type we're trying to convert to.
// iface = the static type we're converting from.
func panicdottypeE(have, want, iface *_type) {
haveString := ""
if have != nil {
haveString = have.string()
}
panic(&TypeAssertionError{iface.string(), haveString, want.string(), ""})
}
그렇다면 성능은 어떨까요?
보면 이렇습니다: 메인 메모리에서 몇 번의 MOV, 매우 예측 가능한 분기 하나, 그리고 마지막으로 포인터 역참조(j = *Eface.data)가 있습니다(애초에 인터페이스를 구체 값으로 초기화했기 때문에 필요한 것이고, 그렇지 않았다면 Eface.data 포인터를 바로 복사할 수도 있었겠죠).
굳이 마이크로벤치마킹할 가치도 없을 정도입니다. 앞서 측정한 동적 디스패치 오버헤드와 마찬가지로, 이 자체만 놓고 보면 이론적으로는 거의 공짜입니다. 실제 비용은 대부분 코드 경로가 캐시 친화적이냐 아니냐 등에 더 많이 좌우될 것입니다. 단순한 마이크로벤치마크는 오히려 너무 왜곡되어 유용한 정보를 주지 못할 가능성이 큽니다.
결국 결론은 언제나 같았습니다. 당신의 구체적인 사용 사례에서 직접 측정하고, 프로세서의 성능 카운터를 확인하고, 이것이 핫패스에 눈에 띄는 영향을 주는지 확인하세요. 그럴 수도 있습니다. 아닐 수도 있습니다. 대체로는 아닐 가능성이 큽니다.
물론 타입 스위치는 조금 더 까다롭습니다. 다음 코드를 봅시다(eface_to_type.go):
var j uint32
var Eface interface{}
// 컴파일러를 속임 (정적 추론 회피)
func typeSwitch() {
i := uint32(42)
Eface = i
switch v := Eface.(type) {
case uint16:
j = uint32(v)
case uint32:
j = v
}
}
이 간단한 타입 스위치 문은 다음과 같은 어셈블리로 번역됩니다(주석 포함):
;; switch v := Eface.(type)
0x0065 00101 MOVQ "".Eface(SB), AX ;; AX = Eface._type
0x006c 00108 MOVQ "".Eface+8(SB), CX ;; CX = Eface.data
0x0073 00115 TESTQ AX, AX ;; Eface._type == nil ?
0x0076 00118 JEQ 153 ;; 맞으면 switch 종료
0x0078 00120 MOVL 16(AX), DX ;; DX = Eface.type._hash
;; case uint32
0x007b 00123 CMPL DX, $-800397251 ;; Eface.type._hash == type.uint32.hash ?
0x0081 00129 JNE 163 ;; 아니면 다음 case(uint16)로
0x0083 00131 LEAQ type.uint32(SB), BX ;; BX = type.uint32
0x008a 00138 CMPQ BX, AX ;; type.uint32 == Eface._type ? (해시 충돌?)
0x008d 00141 JNE 206 ;; 아니면 BX를 비우고 다음 case(uint16)로
0x008f 00143 MOVL (CX), BX ;; BX = *Eface.data
0x0091 00145 JNE 163 ;; 0x00d3에서 시작하는 간접 점프의 착지점
0x0093 00147 MOVL BX, "".j(SB) ;; j = BX = *Eface.data
;; 종료
0x0099 00153 MOVQ 40(SP), BP
0x009e 00158 ADDQ $48, SP
0x00a2 00162 RET
;; case uint16
0x00a3 00163 CMPL DX, $-269349216 ;; Eface.type._hash == type.uint16.hash ?
0x00a9 00169 JNE 153 ;; 아니면 switch 종료
0x00ab 00171 LEAQ type.uint16(SB), DX ;; DX = type.uint16
0x00b2 00178 CMPQ DX, AX ;; type.uint16 == Eface._type ? (해시 충돌?)
0x00b5 00181 JNE 199 ;; 아니면 AX를 비우고 switch 종료
0x00b7 00183 MOVWLZX (CX), AX ;; AX = uint16(*Eface.data)
0x00ba 00186 JNE 153 ;; 0x00cc에서 시작하는 간접 점프의 착지점
0x00bc 00188 MOVWLZX AX, AX ;; AX = uint16(AX) (중복)
0x00bf 00191 MOVL AX, "".j(SB) ;; j = AX = *Eface.data
0x00c5 00197 JMP 153 ;; 끝, switch 종료
;; 간접 점프 테이블
0x00c7 00199 MOVL $0, AX ;; AX = $0
0x00cc 00204 JMP 186 ;; 153(종료)으로 간접 점프
0x00ce 00206 MOVL $0, BX ;; BX = $0
0x00d3 00211 JMP 145 ;; 163(case uint16)으로 간접 점프
다시 말하지만, 생성된 코드를 차근차근 따라가고 주석을 읽어보면 여기에 무슨 흑마법이 숨어 있는 것은 아닙니다. 제어 흐름이 앞뒤로 많이 점프해서 처음엔 다소 복잡해 보일 수 있지만, 그 외에는 원래 Go 코드를 꽤 충실하게 옮긴 것에 불과합니다. 그래도 흥미로운 점이 몇 가지 있습니다.
메모 1: 배치
먼저 생성된 코드의 상위 수준 배치가 원래 switch 문과 꽤 비슷하다는 점에 주목하세요:
_type을 적재하고, 혹시 모를 nil 포인터를 검사하는 초기 명령 블록이 있습니다.되돌아보면 자명해 보이지만, 두 번째 포인트는 상당히 중요합니다. 타입 스위치가 생성하는 명령어 수는 오로지 그 안에 들어 있는 case의 개수에 비례한다는 뜻이기 때문입니다. 실전에서는 이것이 의외의 성능 문제를 만들 수 있습니다. 예를 들어 case가 잔뜩 들어 있는 거대한 타입 스위치는 엄청난 양의 명령어를 생성할 수 있고, 잘못된 경로에서 사용되면 L1i 캐시를 흔들 수 있습니다.
우리의 단순한 switch 문에서 또 흥미로운 점은 case의 배치 순서입니다. 원래 Go 코드에서는 case uint16이 먼저고 그 다음이 case uint32였습니다. 그런데 컴파일러가 생성한 어셈블리에서는 순서가 뒤집혀 case uint32가 먼저, case uint16이 그 다음으로 옵니다. 적어도 제가 보기엔, 이 재배치가 이 경우 성능상 이득이 되는 것은 순전히 운에 가깝습니다.
사실 타입 스위치를 조금만 여러 방식으로 실험해 보면, 특히 case가 둘보다 많은 경우, 컴파일러가 항상 어떤 결정적 휴리스틱에 따라 case 순서를 섞는다는 사실을 알 수 있습니다. 그 휴리스틱이 정확히 무엇인지는 저도 모르겠습니다(늘 그렇듯 알고 있다면 꼭 알고 싶습니다).
메모 2: O(n)
둘째로, 제어 흐름이 단순히 한 case에서 다음 case로 맹목적으로 점프하다가, 참이 되는 case를 만나거나 switch의 끝에 도달할 때까지 계속된다는 점에 주목하세요. 이것 역시 한 번 생각해 보면 당연하지만("그럼 어떻게 다르게 할 수 있겠는가?"), 더 높은 수준에서 추상적으로 생각하다 보면 놓치기 쉽습니다.
실전에서 이는 타입 스위치의 평가 비용이 case 수에 선형적으로 비례한다는 뜻입니다. 즉 O(n)입니다. 마찬가지로 N개의 case를 가진 타입 스위치를 평가하는 시간 복잡도는 사실상 N개의 타입 단언을 평가하는 것과 동일합니다. 말했듯 여기엔 마법이 없습니다.
몇 개의 벤치마크로 이를 쉽게 확인할 수 있습니다(eface_to_type_test.go):
var j uint32
var eface interface{} = uint32(42)
func BenchmarkEfaceToType(b *testing.B) {
b.Run("switch-small", func(b *testing.B) {
for i := 0; i < b.N; i++ {
switch v := eface.(type) {
case int8:
j = uint32(v)
case int16:
j = uint32(v)
default:
j = v.(uint32)
}
}
})
b.Run("switch-big", func(b *testing.B) {
for i := 0; i < b.N; i++ {
switch v := eface.(type) {
case int8:
j = uint32(v)
case int16:
j = uint32(v)
case int32:
j = uint32(v)
case int64:
j = uint32(v)
case uint8:
j = uint32(v)
case uint16:
j = uint32(v)
case uint64:
j = uint32(v)
default:
j = v.(uint32)
}
}
})
}
benchstat <(go test -benchtime=1s -bench=. -count=3 ./eface_to_type_test.go)
name time/op
EfaceToType/switch-small-8 1.91ns ± 2%
EfaceToType/switch-big-8 3.52ns ± 1%
추가 case들이 있는 두 번째 타입 스위치는 실제로 반복당 거의 두 배의 시간이 걸립니다.
흥미로운 연습 문제 하나를 남기겠습니다. 위 두 벤치마크 중 어느 쪽이든(아무 위치나) case uint32를 추가해 보세요. 성능이 크게 좋아지는 것을 보게 될 것입니다:
benchstat <(go test -benchtime=1s -bench=. -count=3 ./eface_to_type_test.go)
name time/op
EfaceToType/switch-small-8 1.63ns ± 1%
EfaceToType/switch-big-8 2.17ns ± 1%
이 챕터에서 모은 도구와 지식을 모두 활용하면, 이 숫자의 이유를 스스로 설명할 수 있을 것입니다. 즐겨 보세요!
메모 3: 타입 해시와 포인터 비교
마지막으로, 각 case에서 타입 비교가 항상 두 단계로 이루어진다는 점에 주목하세요:
_type.hash)를 비교하고,_type 포인터의 메모리 주소를 직접 비교합니다.각 _type 구조체는 컴파일러에 의해 한 번만 생성되어 .rodata 섹션의 전역 변수에 저장되므로, 각 타입은 프로그램 수명 내내 고유한 주소를 갖는 것이 보장됩니다. 그런 맥락에서, 성공적인 매칭이 단순히 해시 충돌의 결과가 아님을 확인하기 위해 추가 포인터 비교를 하는 것은 이해가 됩니다. 하지만 그러면 자연스러운 질문이 떠오릅니다. 애초에 포인터를 직접 비교해 버리고 타입 해시라는 개념 자체를 없애면 안 되는 걸까요? 특히 앞서 본 단순 타입 단언은 타입 해시를 전혀 사용하지 않는데도 말입니다.
답은, 저도 전혀 모르겠다는 것입니다. 이 점에 대해 설명해 줄 수 있다면 정말 반갑겠습니다. 늘 그렇듯 더 아는 것이 있다면 이슈를 열어 주세요.
타입 해시 이야기가 나온 김에, $-800397251이 type.uint32.hash에, $-269349216이 type.uint16.hash에 해당한다는 것은 어떻게 아느냐고 묻고 싶을지도 모르겠습니다. 당연히 힘들게 알아냈죠(eface_type_hash.go):
// runtime의 eface와 _type을 단순화한 정의
type eface struct {
_type *_type
data unsafe.Pointer
}
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
/* 많은 필드 생략 */
}
var Eface interface{}
func main() {
Eface = uint32(42)
fmt.Printf("eface._type.hash = %d\n", int32((*eface)(unsafe.Pointer(&Eface))._type.hash))
Eface = uint16(42)
fmt.Printf("eface._type.hash = %d\n", int32((*eface)(unsafe.Pointer(&Eface))._type.hash))
}
$ go run eface_type_hash.go
eface._type.hash = -800397251
eface._type.hash = -269349216
이것으로 인터페이스는 끝입니다. 이 챕터가 인터페이스와 그 내부 구조에 대해 궁금했던 대부분의 질문에 답을 주었길 바랍니다. 무엇보다도, 더 깊이 파고들 필요가 생겼을 때 스스로 조사할 수 있는 도구와 기술을 제공했기를 바랍니다.
질문이나 제안이 있다면 chapter2: 접두어를 붙여 이슈를 열어 주세요!