Unsafe Code
Unsafe Code
The boring default is never to import unsafe. The one use this book will show is unsafe.Sizeof (and the idea of Alignof) so you can see how big a value is. That is a measurement. It is not a licence to poke memory.
Mental model
Go’s type system does not let you treat an arbitrary integer as a pointer. Package unsafe does. The compiler and the garbage collector have a contract with unsafe.Pointer. If you violate it, you do not get a nice error. You get a binary that is wrong on the next toolchain release.
unsafe.Sizeof(x) returns the size in bytes of x’s representation as a uintptr. It does not allocate. It does not follow pointers: the size of a string is the header (pointer plus length), not the bytes of the text.
unsafe.Alignof(x) is the alignment the compiler uses for that type. This chapter does not call it in code. You can see alignment effects with Sizeof on structs that include padding.
We will not take unsafe.Pointer to a uintptr and back. That is how people write exploits and also how people write “clever” code that breaks when the GC moves. Do not.
Worked examples
Sizes below assume a typical 64-bit machine (amd64 or arm64). On 32-bit, int and headers shrink. Measure on the machine you ship.
Case 1: Size of the types you already use
Save as sizes.go:
// sizes.go
package main
import (
"fmt"
"unsafe"
)
type Ticket struct {
ID int
Table int
}
func main() {
var n int
var s string
var t Ticket
var p *Ticket
fmt.Println("int", unsafe.Sizeof(n))
fmt.Println("string", unsafe.Sizeof(s))
fmt.Println("Ticket", unsafe.Sizeof(t))
fmt.Println("*Ticket", unsafe.Sizeof(p))
}Run:
go run sizes.goTypical output on 64-bit:
int 8
string 16
Ticket 16
*Ticket 8
string is 16 because it is a pointer and a length, each 8 bytes. The characters live elsewhere. Ticket is two ints. A pointer is one machine word.
Case 2: Padding is visible as a larger Sizeof
Save as padding.go. Both structs hold one int64 and two bools. Field order changes the size because the compiler inserts padding to align int64.
// padding.go
package main
import (
"fmt"
"unsafe"
)
type Waste struct {
A bool
B int64
C bool
}
type Tight struct {
B int64
A bool
C bool
}
func main() {
fmt.Println("Waste", unsafe.Sizeof(Waste{}))
fmt.Println("Tight", unsafe.Sizeof(Tight{}))
}Run:
go run padding.goTypical output on 64-bit:
Waste 24
Tight 16
Waste is 1 + 7 pad + 8 + 1 + 7 pad = 24. Tight is 8 + 1 + 1 + 6 pad = 16. That is interesting if you have millions of these in a slice. It is not a reason to reorder a desk Ticket with three fields.
Case 3: A slice header is not the backing array
Save as slice_size.go:
// slice_size.go
package main
import (
"fmt"
"unsafe"
)
func main() {
small := []int{1, 2, 3}
large := make([]int, 10_000)
fmt.Println("small header", unsafe.Sizeof(small))
fmt.Println("large header", unsafe.Sizeof(large))
fmt.Println("one int", unsafe.Sizeof(small[0]))
}Run:
go run slice_size.goTypical output on 64-bit:
small header 24
large header 24
one int 8
Both headers are 24 bytes (pointer, length, capacity). Sizeof does not report the 10_000 integers. Do not use it to estimate memory of a slice; use len × element size plus the header, and remember capacity.
Case 4: Field alignment and padding with Offsetof
Save as alignof.go. Why did Waste take 24 bytes in Case 2 while Tight took 16? Hardware CPUs read memory in naturally aligned boundaries (e.g. an 8-byte int64 must sit at a memory address divisible by 8). unsafe.Alignof reports the required alignment, and unsafe.Offsetof shows the exact byte offset of each field within the struct.
// alignof.go
package main
import (
"fmt"
"unsafe"
)
type Ticket struct {
Active bool
Table int64
ID int32
}
func main() {
var t Ticket
fmt.Println("size of Ticket:", unsafe.Sizeof(t))
fmt.Println("align of bool:", unsafe.Alignof(t.Active))
fmt.Println("align of int64:", unsafe.Alignof(t.Table))
fmt.Println("offset of Active:", unsafe.Offsetof(t.Active))
fmt.Println("offset of Table:", unsafe.Offsetof(t.Table))
fmt.Println("offset of ID:", unsafe.Offsetof(t.ID))
}Run:
go run alignof.goTypical output on 64-bit:
size of Ticket: 24
align of bool: 1
align of int64: 8
offset of Active: 0
offset of Table: 8
offset of ID: 16
Active starts at byte 0 and takes 1 byte. Because Table requires 8-byte alignment, the compiler leaves bytes 1 through 7 unused as padding. Table occupies bytes 8 through 15. ID occupies bytes 16 through 19. Finally, 4 trailing padding bytes are added so that an array of Ticket structs preserves the 8-byte alignment for the next element (total 24 bytes).
The trap
Importing unsafe so you can convert a []byte to a string without copying looks fast and is a contract with the compiler you probably do not want. This chapter will not show that conversion.
The boring path copies. Save as copy_string.go:
// copy_string.go
package main
import "fmt"
func main() {
b := []byte("soup")
s := string(b)
b[0] = 'S'
fmt.Println(s)
fmt.Println(string(b))
}Run:
go run copy_string.goOutput:
soup
Soup
s is independent of b because string(b) copied. That is the behaviour you want at the desk. A unsafe.Pointer cast that aliases the same bytes will make s change when b changes — or worse, when the GC reclaims b. Do not go looking for that spell.
unsafe.Pointer is a contract with the compiler. If you are not prepared to read the current unsafe package docs and the Go release notes every six months, you are not prepared to use it.
The boring rule
- Do not import
unsafein desk code. unsafe.Sizeof,Alignof, andOffsetofare diagnostic rulers. Use them in a throwaway program when you are curious about memory layout.- Do not reorder fields for speed until a profile says a giant slice of that struct is hot.
- Do not use
unsafe.Pointer. Not to dodge a copy, not to call C, not to “inspect” a slice. - If a library requires you to pass
unsafe.Pointer, read why, wrap it in one file, and keep it off the rest of the API.
Try this
- In
sizes.go, add aboolfield toTicket(first, then last). PrintSizeofeach time. On 64-bit you will likely see padding. - In
alignof.go, reorder the fields inTicketto putTable int64first, thenID int32, thenActive bool. Printunsafe.Sizeof(t)and observe how the size drops from 24 to 16 bytes. - Print
unsafe.Sizeof([3]int{})and compare it tounsafe.Sizeof([]int{}). The array is three ints; the slice is a header. - Run
sizes.gowithGOARCH=386if you have a C compiler and cgo, or just readgo env GOARCHand accept that 32-bit numbers differ. Do not invent aPointercast to “normalise” them.