Go的设计保证了一系列安全性,限制了Go程序可能出现问题的方式。在编译期间,类型检查会检测到大多数试图将操作应用于不适合其类型的值的尝试,例如,从一个字符串中减去另一个字符串。严格的类型转换规则阻止了直接访问内置类型(如字符串、映射、切片和通道)的内部。
对于静态无法检测到的错误,例如越界访问数组或空指针解引用,动态检查确保程序在发生禁止操作时立即终止,并提供信息丰富的错误。自动内存管理(垃圾回收)消除了"释放后使用"的错误,以及大多数内存泄漏。
许多实现细节对于Go程序是不可访问的。无法发现类似结构体这样的聚合类型的内存布局,也无法获取函数的机器代码,或者当前goroutine正在运行的操作系统线程的标识。
实际上,Go调度器自由地将goroutine从一个线程移动到另一个线程。指针标识一个变量而不揭示变量的数值地址。随着垃圾回收器移动变量,地址可能会更改;指针会被透明地更新。
这些特性共同使得Go程序,特别是失败的程序,比C语言程序更加可预测和less神秘。通过隐藏底层细节,它们还使得Go程序高度可移植,因为语言语义在很大程度上独立于任何特定的编译器、操作系统或CPU架构(并非完全独立:一些细节会泄露出来,例如处理器的字长、某些表达式的求值顺序以及编译器施加的一些实现限制)。
偶尔,我们可能选择放弃其中一些有用的保证,以达到最高的性能,与其他语言编写的库进行交互,或者实现无法用纯Go表达的函数。
在本章中,我们将看到如何使用unsafe包跳出通常的规则,以及如何使用cgo工具为C库和操作系统调用创建Go绑定。
本章描述的方法不应该轻率地使用。如果不仔细注意细节,它们可能会导致不可预测的、难以理解的、非局部的失败,这是C程序员不愿意接触到的。使用unsafe还会使得Go与未来版本的兼容性保证失效,因为,无论是有意还是无意,很容易依赖于未指定的实现细节,这些细节可能会意外更改。
unsafe包相当神奇。虽然它看起来像是一个常规的包,并且以通常的方式导入,但实际上它是由编译器实现的。它提供了许多内置语言特性的访问权限,这些特性通常不可用,因为它们暴露了Go的内存布局细节。将这些特性呈现为一个单独的包使得它们在需要时更加显眼。此外,一些环境可能出于安全原因限制了对unsafe包的使用。
unsafe包在与操作系统交互的低级别包(如runtime、os、syscall和net)中广泛使用,但在普通程序中几乎从不需要。
13.1 unsafe.Sizeof、Alignof和Offsetof
unsafe.Sizeof函数报告其操作数的表示大小(以字节为单位),其操作数可以是任何类型的表达式;表达式不会被求值。对Sizeof的调用是一个uintptr类型的常量表达式,因此结果可以用作数组类型的维度,或者用于计算其他常量。
go
import "unsafe"
fmt.Println(unsafe.Sizeof(float64(0))) // "8"
Sizeof仅报告数据结构的固定部分的大小,例如字符串的指针和长度,但不包括字符串内容等间接部分。下面列出了所有非聚合Go类型的典型大小,尽管确切的大小可能因工具链而异。为了可移植性,我们已经给出了引用类型(或包含引用的类型)的大小,以字为单位,其中字在32位平台上为4字节,在64位平台上为8字节。
当值正确对齐时,计算机从内存中加载和存储值的效率最高。例如,int16等两字节类型的值的地址应为偶数,rune等四字节值的地址应为4的倍数,而float64、uint64或64位指针等八字节值的地址应为8的倍数。更高倍数的对齐要求通常很少见,即使对于更大的数据类型,如complex128也是如此。
因此,聚合类型(如结构体或数组)的值的大小至少是其字段或元素大小的总和,但由于存在"空隙",可能会更大。空隙是编译器添加的未使用的空间,以确保接下来的字段或元素相对于结构体或数组的起始位置正确对齐。
语言规范并不保证字段声明的顺序就是它们在内存中排列的顺序,因此理论上编译器可以自由重新排列它们,尽管截至我们撰写本文时,没有编译器这样做。如果结构体字段的类型大小不同,以尽可能紧凑的方式声明字段的顺序可能更节省空间。下面的三个结构体具有相同的字段,但第一个结构体比其他两个需要多达50%的内存空间:
本书不涉及对齐算法的细节,而且确实不值得为每个结构体担心,但是高效的对齐可能会使频繁分配的数据结构更加紧凑,因此更快。
unsafe.Alignof函数报告其参数类型的所需对齐方式。与Sizeof类似,它可以应用于任何类型的表达式,并产生一个常量。通常,布尔类型和数值类型的对齐方式与它们的大小相同(最大为8字节),而所有其他类型都是字对齐的。
unsafe.Offsetof函数的操作数必须是一个字段选择器x.f,它计算相对于结构体x的开始处的字段f的偏移量,如果有的话,还要考虑空隙。图13.1显示了一个结构体变量x及其在典型的32位和64位Go实现中的内存布局。灰色区域表示空隙。
go
var x struct {
a bool
b int16
c []int
}
下表显示了将三个unsafe函数应用于结构体变量x本身以及其三个字段的结果:
尽管它们的名称是unsafe,但事实上这些函数并不是不安全的,它们在优化空间时可能有助于理解程序中原始内存的布局。
13.2 unsafe.Pointer
大多数指针类型写作*T
,意味着"指向类型T的变量的指针"。unsafe.Pointer类型是一种特殊类型的指针,可以保存任何变量的地址。当然,我们不能通过*p
间接地使用unsafe.Pointer,因为我们不知道该表达式应该具有什么类型。与普通指针类似,unsafe.Pointer是可比较的,并且可以与nil比较,nil是该类型的零值。
普通的T指针可以转换为unsafe.Pointer,并且unsafe.Pointer可以转换回普通指针,不一定是相同类型的T。例如,通过将float64指针转换为uint64,我们可以检查浮点变量的位模式:
go
package math
func Float64bits(f float64) uint64 { return *(*uint64)(unsafe.Pointer(&f)) }
fmt.Printf("%#016x\n", Float64bits(1.0)) // "0x3ff0000000000000"
通过结果指针,我们也可以更新位模式。对于浮点变量来说,这是无害的,因为任何位模式都是合法的,但一般来说,unsafe.Pointer转换使我们能够向内存写入任意值,从而破坏了类型系统。
unsafe.Pointer还可以转换为一个uintptr,它保存了指针的数值,让我们可以对地址进行算术运算(回想一下第三章,uintptr是一个无符号整数,宽度足以表示一个地址)。这种转换也可以反向应用,但同样地,从uintptr转换为unsafe.Pointer可能会破坏类型系统,因为并非所有的数字都是有效的地址。
因此,许多unsafe.Pointer值都是将普通指针转换为原始数字地址,并反向转换的中间值。下面的示例获取变量x的地址,添加其b字段的偏移量,将结果地址转换为*int16,并通过该指针更新x.b:
go
var x struct {
a bool
b int16
c []int
}
// equivalent to pb := &x.b
pb := (*int16)(unsafe.Pointer(
uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b)))
*pb = 42
fmt.Println(x.b) // "42"
虽然语法可能有些繁琐,也许这并不是坏事,因为这些特性应该谨慎使用,但不要试图引入类型为uintptr的临时变量来换行。以下代码是不正确的:
go
// NOTE: subtly incorrect!
tmp := uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b)
pb := (*int16)(unsafe.Pointer(tmp))
*pb = 42
原因非常微妙。一些垃圾收集器会在内存中移动变量,以减少碎片化或管理开销。这种类型的垃圾收集器被称为移动GC。当变量被移动时,所有持有旧位置地址的指针都必须更新为指向新位置。从垃圾收集器的角度来看,unsafe.Pointer是一个指针,因此其值必须随着变量移动而更改,但uintptr只是一个数字,所以其值不能更改。上面的不正确代码用一个数字隐藏了一个指针,使得垃圾收集器无法感知到,而这个指针被存储在非指针类型的变量tmp中。到第二条语句执行时,变量x可能已经移动,而tmp中的数字将不再是地址&x.b。第三条语句会在一个内存位置上写入值42,这个内存位置可能已经不是&x.b。
上面的问题会导致很多其他错误写法。在执行了这个语句之后:
go
pT := uintptr(unsafe.Pointer(new(T))) // NOTE: wrong!
在执行了这个语句之后,没有指针引用到由new创建的变量,因此当这个语句完成时,垃圾收集器有权在这个语句结束后对其进行回收,此后pT包含的是变量的地址,但不再有效。
当前的Go实现中没有使用移动垃圾收集器(尽管未来的实现可能会使用),但这并不是一种自满的理由:Go的当前版本确实会将一些变量移动到内存中的其他位置。回想一下第5.2节,goroutine堆栈会根据需要增长。当这种情况发生时,旧堆栈上的所有变量可能会被重新定位到一个新的、更大的堆栈上,因此我们不能依赖于变量地址的数值在其生命周期内保持不变。
在撰写本文时,关于在unsafe.Pointer转换为uintptr后Go程序员可以依赖的内容几乎没有明确的指导(参见Go issue 7192)。将所有uintptr值视为变量的先前地址,并尽量减少将unsafe.Pointer转换为uintptr并使用该uintptr之间的操作数量。在我们上面的第一个示例中,这三个操作------转换为uintptr,加上字段偏移量,再转换回来------都出现在单个表达式中。
当调用返回uintptr的库函数时,例如来自reflect包的以下函数,应立即将结果转换为unsafe.Pointer以确保它继续指向相同的变量。
go
package reflect
func (Value) Pointer() uintptr
func (Value) UnsafeAddr() uintptr
func (Value) InterfaceData() [2]uintptr // (index 1)
13.3 例子:深度相等
reflect包中的DeepEqual函数用于判断两个值是否"深度"相等。DeepEqual会将基本值按照内置的==
运算符进行比较;对于复合值,它会递归地遍历它们,并比较相应的元素。由于它适用于任何一对值,即使它们不可使用==
进行比较,因此它在测试中被广泛使用。
以下测试使用DeepEqual来比较两个[]string值:
go
got := strings.Split("a:b:c", ":")
want := []string{"a", "b", "c"}
if !reflect.DeepEqual(got, want) { /* ... */ }
尽管DeepEqual很方便,但它的区分看起来可能是随意的。例如,它不将nil映射视为与非nil空映射相等,也不将nil切片视为与非nil空切片相等:
go
var a, b []string = nil, []string{}
fmt.Println(reflect.DeepEqual(a, b)) // "false"
var c, d map[string]int = nil, make(map[string]int)
fmt.Println(reflect.DeepEqual(c, d)) // "false"
在本节中,我们将定义一个名为Equal的函数,用于比较任意值。与DeepEqual类似,它基于它们的元素来比较切片和映射,但与DeepEqual不同的是,它将nil切片(或映射)视为与非nil空切片(或映射)相等。基本的递归过程可以使用反射来完成,使用与我们在第12.3节中看到的Display程序类似的方法。
像往常一样,我们为递归定义了一个未导出的函数equal。暂时不要担心seen参数。对于要比较的每一对值x和y,equal检查它们是否都是有效的(或都不是有效的),并检查它们是否具有相同的类型。该函数的结果被定义为一组switch case语句,用于比较相同类型的两个值。由于篇幅原因,我们省略了几个case,因为这个模式现在应该很熟悉了。
go
func equal(x, y reflect.Value, seen map[comparison]bool) bool {
if !x.IsValid() || !y.IsValid() {
return x.IsValid() == y.IsValid()
}
if x.Type() != y.Type() {
return false
}
// ...cycle check omitted (shown later)...
switch x.Kind() {
case reflect.Bool:
return x.Bool() == y.Bool()
case reflect.String:
return x.String() == y.String()
// ...numeric cases omitted for brevity...
case reflect.Chan, reflect.UnsafePointer, reflect.Func:
return x.Pointer() == y.Pointer()
case reflect.Ptr, reflect.Interface:
return equal(x.Elem(), y.Elem(), seen)
case reflect.Array, reflect.Slice:
if x.Len() != y.Len() {
return false
}
for i := 0; i < x.Len(); i++ {
if !equal(x.Index(i), y.Index(i), seen) {
return false
}
}
return true
// ...struct and map cases omitted for brevity...
}
panic("unreachable")
}
像往常一样,我们不会在API中暴露使用反射的细节,因此导出的函数Equal必须在其参数上调用reflect.ValueOf:
go
// Equal reports whether x and y are deeply equal.
func Equal(x, y interface{}) bool {
seen := make(map[comparison]bool)
return equal(reflect.ValueOf(x), reflect.ValueOf(y), seen)
}
type comparison struct {
x, y unsafe.Pointer
t reflect.Type
}
为了确保算法即使对于循环数据结构也能终止,它必须记录已经比较过的变量对,并避免第二次比较它们。Equal分配了一组comparison结构,每个结构包含两个变量的地址(表示为unsafe.Pointer值)和比较的类型。我们需要记录类型,除了地址之外,因为不同的变量可能具有相同的地址。例如,如果x和y都是数组,那么x和x[0]具有相同的地址,y和y[0]也是如此,因此区分我们是否已经比较了x和y,还是x[0]和y[0]是很重要的。
一旦equal确定其参数具有相同的类型,并且在执行switch之前,它会检查是否正在比较已经看到的两个变量,如果是,则终止递归。
go
// cycle check
if x.CanAddr() && y.CanAddr() {
xptr := unsafe.Pointer(x.UnsafeAddr())
yptr := unsafe.Pointer(y.UnsafeAddr())
if xptr == yptr {
return true // identical references
}
c := comparison{xptr, yptr, x.Type()}
if seen[c] {
return true // already seen
}
seen[c] = true
}
这是我们的Equal函数的示例:
它甚至可以处理类似于导致第12.3节中的Display函数陷入循环的循环输入:
go
// Circular linked list a -> b -> a and c -> c.
type link struct {
value string
tail *link
}
a, b, c := &link{value: "a"}, &link{value: "b"}, &link{value: "c"}
a.tail, b.tail, c.tail = b, a, c
fmt.Println(Equal(a, a)) // "true"
fmt.Println(Equal(b, b)) // "true"
fmt.Println(Equal(c, c)) // "true"
fmt.Println(Equal(a, b)) // "false"
fmt.Println(Equal(a, c)) // "false"
13.4 使用cgo调用C代码
Go程序可能需要使用用C实现的硬件驱动程序,查询用C++实现的嵌入式数据库,或者使用用Fortran实现的一些线性代数例程。长期以来,C一直是编程的通用语言,因此许多旨在广泛使用的软件包都会导出一个与C兼容的API,而不管其实现的语言是什么。
在本节中,我们将构建一个简单的数据压缩程序,该程序使用cgo,这是一个为C函数创建Go绑定的工具。这类工具称为外部函数接口(FFI),而cgo并不是适用于Go程序的唯一一种。SWIG(swig.org)是另一种工具;它提供了更复杂的特性,用于与C++类集成,但我们这里不会展示它。
标准库中的compress/...子树提供了流行压缩算法的压缩器和解压器,包括LZW(Unix compress命令使用)和DEFLATE(GNU gzip命令使用)。这些包的API在细节上略有不同,但它们都提供了一个io.Writer的包装器,用于压缩写入到其中的数据,并提供了一个io.Reader的包装器,用于解压从中读取的数据。例如:
go
package gzip // compress/gzip
func NewWriter(w io.Writer) io.WriteCloser
func NewReader(r io.Reader) (io.ReadCloser, error)
基于优雅的Burrows-Wheeler变换的bzip2算法运行速度比gzip慢,但提供了显著更好的压缩效果。compress/bzip2包提供了一个用于bzip2的解压缩器,但目前该包没有提供压缩器。从头开始构建一个压缩器是一个相当大的任务,但是有一个经过良好文档化且高性能的开源C实现,即来自bzip.org的libbzip2包。
如果C库很小,我们会将其移植为纯Go;如果其性能对我们的目的不是关键,我们最好使用os/exec包将其作为辅助子进程调用C程序。当需要使用一个具有C API的复杂、性能关键的库时,使用cgo对其进行封装可能是有意义的。在本章的其余部分中,我们将通过一个示例来详细说明。
从libbzip2 C包中,我们需要bz_stream结构类型,该类型保存输入和输出缓冲区,以及三个C函数:BZ2_bzCompressInit,它分配流的缓冲区;BZ2_bzCompress,它将数据从输入缓冲区压缩到输出缓冲区;和BZ2_bzCompressEnd,它释放缓冲区(不要担心libbzip2包的机制;此示例的目的是展示这些部分如何组合在一起)。
我们将直接从Go中调用BZ2_bzCompressInit和BZ2_bzCompressEnd C函数,但对于BZ2_bzCompress,我们将在C中定义一个包装函数,以展示如何实现。下面的C源文件与我们的包中的Go代码放在一起:
c
/* This file is gopl.io/ch13/bzip/bzip2.c */
/* a simple wrapper for libbzip2 suitable for cgo. */
#include <bzlib.h>
int bz2compress(bz_stream *s, int action,
char *in, unsigned *inlen, char *out, unsigned *outlen) {
s->next_in = in;
s->avail_in = *inlen;
s->next_out = out;
s->avail_out = *outlen;
int r = BZ2_bzCompress(s, action);
*inlen -= s->avail_in;
*outlen -= s->avail_out;
return r;
}
现在让我们转向Go代码,其中的第一部分如下所示。import "C" 声明是特殊的。虽然没有C包,但这个导入会导致go build在Go编译器看到它之前使用cgo工具对文件进行预处理。
go
// Package bzip provides a writer that uses bzip2 compression (bzip.org).
package bzip
/*
#cgo CGLAGS: -I/usr/include
#cgo LDFLAGS: -L/usr/lib -lbz2
#include <bzlib.h>
int bz2compress(bz_stream *s, int action,
char *in, unsigned *inlen, char *out, unsigned *outlen)
*/
import "C"
import "io"
type writer struct {
w io.Writer // underlying output stream
stream *C.bz_stream
outbuf [64 * 1024]byte
}
// NewWriter returns a writer for bzip2-compressed streams.
func NewWriter(out io.Writer) io.WriteCloser {
const (
blockSize = 9
verbosity = 0
workFactor = 30
)
w := &writer{w: out, stream: new(C.bz_stream)}
C.BZ2_bz_CompressInit(w.stream, blockSize, verbosity, workFactor)
return w
}
在预处理过程中,cgo产生一个临时包,这个包里面包含了所有C函数和类型对应的Go语言声明,例如C.bz_stream和C.BZ2_bzCompressInit。cgo工具通过以特殊方式调用C编译器来发现这些类型,即在导入声明之前的注释内容。
该注释还可以包含#cgo指令,用于指定C工具链的额外选项。CFLAGS和LDFLAGS为编译器和链接器命令提供额外参数,以便它们可以定位bzlib.h头文件和libbz2.a存档库。示例假设这些文件已经安装在您系统的/usr目录下。你可能需要修改或删除这些标志以适应你的安装。
NewWriter调用C函数BZ2_bzCompressInit来初始化流的缓冲区。writer类型包括另一个缓冲区,用于排空解压缩器的输出缓冲区。
Write方法(如下所示)将未压缩的数据提供给压缩器,在循环中调用函数bz2compress,直到所有数据都被消耗。请注意,Go程序可以通过C.x表示法访问C类型,如bz_stream、char和uint,C函数,如bz2compress,甚至像BZ_RUN这样的类似对象的C预处理宏。即使C.uint类型与Go的uint类型具有相同的宽度,它们也是不同的。
go
func (w *writer) Write(data []byte) (int, error) {
if w.stream == nil {
panic("closed")
}
var total int // uncompressed bytes written
for len(data) > 0 {
inlen, outlen := C.uint(len(data)), C.uint(cap(w.outbuf))
C.bz2compress(w.stream, C.BZ_RUN,
(*C.char)(unsafe.Pointer(&data[0])), &inlen,
(*C.char)(unsafe.Pointer(&w.outbuf)), &outlen)
total += int(inlen)
data = data[inlen:]
if _, err := w.w.Write(w.outbuf[:outlen]); err != nil {
return total, err
}
}
return total, nil
}
循环的每次迭代都将剩余数据部分的地址和长度以及w.outbuf的地址和容量传递给bz2compress函数。这两个长度变量是通过它们的地址而不是它们的值传递的,以便C函数可以更新它们以指示消耗了多少未压缩数据和产生了多少压缩数据。然后,每个压缩数据块都被写入底层的io.Writer。
Close方法与Write方法的结构类似,使用循环来清空流的输出缓冲区中的任何剩余压缩数据。
go
// Close flushes the compressed data and closes the stream.
// It does not close the underlying io.Writer.
func (w *writer) Close() error {
if w.stream == nil {
panic("closed")
}
defer func() {
C.BZ2_bz_CompressEnd(w.stream)
w.stream = nil
}()
for {
inlen, outlen := C.uint(0), C.uint(cap(w.outbuf))
r := C.bz2compress(w.stream, C.BZ_FINISH, nil, &inlen,
(*C.char)(unsafe.Pointer(&w.outbuf)), &outlen)
if _, err := w.w.Write(w.outbuf[:outlen]); err != nil {
return err
}
if r == C.BZ_STREAM_END {
return nil
}
}
}
Close调用C.BZ2_bzCompressEnd来释放流缓冲区,使用defer确保在所有返回路径上都会执行此操作。此时,w.stream指针不再安全可引用。为了保守起见,我们将其设置为nil,并在每个方法中添加显式的nil检查,以便在Close之后用户误调用同一方法时程序会抛出panic异常。
不仅writer不是并发安全的,而且对Close和Write的并发调用可能导致程序在C代码中崩溃。修复这个问题是练习13.3。
下面的程序bzipper是一个使用我们新包的bzip2压缩器命令。它的行为类似于许多Unix系统上存在的bzip2命令。
go
// Bzipper reads input, bzip2-compresses it, and writes it out.
package main
import (
"io"
"log"
"os"
"gopl.io/ch13/bzip"
)
func main() {
w := bzip.NewWriter(os.Stdout)
if _, err := io.Copy(w, os.Stdin); err != nil {
log.Fatalf("bzipper: %v\n", err)
}
if err := w.Close(); err != nil {
log.Fatalf("bzipper: close: %v\n", err)
}
}
在下面的会话中,我们使用bzipper来压缩/usr/share/dict/words,即系统词典,从938,848字节压缩到335,405字节,大约是其原始大小的三分之一,然后使用系统的bunzip2命令进行解压缩。SHA256哈希在压缩前后都相同,这让我们对压缩器的正常工作有了信心(如果您的系统上没有sha256sum命令,请使用你对练习4.2的解决方案)。
我们已经演示了如何将一个C库链接到一个Go程序中。往另一个方向看,也可以将一个Go程序编译成一个静态存档文件,可以链接到一个C程序中,或者编译成一个共享库,可以动态地被一个C程序加载。在这里,我们只是初步了解了cgo的表面,关于内存管理、指针、回调、信号处理、字符串、errno、finalizers(Go语言中的一种特殊的函数,用于在对象被垃圾回收之前执行一些清理操作)以及goroutines和操作系统线程之间的关系,还有很多内容值得深入探讨。特别是,正确地从Go到C或相反方向传递指针的规则是复杂的,原因类似于我们在第13.2节中讨论的那些,并且尚未被权威地指定。欲了解更多信息,请从https://golang.org/cmd/cgo开始阅读。
13.5 关于安全的注意事项
我们在上一章结束时提出了关于反射接口的缺点的警告。这个警告同样适用于本章描述的unsafe包,甚至更加强烈。
高级语言使程序和程序员不仅免受个别计算机指令集的复杂细节的影响,而且免受诸如变量存储位置、数据类型大小、结构布局细节以及其他实现细节的干扰。由于这种隔离层的存在,可以编写安全、健壮的程序,并且这些程序在任何操作系统上都可以运行而无需更改。
unsafe包允许程序员穿透这种隔离,使用一些关键但通常无法访问的特性,或者可能实现更高的性能。然而,代价通常是可移植性和安全性,因此使用unsafe是有风险的。我们关于何时以及如何使用unsafe的建议与Knuth在早期优化方面的评论相一致,在第11.5节中我们引用过。大多数程序员根本不需要使用unsafe。然而,偶尔会出现一些情况,其中一些关键的代码片段最好使用unsafe来编写。如果经过仔细研究和测量表明unsafe确实是最佳方法,那么应该将其限制在尽可能小的范围内,以便大部分程序不会察觉到其使用。
目前,将最后两章放在脑后。编写一些有实质性的Go程序。避免使用反射和unsafe;只有在必要时再回顾这些章节。
与此同时,愉快地编写Go程序。我们希望你享受编写Go程序的过程,就像我们一样。