
unsafe
unsafe 是 Go 标准库中最容易被误用的包。它允许程序绕过一部分类型安全规则,查看任意类型的内存表示、计算字段偏移、把指针转换成另一种指针,甚至构造字符串和切片视图。
Go 官方文档对它的描述非常直接:unsafe 包包含绕过 Go 类型安全的操作;导入它的包可能不可移植,也不受 Go 1 兼容性保证保护。
所以本文的核心判断是:unsafe 适合封装在很小、很明确、经过基准和工具验证的边界内;它不应该成为普通业务代码的快捷写法。
本文会先理解 Sizeof、Alignof、Offsetof、Pointer,再讲 StringData、SliceData、String、Slice,最后实现两个版本的只读字节视图:安全版本使用普通转换,零拷贝版本使用 unsafe。对比之后可以看到,少一次分配并不自动等于更好的工程选择。
本文代码按 Go 1.27 标准库整理。
unsafe的布局、对齐和实现细节必须在目标架构上验证,示例输出不应视为跨平台固定值。
为什么存在 unsafe
Go 的普通代码有明确的类型边界:
var number int64 = 42
var text string = string(number) // 这是类型转换,不是把 int64 当成字符串内存
unsafe 允许在特殊场景下观察或转换底层表示:
- 编写运行时、序列化、内存映射和系统调用适配代码;
- 与 C ABI 或硬件内存交互;
- 实现经过约束的零拷贝视图;
- 检查结构体布局和对齐;
- 在性能关键路径中减少已确认的分配。
这些场景有共同特点:边界窄、约束强、测试必须覆盖生命周期和架构差异。
Sizeof、Alignof 和 Offsetof
Sizeof
unsafe.Sizeof(x) 返回值的静态大小,单位是字节。它不会递归计算引用指向的数据大小。
fmt.Println(unsafe.Sizeof(int32(0)))
fmt.Println(unsafe.Sizeof(string("hello")))
fmt.Println(unsafe.Sizeof([]byte("hello")))
在 64 位 Go 实现中,string 通常由数据指针和长度组成,切片通常由数据指针、长度和容量组成。Sizeof 得到的是这个描述符大小,不是字符串或切片底层数组大小。
Alignof
Alignof 返回类型的对齐要求:
fmt.Println(unsafe.Alignof(uint64(0)))
对齐影响结构体字段位置和 CPU 访问效率。不能假设所有架构都有相同布局。
Offsetof
Offsetof 返回结构体字段相对于结构体起始地址的字节偏移:
type Header struct {
Version uint8
Flags uint16
Length uint32
}
fmt.Println(unsafe.Offsetof(Header{}.Version))
fmt.Println(unsafe.Offsetof(Header{}.Flags))
fmt.Println(unsafe.Offsetof(Header{}.Length))
编译器可能在字段之间插入 padding,使字段满足对齐要求。不要根据字段声明顺序手算偏移。
查看布局
func printLayout() {
type Packet struct {
Kind uint8
Size uint64
Valid bool
}
fmt.Printf("size=%d align=%d\n", unsafe.Sizeof(Packet{}), unsafe.Alignof(Packet{}))
fmt.Printf("Kind offset=%d\n", unsafe.Offsetof(Packet{}.Kind))
fmt.Printf("Size offset=%d\n", unsafe.Offsetof(Packet{}.Size))
fmt.Printf("Valid offset=%d\n", unsafe.Offsetof(Packet{}.Valid))
}
如果结构体会通过网络协议或文件格式序列化,不要直接把 Go 结构体内存当成协议格式。padding、大小端和字段布局都需要显式定义。
unsafe.Pointer
unsafe.Pointer 是可以指向任意类型的指针。官方文档只认可若干种转换模式,例如:
value := uint32(0x11223344)
ptr := unsafe.Pointer(&value)
bytes := (*[4]byte)(ptr)
fmt.Printf("%x\n", bytes[:])
这个例子直接观察 uint32 的内存字节顺序,但输出会受机器大小端影响。网络协议应该使用 encoding/binary:
var buffer [4]byte
binary.BigEndian.PutUint32(buffer[:], value)
unsafe.Pointer 转换不能替代序列化库。只有当你确实需要访问同一块内存的不同视图时,才考虑它。
uintptr 不是长期指针
错误写法:
address := uintptr(unsafe.Pointer(&value))
// 中间执行可能触发 GC
ptr := (*uint32)(unsafe.Pointer(address))
把指针保存为 uintptr 后,GC 不会把它当作活跃指针追踪。正确的 Pointer 到 uintptr 转换应该出现在同一表达式中,并且只用于立即计算地址偏移:
field := (*byte)(unsafe.Add(unsafe.Pointer(&value), offset))
Go 1.17 之后可以使用 unsafe.Add 表达指针偏移,比手写 uintptr 更容易被工具检查。
字符串和字节视图
普通转换
把字符串转为字节切片:
func bytesCopy(s string) []byte {
return []byte(s)
}
这会创建一份可修改的副本,生命周期清晰,最推荐。
把字节切片转成字符串:
func stringCopy(b []byte) string {
return string(b)
}
这同样通常会创建字符串副本,调用者后续修改 b 不会改变返回值。
unsafe.StringData 和 unsafe.String
Go 提供了更直接的底层 API:
func stringBytesView(s string) []byte {
if len(s) == 0 {
return nil
}
return unsafe.Slice(unsafe.StringData(s), len(s))
}
这个函数返回的是字符串底层存储的只读视图,但类型是 []byte,调用者很容易误以为可以修改它。更安全的封装方式是返回 []constByte 并不现实,因为 Go 没有 const slice,因此通常不要公开这种视图,或者只在内部立即读取。
错误示例:
view := stringBytesView("hello")
view[0] = 'H' // 未定义行为风险,不应该这样做
字符串底层数据不可修改。即使某次运行没有立刻崩溃,也不能把它当成合法操作。
unsafe.String
从字节指针和长度构造字符串:
func stringView(b []byte) string {
if len(b) == 0 {
return ""
}
return unsafe.String(unsafe.SliceData(b), len(b))
}
返回的字符串引用 b 的底层数组,b 在字符串使用完之前必须保持有效且不能被修改。把这种字符串保存到缓存、跨 goroutine 传递或返回给外部调用者都需要非常谨慎。
实现字节视图
安全版本
先写一个没有 unsafe 的版本:
type Bytes struct {
data []byte
}
func NewBytes(s string) Bytes {
return Bytes{data: []byte(s)}
}
func (b Bytes) At(i int) byte {
return b.data[i]
}
func (b Bytes) Len() int {
return len(b.data)
}
func (b Bytes) String() string {
return string(b.data)
}
这个版本有一次复制,但所有权很清晰:Bytes 拥有自己的字节数组,原字符串后续如何变化都不影响它。
零拷贝版本
只读场景可以把字符串保存为字符串本身,需要读取时用 unsafe.StringData 临时构造切片:
type ReadOnlyString struct {
value string
}
func NewReadOnlyString(value string) ReadOnlyString {
return ReadOnlyString{value: value}
}
func (s ReadOnlyString) Len() int {
return len(s.value)
}
func (s ReadOnlyString) At(index int) byte {
return unsafe.Slice(unsafe.StringData(s.value), len(s.value))[index]
}
func (s ReadOnlyString) String() string {
return s.value
}
这里没有把字符串转换成 []byte,At 也没有分配长期切片。它仍然遵守字符串只读规则。
如果业务需要把字符串传给只接受 []byte 的函数,可以在非常窄的同步调用内构造视图:
func consumeReadOnlyString(s string, consume func([]byte)) {
if len(s) == 0 {
consume(nil)
return
}
view := unsafe.Slice(unsafe.StringData(s), len(s))
consume(view)
}
consume 不得修改 view,也不得在函数返回后保存它。这个约束必须写进函数文档,否则后来的维护者很容易破坏安全假设。
unsafe.Slice 和 SliceData
unsafe.Slice(ptr, len) 从指针构造切片:
values := [3]int{10, 20, 30}
slice := unsafe.Slice(&values[0], len(values))
fmt.Println(slice)
unsafe.SliceData 返回切片底层数组指针:
values := []byte("hello")
ptr := unsafe.SliceData(values)
fmt.Println(*ptr)
边界规则:
- 指针为 nil 时,长度必须为 0;
- 长度不能为负数;
- 构造出的切片不能越过真实分配;
- 指针指向的对象必须在切片使用期间保持存活;
- 切片的元素类型必须和真实内存布局匹配。
如果只是想把一段连续内存解析成整数数组,优先使用 encoding/binary 或 bytes。unsafe.Slice 只有在你控制分配、元素布局和生命周期时才值得考虑。
实战:零拷贝协议头解析
假设程序已经拿到一个固定长度的 ASCII 协议头:
VER=1;LEN=42;TYPE=PING
第一版使用普通字符串函数,安全且足够快:
func field(line, key string) (string, bool) {
prefix := key + "="
for _, part := range strings.Split(line, ";") {
if strings.HasPrefix(part, prefix) {
return strings.TrimPrefix(part, prefix), true
}
}
return "", false
}
如果 profiling 证明 []byte 转换和重复复制是热点,可以写一个只读扫描器:
type Scanner struct {
data []byte
}
func NewScanner(s string) Scanner {
if len(s) == 0 {
return Scanner{}
}
return Scanner{data: unsafe.Slice(unsafe.StringData(s), len(s))}
}
func (s Scanner) Field(key string) (string, bool) {
start := 0
for start <= len(s.data) {
end := bytes.IndexByte(s.data[start:], ';')
if end < 0 { end = len(s.data) - start }
part := s.data[start : start+end]
if bytes.HasPrefix(part, []byte(key+"=")) {
value := part[len(key)+1:]
return unsafe.String(unsafe.SliceData(value), len(value)), true
}
start += end + 1
}
return "", false
}
这个实现有几个非常严格的前提:
NewScanner的输入字符串必须在 Scanner 使用期间保持存活;Field返回的字符串仍然引用原字符串,不能让调用者修改底层数据;- 输入必须是合法的字节序列,协议层负责校验编码;
key + "="当前会产生临时字符串,真正优化前要先 benchmark;- 如果数据量不大,安全版本通常更值得维护。
因此它只能作为一个封装在内部的候选优化,而不是默认的数据解析器。
基准测试思路
func BenchmarkSafeField(b *testing.B) {
input := "VER=1;LEN=42;TYPE=PING"
for i := 0; i < b.N; i++ {
_, _ = field(input, "TYPE")
}
}
func BenchmarkUnsafeField(b *testing.B) {
input := "VER=1;LEN=42;TYPE=PING"
scanner := NewScanner(input)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = scanner.Field("TYPE")
}
}
只有在 benchmark 确认分配和耗时是实际瓶颈,并且收益大于维护风险时,才应该保留 unsafe 版本。运行时还应该使用:
go test -bench=. -benchmem ./...
go vet ./...
go test -race ./...
go vet 可以发现一部分不符合规范的 Pointer 使用,但通过 go vet 不等于证明所有 unsafe 代码都正确。
unsafe 与 GC、逃逸和生命周期
Go 的 GC 需要看到仍然有效的指针,才能保证对象不会过早回收。unsafe.Pointer 仍然是 GC 可以识别的指针;把它转换成 uintptr 后,GC 不会把整数当作指针追踪。
下面的原则必须牢记:
- 不要把
uintptr当作长期地址保存; - 不要把 Go 指针写入可能被 Go 代码之外长期保存的内存,除非符合 cgo 规则;
- 不要让由字符串构造的零拷贝切片逃过原字符串生命周期;
- 不要把只读字符串视图交给会修改输入的函数;
- 不要假设结构体布局跨架构不变;
- 不要把
unsafe用来绕过边界检查或类型检查; - 不要为了少一次分配牺牲清晰的所有权模型。
常见错误
把 string 的底层内存当成可写数组
bytes := unsafe.Slice(unsafe.StringData("hello"), 5)
bytes[0] = 'H'
这是错误用法。字符串是不可变值,底层存储不能写。
用 unsafe 解析网络协议
网络协议需要明确大小端、字段长度和版本。unsafe 读取 Go 结构体会受到 padding、对齐和架构影响。应使用 encoding/binary、encoding/json 或明确的协议解析器。
复制包含 sync 类型的结构体
unsafe 经常和结构体布局一起出现,但不要因此复制包含 Mutex、Once、Pool 等同步原语的结构体。它们通常只能初始化一次并通过指针使用。
忽略空切片和 nil 指针
unsafe.Slice(nil, 1) 会 panic。unsafe.StringData("") 返回的指针也不能被当成指向可写内存的地址。
总结
unsafe 给了 Go 程序观察和重解释内存的能力,也同时拿走了一部分编译器可以提供的保证。Sizeof、Alignof 和 Offsetof 适合检查布局;Pointer、Add、Slice 和字符串 API 适合极少数明确的底层边界。
工程上的使用顺序应该是:
- 先写普通、安全、可读的实现;
- 用 benchmark 证明真正存在分配或性能瓶颈;
- 把
unsafe限制在一个很小的函数里; - 在函数注释中写明生命周期、只读性和架构假设;
- 用
go vet、go test -race、多架构构建和基准测试验证; - 如果收益不明显,就保留安全版本。