defer
defer 는 Go 일상 개발에서 매우 빈번하게 등장하는 키워드로, 선입후출 방식으로 defer 와 연관된 함수를 실행합니다. 많은 경우 이러한 메커니즘을 활용하여 리소스 해제 작업을 수행합니다. 예를 들어 파일 닫기 등의 작업입니다.
fd, err := os.Open("/dev/stdin")
if err != nil{
return err
}
defer fd.Close()
...이처럼 빈번하게 등장하는 키워드이므로, 그 배후의 구조를 알아볼 필요가 있습니다.
구조
defer 키워드는 runtime._defer 구조체에 해당하며, 구조는 복잡하지 않습니다.
type _defer struct {
started bool
heap bool
openDefer bool
sp uintptr // sp at time of defer
pc uintptr // pc at time of defer
fn func() // can be nil for open-coded defers
_panic *_panic // panic that is running defer
link *_defer // next defer on G; can point to either heap or stack!
fd unsafe.Pointer // funcdata for the function associated with the frame
varp uintptr // value of varp for the stack frame
framepc uintptr
}여기서 fn 필드는 defer 키워드에 해당하는 함수이며, link 는 다음 연결된 defer 를 나타내고, sp 와 pc 는 호출자의 함수 정보를 기록하여 defer 가 어느 함수에 속하는지 판단하는 데 사용됩니다. defer 는 런타임에 연결 리스트 형태로 존재하며, 리스트의头部는协程 G 에 있으므로 defer 는 실제로协程과 직접 연관됩니다.
type g struct {
...
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
...
}协程이 함수를 실행할 때, 순서대로 함수 내의 defer 를 리스트의头部에서 추가합니다.
defer fn1()
defer fn2()
defer fn3()위 코드는 이 그림에 해당합니다.

协程 외에도 P 도 defer 와 어느 정도 연관이 있습니다. P 의 구조체에는 deferpool 필드가 있습니다.
type p struct {
...
deferpool []*_defer // pool of available defer structs (see panic.go)
deferpoolbuf [32]*_defer
...
}deferpool 에는 미리 할당된 defer 구조체가 보관되어 있으며, P 와 연관된协程 G 에 새 defer 구조체를 할당하는 데 사용되어 오버헤드를 줄일 수 있습니다.
할당
문법상 defer 키워드 사용은 컴파일러에 의해 runtime.deferproc 함수 호출로 변환됩니다. 예를 들어 Go 코드는 이렇게 작성합니다.
defer fn1(x, y)하지만 컴파일 후 실제 코드는 이렇습니다.
deferproc(func(){
fn1(x, y)
})따라서 실제로 defer 는 인자도 없고 반환값도 없는 함수를 전달받습니다. deferproc 함수 코드는 다음과 같습니다.
func deferproc(fn func()) {
gp := getg()
d := newdefer()
d.link = gp._defer
gp._defer = d
d.fn = fn
d.pc = getcallerpc()
d.sp = getcallersp()
return0()
}이 함수는 defer 구조체를 생성하여协程 G 리스트의头部에 추가하는 역할을 하며, 그중 runtime.newdefer 함수는 P 의 deferpool 에서 미리 할당된 defer 구조체를 가져오려고 시도합니다.
if len(pp.deferpool) == 0 && sched.deferpool != nil {
lock(&sched.deferlock)
for len(pp.deferpool) < cap(pp.deferpool)/2 && sched.deferpool != nil {
d := sched.deferpool
sched.deferpool = d.link
d.link = nil
pp.deferpool = append(pp.deferpool, d)
}
unlock(&sched.deferlock)
}먼저 전역 sched.deferpool 에서 로컬 deferpool 로 defer 구조체의 절반을 채운 다음, P 의 deferpool 에서 가져오려고 시도합니다.
if n := len(pp.deferpool); n > 0 {
d = pp.deferpool[n-1]
pp.deferpool[n-1] = nil
pp.deferpool = pp.deferpool[:n-1]
}
if d == nil {
// Allocate new defer.
d = new(_defer)
}
d.heap = true마지막으로 정말 찾을 수 없을 때만 수동 할당 방식을 사용합니다. 마지막에 이런 코드가 있습니다.
d.heap = true이는 defer 가 힙에서 할당됨을 나타내며,相应地 false 일 때는 스택에서 할당됩니다. 스택에서 할당된 메모리는 반환 시 자동 회수되며, 메모리 관리 효율이 힙보다 더 높습니다. 스택에서 할당할지 여부를 결정하는 요소는 루프 층수이며, 이 부분 로직은 cmd/compile/ssagen 의 escape.goDeferStmt 메서드 아래 작은 부분에서 추적할 수 있습니다.
func (e *escape) goDeferStmt(n *ir.GoDeferStmt) {
...
if n.Op() == ir.ODEFER && e.loopDepth == 1 {
k = e.later(e.discardHole())
n.SetEsc(ir.EscNever)
}
...
}e.loopDepth 는 현재 문의 루프 층수를 나타내며, 현재 defer 문이 루프에 없으면 스택에 할당합니다.
case ir.ODEFER:
n := n.(*ir.GoDeferStmt)
if s.hasOpenDefers {
s.openDeferRecord(n.Call.(*ir.CallExpr))
} else {
d := callDefer
if n.Esc() == ir.EscNever {
d = callDeferStack
}
s.callResult(n.Call.(*ir.CallExpr), d)
}스택에서 할당하면 바로 스택에 defer 구조체를 생성하며, 최종적으로 runtime.deferprocStack 함수가 defer 구조체 생성을 완료합니다.
if k == callDeferStack {
// Make a defer struct d on the stack.
if stksize != 0 {
s.Fatalf("deferprocStack with non-zero stack size %d: %v", stksize, n)
}
t := deferstruct()
...
// Call runtime.deferprocStack with pointer to _defer record.
ACArgs = append(ACArgs, types.Types[types.TUINTPTR])
aux := ssa.StaticAuxCall(ir.Syms.DeferprocStack, s.f.ABIDefault.ABIAnalyzeTypes(nil, ACArgs, ACResults))
callArgs = append(callArgs, addr, s.mem())
call = s.newValue0A(ssa.OpStaticLECall, aux.LateExpansionResultType(), aux)
call.AddArgs(callArgs...)
call.AuxInt = int64(types.PtrSize) // deferprocStack takes a *_defer argdeferprocStack 함수의 시그니처는 다음과 같습니다.
func deferprocStack(d *_defer)구체적인 생성 로직은 deferproc 과 큰 차이가 없으며, 주요 차이점은 스택에서 할당할 때 defer 구조체의 출처는 직접 생성된 구조체이고, 힙에서 할당된 defer 의 출처는 new 함수라는 점입니다.
실행
함수가 반환되거나 panic 이 발생하면 runtime.deferreturn 함수로 진입하며, 이 함수는协程의 리스트에서 defer 를 꺼내 실행하는 역할을 합니다.
func deferreturn() {
gp := getg()
for {
d := gp._defer
sp := getcallersp()
if d.sp != sp {
return
}
fn := d.fn
d.fn = nil
gp._defer = d.link
freedefer(d)
fn()
}
}먼저 getcallersp() 를 통해 현재 함수의 스택 프레임을 가져와 defer 구조체의 sp 와 비교하여 defer 가 현재 함수에 속하는지 판단한 다음, defer 구조체를 리스트头部에서 꺼내고 gp._defer = d.link 를 사용하여 다음 defer 를 실행하며, runtuime.freedefer 함수를 통해 defer 구조체를 풀에 반환한 후 fn 을 호출하여 실행합니다. 이렇게 현재 함수에 속한 모든 defer 를 실행할 때까지 순환합니다.
오픈 코딩
defer 사용에는 비용이 따릅니다. 문법상 편의를 제공하지만 직접 함수 호출을 하는 것이 아니며 일련의 과정을 거치므로 성능 손실이 발생합니다. 그래서 Go 공식에서 최적화 방식을 설계했는데, 바로 오픈 코딩입니다. 이는 defer 에 대한 최적화 방식으로, 원래 영문명은 open-coded 이며, 국내에서는 대부분 오픈 코딩으로 번역했습니다. 여기서 open 은 펼친다는 의미로, defer 함수의 코드를 현재 함수 코드에 펼치는 것으로, 마치 함수 인라인과 같습니다. 이러한 최적화 방식에는 몇 가지 제한 조건이 있습니다.
- 함수 내
defer수는 8 개를 초과할 수 없음 defer와return두 수의 곱은 15 를 초과할 수 없음defer는 루프에 나타날 수 없음- 컴파일 최적화가 비활성화되지 않음
- 수동으로
os.Exit()호출하지 않음 - 힙에서 인자를 복사할 필요 없음
이 부분 판단 로직은 cmd/compile/ssagen.buildssa 함수의 아래 부분 코드에서 추적할 수 있습니다.
s.hasOpenDefers = base.Flag.N == 0 && s.hasdefer && !s.curfn.OpenCodedDeferDisallowed()
if s.hasOpenDefers && len(s.curfn.Exit) > 0 {
s.hasOpenDefers = false
}
if s.hasOpenDefers {
for _, f := range s.curfn.Type().Results().FieldSlice() {
if !f.Nname.(*ir.Name).OnStack() {
s.hasOpenDefers = false
break
}
}
}
if s.hasOpenDefers && s.curfn.NumReturns*s.curfn.NumDefers > 15 {
s.hasOpenDefers = false
}그런 다음 Go 는 현재 함수에 8 비트 정수 변수 deferBits 를 생성하여 bitmap 으로 defer 를 표시하며, 각 비트가 하나를 표시합니다. 8 비트 정수 uint8 는 최대 8 개를 표시할 수 있으며, 해당 비트가 1 이면 최적화된 defer 가 함수 반환 시 실행됩니다.
