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:
*T1→unsafe.Pointer→*T2, whenT2is no larger thanT1and they have compatible memory layout. Example:math.Float64bitsis*(*uint64)(unsafe.Pointer(&f)).- Pointer arithmetic in one expression:
unsafe.Pointer(uintptr(p) + off)— the conversion touintptr, the arithmetic, and the conversion back must be in the same expression, so the GC never sees an object referenced only by an integer. Preferunsafe.Add(p, off). - Calls to
syscall.Syscallwithuintptr(unsafe.Pointer(p))directly in the argument list: the compiler keepspalive for the call. reflect.Value.Pointer/UnsafeAddrresults 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, useruntime/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=0produces a static binary that runs in ascratchcontainer — the norm for Go services. Several standard packages have pure-Go fallbacks used when cgo is off (netDNS resolution,os/user). - Tooling:
go tool pprofand the execution tracer see time in C only as a cgo call; profiling inside the C code needsperf.
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 unlessdebug.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¶
- Why must
uintptr(p) + offbe converted back tounsafe.Pointerin the same expression? - Go's GC does not move heap objects. Why is holding a
uintptrto a variable still dangerous? - What does a blocking C function do to the scheduler, and how does that differ from a goroutine blocked on a channel?
- Why is
syscall.Getpid10x faster thansyscall.Getppidon macOS? - Name two ways reading a memory-mapped file can hurt a Go program that reading it with
preadwould not.