b-rpc的一些高并发机制

一个协程可以看做是一个任务处理函数。对协程来说两个操作是yield和resume,yield是指一个正在被pthread系统线程执行的协程被挂起,让出cpu的使用权,
pthread继续去执行另一个协程的任务函数,从协程角度看是协程中止了运行,从系统线程角度看是pthread继续在运行;
resume是指一个被中止的协程的任务函数重新被pthread执行,恢复执行点为上一次yield操作的返回点。
	
有了yield和resume,就可以实现pthread线程执行流在不同函数间的跳转,只需要将函数作为协程的任务函数即可。
一个线程执行一个协程A的任务处理函数taskFunc_A时,如果想要去执行另一个协程B的任务处理函数taskFunc_B,可以在taskFunc_A内执行一个yield语句,
然后线程执行流可以从taskFunc_A中跳出,去执行taskFunc_B。如果想让taskFunc_A恢复执行,则调用一个resume语句,
让taskFunc_A从yield语句的返回点处开始继续执行,并且taskFunc_A的执行结果不受yield的影响。
	
协程的原理与实现方式
协程有三个组成要素:任务函数,存储寄存器状态的结构,私有栈空间。
协程被称作用户级线程,有其私有的栈空间,当pthread执行一个协程的任务函数时,协程任务函数的栈帧、形参、局部变量都分配在协程的私有栈上。

brpc的bthread
上面可以认为是实现了N:1用户级线程,即所有协程都在一个系统线程pthread上被调度执行。
N:1协程的一个问题就是如果其中一个协程的任务函数在执行阻塞的网络I/O,或者在等待互斥锁,整个pthread系统线程就被挂起,其他的协程当然也无法得到执行了。

brpc在N:1协程的基础上做了扩展,实现了M:N用户级线程,即N个pthread系统线程去调度执行M个协程(M远远大于N),一个pthread有其私有的任务队列,队列中存储等待执行的若干协程,
一个pthread执行完任务队列中的所有协程后,也可以去其他pthread的任务队列中拿协程任务,即work-steal,这样的话如果一个协程在执行较为耗时的操作时,
同一任务队列中的其他协程有机会被调度到其他pthread上去执行,从而实现了全局的最大并发,充分利用多核。
	
brpc也实现了协程级的互斥与唤醒,即Butex机制,一个协程在需要等待网络I/O或等待互斥锁的时候,
会自动yield让出cpu(协程级别的挂起,不是线程级别的挂起),在适当时候会被其他协程唤醒,恢复执行。
在brpc中将一个协程任务可以称作一个bthread。
	
bthread粒度挂起与唤醒的设计原理:
由于bthread是在pthread系统线程中执行,在需要bthread间互斥的场景下不能使用pthread级别的锁,
否则pthread会被挂起,不仅当前的bthread中止执行,pthread私有的TaskGroup的任务队列中其他bthread也无法在该pthread上调度执行。
因此需要在应用层实现bthread粒度的互斥机制,一个bthread被挂起时,pthread仍然要保持运行状态,保证TaskGroup任务队列中的其他bthread的正常执行。

bthread粒度互斥的方案为:
如果位于heap内存上或static静态区上的一个对象A可能会被在不同pthread执行的多个bthread同时访问,则为对象A维护一个互斥锁(一般是一个原子变量)和等待队列,
同时访问对象A的多个bthread首先要竞争锁,假设三个bthread 1、2、3分别在pthread 1、2、3上执行,bthread 1、bthread 2、bthread 3同时访问heap内存上的一个对象A,这时就产生了竞态,假设bthread 1获取到锁,
可以去访问对象A,bthread 2、bthread 3先将自身必要的信息(bthread的tid等)存入等待队列,
然后自动yiled,让出cpu,让pthread 2、pthread 3继续去执行各自私有TaskGroup的任务队列中的下一个bthread;
	
bthread 1访问完对象A后,通过查询对象A的互斥锁的等待队列,能够得知bthread 2、bthread 3因等待锁而被挂起,
bthread 1负责将bthread 2、3的tid重新压入某个pthread(不一定是之前执行执行bthread 2、3的pthread 2、3)的TaskGroup的任务队列,
bthread 2、3就能够再次被pthread执行,这就实现了bthread粒度的唤醒。
	
唤醒只是从锁等待队列加入到了任务队列,并不一定马上能执行。协程挂起在用户层实现的,
加锁就是CAS并yield,没有走系统调用。相当于挂起就是从任务队列移出去,唤醒就是把这个协程加入任务队列。
	
// 锁变量的值。
butil::atomic< int > value;
// 等待队列,存储等待互斥锁的各个bthread的信息。
ButexWaiterList waiters;
    
附带介绍一下futex,在futex出现之前,想要pthread系统线程等待一把锁,有两种实现方式:
使用spinlock自旋锁,实现起来也很简单,spinlock属于应用层的同步机制,直接运行在用户态,不涉及用户态-内核态的切换。spinlock存在的问题是,
只适用于需要线程加锁的临界区代码段较小的场景,在这样的场景下可以认为一个线程加了锁后很快就会释放锁,等待锁的其他线程只需要很少的while()调用就可以得到锁;
但如果临界区代码很长,一个线程加锁后会耗费相当一段时间去执行临界区代码,在这个线程释放锁之前其他线程只能不停地在while()中不断busy-loop,耗费cpu资源,且在应用程序层面又没有办法能让pthread系统线程挂起。
	
直接使用Linux提供的pthread_mutex_lock系统调用,使用Linux内核提供的同步机制,可以让pthread系统线程挂起,让出CPU。但缺点是如果直接使用Linux内核提供的同步机制,
每一次lock、unlock操作都是一次系统调用,需要进行用户态-内核态的切换,存在一定的性能开销,
但lock、unlock的时候不一定会有线程间的竞争,在没有线程竞争的情况下没有必要进行用户态-内核态的切换。
	
futex机制是结合了spinlock和内核态的线程锁:
一个线程在加锁的时候,在用户态使用原子操作执行“尝试加锁,如果当前锁变量值为0,则将锁变量值更新为1,返回成功;如果当前锁变量值为1,说明之前已有线程拿到了锁,
返回失败,如果加锁成功,则可直接去执行临界区代码;如果加锁失败,则用类似pthread_mutex_lock的系统调用将当前线程挂起。由于可能有多个线程同时被挂起,唤醒时需要,
所以必须将各个被挂起线程的信息存入一个与锁相关的等待队列中;一个线程在释放锁的时候,也是用原子操作将锁变量的值改回0,如果与锁相关的等待队列不为空,
则释放锁的线程(必须使用内核提供的系统调用去唤醒因等待锁而被挂起的线程)。
	
在一个线上系统会产生大量的bthread,系统的cpu核数有限,如何让大量的bthread在有限的cpu核心上得到充分调度执行,实现全局的最大并发主要是由TaskGroup对象、TaskControl对象实现的。
每一个TaskGroup对象是系统线程pthread的线程私有对象,它内部包含有任务队列,并控制pthread如何执行任务队列中的众多bthread任务。
bthread在任务函数执行过程中yield挂起,则pthread去执行任务队列中下一个bthread,如果任务队列为空,则执行流返回pthread的调度bthread,
等待其他pthread传递新的bthread。挂起的bthread何时恢复运行取决于具体的业务场景,它应该被某个bthread唤醒(这样的例子有负责向TCP连接写数据的bthread因等待inode输出缓冲可写而被yield挂起、等待Butex互斥锁的bthread被yield挂起等)。

获取全局的ResourcePool的单例的接口函数为ResourcePool::singleton(),该函数可被多个线程同时执行,要注意代码中的double check:
 static inline ResourcePool* singleton() {
     // 如果当前_singleton指针不为空,则之前已经有线程为_singleton赋过值,直接返回非空的_singleton值即可。
     ResourcePool* p = _singleton.load(butil::memory_order_consume);
     if (p) {
         return p;
     }
     // 修改_singleton的代码可被多个线程同时执行,必须先加锁。
     pthread_mutex_lock(&_singleton_mutex);
     // double check,再次检查_singleton指针是否为空。因为可能有两个线程同时进入ResourcePool::singleton()函数,同时检测到_singleton值为空
     // 接着同时执行到pthread_mutex_lock(&_singleton_mutex),但只能有一个线程(A)执行_singleton.store(),
     // 另一个线程(B)必须等待。线程A执行pthread_mutex_unlock(&_singleton_mutex)后,线程B恢复执行,必须再次
     // 判断_singleton是否为空,因为_singleton之前已经被线程A赋了值,线程B不能再次给_singleton赋值。
     p = _singleton.load(butil::memory_order_consume);
     if (!p) {
         // 创建一个新的ResourcePool对象,对象指针赋给_singleton。
         p = new ResourcePool();
         _singleton.store(p, butil::memory_order_release);
     } 
     pthread_mutex_unlock(&_singleton_mutex);
     return p;
 }
 
从fd读取数据的方式:传统的RPC框架一般会区分I/O线程和worker线程,一台机器上处理的所有fd会散列到多个I/O线程上,每个I/O线程在其私有的EventLoop对象上执行类似下面这样的循环:
while (!stop) {
  int n = epoll_wait();
  for (int i = 0; i < n; ++i) {
    if (event is EPOLLIN) {
      // read data from fd
      // push pointer of reference of data to task queue
    }
  }
}

I/O线程从每个fd上读取到数据后,将已读取数据的指针或引用封装在一个Task对象中,再将Task的指针压入一个全局的任务队列,worker线程从任务队列中拿到报文并进行业务处理。
muduo网络库也是类似这种机制(主线程监听并创建连接fd,放入全局任务队列,工作线程获取任务,读取fd中数据)。
	
而brpc没有专门的I/O线程,只有worker线程,epoll_wait()也是在bthread中被执行。当一个fd可读时,读取动作并不是在epoll_wait()所在的bthread 1上执行,
而是会新建一个bthread 2,bthread 2负责将fd的inode内核输入缓存中的数据读到应用层缓存区,pthread执行流会立即进入bthread 2,bthread 1会被加入任务队列的尾部,
可能会被steal到其他pthread上执行,如果一个fd有多个可读,产生的多个bthread可以被steal到其他的pthread,利用多核的特性进行读取,达到更大的并发。

bthread 2进行拆包时,每解析出一个完整的应用层报文,就会为每个报文的处理再专门创建一个bthread,所以bthread 2可能会创建bthread 3、4、5,这样的设计意图是尽量让一个fd上读出的各个报文也得到最大化的并发处理。

多线程向同一个TCP连接写数据的设计原理:考虑brpc自带的示例程序example/multi_threaded_echo_c++/client.cpp,use_bthread为true的情况下,
多个bthread通过一条TCP长连接向服务端发送数据,而多个bthread通常又是运行在多个系统线程pthread上的,
所以多个pthread如何高效且线程安全地向一个TCP连接写数据,是系统设计需要重点考虑的。

为每个可被多线程写入数据的fd维护一个单项链表,每个试图向fd写数据的线程首先判断自己是不是当前第一个向fd写数据的线程,如果是,则持有写数据的权限,
可以执行向fd写数据的操作;如果不是,则将待写数据加入链表就即刻返回(bthread执行结束,挂起,等待响应数据)。

掌握写权限的线程,在向fd写数据的时候,不仅可以写本线程持有的待写数据,而且可以观察到fd的链表上是否还加入了其他线程的待写数据,
写入的时候可以尽量写入足够多的数据,但只执行一次写操作,如果因为fd的内核inode输出缓冲区已满而未能全部写完,则启动一个新的bthread去执行后续写操作,
当前bthread立即返回(被挂起,等待响应response的bthread唤醒)。

新启动的执行写操作的bthread,负责将fd的链表上的所有待写入数据写入fd(后续可能会有线程不断将待写数据加入待写链表),直到将链表清空。
如果fd的内核inode缓冲区已满而不能写入,则该bthread将被挂起,让出cpu。

等到epoll通知fd可写时,该thread再被唤起,继续写入。fd一般被设为非阻塞,如果fd的内核inode输出缓存已满,对fd调用::write(或者::writev)会返回-1且errno为EAGAIN。
这时候KeepWrite bthread不能去主动等待fd是否可写,必须要挂起,yield让出cpu,如果再写会导致线程级别的挂起。KeepWrite bthread直到通过一个原子操作判断出_write_hread已为NULL时,才会执行完成。

所有bthread都不会有任何的等待操作,这就做到了wait-free,当然也是lock-free的(判断自己是不是第一个向fd写数据的线程的操作实际上是个原子交换操作)。

举个例子:
假设T0时刻有3个分别被不同pthread执行的bthread同时向同一个fd写入数据,3个bthread同时进入到StartWrite函数执行_write_head.exchange原子操作,
_write_head初始值是NULL,假设bthread 0第一个用自己的req指针与_write_head做exchange,则bthread 0获取了向fd写数据的权限,
bthread 1和bthread 2将待发送的数据加入_write_head链表后直接return 0返回(bthread 1和bthread 2返回后会被挂起,yield让出cpu)。

T1时刻起,bthread 1向fd写自身携带的WriteRequest 1中的数据,执行一次写操作后,进入IsWriteComplete,判断是否写完(WriteRequest 1中的数据未写完,
或者虽然WriteRequest 1的数据写完了但是还有其他bthread往链表中加入了待写数据,都算没写完。)。

bthread 1所在的pthread执行进入IsWriteComplete,假设判断出WriteRequest 1中仍然有未写数据,并且_write_head也并不指向WriteRequest 1而是指向了新来的WriteRequest 3,
为保证将数据依先后顺序写入fd,将单向链表做翻转(如果有新来的WriteRequest暂时也不管它,后续会处理。这里先假设没有bthread加入新的待写数据)。

因为IsWriteComplete返回了false,仍然有待写数据要写,但接下来的写操作不能再由bthread 1负责,因为剩下的待写数据也不能保证一次都写完,
bthread 1不可能去等待fd的内核inode输出缓存是否有可用空间,否则会令bthread 1所在的整个pthread线程卡顿,pthread私有的TaskGroup上的任务执行队列中其他bthread就得不到及时执行。

因此bthread 1创建一个新的KeepWrite bthread专门负责剩余数据的发送,bthread 1即刻返回(bthread 1到这里也就完成了任务,会被挂起,yield让出cpu)。

T3时刻起,KeepWrite bthread得到了调度,开始写之前剩余的数据。假设一次向fd的写操作执行后,WriteRequest 1、WriteRequest 2中的数据全部写完,
WriteRequest 3写了一部分,并且此时又有其他两个bthread向_write_head链表中新加入了待写数据WriteRequest 4和WriteRequest 5,
KeepWrite bthread执行IsWriteComplete,并通过_write_head.compare_exchange_strong 原子操作检测到之前新增的待写数据WriteRequest 4、5,并完成WriteRequest 5->4->3链表的翻转。

假设在_write_head.compare_exchange_strong执行之后立即有其他bthread又向_write_head链表中新加入了待写数据WriteRequest 6和WriteRequest 7,
但WriteRequest 6和7在_write_head.compare_exchange_strong被调用之后是暂时被无视的,等到下一轮调用IsWriteComplete时才会被发现。

KeepWrite bthread继续尽可能多的向fd写一次数据,一次写操作后再调用IsWriteComplete判断当前已翻转的链表是否已全部写完,如此循环反复。
且同一时刻针对一个TCP连接不会同时存在多个KeepWrite bthread,一个TCP连接上最多只会有一个KeepWrite bthread在工作。

KeepWrite bthread在向fd写数据时,fd一般被设为非阻塞,如果fd的内核inode输出缓存已满,对fd调用::write(或者::writev)会返回-1且errno为EAGAIN。
这时候KeepWrite bthread不能去主动等待fd是否可写,必须要挂起,yield让出cpu,让KeepWrite bthread所在的pthread接着去调度执行私有的TaskGroup的任务队列中的下一个bthread,
否则pthread就会卡住,影响了TaskGroup任务队列中其他bthread的执行。

等到内核inode输出缓存有了可用空间时,epoll会返回fd的可写事件,epoll所在的bthread会唤醒KeepWrite bthread,
KeepWrite bthread的id会被重新加入某个TaskGroup的任务队列,重新调度执行,继续向fd写入数据。

以brpc自带的实例程序example/multi_threaded_echo_c++/client.cpp为例,该程序运行后,会与单台服务器建立一条TCP长连接,创建thread_num个bthread(后续假设thread_num=3),
在此TCP连接上发送、接收数据。RPC使用bthread同步方式:负责发送数据的bthread完成发送操作后,不能结束,而是需要bthread级挂起,
等待负责接收服务器响应的bthread将其唤醒后,再恢复执行。

在main函数中调用bthread_start_background创建3个bthread(此接口传入use_bthread=true是协程级别同步,use_bthread=false就是线程级别的阻塞)。
N个TaskGroup对象(后续假设N=4),每个TaskGroup对应一个系统线程pthread,是pthread的线程私有对象,每个pthread启动后以自己的TaskGroup对象的run_main_task函数作为主工作函数,
在该函数内执行无限循环,不断地从TaskGroup的任务队列中取得bthread id、通过id找到bthread对象、去执行bthread任务函数。

在TaskMeta对象池中创建3个TaskMeta对象(每个TaskMeta等同一个bthread),每个TaskMeta的fn函数指针指向client.cpp中定义的static类型函数sender,sender就是bthread的任务处理函数。

每个TaskMeta创建完后,按照散列规则找到一个TaskGroup对象,并将tid压入该TaskGroup对象的_remote_rq队列中(TaskGroup所属的pthread线程称为worker线程,
worker线程自己产生的bthread的tid会被压入自己私有的TaskGroup对象的_rq队列,本实例中的main函数所在线程不属于worker线程,
所以main函数所在的线程生成的bthread的tid会被压入找到的TaskGroup对象的_remote_rq队列),main函数执行到这里,不能直接结束,必须等待3个bthread全部执行sender函数结束后,main才能结束。

此时Client进程内部的线程状态是:
bthread状态:三个bthread 1、2、3已经创建完毕,各自的bthread id已经被分别压入不同的TaskGroup对象(设为TaskGroup 1、2、3)的任务队列_remote_rq中。
  	
pthread状态:此时进程中存在5个pthread线程:3个pthread即将从各自私有的TaskGroup对象的_remote_rq中拿到bthread id,
将要执行bthread id对应的TaskMeta对象的任务函数;1个pthread仍然在run_main_task函数上(因为创建了4个TaskGroup对象),等待新任务到来。

TaskGroup 1、2、3分别对应的3个pthread开始执行各自拿到的bthread的任务函数,即client.cpp中的static类型的sender函数。

调用Socket::Write函数执行实际的发送数据过程。Socket对象表示Client与单台Server的连接。向fd写入数据的细节已在上面阐述。
  
 在实际发送数据前需要先建立与Server的TCP长连接,并惰性初始化event_dispatcher_num个EventDispatcher对象(假设event_dispatcher_num=2),
 从而新建2个bthread 4和5,当bthread 4、5得到pthread执行时,会调用epoll_wait检测是否有I/O事件触发。

因为RPC使用同步方式,所以bthread完成数据发送后调用bthread_id_join将自身挂起,让出cpu,等待负责接收服务器响应的bthread来唤醒。

此时Client进程内部的线程状态是:bthread 1、2、3都已挂起,执行bthread任务的pthread 1、2、3分别跳出了bthread 1、2、3的任务函数,
回到TaskGroup::run_main_task函数继续等待新的bthread任务,因为在向fd写数据的过程中通常会新建一个KeepWrite bthread(bthread 6),
假设这个bthread的id被压入到TaskGroup 4的任务队列中,被pthread 4执行,所以pthread 1、2、3此时没有新bthread可供执行。

KeepWrite bthread完成工作后,3个请求都被发出,假设服务器正常返回了3个响应,由于3个响应是在一个TCP连接上接收的,所以bthread 4、5二者只会有一个通过epoll_wait()检测到fd可读。

此时会新建一个bthread 7去负责将fd的inode输入缓存中的数据读取到应用层,在拆包过程中,解析出一条Response,就为这个Response的处理再新建一个bthread,
目的是实现响应读取+处理的最大并发。因此Response 1在bthread 8中被处理,Response 2在bthread 9中被处理,Response 3在bthread 7中被处理(最后一条Response不需要再新建bthread)。

bthread 8、9、7会将Response 1、2、3分别复制到相应Controller对象的response中,这时应用程序就会看到数据了。bthread 8、9、7也会将挂起的bthread 1、2、3唤醒,
bthread 1、2、3会恢复执行,可以对Controller对象中的response做一些操作,并开始发送下一个RPC请求。

对异步rpc的一些理解:brpc还提供了异步机制(是一个Done的结构体,有Done是异步,没Done是同步),Done是从外部传入的,所以可以在本次调用结束后用Done这个对象,所以能异步。摘自源码的一句话:
// Because `done’(last parameter) is NULL, 
// this function waits until the response comes back or error occurs(including timedout).

我之前在开发过的一个服务,在服务端是异步(有done),但是我们是在函数返回之前调用的。
if (rpc_done) {
    rpc_done->Run(); // 异步等待读取消息且将消息写入指定的用户层缓存区
}
所以仍然是同步。

example中对于多线程向同一fd写有use_bthread选项可以选择,use_bthread为false是创建多个线程(这种情况下多个线程写之前也不用加大锁,内部是保证了同步机制的。
且Brpc底层有stub机制,哪个线程的哪个request发的,就会把回复放回对应的response里面),use_bthread为true为创建bthread,它内部会自动维护这套复杂的机制。

真正的监听在epoll_create和epoll_add后就开始了,epoll_wait就是去查询一下内核的就绪链表并返回,它有阻塞和非阻塞两种,阻塞就是要设一个超时时间。
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

成长是自己的事

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值