2021SC@SDUSC
一、bthread的背景知识学习
经过前面6篇代码分析,我将BRPC一个极其实用的工具——bvar做了全面系统的讲解和分析。接下来可以进入到全新的一部分,这是BRPC真正的精华所在——bthread。Bthread是brpc用到的一个线程库,也是brpc的核心之一,默认情况下,包括用户代码在内的绝大部分代码都是运行在bthread里的,bthread也是brpc实现高性能的基石。接下来先简单介绍一下bthread。
下面是官方文档中给出的解释:
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库,但写的是同步代码。
部分名词解释:
1.scalability:可伸缩性(可扩展性),是一种对软件系统计算处理能力的设计指标,高可伸缩性代表一种弹性,在系统扩展成长过程中,软件能够保证旺盛的生命力,通过很少的改动甚至只是硬件设备的添置,就能实现整个系统处理能力的线性增长,实现高吞吐量和低延迟高性能。
2.cache locality:缓存局部性,我们都在在操作系统中接触到过这个知识点,局部性原理是指计算机在执行某个程序时,倾向于使用最近使用的数据。局部性原理有两种表现形式:时间局部性和空间局部性。
- 时间局部性是指被引用过的存储器位置很可能会被再次引用,例如:重复的引用一个变量时则表现出较好的时间局部性
- 空间局部性是指被引用过的存储器位置附近的数据很可能将被引用;例如:遍历二维数组时按行序访问数据元素具有较好的空间局部性
3.LWP:轻量级进程,在计算机操作系统中,轻量级进程(LWP)是一种实现多任务的方法。与普通进程相比,LWP与其他进程共享所有(或大部分)它的逻辑地址空间和系统资源;与线程相比,LWP有它自己的进程标识符,优先级,状态,以及栈和局部存储区,并和其他进程有着父子关系;这是和类Unix操作系统的系统调用vfork()生成的进程一样的。另外,线程既可由应用程序管理,又可由内核管理,而LWP只能由内核管理并像普通进程一样被调度。Linux内核是支持LWP的典型例子。
bthread机制有如下特点:
- 用户可以延续同步的编程模式,能在数百纳秒内建立bthread,可以用多种原语同步。
- bthread所有接口可在pthread中被调用并有合理的行为,使用bthread的代码可以在pthread中正常执行。
- 能充分利用多核。
二、代码分析
本周先分析bthread机制的基石——Butex,可以将Butex理解为和mutex类似的互斥锁,它被设计用来解决多线程下的临界区问题。由于涉及到多个文件的多个类,本章无法全部讲解,故挑选主干部分讲解。
由于brpc中引入了bthread,如果在bthread中使用了mutex,那么将会挂起当前pthread,导致该bthread_worker无法执行其他bthread,因此类似pthread和futex的关系,brpc引入butex来实现bthread粒度的挂起和唤醒。
首先看下Butex的属性。
int类型的value。
ButexWaiterList是一个链表,保存了在该butex上挂起的bthread或pthread。
还有一个FastPthreadMutex类型的waiter_lock锁。
waiter_lock互斥锁,马上讲解。
首先看下butex中使用到的FastPthreadMutex,FastPthreadMutex在mutex.h文件中,可以看出是基于futex实现的pthread粒度的锁,当不用竞争锁时,lock和unlock操作都是通过修改一个用户态的atomic来实现,只有当需要竞争的时候才会陷入内核进行挂起和wake。
可以从上面看到如果使用了BTHREAD_USE_FAST_PTHREAD_MUTEX宏定义,使用的是FastPthreadMutex,否则使用pthread_mutex_t。
当然它的实现功能很多,不只有一个宏定义,比如同时我们还可以定义一个统计等待数量的宏定义,它去生成一个我们上面讲过的Adder达到计数的目的。
话回正题,首先看下lock()方法。首先尝试修改locked这个atomic,如果发现锁没被占用,那么直接返回,否则调用lock_contended方法。同时注意这里使用了memory_order_acquire,和memory_order_release形成同步关系,保证了当前线程获得锁之后能看到上个线程在释放锁之前对内存的修改。
与之相关的还有try_lock()方法,它去尝试获得锁,如果没有也不去竞争,直接返回。
紧接着当发现当前锁被占用需要竞争时,通过系统调用futex_wait_private将当前线程挂起到whole对应的队列中。
unlock()方法通过futex_wake_private唤醒whole对应队列中的一个pthread,上面也提到过,这里使用memory_order_release保证当前线程对内存的修改能被后续竞争到锁的线程看到。
然后看回Butex,由于它的内容非常多,创建删除等基本操作不再浪费篇幅了,都是方法的调用,我们直接看主要方法,不再完整解释整篇代码。首先看butex_wait()等待方法。
如果当前线程不是bthread_worker,那么直接调用butex_wait_from_pthread。
如果当前是bthread,那么直接在该bthread栈上创建ButexBthreadWaiter。然后设置remained,即pthread执行下一个bthread之前需要做的事情,设置完成后切到其他bthread上继续执行。BT_LOOP_WHEN(unsleep_if_necessary(&bbw, get_global_timer_thread()) < 0,30/nops before sched_yield/);为当前bthread恢复后开始执行的地方。(同时我们还可以注意到,如果宏定义了SHOW_BTHREAD_BUTEX_WAITER_COUNT_IN_VARS,那么Adder中将打入1,即增加一个计数,在它不再等待后,打入-1,即减回来。)
然后看下上面提到的set_remained干了什么,即wait_for_butex中做的工作。首先在临界区中再次判断下expect_value是否等于butex中的value,如果相等,才把该waiter加入到butex的等待队列中;如果不相等,则将waiter状态设为值不匹配,重新加入到TaskGroup等待调度。
然后是butex_wake()。(与此类似的还有butex_wake_all和butex_wake_except,两个方法实际上和它实现方法一样,只是wake的数量不同,辅以简单操作即可。)
在该butex的等待队列中唤醒第一个ButexWaiter front,如果front是pthread,那么调用wakeup_pthread;
然后如果当前pthread是bthread_worker,那么直接让出当前bthread,直接执行被唤醒的bthread,如果当前pthread不是bthread_worker,那么就把当前bthread加入到某个task_group的_remote_rq中。