2025-03-04 09:41:54
C++17 中引入了 std::any
,可以非常方便地将任意类型的变量放到其中,做到安全的类型擦除。然而万物皆有代价,这种灵活性背后必然伴随着性能取舍。
std::any 的实现本身也并不复杂,本文将基于 libstd++ 标准库源码 深入解析其实现机制与性能开销。
std::any 需要解决的核心问题在于:
从 libstd++ 源码中提取的关键类结构如下
1 |
class any { |
可以看到有两个核心变量:
_M_storage
:负责存储数据值本身或者指针。_M_manager
:函数指针,负责指向具体类型 template class 的实现,其中包含了类型信息。我们先看 _M_storage
的实现:
1 |
union _Storage |
_Storage
类是一个 union 实现。里面包含两个属性:_M_ptr
和长度为 sizeof(_M_ptr)
的 char 数组 _M_buffer
。即长度为指针大小,在 64 位机器下,_M_buffer
的长度是 8。
那么,在什么情况下分别使用 _M_ptr
和 _M_buffer
呢?主要通过以下模板变量进行编译期决策。
1 |
template<typename _Tp, typename _Safe = is_nothrow_move_constructible<_Tp>, bool _Fits = (sizeof(_Tp) <= sizeof(_Storage)) && (alignof(_Tp) <= alignof(_Storage))> |
简单来说:_Tp 可以无异常移动构造 && _Tp 能完全放入 _Storage 中
。
这是一个非常典型的 SOO(Small Object Optimization 小对象优化)。即:对于小尺寸对象,直接在容器自身的连续内存中 (通常为栈内存) 完成存储,这样可以避免在堆上开辟新的内存。
因此:
_M_buffer
中通过 placement new 创建对象。避免堆内存分配带来的性能开销,提升 CPU 缓存局部性(对高频访问的场景尤为重要)。_M_storage
存储对应的指针。但这个内存结构的设计,也存在着潜在的内存浪费:union 的内存等于最大字段的内存,因此即使在 std::any 中存储 1 字节的 char 类型变量,_M_storage
也需要 8 字节。
另外,我们发现在 _Storage
并未存储任何类型信息。但我们可以通过 std::any 的 type() 函数获取到对应的类型信息。这是如何做到呢?
接下来,我们看 _M_manager
的实现:
std::any 的做法非常巧妙,将所有需要类型信息的操作,都通过一个 template class 的 static 函数来实现。std::any 对象中只存储这个函数的指针,即 void (*_M_manager)(_Op, const any*, _Arg*)
。
1 |
template<typename _Tp> |
以 std::any 的 type() 函数实现为例, 代码如下:
1 |
const type_info& type() const noexcept |
我们可以看到,通过_M_manager
找到对应template class的具体实现,直接调用typeid(_Tp)
就可以获取到对应的 typeinfo 信息了。
但值得注意的是,在调用 _M_manager
函数的时候,额外传递了一个 enum 值 _Op_get_type_info
。
这是 std::any 的特殊设计,通过枚举值区分不同的逻辑,将所有需要类型信息的操作都整合到一个函数入口。这样做仅用一个函数指针即可,可以节省内存开销。
虽然 std::any 提供了极大的灵活性,且绝大部分场景下性能也够用。但根据我们对源码的深入分析,发现 std::any 的设计特点必然会带来一些额外的开销:
2025-01-03 22:41:54
我们在使用 C++ 的时候,有时会需要在类的内部获取自身的 shared_ptr,这就会用到 std::enable_shared_from_this
。在实际使用过程中,std::enable_shared_from_this
有三个陷阱需要注意:
以上 case 均可以通过 wandbox 复现。
那么为什么会有这些限制呢?本文将从 std::enable_shared_from_this 的源码角度解读其原因。(本文基于 clang libc++ 的源码实现进行解读, 代码地址:shared_ptr.h#L1433)
1 |
|
我把 enable_shared_from_this 的源码摘录下来,删掉了一些不太重要的逻辑以方便理解。代码如下:
1 |
template <class _Tp> |
从代码可以看出 enable_shared_from_this 核心的就是一个 weak_ptr 属性 __weak_this_
。而 shared_from_this 其实就是把 weak_ptr 转换成 shared_ptr。
那么问题来了,__weak_this_
是在什么时候设置呢?答案是:在创建 shared_ptr 对象的时候。
以下是 shared_ptr 中创建对象的逻辑,其中在 __enable_weak_this
中设置了 enable_shared_from_this 的 __weak_this_
属性。
1 |
template <class _Yp, class _CntrlBlk> |
在 __enable_weak_this
的实现中,因为 enable_shared_from_this 类里面将 shared_ptr<T>
设置为了 friend class。因此 shared_ptr 可以直接访问并设置 enable_shared_from_this 的 __weak_this_
属性。
同时,__enable_weak_this
使用 SFINAE 实现了一个模板匹配,即:只有当满足 __enable_if_t<is_convertible<_OrigPtr*, const enable_shared_from_this<_Yp>*>::value, int> = 0
时(即对应类可以转换成 enable_shared_from_this,也就是类 public 继承了 enable_shared_from_this), 才会设置 __weak_this_
。 否则会匹配到一个空实现。
1 |
// 匹配到 enable_shared_from_this |
解读完源码之后,一切情况非常明了。我们再回头看下文章刚开始提到的三个陷阱:
__weak_this_
属性,最终才能得到一个 shared_ptr 对象。所以在执行原始对象的构造函数时,__weak_this_
属性尚未设置,当然不能用 shared_from_this。__weak_this_
。__enable_weak_this
,从而设置 __weak_this_
。2024-05-06 17:25:54
我们有这么一段业务代码,在 Gin 的 API Handler 中,开了一个子 goroutine 写 DB,代码大概是这样:
1 |
package main |
代码在测试阶段一直没啥问题,但是一上线立马出现了大面积的 panic。panic 的栈也非常奇怪,挂在了 mysql driver 里面:
1 |
panic: sync/atomic: store of nil value into Value |
把 mysql driver 相关栈的源码扒出来,大概是这样:
1 |
func (mc *mysqlConn) startWatcher() { |
具体的故障现象大概明确了:
context.Done()
, 当 channel 返回时,将ctx.Err()
设置到原子变量里面。context.Done()
虽然返回了,ctx.Err()
却是 nil。这就导致了在 set 原子变量时直接 panic 了。这个问题非常难以理解,因为根据 context 的源码来看,只要context.Done()
返回了,ctx.Err()
就不可能是 nil。而且这个问题在测试环境无法复现,问题排查暂时陷入了僵局。
虽然 panic 的原因暂未查明,但是仔细看下这段业务逻辑,就可以看出来一些问题。
首先,我们需要知道这个 context 在什么时候会触发 Done,也就是什么时候 cancel 的。翻下 Golang HTTP Server 的源码,事情一目了然:
1 |
func (c *conn) serve(ctx context.Context) { |
在开始处理请求之前,HTTP Server 会创建一个 context 对象,在请求处理结束之后,会自动 cancel 这个 context。
也就是说:当 API Handler 的处理逻辑完成返回的时候,context 会主动 cancel。此时即使子 goroutine 的处理逻辑还没结束,db 请求也会取消。按照 mysql driver 的逻辑,应该会抛出来一个context canceled
的 Err。
翻了下测试环境的日志,的确有偶发的context canceled
。 之所以不是必现,是因为子 goroutine 之后还有后置的处理逻辑。如果子 goroutine 的处理逻辑快于接口的后续处理逻辑,那这个 Err 就不会触发。
实际上,这里业务代码对 Context 使用上出现了错误:在这个场景下,子 goroutine 的逻辑处理的生命周期实际上是和父层的逻辑完全没有关系,我们不需要用同一个 context 强行把两个逻辑的生命周期保持一致。
在这种情况下,子 goroutine 中可以用context.Background()
创建一个新的 context 对象 ,和外部接口主逻辑的 context 分离开,以免受到影响。
按照这个逻辑更改完成之后,测试环境没有了context canceled
错误,线上服务也正常恢复了。
问题虽然得到了解决,但是 panic 的原因还没有完全查明,问题的阴影仍然持续笼罩着:
继续深扒下源码,这次找到了 Gin 对请求的处理过程:在每个处理过程中,都有对sync.Pool
的使用。
对缓存的复用和清理一般是问题频发的根源,我们对这块着重进行了梳理,还真的找到了原因:
gin.Context
本质上是对c.Request.Context()
的封装。所有对 Context 的 Done、Err 方法调用,都会转发给c.Request.Context()
。sync.Pool
对gin.Context
进行对象复用。每次从sync.Pool
拿到一个 gin.Context 对象的时候,都会重置其 Request 属性。1 |
// ServeHTTP conforms to the http.Handler interface. |
1 |
// Done returns nil (chan which will wait forever) when c.Request has no Context. |
梳理下来,所有的情况都可以得到解释。简单来说:请求 1 中开的子 goroutine 持有的 context 对象,会被请求 2 复用,造成并发问题。
存在这样一种 case:请求1的子goroutine,在ctx.Done返回,并且要准备取ctx.Err之前。context刚好被复用,并且新的请求还没有结束。
ctx.Done
。整个外部处理逻辑结束,触发 HTTP Server 内部的 context cancel。此时,子 goroutine 中的ctx.Done
channel 返回,准备去取context.Err()
。同时请求 2 到来,复用了 context 对象。c.Request.Context().Err()
当然会返回 nil为什么测试环境很难复现: 测试环境请求非常稀疏:子 goroutine 在取ctx.Err()
之前,如果没有其他请求到来并复用这个 context,是不会出现问题的。
为了方便构造这种 case,我们需要复现两个充分必要条件:
ctx.Err()
之前的间隙,请求 2 复用其 context 对象,并重置 Request 对象。对于条件 1,我们需要简单了解下 sync.Pool 的原理,具体可以看我的另外一篇博客 《深度分析 Golang sync.Pool 底层原理》:
debug.SetGCPercent(0)
。因为每轮 GC 之后,sync.Pool 都会被强制清空。sync.Pool
会在每个 P 内部有一个私有对象和 localPool,只有设置为 1,才会保证一定可以复用上次请求的 context。对于条件 2,其实只要请求 QPS 足够大,基本是可以必现的。我们使用 sleep 协调下两个请求,以模拟这种 case。代码如下:
1 |
package main |
为了方便描述问题,这里还有个额外的情况没有说明:我们在使用 Gin 时开启了 ContextWithFallback
,这是在是在Gin的v1.8.1版本引入的。
如果你的Gin版本在 v1.8.1 之前或者 v1.8.1 之后并开启了 ContextWithFallback
,才会保证所有对gin.Context
的Done()
、Err()
函数的访问,全部转发给c.Request.Context()
。如果没有开启 ContextWithFallback
, 实际上ctx.Done()
channel 会永远阻塞, 并不会出现本文中的问题。
总结来说该问题的根源在于:不应该在子 goroutine 中继续使用gin.Context
,即使不会 panic,也会导致高概率的context.Canceled
错误。
我们之后应该如何避免:
方法一:其实可以将 gin 的 ContextWithFallback 设置为 false,这样这类问题都不会出现。
方法二:这种子 goroutine 的逻辑生命周期不需要和外部逻辑强行保持一致的 case, 直接利用context.Background
创建一个新的 context 对象即可。
方法三:如果确实有场景需要在子 goroutine 中用 gin 的 Context,可以使用gin.Context.Copy
函数复制出来一个新的 context 对象。
2023-11-29 13:05:01
几乎世界上每个 Golang 程序员都踩过一遍 for 循环变量的坑,而这个坑的解决方案已经作为实验特性加入到了 Go 1.21 中,并且有望在 Go 1.22 中完全开放。
举个例子,有这么段代码:
1 |
var ids []*int |
可以试着在 playgound 里面运行下:go.dev/play/p/O8MVGtueGAf
答案是:打印出来的全是 10。
这个结果实在离谱。原因是因为在目前 Go 的设计中,for 中循环变量的定义是 per loop 而非 per iteration。也就是整个 for 循环期间,变量 i
只会有一个。以上代码等价于:
1 |
var ids []*int |
同样的问题在闭包使用循环变量时也存在,代码如下:
1 |
var prints []func() |
根据上面的经验,闭包 func 中 fmt.Println(v)
,捕获到的 v
都是同一个变量。因此打印出来的都是 3。
在目前的 go 版本中,正常来说我们会这么解决:
1 |
var ids []*int |
定义一个新的局部变量, 这样无论闭包还是指针,每次迭代时所引用的内存都不一样了。
这个问题其实在 C++ 中也同样存在: wandbox.org/permlink/Se5WaeDb6quA8FCC。
但真的太容易搞错了,几乎每个 Go 程序员都踩过一遍,而且也非常容易忘记。即使这次记住了,下次很容易又会踩一遍。
甚至知名证书颁发机构 Let’s Encrypt 就踩过一样的坑 bug#1619047。代码如下:
1 |
// authz2ModelMapToPB converts a mapping of domain name to authz2Models into a |
在这个代码中,开发人员显然是很清楚这个 for 循环变量问题的,为此专门写了一段 kCopy := k
。但是没想到紧接着下一行就不小心用了 &v
。
因为这个 bug,Let’s Encrypt 为此召回了 300 万份有问题的证书。
Go 团队目前的负责人 Russ Cox 在 2022 年 10 月份的这个讨论 discussions/56010 里面,提到要修改 for 循环变量的语义,几乎是一呼百应。今年五月份,正式发出了这个提案proposal#60078。
在今年 8 月份发布的 Go 1.21 中已经带上了这个修改。只要开启 GOEXPERIMENT=loopvar
这个环境变量,for 循环变量的生命周期将变成每个迭代定义一次。
但毫无疑问,这是个 break change。如果代码中依赖了这个 for 循环变量是 per loop 的特性,那升级之后就会遇到问题。例如以下代码:
1 |
func sum(list []int) int { |
另外,对于程序性能也会有轻微影响, 毕竟新的方案里面将重复分配 N 次变量。对于性能极其敏感的场景,用户可以自行把循环变量提到外面。
同样的改变在 C# 也发生过,并没有出现大问题。
这个方案预计最早在 Go 1.22 就会正式开启了。按照 Go 每年发两个版本的惯例,在 2024 年 2 月份,我们就可以正式用上这个特性,彻底抛弃 x := x
的写法 ~
本文主要内容汇总自 go/wiki/LoopvarExperiment 和 proposal#60078
2023-11-25 19:25:54
Bigcache是用Golang实现的本地内存缓存的开源库,主打的就是可缓存数据量大,查询速度快。 在其官方的介绍文章《Writing a very fast cache service with millions of entries in Go》一文中,明确提出了bigcache的设计目标:
目前有许多开源的cache库,大部分都是基于map实现的,例如go-cache,ttl-cache等。bigcache明确指出,当数据量巨大时,直接基于map实现的cache库将出现严重的性能问题,这也是他们设计了一个全新的cache库的原因。
本文将通过分析bigcache v3.1.0的源码,揭秘bigcache如何解决现有map库的性能缺陷,以极致的性能优化,实现超高性能的缓存库。
当map里面数据量非常大时,会出现性能瓶颈。这是因为在Golang进行GC时,会扫描map中的每个元素。当map足够大时,GC时间过长,会对程序的性能造成巨大影响。
根据bigcache介绍文章的测试,在缓存数据达到数百万条时,接口的99th百分位延迟超过了一秒。监测指标显示堆中超过4,000万个对象,GC的标记和扫描阶段耗时超过了四秒。这样的延迟对于bigcache来说是完全无法接受的。
这个问题在Go 1.5版本中有一项专门的优化(issue-9477):如果map的key和value中使用没有指针,那么GC时将无需遍历map。例如map[int]int
、map[int]bool
。这是当时的pull request: go-review.googlesource.com/c/go/+/3288。里面提到:
Currently scanning of a map[int]int with 2e8 entries (~8GB heap)
takes ~8 seconds. With this change scanning takes negligible time.
对2e8个元素的map[int]int上进行了测试,GC扫描时间从8秒减少到0。
为什么当map的key和value不包含指针时,可以省去对元素的遍历扫描呢?这是因为map中的int、bool这种不可能会和外部变量有引用关系:
map[int]int
为例,外部没有办法取到这个key和value的指针,那也就无从引用了。这个优化听起来非常强大好用,但是在Golang中指针无处不见,结构体指针、切片甚至字符串的底层实现都包含指针。一旦在map中使用它们(例如map[int][]byte、map[string]int),同样会触发垃圾回收器的遍历扫描。
bigcache整体设计的出发点都是基于上文提到的Golang对Map GC优化,整个设计思路包含几个方面:
这是一个非常常见的数据存储优化手段。表面上bigcache中所有的数据是存在一个大cache里面,但实际上底层数据分成了N个不互重合的部分,每一个部分称为一个shard。
在Set或者Get数据时,先对key计算hash值,根据hash值取余得到目标shard,之后所有的读写操作都是在各自的shard上进行。
以Set方法为例:
1 |
func (c *BigCache) Set(key string, entry []byte) error { |
这么做的优势是可以减少锁冲突,提升并发量:当一个shard被加上Lock的时候,其他shard的读写不受影响。
在bigcache的设计中,对于shard有如下要求:
%
快很多(根据不权威的benchmark,计算速度大概会有2倍左右的差距)。1 |
func (c *BigCache) getShard(hashedKey uint64) (shard *cacheShard) { |
前文提到,map的key和value一旦涉及指针相关的类型,GC时就会触发遍历扫描。
因此在bigcache的设计中,shard中的map直接定义为了map[uint64]uint32
,避免了存储任何指针。shard的结构体定义如下:
1 |
type cacheShard struct { |
其中:hashmap
的key是cache key的hash值,而value仅仅是个uint32。这显然不是我们Set的时候value的原始byte数组。
那value的原始值存在了哪里?答案是cacheShard中的另外一个属性entries queue.BytesQueue
。queue.BytesQueue
是一个ring buffer的内存结构,本质上就是个超大的[]byte
数组,里面存放了所有的原始数据。每个原始数据就存放在这个大[]byte数组中的其中一段。
hashmap中uint32的value值存放的就是value的原始值在BytesQueue
中的数组下标。(其实并不只是原始的value值,里面也包含了key、插入时间戳等信息)
之所以用一个大的[]byte
数组和ring buffer结构,除了方便管理和复用内存之外,一个更重要的原因是:对于[]byte数组, GC时只用看做一个变量扫描,无需再遍历全部数组。这样又避免了海量数据对GC造成的负担。
bigcache在内存结构设计上完全遵循FIFO原则:
BytesQueue
中。基本不直接对内存进行修改和删除等。这样一整套设计约定下来,bigcache的逻辑变成非常简洁明了,但这样同时造成了bigcache的局限性。
1 |
cache.Set("my-unique-key", []byte("value")) |
前面讲述了bigcache的设计思想之后,Set的整个逻辑也就很清晰了:
这里需要注意的是,在bigcache的设计里面,Set时value一定得是个[]byte
类型。
前文讲到,bigcache中所有的原始数据都会被塞到一个大的[]byte数组里。因此对于bigcache来说最理想的肯定是直接给到[]byte
最为方便,否则还需要考虑序列化等问题。
BytesQueue是一个ring buffer设计,本文不再细究其实现了,和其他ring buffer的结构大同小异。
除了正常的set逻辑外,还有一些额外的情况需要考虑在内:
情况1:如果key之前设置过,Set的时候会如何处理?
在其他cache库的实现中,这种情况一般是找到旧值、删除,然后把新值设置到旧值的位置。
但在bigcache中并不是这样,前文提到,bigcache的内存结构设计是FIFO式的,哪怕是有旧值的情况下,新值也不会复用其内存,依旧是push新的value到队列中。
那旧值将如何处理的呢?我们看下代码:
1 |
if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 { |
最核心的一句就是:delete(s.hashmap, hashedKey)
简单来说:之前的旧值并未从内存中移除,仅仅只是将其偏移量从s.hashmap中移除了,使得外部读不到。
那旧值什么时候会被淘汰呢?会有两种情况:
CleanWindow
,且旧值刚好过时,会被清理的定时器自动淘汰MaxEntrySize
或者HardMaxCacheSize
,当内存满时,也会触发最旧数据的淘汰。在此之前,旧值的数据一直都会保留在内存中。
另外还有resetHashFromEntry
,这个逻辑主要是把entry中的hash部分的数值置为0。这么做只是打上一个已处理的标记,保证数据在淘汰的时候不再去调用OnRemove的callback而已。
其实这里还有个场景:当s.hashmap[hashedKey]
存在value时,并不一定是设置过这个key,也有可能发生了hash碰撞。
按照上述逻辑,bigcache并未对hash碰撞做特殊处理,统一都把之前相同hash的旧key删除。 毕竟这只是缓存的场景,并不保证之前Set进去的数据一直会存在。
问题2:当ring buffer满时,无法继续push数据,bigcache会如何处理?
情况分成两种:
entries queue.BytesQueue
未达到设定的HardMaxCacheSize(最大内存上限),或者无HardMaxCacheSize要求,则直接扩容queue.BytesQueue
直到达到上限。不过扩容的时候,是创建了一个新的空[]byte
数组,把原有数据copy过去。BytesQueue
中。如果这个时候新数据非常大,可能会为此淘汰掉许多旧数据。1 |
entry, _ := cache.Get("my-unique-key") |
Get基本上是Set的逆过程,整个过程更简单一些,没有太多额外的知识可讲。不过在使用时,需要注意的是:
跟删除有关的核心逻辑只有这两行,整个逻辑和Set过程中清除旧值的一样:
1 |
... |
不过在调用bigcache.Delete
接口时需要注意的是,如果key不存在时,会返回一个ErrEntryNotFound
上面讲到删除逻辑和set时清除旧值时,都只是简单的把key从map中删除,不让外部读取到而已。那原始值什么时候删呢?答案就是过期淘汰。
bigcache有个设计上的优势:bigcache没有开放单个元素的可过期时间,所有元素的cache时长都是一样的,这就意味着所有元素的过期时间在队列中天然有序。
这就使得淘汰逻辑非常简单,代码如下:
1 |
func (s *cacheShard) cleanUp(currentTimestamp uint64) { |
其实就是从头到尾遍历数组,直至元素不过期就跳出。
另外,即使淘汰过期数据时,数据也并未被真实的删除,仅仅对应于ring buffer中head和tail下标的移动。
这样整个删除过程非常轻量级,好处不仅在于逻辑更简单,更重要的是,淘汰时需要对整个shard加写锁,这种对有序数组的遍历删除,加锁的时间会非常短(当然也取决于这个时刻过期的数据条数)。
当然,这也意味着bigcache的局限性:数据过期模式非常简单,这种FIFO式的数据淘汰相比于LRU、LFU来说,缓存命中率会低不少。
此外从这里可以得知,哪怕是经过了淘汰,bigcache的内存也不会主动降下去,除非外部调用了Reset方法。因此在实际实践中,我们最好是控制好HardMaxCacheSize,以免OOM。
bigcache的主要逻辑已经基本讲完了,作为一个以性能为卖点的cache库,bigcache在细节上也有大量的性能优化:
varint的使用: 在最开始讲bigcache中每个entry结构的设计时,图中有一个blocksize,代表数据entry的大小,用于bigcache确定数据边界。这里blocksize用到了varint来表示,可以一定程度上减小数据量。具体varint的介绍可以参考我的另外一篇文章《解读 Golang 标准库里的 varint 实现》。
buffer内存复用:在每次set数据的时候,上面varint和整个entry都需要动态地分配内存,bigcache这里在每个shard中内置了两个全局的buffer: headerBuffer
和entrybuffer
,避免了每次的内存分配。
自己实现fnv Hash: bigcache自己实现了一套fnv hash,并没有用go官方标准库的,这也是基于性能的考虑。在Go官方的实现中 hash/fnv/fnv.go,创建Fnv对象的时候,有这么一段逻辑:
1 |
func New32a() hash.Hash32 { |
根据Golang的逃逸分析,s这个变量在结束的时候会被外部用到,这样Go编译器会将其分配到堆上(逃逸到堆上)。
我们知道,直接在栈上操作内存比堆上更快速,因此bigcache实现了一个基于栈内存的fnv hash版本。
bigcache的介绍文章中也提到,JSON序列化问题成为了一个性能问题:
While profiling our application, we found that the program spent a huge amount of time on JSON deserialization. Memory profiler also reported that a huge amount of data was processed by
json.Marshal
.
他们换成了ffjson来替换go标准库中的json操作,性能得到了不少的提升。
不过这样给我们提了个醒,如果不是海量数据,尚未达到map的gc瓶颈,倒是没有必要直接就上bigcache, 毕竟序列化所带来的开销也不算低。
bigcache.Config
中有很多配置参数,这里大概列一下:
1 |
// Config for BigCache |
2023-11-23 12:10:19
最近发现 Golang 标准库竟然自带了 varint 的实现,代码位置在 encoding/binary/varint.go,这个跟protobuf里面的varint实现基本是一致的。刚好借助 golang 标准库的 varint 源码,我们来系统地学习和梳理下 varint。
熟悉 protobuf 的人肯定对 varint 不陌生,protobuf 里面除了带 fix (如 fixed32、fixed64) 之外的整数类型, 都是 varint 编码。
varint 的出现主要是为了解决两个问题:
本文将通过分析 Golang 标准库自带的 varint 源码实现,介绍 varint 的设计原理以及Golang标准库是如何解决 varint 在编码负数时遇到的问题。
varint 的设计原理非常简单:
例如:对于一个整数 300,它的二进制表示是 100101100
。我们可以将它分为两组,即 10
和 0101100
。然后在每组前面添加标志位,得到两个字节 00000010
和 10101100
,这两个字节就是 300 的 varint 编码。相比于用 uint32 的 4 字节表示,少了 50% 的存储空间。
在 Golang 标准库中有两套 varint 的函数: 分别用于无符号整数的 PutUvarint 和 Uvarint,以及用于有符号整数的 Varint 和 PutVarint。
我们先看下无符号整数的 varint 实现,代码如下:
1 |
func PutUvarint(buf []byte, x uint64) int { |
代码里有个非常重要的常量:0x80,对应于二进制编码就是 1000 0000
。这个常量对接下来的逻辑非常重要:
x >= 0x80
。这意味着 x 的二进制表示形式至少有 8 位,我们刚才讲到 7 个 bit 位为一组,那 x 就需要被拆分了。byte(x) | 0x80
。将 x 的最低 8 位与 1000 0000
进行按位或操作,然后将结果存储在 buf[i] 中。这样 既可以将最高位设置为 1,同时也提取出了 x 的最低 7 位。x >>= 7
. 将 x 右移 7 位,处理下一个分组。buf[i] = byte(x)
。当 for 循环结束时,即意味着 x 的二进制表示形式最高位必然是 0。此时就不用做额外的补零操作了。经过编码之后,原数据的最低位将在byte数组的最开始的位置,最高位在byte数组的尾部。
而Uvarint
是 PutUvarint
的逆过程,实际上就是逐byte取元素还原,直到byte最高位是0,则还原结束。
需要注意的是,varint 将整数划分为 7 位一组。这意味着,对于大整数 varint 将会出现负向优化。例如对于 uint64 的最大值来说,本来只需要 8 个 byte 来表示,但在 varint 中却需要 10 个字节来表示了。(64/7 ≈ 10
)
看似 varint 编码已经完美无缺了,但以上忽略了一点:负数的存在。
我们知道,在计算机中数字是用补码来表示的,而负数的补码则是将其绝对值按位取反再加 1。这就意味着一个很小的负数,它的二进制形式对应于一个非常大的整数。例如:对于一个 32 位的整数 -5
来说,其绝对值 5 的二进制形式是 101
。 但 -5
的二进制形式却是 11111111111111111111111111111011
,如果使用 varint 对其编码, 需要 5 个字节才能表示。
Golang标准库引入了 zigzag 编码来解决这个问题,zigzag 的原理非常简单:
2n
。例如整数 2,经过 zigzag 编码之后变成了 4。-n
来说,会将其映射为 2n-1
。例如负数 -3
,经过 zigzag 编码之后变成了 5。这样负数和正数在数值上完全不会冲突,正整数和负整数交错排列,这也是为什么叫做 zigzag 编码 (锯齿形编码)的原因。
同时,负数被转换成正数之后,二进制编码也精简了许多。
例如: 对 -5
进行 zigzag 编码后,变成了 9,对应于二进制为 00000000000000000000000000001001
,使用 1 个字节即可表示 varint。
我们看下 Golang 标准库的实现,代码如下:
1 |
func PutVarint(buf []byte, x int64) int { |
从代码可以看出,对于有符号整数的varint实现,golang标准库这里分成了两步:
我们详细讲下 zigzag 编码的实现部分:
ux := uint64(x) << 1
。这个位运算左移一位,相当于 ux*2
。对于正数,符合 ZigZag 编码。ux := uint64(x) << 1; ux = ^ux
。负数这里就有些难以理解了,为什么这么转换之后就等于2n - 1
了?我们可以大概推导下整个过程,假设我们有个整数 -n
:
2*(~(-n))+1
负数的补码=绝对值按位取反+1
,那如何根据补码再推导出绝对值?这里有个公式是:|A| = ~A+1
2*(n-1) + 1 = 2n - 1
。这就完美对应上了负数的 ZigZag 编码。在 Golang 标准库里面,调用 PutUvarint 时只会使用 varint 编码,调用 PutVarint 会先进行 zigzag 编码,再进行 varint 编码。
而在 protobuf 中,如果类型是 int32、int64、uint32、uint64,只会使用 varint 编码。使用 sint32、sint64 将先进行 zigzag 编码,再进行 varint 编码
虽然 varint 编码设计非常精妙,但并不适用于所有的场景:
2^63
,那使用 varint 会用到 10 字节。相比于传统的八字节整数,反而多用了 25% 的空间