2024年最新字节跳动 Go RPC 框架 KiteX 性能优化实践(7),2024年最新Golang面试送分题

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

// @syscall

type EpollEvent struct {

Events uint32

Fd     int32

Pad    int32

}

// @runtime

type epollevent struct {

events uint32

data   [8]byte // unaligned uintptr

}

我们看到,runtime 使用的 epollevent 是系统层 epoll 定义的原始结构;而对外版本则对其做了封装,将 epoll_data(epollevent.data) 拆分为固定的两字段:Fd 和 Pad。那么 runtime 又是如何使用的呢?在源码里我们看到这样的逻辑:

*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd

pd := *(**pollDesc)(unsafe.Pointer(&ev.data))

显然,runtime 使用 epoll_data(&ev.data) 直接存储了 fd 对应结构体(pollDesc)的指针,这样在事件触发时,可以直接找到结构体对象,并执行相应逻辑。而对外版本则由于只能获得封装后的 Fd 参数,因此需要引入额外的 Map 来增删改查结构体对象,这样性能肯定相差很多。

所以我们果断抛弃了 syscall.EpollWait,转而仿照 runtime 自行设计了 EpollWait 调用,同样采用 unsafe.Pointer 存取结构体对象。测试表明,该方案下 吞吐量 ↑10%,TP99 ↓10%,获得了较为明显的收益。

Thrift 序列化/反序列化优化


序列化是指把数据结构或对象转换成字节序列的过程,反序列化则是相反的过程。RPC 在通信时需要约定好序列化协议,client 在发送请求前进行序列化,字节序列通过网络传输到 server,server 再反序列进行逻辑处理,完成一次 RPC 请求。Thrift 支持 Binary、Compact 和 JSON 序列化协议。目前公司内部使用的基本都是 Binary,这里只介绍 Binary 协议。

Binary 采用 TLV 编码实现,即每个字段都由 TLV 结构来描述,TLV 意为:Type 类型, Lenght 长度,Value 值,Value 也可以是个 TLV 结构,其中 Type 和 Length 的长度固定,Value 的长度则由 Length 的值决定。TLV 编码结构简单清晰,并且扩展性较好,但是由于增加了 Type 和 Length,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。

序列化和反序列的性能优化从大的方面来看可以从空间和时间两个维度进行优化。从兼容已有的 Binary 协议来看,空间上的优化似乎不太可行,只能从时间维度进行优化,包括:

  1. 减少内存操作次数,包括内存分配和拷贝,尽量预分配内存,减少不必要的开销;

  2. 减少函数调用次数,比如可调整代码结构和 inline 等手段进行优化;

调研

根据 go_serialization_benchmarks 的压测数据,我们找到了一些性能卓越的序列化方案进行调研,希望能够对我们的优化工作有所启发。

通过对 protobuf、gogoprotobuf 和 Cap’n Proto 的分析,我们得出以下结论:

  1. 网络传输中出于 IO 的考虑,都会尽量压缩传输数据,protobuf 采用了 Varint 编码在大部分场景中都有着不错的压缩效果;

  2. gogoprotobuf 采用预计算方式,在序列化时能够减少内存分配次数,进而减少了内存分配带来的系统调用、锁和 GC 等代价;

  3. Cap’n Proto 直接操作 buffer,也是减少了内存分配和内存拷贝(少了中间的数据结构),并且在 struct pointer 的设计中把固定长度类型数据和非固定长度类型数据分开处理,针对固定长度类型可以快速处理;

从兼容性考虑,不可能改变现有的 TLV 编码格式,因此数据压缩不太现实,但是 2 和 3 对我们的优化工作是有启发的,事实上我们也是采取了类似的思路。

思路

减少内存操作
buffer 管理

无论是序列化还是反序列化,都是从一块内存拷贝数据到另一块内存,这就涉及到内存分配和内存拷贝操作,尽量避免内存操作可以减少不必要的系统调用、锁和 GC 等开销。

事实上 KiteX 已经提供了 LinkBuffer 用于 buffer 的管理,LinkBuffer 设计上采用链式结构,由多个 block 组成,其中 block 是大小固定的内存块,构建对象池维护空闲 block,由此复用 block,减少内存占用和 GC。

刚开始我们简单地采用 sync.Pool 来复用 netpoll 的 LinkBufferNode,但是这样仍然无法解决对于大包场景下的内存复用(大的 Node 不能回收,否则会导致内存泄漏)。目前我们改成了维护一组 sync.Pool,每组中的 buffer size 都不同,新建 block 时根据最接近所需 size 的 pool 中去获取,这样可以尽可能复用内存,从测试来看内存分配和 GC 优化效果明显。

string / binary 零拷贝

对于有一些业务,比如视频相关的业务,会在请求或者返回中有一个很大的 Binary 二进制数据代表了处理后的视频或者图片数据,同时会有一些业务会返回很大的 String(如全文信息等)。这种场景下,我们通过火焰图看到的热点都在数据的 copy 上,那我们就想了,我们是否可以减少这种拷贝呢?

答案是肯定的。既然我们底层使用的 Buffer 是个链表,那么就可以很容易地在链表中间插入一个节点。

我们就采用了类似的思想,当序列化的过程中遇到了 string 或者 binary 的时候, 将这个节点的 buffer 分成两段,在中间原地插入用户的 string / binary 对应的 buffer,这样可以避免大的 string / binary 的拷贝了。

这里再介绍一下,如果我们直接用 []byte(string) 去转换一个 string 到 []byte 的话实际上是会发生一次拷贝的,原因是 Go 的设计中 string 是 immutable 的但是 []byte 是 mutable 的,所以这么转换的时候会拷贝一次;如果要不拷贝转换的话,就需要用到 unsafe 了:

func StringToSliceByte(s string) []byte {

l := len(s)

return ([]byte)(unsafe.Pointer(&reflect.SliceHeader{

Data: (*(*reflect.StringHeader)(unsafe.Pointer(&s))).Data,

Len:  l,

Cap:  l,

}))

}

这段代码的意思是,先把 string 的地址拿到,再拼装上一个 slice byte 的 header,这样就可以不拷贝数据而将 string 转换成 []byte 了,不过要注意这样生成的 []byte 不可写,否则行为未定义。

预计算

线上存在某些服务有大包传输的场景,这种场景下会引入不小的序列化 / 反序列化开销。一般大包都是容器类型的大小非常大导致的,如果能够提前计算出 buffer,一些 O(n) 的操作就能降到 O(1),减少了函数调用次数,在大包场景下也大量减少了内存分配的次数,带来的收益是可观的。

基本类型

如果容器元素为基本类型(bool, byte, i16, i32, i64, double)的话,由于基本类型大小固定,在序列化时是可以提前计算出总的大小,并且一次性分配足够的 buffer,O(n) 的 malloc 操作次数可以降到 O(1),从而大量减少了 malloc 的次数,同理在反序列化时可以减少 next 的操作次数。

struct 字段重排

上面的优化只能针对容器元素类型为基本类型的有效,那么对于元素类型为 struct 的是否也能优化呢?答案是肯定的。

沿用上面的思路,假如 struct 中如果存在基本类型的 field,也可以预先计算出这些 field 的大小,在序列化时为这些 field 提前分配 buffer,写的时候也把这些 field 顺序统一放到前面写,这样也能在一定程度上减少 malloc 的次数。

一次性计算

上面提到的是基本类型的优化,如果在序列化时,先遍历一遍 request 所有 field,便可以计算得到整个 request 的大小,提前分配好 buffer,在序列化和反序列时直接操作 buffer,这样对于非基本类型也能有优化效果。

定义新的 codec 接口:

type thriftMsgFastCodec interface {

BLength() int // count length of whole req/resp

FastWrite(buf []byte) int

FastRead(buf []byte) (int, error)

}

在 Marshal 和 Unmarshal 接口中做相应改造:

func (c thriftCodec) Marshal(ctx context.Context, message remote.Message, out remote.ByteBuffer) error {

if msg, ok := data.(thriftMsgFastCodec); ok {

msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, thrift.TMessageType(msgType), int32(seqID))

msgEndLen := bthrift.Binary.MessageEndLength()

buf, err := out.Malloc(msgBeginLen + msg.BLength() + msgEndLen)// malloc once

if err != nil {

return perrors.NewProtocolErrorWithMsg(fmt.Sprintf(“thrift marshal, Malloc failed: %s”, err.Error()))

}

offset := bthrift.Binary.WriteMessageBegin(buf, methodName, thrift.TMessageType(msgType), int32(seqID))

offset += msg.FastWrite(buf[offset:])

bthrift.Binary.WriteMessageEnd(buf[offset:])

return nil

}

}

func (c thriftCodec) Unmarshal(ctx context.Context, message remote.Message, in remote.ByteBuffer) error {

data := message.Data()

if msg, ok := data.(thriftMsgFastCodec); ok && message.PayloadLen() != 0 {

msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, msgType, seqID)

buf, err := tProt.next(message.PayloadLen() - msgBeginLen - bthrift.Binary.MessageEndLength()) // next once

if err != nil {

return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())

}

_, err = msg.FastRead(buf)

if err != nil {

return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())

}

err = tProt.ReadMessageEnd()

if err != nil {

return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())

}

tProt.Recycle()

return err

}

}

生成代码中也做相应改造:

func (p *Demo) BLength() int {

l := 0

l += bthrift.Binary.StructBeginLength(“Demo”)

if p != nil {

l += p.field1Length()

l += p.field2Length()

l += p.field3Length()

}

l += bthrift.Binary.FieldStopLength()

l += bthrift.Binary.StructEndLength()

return l

}

func (p *Demo) FastWrite(buf []byte) int {

offset := 0

offset += bthrift.Binary.WriteStructBegin(buf[offset:], “Demo”)

if p != nil {

offset += p.fastWriteField2(buf[offset:])

offset += p.fastWriteField4(buf[offset:])

offset += p.fastWriteField1(buf[offset:])

offset += p.fastWriteField3(buf[offset:])

}

offset += bthrift.Binary.WriteFieldStop(buf[offset:])

offset += bthrift.Binary.WriteStructEnd(buf[offset:])

return offset

}

使用 SIMD 优化 Thrift 编码

公司内广泛使用 list<i64/i32> 类型来承载 ID 列表,并且 list<i64/i32> 的编码方式十分符合向量化的规律,于是我们用了 SIMD 来优化 list<i64/i32> 的编码过程。

我们使用了 avx2,优化后的结果比较显著,在大数据量下针对 i64 可以提升 6 倍性能,针对 i32 可以提升 12 倍性能;在小数据量下提升更明显,针对 i64 可以提升 10 倍,针对 i32 可以提升 20 倍。

减少函数调用
inline

inline 是在编译期间将一个函数调用原地展开,替换成这个函数的实现,它可以减少函数调用的开销以提高程序的性能。

在 Go 中并不是所有函数都能 inline,使用参数-gflags="-m"运行进程,可显示被 inline 的函数。以下几种情况无法内联:

  1. 包含循环的函数;

  2. 包含以下内容的函数:闭包调用,select,for,defer,go 关键字创建的协程;

  3. 超过一定长度的函数,默认情况下当解析 AST 时,Go 申请了 80 个节点作为内联的预算。每个节点都会消耗一个预算。比如,a = a + 1 这行代码包含了 5 个节点:AS, NAME, ADD, NAME, LITERAL。当一个函数的开销超过了这个预算,就无法内联。

编译时通过指定参数-l可以指定编译器对代码内联的强度(go 1.9+),不过这里不推荐大家使用,在我们的测试场景下是 buggy 的,无法正常运行:

// The debug[‘l’] flag controls the aggressiveness. Note that main() swaps level 0 and 1, making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and are not supported.

//      0: disabled

//      1: 80-nodes leaf functions, oneliners, panic, lazy typechecking (default)

//      2: (unassigned)

//      3: (unassigned)

//      4: allow non-leaf functions

内联虽然可以减少函数调用的开销,但是也可能因为存在重复代码,从而导致 CPU 缓存命中率降低,所以并不能盲目追求过度的内联,需要结合 profile 结果来具体分析。

go test -gcflags=‘-m=2’ -v -test.run TestNewCodec 2>&1 | grep “function too complex” | wc -l

48

go test -gcflags=‘-m=2 -l=4’ -v -test.run TestNewCodec 2>&1 | grep “function too complex” | wc -l

25

从上面的输出结果可以看出,加强内联程度确实减少了一些"function too complex",看下 benchmark 结果:

上面开启最高程度的内联强度,确实消除了不少因为“function too complex”带来无法内联的函数,但是压测结果显示收益不太明显。

测试结果

我们构建了基准测试来对比优化前后的性能,下面是测试结果。

环境:Go 1.13.5 darwin/amd64 on a 2.5 GHz Intel Core i7 16GB

小包

data size: 20KB

大包

data size: 6MB

无拷贝序列化


在一些 request 和 response 数据较大的服务中,序列化和反序列化的代价较高,有两种优化思路:

  1. 如前文所述进行序列化和反序列化的优化

  2. 以无拷贝序列化的方式进行调用

调研

通过无拷贝序列化进行 RPC 调用,最早出自 Kenton Varda 的 Cap’n Proto 项目,Cap’n Proto 提供了一套数据交换格式和对应的编解码库。

Cap’n Proto 本质上是开辟一个 bytes slice 作为 buffer ,所有对数据结构的读写操作都是直接读写 buffer,读写完成后,在头部添加一些 buffer 的信息就可以直接发送,对端收到后即可读取,因为没有 Go 语言结构体作为中间存储,所有无需序列化这个步骤,反序列化亦然。

简单总结下 Cap’n Proto 的特点:

  1. 所有数据的读写都是在一段连续内存中

  2. 将序列化操作前置,在数据 Get/Set 的同时进行编解码

  3. 在数据交换格式中,通过 pointer(数据存储位置的 offset)机制,使得数据可以存储在连续内存的任意位置,进而使得结构体中的数据可以以任意顺序读写

    1. 对于结构体的固定大小字段,通过重新排列,使得这些字段存储在一块连续内存中
  4. 对于结构体的不定大小字段(如 list),则通过一个固定大小的 pointer 来表示,pointer 中存储了包括数据位置在内的一些信息

首先 Cap’n Proto 没有 Go 语言结构体作为中间载体,得以减少一次拷贝,然后 Cap’n Proto 是在一段连续内存上进行操作,编码数据的读写可以一次完成,因为这两个原因,使得 Cap’ Proto 的性能表现优秀。

下面是相同数据结构下 Thrift 和 Cap’n Proto 的 Benchmark,考虑到 Cap’n Proto 是将编解码操作前置了,所以对比的是包括数据初始化在内的完整过程,即结构体数据初始化+(序列化)+写入 buffer +从 buffer 读出+(反序列化)+从结构体读出数据。

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

置在内的一些信息

首先 Cap’n Proto 没有 Go 语言结构体作为中间载体,得以减少一次拷贝,然后 Cap’n Proto 是在一段连续内存上进行操作,编码数据的读写可以一次完成,因为这两个原因,使得 Cap’ Proto 的性能表现优秀。

下面是相同数据结构下 Thrift 和 Cap’n Proto 的 Benchmark,考虑到 Cap’n Proto 是将编解码操作前置了,所以对比的是包括数据初始化在内的完整过程,即结构体数据初始化+(序列化)+写入 buffer +从 buffer 读出+(反序列化)+从结构体读出数据。

[外链图片转存中…(img-jOD9Iy8Y-1715486223631)]
[外链图片转存中…(img-nvH9L9bH-1715486223632)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 17
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值