Commit 84ddb0b3 authored by zhujiashun's avatar zhujiashun

Add docs in rpc_in_depth and tools

Change-Id: I81f8562b9e2623479b77b45f00611c84e9c8c047
parent e9eea50e
我们都知道多核编程要用锁,以避免多个线程在修改同一个数据时产生[race condition](http://en.wikipedia.org/wiki/Race_condition)。当锁成为性能瓶颈时,我们又总想试着绕开它,而不可避免地接触了原子指令。但在实践中,用原子指令写出正确的代码是一件非常困难的事,琢磨不透的race condition、[ABA problem](https://en.wikipedia.org/wiki/ABA_problem)[memory fence](https://en.wikipedia.org/wiki/Memory_barrier)很烧脑,这篇文章试图通过介绍[SMP](http://en.wikipedia.org/wiki/Symmetric_multiprocessing)架构下的原子指令帮助大家入门。C++11正式引入了[原子指令](http://en.cppreference.com/w/cpp/atomic/atomic),我们就以其语法描述。
顾名思义,原子指令是**对软件**不可再分的指令,比如x.fetch_add(n)指原子地给x加上n,这个指令**对软件**要么没做,要么完成,不会观察到中间状态。常见的原子指令有:
| 原子指令 (x均为std::atomic<int>) | 作用 |
| ---------------------------------------- | ---------------------------------------- |
| x.load() | 返回x的值。 |
| x.store(n) | 把x设为n,什么都不返回。 |
| x.exchange(n) | 把x设为n,返回设定之前的值。 |
| x.compare_exchange_strong(expected_ref, desired) | 若x等于expected_ref,则设为desired,返回成功;否则把最新值写入expected_ref,返回失败。 |
| x.compare_exchange_weak(expected_ref, desired) | 相比compare_exchange_strong可能有[spurious wakeup](http://en.wikipedia.org/wiki/Spurious_wakeup)。 |
| x.fetch_add(n), x.fetch_sub(n), x.fetch_xxx(n) | x += n, x-= n(或更多指令),返回修改之前的值。 |
你已经可以用这些指令做原子计数,比如多个线程同时累加一个原子变量,以统计这些线程对一些资源的操作次数。但是,这可能会有两个问题:
- 这个操作没有你想象地快。
- 如果你尝试通过看似简单的原子操作控制对一些资源的访问,你的程序有很大几率会crash。
# Cacheline
没有任何竞争或只被一个线程访问的原子操作是比较快的,“竞争”指的是多个线程同时访问同一个[cacheline](https://en.wikipedia.org/wiki/CPU_cache#Cache_entries)。现代CPU为了以低价格获得高性能,大量使用了cache,并把cache分了多级。百度内常见的Intel E5-2620拥有32K的L1 dcache和icache,256K的L2 cache和15M的L3 cache。其中L1和L2cache为每个核心独有,L3则所有核心共享。一个核心写入自己的L1 cache是极快的(4 cycles, 2 ns),但当另一个核心读或写同一处内存时,它得确认看到其他核心中对应的cacheline。对于软件来说,这个过程是原子的,不能在中间穿插其他代码,只能等待CPU完成[一致性同步](https://en.wikipedia.org/wiki/Cache_coherence),这个复杂的算法相比其他操作耗时会很长,在E5-2620上大约在700ns左右。所以访问被多个线程频繁共享的内存是比较慢的。
要提高性能,就要避免让CPU同步cacheline。这不仅仅和原子指令的性能有关,而是会影响到程序的整体性能。比如像一些临界区很小的场景,使用spinlock效果仍然不佳,问题就在于实现spinlock使用的exchange,fetch_add等指令必须在CPU同步好最新的cacheline后才能完成,看上去只有几条指令,花费若干微秒不奇怪。最有效的解决方法非常直白:**尽量避免共享**。从源头规避掉竞争是最好的,有竞争就要协调,而协调总是很难的。
- 一个依赖全局多生产者多消费者队列(MPMC)的程序难有很好的多核扩展性,因为这个队列的极限吞吐取决于同步cache的延时,而不是核心的个数。最好是用多个SPMC或多个MPSC队列,甚至多个SPSC队列代替,在源头就规避掉竞争。
- 另一个例子是全局计数器,如果所有线程都频繁修改一个全局变量,性能就会很差,原因同样在于不同的核心在不停地同步同一个cacheline。如果这个计数器只是用作打打日志之类的,那我们完全可以让每个线程修改thread-local变量,在需要时再合并所有线程中的值,性能可能有几十倍的差别。
做不到完全不共享,那就尽量少共享。在一些读很多的场景下,也许可以降低写的频率以减少同步cacheline的次数,以加快读的平均性能。一个相关的编程陷阱是避免false sharing:这指的是那些不怎么被修改的变量,由于同一个cacheline中的另一个变量被频繁修改,而不得不经常等待cacheline同步而显著变慢了。多线程中的变量尽量按访问规律排列,频繁被其他线程的修改要放在独立的cacheline中。要让一个变量或结构体按cacheline对齐,可以include <base/macros.h>然后使用BAIDU_CACHELINE_ALIGNMENT宏,用法请自行grep一下baidu-rpc的代码了解。
# Memory fence
仅靠原子累加实现不了对资源的访问控制,即使简单如[spinlock](https://en.wikipedia.org/wiki/Spinlock)[引用计数](https://en.wikipedia.org/wiki/Reference_counting),看上去正确的代码也可能会crash。这里的关键在于**重排指令**导致了读写一致性的变化。只要没有依赖,代码中在后面的指令(包括访存)就可能跑到前面去,[编译器](http://preshing.com/20120625/memory-ordering-at-compile-time/)[CPU](https://en.wikipedia.org/wiki/Out-of-order_execution)都会这么做。这么做的动机非常自然,CPU要尽量塞满每个cycle,在单位时间内运行尽量多的指令。一个核心访问自己独有的cache是很快的,所以它能很好地管理好一致性问题。当软件依次写入a,b,c后,它能以a,b,c的顺序依次读到,哪怕在CPU层面是完全并发运行的。当代码只运行于单线程中时,重排对软件是透明的。但在多核环境中,这就不成立了。如上节中提到的,访存在等待cacheline同步时要花费数百纳秒,最高效地自然是同时同步多个cacheline,而不是一个个做。一个线程在代码中对多个变量的依次修改,可能会以不同的次序同步到另一个线程所在的核心上,CPU也许永远无法保证这个顺序如同TCP那样,有序修改有序读取,因为不同线程对数据的需求顺序是不同的,按需访问是合理的(从而导致同步cacheline的序和写序不同)。如果其中第一个变量扮演了开关的作用,控制对后续变量对应资源的访问。那么当这些变量被一起同步到其他核心时,更新顺序可能变了,第一个变量未必是第一个更新的,其他线程可能还认为它代表着其他变量有效,而去访问了已经被删除的资源,从而导致未定义的行为。比如下面的代码片段:
| 线程1 | 线程2 |
| ---------------------------------------- | ----------------------------------- |
| `// ready was initialized to false``p.init();``ready = ``true``;` | `if` `(ready) {`` ``p.bar();``}` |
从人的角度,这是对的,因为线程2在ready为true时才会访问p,按线程1的逻辑,此时p应该初始化好了。但对多核机器而言,这段代码难以正常运行:
- 线程1中的ready = true可能会被编译器或cpu重排到p.init()之前,从而使线程2看到ready为true时,p仍然未初始化。
- 即使没有重排,ready和p的值也会独立地同步到线程2所在核心的cache,线程2仍然可能在看到ready为true时看到未初始化的p。这种情况同样也会在线程2中发生,比如p.bar()中的一些代码被重排到检查ready之前。
通过这个简单例子,你可以窥见原子指令编程的复杂性了吧。为了解决这个问题,CPU提供了[memory fence](http://en.wikipedia.org/wiki/Memory_barrier),让用户可以声明访存指令间的可见性(visibility)关系,boost和C++11对memory fencing做了抽象,总结为如下几种[memory order](http://en.cppreference.com/w/cpp/atomic/memory_order).
| memory order | 作用 |
| -------------------- | ---------------------------------------- |
| memory_order_relaxed | 没有fencing作用 |
| memory_order_consume | 后面依赖此原子变量的访存指令勿重排至此条指令之前 |
| memory_order_acquire | 后面访存指令勿重排至此条指令之前 |
| memory_order_release | 前面访存指令勿重排至此条指令之后。当此条指令的结果对其他线程可见后,之前的所有指令都可见 |
| memory_order_acq_rel | acquire + release语意 |
| memory_order_seq_cst | acq_rel语意外加所有使用seq_cst的指令有严格地全序关系 |
有了memory order,上面的例子可以这么更正:
| 线程1 | 线程2 |
| ---------------------------------------- | ---------------------------------------- |
| `// ready was initialized to false``p.init();``ready.store(``true``, std::memory_order_release);` | `if` `(ready.load(std::memory_order_acquire)) {`` ``p.bar();``}` |
线程2中的acquire和线程1的release配对,确保线程2在看到ready==true时能看到线程1 release之前所有的访存操作。
注意,memory fence不等于可见性,即使线程2恰好在线程1在把ready设置为true后读取了ready也不意味着它能看到true,因为同步cache是有延时的。memory fence保证的是可见性的顺序:“假如我看到了a的最新值,那么我一定也得看到b的最新值”。为什么CPU不在读到最新值后才告知软件呢?首先这么做增加了读的延时,其次当写很多时,读就一直忙不迭地同步,最终被饿死。况且即使软件拿到了最新值,等它做出决策发起修改时,最新值可能又变了,这个决策变得毫无意义。
另一个问题是:如果我看到的是a的旧值,那我也许什么都不该干。那我怎么知道看到的是新值还是旧值?一般分两种情况:
- 值是特殊的。比如在上面的例子中,ready=true是个特殊值,只要线程2看到ready为true就意味着更新了。只要设定了特殊值,读到或没有读到特殊值都代表了一种含义。
- 总是累加。一些场景下没有特殊值,那我们就用fetch_add之类的指令累加一个变量,只要变量的值域足够大,在很长一段时间内,新值和之前所有的旧值都会不相同,我们就能区分彼此了。
原子指令的例子可以看boost.atomic的[Example](http://www.boost.org/doc/libs/1_56_0/doc/html/atomic/usage_examples.html),atomic的官方描述可以看[这里](http://en.cppreference.com/w/cpp/atomic/atomic)
# wait-free & lock-free
原子指令能为我们的服务赋予两个重要属性:[wait-free](http://en.wikipedia.org/wiki/Non-blocking_algorithm#Wait-freedom)[lock-free](http://en.wikipedia.org/wiki/Non-blocking_algorithm#Lock-freedom)。前者指不管OS如何调度线程,每个线程都始终在做有用的事;后者比前者弱一些,指不管OS如何调度线程,至少有一个线程在做有用的事。如果我们的服务中使用了锁,那么OS可能把一个刚获得锁的线程切换出去,这时候所有依赖这个锁的线程都在等待,而没有做有用的事,所以用了锁就不是lock-free,更不会是wait-free。为了确保一件事情总在确定时间内完成,实时操作系统(RTOS)的关键代码至少是lock-free的。在我们广泛又多样的在线服务中,对时效性也有着严苛的要求,如果RPC中最关键的部分满足wait-free或lock-free,就可以提供更稳定的服务质量。
值得提醒的是,常见想法是lock-free或wait-free的算法会更快,但事实可能相反,因为:
- lock-free和wait-free必须处理复杂的race condition和ABA problem,完成相同目的的代码比用锁更复杂。
- 使用mutex的算法变相带“后退”效果。后退(backoff)指出现竞争时尝试另一个途径以避免激烈的竞争,mutex出现竞争时会使调用者睡眠,在高度竞争时规避了激烈的cacheline同步,使拿到锁的那个线程可以很快地完成一系列流程,总体吞吐可能反而高了。
mutex导致低性能往往是因为临界区过大(限制了并发度),或临界区过小(上下文切换开销变得突出,应考虑用adaptive mutex)。lock-free和wait-free算法的价值在于其避免了deadlock/livelock,在各种情况下的稳定表现,而不是绝对的高性能。但在一种情况下lock-free和wait-free算法的性能多半更高:就是算法本身可以用少量原子指令实现。实现锁也是要用原子指令的,当算法本身用一两条指令就能完成的时候,相比额外用锁肯定是更快了。
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)
# 概述
LALB全称Locality-aware load balancing,是一个能把请求及时、自动地送到延时最低的下游的负载均衡算法,特别适合混合部署环境。该算法产生自DP系统,现已加入baidu-rpc!
LALB可以解决的问题:
- 下游的机器配置不同,访问延时不同,round-robin和随机分流效果不佳。
- 下游服务和离线服务或其他服务混部,性能难以预测。
- 自动地把大部分流量送给同机部署的模块,当同机模块出问题时,再跨机器。
- 优先访问本机房服务,出问题时再跨机房。
**…**
# 背景
最常见的分流算法是round robin和随机。这两个方法的前提是下游的机器和网络都是类似的,但在目前的线上环境下,特别是混部的产品线中,已经很难成立,因为:
- 每台机器运行着不同的程序组合,并伴随着一些离线任务,机器的可用资源在持续动态地变化着。
- 机器配置不同。
- 网络延时不同。
这些问题其实一直有,但往往被OP辛勤的机器监控和替换给隐藏了。框架层面也有过一些努力,比如UB中的[WeightedStrategy](https://svn.baidu.com/public/trunk/ub/ub_client/ubclient_weightstrategy.h)是根据下游的cpu占用率来进行分流,但明显地它解决不了延时相关的问题,甚至cpu的问题也解决不了:因为它被实现为定期reload一个权值列表,可想而知更新频率高不了,等到负载均衡反应过来,一大堆请求可能都超时了。并且这儿有个数学问题:怎么把cpu占用率转为权值。假设下游差异仅仅由同机运行的其他程序导致,机器配置和网络完全相同,两台机器权值之比是cpu idle之比吗?假如是的,当我们以这个比例给两台机器分流之后,它们的cpu idle应该会更接近对吧?而这会导致我们的分流比例也变得接近,从而使两台机器的cpu idle又出现差距。你注意到这个悖论了吗?这些因素使得这类算法的实际效果和那两个基本算法没什么差距,甚至更差,用者甚少。
我们需要一个能自适应下游负载、规避慢节点的通用分流算法。
# Locality-aware
在DP 2.0中我们使用了一种新的算法: Locality-aware load balancing,能根据下游节点的负载分配流量,还能快速规避失效的节点,在很大程度上,这种算法的延时也是全局最优的。基本原理非常简单:
> 以下游节点的吞吐除以延时作为分流权值。
比如只有两台下游节点,W代表权值,QPS代表吞吐,L代表延时,那么W1 = QPS1 / L1和W2 = QPS2 / L2分别是这两个节点的分流权值,分流时随机数落入的权值区间就是流量的目的地了。
一种分析方法如下:
- 稳定状态时的QPS显然和其分流权值W成正比,即W1 / W2 ≈ QPS1 / QPS2。
- 根据分流公式又有:W1 / W2 = QPS1 / QPS2 * (L2 / L1)。
故稳定状态时L1和L2应当是趋同的。当L1小于L2时,节点1会更获得相比其QPS1更大的W1,从而在未来获得更多的流量,直到**其延时高于平均值或没有更多的流量。**
注意这个算法并不是按照延时的比例来分流,不是说一个下游30ms,另一个60ms,它们的流量比例就是60 / 30。而是30ms的节点会一直获得流量直到它的延时高于60ms,或者没有更多流量了。以下图为例,曲线1和曲线2分别是节点1和节点2的延时与吞吐关系图,随着吞吐增大延时会逐渐升高,接近极限吞吐时,延时会飙升。左下的虚线标记了QPS=400时的延时,此时虽然节点1的延时有所上升,但还未高于节点2的基本延时(QPS=0时的延时),所以所有流量都会分给节点1,而不是按它们基本延时的比例(图中大约2:1)。当QPS继续上升达到1600时,分流比例会在两个节点延时相等时平衡,图中为9 : 7。很明显这个比例是高度非线性的,取决于不同曲线的组合,和单一指标的比例关系没有直接关联。在真实系统中,延时和吞吐的曲线也在动态变化着,分流比例更加动态。
![img](http://wiki.baidu.com/download/attachments/38012521/image2015-5-20%2023%3A5%3A34.png?version=1&modificationDate=1432134338000&api=v2)
我们用一个例子来看一下具体的分流过程。启动3台server,逻辑分别是sleep 1ms,2ms,3ms,对于client来说这些值就是延时。启动client(50个同步访问线程)后每秒打印的分流结果如下:
![img](http://wiki.baidu.com/download/attachments/38012521/image2015-5-29%208%3A57%3A44.png?version=1&modificationDate=1432861065000&api=v2)
S[n]代表第n台server。由于S[1]和S[2]的平均延时大于1ms,LALB会发现这点并降低它们的权值。它们的权值会继续下降,直到被算法设定的最低值拦住。这时停掉server,反转延时并重新启动,即逻辑分别为sleep 3ms,2ms,1ms,运行一段时候后分流效果如下:
![img](http://wiki.baidu.com/download/attachments/38012521/image2015-5-29%209%3A2%3A53.png?version=1&modificationDate=1432861373000&api=v2)
刚重连上server时,client还是按之前的权值把大部分流量都分给了S[0],但由于S[0]的延时从1ms上升到了3ms,client的qps也降到了原来的1/3。随着数据积累,LALB逐渐发现S[2]才是最快的,而把大部分流量切换了过去。同样的服务如果用rr或random访问,则qps会显著下降:
"rr" or "random": ![img](http://wiki.baidu.com/download/attachments/38012521/image2015-5-31%2020%3A57%3A24.png?version=1&modificationDate=1433077045000&api=v2)
"la" : ![img](http://wiki.baidu.com/download/attachments/38012521/image2015-5-31%2020%3A58%3A21.png?version=1&modificationDate=1433077102000&api=v2)
真实的场景中不会有这么显著的差异,但你应该能看到差别了。
这有很多应用场景:
- 如果本机有下游服务,LALB会优先访问这些最近的节点。比如CTR应用中有一个计算在1ms左右的receiver模块,被model模块访问,很多model和receiver是同机部署的,以前的分流算法必须走网络,使得receiver的延时开销较大(3-5ms),特别是在晚上由于离线任务起来,很不稳定,失败率偏高,而LALB会优先访问本机或最近的receiver模块,很多流量都不走网络了,成功率一下子提升了很多。
- 如果同rack有下游服务,LALB也会优先访问,减少机房核心路由器的压力。甚至不同机房的服务可能不再需要隔离,LALB会优先走本机房的下游,当本机房下游出问题时再自动访问另一些机房。
但我们也不能仅看到“基本原理”,这个算法有它复杂的一面:
- 传统的经验告诉我们,不能把所有鸡蛋放一个篮子里,而按延时优化不可避免地会把很多流量送到同一个节点,如果这个节点出问题了,我们如何尽快知道并绕开它。
- 对吞吐和延时的统计都需要统计窗口,窗口越大数据越可信,噪声越少,但反应也慢了,一个异常的请求可能对统计值造不成什么影响,等我们看到统计值有显著变化时可能已经太晚了。
- 我们也不能只统计已经回来的,还得盯着路上的请求,否则我们可能会向一个已经出问题(总是不回)的节点傻傻地浪费请求。
- ”按权值分流”听上去好简单,但你能写出多线程和可能修改节点的前提下,在O(logN)时间内尽量不互斥的查找算法吗?
这些问题可以归纳为以下几个方面。
## DoublyBufferedData
LoadBalancer是一个读远多于写的数据结构:大部分时候,所有线程从一个不变的server列表中选取一台server。如果server列表真是“不变的”,那么选取server的过程就不用加锁,我们可以写更复杂的分流算法。一个方法是用读写锁,但当读临界区不是特别大时(毫秒级),读写锁并不比mutex快,而实用的分流算法不可能到毫秒级,否则开销也太大了。另一个方法是双缓冲,很多检索端用类似的方法实现无锁的查找过程,它大概这么工作:
- 数据分前台和后台。
- 检索线程只读前台,不用加锁。
- 只有一个写线程:修改后台数据,切换前后台,睡眠一段时间,以确保老前台(新后台)不再被检索线程访问。
这个方法的问题在于它假定睡眠一段时间后就能避免和前台读线程发生竞争,这个时间一般是若干秒。由于多次写之间有间隔,这儿的写往往是批量写入,睡眠时正好用于积累数据增量。
但这套机制对“server列表”不太好用:总不能插入一个server就得等几秒钟才能插入下一个吧,即使我们用批量插入,这个"冷却"间隔多少会让用户觉得疑惑:短了担心安全性,长了觉得没有必要。我们能尽量降低这个时间并使其安全么?
我们需要**写以某种形式和读同步,但读之间相互没竞争**。一种解法是,读拿一把thread-local锁,写需要拿到所有的thread-local锁。具体过程如下:
- 数据分前台和后台。
- 读拿到自己所在线程的thread-local锁,执行查询逻辑后释放锁。
- 同时只有一个写:修改后台数据,切换前后台,**挨个**获得所有thread-local锁并立刻释放,结束后再改一遍新后台(老前台)。
我们来分析下这个方法的基本原理:
- 当一个读正在发生时,它会拿着所在线程的thread-local锁,这把锁会挡住同时进行的写,从而保证前台数据不会被修改。
- 在大部分时候thread-local锁都没有竞争,对性能影响很小。
- 逐个获取thread-local锁并立刻释放是为了**确保对应的读线程看到了切换后的新前台**。如果所有的读线程都看到了新前台,写线程便可以安全地修改老前台(新后台)了。
其他特点:
- 不同的读之间没有竞争,高度并发。
- 如果没有写,读总是能无竞争地获取和释放thread-local锁,一般小于25ns,对延时基本无影响。如果有写,由于其临界区极小(拿到立刻释放),读在大部分时候仍能快速地获得锁,少数时候释放锁时可能有唤醒写线程的代价。由于写本身就是少数情况,读整体上几乎不会碰到竞争锁。
完成这些功能的数据结构是[DoublyBufferedData<>](https://svn.baidu.com/public/trunk/common/base/containers/doubly_buffered_data.h),我们常简称为DBD。baidu-rpc中的所有load balancer都使用了这个数据结构,使不同线程在分流时几乎不会互斥。而其他rpc实现往往使用了全局锁,这使得它们无法写出复杂的分流算法:否则分流代码将会成为竞争热点。
这个结构有广泛的应用场景:
- reload词典。大部分时候词典都是只读的,不同线程同时查询时不应查询。
- 可替换的全局callback。像base/logging.cpp支持配置全局LogSink以重定向日志,这个LogSink就是一个带状态的callback。如果只是简单的全局变量,在替换后我们无法直接删除LogSink,因为可能还有都写线程在用。用DBD可以解决这个问题。
## weight tree
LALB的查找过程是按权值分流,O(N)方法如下:获得所有权值的和total,产生一个间于[0, total-1]的随机数R,逐个遍历权值,直到当前权值之和不大于R,而下一个权值之和大于R。
这个方法可以工作,也好理解,但当N达到几百时性能已经很差,这儿的主要因素是cache一致性:LALB是一个基于反馈的算法,RPC结束时信息会被反馈入LALB,被遍历的数据结构也一直在被修改。这意味着前台的O(N)读必须刷新每一行cacheline。当N达到数百时,一次查找过程可能会耗时百微秒,更别提更大的N了,LALB(将)作为baidu-rpc的默认分流算法,这个性能开销是无法接受的。
另一个办法是用完全二叉树。每个节点记录了左子树的权值之和,这样我们就能在O(logN)时间内完成查找。当N为1024时,我们最多跳转10次内存,总耗时可控制在1微秒内,这个性能是可接受的。这个方法的难点是如何和DoublyBufferedData结合。
- 我们不考虑不使用DoublyBufferedData,那样要么绕不开锁,要么写不出正确的算法。
- 前台后必须共享权值数据,否则切换前后台时,前台积累的权值数据没法同步到后台。
- “左子树权值之和”也被前后台共享,但和权值数据不同,它和位置绑定。比如权值结构的指针可能从位置10移动到位置5,但“左子树权值之和”的指针不会移动,算法需要从原位置减掉差值,而向新位置加上差值。
- 我们不追求一致性,只要最终一致即可,这能让我们少加锁。这也意味着“权值之和”,“左子树权值之和”,“节点权值”未必能精确吻合,查找算法要能适应这一点。
最困难的部分是增加和删除节点,它们需要在整体上对前台查找不造成什么影响,详细过程请参考代码。
## base_weight
QPS和latency使用一个循环队列统计,默认容量128。我们可以使用这么小的统计窗口,是因为inflight delay能及时纠正过度反应,而128也具备了一定的统计可信度。不过,这么计算latency的缺点是:如果server的性能出现很大的变化,那么我们需要积累一段时间才能看到平均延时的变化。就像上节例子中那样,server反转延时后client需要积累很多秒的数据才能看到的平均延时的变化。目前我们并么有处理这个问题,因为真实生产环境中的server不太会像例子中那样跳变延时,大都是缓缓变慢。当集群有几百台机器时,即使我们反应慢点给个别机器少分流点也不会导致什么问题。如果在产品线中确实出现了性能跳变,并且集群规模不大,我们再处理这个问题。
权值的计算方法是base_weight = QPS * WEIGHT_SCALE / latency ^ p。其中WEIGHT_SCALE是一个放大系数,为了能用整数存储权值,又能让权值有足够的精度,类似定点数。p默认为2,延时的收敛速度大约为p=1时的p倍,选项quadratic_latency=false可使p=1。
权值计算在各个环节都有最小值限制,为了防止某个节点的权值过低而使其完全没有访问机会。即使一些延时远大于平均延时的节点,也应该有足够的权值,以确保它们可以被定期访问,否则即使它们变快了,我们也不会知道。
Icon
除了待删除节点,所有节点的权值绝对不会为0。
这也制造了一个问题:即使一个server非常缓慢(但没有断开连接),它的权值也不会为0,所以总会有一些请求被定期送过去而铁定超时。当qps不高时,为了降低影响面,探测间隔必须拉长。比如为了把对qps=1000的影响控制在1%%内,故障server的权值必须低至使其探测间隔为10秒以上,这降低了我们发现server变快的速度。这个问题的解决方法有:
- 什么都不干。这个问题也许没有想象中那么严重,由于持续的资源监控,线上服务很少出现“非常缓慢”的情况,一般性的变慢并不会导致请求超时。
- 保存一些曾经发向缓慢server的请求,用这些请求探测。这个办法的好处是不浪费请求。但实现起来耦合很多,比较麻烦。
- 强制backup request。
- 再选一次。
## inflight delay
我们必须追踪还未结束的RPC,否则我们就必须等待到超时或其他错误发生,而这可能会很慢(超时一般会是正常延时的若干倍),在这段时间内我们可能做出了很多错误的分流。最简单的方法是统计未结束RPC的耗时:
- 选择server时累加发出时间和未结束次数。
- 反馈时扣除发出时间和未结束次数。
- 框架保证每个选择总对应一次反馈。
这样“当前时间 - 发出时间之和 / 未结束次数”便是未结束RPC的平均耗时,我们称之为inflight delay。当inflight delay大于平均延时时,我们就线性地惩罚节点权值,即weight = base_weight * avg_latency / inflight_delay。当发向一个节点的请求没有在平均延时内回来时,它的权值就会很快下降,从而纠正我们的行为,这比等待超时快多了。不过这没有考虑延时的正常抖动,我们还得有方差,方差可以来自统计,也可简单线性于平均延时。不管怎样,有了方差bound后,当inflight delay > avg_latency + max(bound * 3, MIN_BOUND)时才会惩罚权值。3是正态分布中的经验数值。
\ No newline at end of file
上游一般通过名字服务发现所有的下游节点,并通过多种负载均衡方法把流量分配给下游节点。当下游节点出现问题时,它可能会被隔离以提高负载均衡的效率。被隔离的节点定期被健康检查,成功后重新加入正常节点。
# 名字服务
在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
# server端多协议
baidu-rpc server在同端口支持所有的协议,大部分时候这对部署和运维更加方便。由于不同协议的格式大相径庭,严格地来说,同端口很难无二义地支持所有协议。出于解耦和可扩展性的考虑,也不太可能集中式地构建一个针对所有协议的分类器。我们的做法就是把协议归三类后逐个尝试:
- 第一类协议:标记或特殊字符在最前面,比如[标准协议](http://gollum.baidu.com/ProtobufRPC)[hulu协议](http://wiki.babel.baidu.com/twiki/bin/view/Com/Main/Hulu_rpc_protocols)的前4个字符分别分别是PRPC和HULU,解析代码只需要检查前4个字节就可以知道协议是否匹配,最先尝试这类协议。这些协议在同一个连接上也可以共存。
- 第二类协议:有较为复杂的语法,没有固定的协议标记或特殊字符,可能在解析一段输入后才能判断是否匹配,目前此类协议只有http。
- 第三类协议:协议标记或特殊字符在中间,比如[nshead](http://wiki.babel.baidu.com/twiki/bin/view/Com/Main/LibNshead)的magic_num在第25-28字节。由于之前的字段均为二进制,难以判断正确性,在没有读取完28字节前,我们无法判定消息是不是nshead格式的,所以处理起来很麻烦,若其解析排在http之前,那么<=28字节的http消息便可能无法被解析,因为程序以为是“还未完整的nshead消息”。
考虑到大多数链接上只会有一种协议,我们会记录前一次的协议选择结果,下次首先尝试。对于长连接,这几乎把甄别协议的开销降到了0;虽然短连接每次都得运行这段逻辑,但由于短连接的瓶颈也往往不在于此,这套方法仍旧是足够快的。在未来如果有大量的协议加入,我们可能得考虑一些更复杂的启发式的区分方法。
# client端多协议
不像server端必须根据连接上的数据动态地判定协议,client端作为发起端,自然清楚自己的协议格式,只要某种协议只通过连接池或短链接发送,即独占那个连接,那么它可以是任意复杂(或糟糕的)格式。因为client端发出时会记录所用的协议,等到response回来时直接调用对应的解析函数,没有任何甄别代价。像memcache,redis这类协议中基本没有magic number,在server端较难和其他协议区分开,但让client端支持却没什么问题。
# 支持新协议
baidu-rpc就是设计为可随时扩展新协议的,步骤如下:
> 以nshead开头的协议有统一支持,看[这里](http://wiki.baidu.com/pages/viewpage.action?pageId=213828733)。
## 增加ProtocolType
[options.proto](https://svn.baidu.com/public/trunk/baidu-rpc/protocol/baidu/rpc/options.proto)的ProtocolType中增加新协议类型,如果你需要的话可以联系我们增加,以确保不会和其他人的需求重合。
目前的ProtocolType(16年底):
```c++
enum ProtocolType {
PROTOCOL_UNKNOWN = 0;
PROTOCOL_BAIDU_STD = 1;
PROTOCOL_STREAMING_RPC = 2;
PROTOCOL_HULU_PBRPC = 3;
PROTOCOL_SOFA_PBRPC = 4;
PROTOCOL_RTMP = 5;
PROTOCOL_HTTP = 6;
PROTOCOL_PUBLIC_PBRPC = 7;
PROTOCOL_NOVA_PBRPC = 8;
PROTOCOL_NSHEAD_CLIENT = 9; // implemented in baidu-rpc-ub
PROTOCOL_NSHEAD = 10;
PROTOCOL_HADOOP_RPC = 11;
PROTOCOL_HADOOP_SERVER_RPC = 12;
PROTOCOL_MONGO = 13; // server side only
PROTOCOL_UBRPC_COMPACK = 14;
PROTOCOL_DIDX_CLIENT = 15; // Client side only
PROTOCOL_REDIS = 16; // Client side only
PROTOCOL_MEMCACHE = 17; // Client side only
PROTOCOL_ITP = 18;
PROTOCOL_NSHEAD_MCPACK = 19;
PROTOCOL_DISP_IDL = 20; // Client side only
PROTOCOL_ERSDA_CLIENT = 21; // Client side only
PROTOCOL_UBRPC_MCPACK2 = 22; // Client side only
}
```
## 实现回调
均定义在struct Protocol中,该结构定义在[protocol.h](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/protocol.h)。其中的parse必须实现,除此之外server端至少要实现process_request,client端至少要实现serialize_request,pack_request,process_response;
实现协议回调还是比较困难的,这块的代码不会像供普通用户使用的那样,有较好的提示和保护,你得先靠自己搞清楚其他协议中的类似代码,然后再动手,最后发给我们做code review。
### parse
定义:`typedef ParseResult (*Parse)(base::IOBuf* source, Socket *socket, bool read_eof, const void *arg);`
用于把消息从source上切割下来,client端和server端使用同一个parse函数。返回的消息会被递给process_request(server端)或process_response(client端)。
参数:source是读取到的二进制内容,socket是对应的连接,read_eof为true表示连接已被对端关闭,arg在server端是对应server的指针,在client端是NULL。
ParseResult可能是错误,也可能包含一个切割下来的message,可能的值有:
- PARSE_ERROR_TRY_OTHERS :不是这个协议,框架会尝试下一个协议。source不能被消费。
- PARSE_ERROR_NOT_ENOUGH_DATA : 到目前为止数据内容不违反协议,但不构成完整的消息。等到连接上有新数据时,新数据会被append入source并重新调用parse。如果不确定数据是否一定属于这个协议,source不应被消费,如果确定数据属于这个协议,也可以把source的内容转移到内部的状态中去。比如http协议解析中即使source不包含一个完整的http消息,它也会被http parser消费掉,以避免下一次重复解析。
- PARSE_ERROR_TOO_BIG_DATA : 消息太大,拒绝掉以保护server,连接会被关闭。
- PARSE_ERROR_NO_RESOURCE : 内部错误,比如资源分配失败。连接会被关闭。
- PARSE_ERROR_ABSOLUTELY_WRONG : 应该是这个协议(比如magic number匹配了),但是格式不符合预期。连接会被关闭。
### serialize_request
定义:`typedef bool (*SerializeRequest)( base::IOBuf* request_buf, Controller* cntl, const google::protobuf::Message* request); `
把request序列化进request_buf,client端必须实现。发生在pack_request之前,一次RPC中只会调用一次。cntl包含某些协议(比如http)需要的信息。成功返回true,否则false。
### pack_request
定义:`typedef int (*PackRequest)( base::IOBuf* msg, uint64_t correlation_id, const google::protobuf::MethodDescriptor* method, Controller* controller, const base::IOBuf& request_buf, const Authenticator* auth);`
把request_buf打包入msg,每次向server发送消息前(包括重试)都会调用。当auth不为空时,需要打包认证信息。成功返回0,否则-1。
### process_request
定义:`typedef void (*ProcessRequest)(InputMessageBase* msg_base);`
处理server端parse返回的消息,server端必须实现。可能会在和parse()不同的线程中运行。多个process_request可能同时运行。
在r34386后必须在处理结束时调用msg_base->Destroy(),为了防止漏调,考虑使用DestroyingPtr<>
### process_response
定义:`typedef void (*ProcessResponse)(InputMessageBase* msg);`
处理client端parse返回的消息,client端必须实现。可能会在和parse()不同的线程中运行。多个process_response可能同时运行。
在r34386后必须在处理结束时调用msg_base->Destroy(),为了防止漏调,考虑使用DestroyingPtr<>
### verify
定义:`typedef bool (*Verify)(const InputMessageBase* msg);`
处理连接的认证,只会对连接上的第一个消息调用,需要支持认证的server端必须实现,不需要认证或仅支持client端的协议可填NULL。成功返回true,否则false。
### parse_server_address
定义:`typedef bool (*ParseServerAddress)(base::EndPoint* out, const char* server_addr_and_port);`
把server_addr_and_port(Channel.Init的一个参数)转化为base::EndPoint,可选。一些协议对server地址的表达和理解可能是不同的。
### get_method_name
定义:`typedef const std::string& (*GetMethodName)(const google::protobuf::MethodDescriptor* method, const Controller*);`
定制method name,可选。
### supported_connection_type
标记支持的连接方式。如果支持所有连接方式,设为CONNECTION_TYPE_ALL。如果只支持连接池和短连接,设为CONNECTION_TYPE_POOLED_AND_SHORT。
### name
协议的名称,会出现在各种配置和显示中,越简短越好,必须是字符串常量。
## 注册到全局
实现好的协议要调用RegisterProtocol[注册到全局](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/global.cpp),以便baidu-rpc发现。就像这样:
```c++
Protocol http_protocol = { ParseHttpMessage,
SerializeHttpRequest, PackHttpRequest,
ProcessHttpRequest, ProcessHttpResponse,
VerifyHttpRequest, ParseHttpServerAddress,
GetHttpMethodName,
CONNECTION_TYPE_POOLED_AND_SHORT,
"http" };
if (RegisterProtocol(PROTOCOL_HTTP, http_protocol) != 0) {
exit(1);
}
```
## r34386引入的不兼容
为了进一步简化protocol的实现逻辑,r34386是一个不兼容改动,主要集中在下面几点:
- ProcessXXX必须在处理结束时调用msg_base->Destroy()。在之前的版本中,这是由框架完成的。这个改动帮助我们隐藏处理EOF的代码(很晦涩),还可以在未来支持更异步的处理(退出ProcessXXX不意味着处理结束)。为了确保所有的退出分支都会调用msg_base->Destroy(),可以使用定义在[destroying_ptr.h](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/destroying_ptr.h)中的DestroyingPtr<>,可能像这样:
```c++
void ProcessXXXRequest(InputMessageBase* msg_base) {
DestroyingPtr<MostCommonMessage> msg(static_cast<MostCommonMessage*>(msg_base));
...
}
```
- 具体请参考[其他协议](https://svn.baidu.com/public/trunk/baidu-rpc/src/baidu/rpc/policy/baidu_rpc_protocol.cpp)的实现。
- InputMessageBase::socket_id()被移除,而通过socket()可以直接访问到对应Socket的指针。ProcessXXX函数中Address Socket的代码可以移除。
ProcessXXXRequest开头的修改一般是这样:
```c++
void ProcessXXXRequest(InputMessageBase* msg_base) {
const int64_t start_parse_us = base::cpuwide_time_us();
- MostCommonMessage* msg = static_cast<MostCommonMessage*>(msg_base);
+ DestroyingPtr<MostCommonMessage> msg(static_cast<MostCommonMessage*>(msg_base));
+ SocketUniquePtr socket(msg->ReleaseSocket());
const Server* server = static_cast<const Server*>(msg_base->arg());
ScopedNonServiceError non_service_error(server);
- const SocketId sock = msg_base->socket_id();
- SocketUniquePtr socket;
- if (Socket::Address(sock, &socket) != 0) {
- RPC_VLOG << "Fail to address client=" << sock;
- return;
- }
- if (socket->CheckEOF()) {
- // Received an EOF event
- return;
- }
```
ProcessXXXResponse开头的修改一般是这样:
```c++
void ProcessRpcResponse(InputMessageBase* msg_base) {
const int64_t start_parse_us = base::cpuwide_time_us();
- MostCommonMessage* msg = static_cast<MostCommonMessage*>(msg_base);
- CheckEOFGuard eof_guard(msg->socket_id());
+ DestroyingPtr<MostCommonMessage> msg(static_cast<MostCommonMessage*>(msg_base));
...
- // After a successful fight, EOF will no longer interrupt the
- // following code. As a result, we can release `eof_guard'
- eof_guard.check();
```
check_eof_guard.h被移除,所以对这个文件的include也得移除:
```c++
- #include "baidu/rpc/details/check_eof_guard.h"
```
- AddClientSideHandler被移除,用如下方法代替:
```
- if (AddClientSideHandler(handler) != 0) {
+ if (get_or_new_client_side_messenger()->AddHandler(handler) != 0) {
```
\ No newline at end of file
### 切换方法
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