panic
panic은 Go 의 내장 함수로, 프로그램이 복구 불가능한 오류를 만났을 때 panic 을 던집니다. 예를 들어 흔한 널 포인터 접근이 있습니다.
func main() {
var a *int
*a = 1
}위 코드를 실행하면 프로그램은 다음과 같은 panic 을 던지고 실행이 중지됩니다.
panic: runtime error: invalid memory address or nil pointer dereference일부 상황에서는 더 심각한 결과를 피하기 위해 수동으로 panic 함수를 호출하여 프로그램을 종료하기도 합니다. 또한 또 다른 내장 함수인 recover 를 사용하여 panic 을 포착하고 defer 와 함께 사용합니다.
func main() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
var a *int
*a = 1
}왜 recover 함수는 반드시 defer 안에서 사용해야 할까요? recover 는 어떤 작업을 수행할까요? 이러한 질문들은 아래 내용에서 답변드리겠습니다.
구조
panic 은 런타임에서도 대응되는 구조체로 표현되며,那就是 runtime._panic 입니다. 구조는 그리 복잡하지 않습니다.
type _panic struct {
argp unsafe.Pointer // panic 중 실행될 defer 호출의 인자 포인터 - 이동 불가 - liblink 에 알려짐
arg any // panic 인자
link *_panic // 이전 panic 으로 연결 - 링크드 리스트
pc uintptr // 이 panic 이 우회될 경우 런타임에서 복귀할 위치
sp unsafe.Pointer // 이 panic 이 우회될 경우 런타임에서 복귀할 위치
recovered bool // 이 panic 이 복구되었는지 여부
aborted bool // 이 panic 이 중단되었는지 여부
goexit bool
}이 구조는 defer 와 매우 유사합니다.
link는 다음_panic구조를 가리킵니다.pc와sp는 호출 함수의 실행 컨텍스트를 가리키며 추후 복구를 위해 사용됩니다.arg는panic함수의 인자입니다.argp는defer의 인자를 가리키며,panic발생 시defer실행을 트리거합니다.aborted는 강제 중지 여부를 나타냅니다.
panic 은 defer 와 마찬가지로 고루틴에서 링크드 리스트 형태로 존재합니다.
type g struct {
_panic *_panic // 가장 안쪽 panic - liblink 에 알려짐
_defer *_defer // 가장 안쪽 defer
}
##恐慌
우리가 능동적으로 panic 함수를 호출하든 프로그램이 panic 을 발생시키든, 최종적으로 runtime.gopanic 함수로 진입합니다.
func gopanic(e any)시작 시 먼저 인자가 nil 인지 검사합니다. 만약 nil 이라면 runtime.PanicNilError 유형의 오류를 생성합니다.
if e == nil {
if debug.panicnil.Load() != 1 {
e = new(PanicNilError)
} else {
panicnil.IncNonDefault()
}
}그런 다음 현재 panic 을 고루틴의 링크드 리스트头部에 추가합니다.
var p _panic
p.arg = e
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))이후 for 루프에 진입하여 현재 고루틴의 defer 링크드 리스트를 하나씩 처리합니다.
for {
d := gp._defer
if d == nil {
break
}
if d.started {
if d._panic != nil {
d._panic.aborted = true
}
d._panic = nil
}
d.started = true
d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
...
}만약 현재 defer 가 이미 다른 panic 에 의해 트리거되었다면, 즉 _defer.started == true라면, 이전의 panic 은 실행되지 않습니다. 그런 다음 defer 에 해당하는 함수를 실행합니다.
p.argp = unsafe.Pointer(getargp())
d.fn()
p.argp = nil
d._panic = nil
d.fn = nil
gp._defer = d.link
freedefer(d)실행이 끝나면 현재 defer 구조를 회수하고 다음 defer 를 계속 실행합니다. 모든 defer 구조 실행이 끝나고 복구되지 않으면 runtime.fatalpanic 함수로 진입합니다. 이 함수는 unrecoverable, 즉 복구 불가능합니다.
func fatalpanic(msgs *_panic) {
pc := getcallerpc()
sp := getcallersp()
gp := getg()
var docrash bool
systemstack(func() {
if startpanic_m() && msgs != nil {
runningPanicDefers.Add(-1)
printpanics(msgs)
}
docrash = dopanic_m(gp, pc, sp)
})
if docrash {
crash()
}
systemstack(func() {
exit(2)
})
*(*int)(nil) = 0 // 도달하지 않음
}이 과정에서 printpanics 가 panic 정보를 출력하며, 우리가 보통 보는 호출 스택 정보가 바로 여기서 출력됩니다. 마지막으로 runtime.exit 함수가 시스템 호출 _ExitProcess 를 통해 프로그램을 종료합니다.
복구
내장 함수 recover 를 호출하면 컴파일 기간 runtime.gorecover 함수 호출로 변환됩니다.
func gorecover(argp uintptr) any {
gp := getg()
p := gp._panic
if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
p.recovered = true
return p.arg
}
return nil
}구현은 매우 간단하며, p.recovered = true 이 한 가지만 수행합니다. 실제 복구 로직을 처리하는 코드는 gopanic 함수 안에 있습니다.
for {
...
d.fn()
...
if p.recovered {
...
}
}복구 로직은 defer 실행 후에 이루어집니다. 이제 recover 함수가 왜 defer 안에서만 사용되어야 하는지 이해할 수 있습니다. defer 밖에서 recover 함수를 사용하면 gp._panic 이 nil 이 되어 p.recovered 가 true 로 설정되지 않으며, gopanic 함수에서 복구 로직으로 진입하지 못합니다.
if p.recovered {
gp._panic = p.link
for gp._panic != nil && gp._panic.aborted {
gp._panic = gp._panic.link
}
if gp._panic == nil {
gp.sig = 0
}
gp.sigcode0 = uintptr(sp)
gp.sigcode1 = pc
mcall(recovery)
throw("recovery failed")
}복구 시 링크드 리스트에서 이미 강제 중지된 panic 을 정리한 후 runtime.recovery 함수로 진입하여 runtime.gogo 를 통해 사용자 함수의 정상 로직 흐름으로 돌아갑니다.
func recovery(gp *g) {
// G 구조체에서 전달된 defer 정보
sp := gp.sigcode0
pc := gp.sigcode1
gp.sched.sp = sp
gp.sched.pc = pc
gp.sched.lr = 0
gp.sched.ret = 1
gogo(&gp.sched)
}중요한 주의사항은 다음 코드입니다.
gp.sched.ret = 1이 코드는 ret 값을 1 로 설정합니다. runtime.deferproc 함수 주석에서 다음 내용을 확인할 수 있습니다.
func deferproc(fn func()) {
...
// deferproc 는 정상적으로 0 을 반환합니다.
// panic 을 중지한 deferred func 는 deferproc 가 1 을 반환하게 합니다.
// 컴파일러가 생성한 코드는 항상 반환값을 검사하고
// deferproc 가 0 이 아니면 함수 끝으로 점프합니다.
return0()
}컴파일러가 생성한 중간 코드는 해당 값이 1 인지 검사하고, 만약 1 이면 바로 runtime.deferreturn 함수를 실행합니다. 일반적으로 이 함수는 함수 반환 전에만 실행되며, 이것이 recover 이후 함수가 직접 반환되는 이유입니다.
