Go 1.18 将引入新的网络地址包

2021-10-07 ⏳4.0分钟(1.6千字) 🕸️

很久之前,我在网上看到一篇 Brad Fitzpatrick 写的文章,名为 netaddr.IP: a new IP address type for Go。Brad 是 Go 语言的核心开发者,同时也是 tailscale 的创始人。Brad 在文章中分析了 Go 语言 net.IP 类型的问题和他们的应对方案以及方案的演变过程。最终 Brad 他们开源了 inet.af/netaddr 这个包。当时我就随便瞄了几眼,留下一点印象。今天收到邮件订阅说是 Go 1.18 接受了 Brad 的提案,准备引入一个新包 net/netip。打开一看,net/netip 就是前面说的 inet.af/netaddr。于是赶紧找出 Brad 的文章仔细读了一遍。读罢不仅明白了原有 net.IP 类型的缺点和新方案的巧妙,还对 Go 语言的内存分配、垃圾回收和 unsafe 包的使用都有更进一步的理解。今天整理成文分享给大家。

Go 的 net.IP 类型有什么问题?

Brad 在文章中列举了 net.IP 的「七宗罪」:

  1. 内容可变。net.IP 的底层类型是 []byte,也就是说net.IP按引用传递,任何处理它的函数都可以改动其中的内容。
  2. 不能直接比较。因为 slice 不能直接比较,所以 net.IP也不能直接使用==判断两个地址是否相等,也不能用作 map 的 key。
  3. 标准库里有 net.IPnet.IPAddr两种地址类型。常见的 IPv4 和 IPv6 地址使用net.IP保存。IPv6 的链路本地地址需要使用net.IPAddr保存(因为要额外保存链路的网卡)。因为有两种地址,所以就得确定要用哪一种或者同时都用。
  4. 占用内存多。一个切片头信息就需要 24 个字节(64位平台,详情见 Russ 的文章)。 所以net.IP的内存占用包含 24 字节的头信息和 4 字节(IPv4)或者 6 字节(IPv6)地址数据。如果需要保存本地链路网卡(zone),那net.IPAddr还需要一个 16 字节的字符串头信息以及具体的网卡名字。
  5. 需要从堆上分配内存。每次从堆上分配内存会给 GC 带来额外的压力。
  6. 从字符串解析 IP 地址的时候无法区分 IPv4 地址和 IPv4-mapped IPv6 地址(形如 ::ffff:192.168.1.1)。
  7. 对外暴露实现细节。 net.IP 的定义为 type IP []byte,底层的[]byte是 API 的一部分,没法改。

那理想中的 IP 类型是什么样的呢?

Brad 总结了一张表格:

特性 Go’s net.IP
不可更改(Immutable) ❌, slice
支持比较(Comparable) ❌, slice
体积小 ❌, 28-56 字节
不需要从堆上分配内存 ❌, slice’s underlying array
支持 IPv4 和 IPv6
区分 IPv4/IPv6 ❌, #37921
支持 IPv6 区域(zones) ❌, 使用专门的 net.IPAddr 类型
对外隐藏实现细节 ❌, 暴露底层类型 []byte
能够跟标准库互通

接下来就是一系列的改进方案了。

方案一:wgcfg.IP

David Crawshaw 在 2019 年 4 月提交代码 89476f8cb5 ,引入了如下 wgcfg.IP 类型:

// 内部使用 IPv6 格式。
// IPv4 地址使用 IPv4-in-IPv6 语法。
type IP struct {
  Addr [16]byte
}

不完美,但解决了一些问题,看下表:

特性 net.IP wgcfg.IP
不可更改(Immutable) ❌, slice
支持比较(Comparable) ❌, slice
体积小 ❌, 28-56 字节 ✅, 16 字节
不需要从堆上分配内存
支持 IPv4 和 IPv6
区分 IPv4/IPv6
支持 IPv6 区域(zones)
对外隐藏实现细节
能够跟标准库互通 ❌, 需要适配

这个方案只需要占用 16 字节,非常紧凑。只要把 Addr 改成 addr 就可以对外隐藏实现细节。但 David 的方案还是不能区分 IPv4 和 IPv4-maped IPv6 地址,也不支持保存 zone 信息。

于是就有了第二种方案。

方案二:netaddr.IP 内嵌接口变量

在 Go 语言中,接口变量也可以相互比较(可以使用 == 比较,也可以当作 map 的 key)。于是 Brad 实现了第一版netaddr.IP方案:

type IP struct {
  ipImpl
}

type ipImpl interface {
  is4() bool
  is6() bool
  String() string
}

type v4Addr [4]byte
type v6Addr [16]byte
type v6AddrZone struct {
  v6Addr
  zone string
}

这次在IP中嵌入了一个接口变量。在 64 位平台,一个接口占用 16 字节,所以这里的了 IP 类型也占 16 个字节。比标准库中 net.IP 占 24 字节外加地址内容要强。因为保存的是指南针,所以需要额外为 v4Addr/v6Addr/v6AddrZone 分配内存。但这次解决了 IPv6 的支持问题。

特性 net.IP wgcfg.IP 方案二
不可更改(Immutable) ❌, slice
支持比较(Comparable) ❌, slice
体积小 ❌, 28-56字节 ✅, 16字节 🤷, 20-32字节
不需要从堆上分配内存
支持 IPv4 和 IPv6
区分 IPv4/IPv6
支持 IPv6 区域(zones)
对外隐藏实现细节
能够跟标准库互通

wgcfg.IP相比,只剩下内存分配问题没有解决。继续扛正面!

方案三:无需分配堆内存的 24 字节表示

net.IP 的切片头长度为 24 字节。time.Time 的长度也是 24 个字节。所以 Brad 认为新地址类型最好也不要超过 24 个字节。

IPv6 地址本身已经占用 16 字节,也就还剩下 8 个字节来保存如下信息:

接口方案出局,因为一个指针就要占用 16 个字节,太大了。字符串头信息也需要 16 个字节,出局。

Brad 想出了这样的方案:

type IP struct {
   addr          [16]byte
   zoneAndFamily uint64
}

然后想办法把地址类型和 zone 信息保存的 zoneAndFamily 字段。问题是怎么存?

如果使用一位或两位保存地址类型,那还剩下 62 或 63 位。可以使用如下几种方案:

Brad 对这几个方案都不满意,于是想了指针方案:

type IP struct {
    addr          [16]byte
    zoneAndFamily *T
}

现在先不管 T 的类型,假设能跑通。只需要声明三个哨兵变量就能标识地址类型:

var (
  z0 *T        // nil 表示空值
  z4 = new(T)  // 表示 IPv4
  z6 = new(T)  // 表示 IPv6(没有 zone 信息)
)

接下来需要考虑如何保存 zone 信息,实现如下效果:

ip1, _ := netaddr.ParseIP("fe80::2%eth0")
ip2, _ := netaddr.ParseIP("fe80::2%eth0")
fmt.Println(ip1 == ip2) // true

单纯 new 两个相同的字符串会返回不同的指针。但 Brad 希望找到一种办法,对于值相同的字符串始终返回相同的指针。这样就可以通过指针来比较两个字符串是否相等。

所以就需要一个 map 保存所有字符串。那这跟之前的索引表的区别是什么?最大的区别就是如果用 zone 索引(整数)做 key,对应的 map 就没办法清理,会越来越大。如果使用指针,可以利用 runtime.SetFinalizer在垃圾回收的时候清理索引表。最终他们搞了 go4.org/intern 包,其核心逻辑如下:

var (
  mu      sync.Mutex
  valMap  = map[key]uintptr{} // to uintptr(*Value)
)

// Value 保存底层可比较的值
type Value struct {
  _      [0]func() // 禁止比较 Value 对象
  cmpVal interface{}
  // resurrected 由 mu 保护并发读写。
  // 只要有地方引用 cmpVal 就会被设为 true
  resurrected bool
}

func GetByString(s string) *Value {
  return get(key{s: s, isString: true})
}

// get 方法违返了 unsafe 的使用规则,所以要添加 nocheckptr 指令
//go:nocheckptr
func get(k key) *Value {
  mu.Lock()
  defer mu.Unlock()
  
  var v *Value
  if addr, ok := valMap[k]; ok {
  // 如果是已经存在的值,则标记并发访问
  	v = (*Value)((unsafe.Pointer)(addr))
  	v.resurrected = true
  }
  if v != nil {
  	return v
  }
  v = k.Value()
  // 设置垃圾回收回调函数
  // Go 在回收 v 之前如果发现有 finalize 函数,
  // 会清空并调用 finalize,期望在下一个周期回收。
  runtime.SetFinalizer(v, finalize)
  // 参考 https://pkg.go.dev/unsafe#Pointer
  // Go 语言要求 unsafe.Pointer 转成 uintptr 之后
  // 要在同一个表达式中转回 unsafe.Pointer
  // 但此处将其保存到 valMap
  valMap[k] = uintptr(unsafe.Pointer(v))
  return v
}

func finalize(v *Value) {
  mu.Lock()
  defer mu.Unlock()
  if v.resurrected {
    // 进入本分支说明在垃圾回收过程中有别的协程
    // 引用了当前 v,所以不能删除
    v.resurrected = false
    runtime.SetFinalizer(v, finalize)
    return
  }
  delete(valMap, keyFor(v.cmpVal))
}

上述代码有两段精妙之处。

第一个是禁止 Value 比较。Go 语言支持比较 struct,但前提是 struct 的第一个成员都支持比较。这里通过嵌入一个不支持比较的_ [0]func()成员就能禁止 Value 结构相互比较。具体分析可以参考这篇文章

另一个则是支持垃圾回收的对象池valMap = map[key]uintptr{}。valMap 存的是 *Value 的 uintptr 指针,这个指针是所谓的弱引用,不会影响到 Go 的垃圾回收。也就说,虽然 valMap 通过 uintptr 「引用」了某个对象,但如果没有正常 Go 代码引用,这个对象仍然会被 GC 回收掉。只是所有的*Value都关联了 finalize 函数,Go 在执行 GC 之前会先执行 finalize 函数,回收过程会延迟到下一个 GC 周期。这样一来,只要有其他地方引用,* Value对象就不会被GC。如果所有引用都释放,会触发一次GC,这时会将 resurrected 设为 false,到下一个 GC 周期就会真正回收内存。完整工作过程可以参考这篇文章

有了 intern 包,就能实现如下效果:

intern.GetByString("eth0") == intern.GetByString("eth0")

所以IP 可以表示成下面这样:

type IP struct {
  addr [16]byte
  z    *intern.Value // 区域和类型
}

var (
  z0    *intern.Value        // nil 表示空值
  z4    = new(intern.Value)  // 表示 IPv4
  z6noz = new(intern.Value)  // 表示 IPv6 (没有区域)
)

The accessors to get/set the zone are then:

func (ip IP) Zone() string {
  if ip.z == nil {
    return ""
  }
  zone, _ := ip.z.Get().(string)
  return zone
}

func (ip IP) WithZone(zone string) IP {
  if !ip.Is6() {
    return ip
  }
  if zone == "" {
    ip.z = z6noz
    return ip
  }
  ip.z = intern.GetByString(zone)
  return ip
}

最终效果如下:

特性 net.IP netaddr.IP
不可更改(Immutable) ❌, slice
支持比较(Comparable) ❌, slice
体积小 ❌, 28-56字节 ✅, 24字节,固定
不需要从堆上分配内存
支持 IPv4 和 IPv6
区分 IPv4/IPv6
支持 IPv6 区域(zones)
对外隐藏实现细节
能够跟标准库互通 🤷

方案四:uint64s 加速

新方案没有暴露底层细节,我们可以轻松修改内部实现。于是 Dave Anderson 将[16]byte优化成了一对uint64

type IP struct {
  // hi and lo are the bits of an IPv6 address. If z==z4, hi and lo
  // contain the IPv4-mapped IPv6 address.
  //
  // hi and lo are constructed by interpreting a 16-byte IPv6
  // address as a big-endian 128-bit number. The most significant
  // bits of that number go into hi, the rest into lo.
  //
  // For example, 0011:2233:4455:6677:8899:aabb:ccdd:eeff is stored as:
  //  hi = 0x0011223344556677
  //  lo = 0x8899aabbccddeeff
  //
  // We store IPs like this, rather than as [16]byte, because it
  // turns most operations on IPs into arithmetic and bit-twiddling
  // operations on 64-bit registers, which is much faster than
  // bytewise processing.
  hi, lo uint64
  
  // z is a combination of the address family and the IPv6 zone.
  z *intern.Value
}

方案五:uint128 类型

最后,Brad 在318330f177中将这对uint64换成了自定义的uint128类型:

type uint128 [2]uint64

type IP struct {
  addr uint128
  z    *intern.Value
}

但 Go 编译器在分配内存的时候有问题,所以 Brad 又在bf0e22f9f3中修改了uint128的定义:

type uint128 struct {
  hi uint64
  lo uint64
}

以上就是文章的全部内容。新的 net/netip 包会跟随 Go 1.18 发布,期待😚