Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Using unsafe.Pointer in Go for Zero-Copy Struct Conversion and System Calls

Every packet that crosses our gateway gets decoded into a struct. The naive path – encoding/binary reading seven fields one at a time, on every packet – is slow in the way that matters most: it is a fixed per-packet tax. On our busiest collector, 3M packets/s with 12-byte headers, that tax showed up as 30% of a core doing nothing but field loads. The obvious fix, casting a byte buffer to a struct with unsafe.Pointer, is the kind of “clever” that ships a correctness bug to a fleet unless you understand exactly what the compiler is (not) checking.

This post is about the narrow cases where a direct struct cast is legitimate, the two traps that make it silently wrong (alignment and endianness), and the GC lifetime rule that prevents memory corruption that doesn’t crash.

unsafe exists so Go can interoperate with C, mmap’d memory, and the syscall ABI. The Go spec allows a small set of pointer conversion patterns; a struct cast uses pattern one: *T1 to unsafe.Pointer to *T2. The compiler will not emit any runtime check for you – bounds, alignment, and liveness are all on your side of the fence. If a go vet run on your codebase does not flag the conversion as obviously wrong, that is a statement about syntax, not about safety.

The rule we apply in code review: a struct cast is only acceptable when you control the byte layout on both sides. That means mmap’d log segments, shared-memory IPC, kernel ABI structures – never a format defined on the wire by someone else’s spec.

Trap one: endianness

A struct cast reads memory in host byte order. Network byte order is big-endian. On the little-endian machines everyone deploys, your freshly cast fields will be byte-swapped garbage unless you define the format as native-endian. We keep a copy of this rule next to every unsafe cast in our tree:

  • Format you own and host-native endianness: cast is fine.
  • Anything on the wire (TCP, files, RPC): use binary.BigEndian, not a cast.

binary.BigEndian.Uint32(buf[4:]) is also allocation-free, so you are not even giving up the perf win on the hot path – you are just being honest about who owns the bytes.

Trap two: alignment

On amd64 an unaligned load is tolerated (slowly); on older 32-bit ARM the kernel traps unaligned access, and your cast reads garbage or faults. A malloc’d byte slice is aligned to the largest native type, but the offset of the cast inside the buffer is your responsibility. If some code path hands you buf[3:], your uint32 fields are misaligned.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Record struct {
	ID    uint32
	Seq   uint64
	Len   uint16
	Flags uint8
}

// recordAt returns a zero-copy view of a Record stored at off in buf.
// Safe only when the format is native-endianness and off is aligned.
func recordAt(buf []byte, off int) (*Record, error) {
	if off < 0 || off+int(unsafe.Sizeof(Record{})) > len(buf) {
		return nil, io.ErrUnexpectedEOF
	}
	if off%int(unsafe.Alignof(Record{})) != 0 {
		return nil, errMisaligned
	}
	return (*Record)(unsafe.Pointer(&buf[off])), nil
}

The explicit bounds check and alignment check are not ceremony – they are the runtime safety checks the compiler declined to emit.

The GC lifetime rule

Here is the subtle one. recordAt returns a pointer into buf’s backing array. If the caller hands that *Record to another goroutine and lets buf go out of scope, the GC sees no Go pointer to the array anymore and collects it – while your goroutine still holds the cast pointer. The pages stay mapped until something reuses them, so the read does not segfault; it returns stale or corrupt data. That is the insidious part: an unsafe-view-outlives-its-backing-buffer bug does not crash, it corrupts, and it corrupts only under GC pressure, which is exactly when you are not looking.

The rule: an unsafe view must never outlive the slice that backs it. If it must, copy the record or keep a reference to buf alive for as long as the view is reachable. We encode this with a comment convention at the call site and a code-review checklist item, because no compiler or linter catches it.

The syscall angle

The other legitimate use of unsafe.Pointer is passing a pointer to a raw syscall so the kernel writes directly into a buffer you own instead of through the allocator:

1
2
3
4
5
6
_, _, errno := syscall.RawSyscall6(
	syscall.SYS_MMAP, 0, uintptr(64<<10),
	syscall.PROT_READ|syscall.PROT_WRITE,
	syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS,
	^uintptr(0), 0,
)

The same lifetime rule applies: the kernel writes into memory Go’s GC does not track, so the region belongs fully to the code that mapped it.

What you actually gain

On the same 2019-era Xeon, decoding a 12-byte header per packet: ~25ns via a struct cast versus ~45ns with encoding/binary per-field loads – about 1.8x, both zero-allocation. The cast trades explicit bounds checks for instruction savings. Where it really pays off is zero-copy payload access: slicing a 4KB payload from a 3M packets/s stream avoids ~12GB/s of memcpy that the “obvious” decode path performs.

Production lessons

  • Keep every unsafe cast in one small, heavily-reviewed file. Grep for unsafe. in review; each new site should justify itself.
  • Run go vet in CI; it catches the obviously wrong conversions, and your reviewers can focus on the subtly wrong ones.
  • Validate bounds and alignment before casting; never cast a subslice whose offset you did not check.
  • Endianness is a property of the format, not the code. Formats you own, cast. Wire formats, binary.BigEndian.
  • Measure before and after. If the safe version is within 2x and the path is not the measured bottleneck, the cast is not worth the review cost.

comments powered by Disqus