MoreRSS

site iconcyhone | 沉思录修改

记录自己思考和总结的一些东西,包括不限于源码分析、读书总结以及技术方案等方面。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

cyhone | 沉思录的 RSS 预览

std::any 的性能开销:基于 libstd++ 源码分析

2025-03-04 09:41:54

C++17 中引入了 std::any,可以非常方便地将任意类型的变量放到其中,做到安全的类型擦除。然而万物皆有代价,这种灵活性背后必然伴随着性能取舍。

std::any 的实现本身也并不复杂,本文将基于 libstd++ 标准库源码 深入解析其实现机制与性能开销。

代价

底层存储

std::any 需要解决的核心问题在于:

  1. 异构数据存储:如何统一管理不同尺寸的对象
  2. 类型安全访问:如何在擦除类型信息后仍能提供安全的类型查询。例如可以直接通过 std::any 提供的 type() 函数,直接获取到底层数据的类型信息。

从 libstd++ 源码中提取的关键类结构如下

1
2
3
4
5
class any {
_Storage _M_storage;

void (*_M_manager)(_Op, const any*, _Arg*);
}

可以看到有两个核心变量:

  • _M_storage:负责存储数据值本身或者指针。
  • _M_manager :函数指针,负责指向具体类型 template class 的实现,其中包含了类型信息。

我们先看 _M_storage 的实现:

1
2
3
4
5
union _Storage
{
void* _M_ptr;
unsigned char _M_buffer[sizeof(_M_ptr)];
};

_Storage 类是一个 union 实现。里面包含两个属性:_M_ptr 和长度为 sizeof(_M_ptr) 的 char 数组 _M_buffer。即长度为指针大小,在 64 位机器下,_M_buffer 的长度是 8。

那么,在什么情况下分别使用 _M_ptr_M_buffer 呢?主要通过以下模板变量进行编译期决策。

1
2
template<typename _Tp, typename _Safe = is_nothrow_move_constructible<_Tp>, bool _Fits = (sizeof(_Tp) <= sizeof(_Storage)) && (alignof(_Tp) <= alignof(_Storage))>
using _Internal = std::integral_constant<bool, _Safe::value && _Fits>;

简单来说:_Tp 可以无异常移动构造 && _Tp 能完全放入 _Storage 中

这是一个非常典型的 SOO(Small Object Optimization 小对象优化)。即:对于小尺寸对象,直接在容器自身的连续内存中 (通常为栈内存) 完成存储,这样可以避免在堆上开辟新的内存。

因此:

  • 对于小尺寸对象(≤指针大小),直接在 _M_buffer 中通过 placement new 创建对象。避免堆内存分配带来的性能开销,提升 CPU 缓存局部性(对高频访问的场景尤为重要)。
  • 对于大尺寸对象,直接在堆上通过 new 申请内存,_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
2
3
4
5
6
template<typename _Tp>
struct _Manager_internal
{
static void
_S_manage(_Op __which, const any* __anyp, _Arg* __arg);
};

以 std::any 的 type() 函数实现为例, 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const type_info& type() const noexcept
{
_Arg __arg;
_M_manager(_Op_get_type_info, this, &__arg);
return *__arg._M_typeinfo;
}

template<typename _Tp>
void
any::_Manager_internal<_Tp>::
_S_manage(_Op __which, const any* __any, _Arg* __arg)
{
switch (__which)
{
case _Op_get_type_info:
__arg->_M_typeinfo = &typeid(_Tp);
break;
}
}

我们可以看到,通过_M_manager找到对应template class的具体实现,直接调用typeid(_Tp)就可以获取到对应的 typeinfo 信息了。

但值得注意的是,在调用 _M_manager 函数的时候,额外传递了一个 enum 值 _Op_get_type_info

这是 std::any 的特殊设计,通过枚举值区分不同的逻辑,将所有需要类型信息的操作都整合到一个函数入口。这样做仅用一个函数指针即可,可以节省内存开销。

总结

虽然 std::any 提供了极大的灵活性,且绝大部分场景下性能也够用。但根据我们对源码的深入分析,发现 std::any 的设计特点必然会带来一些额外的开销:

  1. 内存开销:在 64 位机器下固定占用 16 byte 空间(8 字节的_M_storage 和 8 字节的_M_manager 函数指针)。存储 1 字节数据时空间利用率仅 6.25%;
  2. 性能开销:小对象直接栈存储,对于大对象会触发堆分配。

从源码角度解读 enable_shared_from_this

2025-01-03 22:41:54

我们在使用 C++ 的时候,有时会需要在类的内部获取自身的 shared_ptr,这就会用到 std::enable_shared_from_this。在实际使用过程中,std::enable_shared_from_this 有三个陷阱需要注意:

  1. 不能在构造函数中使用 shared_from_this(), 否则会抛出 std::bad_weak_ptr 异常。对应下面情况 1。
  2. 创建的对象必须由 shared_ptr 管理,shared_from_this() 才能生效,否则也会报 std::bad_weak_ptr 异常。对应下面情况 2。
  3. 对应类必须 public 继承 std::enable_shared_from_this, 不能是 protected 或 private 继承,否则也会报 std::bad_weak_ptr 异常。对应下面情况 3。

以上 case 均可以通过 wandbox 复现。

那么为什么会有这些限制呢?本文将从 std::enable_shared_from_this 的源码角度解读其原因。(本文基于 clang libc++ 的源码实现进行解读, 代码地址:shared_ptr.h#L1433)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <memory>

// 情况 1:在构造函数中使用 shared_from_this
class Case1 : public std::enable_shared_from_this<Case1>{
public:
Case1(){
// 抛异常:terminating due to uncaught exception of type std::__1::bad_weak_ptr: bad_weak_ptr
auto case1 = shared_from_this();
}
};

// 情况 2:不使用 shared_ptr 管理对象
class Case2 : public std::enable_shared_from_this<Case2>{
public:
std::shared_ptr<Case2> get_shared_ptr() {
// 抛异常:terminating due to uncaught exception of type std::__1::bad_weak_ptr: bad_weak_ptr
return shared_from_this();
}
};

// 情况 3:未 public 继承 std::enable_shared_from_this
class Case3 : std::enable_shared_from_this<Case3>{
public:
std::shared_ptr<Case3> get_shared_ptr() {
// 抛异常:terminating due to uncaught exception of type std::__1::bad_weak_ptr: bad_weak_ptr
return shared_from_this();
}
};

int main(){
// 情况 1
auto c1 = std::make_shared<Case1>();

// 情况 2
Case2* c2 = new Case2();
c2->get_shared_ptr();

// 情况 3
auto c3 = std::make_shared<Case3>();
c3->get_shared_ptr();

return 0;
}

我把 enable_shared_from_this 的源码摘录下来,删掉了一些不太重要的逻辑以方便理解。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
template <class _Tp>
class enable_shared_from_this {
mutable weak_ptr<_Tp> __weak_this_;

public:
shared_ptr<_Tp> shared_from_this() {
return shared_ptr<_Tp>(__weak_this_);
}

template <class _Up>
friend class shared_ptr;
};

从代码可以看出 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
2
3
4
5
6
7
8
9
template <class _Yp, class _CntrlBlk>
static shared_ptr<_Tp> __create_with_control_block(_Yp* __p, _CntrlBlk* __cntrl) _NOEXCEPT {
shared_ptr<_Tp> __r;
__r.__ptr_ = __p;
__r.__cntrl_ = __cntrl;
// 设置__weak_this_
__r.__enable_weak_this(__r.__ptr_, __r.__ptr_);
return __r;
}

__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
2
3
4
5
6
7
8
9
10
11
12
13
// 匹配到 enable_shared_from_this
template <class _Yp,
class _OrigPtr,
__enable_if_t<is_convertible<_OrigPtr*, const enable_shared_from_this<_Yp>*>::value, int> = 0>
void __enable_weak_this(const enable_shared_from_this<_Yp>* __e, _OrigPtr* __ptr) _NOEXCEPT {
typedef __remove_cv_t<_Yp> _RawYp;
if (__e && __e->__weak_this_.expired()) {
__e->__weak_this_ = shared_ptr<_RawYp>(*this, const_cast<_RawYp*>(static_cast<const _Yp*>(__ptr)));
}
}

// 空实现
void __enable_weak_this(...) _NOEXCEPT {}

解读完源码之后,一切情况非常明了。我们再回头看下文章刚开始提到的三个陷阱:

  • 情况 1:不能在构造函数中使用 shared_from_this()。这是因为整个过程是:先创建好了原始对象,再去设置 __weak_this_ 属性,最终才能得到一个 shared_ptr 对象。所以在执行原始对象的构造函数时,__weak_this_ 属性尚未设置,当然不能用 shared_from_this。
  • 情况 2:创建的对象必须由 shared_ptr 管理,shared_from_this() 才能生效。这是因为,只有在 shared_ptr 里面才会设置 __weak_this_
  • 情况 3:对应类必须 public 继承 std::enable_shared_from_this。因为只有 public 继承,才能正确匹配到对应的 __enable_weak_this,从而设置 __weak_this_

Context的错误使用引发Panic的问题复盘

2024-05-06 17:25:54

我们有这么一段业务代码,在 Gin 的 API Handler 中,开了一个子 goroutine 写 DB,代码大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

var db *gorm.DB

func ServerHandler(c *gin.Context) {
// 一些旁路逻辑,为了不影响接口耗时,在子goroutine中执行
go func() {
db.WithContext(c).Exec("update xxx")
}()
// 一些后置逻辑
}

代码在测试阶段一直没啥问题,但是一上线立马出现了大面积的 panic。panic 的栈也非常奇怪,挂在了 mysql driver 里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
panic: sync/atomic: store of nil value into Value
goroutine 357413 [running]:
sync/atomic.(*Value).Store(0xc004097ef0, {0x0,0x0})

/usr/local/go/src/sync/atomic/value.go:47 +0xeb
github.com/go-sql-driver/mysql.(*atomicError).Set(..)
/root/go/pkg/mod/github.com/go-sql-driver/[email protected]/utils.go:831
github.com/go-sql-driver/mysql.(*mysqlConn).cancel(0xc004e6fc20, {0x0, 0x0})
/root/go/pkg/mod/github.com/go-sql-driver/[email protected]/connection.go:435 +0x3d
github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher.func1()
/root/go/pkg/mod/github.com/go-sql-driver/[email protected]/connection.go:622 +0x192
created by github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher
/root/go/pkg/mod/github.com/go-sql-driver/[email protected]/connection.go:611 +0x105

把 mysql driver 相关栈的源码扒出来,大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (mc *mysqlConn) startWatcher() {
watcher := make(chan context.Context, 1)
mc.watcher = watcher
finished := make(chan struct{})
mc.finished = finished
go func() {
for {
var ctx context.Context
select {
case ctx = <-watcher:
case <-mc.closech:
return
}

select {
case <-ctx.Done():
// 监听ctx.Done()
mc.cancel(ctx.Err())
case <-finished:
case <-mc.closech:
return
}
}
}()
}

// finish is called when the query has canceled.
func (mc *mysqlConn) cancel(err error) {
// 这里设置了原子变量
mc.canceled.Set(err)
mc.cleanup()
}

具体的故障现象大概明确了:

  1. mysql driver 里面监听了context.Done(), 当 channel 返回时,将ctx.Err()设置到原子变量里面。
  2. 问题就在于:context.Done()虽然返回了,ctx.Err()却是 nil。这就导致了在 set 原子变量时直接 panic 了。

这个问题非常难以理解,因为根据 context 的源码来看,只要context.Done()返回了,ctx.Err()就不可能是 nil。而且这个问题在测试环境无法复现,问题排查暂时陷入了僵局。

错误的 Context 使用

虽然 panic 的原因暂未查明,但是仔细看下这段业务逻辑,就可以看出来一些问题。

首先,我们需要知道这个 context 在什么时候会触发 Done,也就是什么时候 cancel 的。翻下 Golang HTTP Server 的源码,事情一目了然:

1
2
3
4
5
6
7
8
9
10
func (c *conn) serve(ctx context.Context) {
...

ctx, cancelCtx := context.WithCancel(ctx)
c.cancelCtx = cancelCtx
defer cancelCtx()

// handle request
....
}

在开始处理请求之前,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 的原因还没有完全查明,问题的阴影仍然持续笼罩着:

  1. 按照我们的推断,应该只会返回 error,不会出现 panic。
  2. 这个问题对于线上和测试环境应该没有什么区别,为什么错误的表现却不一样?

Gin 对 Context 的缓存

继续深扒下源码,这次找到了 Gin 对请求的处理过程:在每个处理过程中,都有对sync.Pool的使用。
对缓存的复用和清理一般是问题频发的根源,我们对这块着重进行了梳理,还真的找到了原因:

  1. gin.Context本质上是对c.Request.Context()的封装。所有对 Context 的 Done、Err 方法调用,都会转发给c.Request.Context()
  2. gin 会利用sync.Poolgin.Context进行对象复用。每次从sync.Pool拿到一个 gin.Context 对象的时候,都会重置其 Request 属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// engine.pool是sync.Pool
c := engine.pool.Get().(*Context)
c.writermem.reset(w)
// 重置Request属性
c.Request = req
c.reset()

engine.handleHTTPRequest(c)

engine.pool.Put(c)
}
1
2
3
4
5
6
7
8
9
// Done returns nil (chan which will wait forever) when c.Request has no Context.
func (c *Context) Done() <-chan struct{} {
return c.Request.Context().Done()
}

// Err returns nil when c.Request has no Context.
func (c *Context) Err() error {
return c.Request.Context().Err()
}

梳理下来,所有的情况都可以得到解释。简单来说:请求 1 中开的子 goroutine 持有的 context 对象,会被请求 2 复用,造成并发问题。

存在这样一种 case:请求1的子goroutine,在ctx.Done返回,并且要准备取ctx.Err之前。context刚好被复用,并且新的请求还没有结束。

  • 请求 1 中开启了子 goroutine ,正在监听 ctx.Done。整个外部处理逻辑结束,触发 HTTP Server 内部的 context cancel。此时,子 goroutine 中的ctx.Done channel 返回,准备去取context.Err()。同时请求 2 到来,复用了 context 对象。
  • 因为线上环境请求非常频繁,context 对象会被立即复用。此时 context 对象的 Request 属性被替换成新的了,因为新的请求还在处理中, c.Request.Context().Err()当然会返回 nil

为什么测试环境很难复现: 测试环境请求非常稀疏:子 goroutine 在取ctx.Err()之前,如果没有其他请求到来并复用这个 context,是不会出现问题的。

怎么复现这个问题?

为了方便构造这种 case,我们需要复现两个充分必要条件:

  • 条件 1:两个请求复用同一个 context 对象。
  • 条件 2:请求 1 在处理 ctx.Err()之前的间隙,请求 2 复用其 context 对象,并重置 Request 对象。

对于条件 1,我们需要简单了解下 sync.Pool 的原理,具体可以看我的另外一篇博客 《深度分析 Golang sync.Pool 底层原理》

  1. 禁用 GC: debug.SetGCPercent(0) 。因为每轮 GC 之后,sync.Pool 都会被强制清空。
  2. 设置 P 的个数为 1。因为sync.Pool会在每个 P 内部有一个私有对象和 localPool,只有设置为 1,才会保证一定可以复用上次请求的 context。

对于条件 2,其实只要请求 QPS 足够大,基本是可以必现的。我们使用 sleep 协调下两个请求,以模拟这种 case。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package main

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"runtime/debug"
"time"
"github.com/gin-gonic/gin"
)

func createTestGinServer() *gin.Engine {
router := gin.Default()
router.ContextWithFallback = true

router.GET("/test1", func(c *gin.Context) {
// 打印地址,以确认两次拿到了context是不是同一个
fmt.Printf("context Pointer address: %p\n", c)

c.JSON(200, gin.H{
"message": "Hello, World!",
})

go func() {
select {
case <-c.Done():
// 等待2秒,保证新的请求到来,覆盖c.Request
time.Sleep(2 * time.Second)
if c.Err() == nil {
panic("context is done, but c.Err() is nil")
} else {
fmt.Printf("context done , and err is %s\n", c.Err())
}
}
}()
})

router.GET("/test2", func(c *gin.Context) {
time.Sleep(3 * time.Second)

c.JSON(200, gin.H{
"message": "Hello, World!",
})
})

return router
}

func callApi(router *gin.Engine, api string) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", api, nil)

// 模拟http server的cancel逻辑
ctx, cancelCtx := context.WithCancel(context.Background())
defer cancelCtx()

req = req.WithContext(ctx)
router.ServeHTTP(w, req)
}

func main() {
// 禁用GC,防止sync.Pool被清空
debug.SetGCPercent(0)

// 设置只有一个P,保证两次请求一定能复用同一个context对象
runtime.GOMAXPROCS(1)

router := createTestGinServer()

callApi(router, "/test1")

// sleep 1s,保证子goroutine一定启动了
time.Sleep(1 * time.Second)

// 重新一个耗时请求,模拟请求未结束的情况
callApi(router, "/test2")

time.Sleep(5 * time.Second)
}

总结

为了方便描述问题,这里还有个额外的情况没有说明:我们在使用 Gin 时开启了 ContextWithFallback,这是在是在Gin的v1.8.1版本引入的。

如果你的Gin版本在 v1.8.1 之前或者 v1.8.1 之后并开启了 ContextWithFallback,才会保证所有对gin.ContextDone()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 对象。

Go 1.22 可能将改变 for 循环变量的语义

2023-11-29 13:05:01

几乎世界上每个 Golang 程序员都踩过一遍 for 循环变量的坑,而这个坑的解决方案已经作为实验特性加入到了 Go 1.21 中,并且有望在 Go 1.22 中完全开放。
举个例子,有这么段代码:

1
2
3
4
5
6
7
8
var ids []*int
for i := 0; i < 10; i++ {
ids = append(ids, &i)
}

for _, item := range ids {
println(*item)
}

可以试着在 playgound 里面运行下:go.dev/play/p/O8MVGtueGAf

答案是:打印出来的全是 10。

这个结果实在离谱。原因是因为在目前 Go 的设计中,for 中循环变量的定义是 per loop 而非 per iteration。也就是整个 for 循环期间,变量 i 只会有一个。以上代码等价于:

1
2
3
4
5
var ids []*int
var i int
for i = 0; i < 10; i++ {
ids = append(ids, &i)
}

同样的问题在闭包使用循环变量时也存在,代码如下:

1
2
3
4
5
6
7
var prints []func()
for _, v := range []int{1, 2, 3} {
prints = append(prints, func() { fmt.Println(v) })
}
for _, print := range prints {
print()
}

根据上面的经验,闭包 func 中 fmt.Println(v),捕获到的 v 都是同一个变量。因此打印出来的都是 3。

在目前的 go 版本中,正常来说我们会这么解决:

1
2
3
4
5
var ids []*int
for i := 0; i < 10; i++ {
i := i // 局部变量
ids = append(ids, &i)
}

定义一个新的局部变量, 这样无论闭包还是指针,每次迭代时所引用的内存都不一样了。

这个问题其实在 C++ 中也同样存在: wandbox.org/permlink/Se5WaeDb6quA8FCC

但真的太容易搞错了,几乎每个 Go 程序员都踩过一遍,而且也非常容易忘记。即使这次记住了,下次很容易又会踩一遍。

甚至知名证书颁发机构 Let’s Encrypt 就踩过一样的坑 bug#1619047。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// authz2ModelMapToPB converts a mapping of domain name to authz2Models into a
// protobuf authorizations map
func authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) {
resp := &sapb.Authorizations{}
for k, v := range m {
// Make a copy of k because it will be reassigned with each loop.
kCopy := k
// 坑在这里
authzPB, err := modelToAuthzPB(&v)
if err != nil {
return nil, err
}
resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
}
return resp, nil
}

在这个代码中,开发人员显然是很清楚这个 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
2
3
4
5
6
7
8
9
10
11
12
13
func sum(list []int) int {
m := make(map[*int]int)
for _, x := range list {
// 每次 & x 都是一样,因此一直追加写同一个元素
m[&x] += x
}

// 这个 for 循环只会执行一次,因为 m 的长度一定是 1
for _, sum := range m {
return sum
}
return 0
}

另外,对于程序性能也会有轻微影响, 毕竟新的方案里面将重复分配 N 次变量。对于性能极其敏感的场景,用户可以自行把循环变量提到外面。

同样的改变在 C# 也发生过,并没有出现大问题。

这个方案预计最早在 Go 1.22 就会正式开启了。按照 Go 每年发两个版本的惯例,在 2024 年 2 月份,我们就可以正式用上这个特性,彻底抛弃 x := x 的写法 ~

本文主要内容汇总自 go/wiki/LoopvarExperimentproposal#60078

剖析Golang Bigcache的极致性能优化

2023-11-25 19:25:54

Bigcache是用Golang实现的本地内存缓存的开源库,主打的就是可缓存数据量大,查询速度快。 在其官方的介绍文章《Writing a very fast cache service with millions of entries in Go》一文中,明确提出了bigcache的设计目标:

  1. 多: 缓存的元素数量非常大,可以达到百万级或千万级。
  2. 快: 对延迟有非常高的要求,平均延迟要求在5毫秒以内。redis、memcached之类的就不考虑在内了,毕竟用Redis还要多走一遍网络IO。
  3. 稳: 99.9分位延迟应在10毫秒左右,99.999分位延迟应在400毫秒左右。

目前有许多开源的cache库,大部分都是基于map实现的,例如go-cache,ttl-cache等。bigcache明确指出,当数据量巨大时,直接基于map实现的cache库将出现严重的性能问题,这也是他们设计了一个全新的cache库的原因。

本文将通过分析bigcache v3.1.0的源码,揭秘bigcache如何解决现有map库的性能缺陷,以极致的性能优化,实现超高性能的缓存库。

bigcache的设计思想

如何避免GC对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]intmap[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这种不可能会和外部变量有引用关系:

  1. int、bool这种在map中存储的就是值本身。
  2. map的key和value不可被寻址。也就是说,以map[int]int为例,外部没有办法取到这个key和value的指针,那也就无从引用了。

这个优化听起来非常强大好用,但是在Golang中指针无处不见,结构体指针、切片甚至字符串的底层实现都包含指针。一旦在map中使用它们(例如map[int][]byte、map[string]int),同样会触发垃圾回收器的遍历扫描。

bigcache的整体设计

bigcache整体设计的出发点都是基于上文提到的Golang对Map GC优化,整个设计思路包含几个方面:

  1. 数据分片存储,以降低锁冲突并提升并发量。
  2. 避免在map中存储指针,从而避免在GC时对map进行遍历扫描。
  3. 采用FIFO式的Ring Buffer设计,简化整体内存设计逻辑。

bigcache

数据分shard

这是一个非常常见的数据存储优化手段。表面上bigcache中所有的数据是存在一个大cache里面,但实际上底层数据分成了N个不互重合的部分,每一个部分称为一个shard。

在Set或者Get数据时,先对key计算hash值,根据hash值取余得到目标shard,之后所有的读写操作都是在各自的shard上进行。

以Set方法为例:

1
2
3
4
5
func (c *BigCache) Set(key string, entry []byte) error {
hashedKey := c.hash.Sum64(key)
shard := c.getShard(hashedKey)
return shard.set(key, hashedKey, entry)
}

这么做的优势是可以减少锁冲突,提升并发量:当一个shard被加上Lock的时候,其他shard的读写不受影响。

在bigcache的设计中,对于shard有如下要求:

  1. 一旦建好,shard将不改变。这带来的两点好处:
    • 不用再考虑shard变化时的数据迁移问题。
    • 因为shard数组是固定不变的,因此从shard数组中根据hash值取目标shard的时候,就无需加锁了。
  2. shard个数必须是2的平方数。这么做的好处是,对2的平方数取余可以改成位运算,会比传统的%快很多(根据不权威的benchmark,计算速度大概会有2倍左右的差距)。
1
2
3
4
func (c *BigCache) getShard(hashedKey uint64) (shard *cacheShard) {
// shardMask: uint64(config.Shards - 1)
return c.shards[hashedKey&c.shardMask]
}
  1. bigcache的shard数默认值是1024。

map不存原始数据,避免GC遍历扫描

前文提到,map的key和value一旦涉及指针相关的类型,GC时就会触发遍历扫描。

因此在bigcache的设计中,shard中的map直接定义为了map[uint64]uint32 ,避免了存储任何指针。shard的结构体定义如下:

1
2
3
4
5
6
type cacheShard struct {
...
hashmap map[uint64]uint32
entries queue.BytesQueue
...
}

其中: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、插入时间戳等信息)

entry

之所以用一个大的[]byte数组和ring buffer结构,除了方便管理和复用内存之外,一个更重要的原因是:对于[]byte数组, GC时只用看做一个变量扫描,无需再遍历全部数组。这样又避免了海量数据对GC造成的负担。

FIFO式的内存结构设计

bigcache在内存结构设计上完全遵循FIFO原则:

  1. 新增数据,包括对老数据的修改,都是直接Append新数据到BytesQueue中。基本不直接对内存进行修改和删除等。
  2. 每个数据项不可以定制单独的缓存时长,必须全部保持一致。这对数据淘汰非常友好,下文会详细讲述。

这样一整套设计约定下来,bigcache的逻辑变成非常简洁明了,但这样同时造成了bigcache的局限性。

Set过程

1
cache.Set("my-unique-key", []byte("value"))

前面讲述了bigcache的设计思想之后,Set的整个逻辑也就很清晰了:

  1. 计算key的hash值,得到对应的shard
  2. 将key和value等信息序列化成指定格式的[]byte, push到BytesQueue中。
  3. 根据BytesQueue返回的内存偏移量(也就是数组下标),将key(hash值)和value(数组下标)设置hashmap中。

这里需要注意的是,在bigcache的设计里面,Set时value一定得是个[]byte类型。

前文讲到,bigcache中所有的原始数据都会被塞到一个大的[]byte数组里。因此对于bigcache来说最理想的肯定是直接给到[]byte最为方便,否则还需要考虑序列化等问题。

BytesQueue是一个ring buffer设计,本文不再细究其实现了,和其他ring buffer的结构大同小异。

除了正常的set逻辑外,还有一些额外的情况需要考虑在内:

情况1:如果key之前设置过,Set的时候会如何处理?

在其他cache库的实现中,这种情况一般是找到旧值、删除,然后把新值设置到旧值的位置。

但在bigcache中并不是这样,前文提到,bigcache的内存结构设计是FIFO式的,哪怕是有旧值的情况下,新值也不会复用其内存,依旧是push新的value到队列中。

那旧值将如何处理的呢?我们看下代码:

1
2
3
4
5
6
7
if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 {
if previousEntry, err := s.entries.Get(int(previousIndex)); err == nil {
resetHashFromEntry(previousEntry)
//remove hashkey
delete(s.hashmap, hashedKey)
}
}

最核心的一句就是:delete(s.hashmap, hashedKey)

简单来说:之前的旧值并未从内存中移除,仅仅只是将其偏移量从s.hashmap中移除了,使得外部读不到。

那旧值什么时候会被淘汰呢?会有两种情况:

  1. 如果设置了CleanWindow ,且旧值刚好过时,会被清理的定时器自动淘汰
  2. 如果设置了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中。如果这个时候新数据非常大,可能会为此淘汰掉许多旧数据。

Get和GetWithInfo

1
2
entry, _ := cache.Get("my-unique-key")
fmt.Println(string(entry))

Get基本上是Set的逆过程,整个过程更简单一些,没有太多额外的知识可讲。不过在使用时,需要注意的是:

  • Get时如果数据到达了过期时间,但暂时还没有被清掉,这个时候也能正常查到value,不会报错。 其实这个倒是符合大多数的实际需求场景,实际场景中其实对缓存过期时间并没有那么敏感,短时间读到旧值一般都是可以接受的。
  • 如果对于缓存时间敏感的场景,可以使用GetWithInfo接口,返回值中有是否过期的标识。

删除

跟删除有关的核心逻辑只有这两行,整个逻辑和Set过程中清除旧值的一样:

1
2
3
4
5
...
delete(s.hashmap, hashedKey)
...
resetHashFromEntry(wrappedEntry)
...

不过在调用bigcache.Delete接口时需要注意的是,如果key不存在时,会返回一个ErrEntryNotFound

过期淘汰

上面讲到删除逻辑和set时清除旧值时,都只是简单的把key从map中删除,不让外部读取到而已。那原始值什么时候删呢?答案就是过期淘汰。

bigcache有个设计上的优势:bigcache没有开放单个元素的可过期时间,所有元素的cache时长都是一样的,这就意味着所有元素的过期时间在队列中天然有序。

这就使得淘汰逻辑非常简单,代码如下:

1
2
3
4
5
6
7
8
9
10
11
func (s *cacheShard) cleanUp(currentTimestamp uint64) {
s.lock.Lock()
for {
if oldestEntry, err := s.entries.Peek(); err != nil {
break
} else if evicted := s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry); !evicted {
break
}
}
s.lock.Unlock()
}

其实就是从头到尾遍历数组,直至元素不过期就跳出。

另外,即使淘汰过期数据时,数据也并未被真实的删除,仅仅对应于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: headerBufferentrybuffer ,避免了每次的内存分配。

  • 自己实现fnv Hash: bigcache自己实现了一套fnv hash,并没有用go官方标准库的,这也是基于性能的考虑。在Go官方的实现中 hash/fnv/fnv.go,创建Fnv对象的时候,有这么一段逻辑:

    1
    2
    3
    4
    func New32a() hash.Hash32 {
    var s sum32a = offset32
    return &s
    }

    根据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配置详解

bigcache.Config中有很多配置参数,这里大概列一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// Config for BigCache
type Config struct {
// Number of cache shards, value must be a power of two
// shard个数。必须2的平方数。
Shards int
// Time after which entry can be evicted
// 最小粒度是秒,当CleanWindow设置的时候,一定要设置这个值
LifeWindow time.Duration
// Interval between removing expired entries (clean up).
// If set to <= 0 then no action is performed. Setting to < 1 second is counterproductive — bigcache has a one second resolution.
// 如果没有设置,数据将不会被定时清理。最好大于1秒,因为bigcache的最小时间粒度就是秒
CleanWindow time.Duration
// Max number of entries in life window. Used only to calculate initial size for cache shards.
// When proper value is set then additional memory allocation does not occur.
MaxEntriesInWindow int
// Max size of entry in bytes. Used only to calculate initial size for cache shards.
// 单条数据最大的size,并不会做强制约束,只是用来初始化cache大小用,这个是仅包含用户自己设置的key和value的大小。
MaxEntrySize int
// StatsEnabled if true calculate the number of times a cached resource was requested.
// 是否对每条数据都开启hit次数统计的功能
StatsEnabled bool
// Verbose mode prints information about new memory allocation
Verbose bool
// Hasher used to map between string keys and unsigned 64bit integers, by default fnv64 hashing is used.
// hash函数,默认是bigcache自己实现的fnv
Hasher Hasher
// HardMaxCacheSize is a limit for BytesQueue size in MB.
// It can protect application from consuming all available memory on machine, therefore from running OOM Killer.
// Default value is 0 which means unlimited size. When the limit is higher than 0 and reached then
// the oldest entries are overridden for the new ones. The max memory consumption will be bigger than
// HardMaxCacheSize due to Shards' s additional memory. Every Shard consumes additional memory for map of keys
// and statistics (map[uint64]uint32) the size of this map is equal to number of entries in
// cache ~ 2×(64+32)×n bits + overhead or map itself.
// 最大内存数限制。
HardMaxCacheSize int
// OnRemove is a callback fired when the oldest entry is removed because of its expiration time or no space left
// for the new entry, or because delete was called.
// Default value is nil which means no callback and it prevents from unwrapping the oldest entry.
// ignored if OnRemoveWithMetadata is specified.
OnRemove func(key string, entry []byte)
// OnRemoveWithMetadata is a callback fired when the oldest entry is removed because of its expiration time or no space left
// for the new entry, or because delete was called. A structure representing details about that specific entry.
// Default value is nil which means no callback and it prevents from unwrapping the oldest entry.
OnRemoveWithMetadata func(key string, entry []byte, keyMetadata Metadata)
// OnRemoveWithReason is a callback fired when the oldest entry is removed because of its expiration time or no space left
// for the new entry, or because delete was called. A constant representing the reason will be passed through.
// Default value is nil which means no callback and it prevents from unwrapping the oldest entry.
// Ignored if OnRemove is specified.
OnRemoveWithReason func(key string, entry []byte, reason RemoveReason)

// Logger is a logging interface and used in combination with `Verbose`
// Defaults to `DefaultLogger()`
Logger Logger
}

解读 Golang 标准库里的 varint 实现

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 的出现主要是为了解决两个问题:

  1. 空间效率:以 uint64 类型为例,可以表示的最大值为 18446744073709551615。然而在实际业务场景中,我们通常处理的整数值远小于 uint64 的最大值。假设在我们的业务中,需要处理的整数值仅为 1,但在网络传输过程中,我们却需要使用 8 个字节来表示这个值。这就导致了大量的空间浪费,因为大部分字节并没有实际存储有效的信息。varint 编码通过使用可变长度的字节序列来表示整数,使得小的整数可以用更少的字节表示,提高空间效率。
  2. 兼容性:varint 使得我们可以在不改变编码 / 解码逻辑的情况下,处理不同大小的整数。这意味着我们可以在不破坏向后兼容性的情况下,将一个字段从较小的整数类型(如 uint32)升级到较大的整数类型(如 uint64)

本文将通过分析 Golang 标准库自带的 varint 源码实现,介绍 varint 的设计原理以及Golang标准库是如何解决 varint 在编码负数时遇到的问题。

varint 的设计原理

varint 的设计原理非常简单:

  • 7bit 为一组:将整数的二进制表示分为 7 个 bit 位一组。从低位到高位,每 7 个 bit 位作为一个单元。
  • 最高位表示 “继续” 标志。在每个 7 位的单元前面添加一个标志位,形成一个 8 位的字节。如果后面还有更多的字节,这个标志位就设置为 1,否则设置为 0。

例如:对于一个整数 300,它的二进制表示是 100101100。我们可以将它分为两组,即 100101100。然后在每组前面添加标志位,得到两个字节 0000001010101100,这两个字节就是 300 的 varint 编码。相比于用 uint32 的 4 字节表示,少了 50% 的存储空间。

无符号整数的 varint

在 Golang 标准库中有两套 varint 的函数: 分别用于无符号整数的 PutUvarint 和 Uvarint,以及用于有符号整数的 Varint 和 PutVarint。

我们先看下无符号整数的 varint 实现,代码如下:

1
2
3
4
5
6
7
8
9
10
func PutUvarint(buf []byte, x uint64) int {
i := 0
for x >= 0x80 {
buf[i] = byte(x) | 0x80
x >>= 7
i++
}
buf[i] = byte(x)
return i + 1
}

代码里有个非常重要的常量:0x80,对应于二进制编码就是 1000 0000。这个常量对接下来的逻辑非常重要:

  1. x >= 0x80。这意味着 x 的二进制表示形式至少有 8 位,我们刚才讲到 7 个 bit 位为一组,那 x 就需要被拆分了。
  2. byte(x) | 0x80。将 x 的最低 8 位与 1000 0000 进行按位或操作,然后将结果存储在 buf[i] 中。这样 既可以将最高位设置为 1,同时也提取出了 x 的最低 7 位
  3. x >>= 7. 将 x 右移 7 位,处理下一个分组。
  4. buf[i] = byte(x)。当 for 循环结束时,即意味着 x 的二进制表示形式最高位必然是 0。此时就不用做额外的补零操作了。

经过编码之后,原数据的最低位将在byte数组的最开始的位置,最高位在byte数组的尾部。

UvarintPutUvarint 的逆过程,实际上就是逐byte取元素还原,直到byte最高位是0,则还原结束。

需要注意的是,varint 将整数划分为 7 位一组。这意味着,对于大整数 varint 将会出现负向优化。例如对于 uint64 的最大值来说,本来只需要 8 个 byte 来表示,但在 varint 中却需要 10 个字节来表示了。(64/7 ≈ 10

负数的编码:zigzag 编码

看似 varint 编码已经完美无缺了,但以上忽略了一点:负数的存在。

我们知道,在计算机中数字是用补码来表示的,而负数的补码则是将其绝对值按位取反再加 1。这就意味着一个很小的负数,它的二进制形式对应于一个非常大的整数。例如:对于一个 32 位的整数 -5 来说,其绝对值 5 的二进制形式是 101。 但 -5 的二进制形式却是 11111111111111111111111111111011,如果使用 varint 对其编码, 需要 5 个字节才能表示。

Golang标准库引入了 zigzag 编码来解决这个问题,zigzag 的原理非常简单:

  • 对于正数 n,会将其映射为 2n。例如整数 2,经过 zigzag 编码之后变成了 4。
  • 对于负数 -n 来说,会将其映射为 2n-1。例如负数 -3,经过 zigzag 编码之后变成了 5。

这样负数和正数在数值上完全不会冲突,正整数和负整数交错排列,这也是为什么叫做 zigzag 编码 (锯齿形编码)的原因。
同时,负数被转换成正数之后,二进制编码也精简了许多。

例如: 对 -5 进行 zigzag 编码后,变成了 9,对应于二进制为 00000000000000000000000000001001,使用 1 个字节即可表示 varint。

我们看下 Golang 标准库的实现,代码如下:

1
2
3
4
5
6
7
8
func PutVarint(buf []byte, x int64) int {
// zigzag 编码
ux := uint64(x) << 1
if x < 0 {
ux = ^ux
}
return PutUvarint(buf, ux)
}

从代码可以看出,对于有符号整数的varint实现,golang标准库这里分成了两步:

  1. 先对整数进行 zigzag 编码进行转换
  2. 对转换之后的数值再进行 varint 编码

我们详细讲下 zigzag 编码的实现部分:

  • 正数:ux := uint64(x) << 1。这个位运算左移一位,相当于 ux*2。对于正数,符合 ZigZag 编码。
  • 负数:ux := uint64(x) << 1; ux = ^ux。负数这里就有些难以理解了,为什么这么转换之后就等于2n - 1了?

我们可以大概推导下整个过程,假设我们有个整数 -n

  1. 对原数值先左移,再进行取反。其实可以看做:对原数值先取反,再左移,然后+1。 即 2*(~(-n))+1
  2. 我们知道负数的补码=绝对值按位取反+1,那如何根据补码再推导出绝对值?这里有个公式是:|A| = ~A+1
  3. 我们将这个公式带到第一步的式子里面: 2*(n-1) + 1 = 2n - 1。这就完美对应上了负数的 ZigZag 编码。

在 Golang 标准库里面,调用 PutUvarint 时只会使用 varint 编码,调用 PutVarint 会先进行 zigzag 编码,再进行 varint 编码。

而在 protobuf 中,如果类型是 int32、int64、uint32、uint64,只会使用 varint 编码。使用 sint32、sint64 将先进行 zigzag 编码,再进行 varint 编码

varint 不适用的情况

虽然 varint 编码设计非常精妙,但并不适用于所有的场景:

  • 大整数:对于非常大的整数,varint 编码可能会比固定长度的编码更消耗空间。例如当所有的整数值域大于 2^63,那使用 varint 会用到 10 字节。相比于传统的八字节整数,反而多用了 25% 的空间
  • 需要快速随机访问的数据:由于 varint 是变长编码,所以我们无法直接通过索引来访问特定的整数,必须从头开始解码,直到找到我们需要的整数。这使得 varint 编码不适合用于需要快速随机访问的数据。
  • 需要频繁进行数学运算的数据:由于 varint 编码的数据需要先解码才能进行数学运算,所以如果一个应用需要频繁地对数据进行数学运算,那么使用 varint 编码可能会导致性能下降。
  • 安全敏感的应用:varint 编码的数据可能会暴露出一些关于原始整数的信息,例如它的大小。在某些安全敏感的应用中,这可能是不可接受的。