Skip to content

panic

panic은 Go 의 내장 함수로, 프로그램이 복구 불가능한 오류를 만났을 때 panic 을 던집니다. 예를 들어 흔한 널 포인터 접근이 있습니다.

go
func main() {
  var a *int
  *a = 1
}

위 코드를 실행하면 프로그램은 다음과 같은 panic 을 던지고 실행이 중지됩니다.

panic: runtime error: invalid memory address or nil pointer dereference

일부 상황에서는 더 심각한 결과를 피하기 위해 수동으로 panic 함수를 호출하여 프로그램을 종료하기도 합니다. 또한 또 다른 내장 함수인 recover 를 사용하여 panic 을 포착하고 defer 와 함께 사용합니다.

go
func main() {
  defer func() {
    if err := recover(); err != nil {
      fmt.Println(err)
    }
  }()
  var a *int
  *a = 1
}

recover 함수는 반드시 defer 안에서 사용해야 할까요? recover 는 어떤 작업을 수행할까요? 이러한 질문들은 아래 내용에서 답변드리겠습니다.

구조

panic 은 런타임에서도 대응되는 구조체로 표현되며,那就是 runtime._panic 입니다. 구조는 그리 복잡하지 않습니다.

go
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 구조를 가리킵니다.
  • pcsp 는 호출 함수의 실행 컨텍스트를 가리키며 추후 복구를 위해 사용됩니다.
  • argpanic 함수의 인자입니다.
  • argpdefer 의 인자를 가리키며, panic 발생 시 defer 실행을 트리거합니다.
  • aborted 는 강제 중지 여부를 나타냅니다.

panicdefer 와 마찬가지로 고루틴에서 링크드 리스트 형태로 존재합니다.

go
type g struct {
  _panic    *_panic // 가장 안쪽 panic - liblink 에 알려짐
  _defer    *_defer // 가장 안쪽 defer
}

##恐慌

우리가 능동적으로 panic 함수를 호출하든 프로그램이 panic 을 발생시키든, 최종적으로 runtime.gopanic 함수로 진입합니다.

go
func gopanic(e any)

시작 시 먼저 인자가 nil 인지 검사합니다. 만약 nil 이라면 runtime.PanicNilError 유형의 오류를 생성합니다.

go
if e == nil {
    if debug.panicnil.Load() != 1 {
        e = new(PanicNilError)
    } else {
        panicnil.IncNonDefault()
    }
}

그런 다음 현재 panic 을 고루틴의 링크드 리스트头部에 추가합니다.

go
var p _panic
p.arg = e
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

이후 for 루프에 진입하여 현재 고루틴의 defer 링크드 리스트를 하나씩 처리합니다.

go
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 에 해당하는 함수를 실행합니다.

go
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, 즉 복구 불가능합니다.

go
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 // 도달하지 않음
}

이 과정에서 printpanicspanic 정보를 출력하며, 우리가 보통 보는 호출 스택 정보가 바로 여기서 출력됩니다. 마지막으로 runtime.exit 함수가 시스템 호출 _ExitProcess 를 통해 프로그램을 종료합니다.

복구

내장 함수 recover 를 호출하면 컴파일 기간 runtime.gorecover 함수 호출로 변환됩니다.

go
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 함수 안에 있습니다.

go
for {
    ...
      d.fn()
      ...
    if p.recovered {
      ...
    }
}

복구 로직은 defer 실행 후에 이루어집니다. 이제 recover 함수가 왜 defer 안에서만 사용되어야 하는지 이해할 수 있습니다. defer 밖에서 recover 함수를 사용하면 gp._panicnil 이 되어 p.recoveredtrue 로 설정되지 않으며, gopanic 함수에서 복구 로직으로 진입하지 못합니다.

go
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 를 통해 사용자 함수의 정상 로직 흐름으로 돌아갑니다.

go
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 함수 주석에서 다음 내용을 확인할 수 있습니다.

go
func deferproc(fn func()) {
    ...
  // deferproc 는 정상적으로 0 을 반환합니다.
  // panic 을 중지한 deferred func 는 deferproc 가 1 을 반환하게 합니다.
  // 컴파일러가 생성한 코드는 항상 반환값을 검사하고
  // deferproc 가 0 이 아니면 함수 끝으로 점프합니다.
  return0()
}

컴파일러가 생성한 중간 코드는 해당 값이 1 인지 검사하고, 만약 1 이면 바로 runtime.deferreturn 함수를 실행합니다. 일반적으로 이 함수는 함수 반환 전에만 실행되며, 이것이 recover 이후 함수가 직접 반환되는 이유입니다.

Golang by www.golangdev.cn edit