Go 1.18 将引入新的网络地址包
涛叔很久之前,我在网上看到一篇 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 的「七宗罪」:
- 内容可变。
net.IP
的底层类型是[]byte
,也就是说net.IP
按引用传递,任何处理它的函数都可以改动其中的内容。 - 不能直接比较。因为 slice 不能直接比较,所以
net.IP
也不能直接使用==
判断两个地址是否相等,也不能用作 map 的 key。 - 标准库里有
net.IP
和net.IPAddr
两种地址类型。常见的 IPv4 和 IPv6 地址使用net.IP
保存。IPv6 的链路本地地址需要使用net.IPAddr
保存(因为要额外保存链路的网卡)。因为有两种地址,所以就得确定要用哪一种或者同时都用。 - 占用内存多。一个切片头信息就需要 24 个字节(64位平台,详情见 Russ 的文章)。 所以
net.IP
的内存占用包含 24 字节的头信息和 4 字节(IPv4)或者 6 字节(IPv6)地址数据。如果需要保存本地链路网卡(zone),那net.IPAddr
还需要一个 16 字节的字符串头信息以及具体的网卡名字。 - 需要从堆上分配内存。每次从堆上分配内存会给 GC 带来额外的压力。
- 从字符串解析 IP 地址的时候无法区分 IPv4 地址和 IPv4-mapped IPv6 地址(形如 ::ffff:192.168.1.1)。
- 对外暴露实现细节。
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 {
[16]byte
Addr }
不完美,但解决了一些问题,看下表:
特性 | 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 {
() bool
is4() bool
is6() string
String}
type v4Addr [4]byte
type v6Addr [16]byte
type v6AddrZone struct {
v6Addrstring
zone }
这次在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 个字节来保存如下信息:
- 地址类型(v4、v6、空值)。至少需要两个比特。
- IPv6 zone 信息(也就是网卡名字)
接口方案出局,因为一个指针就要占用 16 个字节,太大了。字符串头信息也需要 16 个字节,出局。
Brad 想出了这样的方案:
type IP struct {
[16]byte
addr uint64
zoneAndFamily }
然后想办法把地址类型和 zone 信息保存的 zoneAndFamily
字段。问题是怎么存?
如果使用一位或两位保存地址类型,那还剩下 62 或 63 位。可以使用如下几种方案:
- 使用剩余的 62 位保存 ASCII 字符,最多支持 8 个字符。太短。
- 对网卡进行编号,只保存数字编号。但这样只能保存本机网卡。
- 使用网卡名字映射表,建立名字到编号的索引。Go 标准库内部就是这样实现的。但这样可能会受到外部攻击,因为这个映射表只增不减。Go 标准库只保存本机网卡,所以没有这个问题。
Brad 对这几个方案都不满意,于是想了指针方案:
type IP struct {
[16]byte
addr *T
zoneAndFamily }
现在先不管 T 的类型,假设能跑通。只需要声明三个哨兵变量就能标识地址类型:
var (
*T // nil 表示空值
z0 = new(T) // 表示 IPv4
z4 = new(T) // 表示 IPv6(没有 zone 信息)
z6 )
接下来需要考虑如何保存 zone 信息,实现如下效果:
, _ := netaddr.ParseIP("fe80::2%eth0")
ip1, _ := netaddr.ParseIP("fe80::2%eth0")
ip2.Println(ip1 == ip2) // true fmt
单纯 new 两个相同的字符串会返回不同的指针。但 Brad 希望找到一种办法,对于值相同的字符串始终返回相同的指针。这样就可以通过指针来比较两个字符串是否相等。
所以就需要一个 map 保存所有字符串。那这跟之前的索引表的区别是什么?最大的区别就是如果用 zone 索引(整数)做 key,对应的 map 就没办法清理,会越来越大。如果使用指针,可以利用 runtime.SetFinalizer
在垃圾回收的时候清理索引表。最终他们搞了 go4.org/intern
包,其核心逻辑如下:
var (
.Mutex
mu sync= map[key]uintptr{} // to uintptr(*Value)
valMap )
// Value 保存底层可比较的值
type Value struct {
[0]func() // 禁止比较 Value 对象
_ interface{}
cmpVal // resurrected 由 mu 保护并发读写。
// 只要有地方引用 cmpVal 就会被设为 true
bool
resurrected }
func GetByString(s string) *Value {
return get(key{s: s, isString: true})
}
// get 方法违返了 unsafe 的使用规则,所以要添加 nocheckptr 指令
//go:nocheckptr
func get(k key) *Value {
.Lock()
mudefer mu.Unlock()
var v *Value
if addr, ok := valMap[k]; ok {
// 如果是已经存在的值,则标记并发访问
= (*Value)((unsafe.Pointer)(addr))
v .resurrected = true
v}
if v != nil {
return v
}
= k.Value()
v // 设置垃圾回收回调函数
// Go 在回收 v 之前如果发现有 finalize 函数,
// 会清空并调用 finalize,期望在下一个周期回收。
.SetFinalizer(v, finalize)
runtime// 参考 https://pkg.go.dev/unsafe#Pointer
// Go 语言要求 unsafe.Pointer 转成 uintptr 之后
// 要在同一个表达式中转回 unsafe.Pointer
// 但此处将其保存到 valMap
[k] = uintptr(unsafe.Pointer(v))
valMapreturn v
}
func finalize(v *Value) {
.Lock()
mudefer mu.Unlock()
if v.resurrected {
// 进入本分支说明在垃圾回收过程中有别的协程
// 引用了当前 v,所以不能删除
.resurrected = false
v.SetFinalizer(v, finalize)
runtimereturn
}
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 包,就能实现如下效果:
.GetByString("eth0") == intern.GetByString("eth0") intern
所以IP
可以表示成下面这样:
type IP struct {
[16]byte
addr *intern.Value // 区域和类型
z }
var (
*intern.Value // nil 表示空值
z0 = new(intern.Value) // 表示 IPv4
z4 = new(intern.Value) // 表示 IPv6 (没有区域)
z6noz )
The accessors to get/set the zone are then:
func (ip IP) Zone() string {
if ip.z == nil {
return ""
}
, _ := ip.z.Get().(string)
zonereturn zone
}
func (ip IP) WithZone(zone string) IP {
if !ip.Is6() {
return ip
}
if zone == "" {
.z = z6noz
ipreturn ip
}
.z = intern.GetByString(zone)
ipreturn 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.
, lo uint64
hi
// z is a combination of the address family and the IPv6 zone.
*intern.Value
z }
方案五:uint128 类型
最后,Brad 在318330f177
中将这对uint64
换成了自定义的uint128
类型:
type uint128 [2]uint64
type IP struct {
addr uint128*intern.Value
z }
但 Go 编译器在分配内存的时候有问题,所以 Brad 又在bf0e22f9f3
中修改了uint128
的定义:
type uint128 struct {
uint64
hi uint64
lo }
以上就是文章的全部内容。新的 net/netip 包会跟随 Go 1.18 发布,期待😚