unsafe, cgo, and System Calls

The three exits from safe Go

Most Go code never leaves the safety of the type system and the runtime. Systems code sometimes must: to reinterpret bytes without copying (unsafe), to call a C library (cgo), or to talk to the kernel directly (syscall, golang.org/x/sys/unix). Each exit has a precise contract; breaking it produces bugs that are intermittent, platform-dependent, and invisible to the race detector. This page covers the contracts and, just as importantly, when not to use these tools at all.

Measurements: Go 1.26.4, darwin/arm64.

unsafe

unsafe.Pointer can be converted to and from any pointer type and uintptr. The compiler and GC still track an unsafe.Pointer (it keeps its target alive); they do not track a uintptr — that is just an integer.

The valid patterns

The unsafe package documentation lists the only conversions that are valid. The ones used in practice:

  1. *T1 → unsafe.Pointer → *T2, when T2 is no larger than T1 and they have compatible memory layout. Example: math.Float64bits is *(*uint64)(unsafe.Pointer(&f)).
  2. Pointer arithmetic in one expression: unsafe.Pointer(uintptr(p) + off) — the conversion to uintptr, the arithmetic, and the conversion back must be in the same expression, so the GC never sees an object referenced only by an integer. Prefer unsafe.Add(p, off).
  3. Calls to syscall.Syscall with uintptr(unsafe.Pointer(p)) directly in the argument list: the compiler keeps p alive for the call.
  4. reflect.Value.Pointer / UnsafeAddr results converted back immediately.

Everything else — storing a uintptr in a variable and converting it back later, building a pointer to beyond the end of an object — is invalid even if it appears to work. Go's GC does not move heap objects, but goroutine stacks move when they grow: a uintptr holding the address of a stack variable silently becomes stale after a stack copy.

Zero-copy conversions

Since Go 1.20 the safe-looking helpers are:

// []byte → string without copying. Valid only if b is never modified afterwards.
s := unsafe.String(unsafe.SliceData(b), len(b))

// string → []byte without copying. The result must NEVER be written to:
// string data may live in read-only memory, and writing faults.
b := unsafe.Slice(unsafe.StringData(s), len(s))

Use them in measured hot paths where the ownership is obvious — e.g. converting a key to a string for a map lookup that does not retain it (though the compiler already optimizes m[string(b)]), or a parser returning strings that point into a buffer that is never reused. The classic bug: a zero-copy string pointing into a bufio.Reader buffer or a sync.Pool buffer that is overwritten on the next read — the "immutable" string changes under you.

Checking

go test -race and -gcflags=all=-d=checkptr enable checkptr, which validates unsafe.Pointer conversions at runtime (alignment, a converted pointer staying within its original allocation) and panics on violations. Run your tests with it if a package uses unsafe.

cgo

cgo lets Go call C (and C call back into Go). The cost is not just the call:

Call (measured) ns/op
Go function call, not inlined 2.1
cgo call to a trivial C function (a + b) 23.8

A cgo call switches from the goroutine stack to a system stack, tells the scheduler the goroutine is in C (exactly like a syscall — so a blocking C call holds an OS thread, as shown in Runtime Scheduler, where 200 goroutines blocked in C created 192 threads), and switches back. ~20 ns is irrelevant for a call that does milliseconds of work in C (an image codec, SQLite) and dominant for one that does nanoseconds; batch small operations into one call.

The other costs:

  • Pointer passing rules. Go memory passed to C must not contain Go pointers (C cannot be traced by the GC), and C must not keep a Go pointer after the call returns. GODEBUG=cgocheck=1 (the default) checks the cheap cases at runtime. To hand C a long-lived reference to a Go value, use runtime/cgo.Handle.
  • C memory is invisible to the Go GC, to GOMEMLIMIT, and to Go heap profiles. A leak in C shows up only as growing RSS.
  • Builds: cgo needs a C toolchain for the target; cross-compiling becomes hard; the binary links libc dynamically by default. CGO_ENABLED=0 produces a static binary that runs in a scratch container — the norm for Go services. Several standard packages have pure-Go fallbacks used when cgo is off (net DNS resolution, os/user).
  • Tooling: go tool pprof and the execution tracer see time in C only as a cgo call; profiling inside the C code needs perf.

Rule: use cgo to reuse a large, well-tested C library you cannot reasonably replace. Do not use it for performance on small operations.

System calls

syscall is frozen; new code uses golang.org/x/sys/unix (and .../windows), which has current constants, structures, and wrappers for each OS.

How a syscall runs: the wrapper calls entersyscall (marking the goroutine as in-syscall so sysmon can hand its P to another thread if it takes long), executes the trap instruction (on Linux) or calls into libSystem (on macOS, which does not support raw syscalls from user programs), then exitsyscall. RawSyscall skips the scheduler bookkeeping and is only safe for calls that never block.

Call (measured on macOS) ns/op
syscall.Getppid() — a real kernel round trip 127
syscall.Getpid() 11 — the C library caches the PID, no kernel entry

A syscall costs on the order of 100 ns of mode switch and bookkeeping before it does any work (System Calls). That is why buffering matters: writing 16-byte messages to a TCP socket one Write at a time cost 5.8 µs per message in Network Programming in Go, and 8 ns with a bufio.Writer batching them into 64 KB writes.

Example: memory-mapping a file

import "golang.org/x/sys/unix"

// MapFile maps a file read-only. The slice is valid until unmap is
// called; touching it afterwards is a segfault, not a panic.
func MapFile(path string) (data []byte, unmap func() error, err error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, nil, err
    }
    defer f.Close() // the mapping outlives the descriptor
    st, err := f.Stat()
    if err != nil {
        return nil, nil, err
    }
    if st.Size() == 0 {
        return nil, func() error { return nil }, nil // mmap of length 0 fails
    }
    data, err = unix.Mmap(int(f.Fd()), 0, int(st.Size()), unix.PROT_READ, unix.MAP_SHARED)
    if err != nil {
        return nil, nil, err
    }
    return data, func() error { return unix.Munmap(data) }, nil
}

The returned []byte is not Go heap memory: the GC ignores it, it costs nothing to "allocate", and reading it triggers page faults that the kernel serves from the page cache (Linux I/O Stack and Page Cache). Trade-offs that bite in Go specifically:

  • A page fault on a cold page blocks the thread without the scheduler knowing (it is not a syscall), so a goroutine reading a cold mapping stalls its P for the whole disk read.
  • I/O errors on a mapped file arrive as SIGBUS, which crashes the program unless debug.SetPanicOnFault(true) is set on that goroutine.
  • If another process truncates the file, accessing the vanished pages is also SIGBUS.

These are the reasons several databases moved away from mmap for their main data files (and why LMDB-style designs that rely on it document the caveats carefully).

Common pitfall

Keeping a zero-copy string past the life of its buffer. A log processor reads lines with bufio.Scanner, converts scanner.Bytes() to a string with unsafe.String "to avoid allocations", and stores the strings as map keys for deduplication. Scanner.Bytes() returns a view into an internal buffer that the next Scan() overwrites, so the map's keys mutate as the file is read: lookups miss, the map contains keys that are no longer equal to their own hashes, and results are wrong in ways no test with small inputs catches. The fix is the boring copy — string(scanner.Bytes()) or scanner.Text() — which costs one allocation per stored key. unsafe conversions are only correct when you can point to the line of code that guarantees the bytes are never written again.

Review questions

  1. Why must uintptr(p) + off be converted back to unsafe.Pointer in the same expression?
  2. Go's GC does not move heap objects. Why is holding a uintptr to a variable still dangerous?
  3. What does a blocking C function do to the scheduler, and how does that differ from a goroutine blocked on a channel?
  4. Why is syscall.Getpid 10x faster than syscall.Getppid on macOS?
  5. Name two ways reading a memory-mapped file can hurt a Go program that reading it with pread would not.