2021SC@SDUSC BRPC代码分析(十二) —— bthread-ExecutionQueue类详解

2021SC@SDUSC


一、简介

        这篇文章介绍ExecutionQueue类——执行队列。
        ExecutionQueue的相关技术最早使用在RPC中实现多线程向同一个fd写数据。在r31345之后加入到bthread。 ExecutionQueue 提供了如下基本功能:

  • 异步有序执行: 任务在另外一个单独的线程中执行, 并且执行顺序严格和提交顺序一致.
  • Multi Producer: 多个线程可以同时向一个ExecutionQueue提交任务
  • 支持cancel一个已经提交的任务
  • 支持stop
  • 支持高优任务插队

他有很多明显的优点。

  • ExecutionQueue的任务提交接口是wait-free的, 这意味着当机器整体比较繁忙的时候,使用ExecutionQueue不会因为某个进程被系统强制切换导致所有线程都被阻塞。
  • ExecutionQueue支持批量处理: 执行线程可以批量处理提交的任务, 获得更好的locality。
  • ExecutionQueue的处理函数不会被绑定到固定的线程中执行, 不同的ExecutionQueue之间的任务处理完全独立,当线程数足够多的情况下,所有非空闲的ExecutionQueue都能同时得到调度。同时也意味着当线程数不足的时候,ExecutionQueue无法保证公平性, 当发生这种情况的时候需要动态增加bthread的worker线程来增加整体的处理能力。
  • ExecutionQueue运行线程为bthread, 可以随意的使用一些bthread同步原语而不用担心阻塞pthread的执行。

上面提到相关技术最早使用在RPC中实现多线程向同一个fd写数据,这是一个比较典型的多生产者-单消费者问题,所以处理这种问题的队列,我们必须要着重提高他的入队效率,入队效率高意味着生产线程可以更快地处理新任务,而消费线程也能每次拿到一批任务批量写出,在大吞吐时容易形成流水线效应而提高效率。我们后续开始分析代码。

二、代码分析

首先从流程开始,我们首先要启动一个ExecutionQueue。创建的返回值是一个64位的id, 相当于ExecutionQueue实例的一个弱引用, 可以wait-free的在O(1)时间内定位一个ExecutionQueue, 这个id可以被复制适用到任何地方, 甚至可以放在RPC中,作为远端资源的定位工具。 同时必须保证meta的生命周期,在对应的ExecutionQueue真正停止前不会释放.
在这里插入图片描述
然后我们可以自己实现一个可以执行任务的类,在循环时箭头所指的位置执行即可提交任务。
在这里插入图片描述
给出一段执行demo。

#include <bthread/execution_queue.h> //实现异步写文件
#include <bthread/sys_futex.h>
#include <bthread/countdown_event.h>
#include <butil/time.h>
#include <butil/fast_rand.h>
#include <iostream>
#include <string>
#include <fstream>

using namespace std;

struct LongIntTask {
    long value;
    string name;
};

class Hello {
  public:
  	void start() {
  		bthread::ExecutionQueueOptions options;
    	bthread::execution_queue_start(&queue_id_, &options, print, NULL);
  	}

  	static int print(void* meta, bthread::TaskIterator<LongIntTask> &iter) {
      ofstream log_file;
      log_file.open("./log", ios::app);
	    for (; iter; ++iter) {
	        cout << "value:" << iter->value << ", name:" << iter->name << endl;
	        log_file << "value:" << iter->value << ", name:" << iter->name << "\n";
	    }
      log_file.close();
	    return 0;
	}

	void execute() {
		for (size_t i = 0; i < 10; ++i) {
	        LongIntTask t;
	        t.value = i;
	        t.name = "zhb";
	        bthread::execution_queue_execute(queue_id_, t, NULL);
	    }
	}

	void stop() {
		bthread::execution_queue_stop(queue_id_);
		bthread::execution_queue_join(queue_id_);
	}

  private:
  	bthread::ExecutionQueueId<LongIntTask> queue_id_;
};

int main(int argc, char const *argv[]) {
    Hello hl;
    hl.start();
    hl.execute();
    cout<< "===============" <<endl;
    hl.stop();
    cout<< "+++++++++++++++" <<endl;
    return 0;
}

接下来我们来看具体内部是怎么实现的。
任务执行的容器是TaskNode,主要是存储了一些任务相关信息,如下。
在这里插入图片描述
在执行任务的时候,会通过allocator分配空间后创建TaskNode,如下。这里的空间分配的重点是会根据提交的任务的数据类型所占用的空间来确定是直接利用node结构里预留的静态空间来分配还是额外动态分配。
在这里插入图片描述

allocate相关代码如下。
在这里插入图片描述在这里插入图片描述
然后我们看怎么启动这个队列。这个方法有四个参数,id用于唯一标识这个队列,options里面只有一个任务执行用的bthread的属性,第三个参数则是队列核心的执行函数,第四个则是这个队列的元信息,也就是传给执行函数的第一个参数,可以用于保存一些队列里各个任务都用到的公共数据。其中id和执行函数是必须的。
在这里插入图片描述
然后去找一下调用的create,在基类被实现。
在这里真正创建了一个queue,主要是从资源池里拿到一个实例,然后附上部分传入参数完成创建。
在这里插入图片描述
在这里插入图片描述
create里面传入的参数execute_func实际上使用了execute_task,clear_task_mem则是用来回收内存的,specific_function则是真正的用户函数,执行任务的时候会通过execute_task进行调用。
在这里插入图片描述
在这里插入图片描述
然后去看任务提交执行的代码。
可以看到有三种重载,以最基础的指定id和任务做拓展,又形成了指定option的版本以及指定option和handle的版本,handle是用于标记某一个具体的任务,用于取消任务,后面会再次提到。可以看出来,前两个方法都去调用了第三个方法。
在这里插入图片描述
第三个方法中,先根据id去定位队列,然后调用队列的execute方法。首先是根据task创建TaskNode,内存分配就是前面提到的小数据直接使用static mem,大的才额外分配,然后是设置options和handle,option只有两个,优先级和是否优先本地执行。
在这里插入图片描述
TaskOptions有两个内容,是否高优先级和是否优先本地执行。
在这里插入图片描述
创建好TaskNode后调用start_execute(node),开始真正对这个队列进行操作,可以看出这是一个用链表形成的队列,首先是对node的一些变量做初始化,如果是高优先级任务对高优先级任务计数器加1,然后把自己换入head,如果前head不为空,说明已经有线程在执行任务了,返回,否则进入下面的执行过程。
在这里插入图片描述
上面判断优先级,然后判断是否本地执行。如果优先本地执行则尝试本地执行,本地执行完任务后如果还有未执行的任务再启动bthread继续执行,否则直接启动bthread执行队列里的所有任务。
在这里插入图片描述
然后看下_execute_tasks,_execute_tasks的入口是一个待执行的TaskNode,通过死循环只要还有待执行的任务就会一直循环执行,如果当前要执行的节点已经执行,释放后指针后移到下一个任务。
在过程中要判断是不是高优先级的任务,最终执行都是去调用_execute函数。
在这里插入图片描述
执行过后释放掉执行过的任务节点,调用_more_tasks判断是否有更多任务,如果没有了break跳出死循环,里面有destroy_queue布尔变量,这个是表明整个队列是否要停止,具体到实现上就是往队列push一个特殊的任务stop_task,遍历到就让队列停止执行任务并回收资源。
在这里插入图片描述

在这里插入图片描述
然后_execute是进行任务执行的根本函数,首先判断head是否为空以及是不是 stop_ task,如果是,则执行完后返回ESTOP就是用于上面判断的标记,从而在_exectute_tasks函数里进行队列的destroy相关操作,执行上都是生成迭代器后使用execute_func函数调用用户函数_type_specific_function,其实分别也就是上面介绍过的execute_task。
在这里插入图片描述
接下来看_more_tasks,负责检查是否有更多的任务,先来看下参数,old_head是之前的头结点,就是上次执行的TaskNode,new_tail用于保存反转后的队尾,has_uniterrated表示old_head是否已经被执行。利用_head做cas操作如果成功说明head没变,直接返回,返回值则是根据has_uniterrated来确定,即便_head没变(没有新任务)但是当前任务还没有被执行还是要返回true,否则false。Cas失败则是后面还有其他任务。
然后进行链表的反转,注意看yield等待,这里是因为加入TaskNode时是先换到头结点然后再设置next指针,所以在翻转过程有可能还未连接,因此用yield等待连接成功再反转,_execute_tasks随后开启新的一轮循环进行任务执行。在这里插入图片描述
在这里插入图片描述
除了执行,前面也提到了可以取消一个任务。去调用该node的cancel。
在这里插入图片描述
要判断该node当前状态,如果还没有执行就可以成功取消,否则返回-1失败。
在这里插入图片描述

总结

以上就是今天介绍的全部内容,本文进行了bthread调度的任务执行队列部分详细源码分析,之后的博客会继续进行其他代码的分析。

猜你喜欢

转载自blog.csdn.net/m0_46306466/article/details/122162395