【项目学习】brpc

brpc是百度开源的高性能服务框架,本文主要探讨了brpc中的bvar(多线程环境下的计数器类库)、bthread(M:N线程库)以及负载均衡算法。bvar利用thread local存储减少竞争,提供监控统计,bthread通过work stealing和butex实现高效并发,负载均衡算法采用Locality-aware load balancing,根据延时和吞吐动态分配流量。此外,文章还介绍了常见的线程模型、避免雪崩的方法以及内存管理中的TCMalloc。
摘要由CSDN通过智能技术生成

目录

1. bvar

2. bthread

FAQ

3. 常见线程模型

问题

4. 同步访问和异步访问

同步访问

异步访问

组合channel

5.  channel和mutex

6. 自适应限流算法

7. 雪崩

8. FlatMap - 权衡空间的快速哈希表结构.

9. work_stealing

10. 负载均衡算法

一致性hash

11.timer

some tips


小菜learning

这些内容全部摘自https://github.com/apache/incubator-brpc/

1. bvar

    bvar多线程环境下的计数器类库,方便记录和查看用户程序中的各类数值,它利用了thread local存储减少了cache bouncing,相比UbMonitor(百度内的老计数器库)几乎不会给程序增加性能开销,也快于竞争频繁的原子操作。brpc集成了bvar,/vars可查看所有曝光的bvar,/vars/VARNAME可查阅某个bvar,在brpc中的使用方法请查看vars。brpc大量使用了bvar提供统计数值,当你需要在多线程环境中计数并展现时,应该第一时间想到bvar。但bvar不能代替所有的计数器,它的本质是把写时的竞争转移到了读:读得合并所有写过的线程中的数据,而不可避免地变慢了。当你读写都很频繁或得基于最新值做一些逻辑判断时,你不应该用bvar。

    为了理解bvar的原理,你得先阅读Cacheline这节(涉及cpu的cacheline、原子指令、指令重排、memory fence等概念),其中提到的计数器例子便是bvar。当很多线程都在累加一个计数器时,每个线程只累加私有的变量而不参与全局竞争,在读取时累加所有线程的私有变量。虽然读比之前慢多了,但由于这类计数器的读多为低频的记录和展现,慢点无所谓。而写就快多了,极小的开销使得用户可以无顾虑地使用bvar监控系统,这便是我们设计bvar的目的。

这里列出上面提到的一些概念:

  1. 1 原子指令

    原子指令是对软件不可再分的指令,比如x.fetch_add(n)指原子地给x加上n,这个指令对软件要么没做,要么完成,不会观察到中间状态。常见的原子指令有:

原子指令 (x均为std::atomic) 作用
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
x.fetch_add(n), x.fetch_sub(n) 原子地做x += n, x-= n,返回修改之前的值。

你已经可以用这些指令做原子计数,比如多个线程同时累加一个原子变量,以统计这些线程对一些资源的操作次数。但是,这可能会有两个问题:

  • 这个操作没有你想象地快。
  • 如果你尝试通过看似简单的原子操作控制对一些资源的访问,你的程序有很大几率会crash。

 

  1. 2 cacheline

没有任何竞争或只被一个线程访问的原子操作是比较快的,“竞争”指的是多个线程同时访问同一个cacheline。现代CPU为了以低价格获得高性能,大量使用了cache,并把cache分了多级。百度内常见的Intel E5-2620拥有32K的L1 dcache和icache,256K的L2 cache和15M的L3 cache。其中L1和L2 cache为每个核心独有,L3则所有核心共享。一个核心写入自己的L1 cache是极快的(4 cycles, ~2ns),但当另一个核心读或写同一处内存时,它得确认看到其他核心中对应的cacheline。对于软件来说,这个过程是原子的,不能在中间穿插其他代码,只能等待CPU完成一致性同步,这个复杂的硬件算法使得原子操作会变得很慢,在E5-2620上竞争激烈时fetch_add会耗费700纳秒左右。访问被多个线程频繁共享的内存往往是比较慢的。比如像一些场景临界区看着很小,但保护它的spinlock性能不佳,因为spinlock使用的exchange, fetch_add等指令必须等待最新的cacheline,看上去只有几条指令,花费若干微秒并不奇怪。

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

  • 一个依赖全局多生产者多消费者队列(MPMC)的程序难有很好的多核扩展性,因为这个队列的极限吞吐取决于同步cache的延时,而不是核心的个数。最好是用多个SPMC或多个MPSC队列,甚至多个SPSC队列代替,在源头就规避掉竞争。
  • 另一个例子是计数器,如果所有线程都频繁修改一个计数器,性能就会很差,原因同样在于不同的核心在不停地同步同一个cacheline。如果这个计数器只是用作打打日志之类的,那我们完全可以让每个线程修改thread-local变量,在需要时再合并所有线程中的值,性能可能有几十倍的差别

一个相关的编程陷阱是false sharing:对那些不怎么被修改甚至只读变量的访问,由于同一个cacheline中的其他变量被频繁修改,而不得不经常等待cacheline同步而显著变慢了。多线程中的变量尽量按访问规律排列,频繁被其他线程修改的变量要放在独立的cacheline中。要让一个变量或结构体按cacheline对齐,可以include <butil/macros.h>后使用BAIDU_CACHELINE_ALIGNMENT宏,请自行grep brpc的代码了解用法。

  1. 3 memory fence

    仅靠原子技术实现不了对资源的访问控制,即使简单如spinlock引用计数,看上去正确的代码也可能会crash。这里的关键在于重排指令导致了读写顺序的变化。只要没有依赖,代码中在后面的指令就可能跑到前面去,编译器CPU都会这么做。

    这么做的动机非常自然,CPU要尽量塞满每个cycle,在单位时间内运行尽量多的指令。如上节中提到的,访存指令在等待cacheline同步时要花费数百纳秒,最高效地自然是同时同步多个cacheline,而不是一个个做。一个线程在代码中对多个变量的依次修改,可能会以不同的次序同步到另一个线程所在的核心上。不同线程对数据的需求不同,按需同步也会导致cacheline的读序和写序不同。

    如果其中第一个变量扮演了开关的作用,控制对后续变量的访问。那么当这些变量被一起同步到其他核心时,更新顺序可能变了,第一个变量未必是第一个更新的,然而其他线程还认为它代表着其他变量有效,去访问了实际已被删除的变量,从而导致未定义的行为。比如下面的代码片段:

// Thread 1
// ready was initialized to false
p.init();
ready = true;
// Thread2
if (ready) {
    p.bar();
}

从人的角度,这是对的,因为线程2在ready为true时才会访问p,按线程1的逻辑,此时p应该初始化好了。但对多核机器而言,这段代码可能难以正常运行:

  • 线程1中的ready = true可能会被编译器或cpu重排到p.init()之前,从而使线程2看到ready为true时,p仍然未初始化。这种情况同样也会在线程2中发生,p.bar()中的一些代码可能被重排到检查ready之前。
  • 即使没有重排,ready和p的值也会独立地同步到线程2所在核心的cache,线程2仍然可能在看到ready为true时看到未初始化的p。

注:x86/x64的load带acquire语意,store带release语意,上面的代码刨除编译器和CPU因素可以正确运行。

通过这个简单例子,你可以窥见原子指令编程的复杂性了吧。为了解决这个问题,CPU和编译器提供了memory fence,让用户可以声明访存指令间的可见性(visibility)关系,boost和C++11对memory fence做了抽象,总结为如下几种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,上面的例子可以这么更正:

// Thread1
// ready was initialized to false
p.init();
ready.store(true, std::memory_order_release);


// Thread2
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的最新值”。

一个相关问题是:如何知道看到的值是新还是旧?一般分两种情况:

  • 值是特殊的。比如在上面的例子中,ready=true是个特殊值,只要线程2看到ready为true就意味着更新了。只要设定了特殊值,读到或没有读到特殊值都代表了一种含义。
  • 总是累加。一些场景下没有特殊值,那我们就用fetch_add之类的指令累加一个变量,只要变量的值域足够大,在很长一段时间内,新值和之前所有的旧值都会不相同,我们就能区分彼此了。

原子指令的例子可以看boost.atomic的Example,atomic的官方描述可以看这里

 

2. bthread

接触到一个新概念

https://github.com/apache/incubator-brpc/blob/master/docs/cn/bthread.md

    bthread是brpc使用的M:N线程库,目的是在提高程序的并发度的同时,降低编码难度,并在核数日益增多的CPU上提供更好的scalability和cache locality。”M:N“是指M个bthread会映射至N个pthread,一般M远大于N。由于linux当下的pthread实现(NPTL)是1:1的,M个bthread也相当于映射至N个LWP。bthread的前身是Distributed Process(DP)中的fiber,一个N:1的合作式线程库,等价于event-loop库,但写的是同步代码

Goals

  • 用户可以延续同步的编程模式,能在数百纳秒内建立bthread,可以用多种原语同步。
  • bthread所有接口可在pthread中被调用并有合理的行为,使用bthread的代码可以在pthread中正常执行。
  • 能充分利用多核。
  • better cache locality, supporting NUMA is a plus.

 

FAQ

Q:bthread是协程(coroutine)吗?

不是。我们常说的协程特指N:1线程库,即所有的协程运行于一个系统线程中,计算能力和各类eventloop库等价。由于不跨线程,协程之间的切换不需要系统调用,可以非常快(100ns-200ns),受cache一致性的影响也小。但代价是协程无法高效地利用多核,代码必须非阻塞,否则所有的协程都被卡住,对开发者要求苛刻。协程的这个特点使其适合写运行时间确定的IO服务器,典型如http server,在一些精心调试的场景中,可以达到非常高的吞吐。但百

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值