skip to content

Calling Into C

Importing C buys an existing native library at the price of pointer rules, per-call overhead, a pinned OS thread and a binary that is no longer self-contained. It is a judgement question.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

17

In cgo, what do C.CBytes and C.GoBytes do, and who frees the C copy?

level: juniorimportance: must knowfreq 45%

answer

  1. Two buffers, two owners
  2. One side mallocs, one side is collected
  3. Copy in, copy out, nothing shared
  4. The C heap has no collector watching it
  5. GoBytes needs a length; CBytes needs a free

basics

~10 s

C.CBytes copies a Go []byte into malloc'd C memory and returns an unsafe.Pointer you must release with C.free. C.GoBytes copies C memory back into a brand-new Go slice that the garbage collector owns.

solid answer

~40 s

Both are copy helpers that cgo generates, and copying is the point: after either call the two sides own separate memory. `C.CBytes(b)` mallocs `len(b)` bytes on the C heap, copies the slice contents in, and returns an `unsafe.Pointer`. Nothing in Go tracks that allocation, so you own it — `defer C.free(cbuf)`, with `#include <stdlib.h>` in the preamble. `C.GoBytes(p, C.int(n))` goes the other way: it allocates a Go slice, copies `n` bytes out of C memory into it, and hands you a normal garbage-collected `[]byte`, so the C side is free to release `p` immediately afterwards. Neither result aliases the other side. That safety costs a full copy in each direction, which is exactly why people reach for passing `&buf[0]` directly instead — and why cgo's pointer-passing rules exist.

code

go · 7 lines
go
// buf holds one chunk of the stream.
cbuf := C.CBytes(buf) // malloc'd copy on the C heap
defer C.free(cbuf)    // the preamble must #include <stdlib.h>

// ... the C codec reads cbuf and rewrites it, reporting n bytes ...

out := C.GoBytes(cbuf, C.int(n)) // a fresh, GC-owned Go slice

go deeper

for a junior

Be ready to say which direction each helper copies and to name C.free as the release for the C-heap allocation. Show the defer on the line after the allocation.

for a middle

Explain that both helpers copy, so neither result aliases the other side, and that the C allocation is invisible to Go's garbage collector. Mention that C.GoBytes needs an explicit length as a C.int.

for a senior

Demonstrate the cost judgment: know that each helper is an allocation plus a memcpy, be able to say what a leak here looks like in production (RSS grows, Go heap profile flat), and say when you would move to a direct pointer pass instead.

for a principal

Own the default for the codebase. Copying is the safe boundary policy that lets any reviewer approve a cgo call; carving out a zero-copy path is a deliberate exception that needs a benchmark behind it and a comment at the call site.

## What the two helpers are `C.CBytes` and `C.GoBytes` are not functions in any package you can import. They are part of the `C` pseudo-package that cgo synthesises for a file that imports `"C"`; cgo rewrites the call into real code at build time. Their whole job is to move bytes **by copying**, so that after the call each side owns memory the other side does not. Their shapes: - `C.CBytes(b []byte) unsafe.Pointer` — allocates `len(b)` bytes with C's allocator, copies the slice's contents into it, and returns a pointer to the allocation. The result is **not** NUL-terminated; it is raw bytes, not a C string. - `C.GoBytes(p unsafe.Pointer, n C.int) []byte` — allocates a new Go slice of length `n`, copies `n` bytes starting at `p` into it, and returns it. ## Who owns what This is the part interviewers actually probe, because it is where leaks and use-after-free come from. The pointer from `C.CBytes` lives on the **C heap**. Go's garbage collector does not know it exists, will never scan it, and will never reclaim it. If you drop the pointer, the memory is leaked for the life of the process. The only release is a call to C's `free`, which cgo exposes as `C.free` once the preamble includes `<stdlib.h>`: ```go cbuf := C.CBytes(chunk) defer C.free(cbuf) ``` Putting the `defer` on the line after the allocation is the habit worth building, exactly as you would with a file handle. In a streaming pipeline that does this per chunk, a missing `free` shows up as a heap that grows without any Go allocation to blame for it — a Go heap profile will look innocent, because the bytes are not on the Go heap at all. The slice from `C.GoBytes` is ordinary **Go memory**. It is collected like any other slice, it can be appended to, sliced, stored in a struct, and returned to a caller. Because it is a copy, the C side may free or overwrite its own buffer the instant `C.GoBytes` returns. That is the useful property: `C.GoBytes` is how you get out of C's lifetime rules entirely. ## Why copying at all Go memory and C memory are governed by different collectors — one automatic, one manual — and cgo's pointer-passing rules restrict what may cross the boundary and for how long. Copying sidesteps every one of those restrictions. A copied buffer cannot be invalidated by a garbage collection, cannot be moved out from under C, and cannot leave a stale Go pointer parked in a C struct. For a first cgo call in a codebase, copying is almost always the right default: it is boring, and boring is what you want on a boundary where mistakes surface as silent corruption rather than a clean panic. The cost is real, though, and it is why the question comes up in a review. Each helper is an allocation plus an `O(n)` memcpy. A pipeline pushing 64 KiB chunks through a C codec at high rate pays that twice per chunk, plus the malloc/free churn. When a benchmark with `-benchmem` shows the copy dominating, the alternative is to pass a pointer into the Go buffer itself (`&buf[0]`), which is legal for a `[]byte` under cgo's rules provided the C side does not keep the pointer past the call. That is a deliberate trade of safety for throughput, not a free upgrade. ## Details that trip people up - **The length argument is `C.int`.** `C.GoBytes` takes the byte count as a `C.int`, so you must convert (`C.int(n)`) and you must have a count in the first place — there is no way for it to discover a length, since raw bytes have no terminator. - **`C.CBytes` does not NUL-terminate.** If the C function expects a NUL-terminated string, `C.CBytes` is the wrong helper. - **An empty slice is a trap.** `C.CBytes(nil)` still returns a pointer you should free, but taking `&b[0]` on an empty slice panics with an index-out-of-range — a different route to the same C pointer, with a different failure. - **`C.free` needs the include.** Without `#include <stdlib.h>` in the preamble comment, `C.free` does not exist and the build fails; the fix is one line, but the error message points at Go, not at C. - **Copies do not alias.** Mutating the Go slice returned by `C.GoBytes` never changes anything the C side can see, and vice versa. If you expected shared memory, you wanted a pointer pass, not a copy. A good short answer names the direction of each helper, says who frees, and then volunteers the cost — that last part is what separates someone who has run cgo in production from someone who has read the doc page.

  • What does the C side have to guarantee about its buffer after C.GoBytes returns?
    Nothing. `C.GoBytes` has already copied the bytes into a fresh Go slice, so the C code may free, reuse, or overwrite its buffer immediately. That is the whole reason to copy: the returned slice has no relationship to C's lifetime rules, and the garbage collector owns it from that point on.
  • Your pipeline's resident memory climbs but a Go heap profile stays flat. How does that fit these helpers?
    That is the signature of a missing `C.free` on a `C.CBytes` result. The allocation lives on the C heap, which the Go collector neither scans nor reports, so a heap profile shows nothing while RSS grows. Look for every `C.CBytes` (and every `C.malloc`) without a matching `defer C.free`, or check the process with a C-level allocator tool.
  • When would you stop copying and pass a pointer into the Go buffer instead?
    When a benchmark shows the two copies dominating — large chunks at high rate. Passing `&buf[0]` is legal because a `[]byte`'s backing array holds no Go pointers, but it binds you to cgo's rule that C must not retain the pointer after the call returns. It is a considered trade, and worth a comment at the call site.

saying these in an interview costs you the question

  • Assuming the garbage collector frees a C.CBytes result
  • Treating C.CBytes output as a NUL-terminated C string
  • Believing C.GoBytes gives a view onto the C buffer
  • Freeing the Go slice returned by C.GoBytes
  • Calling both helpers on a hot path without measuring the copy
open as a page

When a Go program is built without cgo, what handles hostname and user lookups instead of libc?

level: middleimportance: must knowfreq 55%

basics

~10 s

Go's own implementations take over. The pure-Go resolver parses /etc/resolv.conf and /etc/hosts and speaks DNS itself, and os/user parses /etc/passwd and /etc/group. Neither consults nsswitch.conf or the host's pluggable name-service modules.

open as a page

In cgo, what does C.CString return, and who is responsible for freeing it?

level: middleimportance: must knowfreq 38%

basics

~10 s

C.CString copies a Go string into memory allocated by C's malloc and returns a *C.char. Go's garbage collector never manages that memory, so the caller must release it with C.free(unsafe.Pointer(p)).

open as a page

Why is calling a C function from Go through cgo far more expensive than a plain Go function call?

level: middleimportance: must knowfreq 42%

basics

~20 s

A cgo call is a runtime transition, not a jump. The goroutine moves onto the thread's system stack, the runtime records that it has left Go code, and both are undone on return. That costs tens of nanoseconds; a Go call costs a couple.

open as a page

What are cgo's rules for passing a Go pointer into C code?

level: middleimportance: must knowfreq 55%

basics

~20 s

Pass a Go pointer to C only if the memory it points at holds no Go pointers, and C must not keep it after the call returns. C may never store a Go pointer into Go memory.

open as a page

Why can a Go binary built with cgo enabled fail to start on a host with a different libc?

level: juniorimportance: should knowfreq 47%

basics

~20 s

With cgo enabled, standard packages such as net and os/user call into the host C library, so the binary is dynamically linked against libc. It needs a compatible libc present at run time, or the loader refuses to start it.

open as a page

What does the special import "C" line do in a Go file, and what is the comment above it?

level: juniorimportance: should knowfreq 32%

basics

~20 s

import "C" is not a real package; it turns on cgo for that file. The comment directly above it, with no blank line between, is the preamble: C code that is compiled and exposed to Go as C.name.

open as a page

What does runtime.LockOSThread do, and when does a C library force you to use it?

level: middleimportance: should knowfreq 34%

basics

~20 s

runtime.LockOSThread wires the calling goroutine to the OS thread it is running on: that goroutine runs nowhere else and no other goroutine runs there until UnlockOSThread. You need it when a C library keeps per-thread state and must be driven from one fixed thread.

open as a page

A Go binary runs in CI but exits with no output on the deploy host — how do you prove libc is the cause?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Interrogate the artefact, not the build script. file and ldd show whether the binary is dynamically linked and against which loader, and go version -m prints the build settings baked into it, including whether cgo was enabled.

open as a page

A Go CLI wrapping a C library grows in RSS for hours while Go's heap profile stays flat. What do you suspect?

level: seniorimportance: should knowfreq 18%

basics

~20 s

Memory allocated on the C side. Go's heap profile and MemStats only account for the Go heap, so malloc'd buffers are invisible to them. The usual cause is a C.CString or a library-returned pointer that is never passed to C.free.

open as a page

Under load a handler's cgo call blocks in C for 300 ms and OS-thread count climbs past 200. Why, and how do you bound it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

A goroutine executing C holds a real OS thread for the whole call and cannot be preempted. The runtime detaches its execution slot and starts another thread so Go work continues, so peak concurrent C calls sets thread count. Bound it with a semaphore at the boundary.

open as a page

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%

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.

open as a page

Between cgo calls, when do you use cgo.Handle versus runtime.Pinner for a Go value C must keep?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Use runtime/cgo.Handle when C only needs an opaque token to hand back to Go: it passes an integer, so no pointer crosses at all. Use runtime.Pinner when C must dereference the Go memory itself, and always scope the Unpin.

open as a page

What do #cgo CFLAGS and #cgo LDFLAGS lines in a cgo preamble do?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

They pass options to the C toolchain from inside the preamble comment. CFLAGS reach the C compiler, typically include paths and defines; LDFLAGS reach the linker, typically library search paths and -l names. Lines from every file in the package are combined.

open as a page

How do GODEBUG=cgocheck=1 and GOEXPERIMENT=cgocheck2 differ in the cgo pointer violations they catch?

level: seniorimportance: nice to knowfreq 22%

basics

~10 s

cgocheck=1 is the cheap default, inspecting pointer arguments at each cgo call. cgocheck2 is a build-time GOEXPERIMENT that catches Go pointers stored into non-Go memory. Both are dynamic.

open as a page

How do you decide whether your team's Go services ship binaries that link libc at all?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Make it a release policy, not a per-build flag. Default to cgo-free binaries for portability, require a documented exception where a service truly needs lookups only the host C library can perform, and agree it with whoever owns the hosts.

open as a page

A cgo call into a C scoring engine takes 50-400 ms; how do you decide whether it stays on the RPC request path?

level: principalimportance: nice to knowfreq 24%

basics

~20 s

Turn it into a capacity and blast-radius decision. Concurrency times call duration is the OS-thread budget, so publish a cap and enforce it on entry; then judge whether a library that can kill the whole process, and that ignores your deadlines, belongs in the request path at all.

open as a page