大家好,今天给各位分享jnd28开奖网站源码分享的一些知识,其中也会对28开奖下载进行解释,文章篇幅可能偏长,如果能碰巧解决你现在面临的问题,别忘了关注本站,现在就马上开始吧!
本文选自“字节跳动基础架构实践”系列文章。“字节跳动基础架构实践”系列文章是由字节跳动基础架构部门各技术团队及专家倾力打造的技术干货内容,和大家分享团队在基础架构发展和演进过程中的实践经验与教训,与各位技术同学一起交流成长。KiteX自2020.04正式发布以来,公司内部服务数量8k+,QPS过亿。经过持续迭代,KiteX在吞吐和延迟表现上都取得了显著收益。本文将简单分享一些较有成效的优化方向,希望为大家提供参考。
前言
KiteX是字节跳动框架组研发的下一代高性能、强可扩展性的GoRPC框架。除具备丰富的服务治理特性外,相比其他框架还有以下特点:集成了自研的网络库Netpoll;支持多消息协议(Thrift、Protobuf)和多交互方式(Ping-Pong、Oneway、Streaming);提供了更加灵活可扩展的代码生成器。
目前公司内主要业务线都已经大范围使用KiteX,据统计当前接入服务数量多达8k。KiteX推出后,我们一直在不断地优化性能,本文将分享我们在Netpoll和序列化方面的优化工作。
自研网络库Netpoll优化
自研的基于epoll的网络库——Netpoll,在性能方面有了较为显著的优化。测试数据表明,当前版本(2020.12)相比于上次分享时(2020.05),吞吐能力↑30%,延迟AVG↓25%,TP99↓67%,性能已远超官方net库。以下,我们将分享两点显著提升性能的方案。
epoll_wait调度延迟优化
Netpoll在刚发布时,遇到了延迟AVG较低,但TP99较高的问题。经过认真研究epoll_wait,我们发现结合polling和eventtrigger两种模式,并优化调度策略,可以显著降低延迟。
首先我们来看Go官方提供的syscall.EpollWait方法:
funcEpollWait(epfdint,events[]EpollEvent,msecint)(nint,errerror)\n
这里共提供3个参数,分别表示epoll的fd、回调事件、等待时间,其中只有msec是动态可调的。
通常情况下,我们主动调用EpollWait都会设置msec=-1,即无限等待事件到来。事实上不少开源网络库也是这么做的。但是我们研究发现,msec=-1并不是最优解。
epoll_wait内核源码(如下)表明,msec=-1比msec=0增加了fetch_events检查,因此耗时更长。
staticintep_poll(structeventpoll*ep,structepoll_event__user*events,\nintmaxevents,longtimeout)\n{\n…\nif(timeout>0){\n…\n}elseif(timeout==0){\n…\ngotosend_events;\n}\n\nfetch_events:\n…\nif(eavail)\ngotosend_events;\n\nsend_events:\n…\n
Benchmark表明,在有事件触发的情况下,msec=0比msec=-1调用要快18%左右,因此在频繁事件触发场景下,使用msec=0调用明显是更优的。
而在无事件触发的场景下,使用msec=0显然会造成无限轮询,空耗大量资源。
综合考虑后,我们更希望在有事件触发时,使用msec=0调用,而在无事件时,使用msec=-1来减少轮询开销。伪代码如下:
varmsec=-1\nfor{\nn,err=syscall.EpollWait(epfd,events,msec)\nifn<=0{\nmsec=-1\ncontinue\n}\nmsec=0\n…\n}\n
那么这样就可以了吗?事实证明优化效果并不明显。
我们再做思考:
msec=0仅单次调用耗时减少50ns,影响太小,如果想要进一步优化,必须要在调度逻辑上做出调整。
进一步思考:
上述伪代码中,当无事件触发,调整msec=-1时,直接continue会立即再次执行EpollWait,而由于无事件,msec=-1,当前goroutine会block并被P切换。但是被动切换效率较低,如果我们在continue前主动为P切换goroutine,则可以节约时间。因此我们将上述伪代码改为如下:
varmsec=-1\nfor{\nn,err=syscall.EpollWait(epfd,events,msec)\nifn<=0{\nmsec=-1\nruntime.Gosched()\ncontinue\n}\nmsec=0\n…\n}\n
测试表明,调整代码后,吞吐量↑12%,TP99↓64%,获得了显著的延迟收益。
合理利用unsafe.Pointer
继续研究epoll_wait,我们发现Go官方对外提供的syscall.EpollWait和runtime自用的epollwait是不同的版本,即两者使用了不同的EpollEvent。以下我们展示两者的区别:
//@syscall\ntypeEpollEventstruct{\nEventsuint32\nFdint32\nPadint32\n}\n//@runtime\ntypeepolleventstruct{\neventsuint32\ndata[8]byte//unaligneduintptr\n}\n
我们看到,runtime使用的epollevent是系统层epoll定义的原始结构;而对外版本则对其做了封装,将epoll_data(epollevent.data)拆分为固定的两字段:Fd和Pad。那么runtime又是如何使用的呢?在源码里我们看到这样的逻辑:
*(**pollDesc)(unsafe.Pointer(&ev.data))=pd\n\npd:=*(**pollDesc)(unsafe.Pointer(&ev.data))\n
显然,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协议来看,空间上的优化似乎不太可行,只能从时间维度进行优化,包括:
减少内存操作次数,包括内存分配和拷贝,尽量预分配内存,减少不必要的开销;减少函数调用次数,比如可调整代码结构和inline等手段进行优化;
调研
根据go_serialization_benchmarks的压测数据,我们找到了一些性能卓越的序列化方案进行调研,希望能够对我们的优化工作有所启发。
通过对protobuf、gogoprotobuf和Cap&39;nProto直接操作buffer,也是减少了内存分配和内存拷贝(少了中间的数据结构),并且在structpointer的设计中把固定长度类型数据和非固定长度类型数据分开处理,针对固定长度类型可以快速处理;
从兼容性考虑,不可能改变现有的TLV编码格式,因此数据压缩不太现实,但是2和3对我们的优化工作是有启发的,事实上我们也是采取了类似的思路。
思路
减少内存操作
buffer管理
无论是序列化还是反序列化,都是从一块内存拷贝数据到另一块内存,这就涉及到内存分配和内存拷贝操作,尽量避免内存操作可以减少不必要的系统调用、锁和GC等开销。
事实上KiteX已经提供了LinkBuffer用于buffer的管理,LinkBuffer设计上采用链式结构,由多个block组成,其中block是大小固定的内存块,构建对象池维护空闲block,由此复用block,减少内存占用和GC。
刚开始我们简单地采用sync.Pool来复用netpoll的LinkBufferNode,但是这样仍然无法解决对于大包场景下的内存复用(大的Node不能回收,否则会导致内存泄漏)。目前我们改成了维护一组sync.Pool,每组中的buffersize都不同,新建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了:
funcStringToSliceByte(sstring)[]byte{\nl:=len(s)\nreturn*(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{\nData:(*(*reflect.StringHeader)(unsafe.Pointer(&s))).Data,\nLen:l,\nCap:l,\n}))\n}\n
这段代码的意思是,先把string的地址拿到,再拼装上一个slicebyte的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接口:
typethriftMsgFastCodecinterface{\nBLength()int//countlengthofwholereq/resp\nFastWrite(buf[]byte)int\nFastRead(buf[]byte)(int,error)\n}\n
在Marshal和Unmarshal接口中做相应改造:
func(cthriftCodec)Marshal(ctxcontext.Context,messageremote.Message,outremote.ByteBuffer)error{\n…\nifmsg,ok:=data.(thriftMsgFastCodec);ok{\nmsgBeginLen:=bthrift.Binary.MessageBeginLength(methodName,thrift.TMessageType(msgType),int32(seqID))\nmsgEndLen:=bthrift.Binary.MessageEndLength()\nbuf,err:=out.Malloc(msgBeginLen+msg.BLength()+msgEndLen)//malloconce\niferr!=nil{\nreturnperrors.NewProtocolErrorWithMsg(fmt.Sprintf(&34;,err.Error()))\n}\noffset:=bthrift.Binary.WriteMessageBegin(buf,methodName,thrift.TMessageType(msgType),int32(seqID))\noffset+=msg.FastWrite(buf[offset:])\nbthrift.Binary.WriteMessageEnd(buf[offset:])\nreturnnil\n}\n…\n}\n\nfunc(cthriftCodec)Unmarshal(ctxcontext.Context,messageremote.Message,inremote.ByteBuffer)error{\n…\ndata:=message.Data()\nifmsg,ok:=data.(thriftMsgFastCodec);ok&&message.PayloadLen()!=0{\nmsgBeginLen:=bthrift.Binary.MessageBeginLength(methodName,msgType,seqID)\nbuf,err:=tProt.next(message.PayloadLen()-msgBeginLen-bthrift.Binary.MessageEndLength())//nextonce\niferr!=nil{\nreturnremote.NewTransError(remote.PROTOCOL_ERROR,err.Error())\n}\n_,err=msg.FastRead(buf)\niferr!=nil{\nreturnremote.NewTransError(remote.PROTOCOL_ERROR,err.Error())\n}\nerr=tProt.ReadMessageEnd()\niferr!=nil{\nreturnremote.NewTransError(remote.PROTOCOL_ERROR,err.Error())\n}\ntProt.Recycle()\nreturnerr\n}\n…\n}\n
生成代码中也做相应改造:
func(p*Demo)BLength()int{\nl:=0\nl+=bthrift.Binary.StructBeginLength(&34;)\nifp!=nil{\nl+=p.field1Length()\nl+=p.field2Length()\nl+=p.field3Length()\n…\n}\nl+=bthrift.Binary.FieldStopLength()\nl+=bthrift.Binary.StructEndLength()\nreturnl\n}\n\nfunc(p*Demo)FastWrite(buf[]byte)int{\noffset:=0\noffset+=bthrift.Binary.WriteStructBegin(buf[offset:],&34;)\nifp!=nil{\noffset+=p.fastWriteField2(buf[offset:])\noffset+=p.fastWriteField4(buf[offset:])\noffset+=p.fastWriteField1(buf[offset:])\noffset+=p.fastWriteField3(buf[offset:])\n}\noffset+=bthrift.Binary.WriteFieldStop(buf[offset:])\noffset+=bthrift.Binary.WriteStructEnd(buf[offset:])\nreturnoffset\n}\n
使用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=&34;运行进程,可显示被inline的函数。以下几种情况无法内联:
包含循环的函数;包含以下内容的函数:闭包调用,select,for,defer,go关键字创建的协程;超过一定长度的函数,默认情况下当解析AST时,Go申请了80个节点作为内联的预算。每个节点都会消耗一个预算。比如,a=a+1这行代码包含了5个节点:AS,NAME,ADD,NAME,LITERAL。当一个函数的开销超过了这个预算,就无法内联。
编译时通过指定参数-l可以指定编译器对代码内联的强度(go1.9+),不过这里不推荐大家使用,在我们的测试场景下是buggy的,无法正常运行:
//Thedebug[&39;]flagcontrolstheaggressiveness.Notethatmain()swapslevel0and1,making1thedefaultand-ldisable.Additionallevels(beyond-l)maybebuggyandarenotsupported.\n//0:disabled\n//1:80-nodesleaffunctions,oneliners,panic,lazytypechecking(default)\n//2:(unassigned)\n//3:(unassigned)\n//4:allownon-leaffunctions\n
内联虽然可以减少函数调用的开销,但是也可能因为存在重复代码,从而导致CPU缓存命中率降低,所以并不能盲目追求过度的内联,需要结合profile结果来具体分析。
gotest-gcflags=&39;-v-test.runTestNewCodec2>&1|grep&34;|wc-l\n48\n\ngotest-gcflags=&39;-v-test.runTestNewCodec2>&1|grep&34;|wc-l\n25\n\n
从上面的输出结果可以看出,加强内联程度确实减少了一些&34;,看下benchmark结果:
上面开启最高程度的内联强度,确实消除了不少因为“functiontoocomplex”带来无法内联的函数,但是压测结果显示收益不太明显。
测试结果
我们构建了基准测试来对比优化前后的性能,下面是测试结果。
环境:Go1.13.5darwin/amd64ona2.5GHzIntelCorei716GB
小包
datasize:20KB
大包
datasize:6MB
无拷贝序列化
在一些request和response数据较大的服务中,序列化和反序列化的代价较高,有两种优化思路:
如前文所述进行序列化和反序列化的优化以无拷贝序列化的方式进行调用
调研
通过无拷贝序列化进行RPC调用,最早出自KentonVarda的Cap&39;nProto提供了一套数据交换格式和对应的编解码库。
Cap&39;nProto的特点:
所有数据的读写都是在一段连续内存中将序列化操作前置,在数据Get/Set的同时进行编解码在数据交换格式中,通过pointer(数据存储位置的offset)机制,使得数据可以存储在连续内存的任意位置,进而使得结构体中的数据可以以任意顺序读写对于结构体的固定大小字段,通过重新排列,使得这些字段存储在一块连续内存中对于结构体的不定大小字段(如list),则通过一个固定大小的pointer来表示,pointer中存储了包括数据位置在内的一些信息
首先Cap&39;nProto是在一段连续内存上进行操作,编码数据的读写可以一次完成,因为这两个原因,使得Cap&39;nProto的Benchmark,考虑到Cap&39;nProto性能大约是Thrift的8-9倍。写入数据+(序列化),视包大小,Cap&39;Proto性能大约是Thrift的4-8倍。
前面说了Cap&39;nProto存在的一些问题:
Cap&39;nProto因为没有Go语言结构体作为中间载体,使得所有的字段都只能通过接口进行读写,用户体验较差。
Thrift协议兼容的无拷贝序列化
Cap&39;nProto作为无拷贝序列化的标杆,那么我们就看看Cap&39;nProto的使用上来看,用户体验还需要仔细打磨一下。Cap&39;nProto得以在连续内存上操作的原因:有pointer机制,数据可以存储在任意位置,允许字段可以以任意顺序写入而不影响解码。但是一方面,在连续内存上容易因为误操作,导致在resize的时候留下hole,另一方面,Thrift没有类似于pointer的机制,故而对数据布局有着更严格的要求。这里有两个思路:坚持在连续内存上进行操作,并对用户使用提出严格要求:1.resize操作必须重新构建数据结构2.当存在结构体嵌套时,对字段写入顺序有着严格要求(可以想象为把一个存在嵌套的结构体从外往里展开,写入时需要按展开顺序写入),且因为Binary等TLV编码的关系,在每个嵌套开始写入时,需要用户主动声明(如StartWriteFieldX)。不完全在连续内存上操作,局部内存连续,可变字段则单独分配一块内存,既然内存不是完全连续的,自然也无法做到一次写操作便完成输出。为了尽可能接近一次写完数据的性能,我们采取了一种链式buffer的方案,一方面当可变字段resize时只需替换链式buffer的一个节点,无需像Cap&39;nProto的BinaryFormat是针对无拷贝序列化场景专门设计的,虽然每次Get时都会进行一次解码,但是解码代价非常小。而Thrift的协议(以Binary为例),没有类似于pointer的机制,当存在多个不定大小字段或者存在嵌套时,必须顺序解析而无法直接通过计算偏移拿到字段数据所在的位置,而每次Get都进行顺序解析的代价过于高昂。解决方案:我们在表示结构体的时候,除了记录结构体的buffer节点,还加了一个索引,里面记录了每个不定大小字段开始的buffer节点的指针。
下面是目前的无拷贝序列化方案与FastRead/Write,在4核下的极限性能对比测试:
测试结果概述:
小包场景,无序列化性能表现较差,约为FastWrite/FastRead的85%。大包场景,无序列化性能表现较好,4K以上的包较FastWrite/FastRead提升7%-40%。
后记
希望以上的分享能够对社区有所帮助。同时,我们也在尝试sharememory-basedIPC、io_uring、tcpzerocopy、RDMA等,更好地提升KiteX性能;重点优化同机、同容器的通讯场景。欢迎各位感兴趣的同学加入我们,共同建设Go语言生态!
参考资料
https://github.com/alecthomas/go_serialization_benchmarkshttps://capnproto.org/https://software.intel.com/content/www/us/en/develop/documentation/cpp-compiler-developer-guide-and-reference/top/compiler-reference/intrinsics/intrinsics-for-intel-advanced-vector-extensions-2/intrinsics-for-shuffle-operations-1/mm256-shuffle-epi8.html
字节跳动基础架构团队
字节跳动基础架构团队是支撑字节跳动旗下包括抖音、今日头条、西瓜视频、火山小视频在内的多款亿级规模用户产品平稳运行的重要团队,为字节跳动及旗下业务的快速稳定发展提供了保证和推动力。
公司内,基础架构团队主要负责字节跳动私有云建设,管理数以万计服务器规模的集群,负责数万台计算/存储混合部署和在线/离线混合部署,支持若干EB海量数据的稳定存储。
文化上,团队积极拥抱开源和创新的软硬件架构。我们长期招聘基础架构方向的同学,具体可参见job.bytedance.com(文末“阅读原文”),感兴趣可以联系邮箱:tech@bytedance.com,邮件标题:姓名-工作年限-基础架构。
欢迎关注「字节跳动技术团队」
简历投递联系邮箱「tech@bytedance.com」
关于本次jnd28开奖网站源码分享和28开奖下载的问题分享到这里就结束了,如果解决了您的问题,我们非常高兴。
