Commit 84ddb0b3 authored by zhujiashun's avatar zhujiashun

Add docs in rpc_in_depth and tools

Change-Id: I81f8562b9e2623479b77b45f00611c84e9c8c047
parent e9eea50e
This diff is collapsed.
bthread_id是一个特殊的同步结构,它可以互斥RPC过程中的不同环节,也可以O(1)时间内找到RPC上下文(即Controller)。注意,这里我们谈论的是bthread_id_t,不是bthread_t(bthread的tid),这个名字起的确实不太好,容易混淆。
具体来说,bthread_id解决的问题有:
- 在发送RPC过程中response回来了,处理response的代码和发送代码产生竞争。
- 设置timer后很快触发了,超时处理代码和发送代码产生竞争。
- 重试产生的多个response同时回来产生的竞争。
- 通过correlation_id在O(1)时间内找到对应的RPC上下文,而无需建立从correlation_id到RPC上下文的全局哈希表。
- 取消RPC。
上文提到的bug在其他rpc框架中广泛存在,下面我们来看下baidu-rpc是如何通过bthread_id解决这些问题的。
bthread_id包括两部分,一个是用户可见的64位id,另一个是对应的不可见的bthread::Id结构体。用户接口都是操作id的。从id映射到结构体的方式和baidu-rpc中的[其他结构](http://wiki.baidu.com/display/RPC/Memory+Management)类似:32位是内存池的位移,32位是version。前者O(1)时间定位,后者防止ABA问题。
bthread_id的接口不太简洁,有不少API:
- create
- lock
- unlock
- unlock_and_destroy
- join
- error
这么多接口是为了满足不同的使用流程。
- 发送request的流程:create -> lock -> ... register timer and send RPC ... -> unlock
- 接收response的流程:lock -> ..process response -> call done
\ No newline at end of file
类似[futex](http://man7.org/linux/man-pages/man2/futex.2.html),用于同步bthread和pthread的原语。bthread等待butex时不会阻塞所在的pthread,这就是butex和futex的关键区别。理解[butex代码](http://websvn.work.baidu.com/repos/public/show/trunk/bthread/bthread/butex.cpp)的正确性比较困难,但在bthread的其他代码中只需要关心[其接口](http://websvn.work.baidu.com/repos/public/show/trunk/bthread/bthread/butex.h),和futex一样,这是一个非常强大的积木。除了bthread_usleep基于独立线程[TimerThread](http://websvn.work.baidu.com/repos/public/show/trunk/bthread/bthread/timer_thread.h?revision=HEAD),大部分阻塞函数都依赖butex,比如bthread_mutex_t, bthread_cond_t, bthread_join等。butex中的超时事件也依赖[TimerThread](http://websvn.work.baidu.com/repos/public/show/trunk/bthread/bthread/timer_thread.h?revision=HEAD)
\ No newline at end of file
# 概述
一些场景希望同样的请求尽量落到一台机器上,比如访问缓存集群时,我们往往希望同一种请求能落到同一个后端上,以充分利用其上已有的缓存,不同的机器承载不同的稳定working set。而不是随机地散落到所有机器上,那样的话会迫使所有机器缓存所有的内容,最终由于存不下形成颠簸而表现糟糕。 我们都知道hash能满足这个要求,比如当有n台服务器时,输入x总是会发送到第hash(x) % n台服务器上。但当服务器变为m台时,hash(x) % n和hash(x) % m很可能都不相等,这会使得几乎所有请求的发送目的地都发生变化,如果目的地是缓存服务,所有缓存将失效,继而对原本被缓存遮挡的数据库或计算服务造成请求风暴,触发雪崩。一致性哈希是一种特殊的哈希算法,在增加服务器时,发向每个老节点的请求中只会有一部分转向新节点,从而实现平滑的迁移。[这篇论文](http://wiki.baidu.com/download/attachments/105311464/ConsistenHashingandRandomTreesDistributedCachingprotocolsforrelievingHotSpotsontheworldwideweb.pdf?version=1&modificationDate=1437363370000&api=v2)中提出了一致性hash的概念。
一致性hash满足以下四个性质:
- 平衡性 (Balance) : 每个节点被选到的概率是O(1/n)。
- 单调性 (Monotonicity) : 当新节点加入时, 不会有请求在老节点间移动, 只会从老节点移动到新节点。当有节点被删除时,也不会影响落在别的节点上的请求。
- 分散性 (Spread) : 当上游的机器看到不同的下游列表时(在上线时及不稳定的网络中比较常见), 同一个请求尽量映射到少量的节点中。
- 负载 (Load) : 当上游的机器看到不同的下游列表的时候, 保证每台下游分到的请求数量尽量一致。
# 实现方式
所有server的32位hash值在32位整数值域上构成一个环(Hash Ring),环上的每个区间和一个server唯一对应,如果一个key落在某个区间内, 它就被分流到对应的server上。
![img](http://wiki.baidu.com/download/attachments/105311464/image2015-7-31%2017%3A35%3A13.png?version=1&modificationDate=1438335312000&api=v2)
当删除一个server的, 它对应的区间会归属于相邻的server,所有的请求都会跑过去。当增加一个server时,它会分割某个server的区间并承载落在这个区间上的所有请求。单纯使用Hash Ring很难满足我们上节提到的属性,主要两个问题:
- 在机器数量较少的时候, 区间大小会不平衡。
- 当一台机器故障的时候, 它的压力会完全转移到另外一台机器, 可能无法承载。
为了解决这个问题,我们为每个server计算m个hash值,从而把32位整数值域划分为n*m个区间,当key落到某个区间时,分流到对应的server上。那些额外的hash值使得区间划分更加均匀,被称为Virtual Node。当删除一个server时,它对应的m个区间会分别合入相邻的区间中,那个server上的请求会较为平均地转移到其他server上。当增加server时,它会分割m个现有区间,从对应server上分别转移一些请求过来。
由于节点故障和变化不常发生, 我们选择了修改复杂度为O(n)的有序数组来存储hash ring,每次分流使用二分查找来选择对应的机器, 由于存储是连续的,查找效率比基于平衡二叉树的实现高。 线程安全性请参照[Double Buffered Data](http://wiki.baidu.com/display/RPC/Locality-aware+load+balancing#Locality-awareloadbalancing-DoublyBufferedData)章节.
# 使用方式
我们内置了分别基于murmurhash3和md5两种hash算法的实现, 使用要做两件事:
- 在Channel.Init 时指定*load_balancer_name*为 "c_murmurhash" 或 "c_md5"。
- 发起rpc时通过Controller::set_request_code()填入请求的hash code。
> request的hash算法并不需要和lb的hash算法保持一致,只需要hash的值域是32位无符号整数。由于memcache默认使用md5,访问memcached集群时请选择c_md5保证兼容性, 其他场景可以选择c_murmurhash以获得更高的性能和更均匀的分布。
\ No newline at end of file
一般有三种操作IO的方式:
- blocking IO: 发起IO操作后阻塞当前线程直到IO结束,标准的同步IO,如默认行为的posix [read](http://linux.die.net/man/2/read)[write](http://linux.die.net/man/2/write)
- non-blocking IO: 发起IO操作后不阻塞,用户可阻塞等待多个IO操作同时结束。non-blocking也是一种同步IO:“批量的同步”。如linux下的[poll](http://linux.die.net/man/2/poll),[select](http://linux.die.net/man/2/select), [epoll](http://linux.die.net/man/4/epoll),BSD下的[kqueue](https://www.freebsd.org/cgi/man.cgi?query=kqueue&sektion=2)
- asynchronous IO: 发起IO操作后不阻塞,用户得递一个回调待IO结束后被调用。如windows下的[OVERLAPPED](https://msdn.microsoft.com/en-us/library/windows/desktop/ms684342(v=vs.85).aspx) + [IOCP](https://msdn.microsoft.com/en-us/library/windows/desktop/aa365198(v=vs.85).aspx)。linux的native AIO只对文件有效。
linux一般使用non-blocking IO提高IO并发度。当IO并发度很低时,non-blocking IO不一定比blocking IO更高效,因为后者完全由内核负责,而read/write这类系统调用已高度优化,效率显然高于一般得多个线程协作的non-blocking IO。但当IO并发度愈发提高时,blocking IO阻塞一个线程的弊端便显露出来:内核得不停地在线程间切换才能完成有效的工作,一个cpu core上可能只做了一点点事情,就马上又换成了另一个线程,cpu cache没得到充分利用,另外大量的线程会使得依赖thread-local加速的代码性能明显下降,如tcmalloc,一旦malloc变慢,程序整体性能往往也会随之下降。而non-blocking IO一般由少量event dispatching线程和一些运行用户逻辑的worker线程组成,这些线程往往会被复用(换句话说调度工作转移到了用户态),event dispatching和worker可以同时在不同的核运行(流水线化),内核不用频繁的切换就能完成有效的工作。线程总量也不用很多,所以对thread-local的使用也比较充分。这时候non-blocking IO就往往比blocking IO快了。不过non-blocking IO也有自己的问题,它需要调用更多系统调用,比如[epoll_ctl](http://man7.org/linux/man-pages/man2/epoll_ctl.2.html),由于epoll实现为一棵红黑树,epoll_ctl并不是一个很快的操作,特别在多核环境下,依赖epoll_ctl的实现往往会面临棘手的扩展性问题。non-blocking需要更大的缓冲,否则就会触发更多的事件而影响效率。non-blocking还得解决不少多线程问题,代码比blocking复杂很多。
# 收消息
“消息”指从连接读入的有边界的二进制串,可能是来自上游client的request或来自下游server的response。baidu-rpc使用一个或多个[EventDispatcher](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/event_dispatcher.h)(简称为EDISP)等待任一fd发生事件。和常见的“IO线程”不同,EDISP不负责读取。IO线程的问题在于一个线程同时只能读一个fd,当多个繁忙的fd聚集在一个IO线程中时,一些读取就被延迟了。多租户、复杂分流算法,[Streaming RPC](http://wiki.baidu.com/pages/viewpage.action?pageId=152229270)等功能会加重这个问题。高负载下偶尔的长延时read也会拖慢一个IO线程中所有fd的读取,对可用性的影响幅度较大。
由于epoll的[一个bug](https://patchwork.kernel.org/patch/1970231/)及epoll_ctl较大的开销,EDISP使用Edge triggered模式。当收到事件时,EDISP给一个原子变量加1,只有当加1前的值是0时启动一个bthread处理对应fd上的数据。在背后,EDISP把所在的pthread让给了新建的bthread,使其有更好的cache locality,可以尽快地读取fd上的数据。而EDISP所在的bthread会被偷到另外一个pthread继续执行,这个过程即是bthread的work stealing调度。要准确理解那个原子变量的工作方式可以先阅读[atomic instructions](http://wiki.baidu.com/pages/viewpage.action?pageId=36886832),再看[Socket::StartInputEvent](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/socket.cpp)。这些方法使得baidu-rpc读取同一个fd时产生的竞争是[wait-free](http://en.wikipedia.org/wiki/Non-blocking_algorithm#Wait-freedom)的。
[InputMessenger](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/input_messenger.h)负责从fd上切割和处理消息,它通过用户回调函数理解不同的格式。Parse一般是把消息从二进制流上切割下来,运行时间较固定;Process则是进一步解析消息(比如反序列化为protobuf)后调用用户回调,时间不确定。InputMessenger会逐一尝试用户指定的多套回调,当某一个Parse成功切割下一个消息后,调用对应的Process。由于一个连接上往往只有一种消息格式,InputMessenger会记录下上次的选择,而避免每次都重复尝试。若一次从某个fd读取出n个消息(n > 1),InputMessenger会启动n-1个bthread分别处理前n-1个消息,最后一个消息则会在原地被Process。
可以看到,fd间和fd内的消息都会在baidu-rpc中获得并发,这使baidu-rpc非常擅长大消息的读取,在高负载时仍能及时处理不同来源的消息,减少长尾的存在。
# 发消息
"消息”指向连接写出的有边界的二进制串,可能是发向上游client的response或下游server的request。多个线程可能会同时向一个fd发送消息,而写fd又是非原子的,所以如何高效率地排队不同线程写出的数据包是这里的关键。baidu-rpc使用一种wait-free MPSC链表来实现这个功能。所有待写出的数据都放在一个单链表节点中,next指针初始化为一个特殊值(Socket::WriteRequest::UNCONNECTED)。当一个线程想写出数据前,它先尝试和对应的链表头(Socket::_write_head)做原子交换,返回值是交换前的链表头。如果返回值为空,说明它获得了写出的权利,它会在原地写一次数据。否则说明有另一个线程在写,它把next指针指向返回的头,那样正在写的线程之后会看到并写出这块数据。这套方法可以让写竞争是wait-free的,而获得写权利的线程虽然在原理上不是wait-free也不是lock-free,可能会被一个值仍为UNCONNECTED的节点锁定(这需要发起写的线程正好在原子交换后,在设置next指针前,仅仅一条指令的时间内被OS换出),但在实践中很少出现。在当前的实现中,如果获得写权利的线程一下子无法写出所有的数据,会启动一个KeepWrite线程继续写,直到所有的数据都被写出。这套逻辑非常复杂,大致原理如下图,细节请阅读[socket.cpp](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/socket.cpp)
![img](http://wiki.baidu.com/download/attachments/48480438/image2015-12-20%2019%3A0%3A23.png?version=1&modificationDate=1450609228000&api=v2)
由于baidu-rpc的写出总能很快地返回,调用线程可以更快地处理新任务,后台写线程也能每次拿到一批任务批量写出,在大吞吐时容易形成流水线效应而提高IO效率。
# Socket
和fd相关的数据均在[Socket](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/socket.h)中,是rpc最复杂的结构之一,这个结构的独特之处在于用64位的SocketId指代Socket对象以方便在多线程环境下使用fd。常用的三个方法:
- Create:创建Socket,并返回其SocketId。
- Address:取得id对应的Socket,包装在一个会自动释放的unique_ptr中(SocketUniquePtr),当Socket被SetFailed后,返回指针为空。只要Address返回了非空指针,其内容保证不会变化,直到指针自动析构。这个函数是wait-free的。
- SetFailed:标记一个Socket为失败,之后所有对那个SocketId的Address会返回空指针(直到健康检查成功)。当Socket对象没人使用后会被回收。这个函数是lock-free的。
可以看到Socket类似[shared_ptr](http://en.cppreference.com/w/cpp/memory/shared_ptr),SocketId类似[weak_ptr](http://en.cppreference.com/w/cpp/memory/weak_ptr),但Socket独有的SetFailed可以在需要时确保Socket不能被继续Address而最终引用计数归0,单纯使用shared_ptr/weak_ptr则无法保证这点,当一个server需要退出时,如果请求仍频繁地到来,对应Socket的引用计数可能迟迟无法清0而导致server无法退出。另外weak_ptr无法直接作为epoll的data,而SocketId可以。这些因素使我们设计了Socket,这个类的核心部分自14年10月完成后很少改动,非常稳定。
存储SocketUniquePtr还是SocketId取决于是否需要强引用。像Controller贯穿了RPC的整个流程,和Socket中的数据有大量交互,它存放的是SocketUniquePtr。epoll主要是提醒对应fd上发生了事件,如果Socket回收了,那这个事件是可有可无的,所以它存放了SocketId。由于SocketUniquePtr只要有效,其中的数据就不会变,这个机制使用户不用关心麻烦的race conditon和ABA problem,可以放心地对共享的fd进行操作。这种方法也规避了隐式的引用计数,内存的ownership明确,程序的质量有很好的保证。baidu-rpc中有大量的SocketUniquePtr和SocketId,它们确实简化了我们的开发。
事实上,Socket不仅仅用于管理原生的fd,它也被用来管理其他资源。比如SelectiveChannel中的每个Sub Channel都被置入了一个Socket中,这样SelectiveChannel可以像普通channel选择下游server那样选择一个Sub Channel进行发送。这个假Socket甚至还实现了健康检查。Streaming RPC也使用了Socket以复用wait-free的写出过程。
# The full picture
![img](http://wiki.baidu.com/download/attachments/48480438/image2015-12-26%2017%3A31%3A6.png?version=1&modificationDate=1451122271000&api=v2)
This diff is collapsed.
上游一般通过名字服务发现所有的下游节点,并通过多种负载均衡方法把流量分配给下游节点。当下游节点出现问题时,它可能会被隔离以提高负载均衡的效率。被隔离的节点定期被健康检查,成功后重新加入正常节点。
# 名字服务
在baidu-rpc中,[NamingService](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/naming_service.h)用于获得服务名对应的所有节点。一个直观的做法是定期调用一个函数以获取最新的节点列表。但这会带来一定的延时(定期调用的周期一般在若干秒左右),作为通用接口不太合适。特别当名字服务提供事件通知时(比如zk),这个特性没有被利用。所以我们反转了控制权:不是我们调用用户函数,而是用户在获得列表后调用我们的接口,对应[NamingServiceActions](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/naming_service.h)。当然我们还是得启动进行这一过程的函数,对应NamingService::RunNamingService。下面以三个实现解释这套方式:
- bns:没有事件通知,所以我们只能定期去获得最新列表,默认间隔是[5秒](http://brpc.baidu.com:8765/flags/ns_access_interval)。为了简化这类定期获取的逻辑,baidu-rpc提供了[PeriodicNamingService](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/periodic_naming_service.h) 供用户继承,用户只需要实现单次如何获取(GetServers)。获取后调用NamingServiceActions::ResetServers告诉框架。框架会对列表去重,和之前的列表比较,通知对列表有兴趣的观察者(NamingServiceWatcher)。这套逻辑会运行在独立的bthread中,即NamingServiceThread。一个NamingServiceThread可能被多个Channel共享,通过intrusive_ptr管理ownership。
- file:列表即文件。合理的方式是在文件更新后重新读取。[该实现](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/policy/file_naming_service.cpp)使用[FileWatcher](https://svn.baidu.com/public/trunk/common/base/file_watcher.h)关注文件的修改时间,当文件修改后,读取并调用NamingServiceActions::ResetServers告诉框架。
- list:列表就在服务名里(逗号分隔)。在读取完一次并调用NamingServiceActions::ResetServers后就退出了,因为列表再不会改变了。
如果用户需要建立这些对象仍然是不够方便的,因为总是需要一些工厂代码根据配置项建立不同的对象,鉴于此,我们把工厂类做进了框架,并且是非常方便的形式:
```
"protocol://service-name"
e.g.
bns://<node-name> # baidu naming service
file://<file-path> # load addresses from the file
list://addr1,addr2,... # use the addresses separated by comma
http://<url> # Domain Naming Service, aka DNS.
```
这套方式是可扩展的,实现了新的NamingService后在[global.cpp](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/global.cpp)中依葫芦画瓢注册下就行了,如下图所示:
![img](http://wiki.baidu.com/download/attachments/158717014/image2015-12-19%2016%3A59%3A8.png?version=1&modificationDate=1450515550000&api=v2)
看到这些熟悉的字符串格式,容易联想到ftp:// zk:// galileo://等等都是可以支持的。用户在新建Channel时传入这类NamingService描述,并能把这些描述写在各类配置文件中。
# 负载均衡
baidu-rpc中[LoadBalancer](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/load_balancer.h)从多个服务节点中选择一个节点,目前的实现见[负载均衡](http://wiki.baidu.com/pages/viewpage.action?pageId=213828685#id-创建和访问Client-负载均衡)
Load balancer最重要的是如何让不同线程中的负载均衡不互斥,解决这个问题的技术是[DoublyBufferedData](http://wiki.baidu.com/pages/viewpage.action?pageId=38012521#Locality-awareloadbalancing-DoublyBufferedData)
和NamingService类似,我们使用字符串来指代一个load balancer,在global.cpp中注册:
![img](http://wiki.baidu.com/download/attachments/158717014/image2015-12-19%2017%3A15%3A14.png?version=1&modificationDate=1450516515000&api=v2)
# 健康检查
对于那些无法连接却仍在NamingService的节点,baidu-rpc会定期连接它们,成功后对应的Socket将被”复活“,并可能被LoadBalancer选择上,这个过程就是健康检查。注意:被健康检查或在LoadBalancer中的节点一定在NamingService中。换句话说,只要一个节点不从NamingService删除,它要么是正常的(会被LoadBalancer选上),要么在做健康检查。
传统的做法是使用一个线程做所有连接的健康检查,baidu-rpc简化了这个过程:为需要的连接动态创建一个bthread专门做健康检查(Socket::HealthCheckThread)。这个线程的生命周期被对应连接管理。具体来说,当Socket被SetFailed后,健康检查线程就可能启动(如果SocketOptions.health_check_interval为正数的话):
- 健康检查线程先在确保没有其他人在使用Socket了后关闭连接。目前是通过对Socket的引用计数判断的。这个方法之所以有效在于Socket被SetFailed后就不能被Address了,所以引用计数只减不增。
- 定期连接直到远端机器被连接上,在这个过程中,如果Socket析构了,那么该线程也就随之退出了。
- 连上后复活Socket(Socket::Revive),这样Socket就又能被其他地方,包括LoadBalancer访问到了(通过Socket::Address)。
\ No newline at end of file
内存管理总是程序中的重要一环,在多线程时代,一个好的内存分配大都在如下两点间权衡:
- 线程间竞争少。内存分配的粒度大都比较小,对性能敏感,如果不同的线程在大多数分配时会竞争同一份资源或同一把锁,性能将会非常糟糕,原因无外乎和cache一致性有关,已被大量的malloc方案证明。
- 浪费的空间少。如果每个线程各申请各的,速度也许不错,但万一一个线程总是申请,另一个线程总是释放,这个方案就显然不靠谱了。所以线程之间总是要共享全局数据的,如何共享就是方案的关键了。
一般用户可以使用[tcmalloc](http://goog-perftools.sourceforge.net/doc/tcmalloc.html)[jemalloc](https://github.com/jemalloc/jemalloc)等成熟的内存分配方案,但这对于较为底层,关注性能长尾的应用是不够的。因为我们注意到:
- 大多数结构是等长的。
这个属性可以大幅简化内存分配的过程,获得比通用malloc更加稳定、快速的性能。baidu-rpc中的ResourcePool<T>和ObjectPool<T>即提供这类分配。
> 这篇文章不是鼓励用户使用ResourcePool<T>或ObjectPool<T>,事实上我们反对用户在程序中使用这两个类。因为”等长“的副作用是某个类型独占了一部分内存,这些内存无法再被其他类型使用,如果不加控制的滥用,反而会在程序中产生大量彼此隔离的内存分配体系,即浪费内存也不见得会有更好的性能。
# ResourcePool<T>
创建一个类型为T的对象并返回一个偏移量,这个偏移量可以在O(1)时间内转换为对象指针。这个偏移量相当于指针,但它的值在一般情况下小于2^32,所以我们可以把它作为64位id的一部分。对象可以被归还,但归还后对象并没有删除,也没有被析构,而是仅仅进入freelist。下次申请时可能会取到这种使用过的对象,需要重置后才能使用。当对象被归还后,通过对应的偏移量仍可以访问到对象,即ResourcePool只负责内存分配,并不解决ABA问题。但对于越界的偏移量,ResourcePool会返回空。
由于对象等长,ResourcePool通过批量分配和归还内存以避免全局竞争,并降低单次的开销。每个线程的分配流程如下:
1. 查看thread-local free block。如果还有free的对象,返回。没有的话步骤2。
2. 尝试从全局取一个free block,若取到的话回到步骤1,否则步骤3。
3. 从全局取一个block,返回其中第一个对象。
原理是比较简单的。工程实现上数据结构、原子变量、memory fence等问题会复杂一些。下面以bthread_t的生成过程说明ResourcePool是如何被应用的。
# ObjectPool<T>
这是ResourcePool<T>的变种,不在返回偏移量,而是直接返回对象指针。内部结构和ResourcePool类似,一些代码更加简单。对于用户来说,这就是一个多线程下的对象池,baidu-rpc里也是这么用的。比如Socket::Write中得把每个待写出的请求包装为WriteRequest,这个对象就是用ObjectPool<WriteRequest>分配的,相比通用的malloc更快、更稳定一些。
# 生成bthread_t
用户期望通过创建bthread获得更高的并发度,所以创建bthread必须很快。 在目前的实现中创建一个bthread的平均耗时小于200ns。如果每次都要从头创建,是不可能这么快的。创建过程更像是从一个bthread池子中取一个实例,我们又同时需要一个id来指代一个bthread,所以这儿正是ResourcePool的用武之地。bthread在代码中被称作Task,其结构被称为TaskMeta,定义在[task_meta.h](https://svn.baidu.com/public/trunk/bthread/bthread/task_meta.h)中,所有的TaskMeta由ResourcePool<TaskMeta>分配。
bthread的大部分函数都需要在O(1)时间内通过bthread_t访问到TaskMeta,并且当bthread_t失效后,访问应返回NULL以让函数做出返回错误。解决方法是:bthread_t由32位的版本和32位的偏移量组成。版本解决[ABA问题](http://en.wikipedia.org/wiki/ABA_problem),偏移量由ResourcePool<TaskMeta>分配。查找时先通过偏移量获得TaskMeta,再检查版本,如果版本不匹配,说明bthread失效了。注意:这只是大概的说法,在多线程环境下,即使版本相等,bthread仍可能随时失效,在不同的bthread函数中处理方法都是不同的,有些函数会加锁,有些则能忍受版本不相等。
![img](http://wiki.baidu.com/download/attachments/99588634/image2015-7-6%2016%3A5%3A53.png?version=1&modificationDate=1436169953000&api=v2)
这种id生成方式在baidu-rpc中应用广泛,baidu-rpc中的SocketId,bthread_id_t也是用类似的方法分配的。
# 栈
使用ResourcePool加快创建的副作用是:一个pool中所有bthread的栈必须是一样大的。这似乎限制了用户的选择,不过基于我们的观察,大部分用户并不关心栈的具体大小,而只需要两种大小的栈:尺寸普通但数量较少,尺寸小但数量众多。所以我们用不同的pool管理不同大小的栈,用户可以根据场景选择。两种栈分别对应属性BTHREAD_ATTR_NORMAL(栈默认为1M)和BTHREAD_ATTR_SMALL(栈默认为32K)。用户还可以指定BTHREAD_ATTR_LARGE,这个属性的栈大小和pthread一样,由于尺寸较大,bthread不会对其做任何caching,创建速度较慢。server默认使用BTHREAD_ATTR_NORMAL运行用户代码。
栈使用[mmap](http://linux.die.net/man/2/mmap)分配,bthread还会用mprotect分配4K的guard page以检测栈溢出。由于mmap和mprotect不能超过max_map_count,此值为内核参数,默认为65536,当bthread非常多后可能要调整此参数。另外当有很多bthread时,内存问题可能不仅仅是栈,也包括各类用户和系统buffer。
(go语言中的)goroutine在1.3前通过[segmented stacks](https://gcc.gnu.org/wiki/SplitStacks)动态地调整栈大小,发现有[hot split](https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ/pub)问题后换成了变长连续栈(类似于vector resizing,只适合内存托管的语言)。由于bthread基本只会在64位平台上使用,虚存空间庞大,对变长栈需求不明确。加上segmented stacks的性能有影响,bthread暂时没有变长栈的计划。
\ No newline at end of file
1. 每次CI前运行tools/switch_trunk确保baidu-rpc依赖的主要模块使用主干版本。依赖的模块有:public/common public/bvar public/iobuf public/bthread public/protobuf-json public/murmurhash public/mcpack2pb2
2. 每次CI前检查所有修改的文件。在svn下可用如下命令:`svn st | grep "^?.*\.\(cpp\|c\|cc\|h\|hpp\|sh\|py\|pl\|proto\|thrift\|java\)$"`。如有预期之外的文件改动,请咨询后再CI。
3. 每次CI前确认comake2和bcloud都可以无warning编译通过。
1. comake2编译方法:comake2 -P && make -sj8
2. bcloud编译方法:bcloud build
4. 每次CI前务必确认在gcc 4.8和3.4下都编译通过,最好也检查gcc 4.4下的编译情况。
1. 用gcc 4.8编译:在~/.bashrc中加入`export PATH=/opt/compiler/gcc-4.8.2/bin:$PATH`,重新登陆后有效。
2. 用gcc 3.4编译:一些老机器的/usr/bin/gcc默认是3.4。BCLOUD默认也是3.4(在2016/6/17是的)。
3. 用gcc 4.4编译:centos 6.3的/usr/bin/gcc默认是4.4。
5. 每次CI前运行单测通过,运行方式:进入src/baidu/rpc/test目录comake2 -P,如果报错则运行comake2 -UB拉依赖(拉完依赖要运行tools/switch_trunk把依赖模块再切回主干),make -sj8,运行所有形如test_*的可执行程序。
\ No newline at end of file
This diff is collapsed.
### 切换方法
bthread通过[boost.context](http://www.boost.org/doc/libs/1_56_0/libs/context/doc/html/index.html)切换上下文。[setjmp](http://en.wikipedia.org/wiki/Setjmp.h), [signalstack](http://linux.die.net/man/2/sigaltstack), [ucontext](http://en.wikipedia.org/wiki/Setcontext)也可以切换上下文,但boost.context是[最快的](http://www.boost.org/doc/libs/1_56_0/libs/context/doc/html/context/performance.html)
### 基本方法
调度bthread需要比上下文切换更多的东西。每个线程都有自己的[TaskGroup](http://websvn.work.baidu.com/repos/public/show/trunk/bthread/baidu/bthread/task_group.h),包含独立的[runqueue](http://websvn.work.baidu.com/repos/public/show/trunk/bthread/baidu/bthread/work_stealing_queue.h),它允许一个线程在一端push/pop,多个线程在另一端steal,这种结构被称作work stealing queue。bthread通过调用TaskGroup::sched或TaskGroup::sched_to放弃CPU,这些函数会先尝试pop自己的runqueue,若没有就偷另一个TaskGroup的runqueue(所有的TaskGroup由[TaskControl](http://websvn.work.baidu.com/repos/public/show/trunk/bthread/baidu/bthread/task_control.h?revision=HEAD)管理),还是没有就调用TaskControl::wait_task(),它在任一runqueue有新bthread时都会被唤醒。获得待运行bthread后TaskGroup会跳至其上下文。
新建bthread的模式和pthread有所不同:可以直接跳至新bthread的上下文,再调度原bthread,这相当于原bthread把自己的时间片让给了新bthread。当新bthread要做的工作比原bthread更紧急时,这可以让新bthread免去可能的调度排队,并保持cache locality。请注意,“调度原bthread"不意味着原bthread必须等到新bthread放弃CPU才能运行,通过调用TaskControl::signal_task(),它很可能在若干微秒后就会被偷至其他pthread worker而继续运行。当然,bthread也允许调度新bthread而让原bthread保持运行。这两种新建方式分别对应bthread_start_urgent()和bthread_start_background()函数。
### 相关工作
标准work stealing调度的过程是每个worker pthread都有独立的runqueue,新task入本地runqueue,worker pthread没事干后先运行本地的task,没有就随机偷另一个worker的task,还是没事就不停地sched_yield... 随机偷... sched_yield... 随机偷... 直到偷到任务。典型代表是intel的[Cilk](http://en.wikipedia.org/wiki/Cilk),近期改进有[BWS](http://jason.cse.ohio-state.edu/bws/)。实现较简单,但硬伤在于[sched_yield](http://man7.org/linux/man-pages/man2/sched_yield.2.html):当一个线程调用sched_yield后,OS对何时唤醒它没任何信息,一般便假定这个线程不急着用CPU,把它排到很低的优先级,比如在linux 2.6后的[CFS](http://en.wikipedia.org/wiki/Completely_Fair_Scheduler)中,OS把调用sched_yield的线程视作用光了时间配额,直到所有没有用完的线程运行完毕后才会调度它。由于在线系统中的低延时(及吞吐)来自线程之间的快速唤醒和数据传递,而这种算法中单个task往往无法获得很好的延时,更适合追求总体完成时间的离线科学计算。这种算法的另一个现实困扰是,在cpu profiler结果中总是有大量的sched_yield,不仅影响结果分析,也让用户觉得这类程序简单粗暴,占CPU特别多。
goroutine自1.1后使用的是另一种work stealing调度,大致过程是新goroutine入本地runqueue,worker先运行本地runqueue中的goroutine,如果没有没有就随机偷另一个worker的runqueue,和标准work stealing只偷一个不同,go会偷掉一半。如果也没有,则会睡眠,所以在产生新goroutine时可能要唤醒worker。由于在调用syscall时会临时放弃go-context,goroutine还有一个全局runqueue,大概每个context调度61次会去取一次全局runqueue,以防止starvation。虽然[设计文档](https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit#heading=h.mmq8lm48qfcw)中称其为scalable,但很难称之为scalable,最多“相比之前只有一个全局runqueue更scalable了”,其中有诸多全局竞争点,其对待starvation的态度也相当山寨,考虑到go的gc也不给力,基本上你不能期待goroutine的延时有什么保证,很可能会出现10ms甚至更长的延时,完全不能用于在线服务。
根据[这份材料](http://www.erlang.org/euc/08/euc_smp.pdf),erlang中的lightweight process也有一部分算法也是work stealing,另外每250毫秒对所有scheduler的负载做个统计,然后根据统计结果调整每个scheduler的负载,不过我觉得效果不会太好,因为这时间也实在太长了。另外他们看上去无法避免在还有待运行的任务时,一些scheduler却闲着。不过对于erlange这类本身[比较慢](http://benchmarksgame.alioth.debian.org/u64q/benchmark.php?test=all&lang=hipe&lang2=gpp&data=u64q)的语言,scheduler要求可能也不高,和我们对在线服务的要求不是一个数量级。
### 调度bthread
之所以对调度如此苛刻,在于我们希望**为每个请求建立bthread**。这意味着如下要求:
- 建立bthread必须非常快。但这不属于调度,在“创建和回收bthread”一节中已有阐述。
- 只要有worker pthread闲着并且可获得CPU,待运行的bthread应该在O(1)内开始运行。这看上去严苛,但却不可或缺,对于一个延时几十毫秒的检索,如果明明有worker pthread可用,但却因为调度的原因导致这个检索任务只能等着本线程的其他任务完成,对可用性(4个9)会有直接的影响。
- 延时不应高于内核的调度延时:大约是5微秒。否则又让用户陷入了两难:一些场景要用pthread,一些用bthread。我们希望用户轻松一点。
- 增加worker pthread数量可以线性增加调度能力。
- 建立后还未运行的bthread应尽量减少内存占用。请求很可能会大于worker pthread数,不可避免地在队列中积累,还没运行却需要占用大量资源,比如栈,是没道理的。
bthread的调度算法能满足这些要求。为了说明白这个过程,我们先解释[futex](http://man7.org/linux/man-pages/man2/futex.2.html)是如何工作的。futex有一系列接口,最主要的是两个:
- futex_wait(void* futex, int expected_value);
- futex_wake(void* futex, int num_wakeup);
*(int*)futex和expected_value相等时,futex_wait会阻塞,**判断相等并阻塞是原子的**。futex_wake则是唤醒阻塞于futex上的线程,最多num_wakeup个。(对齐的)有效地址都可以作为futex。一种配合方式如下:
```c++
int g_futex = 0; // shared by all threads
// Consumer thread
while (1) {
const int expected_val = g_futex;
while (there's sth to consume) {
consume(...);
}
futex_wait(&g_futex, expected_val);
}
// Producer thread
produce(...);
atomically_add1(g_futex); /*note*/
futex_wake(&g_futex, 1);
```
由于futex_wait的原子性,在Producer thread中原子地给g_futex加1后,至少有一个Consumer thread要么看到g_futex != expected_val而不阻塞,或从阻塞状态被唤醒,然后去消费掉所有的东西。比如Consumer thread在消费掉所有的东西后到重新futex_wait中这段时间,Producer thread可能产生了更多东西。futex把这个race condition简化成了两种可能:
- 这段时间内Producer thread执行了原子加(note那行)
- 这段时间内Producer thread还没有执行原子加。
第一种情况,Consumer thread中的futex_wait会看到g_futex != expected_val,立刻返回-1 (errno=EWOULDBLOCK)。第二种情况,Consumer会先阻塞,然后被原子加之后的futex_wake唤醒。总之Consumer总会看到这个新东西,而不会漏掉。我们不再说明更多细节,需要提醒的是,这个例子只是为了说明后续算法,如果你对[原子操作](http://wiki.baidu.com/pages/viewpage.action?pageId=36886832)还没有概念,**绝对**不要在工作项目中使用futex。
bthread的基本调度结构是每个worker pthread都有一个runqueue,新建的bthread压入本地runqueue,调度就是告诉一些闲着的worker,“我这有bthread待运行了,赶紧来偷”。如果我们没有提醒其他worker,在本地runqueue中的bthread必须等到本线程挨个执行排在它之前的bthread,直到把它弹出为止。这种只在一个worker pthread中轮换的方式就是fiber或N:1 threading。显而易见,这限制了并行度。为了充分发挥SMP的潜力,我们要唤醒其他worker来偷走bthread一起并行执行。把上面的代码中的consume换成"偷bthread”,produce换成“压入本地runqueue",就是最简单的bthread调度算法。
TODO:原理
下图中每个线程都在不停地建立(很快结束的)bthread,最多有24个这样的线程(24核机器)。使用三种不同方法的吞吐如下:
- futex(绿色) - 用一个全局futex管理所有闲置的worker pthread,在需要时signal这个futex。这个方法完全不能扩展,在2个线程时已经饱和(120万左右)。
- futex optimized with atomics(红色)- 仍然是一个全局futex,但是用原子变量做了缓冲,可以合并掉连续的futex_wake。这个方法已经可以扩展,最终能达到1300万左右的吞吐。这种方法的延时会高一些。
- new algorithm(蓝色)- 新算法有非常好的扩展性,不仅是斜的,而且斜率还在变大。这是由于新算法在激烈的全局竞争后会只修改thread local变量,所以越来越快,最终可以达到4500万吞吐。由于类似的原因,新算法的延时越忙反而越低,在不忙时和内核调度延时(5us)接近,在很忙时会降到0.2us。
![img](http://wiki.baidu.com/download/attachments/35959040/image2014-12-7%2021%3A37%3A22.png?version=1&modificationDate=1417959443000&api=v2)
\ No newline at end of file
# 常见线程模型
## 一个连接对应一个线程或进程
线程/进程处理来自绑定连接的消息,连接不断开线程/进程就不退。当连接数逐渐增多时,线程/进程占用的资源和上下文切换成本会越来越大,性能很差,这就是[C10K问题](http://en.wikipedia.org/wiki/C10k_problem)的来源。这两种方法常见于早期的web server,现在很少使用。
## 单线程reactor
[libevent](http://libevent.org/)[, ](http://en.wikipedia.org/wiki/Reactor_pattern)[libev](http://software.schmorp.de/pkg/libev.html)等event-loop库为典型,一般是由一个event dispatcher等待各类事件,待事件发生后原地调用event handler,全部调用完后等待更多事件,故为"loop"。实质是把多段逻辑按事件触发顺序交织在一个系统线程中。一个event-loop只能使用一个核,故此类程序要么是IO-bound,要么是逻辑有确定的较短的运行时间(比如http server),否则一个回调卡住就会卡住整个程序,容易产生高延时,在实践中这类程序非常不适合多人参与,一不注意整个程序就显著变慢了。event-loop程序的扩展性主要靠多进程。
单线程reactor的运行方式如下图所示:
![img](http://wiki.baidu.com/download/attachments/99588643/image2015-7-6%2016%3A3%3A42.png?version=1&modificationDate=1436169822000&api=v2)
## N:1线程库
[GNU Pth](http://www.gnu.org/software/pth/pth-manual.html), [StateThreads](http://state-threads.sourceforge.net/index.html)等为典型,一般是把N个用户线程映射入一个系统线程(LWP),同时只能运行一个用户线程,调用阻塞函数时才会放弃时间片,又称为[Fiber](http://en.wikipedia.org/wiki/Fiber_(computer_science))。N:1线程库与单线程reactor等价,只是事件回调被替换为了独立的栈和寄存器状态,运行回调变成了跳转至对应的上下文。由于所有的逻辑运行在一个系统线程中,N:1线程库不太会产生复杂的race condition,一些编码场景不需要锁。和event loop库一样,由于只能利用一个核,N:1线程库无法充分发挥多核性能,只适合一些特定的程序。不过这也使其减少了多核间的跳转,加上对独立signal mask的舍弃,上下文切换可以做的很快(100~200ns),N:1线程库的性能一般和event loop库差不多,扩展性也主要靠多进程。
## 多线程reactor
[kylin](http://websvn.work.baidu.com/repos/public/list/trunk/kylin/?revision=HEAD), [boost::asio](http://www.boost.org/doc/libs/1_56_0/doc/html/boost_asio.html)为典型。一般由一个或多个线程分别运行event dispatcher,待事件发生后把event handler交给一个worker thread执行。由于百度内以SMP机器为主,这种可以利用多核的结构更加合适,多线程交换信息的方式也比多进程更多更简单,所以往往能让多核的负载更加均匀。不过由于cache一致性的限制,多线程reactor模型并不能获得线性于核数的扩展性,在特定的场景中,粗糙的多线程reactor实现跑在24核上甚至没有精致的单线程reactor实现跑在1个核上快。reactor有proactor变种,即用异步IO代替event dispatcher,boost::asio[在windows下](http://msdn.microsoft.com/en-us/library/aa365198(VS.85).aspx)就是proactor。
多线程reactor的运行方式如下:
![img](http://wiki.baidu.com/download/attachments/99588643/image2015-7-6%2016%3A4%3A28.png?version=1&modificationDate=1436169869000&api=v2)
# 那我们还能改进什么呢?
## 扩展性并不好
理论上用户把逻辑都写成事件驱动是最好的,但实际上由于编码难度和可维护性的问题,用户的使用方式大都是混合的:回调中往往会发起同步操作,从而阻塞住worker线程使其无法去处理其他请求。一个请求往往要经过几十个服务,这意味着线程把大量时间花在了等待下游请求上。用户往往得开几百个线程以维持足够的吞吐,这造成了高强度的调度开销。另外为了简单,任务的分发大都是使用全局竞争的mutex + condition。当所有线程都在争抢时,效率显然好不到哪去。更好的办法是使用更多的任务队列和相应的的调度算法以减少全局竞争。
## 异步编程是困难的
异步编程中的流程控制对于专家也充满了陷阱。任何挂起操作(sleep一会儿,等待某事完成etc)都意味着用户需要显式地保存状态,并在回调函数中恢复状态。异步代码往往得写成状态机的形式。当挂起的位置较少时,这有点麻烦,但还是可把握的。问题在于一旦挂起发生在条件判断、循环、子函数中,写出这样的状态机并能被很多人理解和维护,几乎是不可能的,而这在分布式系统中又很常见,因为一个节点往往要对多个其他节点同时发起操作。另外如果恢复可由多种事件触发(比如fd有数据或超时了),挂起和恢复的过程容易出现race condition,对多线程编码能力要求很高。语法糖(比如lambda)可以让编码不那么“麻烦”,但无法降低难度。
## 异步编程不能使用[RAII](http://en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization)
更常见的方法是使用共享指针,这看似方便,但也使内存的ownership变得难以捉摸,如果内存泄漏了,很难定位哪里没有释放;如果segment fault了,也不知道哪里多释放了一下。大量使用引用计数的用户代码很难控制代码质量,容易长期在内存问题上耗费时间。如果引用计数还需要手动维护,保持质量就更难了(kylin就是这样),每次修改都会让维护者两难。没有RAII模式也使得使用同步原语更易出错,比如不能使用lock_guard;比如在callback之外lock,callback之内unlock;在实践中都很容易出错。
## cache bouncing
当event dispatcher把任务递给worker时,用户逻辑不得不从一个核跳到另一个核,相关的cpu cache必须同步过来,这是微秒级的操作,并不很快。如果worker能直接在event dispatcher所在的核上运行就更好了,因为大部分系统(在这个时间尺度下)并没有密集的事件流,尽快运行已有的任务的优先级高于event dispatcher获取新事件。另一个例子是收到response后最好在当前cpu core唤醒发起request的阻塞线程。
# M:N线程库
要满足我们期望的这些改善,一个选择是M:N线程库,即把M个用户线程映射入N个系统线程(LWP)。我们看看上面的问题在这个模型中是如何解决的:
- 每个系统线程往往有独立的runqueue,可能有一个或多个scheduler把用户线程分发到不同的runqueue,每个系统线程会优先运行自己runqueue中的用户线程,然后再做全局调度。这当然更复杂,但比全局mutex + condition有更好的扩展性。
- 虽然M:N线程库和多线程reactor是等价的,但同步的编码难度显著地低于事件驱动,大部分人都能很快掌握同步操作。
- 不用把一个函数拆成若干个回调,可以使用RAII。
- 从用户线程A切换为用户线程B时,也许我们可以让B在A所在的核上运行,而让A去其他核运行,从而使更高优先级的B更少受到cache miss的干扰。
实现全功能的M:N线程库是极其困难的,所以M:N线程库一直是个活跃的研究话题。我们这里说的M:N线程库特别针对编写网络服务,在这一前提下一些需求可以简化,比如没有时间片抢占,没有优先级等,即使有也以简单粗暴为主,无法和操作系统级别的实现相比。M:N线程库可以在用户态也可以在内核中实现,用户态的实现以新语言为主,比如GHC threads和goroutine,这些语言可以围绕线程库设计全新的API。而在主流语言中的实现往往得修改内核,比如[Windows UMS](https://msdn.microsoft.com/en-us/library/windows/desktop/dd627187(v=vs.85).aspx)。google SwicthTo虽然是1:1,但基于它可以实现M:N的效果。在使用上M:N线程库更类似于系统线程,需要用锁或消息传递保证代码的线程安全。
\ No newline at end of file
在几点几分做某件事是RPC框架的基本需求,这件事比看上去难。
让我们先来看看系统提供了些什么: posix系统能以[signal方式](http://man7.org/linux/man-pages/man2/timer_create.2.html)告知timer触发,不过signal逼迫我们使用全局变量,写[async-signal-safe](https://docs.oracle.com/cd/E19455-01/806-5257/gen-26/index.html)的函数,在面向用户的编程框架中,我们应当尽力避免使用signal。linux自2.6.27后能以[fd方式](http://man7.org/linux/man-pages/man2/timerfd_create.2.html)通知timer触发,这个fd可以放到epoll中和传输数据的fd统一管理。唯一问题是:这是个系统调用,且我们不清楚它在多线程下的表现。
为什么这么关注timer的开销?让我们先来看一下RPC场景下一般是怎么使用timer的:
- 在发起RPC过程中设定一个timer,在超时时间后取消还在等待中的RPC。几乎所有的RPC调用都有超时限制,都会设置这个timer。
- RPC结束前删除timer。大部分RPC都由正常返回的response导致结束,timer很少触发。
你注意到了么,在RPC中timer更像是”保险机制”,在大部分情况下都不会发挥作用,自然地我们希望它的开销越小越好。一个几乎不触发的功能需要两次系统调用似乎并不理想。那在应用框架中一般是如何实现timer的呢?谈论这个问题需要区分“单线程”和“多线程”:
- 在单线程框架中,比如以[libevent](http://libevent.org/)[, ](http://en.wikipedia.org/wiki/Reactor_pattern)[libev](http://software.schmorp.de/pkg/libev.html)为代表的eventloop类库,或以[GNU Pth](http://www.gnu.org/software/pth/pth-manual.html), [StateThreads](http://state-threads.sourceforge.net/index.html)为代表的coroutine / fiber类库中,一般是以[小顶堆](https://en.wikipedia.org/wiki/Heap_(data_structure))记录触发时间。[epoll_wait](http://man7.org/linux/man-pages/man2/epoll_wait.2.html)前以堆顶的时间计算出参数timeout的值,如果在该时间内没有其他事件,epoll_wait也会醒来,从堆中弹出已超时的元素,调用相应的回调函数。整个框架周而复始地这么运转,timer的建立,等待,删除都发生在一个线程中。只要所有的回调都是非阻塞的,且逻辑不复杂,这套机制就能提供基本准确的timer。不过就像[Threading Overview](http://wiki.baidu.com/pages/viewpage.action?pageId=99588643)中说的那样,这不是RPC的场景。
- 在多线程框架中,任何线程都可能被用户逻辑阻塞较长的时间,我们需要独立的线程实现timer,这种线程我们叫它TimerThread。一个非常自然的做法,就是使用用锁保护的小顶堆。当一个线程需要创建timer时,它先获得锁,然后把对应的时间插入堆,如果插入的元素成为了最早的,唤醒TimerThread。TimerThread中的逻辑和单线程类似,就是等着堆顶的元素超时,如果在等待过程中有更早的时间插入了,自己会被插入线程唤醒,而不会睡过头。这个方法的问题在于每个timer都需要竞争一把全局锁,操作一个全局小顶堆,就像在其他文章中反复谈到的那样,这会触发cache bouncing。同样数量的timer操作比单线程下的慢10倍是非常正常的,尴尬的是这些timer基本不触发。
我们重点谈怎么解决多线程下的问题。
一个惯例思路是把timer的需求散列到多个TimerThread,但这对TimerThread效果不好。注意我们上面提及到了那个“制约因素”:一旦插入的元素是最早的,要唤醒TimerThread。假设TimerThread足够多,以至于每个timer都散列到独立的TImerThread,那么每次它都要唤醒那个TimerThread。 “唤醒”意味着触发linux的调度函数,触发上下文切换。在非常流畅的系统中,这个开销大约是3-5微秒,这可比抢锁和同步cache还慢。这个因素是提高TimerThread扩展性的一个难点。多个TimerThread减少了对单个小顶堆的竞争压力,但同时也引入了更多唤醒。
另一个难点是删除。一般用id指代一个Timer。通过这个id删除Timer有两种方式:1.抢锁,通过一个map查到对应timer在小顶堆中的位置,定点删除,这个map要和堆同步维护。2.通过id找到Timer的内存结构,做个标记,留待TimerThread自行发现和删除。第一种方法让插入逻辑更复杂了,删除也要抢锁,线程竞争更激烈。第二种方法在小顶堆内留了一大堆已删除的元素,让堆明显变大,插入和删除都变慢。
第三个难点是TimerThread不应该经常醒。一个极端是TimerThread永远醒着或以较高频率醒过来(比如每1ms醒一次),这样插入timer的线程就不用负责唤醒了,然后我们把插入请求散列到多个堆降低竞争,问题看似解决了。但事实上这个方案提供的timer精度较差,一般高于2ms。你得想这个TimerThread怎么写逻辑,它是没法按堆顶元素的时间等待的,由于插入线程不唤醒,一旦有更早的元素插入,TimerThread就会睡过头。它唯一能做的是睡眠固定的时间,但这和现代OS scheduler的假设冲突:频繁sleep的线程的优先级最低。在linux下的结果就是,即使只sleep很短的时间,最终醒过来也可能超过2ms,因为在OS看来,这个线程不重要。一个高精度的TimerThread有唤醒机制,而不是定期醒。
另外,更并发的数据结构也难以奏效,感兴趣的同学可以去搜索"concurrent priority queue"或"concurrent skip list",这些数据结构一般假设插入的数值较为散开,所以可以同时修改结构内的不同部分。但这在RPC场景中也不成立,相互竞争的线程设定的时间往往聚集在同一个区域,因为程序的超时大都是一个值,加上当前时间后都差不多。
这些因素让TimerThread的设计相当棘手。由于大部分用户的qps较低,不足以明显暴露这个扩展性问题,在r31791前我们一直沿用“用一把锁保护的TimerThread”。TimerThread是baidu-rpc在默认配置下唯一的高频竞争点,这个问题是我们一直清楚的技术债。随着baidu-rpc在高qps系统中应用越来越多,是时候解决这个问题了。r31791后的TimerThread解决了上述三个难点,timer操作几乎对RPC性能没有影响,我们先看下性能差异。
> 在示例程序example/mutli_threaded_echo_c++中,r31791后TimerThread相比老TimerThread在24核E5-2620上(超线程),以50个bthread同步发送时,节省4%cpu(差不多1个核),qps提升10%左右;在400个bthread同步发送时,qps从30万上升到60万。新TimerThread的表现和完全关闭超时时接近。
那新TimerThread是如何做到的?
- 一个TimerThread而不是多个。
- 创建的timer散列到多个Bucket以降低线程间的竞争,默认12个Bucket。
- Bucket内不使用小顶堆管理时间,而是链表 + nearest_run_time字段,当插入的时间早于nearest_run_time时覆盖这个字段,之后去和全局nearest_run_time(和Bucket的nearest_run_time不同)比较,如果也早于这个时间,修改并唤醒TimerThread。链表节点在锁外使用[ResourcePool](http://wiki.baidu.com/display/RPC/Memory+Management)分配。
- 删除时通过id直接定位到timer内存结构,修改一个标志,timer结构总是由TimerThread释放。
- TimerThread被唤醒后首先把全局nearest_run_time设置为几乎无限大(max of int64),然后取出所有Bucket内的链表,并把Bucket的nearest_run_time设置为几乎无限大(max of int64)。TimerThread把未删除的timer插入小顶堆中维护,这个堆就它一个线程用。在每次运行回调或准备睡眠前都会检查全局nearest_run_time, 如果全局更早,说明有更早的时间加入了,重复这个过程。
这里勾勒了TimerThread的大致工作原理,工程实现中还有不少细节问题,具体请阅读[timer_thread.h](https://svn.baidu.com/public/trunk/bthread/bthread/timer_thread.h)[timer_thread.cpp](https://svn.baidu.com/public/trunk/bthread/bthread/timer_thread.cpp)
这个方法之所以有效:
- Bucket锁内的操作是O(1)的,就是插入一个链表节点,临界区很小。节点本身的内存分配是在锁外的。
- 由于大部分插入的时间是递增的,早于Bucket::nearest_run_time而参与全局竞争的timer很少。
- 参与全局竞争的timer也就是和全局nearest_run_time比一下,临界区很小。
- 和Bucket内类似,极少数Timer会早于全局nearest_run_time并去唤醒TimerThread。唤醒也在全局锁外。
- 删除不参与全局竞争。
- TimerThread自己维护小顶堆,没有任何cache bouncing,效率很高。
- TimerThread醒来的频率大约是RPC超时的倒数,比如超时=100ms,TimerThread一秒内大约醒10次,已经最优。
至此baidu-rpc在默认配置下不再有全局竞争点,在400个线程同时运行时,profiling也显示几乎没有对锁的等待。
下面是一些和linux下时间管理相关的知识:
- epoll_wait的超时精度是毫秒,较差。pthread_cond_timedwait的超时使用timespec,精度到纳秒,一般是60微秒左右的延时。
- 出于性能考虑,TimerThread使用wall-time,而不是单调时间,可能受到系统时间调整的影响。具体来说,如果在测试中把系统时间往前或往后调一个小时,程序行为将完全undefined。未来可能会让用户选择单调时间。
- 在cpu支持nonstop_tsc和constant_tsc的机器上,baidu-rpc和bthread会优先使用基于rdtsc的cpuwide_time_us。那两个flag表示rdtsc可作为wall-time使用,不支持的机器上会转而使用较慢的内核时间。我们的机器(Intel Xeon系列)大都有那两个flag。rdtsc作为wall-time使用时是否会受到系统调整时间的影响,未测试不清楚。
\ No newline at end of file
# AddressSanitizer介绍
​ AddressSanitizer最初由google研发,简称asan, 用于运行时检测C/C++程序中的内存错误,相比较传统工具如valgind,运行速度快,检测到错误之后,输出信息非常详细,可以通过add2line符号化输出,从而直接定位到代码行,方便快速的定位问题;
官方doc: <http://clang.llvm.org/docs/AddressSanitizer.html>
# AddressSanitizer可以检测的错误类型
- 堆、栈及全局变量的越界访问;
- use-after-free;
- use-after-return;
- double-free, invalid free;
- 内存泄露;
# AddressSanitizer支持的平台
- Linux i386/x86_64
- OS X 10.7 - 10.11 (i386/x86_64)
- iOS Simulator
- Android ARM
- FreeBSD i386/x86_64
# AddressSanitizer如何使用
## **4.1环境配置**
- gcc4.8及以上版本 (gcc4.8已经集成了asan功能);
- asan不支持tcmalloc,所以代码中请确保关闭该功能;
## **4.2 使用方法(针对C++)**
- 在COMAKE文件中添加asan编译参数: -fPIC -fsanitize=address -fno-omit-frame-pointer
- 在COMAKE文件中添加asan链接参数:-lasan
# 使用示例
1. ** **为了验证asan的功能是否生效,我们手动在测试代码client.cpp的中添加了内存越界访问的错误代码,该代码中依赖baidu-rpc生成的静态链接库libbdrpc.a;
2. 预期:启动client及server之后,输出内存越界错误信息,表明asan的配置已经生效;
## **5.1 在测试代码client.cpp中添加内存越界代码**
** **在测试代码client.cpp中添加以下内存越界代码:
​ ![img](http://wiki.baidu.com/download/attachments/120898866/mc.png?version=1&modificationDate=1436440081000&api=v2)
## **5.2 使用asan检测测试代码及baidu-rpc内存错误**
- 在baidu-rpc源码及测试代码client.cpp中,修改COMAKE文件,添加asan编译及链接的参数:
​ ![img](http://wiki.baidu.com/download/attachments/120898866/22.png?version=1&modificationDate=1436440186000&api=v2)
- 保存之后,执行comake2 -UB && comake2 && make -j10,生成静态链接库 libbdrpc.a;
- 运行测试代码的可执行文件echo_client及echo_server,输出内存越界的错误提示,表明环境设置成功;
![img](http://wiki.baidu.com/download/attachments/120898866/cc.png?version=1&modificationDate=1436440721000&api=v2)
- 使用addr2line符号化输出,直接定位到代码行,根据个人需求,可以直接使用addr2line,也可以写脚本实现:
![img](http://wiki.baidu.com/download/attachments/120898866/tt.png?version=1&modificationDate=1436440921000&api=v2)
- 以上则表明环境配置成功,如果代码中有内存越界等问题的话,asan会检测出来,并直接输出到标准错误;
\ No newline at end of file
可代替[ab](https://httpd.apache.org/docs/2.2/programs/ab.html)测试http server极限性能。ab功能较多但年代久远,有时本身可能会成为瓶颈。benchmark_http基本上就是一个baidu-rpc http client,性能很高,功能较少,一般压测够用了。
使用方法:
首先你得[下载和编译](http://wiki.baidu.com/display/RPC/Getting+Started)了baidu-rpc源码,然后去example/http_c++目录,comake2 -P && make -sj4,成功后应该能看到benchmark_http。
\ No newline at end of file
## 获取脚本
你需要[public/baidu-rpc/tools](http://websvn.work.baidu.com/repos/public/list/trunk/baidu-rpc/tools)目录下的makeproj, makeproj_template和shflags。它们可以被拷贝其他地方。
## 使用makeproj
```
$ ./makeproj -h
USAGE: ./makeproj [flags] args
flags:
--path: Repository <path> of your project. e.g. app/ecom/fcr/my_project (default: '.')
--workroot: Where to put your code tree (default: '')
--namespace: namespace of your code (default: 'example')
--port: default port of your server (default: 9000)
-h,--[no]help: show this help (default: false)
```
以~/jpaas_test为根目录建立代码路径为app/ecom/fcr/fancy_project的项目。(目录会自动建立)
```
$ makeproj --path app/ecom/fcr/fancy_project --workroot ~/jpaas_test
```
进入新建立的模块目录:
```
$ cd ~/jpaas_test/app/ecom/fcr/fancy_project/
$ ls
build.sh COMAKE fancy_project_client.cpp fancy_project.proto fancy_project_server.cpp jpaas_control Release
```
运行build.sh会下载依赖模块、编译、并生成output/bin/<server>.tar.gz,它可以部署至jpaas。如下图所示:
```
$ ls output/bin
fancy_project_client fancy_project_server fancy_project_server.tar.gz
```
\ No newline at end of file
parallel_http能同时访问大量的http服务(几万个),适合在命令行中查询线上所有server的内置信息,供其他工具进一步过滤和聚合。curl很难做到这点,即使多个curl以后台的方式运行,并行度一般也只有百左右,访问几万台机器需要等待极长的时间。
\ No newline at end of file
# 1 背景介绍
目 前公司的rpc框架有baidu-rpc、hulu、sofa、nova等,很多的业务都是基于这些框架搭建服务的,但是如果依赖的下游未ready的情 况下,很难开展联调测试,目前在进行功能验证时,只能人工的写一些mock代码来模拟下游,这种方式的代码复用率低,不同产品测试之间无法共享使用。此 外,pbrpc的模块的异常测试往往也是需要修改接口代码,改变接口行为,模拟异常,测试很难自动化;pbrpc mockserver就是解决在这些问题,具体的适用场景:
使用场景:
1. **模块功能验证**: 涉及上下游模块联调测试,但依赖的下游未ready情况下,可以快速的用pbprc_mockserver,模拟下游,测试模块自身功能;
2. **异常测试**:下游server的异常难构造,可以通过pbrpc_mockserver 来定制预期的response,构造各种异常场景、异常数据等;
# 2 使用示例
1. 获取工具:svn co <https://svn.baidu.com/com-test/trunk/public/baidu-rpc/pbrpc_service_tools/pbrpc_mockserver/>
2. 生成一个简单的echo服务的mockserver,执行如下指令
```python
./gen_mock_server.py -s example.EchoService -m Echo -p .proto/echo.proto
```
- 当前data目录生成,json格式的输入和输出文件,**用户可以修改json 来自定义 response**
```bash
ll data/
total 12
-rw-rw-r-- 1 work work 26 Dec 23 20:26 example.EchoService.Echo.input.json
-rw-rw-r-- 1 work work 26 Dec 23 20:26 example.EchoService.Echo.output.json
-rw-rw-r-- 1 work work 202 Dec 23 20:26 example.EchoService.info
```
- 在src/mockserver/ 目录下生成mock 代码
3. 执行sh bulid.sh 编译生成mockserver
4. 启动mockserver ./mockserver --port=9999 &
# 3 实现介绍
## 3.1 内部框架
![img](http://wiki.baidu.com/download/attachments/105293828/image2014-12-22%2019%3A53%3A41.png?version=1&modificationDate=1433828648000&api=v2)
## 3.2 功能详解
- ### 接收用户注册proto & service,生成response模版,支持用户自定义
用 户提供rpc的proto文件,并且提供需要mock的service name,二者缺一不可。 使用protobuf自带的protoc工具 基于proto文件生成c语言文件;根据service name来获取service的method,method的输入输出类型,根据输出类型获取响应的结构,生成用户可读的json格式的响应文件。 用户根据响应json文件的模板,自定义响应各个字段的内容;
模块工作流程如下:
1. Proto文件使用protoc生成c语言描述文件,编译进mockserver proto解析系统;
2. Mockserver proto解析系统根据service name,解析service中的各个method;
3. 根据method解析method的响应message类型;
4. 根据响应message类型,使用递归的方式遍历message的各个字段,包括嵌套的message
5. 生成key-value对形式的json格式的响应类型
- ### mockserver 源码的自动生成:
根据用户提供的proto,和用户需要mock的service,基于baidu-rpc的通用server的模版,自动生成指定service的mockserver源码。
- ### response自动填充功能:
Mockserver模块负责解析用户请求,根据用户请求的rpc协议,解析出用户请求的service及对应的method,根据proto及method的得出需要响应的message类型,工作流程如下:
1. Mockserver接收用户请求;
2. 解析用户请求,根据用户请求类型,分别使用不同的协议处理handler处理;
3. 解析出请求的service,及对应的method;
4. 根据method解析出request,并得出response的类型;
5. 根据response的类型,结合用户自定义的response类型的json串,填充response结构并转换为pb格式;
6. 组装响应,发送给客户端,完成server mock。
\ No newline at end of file
rpc_press无需写代码就压测各种rpc server,目前支持的协议有:
- 标准协议
- hulu-pbrpc协议
- sofa-pbrpc协议
- public/pbrpc协议(老版pbrpc协议)
- nova-pbrpc协议
# 获取工具
在终端中运行如下命令即可编译出最新版baidu-rpc包含的rpc_press工具.
`PREVDIR=`pwd` && TEMPDIR=`mktemp -d -t build_rpc_press.XXXXXXXXXX` && mkdir $TEMPDIR/public && cd $TEMPDIR/public && svn co https://svn.baidu.com/public/trunk/baidu-rpc && cd baidu-rpc && comake2 -UB -J8 -j8 && comake2 -P && make -sj8 && cd tools/rpc_press && comake2 -P && make -sj8 && cp -f ./rpc_press $PREVDIR && cd $PREVDIR; rm -rf $TEMPDIR`
编译完成后,rpc_press就会出现在当前目录下。如果编译出错,看[Getting Started](http://wiki.baidu.com/pages/viewpage.action?pageId=71337200)
也可以从[agile](http://agile.baidu.com/#/release/public/baidu-rpc)上获取产出,下面是获取版本r34466中的rpc_press的命令:
`wget -r -nH --level=0 --cut-dirs=8 getprod@buildprod.scm.baidu.com:/temp/data/prod-64/public/baidu-rpc/d92a9fac91892a5f4784fc105e493933/r34466/output/bin/rpc_press --user getprod --password getprod --preserve-permissions`
在CentOS 6.3上如果出现找不到libssl.so.4的错误,可执行`ln -s /usr/lib64/libssl.so.6 libssl.so.4临时解决`
# 发压力
rpc_press会动态加载proto文件,无需把proto文件编译为c++源文件。rpc_press会加载json格式的输入文件,转为pb请求,发向server,收到pb回复后如有需要会转为json并写入用户指定的文件。
rpc_press的所有的选项都来自命令行参数,而不是配置文件.
如下的命令向下游0.0.0.0:8002用标准协议重复发送两个pb请求,分别转自'{"message":"hello"}和'{"message":"world"},持续压力直到按ctrl-c,qps为100。
json也可以写在文件中,假如./input.json包含了上述两个请求,-input=./input.json也是可以的。
必需参数:
- -proto:指定相关的proto文件名。
- -method:指定方法名,形式必须是package.service.method。
- -server:当-lb_policy为空时,是服务器的ip:port;当-lb_policy不为空时,是集群地址,比如bns://node-name, file://server_list等等。具体见[名字服务](http://wiki.baidu.com/pages/viewpage.action?pageId=213828685#id-创建和访问Client-名字服务)
- -input: 指定json请求或包含json请求的文件。r32157后json间不需要分隔符,r32157前json间用分号分隔。
可选参数:
- -inc: 包含被import的proto文件的路径。rpc_press默认支持import目录下的其他proto文件,但如果proto文件在其他目录,就要通过这个参数指定,多个路径用分号(;)分隔。
- -lb_policy: 指定负载均衡算法,默认为空,可选项为: rr random la c_murmurhash c_md5,具体见[负载均衡](http://wiki.baidu.com/pages/viewpage.action?pageId=213828685#id-创建和访问Client-负载均衡)
- -timeout_ms: 设定超时,单位是毫秒(milliseconds),默认是1000(1秒)
- -max_retry: 最大的重试次数,默认是3, 一般无需修改. baidu-rpc的重试行为具体请见[这里](http://wiki.baidu.com/pages/viewpage.action?pageId=213828685#id-创建和访问Client-重试).
- -protocol: 连接server使用的协议,可选项见[协议](http://wiki.baidu.com/pages/viewpage.action?pageId=213828685#id-创建和访问Client-协议), 默认是baidu_std(标准协议)
- -connection_type: 连接方式,可选项为: single pooled short,具体见[连接方式](http://wiki.baidu.com/pages/viewpage.action?pageId=213828685#id-创建和访问Client-连接方式)。默认会根据协议自动选择,无需指定.
- -output: 如果不为空,response会转为json并写入这个文件,默认为空。
- -duration:大于0时表示发送这么多秒的压力后退出,否则一直发直到按ctrl-c或进程被杀死。默认是0(一直发送)。
- -qps:大于0时表示以这个压力发送,否则以最大速度(自适应)发送。默认是100。
- -dummy_port:修改dummy_server的端口,默认是8888
常用的参数组合:
- 向下游0.0.0.0:8002、用标准协议重复发送./input.json中的所有请求,持续压力直到按ctrl-c,qps为100。
./rpc_press -proto=echo.proto -method=example.EchoService.Echo -server=0.0.0.0:8002 -input=./input.json -qps=100
- 以round-robin分流算法向bns://node-name代表的所有下游机器、用标准协议重复发送两个pb请求,持续压力直到按ctrl-c,qps为100。
./rpc_press -proto=echo.proto -method=example.EchoService.Echo -server=bns://node-name -lb_policy=rr -input='{"message":"hello"} {"message":"world"}' -qps=100
- 向下游0.0.0.0:8002、用hulu协议重复发送两个pb请求,持续压力直到按ctrl-c,qps为100。
./rpc_press -proto=echo.proto -method=example.EchoService.Echo -server=0.0.0.0:8002 -protocol=hulu_pbrpc -input='{"message":"hello"} {"message":"world"}' -qps=100
- 向下游0.0.0.0:8002、用标准协议重复发送两个pb请求,持续最大压力直到按ctrl-c。
./rpc_press -proto=echo.proto -method=example.EchoService.Echo -server=0.0.0.0:8002 -input='{"message":"hello"} {"message":"world"}' -qps=0
- 向下游0.0.0.0:8002、用标准协议重复发送两个pb请求,持续最大压力10秒钟。
./rpc_press -proto=echo.proto -method=example.EchoService.Echo -server=0.0.0.0:8002 -input='{"message":"hello"} {"message":"world"}' -qps=0 -duration=10
- echo.proto中import了另一个目录下的proto文件
./rpc_press -proto=echo.proto -inc=<another-dir-with-the-imported-proto> -method=example.EchoService.Echo -server=0.0.0.0:8002 -input='{"message":"hello"} {"message":"world"}' -qps=0 -duration=10
rpc_press启动后会默认在8888端口启动一个dummy server,用于观察rpc_press本身的运行情况:
```
./rpc_press -proto=latest_baidu_rpc/public/baidu-rpc/example/multi_threaded_echo_c++/echo.proto -service=example.EchoService -method=Echo -server=0.0.0.0:8002 -input=./input.json -duration=0 -qps=100
TRACE: 01-30 16:10:04: * 0 src/baidu/rpc/server.cpp:733] Server[dummy_servers] is serving on port=8888.
TRACE: 01-30 16:10:04: * 0 src/baidu/rpc/server.cpp:742] Check out http://db-rpc-dev00.db01.baidu.com:8888 in your web browser.</code>
```
dummy_server启动时会在终端打印日志,一般按住ctrl点击那个链接可以直接打开对应的内置服务页面,就像这样:
![img](http://wiki.baidu.com/download/attachments/97645422/image2016-1-30%2016%3A16%3A39.png?version=1&modificationDate=1454141816000&api=v2)
切换到vars页面,在Search框中输入rpc_press可以看到当前压力的延时分布情况:
![img](http://wiki.baidu.com/download/attachments/97645422/image2016-1-30%2016%3A14%3A59.png?version=1&modificationDate=1454141716000&api=v2)
你可以通过-dummy_port参数修改dummy_server的端口,但请确保端口在8000到8999范围内,否则总是无法在浏览器中访问。
如果你无法打开浏览器,命令行中也会定期打印信息:
```
2016/01/30-16:19:01 sent:101 success:101 error:0 total_error:0 total_sent:28379
2016/01/30-16:19:02 sent:101 success:101 error:0 total_error:0 total_sent:28480
2016/01/30-16:19:03 sent:101 success:101 error:0 total_error:0 total_sent:28581
2016/01/30-16:19:04 sent:101 success:101 error:0 total_error:0 total_sent:28682
2016/01/30-16:19:05 sent:101 success:101 error:0 total_error:0 total_sent:28783
2016/01/30-16:19:06 sent:101 success:101 error:0 total_error:0 total_sent:28884
2016/01/30-16:19:07 sent:101 success:101 error:0 total_error:0 total_sent:28985
2016/01/30-16:19:08 sent:101 success:101 error:0 total_error:0 total_sent:29086
2016/01/30-16:19:09 sent:101 success:101 error:0 total_error:0 total_sent:29187
2016/01/30-16:19:10 sent:101 success:101 error:0 total_error:0 total_sent:29288
[Latency]
avg 122 us
50% 122 us
70% 135 us
90% 161 us
95% 164 us
97% 166 us
99% 172 us
99.9% 199 us
99.99% 199 us
max 199 us
```
上方的字段含义应该是自解释的,在此略过。下方是延时信息,第一项"avg"是10秒内的平均延时,最后一项"max"是10秒内的最大延时,其余以百分号结尾的则代表延时分位值,即有左侧这么多比例的请求延时小于右侧的延时(单位微秒)。一般性能测试需要关注99%之后的长尾区域。
# FAQ
**Q: 如果下游是基于j-protobuf框架的服务模块,压力工具该如何配置?**
A:因为协议兼容性问题,启动rpc_press的时候需要带上-baidu_protocol_use_fullname=false
\ No newline at end of file
r31658后,baidu-rpc能随机地把一部分请求写入一些文件中,并通过rpc_replay工具回放。目前支持的协议有:baidu_std, hulu_pbrpc, sofa_pbrpc。
# 获取工具
在终端中运行如下命令即可编译出最新版baidu-rpc包含的rpc_replay工具.
`PREVDIR=`pwd` && TEMPDIR=`mktemp -d -t build_rpc_replay.XXXXXXXXXX` && mkdir $TEMPDIR/public && cd $TEMPDIR/public && svn co https://svn.baidu.com/public/trunk/baidu-rpc && cd baidu-rpc && comake2 -UB -J8 -j8 && comake2 -P && make -sj8 && cd tools/rpc_replay && comake2 -P && make -sj8 && cp -f ./rpc_replay $PREVDIR && cd $PREVDIR; rm -rf $TEMPDIR`
编译完成后,rpc_press就会出现在当前目录下。如果编译出错,看[Getting Started](http://wiki.baidu.com/pages/viewpage.action?pageId=71337200)
也可以从[agile](http://agile.baidu.com/#/release/public/baidu-rpc)上获取产出,下面是获取版本r34466中的rpc_replay的命令:
`wget -r -nH --level=0 --cut-dirs=8 getprod@buildprod.scm.baidu.com:/temp/data/prod-64/public/baidu-rpc/d92a9fac91892a5f4784fc105e493933/r34466/output/bin/rpc_replay --user getprod --password getprod --preserve-permissions`
在CentOS 6.3上如果出现找不到libssl.so.4的错误,可执行`ln -s /usr/lib64/libssl.so.6 libssl.so.4临时解决`
# 采样
baidu-rpc通过如下flags打开和控制如何保存请求,包含(R)后缀的flag都可以动态设置。
![img](http://wiki.baidu.com/download/attachments/158707916/image2016-2-3%2017%3A39%3A47.png?version=1&modificationDate=1454492387000&api=v2)
![img](http://wiki.baidu.com/download/attachments/158707916/image2016-2-3%2017%3A40%3A26.png?version=1&modificationDate=1454492426000&api=v2)
参数说明:
- -rpc_dump是主开关,关闭时其他以rpc_dump开头的flag都无效。当打开-rpc_dump后,baidu-rpc会以一定概率采集请求,如果服务的qps很高,baidu-rpc会调节采样比例,使得每秒钟采样的请求个数不超过-bvar_collector_expected_per_second对应的值。这个值在目前同样影响rpcz和contention profiler,一般不用改动,以后会对不同的应用独立开来。
- -rpc_dump_dir:设置存放被dump请求的目录
- -rpc_dump_max_files: 设置目录下的最大文件数,当超过限制时,老文件会被删除以腾出空间。
- -rpc_dump_max_requests_in_one_file:一个文件内的最大请求数,超过后写新文件。
baidu-rpc通过一个[bvar::Collector](https://svn.baidu.com/public/trunk/bvar/bvar/collector.h)来汇总来自不同线程的被采样请求,不同线程之间没有竞争,开销很小。
写出的内容依次存放在rpc_dump_dir目录下的多个文件内,这个目录默认在./rpc_dump_<app>,其中<app>是程序名。不同程序在同一个目录下同时采样时会写入不同的目录。如果程序启动时rpc_dump_dir已经存在了,目录将被清空。目录中的每个文件以requests.yyyymmdd_hhmmss_uuuuus命名,以保证按时间有序方便查找,比如:
![img](http://wiki.baidu.com/download/attachments/158707916/image2015-12-19%200%3A11%3A6.png?version=1&modificationDate=1450455081000&api=v2)
目录下的文件数不超过rpc_dump_max_files,超过后最老的文件被删除从而给新文件腾出位置。
文件是二进制格式,格式与标准协议的二进制格式类似,每个请求的binary layout如下:
```
"PRPC" (4 bytes magic string)
body_size(4 bytes)
meta_size(4 bytes)
RpcDumpMeta (meta_size bytes)
serialized request (body_size - meta_size bytes, including attachment)
```
请求间紧密排列。一个文件内的请求数不超过rpc_dump_max_requests_in_one_file。
> 一个文件可能包含多种协议的请求,如果server被多种协议访问的话。回放时被请求的server也将收到不同协议的请求。
baidu-rpc提供了[SampleIterator](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/rpc_dump.h)从一个采样目录下的所有文件中依次读取所有的被采样请求,用户可根据需求把serialized request反序列化为protobuf请求,做一些二次开发。
```
#include <baidu/rpc/rpc_dump.h>
...
baidu::rpc::SampleIterator it("./rpc_data/rpc_dump/echo_server");
for (SampleRequest* req = it->Next(); req != NULL; req = it->Next()) {
...
// req->meta的类型是baidu::rpc::RpcDumpMeta,定义在protocol/baidu/rpc/rpc_dump.proto
// req->request的类型是base::IOBuf,对应格式说明中的"serialized request"
// 使用结束后必须delete req。
}
```
# 回放
baidu-rpc在[tools/rpc_replay](https://svn.baidu.com/public/trunk/baidu-rpc/tools/rpc_replay/)提供了默认的回放工具。运行方式如下:
![img](http://wiki.baidu.com/download/attachments/158707916/image2015-12-19%200%3A40%3A56.png?version=1&modificationDate=1450456871000&api=v2)
主要参数说明:
- -dir指定了存放采样文件的目录
- -times指定循环回放次数。其他参数请加上--help运行查看。
- -connection_type: 连接server的方式
- -dummy_port:修改dummy_server的端口
- -max_retry:最大重试次数,默认3次。
- -qps:大于0时限制qps,默认为0(不限制)
- -server:server的地址
- -thread_num:发送线程数,为0时会根据qps自动调节,默认为0。一般不用设置。
- -timeout_ms:超时
- -use_bthread:使用bthread发送,默认是。
rpc_replay会默认启动一个仅监控用的dummy server。打开后可查看回放的状况。其中rpc_replay_error是回放失败的次数。
![img](http://wiki.baidu.com/download/attachments/158707916/image2015-12-19%200%3A44%3A30.png?version=1&modificationDate=1450457085000&api=v2)
如果你无法打开浏览器,命令行中也会定期打印信息:
```
2016/01/30-16:19:01 sent:101 success:101 error:0 total_error:0 total_sent:28379
2016/01/30-16:19:02 sent:101 success:101 error:0 total_error:0 total_sent:28480
2016/01/30-16:19:03 sent:101 success:101 error:0 total_error:0 total_sent:28581
2016/01/30-16:19:04 sent:101 success:101 error:0 total_error:0 total_sent:28682
2016/01/30-16:19:05 sent:101 success:101 error:0 total_error:0 total_sent:28783
2016/01/30-16:19:06 sent:101 success:101 error:0 total_error:0 total_sent:28884
2016/01/30-16:19:07 sent:101 success:101 error:0 total_error:0 total_sent:28985
2016/01/30-16:19:08 sent:101 success:101 error:0 total_error:0 total_sent:29086
2016/01/30-16:19:09 sent:101 success:101 error:0 total_error:0 total_sent:29187
2016/01/30-16:19:10 sent:101 success:101 error:0 total_error:0 total_sent:29288
[Latency]
avg 122 us
50% 122 us
70% 135 us
90% 161 us
95% 164 us
97% 166 us
99% 172 us
99.9% 199 us
99.99% 199 us
max 199 us
```
上方的字段含义应该是自解释的,在此略过。下方是延时信息,第一项"avg"是10秒内的平均延时,最后一项"max"是10秒内的最大延时,其余以百分号结尾的则代表延时分位值,即有左侧这么多比例的请求延时小于右侧的延时(单位微秒)。性能测试需要关注99%之后的长尾区域。
rpc_view可以查看端口不在8000-8999的server的内置服务。之前如果一个服务的端口不在8000-8999,我们只能在命令行下使用curl查看它的内置服务,没有历史趋势和动态曲线,也无法点击链接,排查问题不方便。rpc_view是一个特殊的http proxy:把对它的所有访问都转为对目标server的访问。只要把rpc_view启动在8000-8999端口,我们就能通过它看到原本不能直接看到的server了。
# 获取工具
在终端中运行如下命令即可编译出最新版baidu-rpc包含的rpc_view工具.
```bash
PREVDIR=`pwd` && TEMPDIR=`mktemp -d -t build_rpc_press.XXXXXXXXXX` && mkdir $TEMPDIR/public && cd $TEMPDIR/public && svn co https://svn.baidu.com/public/trunk/baidu-rpc && cd baidu-rpc && comake2 -UB -J8 -j8 && comake2 -P && make -sj8 && cd tools/rpc_view && comake2 -P && make -sj8 && cp -f ./rpc_view $PREVDIR && cd $PREVDIR; rm -rf $TEMPDIR
```
编译完成后,rpc_press就会出现在当前目录下。如果编译出错,看[Getting Started](http://wiki.baidu.com/pages/viewpage.action?pageId=71337200)
也可以从[agile](http://agile.baidu.com/#/release/public/baidu-rpc)上获取产出,下面是获取版本r34466中的rpc_view的命令:
```bash
wget -r -nH --level=0 --cut-dirs=8 getprod@buildprod.scm.baidu.com:/temp/data/prod-64/public/baidu-rpc/d92a9fac91892a5f4784fc105e493933/r34466/output/bin/rpc_view --user getprod --password getprod --preserve-permissions
```
在CentOS 6.3上如果出现找不到libssl.so.4的错误,可执行`ln -s /usr/lib64/libssl.so.6 libssl.so.4临时解决`
# 访问目标server
确保你的机器能访问目标server,开发机应该都可以,一些测试机可能不行。运行./rpc_view <server-address>就可以了。
比如:
```
$ ./rpc_view 10.46.130.53:9970
TRACE: 02-14 12:12:20: * 0 src/baidu/rpc/server.cpp:762] Server[rpc_view_server] is serving on port=8888.
TRACE: 02-14 12:12:20: * 0 src/baidu/rpc/server.cpp:771] Check out http://db-rpc-dev00.db01.baidu.com:8888 in web browser.
```
打开rpc_view在8888端口提供的页面(在secureCRT中按住ctrl点url):
![img](http://wiki.baidu.com/download/attachments/167651918/image2016-2-14%2012%3A15%3A42.png?version=1&modificationDate=1455423342000&api=v2)
这个页面正是目标server的内置服务,右下角的提示告诉我们这是rpc_view提供的。这个页面和真实的内置服务基本是一样的,你可以做任何操作。
# 更换目标server
你可以随时停掉rpc_view并更换目标server,不过你觉得麻烦的话,也可以在浏览器上操作:给url加上?changetarget=<new-server-address>就行了。
假如我们之前停留在原目标server的/connections页面:
![img](http://wiki.baidu.com/download/attachments/167651918/image2016-2-14%2012%3A22%3A32.png?version=1&modificationDate=1455423752000&api=v2)
加上?changetarge后就跳到新目标server的/connections页面了。接下来点击其他tab都会显示新目标server的。
![img](http://wiki.baidu.com/download/attachments/167651918/image2016-2-14%2012%3A23%3A10.png?version=1&modificationDate=1455423790000&api=v2)
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment