skip to content

Why can't a Go panic unwind out of a C frame, and what must an exported Go callback do about it?

level: seniorimportance: should knowfreq 27%

answer

  1. unwinding needs compiler metadata
  2. C frames carry none of it
  3. the outer Go recover never sees it
  4. recover inside the exported function
  5. a C fault is not a panic at all

basics

~20 s

Panicking walks Go frames and runs their deferred calls; C frames carry none of that information, so the runtime cannot travel through them and aborts the whole process instead. Every Go function exported to C must recover in its own deferred call and return a status code.

solid answer

~60 s

A panic is not an exception mechanism bolted onto the machine stack: the runtime walks the goroutine's Go frames using metadata the compiler emitted, running deferred calls as it goes. A C frame has none of that metadata, so when the unwinding reaches the boundary the runtime has nowhere to go and terminates the program. That means a `defer`/`recover` in the Go function that originally called into C will never see a panic raised in an exported callback below it. The rule is that every `//export`ed function recovers inside itself and turns the failure into a value C understands — a status code or an error string — using a named result so the deferred function can set it. The reverse direction is worse: a memory fault inside the C library is not a Go panic at all and `recover` can do nothing with it; the process dies with a mixed Go and C stack. The blast radius is the whole process, which is the strongest argument for isolating an untrusted engine.

code

go · 10 lines
go
//export Score
func Score(x C.double) (rc C.int) {
	defer func() {
		if r := recover(); r != nil {
			rc = -1 // a panic must never reach the C caller
		}
	}()
	rc = C.int(score(float64(x)))
	return rc
}

go deeper

for a junior

Remember that panicking runs deferred calls up the goroutine's own Go frames, and that C code has nothing for it to walk. Know that an unhandled panic at that boundary ends the whole program.

for a middle

Be able to explain why the metadata the compiler emits for Go frames is what makes unwinding possible, and to write the exported function correctly: named result, deferred recover, status code returned to C.

for a senior

Show that you treat the boundary as an error barrier and know the reverse case — a fault inside C is unrecoverable and kills every in-flight request. Be ready to describe the signal-handler clash and how you would spot it.

for a principal

Own the blast radius. Argue explicitly about whether an in-process C dependency is worth a whole-process failure mode, what evidence would move you to an out-of-process worker, and who accepts the risk when the library is not maintained by your team.

## What panicking actually is Go's panic is not a stack-scanning search for a handler in the C++ or Java sense. When a goroutine panics, the runtime walks its own frames using tables the compiler emitted for exactly this purpose, running each frame's deferred calls in last-in-first-out order and checking whether any of them calls `recover`. If one does, unwinding stops there and execution resumes after that frame's call. If none does, the runtime prints the goroutine dumps and exits. Everything in that description depends on the frames being Go frames the compiler described. ## Why a C frame stops it When Go calls C, the thread switches to its system stack and runs code the Go compiler never saw. Those frames have no deferred-call records, no stack maps, and no unwind tables the runtime knows how to read. If C then calls back into an exported Go function and that function panics, the panic unwinds the Go frames beneath the boundary — their defers do run — and then arrives at the C frame with no way forward. The runtime cannot skip it (the C code holds resources and expects to return normally) and cannot unwind it, so it gives up and terminates the process. The consequence people get wrong: a `recover` in the Go function that made the original cgo call is useless. It is separated from the panic by C frames the unwinder will never cross. ## The rule for exported functions Treat the boundary as a hard error barrier. Every function you export to C recovers in its own deferred call and converts the panic into something C can inspect — conventionally a non-zero status code, sometimes a status plus an error string the caller retrieves separately. Because a deferred function can only change the returned value when the result is named, the exported function uses a named result. That this must be done *inside* every exported function, rather than once somewhere central, is the whole point: there is no outer frame that can catch on their behalf. ## The other direction: faults inside C Go's runtime installs handlers for the fault signals and turns a memory fault in *Go* code into an ordinary run-time panic — that is why a nil dereference in Go is recoverable. That courtesy does not extend to C. A bad pointer inside the C library raises a genuine fault in code the runtime has no model of; the process dies and prints a stack that mixes Go and C frames. No `recover`, anywhere, catches it, and no amount of deferred code around the cgo call changes it. So the honest risk statement for an in-process C dependency is: any memory error in that library kills every in-flight request in the process, not just the one that triggered it. ## Signal handlers: the quieter clash The same signals are how the Go runtime does several jobs at once — turning faults in Go code into panics, driving the CPU profiler with a timer signal, and interrupting long-running Go code with a preemption signal. A C library that installs its own handlers over Go's breaks whichever of those it displaces: faults in Go code stop becoming panics, profiles go quiet, or preemption stops working, and the symptom rarely points at the library that caused it. The contract, stated in the `os/signal` documentation for programs using cgo, is that non-Go code which installs handlers must save the handler Go installed and chain to it, and Go code should not install a handler over one the C library needs. When a mysterious loss of profiling data or of nil-dereference panics appears right after a library upgrade, this is the first thing to check. ## What this pushes you towards Three practical positions follow. Wrap every exported callback in its own recover, without exception. Validate inputs on the Go side before handing them to a library whose faults you cannot contain. And when the engine is untrusted, unmaintained or crash-prone, weigh running it in a separate process: the serialisation cost buys you a blast radius of one worker instead of one fleet member's entire request set.

  • Can recover catch a memory fault raised inside the C library?
    No. The runtime converts fault signals into panics only for faults in Go code, where it has frame information. A bad pointer inside C raises a real fault in code the runtime cannot model, and the process dies with a mixed Go and C stack. Nothing on the Go side can intercept it.
  • Do the deferred calls in the panicking exported function still run?
    Yes. Unwinding proceeds normally through the Go frames below the boundary, running their deferred calls, and only fails when it reaches the C frame. That is exactly why the recover has to live in the exported function itself rather than in some outer Go caller.
  • A C library installs its own handlers for fault and timer signals. What breaks?
    Whatever the Go runtime used those signals for: faults in Go code stop turning into recoverable panics, CPU profiles go empty, or signal-based preemption of long-running Go code stops working. The documented contract is that non-Go code must save Go's handler and chain to it rather than replacing it.
  • Why is a panic crossing this boundary worse than a panic in an ordinary handler?
    An ordinary handler panic can be recovered by that request's own deferred call and contained to one request. A panic that reaches a C frame cannot be recovered by anyone, so the runtime tears the process down and every in-flight request on that instance fails with it.

saying these in an interview costs you the question

  • Expects a recover in the outer Go caller to catch it
  • Says a segfault in C surfaces as a catchable Go panic
  • Wraps the cgo call in defer and recover and calls it safe
  • Believes a panic only kills the goroutine that raised it
  • Assumes C++ exceptions propagate into Go as errors