这篇文档主要描述我们的fiber调度相关的逻辑及策略。
flare使用修改版的boost.context进行上下文切换。
根据ISA的不同,我们的修改版的boost.context位于:
flare中fiber和pthread之间采用M:N的模型来进行调度。
相对于N:1以及常见的协程(Fiber和协程的区别见fiber.md)设计,M:N会引入更多的cache miss,导致性能下降。这是我们为了保证性能(响应时间等)的平稳性所难以避免的代价。
但是这并不是说我们的性能表现不如其他N:1的框架或协程框架。系统的整体性能实际上可以由多方面进行优化。单纯的调度模型并不会决定一个框架的整体性能。
我们会启动不少于flare_concurrency_hint
个线程来执行fibers。我们的实现中,实际创建的线程数可能大于flare_concurrency_hint
。后文将会做更进一步解释。
根据调度的配置参数不同,可能出现如下两种问题:
通常而言,我们建议用户通过如下参数向框架描述负载情况并由框架自行确定调度相关参数:
flare_fiber_scheduling_optimize_for
:指定负载类型(CPU密集或网络密集),可选项:
compute-heavy
:计算密集。框架将尽可能保证各个处理器之间的负载均衡。compute
:计算密集,但是框架在确定参数时不会像compute-heavy
那么激进。neutral
:默认配置,应当可以满足大多数使用场景。io
:IO(特指通过Flare进行的,不包括直接读写磁盘等Flare感知不到的场景)密集。这种场景下各个请求处理时间较短,因此高负载下QPS很高,组间少量不均衡的影响小于共享数据竞争的影响。这种情况下框架确定参数时优先考虑降低数据竞争。io-heavy
:同io
,但是更加激进。由于这一参数通常由一个服务的业务特点决定,是相对恒定的,因此通常可以考虑通过FLARE_OVERRIDE_FLAG
在代码中直接设定。
出于性能考虑,flare中对底层的pthread workers进行了分组,每一个分组我们称之为一个调度组,fiber通常在一个组内的pthread workers之间迁移。
我们的设计允许各个调度组之间以任务偷取的方式迁移fiber,但是任务偷取的频率是受到限制的。隶属于不同NUMA节点的调度组之间的任务偷取默认被禁用,如果手动启用,其频率也会受到相对于同NUMA节点之间而言,更进一步的限制。
调度组的大小取决于参数flare_scheduling_group_size
,且不能超过64。
每个调度组内至多允许有flare_fiber_run_queue_size
个可执行的fibers。默认值通常可以满足业务需求,对于极端情况,可自行修改。这一参数必须是2的整数次幂。
在如下几种情况下,我们会将各个调度组的亲和性分别关联到某个不同的NUMA域(中的所有CPU):
对于多线程之间的同步操作较少的负载而言,设置亲和性有助于改善其整体吞吐及时延。
flare_numa_aware
参数为true
flare_numa_aware
未指定且以下两条均成立:
flare_concurrency_hint
大于CPU个数,分组后调度组个数不小于NUMA节点数关于调度组及其相关的参数对性能的影响,参见调度组。
fiber从被创建(或被唤醒)到被pthread执行是一个典型的生产消费的场景。通常我们有如下办法可以解决:
由于上述算法均有较为明显的缺陷,因此我们自行设计了唤醒算法。
显然这儿的算法是针对某个调度组而言,因此其共享的状态均是调度组内的pthread workers之间共享,且pthread个数可控(为flare_scheduling_group_size
)。
我们维护如下(调度组内)共享状态:
flare_fiber_run_queue_size
)队列。对于每个线程,我们还维护如下数据:
初看起来,我们这儿run queue使用有界队列会为用户引入不必要的可能需要调节的参数(flare_fiber_run_queue_size
)。
但是实际上,相对于无界队列,有界队列有如下优势:
同时,无界队列虽然可以避免需要手动调节参数,但是在实际情况中,考虑到:
flare_fiber_run_queue_size * sizeof(FiberEntity*)
,即便我们预设一个很大的值(如1M),每个调度组也只有队列节点大小*1M的开销。vm.max_map_count
、用于映射栈的物理内存大小等)。因此通常一个足够大的预设值可以满足绝大多数环境;当预设值不足时程序本身往往已经不能正常运行了,此时无界队列自动扩容带来的好处很少有实际场景。
因此我们选择了有界队列作为我们的run queue。
我们对生产者(创建、唤醒fiber的线程)和消费者(空闲的等待新fiber来执行的线程)分开描述。
当一个pthread worker从队列中取不到fiber(即队列为空)后,会检测spinning mask。这一字段记录了当前正在轮询run queue的pthread workers。
如果当前其中置1位的个数(内部通过一条popcnt(SSE4)指令实现)小于2,则CAS(compare-and-swap)将自己对应的位置1(失败时重试)。否则转入休眠。
这样保证了至多调度组内只有2个线程在盲等,避免低负载时过多的CPU消耗在盲等上。
如果pthread worker轮询超时之后仍然取不到fiber,转入休眠。
如果pthread worker在轮询阶段取到了fiber,在离开之前会将pending wakeup置为true。此时另一轮询的pthread worker(如果有)发现这一字段改为true之后,会唤醒(唤醒逻辑见后文)一个新的pthread worker来轮询。这样可以提前唤醒pthread worker,在持续有新的fiber生成时,尽量保证有已经被唤醒的pthread worker可以直接执行。既改善了fiber的调度延迟,又可以避免下一次有fiber可执行时,生产方的唤醒pthread worker的syscall成本。
获取fiber之后返回时,pthread worker会将对应的spinning mask改为0(可能已经为0,见下文)。
pthread worker会在休眠前后,对sleeping mask进行对应的更新。
在将fiber加入run queue之后,生产者首先检查spinning mask,如果不为0,通过__builtin_ffsll
找到最低的置1位(通常编译为一条bsf指令),并将之(通过原子cas)置0。通过原子将之改为0,我们可以确信不会有其他的生产线程也误以为这个pthread worker会执行它的fiber,即我们以这一cas宣告了“所有权”。与此同时,对应的轮询线程应当已经或即将从run queue中获取我们刚加入的fiber。因此生产者无需syscall而可以直接返回。
否则生产者通过__builtin_ffsll
找到sleeping mask的最低置1位并将之改为0(cas操作,同上),之后通过相应的wait slot将之唤醒。
这儿我们始终优先考虑编号更低(在mask中对应更低位)的线程,负载较低时这会将负载集中在特定的几个线程上。这有如下一些好处:
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )