0%

brpc-doc笔记

原子指令

在涉及到多线程时一般会用到锁。

但是重量级锁带来的race condition又会大量的消耗性能。

于是有了乐观锁,典型的就是借助原子指令,CAS来进行操作。这个还依赖的是硬件的发展。

但是原子指令又会带来ABA问题和memory face问题。

如果只是简单的变值的ABA其实是没什么问题的,但是链表的ABA问题就大了。

ABA

Memory face之前没有了解过,看了看是内存屏障的意思。

有点像volatile的禁止指令局部指令重排序的样子。

memory face

Cacheline

Cache Entry

Data is transferred between memory and cache in blocks of fixed size, called cache lines or cache blocks. When a cache line is copied from memory into the cache, a cache entry is created. The cache entry will include the copied data as well as the requested memory location (called a tag).


When the processor needs to read or write a location in main memory, it first checks for a corresponding entry in the cache. The cache checks for the contents of the requested memory location in any cache lines that might contain that address. If the processor finds that the memory location is in the cache, a cache hit has occurred. However, if the processor does not find the memory location in the cache, a cache miss has occurred. In the case of a cache hit, the processor immediately reads or writes the data in the cache line. For a cache miss, the cache allocates a new entry and copies data from main memory, then the request is fulfilled from the contents of the cache.

​ — wiki

这里涉及到修改common memory让其他cpu的cache line中的值失效的问题。

当另一个核心读或写同一处内存时,它得确认看到其他核心中对应的cacheline。对于软件来说,这个过程是原子的,不能在中间穿插其他代码,只能等待CPU完成一致性同步,这个复杂的硬件算法使得原子操作会变得很慢,在E5-2620上竞争激烈时fetch_add会耗费700纳秒左右。

访问被多个线程频繁共享的内存往往是比较慢的。


要提高性能,就要避免让CPU频繁同步cacheline。这不单和原子指令本身的性能有关,还会影响到程序的整体性能。最有效的解决方法很直白:尽量避免共享


一个依赖全局多生产者多消费者队列(MPMC)的程序难有很好的多核扩展性,因为这个队列的极限吞吐取决于同步cache的延时,而不是核心的个数。最好是用多个SPMC或多个MPSC队列,甚至多个SPSC队列代替,在源头就规避掉竞争。


另一个例子是计数器,如果所有线程都频繁修改一个计数器,性能就会很差,原因同样在于不同的核心在不停地同步同一个cacheline。如果这个计数器只是用作打打日志之类的,那我们完全可以让每个线程修改thread-local变量,在需要时再合并所有线程中的值,性能可能有几十倍的差别


缓存一致性

之前感觉在JMM中理解过类似的。

缓存一致性

重点还是在多核心的pc中,每个核心都有自己的一级或者二级的缓存。

然后如果一个核去修改主存中的值,如果其他的核没有收到信号,那么可能会导致并发问题。

这个问题的解决就是缓存一致性,从文章里我们可以看到其实现代的cpu已经自带的缓存一致性。

那么问题来了,那为什么volatile还要发明出来。

cpu不直接和主存通信,那么修改了变量之后,什么时候把修改过得值写到主存中呢?

一般有两种方式

  • 直写

我们透过本级缓存,直接把数据写到下一级缓存(或直接到内存)中,如果对应的段被缓存了,我们同时更新缓存中的内容(甚至直接丢弃)

  • 回写

    回写定律:当所有的脏段被回写后,任意级别缓存中的缓存段的内容,等同于它对应的内存中的内容。

    换句话说,回写模式的定律中,我们去掉了“在任意时刻”这个修饰语



如果都是直写,那么当然没什么问题,但是如果出现回写,数据写回不及时,在并发情况下就会出问题。



那么volatile的增强写语义就很好理解了。

那么增强的读语义呢?

这个我还是觉得有点奇怪的

首先读写主存是独占的,一个核写了之后,因为缓存一致性的存在,那么必然会导致其他核的缓存失效。

那么这时候就只能去读主存中的值了。

所以volatile的读语义其实并没有被Jvm增强,这是硬件所必然的结果。

那么另外一个经典的volatile修饰boolean变量,如果不修饰,另外一个线程可能永远读不到修改后的值的问题。

再仔细想想的话,真的是没读到吗,肯定是读到了,因为缓存一致性的存在,那么那个线程中的值肯定是修改之后的,但是为什么表现的还是没读到的样子呢,那是指令重排序的问题。

所以JSR133增强了volatile,就是在指令重排序上。



内存屏障

重排指令导致了读写顺序的变化。只要没有依赖,代码中在后面的指令就可能跑到前面去

编译器CPU都会这么做。

这么做的动机非常自然,CPU要尽量塞满每个cycle,在单位时间内运行尽量多的指令。

如上节中提到的,访存指令在等待cacheline同步时要花费数百纳秒,最高效地自然是同时同步多个cacheline,而不是一个个做。

一个线程在代码中对多个变量的依次修改,可能会以不同的次序同步到另一个线程所在的核心上。

不同线程对数据的需求不同,按需同步也会导致cacheline的读序和写序不同。

简单说就是为了结果指令重排序带来的并发问题。

Java内存模型中引入了happens-before原则volatile来解决。



wait-free & lock-free

原子指令能为我们的服务赋予两个重要属性:wait-freelock-free

前者指不管OS如何调度线程,每个线程都始终在做有用的事;

后者比前者弱一些,指不管OS如何调度线程,至少有一个线程在做有用的事。


如果我们的服务中使用了锁,那么OS可能把一个刚获得锁的线程切换出去,这时候所有依赖这个锁的线程都在等待,而没有做有用的事,所以用了锁就不是lock-free,更不会是wait-free。

为了确保一件事情总在确定时间内完成,实时系统的关键代码至少是lock-free的。


在百度广泛又多样的在线服务中,对时效性也有着严苛的要求,如果RPC中最关键的部分满足wait-free或lock-free,就可以提供更稳定的服务质量。事实上,brpc中的读写都是wait-free的。

值得提醒的是,常见想法是lock-free或wait-free的算法会更快,但事实可能相反,因为:

lock-free和wait-free必须处理更多更复杂的race condition和ABA problem,完成相同目的的代码比用锁更复杂。代码越多,耗时就越长。
使用mutex的算法变相带“后退”效果。后退(backoff)指出现竞争时尝试另一个途径以临时避免竞争,mutex出现竞争时会使调用者睡眠,使拿到锁的那个线程可以很快地独占完成一系列流程,总体吞吐可能反而高了。

mutex导致低性能往往是因为临界区过大(限制了并发度),或竞争过于激烈(上下文切换开销变得突出)。

lock-free/wait-free算法的价值在于其保证了一个或所有线程始终在做有用的事,而不是绝对的高性能。

但在一种情况下lock-free和wait-free算法的性能多半更高:就是算法本身可以用少量原子指令实现。实现锁也是要用原子指令的,当算法本身用一两条指令就能完成的时候,相比额外用锁肯定是更快了。

就是说其实CAS如果使用不当,并不比同步加锁来的性能更高。

但是wait-free则是很有必要的。

雪崩

“雪崩”指的是访问服务集群时绝大部分请求都超时,且在流量减少时仍无法恢复的现象。


当流量超出服务的最大qps时,服务将无法正常服务;当流量恢复正常时(小于服务的处理能力),积压的请求会被处理,虽然其中很大一部分可能会因为处理的不及时而超时,但服务本身一般还是会恢复正常的。这就相当于一个水池有一个入水口和一个出水口,如果入水量大于出水量,水池子终将盛满,多出的水会溢出来。但如果入水量降到出水量之下,一段时间后水池总会排空。雪崩并不是单一服务能产生的。


如果一个请求经过两个服务,情况就有所不同了。比如请求访问A服务,A服务又访问了B服务。当B被打满时,A处的client会大量超时,如果A处的client在等待B返回时也阻塞了A的服务线程(常见),且使用了固定个数的线程池(常见),那么A处的最大qps就从线程数 / 平均延时,降到了线程数 / 超时。由于超时往往是平均延时的3~4倍,A处的最大qps会相应地下降3~4倍,从而产生比B处更激烈的拥塞。如果A还有类似的上游,拥塞会继续传递上去。但这个过程还是可恢复的。B处的流量终究由最前端的流量触发,只要最前端的流量回归正常,B处的流量总会慢慢降下来直到能正常回复大多数请求,从而让A恢复正常。


但有两个例外:

  1. A可能对B发起了过于频繁的基于超时的重试。这不仅会让A的最大qps降到线程数 / 超时,还会让B处的qps翻重试次数倍。这就可能陷入恶性循环了:只要线程数 / 超时 * 重试次数大于B的最大qpsB就无法恢复 -> A处的client会继续超时 -> A继续重试 -> B继续无法恢复。
  2. A或B没有限制某个缓冲或队列的长度,或限制过于宽松。拥塞请求会大量地积压在那里,要恢复就得全部处理完,时间可能长得无法接受。由于有限长的缓冲或队列需要在填满时解决等待、唤醒等问题,有时为了简单,代码可能会假定缓冲或队列不会满,这就埋下了种子。即使队列是有限长的,恢复时间也可能很长,因为清空队列的过程是个追赶问题,排空的时间取决于积压的请求数 / (最大qps - 当前qps),如果当前qps和最大qps差的不多,积压的请求又比较多,那排空时间就遥遥无期了。

然后我们知道,处理雪崩问题,需要正确的处理超时重试和服务的等待队列问题。

了解这些因素后可以更好的理解brpc中相关的设计。

  1. 拥塞时A服务最大qps的跳变是因为线程个数是硬限,单个请求的处理时间很大程度上决定了最大qps。而brpc server端默认在bthread中处理请求,个数是软限,单个请求超时只是阻塞所在的bthread,并不会影响为新请求建立新的bthread。brpc也提供了完整的异步接口,让用户可以进一步提高io-bound服务的并发度,降低服务被打满的可能性。
  2. brpc中重试默认只在连接出错时发起,避免了流量放大,这是比较有效率的重试方式。如果需要基于超时重试,可以设置backup request,这类重试最多只有一次,放大程度降到了最低。brpc中的RPC超时是deadline,超过后RPC一定会结束,这让用户对服务的行为有更好的预判。在之前的一些实现中,RPC超时是单次超时*重试次数,在实践中容易误判。
  3. brpc server端的max_concurrency选项控制了server的最大并发:当同时处理的请求数超过max_concurrency时,server会回复client错误,而不是继续积压。这一方面在服务开始的源头控制住了积压的请求数,尽量避免延生到用户缓冲或队列中,另一方面也让client尽快地去重试其他server,对集群来说是个更好的策略。


对于brpc的用户来说,要防止雪崩,主要注意两点:

  1. 评估server的最大并发,设置合理的max_concurrency值。这个默认是不设的,也就是不限制。无论程序是同步还是异步,用户都可以通过 最大qps * 非拥塞时的延时(秒)来评估最大并发,原理见little’s law,这两个量都可以在brpc中的内置服务中看到。max_concurrency与最大并发相等或大一些就行了。
  2. 注意考察重试发生时的行为,特别是在定制RetryPolicy时。如果你只是用默认的brpc重试,一般是安全的。但用户程序也常会自己做重试,比如通过一个Channel访问失败后,去访问另外一个Channel,这种情况下要想清楚重试发生时最差情况下请求量会放大几倍,服务是否可承受。



hash表

解决冲突的方法除了常见的开地址和线性探测及变种,还提供了其他的一些。

混合开链和闭链:一般是把桶数组中的一部分拿出来作为容纳冲突元素的空间,典型如Coalesced hashing,但这种结构没有解决开链的内存跳转问题,结构又比闭链复杂很多,工程效果并不好。


多次哈希:一般用多个哈希表代替一个哈希表,当发生冲突时(用另一个哈希值)尝试另一个哈希表。典型如Cuckoo hashing,这个结构也没有解决内存跳转。



IO

一般有三种操作IO的方式:

  • blocking IO: 发起IO操作后阻塞当前线程直到IO结束,标准的同步IO,如默认行为的posix readwrite
  • non-blocking IO: 发起IO操作后不阻塞,用户可阻塞等待多个IO操作同时结束。non-blocking也是一种同步IO:“批量的同步”。如linux下的poll,select, epoll,BSD下的kqueue
  • asynchronous IO: 发起IO操作后不阻塞,用户得递一个回调待IO结束后被调用。如windows下的OVERLAPPED.aspx) + IOCP.aspx)。linux的native AIO只对文件有效

这个批量的同步真的用的厉害,佩服。

linux一般使用non-blocking IO提高IO并发度。

当IO并发度很低时,non-blocking IO不一定比blocking IO更高效,因为后者完全由内核负责,而read/write这类系统调用已高度优化,效率显然高于一般得多个线程协作的non-blocking IO。

不过non-blocking IO也有自己的问题,

它需要调用更多系统调用,比如epoll_ctl,由于epoll实现为一棵红黑树,epoll_ctl并不是一个很快的操作,特别在多核环境下,依赖epoll_ctl的实现往往会面临棘手的扩展性问题。non-blocking需要更大的缓冲,否则就会触发更多的事件而影响效率。

non-blocking还得解决不少多线程问题,代码比blocking复杂很多。

IO线程的问题在于一个线程同时只能读一个fd,当多个繁忙的fd聚集在一个IO线程中时,一些读取就被延迟了。

多租户、复杂分流算法,Streaming RPC等功能会加重这个问题。高负载下常见的某次读取卡顿会拖慢一个IO线程中所有fd的读取,对可用性的影响幅度较大。



超时回调的设计。

这个问题在设计http server的时候也遇到过。

因为是事件驱动,一个连接一个fd,放进epoll中等事件发生。

但是如果用户关闭连接我们自然就要把fd从epoll中拿出来丢掉,不然一直堆积在epoll中不好。

那么问题来了,用户的关闭其实是封装在read事件中,这个还好解决,如果我们read不到数据大概就是关闭了。

但是用户断电了,突然关闭了,那么是无感知的,也不会产生事件,那么这个fd其实是没用的,但是还是会存在我们的epoll中。

这时候就需要引入超时。

在libevent中提供了一个超时事件,可以传入一个callback,然后丢到事件管理器中。

libevent是基于最小堆实现的,也就是说

把所有的超时事件构造成一个堆,事件最短的放在最上面。

当触发的时候,先一个一个检查,把已经过了时间的事件移出去。

这样其实我们就知道这个超时是有延迟的,并不是到点就执行,但是延迟的时间应该不会太长。

一般是以小顶堆)记录触发时间。epoll_wait前以堆顶的时间计算出参数timeout的值,如果在该时间内没有其他事件,epoll_wait也会醒来,从堆中弹出已超时的元素,调用相应的回调函数。

但是这是单线程的情况,在多线程中应该怎么设计呢。

看brpc文档

在多线程框架中,任何线程都可能被用户逻辑阻塞较长的时间,我们需要独立的线程实现timer,这种线程我们叫它TimerThread。一个非常自然的做法,就是使用用锁保护的小顶堆。当一个线程需要创建timer时,它先获得锁,然后把对应的时间插入堆,如果插入的元素成为了最早的,唤醒TimerThread。TimerThread中的逻辑和单线程类似,就是等着堆顶的元素超时,如果在等待过程中有更早的时间插入了,自己会被插入线程唤醒,而不会睡过头。这个方法的问题在于每个timer都需要竞争一把全局锁,操作一个全局小顶堆,就像在其他文章中反复谈到的那样,这会触发cache bouncing。同样数量的timer操作比单线程下的慢10倍是非常正常的,尴尬的是这些timer基本不触发。

cache bouncing好像和多线程自旋锁有关,暂时也没太看明白

cacheline bouncing

一个惯例思路是把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自行发现和删除。第一种方法让插入逻辑更复杂了,删除也要抢锁,线程竞争更激烈。第二种方法在小顶堆内留了一大堆已删除的元素,让堆明显变大,插入和删除都变慢。

这个,还有删除操作。。。

这个之前真没考虑到,最小堆得删除的话,可能要另外一个数据结构去删除,然后可能只有遍历去找了。

文档中说使用一个HashMap。但是维护起来成本太高了。

第三个难点是TimerThread不应该经常醒。一个极端是TimerThread永远醒着或以较高频率醒过来(比如每1ms醒一次),这样插入timer的线程就不用负责唤醒了,然后我们把插入请求散列到多个堆降低竞争,问题看似解决了。但事实上这个方案提供的timer精度较差,一般高于2ms。

你得想这个TimerThread怎么写逻辑,它是没法按堆顶元素的时间等待的,由于插入线程不唤醒,一旦有更早的元素插入,TimerThread就会睡过头。它唯一能做的是睡眠固定的时间,但这和现代OS scheduler的假设冲突:频繁sleep的线程的优先级最低。在linux下的结果就是,即使只sleep很短的时间,最终醒过来也可能超过2ms,因为在OS看来,这个线程不重要。一个高精度的TimerThread有唤醒机制,而不是定期醒。

这个延迟的问题,我不是很有概念到底可接受的范围是多大。

文档里说高于2ms就算是差的了。

照这个频率的话,那么如果是高频率的醒来的话,那么间隔肯定在2ms内。。。

那还不如一直醒着

另外,更并发的数据结构也难以奏效,感兴趣的同学可以去搜索”concurrent priority queue”或”concurrent skip list”,这些数据结构一般假设插入的数值较为散开,所以可以同时修改结构内的不同部分。但这在RPC场景中也不成立,相互竞争的线程设定的时间往往聚集在同一个区域,因为程序的超时大都是一个值,加上当前时间后都差不多。

这就是说,ConcurrentHashMap的效率其实也没有那么恐怖。

那新TimerThread是如何做到的?

  • 一个TimerThread而不是多个。
  • 创建的timer散列到多个Bucket以降低线程间的竞争,默认12个Bucket。
  • Bucket内不使用小顶堆管理时间,而是链表 + nearest_run_time字段,当插入的时间早于nearest_run_time时覆盖这个字段,之后去和全局nearest_run_time(和Bucket的nearest_run_time不同)比较,如果也早于这个时间,修改并唤醒TimerThread。链表节点在锁外使用ResourcePool分配。
  • 删除时通过id直接定位到timer内存结构,修改一个标志,timer结构总是由TimerThread释放。
  • TimerThread被唤醒后首先把全局nearest_run_time设置为几乎无限大(max of int64),然后取出所有Bucket内的链表,并把Bucket的nearest_run_time设置为几乎无限大(max of int64)。TimerThread把未删除的timer插入小顶堆中维护,这个堆就它一个线程用。在每次运行回调或准备睡眠前都会检查全局nearest_run_time, 如果全局更早,说明有更早的时间加入了,重复这个过程。

删除时直接通过id定位到timer内存结构,修改一个标志位。

唤醒的过程还没看懂。。。

还是c++强啊,这个在Java里就没法实现了。

这个方法之所以有效:

  • 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次,已经最优。

干货 !

下面是一些和linux下时间管理相关的知识:

  • epoll_wait的超时精度是毫秒,较差。pthread_cond_timedwait的超时使用timespec,精度到纳秒,一般是60微秒左右的延时。
  • 出于性能考虑,TimerThread使用wall-time,而不是单调时间,可能受到系统时间调整的影响。具体来说,如果在测试中把系统时间往前或往后调一个小时,程序行为将完全undefined。未来可能会让用户选择单调时间。
  • 在cpu支持nonstop_tsc和constant_tsc的机器上,brpc和bthread会优先使用基于rdtsc的cpuwide_time_us。那两个flag表示rdtsc可作为wall-time使用,不支持的机器上会转而使用较慢的内核时间。我们的机器(Intel Xeon系列)大都有那两个flag。rdtsc作为wall-time使用时是否会受到系统调整时间的影响,未测试不清楚。

常见线程模型

连接独占线程或进程

在这个模型中,线程/进程处理来自绑定连接的消息,在连接断开前不退也不做其他事情。当连接数逐渐增多时,线程/进程占用的资源和上下文切换成本会越来越大,性能很差,这就是C10K问题的来源。这种方法常见于早期的web server,现在很少使用。

C10k问题还是挺经典的。

单线程reactor

libevent, libev等event-loop库为典型。这个模型一般由一个event dispatcher等待各类事件,待事件发生后原地调用对应的event handler,全部调用完后等待更多事件,故为”loop”。这个模型的实质是把多段逻辑按事件触发顺序交织在一个系统线程中。一个event-loop只能使用一个核,故此类程序要么是IO-bound,要么是每个handler有确定的较短的运行时间(比如http server),否则一个耗时漫长的回调就会卡住整个程序,产生高延时。在实践中这类程序不适合多开发者参与,一个人写了阻塞代码可能就会拖慢其他代码的响应。由于event handler不会同时运行,不太会产生复杂的race condition,一些代码不需要锁。此类程序主要靠部署更多进程增加扩展性。

主要问题不在IO上其实,因为这种模型无锁,不可控的问题在用户的代码中。

多线程Reactor,比如Netty,其实也有这个问题。

N:1线程库

又称为Fiber),以GNU Pth, StateThreads等为典型,一般是把N个用户线程映射入一个系统线程。同时只运行一个用户线程,调用阻塞函数时才会切换至其他用户线程。N:1线程库与单线程reactor在能力上等价,但事件回调被替换为了上下文(栈,寄存器,signals),运行回调变成了跳转至上下文。和event loop库一样,单个N:1线程库无法充分发挥多核性能,只适合一些特定的程序。只有一个系统线程对CPU cache较为友好,加上舍弃对signal mask的支持的话,用户线程间的上下文切换可以很快(100~200ns)。N:1线程库的性能一般和event loop库差不多,扩展性也主要靠多进程。

多线程reactor

boost::asio为典型。一般由一个或多个线程分别运行event dispatcher,待事件发生后把event handler交给一个worker线程执行。 这个模型是单线程reactor的自然扩展,可以利用多核。由于共用地址空间使得线程间交互变得廉价,worker thread间一般会更及时地均衡负载,而多进程一般依赖更前端的服务来分割流量,一个设计良好的多线程reactor程序往往能比同一台机器上的多个单线程reactor进程更均匀地使用不同核心。不过由于cache一致性的限制,多线程reactor并不能获得线性于核心数的性能,在特定的场景中,粗糙的多线程reactor实现跑在24核上甚至没有精致的单线程reactor实现跑在1个核上快。由于多线程reactor包含多个worker线程,单个event handler阻塞未必会延缓其他handler,所以event handler未必得非阻塞,除非所有的worker线程都被阻塞才会影响到整体进展。事实上,大部分RPC框架都使用了这个模型,且回调中常有阻塞部分,比如同步等待访问下游的RPC返回。

那么netty有没有这个问题呢。。。。

感觉考虑了好多关于缓存一致性的问题

M:N线程库

即把M个用户线程映射入N个系统线程。M:N线程库可以决定一段代码何时开始在哪运行,并何时结束,相比多线程reactor在调度上具备更多的灵活度。但实现全功能的M:N线程库是困难的,它一直是个活跃的研究话题。我们这里说的M:N线程库特别针对编写网络服务,在这一前提下一些需求可以简化,比如没有时间片抢占,没有(完备的)优先级等。M:N线程库可以在用户态也可以在内核中实现,用户态的实现以新语言为主,比如GHC threads和goroutine,这些语言可以围绕线程库设计全新的关键字并拦截所有相关的API。而在现有语言中的实现往往得修改内核,比如Windows UMS.aspx)和google SwicthTo(虽然是1:1,但基于它可以实现M:N的效果)。相比N:1线程库,M:N线程库在使用上更类似于系统线程,需要用锁或消息传递保证代码的线程安全。

问题

多核扩展性

理论上代码都写成事件驱动型能最大化reactor模型的能力,但实际由于编码难度和可维护性,用户的使用方式大都是混合的:回调中往往会发起同步操作,阻塞住worker线程使其无法处理其他请求。一个请求往往要经过几十个服务,线程把大量时间花在了等待下游请求上,用户得开几百个线程以维持足够的吞吐,这造成了高强度的调度开销,并降低了TLS相关代码的效率。任务的分发大都是使用全局mutex + condition保护的队列,当所有线程都在争抢时,效率显然好不到哪去。更好的办法也许是使用更多的任务队列,并调整调度算法以减少全局竞争。比如每个系统线程有独立的runqueue,由一个或多个scheduler把用户线程分发到不同的runqueue,每个系统线程优先运行自己runqueue中的用户线程,然后再考虑其他线程的runqueue。这当然更复杂,但比全局mutex + condition有更好的扩展性。这种结构也更容易支持NUMA。

当event dispatcher把任务递给worker线程时,用户逻辑很可能从一个核心跳到另一个核心,并等待相应的cacheline同步过来,并不很快。如果worker的逻辑能直接运行于event dispatcher所在的核心上就好了,因为大部分时候尽快运行worker的优先级高于获取新事件。类似的是收到response后最好在当前核心唤醒正在同步等待RPC的线程。

回调中往往发起的是同步操作。

非阻塞read也算吗?

异步编程

异步编程中的流程控制对于专家也充满了陷阱。任何挂起操作,如sleep一会儿或等待某事完成,都意味着用户需要显式地保存状态,并在回调函数中恢复状态。异步代码往往得写成状态机的形式。当挂起较少时,这有点麻烦,但还是可把握的。问题在于一旦挂起发生在条件判断、循环、子函数中,写出这样的状态机并能被很多人理解和维护,几乎是不可能的,而这在分布式系统中又很常见,因为一个节点往往要与多个节点同时交互。另外如果唤醒可由多种事件触发(比如fd有数据或超时了),挂起和恢复的过程容易出现race condition,对多线程编码能力要求很高。语法糖(比如lambda)可以让编码不那么“麻烦”,但无法降低难度。

共享指针在异步变成中很普遍,这看似方便,但也使内存的ownership变得难以捉摸,如果内存泄漏了,很难定位哪里没有释放;如果segment fault了,也不知道哪里多释放了一下。大量使用引用计数的用户代码很难控制代码质量,容易长期在内存问题上耗费时间。如果引用计数还需要手动维护,保持质量就更难了,维护者也不会愿意改进。没有上下文会使得RAII无法充分发挥作用, 有时需要在callback之外lock,callback之内unlock,实践中很容易出错。

讲了这么多线程模型,好像都有缺点。

负载均衡

最常见的分流算法是round robin和随机。这两个方法的前提是下游的机器和网络都是类似的,但在目前的线上环境下,特别是混部的产品线中,已经很难成立,因为:

  • 每台机器运行着不同的程序组合,并伴随着一些离线任务,机器的可用资源在持续动态地变化着。
  • 机器配置不同。
  • 网络延时不同。

干货!

这些问题其实一直有,但往往被OP辛勤的机器监控和替换给隐藏了。框架层面也有过一些努力,比如UB中的WeightedStrategy是根据下游的cpu占用率来进行分流,但明显地它解决不了延时相关的问题,甚至cpu的问题也解决不了:因为它被实现为定期reload一个权值列表,可想而知更新频率高不了,等到负载均衡反应过来,一大堆请求可能都超时了。并且这儿有个数学问题:怎么把cpu占用率转为权值。假设下游差异仅仅由同机运行的其他程序导致,机器配置和网络完全相同,两台机器权值之比是cpu idle之比吗?假如是的,当我们以这个比例给两台机器分流之后,它们的cpu idle应该会更接近对吧?而这会导致我们的分流比例也变得接近,从而使两台机器的cpu idle又出现差距。你注意到这个悖论了吗?这些因素使得这类算法的实际效果和那两个基本算法没什么差距,甚至更差,用者甚少。

我们需要一个能自适应下游负载、规避慢节点的通用分流算法。

Pigeon也是进行了weight

但是问题也提出来了,就是更新频率高不了,导致会有一些超时问题只能,你懂得。。

Welcome to my other publishing channels