搜狗workflow异步调度框架

搜狗workflow异步调度框架

来源 https://zhuanlan.zhihu.com/p/172485495

参考 https://github.com/sogou/workflow/blob/master/README_cn.md

参考 https://www.zhihu.com/column/c_1456603443661643776

 

虽然我更新本博客比较慢,但是github上的workflow项目本身在持续更新中~来看看和上一篇相比我们都改了什么吧:

  • 加上了windows分支,以srpc性能看,网络还能快20%以上!
  • 加上了英文Readme,大家可以愉快地分享给歪果小伙伴了
  • tutorial里更改了展示使用内部组件waitgroup来等待的用法,让你做C++开发也能感受到GO一般的丝滑~

这里附上我们开源了一周的github地址:sogou/workflow

和这个系列的上一篇:1412:搜狗workflow异步调度框架 - 基本介绍篇

今天我还是要抱着跟大家学习的心态,迫不及待要从整体的角度来写一下workflow的架构。并且郑重声明一下,本篇只是本人作为开发者之一、想尽快和大家学习交流而写的个人梳理,并非我们项目的官方推广。后续如果要出官方介绍,可能会跟本篇的组织表达方式与立足点有所出入~

架构设计必然是从底向上开始,所以我们直接从kernel目录的设计思路开始聊。

1. 封装调度器

上次说到,我们作为异步调度框架,目前支持的异步调度资源分为6种:

这里可以举大家平时接触最多的网络通信框架和计算调度框架作为重点讲解一下。

我们需要封装调度器去操作这些系统资源,简单来说就是操作一批网络连接或者说线程。注意这里说的“操作”,也就是说调度器远不止连接池和线程池那么简单,我们要做的事情是:

  • 包含与管理资源池
  • 实现如何对一批连接尽可能高性能地响应其读写、如何尽可能快且尽可能通用地给出一个足够灵活的机制去让各线程执行各种计算
  • 提供请求接口给上层使用

我们以线程执行器Executor为例来看看具体怎么做。以上第二点尽可能快又足够灵活的机制,就是我们设计的ExecQueue,在以下代码得以体现:

class Executor
{
public:
    // 一次要执行的接口,对于线程执行器来说,就是把一个执行任务扔进某个队列中
    int request(ExecSession *session, ExecQueue *queue);

private:
    // 执行器和系统资源,是一个包含关系
    thrdpool_t *thrdpool;
};

2. 封装调度的基本单位

构思完了调度器,我们需要构思一下被调度的基本单位。

对应每种可以调度对象的系统接口,我们必须封装自己的结构,作为每次与系统资源交互的基本单位,通过调度器提供的请求接口,扔到调度器里被调度。

具体来说,这显然是一次网络交互、或者一次线程需要执行的计算任务。然后每个基本单位上,可以有上下文、供子类做具体实现的接口/函数指针等等。

我们以网络交互为例:

class CommSession
{
    // 往连接上要发的数据
    virtual CommMessageOut *message_out() = 0;
    // 连接上收到数据流,如何切下一个数据包
    virtual CommMessageIn *message_in() = 0;
    // 本次网络事件被响应的时机
    virtual void handle(int state, int error) = 0;
    // 一般我们的上下文是存在派生类上
};

阶段性总结一下,写到这里,我们就可以愉快地做网络收发或者线程调度了~这些模块都已经是可以单独拆出来用的。

作为框架,我们基于上述的多种调度器和调度单位,可以给用户封装各种具体网络协议和计算算法。但是这还不是我们的串并行任务系统的核心价值。

3. 任务流

我们想要实现任务流(无论是DAG还是串并连),意味着我们需要一套机制去按顺序触发具体的子任务执行、并接管其执行完之后要做的事情。实现的方式有很多,我们做了一套子任务系统来满足抽象的任务调度,而这个任务本身是网络通信还是计算,都不重要。

由于我们的子任务是要给异步框架用的,所以每个任务你不能只有一个接口:execute()之类,我们必须有开始执行的dispatch()和执行完毕的done()两个需要实现,而任务流系统本身只是做按顺序调起你的开始和结束这两个接口的事情。

class SubTask
{
     // 子任务被调起的时机
     virtual void dispatch() = 0;
     // 子任务执行完成的时机
     virtual SubTask *done() = 0;
     // 内部实现,决定了任务流走向
     void subtask_done();
     };

关于任务流,之后会详细介绍其概念,有做类似事情的小伙伴欢迎多多交流互相学习,我也会多翻阅一些资料再写,这是非常非常有意思的一个主题。

4.可以被任务流执行的基本调度单位

让每个基本单位可以被任务流执行下去,并且被某些调度器调度,做法很简单,从执行单位和子任务共同派生出来就可以了:

class CommRequest : public SubTask, public CommSession
{
    // 我们来实现以下SubTask的dispatch接口
    // 这个网络任务被调起,我们要做的事情,就是发送网络请求
    // 这个通过调用具体通信器的request去发消息
    void dispatch()
    {
        if (this->scheduler->request(this, this->object, this->wait_timeout,
                                     &this->target) < 0)
        {
            ;
        }
    }

    // 然后是CommSession的handle接口
    // 这个接口的意思是网络事件被响应的时机
    // 假设我们作为一个client,发送完请求后,我们关注的事件是这个fd上的写事件
    // 所以这里被调起意味着有回复了(当然也可能超时
    void handle(int state, int error)
    {
        // 处理各种错误
        // 我们在这里调用一下Subtask的subtask_done,让后续任务本身得以执行下去
        this->subtask_done();
    }
};

学习委员划重点:每一个可以被调度的基本单位,想同时具有子任务的属性,则必须子类里执行这个subtask_done(),以此打通任务流。

5.基本任务

我们目前为止,介绍的都是kernel的内容,现在我们来接触一下更为具体的概念:任务。

我们需要一层infrastructure的基本任务层,对接每一种具体的系统资源,比如:

ExecRequest封装出来的任务是个WFThreadTask,而CommRequest封装出来应该是个WFNetworkTask。这里可以看到,资源和任务都是一一对应的,这是目前个人认为框架内部做得比较好的抽象之一。

继续以网络请求看看,派生出来的任务应该长怎么样。看过我们的tutorial的小伙伴应该知道(前面文章也介绍过),我们有任务流Series的概念。所以这一层的基本任务,都需要做的事情是:

  • 管理好所在的series(没有的话,默默创建一个,这样别人才能串到你后边~
  • 异步所需要的上下文
  • 异步所需要的回调函数
template<class REQ, class RESP>
class WFNetworkTask : public CommRequest
{
    void start()
    {
        assert(!series_of(this));
        Workflow::start_series_work(this, nullptr);
    }

    // 这个user_data是给开发者用的
    void *user_data;

    // 这是网络任务本身的上下文:要发送的请求和要接收的回复
    REQ req;
    RESP resp;

    // 回调函数
    std::function<void (WFNetworkTask<REQ, RESP> *)> callback;
};

6.用户接口

刚才看到的已经是具体资源所对应的任务了~那么,我们在这些资源上,可以做什么?

  • 对于网络任务,我们需要做协议;
  • 对于计算任务,我们需要写算法;

网络任务的协议刚才看到,是两个模版类型,即我们通过某种特化就可以指定一种具体协议的网络任务了(显然没有那么简单!但是先这样介绍哈哈哈^_^

using WFHttpTask = WFNetworkTask<protocol::HttpRequest,
                                 protocol::HttpResponse>;
using http_callback_t = std::function<void (WFHttpTask *)>;

using WFRedisTask = WFNetworkTask<protocol::RedisRequest,
                                  protocol::RedisResponse>;
using redis_callback_t = std::function<void (WFRedisTask *)>;

using WFMySQLTask = WFNetworkTask<protocol::MySQLRequest,
                                  protocol::MySQLResponse>;
using mysql_callback_t = std::function<void (WFMySQLTask *)>;

using __WFKafkaTask = WFNetworkTask<protocol::KafkaRequest,
                                    protocol::KafkaResponse>;
using __kafka_callback_t = std::function<void (__WFKafkaTask *)>;

然后,因为我们是不依赖任何第三方协议库的,所以这些协议都是亲手解析的~写好了具体的HttpMessage,我们就可以特化出一个Http任务了。

所有用户通过工厂创建出来的任务,拿到的类型都在图二的User Interface层。

7.具体实现

每种资源所对应的做法都是非常对称的,让我们可以看到计算机世界的美,和巴赫的平均律一样精妙~

  • 网络对应的是协议、请求、回复
  • 计算对应的则是算法、输入、输出

    (P.S. 这里其实我认为“协议”应该改成“通信方式”哈,但是workflow是个成熟的框架了,它自己认为这里应该是“协议”与算法对称)

这里以算法任务来讲一下吧。我们一个排序算法,用户拿到的是个WFSortTask:

// 排序任务是线程的排序算法的特化,输入输出
template<typename T>
using WFSortTask = WFThreadTask<algorithm::SortInput<T>,
                                algorithm::SortOutput<T>>;
template<typename T>
using sort_callback_t = std::function<void (WFSortTask<T> *)>;

// 算法工厂
class WFAlgoTaskFactory
{
public:
    // workflow的所有任务都是要由工厂来create的~
    template<typename T, class CB = sort_callback_t<T>>
    static WFSortTask<T> *create_sort_task(const std::string& queue_name,
                                           T *first, T *last,
                                           CB callback);
    // 这个接口可以创建一个具体用来做并行排序算法的任务
    template<typename T, class CB = sort_callback_t<T>>
    static WFSortTask<T> *create_psort_task(const std::string& queue_name,
                                            T *first, T *last,
                                            CB callback);
    };

但是,具体到底是创建一个单一的排序任务,还是我可以并行排序,是由调用create_sort_task()还是create_psort_task()接口来决定的。这是我们设计框架时谢爷说得最多的一句话:

“一切都是行为派生!”

(P.S. 第二多的话有可能是"颖欣你这里写得不对啊"。。。anyway…

我们就可以看到图二,最上边的这层Implementation,是内部针对不同api所生成的具体实现,但是返回给用户的都是同一类task,这样用户在使用callback的时候,都是同一种参数,比如排序任务,大家都是:

std::function<void (WFSortTask<T> *)>;

8. 进程级资源管理

回到图一最上层: Instance Manager。

刚才说到的执行器,请求接口是把一个要执行的任务扔到一个队列里。这个队列是在哪里创建的呢?

我们全局会有进程级的一些资源,一般是使用单例模式,用户使用到的时候才会创建对应的资源管理器。上周有热心小伙伴提到过各种资源的纵向拆分问题,方便用户只用某种资源的异步调度,但是由于本身如果只用到网络,那么计算调度器是不会被创建的,所以一般来说编译到一起也没问题。如果小伙伴想编译时就拆开,目前来说还得自己改cmake~

 

 

Workflow的计算调度算法

来源 https://zhuanlan.zhihu.com/p/518265010

让大家好奇了这么久,终于写被cue到最多的话题:Workflow的计算调度,包括独创的调度算法与相关数据结构。

C++ Workflow作为一个异步调度编程范式,对调度的拆解是有几个层次的:

  1. 用户代码层:提供任务流级别的结构化并发,包括串并联、DAG和复合任务等,用于管理业务逻辑,组织要做的事情的依赖关系;
  2. 资源管理层:对不同资源内部做了协调和管理,比如网络、CPU、GPU、文件IO等,用最少的代价、做最高效、最通用的资源复用。

今天就重点介绍一下,Workflow内部独创的计算调度算法,包括Executor模块(仅200行代码)及相关模块,整体是如何管理计算资源、协调不同计算任务,从而做到无论任务耗时长短,都可以尽可能均衡调度的最通用的方案。

而且看完之后,也可以对上一篇《一个逻辑完备的线程池》中一直强调的生命周期有一个更好的理解。架构设计一直要强调每一个模块本身做到完备和自洽,是因为更有利于演化出上层模块。

1. 计算调度面临的问题

无论是用何种计算硬件,计算调度要解决的问题是:

  1. 充分使用资源;
  2. 不同类别的任务的资源分配;
  3. 优先级管理;

第一点很好理解,以CPU为例,只要来任务了就要尽量跑满CPU上的若干核。

第二点,不同类别是比如:每件事情由3种步骤 A->B->C 组成,耗费的计算资源是1:2:3,简单的做法是可以分别给予3个线程池,线程比例1:2:3(假设24核的机器,我们可以分别把3个线程池中的线程数配置为4,8,12)。

但由于线上环境是复杂多变的如果耗费资源变成了7:2:3,固定线程数方案显然不可取,不改动代码是难以匹配这样的状况。

这么做的另一个弊端是,如果一批提交100个a,那么显然只有4个线程可以工作,难以做到“充分使用资源”;

还有没有解决办法呢?更复杂地,可以引入动态监测耗时,然而引入任何复杂方案都会有新的overhead,绝大部分情况下这些都是浪费。

继续看第三点,优先级管理是比如:还是 A->B->C 三种任务。现在增加了一个D,我想要尽快被调起来,简单的做法往往是给所有任务一个优先级编号,比如1-32。

但这并不是长久的解决办法,编号是固定的总会往更高优的用完,而且任务自己都是贪心的,只要有最高优先级,最终大家都会卷起来(不是

我们需要的,是一个灵活配置线程比例、充分调度CPU、且可以公平处理优先级的方案。

2. 创新的数据结构:多维调度队列

Workflow内部几乎所有的方案都是往通用了做,对于CPU计算,则是:全局一个线程池,和统一的管理方式。使用上,计算任务只需要带一个队列名,即可交给Workflow帮你做到最均衡的调度。

基本原理图如下:

Workflow的计算调度架构图


Executor内部,有一个线程池和一个基本的主队列。而每个任务本身,同时也是属于一个ExecQueue,可以看到其结构是一组以名字区分的子队列。

这种数据结构的组合,可以做到以下三点:

  1. 首先,只要有空闲计算线程可用,任务将实时调起,计算队列名不起作用。
  2. 当计算线程无法实时调起每个任务的时候,那么同一队列名下的任务将按FIFO的顺序被调起,而队列与队列之间则是平等对待;
    例如,先连续提交n个队列名为A的任务,再连续提交n个队列名为B的任务。那么无论每个任务的cpu耗时分别是多少,也无论计算线程数多少,这两个队列将近倾向于同时执行完毕。
  3. 这个规律可以扩展到任意队列数量以及任意提交顺序。

分别来看看算法是什么。

第一点:Executor的线程不停从Executor内部的主队列中拿任务出来执行;

只要有资源,任务都可以被实时调起,与队列名无关

第二点:线程从主队列把任务取走、并准备执行任务之前,也把任务从它自己的子队列里拿走。并且,如果该子队列后面还有任务,就把下一个任务出来,放到主队列中。

每个队列名下的任务会按FIFO被调起,而子队列之间是公平的

第三点:外部用户给Workflow提交任务的时候,Workflow会先把任务按名字放到子队列。并且如果发现这是子队列中的第一个任务(说明刚才子队列是空的),便立刻提交到主队列中。

新增子队列的做法,非常好地解决了优先级问题

算法本身相当简单,而提交任务时,只需要给调度器轻微的指导,既队列名(对应Executor的一个ExecQueue),无需指定优先级或计算时间预估等信息。

当我们收到的A, B, C任务数足够多而且数量相等,无论任务以什么顺序到达,也无论每个(注意是每个而不是每种)任务的计算时间多少,A, B, C三个子队列将同时计算完成。

而主队列长度,永远不超过子队列的个数,且主队列中,每个子队列的任务永远只有一个,这是算法的必然结果。

3. 源码简析

我们用最简单的WFGoTask为例子,把抽象的调度算法从外到里一层层落实到代码上。

1) 用法示例

void func(int a);

// 使用时
WTGoTask *task = WFTaskFactory::create_go_task("queue_name_test", func, 4); 
task->start();

2) 派生关系

了解过Workflow任务的小伙伴一定知道,Workflow任何任务都是行为派生,而中间有一层,是基本单元,即由SubTask和具体执行单元双派生,这样既可以让上层任务被SubTask串到任务流里、也可以做具体执行单元做的事情。

对计算调度来说,具体执行单元那肯定是每个可以被线程调度起来的计算任务。

我们可以看到WFGoTask是从ExecRequest派生的,而ExecRequest就是执行的基本单元。(复习到网络层面,基本单元是CommRequest,一个代表执行,一个代表网络,对称性无处不在~)
打开src/kernel/ExecRequest.h文件可以找到它,这里只看dispatch()里做了什么:

 // 这里可以看到,具体执行单元是ExecSession,它负责和Executor等打交道
class ExecRequest : public SubTask, public ExecSession 
{
public:
    virtual void dispatch()
    {
        if (this->executor->request(this, this->queue) < 0)
        ...
    }

dispath() 做的事情,就是把自己和自己的队列,通过request()接口提交到Executor

3) Executor的生产接口:request()

int Executor::request(ExecSession *session, ExecQueue *queue)
{
    ...
    // 把任务加到对应的子队列后面  
    list_add_tail(&entry->list, &queue->task_list);

    if (queue->task_list.next == &entry->list)
    { // 子队列刚才是空的,那么可以立刻把此任务提交到Executor中
        struct thrdpool_task task = {
        .routine = Executor::executor_thread_routine,
        .context = queue
        };

        if (thrdpool_schedule(&task, this->thrdpool) < 0)  //提交
            ...
    }  
    return -!entry;  
}

4) Executor的消费接口:routine()

刚才看到线程真正执行一个任务的时候,是调用的executor_thread_routine(),传进去的context就是这个任务所在的子队列。

void Executor::executor_thread_routine(void *context)
{
    ExecQueue *queue = (ExecQueue *)context;
    ...
    entry = list_entry(queue->task_list.next, struct ExecTaskEntry, list);  
    list_del(&entry->list); // 从子队列里删掉当前正要执行的任务
    ... 
    if (!list_empty(&queue->task_list))
    { // 如果子队列后面还有任务(也就是同名任务),放进来主队列中
        struct thrdpool_task task = {
        .routine = Executor::executor_thread_routine,
        .context = queue
        }; 
        __thrdpool_schedule(&task, entry, entry->thrdpool);
    }
    ...
    session->execute(); // 执行当前任务... 跑啊跑...
    session->handle(ES_STATE_FINISHED, 0); // 执行完,调用接口,会打通后续任务流
}

4. 改造案例分享:用任务流替代传统流水线模式

在公司内部,最经典的改造案例就是用Workflow的任务调度替换掉传统的流水线模式,和开头介绍的按资源比例分配不同模块的线程数量是类似的,这对于某一个步骤突发增加的耗时、想要额外增加另一个步骤/module等,都是非常不灵活的方案。

每个t要分步骤ABCD,在pipeline模型下的流程

而Workflow的方案相比起来,则可以完美避开传统做法的许多弊端。

除此之外,实际的计算调度中还有一些问题,是非常考验框架的实现细节的。比如,错误处理好不好做,依赖和取消好不好做,生命周期好不好管理。虽然这些不是计算模块本身的事情,但Workflow的任务流这层都提供了很好的解决方案。

在上一篇线程池的文章po上网时,也有小伙伴问到:

做法如下简单分享一下:

5. 最后

如上总结一些心得吧。

我们做计算调度时往往忘了,根本要解决问题是A->B->C ,而不是关心1:2:3。如果常常要担心其他问题,往往是因为调度方案本身做得不够通用。只有一个最通用、最回归问题本质的架构方案,才可以让开发者不用关心其他问题,专注于提升自己的模块本身,也更方便上层做二次开发,为开发者提供充满想象力的无穷可能性。

另外也是作为上一篇逻辑完备的线程池的场景补充吧,把Executor等上层代码拿出来分析,才能真正感受到底层线程池中让任务本身可以调起下一个任务等做法的重要性。

Workflow内部有许多创新的做法,也许我本身有许多表达不好或者技术不到位的地方,但技术文章都是抱着一种分享新思路新做法的心态~ 那么,很希望可以看到大家的不同意见,欢迎发到项目的issue中~~~

 

性能优化上篇

https://zhuanlan.zhihu.com/p/184863276

今天这个话题是非常通用的入门话题:写完代码我们需要做什么最基本的系统性能优化。

由于workflow是个异步调度引擎,workflow的职责就是让系统各资源尽可能地利用起来,所以我的日常工作,除了写bug之外,还要配合开发小伙伴现场debug、分析用了workflow之后的各项指标是否还能进一步提升。我还是结合具体几类资源为线索来介绍:

  1. CPU:多路复用相关的线程数、计算相关线程数、多进程
  2. 网络:长连接短连接、连接数控制、超时配置、压缩
  3. 计时器:timerfd的优化
  4. 计数器:命名计数器与匿名计数器
  5. 文件IO:实际场景用得少,先不写了
  6. GPU:目前我只做了demo版,所以没有放出来,也先不写了

其中计时器和计数器相对简单一些,我会这里介绍下内部实现,其他的内部实现做了很多优化,每个话题都值得以后单独写一下。

一、CPU

先来看看我们的配置项:

static constexpr struct WFGlobalSettings GLOBAL_SETTINGS_DEFAULT =
{
    .endpoint_params    =   ENDPOINT_PARAMS_DEFAULT,
    .dns_ttl_default    =   12 * 3600,
    .dns_ttl_min        =   180,
    .dns_threads        =   4,
    .poller_threads     =   4,
    .handler_threads    =   20,
    .compute_threads    =   -1,
};

1. 基本网络线程

一般用epoll的框架都需要对其进行类似proactor式的封装,那么就要负责做以下事情,以及决定具体哪个线程去分工:

  • 对epoll具体某个fd进行读写
  • 读写时把完整数据包切下来
  • 数据包切完之后的解析(即反序列化)
  • 执行用户的操作

Workflow当前的做法,poller_threads线程是去操作epoll读写和做fd读的切消息的事情,而handler_threads是做基本用户操作的,比如callback和作为server的话,我们的process函数所在的线程。

brpc是不区分的,我个人理解是因为这两点:

  • 它套了一层bthread做换线程的调度;
  • fd上拉了写链表:没人在写你就写,有人在写你就把数据扔下就行了,这个人会帮你写;

Workflow没有做这样的优化,主要还是因为一个进程内网络读写和业务操作压力比例基本是差不多确定的,业务上线前的调优调整一下poller和handler线程比例基本足够了。而且workflow才1岁多,很多优化可能会往后放。

这里也顺带说一句,对于把数据包切下来和切完之后的解析,其实有些协议是不太能分得开的。我鶸鶸地给大家列一下,从协议设计上,可以分以下三类:

  1. 收到消息就能知道我怎么完整地切一条消息出来;
  2. 收一点之后判断一下才能知道我怎么切一条消息出来;
  3. 一边收数据流一边解析,不到最后一刻都不知道是不是收完。

第1种就很简单,一般做rpc协议我们都会友好地在头部告诉你大概多长。

第2种有点类似HTTP这样,大概收完头部你就知道后边还有多少了,这个时候你收header,是要自己边收边parse的。

第3种比如MySQL这种吐血的协议,大概它在设计的时候就没有想过你要多线程去操作一个fd读消息,你得根据当前哪种包的状态再判断,这种必须写个状态机去完整收完了才能交给用户。而这个收的期间,我已经把每个field和每个ResultSet给解析出来了,收完基本等于数据反序列化也做完了。

所以第2种、第3种,对于切完整消息和解析消息的反序列化操作其实并不会太分得开,workflow都会在poller_threads里做。

2. 计算线程

我们内部会有独立的计算线程池,默认是和系统cpu数一致的数量,这个是基本可以减少线程数过多而频繁切换的问题,当然如果用不到计算任务,此线程池不会创建。

和cpu数一致,那么不同时期不同类型的计算任务占比不同,这个workflow怎么解决呢?我们内部用了一个谢爷发明的多维队列调度模型,已经申请专利,以后有机会让谢爷写一篇给大家讲讲>_<

简单来说,workflow的计算任务都是带名字的,对于业务开发来说,基本只需要把同一类任务以同一个名字去创建,那么start之后是基本可以保证不同名字的任务被公平调度,并且整体尽可能用满计算线程数,这是一种比优先级和固定队列要灵活得多的做法。

P.S. 我们也有独立的DNS线程池,但是DNS目前的路由模式我觉得要并发去更新真的非常粗暴非常不喜欢,有空了路由机制是我第一个要动刀改进的地方!(认真立flag中o(* ̄▽ ̄*)o

3. 多进程

一般来说我们不太需要多进程,但是不可避免的情况下,先前有个场景确实需要小伙伴拆多进程:使用Intel QAT加速卡多线程会卡spinlock,这个前几篇文章有个系列已经提到过。

通用点说多进程,一般来说我们作为server的做法是先bind再listen,然后fork多个进程,然后,重点是在于,你这个时候再去epoll_create,那么操作系统来保证连进来同一个端口的连接不会惊群accept。

这个我个人的理解是:

  1. 首先我们bind并listen,是保证多个进程拿到同一个listen_fd。
  2. 然后先fork再epoll_create,意思是由多个epoll去listen同一个listen_fd。由于epoll不是用户态的,操作系统来保证同一个listen_fd的accpet只会被一个epoll来响应,所以不会有惊群。

说回workflow~workflow的server想做成多进程就很简单了:用WFServer::serve()接口,做以上fd自己bind、listen,再fork多次的事情就可以了。

也给大家列一下demo测试中多进程操作加速卡的性能。绿色的点是nginx(只能打到8w),nginx本身就是多进程单线程的,但是由于QAT只以多进程纬度来处理并发,因此我们只以进程数对比,基本轻松上10w了。并且说一下,QAT加速卡如果只做RSA计算,极限QPS也就是10w左右。

以及短连接、长连接情况下多进程、多线程在我们小伙伴调用QAT加速卡每个请求做2次RSA解压时的QPS对比情况:

这里的短连接长连接,就当作给大家抛砖引玉网络调优话题,但今天来不及,明天继续写下篇~~~

 

性能优化下篇

https://zhuanlan.zhihu.com/p/484293077

一、 和事件循环不一样的全新玩法

有趣的新东西放第一部分说:Workflow使用epoll的方式有什么不同?

答案是线程模型。

我们常用epoll的三个接口:create、ctl、wait。接口是线程安全的,感谢小伙伴指出~

那么连接多了的时候,异步要做的就是用尽可能少的线程去管理fd,以节省创建销毁线程的overhead以及线程所占用的内存和对资源的争抢。

所以高性能网络框架,都要管理着自己的多个线程(或者nginx的多进程)对epoll进行操作,并对上层提供原子性的语义。

好,我们现在给n个网络线程去操作epoll,全局这么多fd怎么分配和管理呢?

我们以前都见过的通用的做法是事件循环,用one loop per thread的方式进行分配和管理的。

以下我描述一下我弱弱的几点理解:

  1. 如果是server,是被动方,那么要做好accept工作
  2. 如果是client,是主动方,那么要做好connect工作
  3. 这些都是要从全局的角度来分发fd
  4. 然后按照这n个线程当前的负载量分发给一个人,这个人来全面负责这个fd的:吃(增)喝(删)拉(改)撒(等)

而Workflow的方式不一样:

  1. 分发部分我们先简单地对fd进行n取模毕竟建立连接大家也是异步做的呀,连接的响应已经可以交给网络线程去做了
  2. 然后这个网络线程就继续做等待这个被分配的fd以及响应它的所有事件
  3. 并且,敲黑板~,如果一个线程在epoll_wait,另一个线程向epoll里添加,删除或修改fd这在Workflow里都是常规操作,因为epoll、kqueue都是支持这个特征的

 

所以看到这里边最大的区别是什么了吗?

事件循环是通过eventfd或者其他方式打断epoll_wait来添加fd。显然,这个做法在很多场景下其实对性能是有影响的。

如果对一个的操作有变动,Workflow怎么做呢?我们会通过一个pipe事件通知这个poller thread。

举个例子。如果要删除一个fd,那么如果别人把fd从epoll删除,删除之后就没有契机告诉该poller thread去做它要做的事情(最典型的,比如,删掉对应的上下文或者调用钩子等)。所以要借助pipe事件来通知“删除”这件小事儿,而这个等这个poller thread下次有正事儿要做的时候,再一并处理就完了,无需现在叫醒它就为了干点小事儿。

好奇宝宝你可能会问:fd直接取模难道不会不均匀吗?

这里有个很重要的设计上的优化理念。

Workflow从来不做空跑QPS之王,Workflow做的是一个跑得又快又稳的通用企业级框架,所以贯穿整个项目一个设计理念就是

面向全局优化:

即,比起尽可能优化一个请求得到最优性能,我们更倾向于优化整体的请求得到最优性能。

如果系统本身很忙,那么其实连进来的大部分fd都会比较繁忙,因此暂时还不需要去做考虑极端情况下的负载均衡,取模就够用了。毕竟每个优化步骤都是有点小开销,到底优化谁,这是个非常compromise的事情。

这个优化思路后面还会持续看到~

虽然这种线程模型的新做法,不一定会成为Workflow高性能的最决定性因素,但却是我个人觉得最值得分享的新思路,可以让我们这些暂时还没有把底层吃透、没办法上来就创新的入门开发者,也看看业内现在有了不一样的眼前一亮的有趣方案,也让我们可以不要那么浮躁,不要为了快速出成绩浪费了自己的思考机会,而应该大胆设计,小心实现。

二、比proactor走得更远:消息的语义设计

上一部分讲的,除了封装多线程以外,网络库还要提供我们所设计的接口。

而Workflow的另一个不同点在于,它不是网络库,而是从网络模块到上层具体协议、任务流都有的成型框架。所以提供的接口语义并不是proactor、reactor,Workflow的语义是以消息为单位的。

为了简单起见,这里以收消息为例:

  • Reactor是有事件来了,我告诉你,你负责去读出来;(epoll所提供的功能)
  • Proactor是你给我一片内存,我把数据读出来了之后告诉你,一次通信的消息可能你是要读好几次才能读完的;(iocp,以及很多网络库的做法)
  • Workflow是别管事件来了和读多少几次,我会帮你把你要的完整消息都收好了,再叫你;(也就是上层的每一个任务)

这显然更加符合人类的自然思维,接口的简洁和易用也是我们对Workflow一直以来的坚持。

我们依然从底向上,看看一个消息长什么样:

  1. pollet_message_t
struct __poller_message poller_message_t;

struct __poller_message
{   
    int (*append)(const void *, size_t *, poller_message_t *);
    char data[0];
};

最底层很简单,一个钩子,以及一片内存。

2. CommMessageIn

class CommMessageIn : private poller_message_t
{
private:
    virtual int append(const void *buf, size_t *size) = 0;
    

这个派生类是收到的消息,增加了一个append接口:
数据来了上层可以拿走,并通过ret告诉底层核心之后的状态。这里的size是个双向参数,你甚至可以告诉我你现在要拿走到哪里,剩下的我帮你接管、下次再给你。

其中:

  • ret返回1表示消息收完;
  • ret返回0表示还没收完;
  • ret<0表示消息错误。

正是这个返回值让底层核心知道如何切出一份完整的消息。

3. CommMessageOut

class CommMessageOut
{
private:
    virtual int encode(struct iovec vectors[], int max) = 0;
    

发送接口也很简单。

到此就是核心通信器所做能看到的消息接口。但作为一个成熟的框架,我们认为还远远不够方便,因此消息的语义我们继续往下看:

ProtocolMessage会从CommMessageIn和CommMessageOut派生,因为对于server来说,in就是request,out就是response,而对于client来说,out就是request,in就是response,我们会需要收发两种功能。

class ProtocolMessage : public CommMessageOut, public CommMessageIn;

而消息收完了没有,是由协议开发者来做的:具体协议需要派生ProtocolMessage来实现刚才说的appendencode接口。

因此Workflow的Http、Redis、MySQL、Kafka、DNS等协议都是自己手写解析的,这样才能真正做纯异步、收发不受原生模块的线程模型影响,这也是性能足够快的关键点之一。

Workflow网络模块、消息队列、上层协议的架构关系

Workflow的很多用户是在用作异步MySQL客户端,虽然我们认为MySQL性能瓶颈应该在server才对,但是只要想提升并发,原生client就会捉襟见肘,而且随着目前MySQL协议各集群的崛起,Workflow伪装成MySQL client的时候,对方也常常并不是原生MySQL server,而是tidb之类的其他集群版伪装者(开源世界就是如此有意思~

三、特殊的异步写实现

如果你是后端开发,你就会有同感,我们处理的绝大部分tcp请求场景其实都是一来一回的,这是Workflow最擅长的领域,以至于在这在场景下,Workflow又出现了一个新思路:

高效的异步写实现。

我们知道fd天生是可以同时监听EPOLLOUT和EPOLLIN的,如果这样做,我们的网络库可以在有事件处理的时候,既看看是不是要读、又看看是不是要写,这些都在这次loop被叫醒的一次中做。

Workflow的做法都如下:

  • 无论是client或者server,fd长期都保持EPOLLIN状态;
  • 需要写数据时,先同步的写,如果数据可以全部写入tcp buffer,则无需改变fd的状态;
  • 如果数据无法全部写入,通过epoll的MOD,原子性的把fd从EPOLLIN状态改为EPOLLOUT状态开始异步发送。
  • 异步发送完成(fd会从epoll里删除),再把fd以EPOLLIN状态重新加入epoll。

而且为了性能考虑,我们的poller_node是一个以fd为下标的数组,而每个node只能关注一种事件,READ或WRITE。然后我们会通过operation来判断调用哪个处理函数(而非用event来判断)。

这种做法实现出来的通信器,其实比任何全双工都要快。

因为大多数情况下,没有必要进行异步写。操作系统会动态调整TCP send buffer的大小,从100多K逐渐增加至少10M。需要异步写的场景很少,所以epoll里的fd基本不用动不会有额外开销。如果真的需要异步写,基于一来一回的模式下这个fd只写的模式那也是炒鸡快的。

四、超时处理是一门学问

超时难不难?超~难。

难在哪里呢?我个人理解是难在于精确地响应、高效的管理、和严格的原子性。

我们先看看精确地响应。

首先要基于一个足够精确的机制,所以我们用了linux的timerfd(kqueue的话用了timer事件,没错依然辣么统一~)。每一个负责操作poller的线程,都有一个timerfd去负责当前该线程所有在监听的fd的超时事件。

这就够精确了吗?那可太天真了,毕竟网络那么多步骤,而且我们不希望用户每个请求都去关心connect、request、response这么多阶段。

那怎么办呢?我们先对超时提出了几个阶段,最底层是全局给几个配置:

  • connect_timeout: 与目标建立连接的超时。
  • receive_timeout: 接收一条完整请求的超时。
  • response_timeout: 等待目标响应的超时。代表成功发送到目标、或从目标读取到一块数据的超时。

这个每读一块数据的response_timeout值得注意,在实际网络链路不好的情况下需要阶段划分出来,否则会很惨烈(相信我我是过来人

再往上一层,我们开发者还需要对连接层有超时管理的权利:

  • keep_alive_timeout: 连接保持时间。默认1分钟。redis server为5分钟。

再往上,每一个任务。

如果连接已经达到上限,默认的情况下,client任务就会失败返回。但是我们允许通过任务上的一个超时值,来配置同步等待的时间。如果在这段时间内,有连接被释放,则任务可以占用这个连接:

  • wait_timeout: 全局唯一一个同步等待超时。

再往上?就不是框架需要管的事情了,众所周知我们提供了WFTimerTask与任务流,所以我们可以用WFTimerTask和我们的业务逻辑的task一起随意搭配组装使用~让你写代码都能感受到拼乐高的乐趣~~

以上的架构设计,足以满足我们对网络请求中精确的超时控制。

我们再看看高效的管理。

目前的超时算法利用了链表+红黑树的数据结构,时间复杂度在O(1)和O(logn)之间,其中n为网络线程的当前管理的fd数量。

超时处理目前看不是瓶颈所在,因为Linux内核epoll相关调用也是O(logn)时间复杂度,我们把超时都做到O(1)也区别不大。

最后,严格的原子性,设计我们配合第五部分的代码一起感受一下。另外涉及到Communicator的状态转换,所以就需要放到单独的代码解析中了(并不是这篇文章写到这里写累了._.

五、循环部分的代码讲解

我们把src/kernel/poller.c中,一个网络线程所执行的核心函数拿出来一行一行解读:

static void *__poller_thread_routine(void *arg) // 线程函数入口
{
    poller_t *poller = (poller_t *)arg;
    __poller_event_t events[POLLER_EVENTS_MAX]; // 刚才提到的以fd为下标的超级快的poller_node数组
    struct __poller_node time_node;
    struct __poller_node *node;
    ...

    while (1)
    {
        __poller_set_timer(poller);   // 继续开始等待之前,需要更新本轮要等的下一个超时时间,就是这里设置的timerfd
        nevents = __poller_wait(events, POLLER_EVENTS_MAX, poller); // 2000 years later ...
        clock_gettime(CLOCK_MONOTONIC, &time_node.timeout);
        has_pipe_event = 0;
        for (i = 0; i < nevents; i++) // 对于每个本线程要处理的已经ready的事件
        {
            node = (struct __poller_node *)__poller_event_data(&events[i]);
            if (node > (struct __poller_node *)1)
            {
                switch (node->data.operation) // 一个正在监听的node(一个会话、或文件读写操作等)只会有一种状态
                {
                case PD_OP_READ:
                    __poller_handle_read(node, poller);
                    break;
                case PD_OP_WRITE:
                    __poller_handle_write(node, poller);
                    break;
                ... // 还有很多其他异步操作,比如连接也是异步的,以及ssl的复杂操作。建议大家先看nossl分支学习
            }
            else if (node == (struct __poller_node *)1)
                has_pipe_event = 1; // 我们使用了node==1标记pipe事件,毕竟fd为1不可能是合法地址,用之~
        }

        if (has_pipe_event) // 这里说明本轮有pipe事件,pipe用来通知各种fd的删除和poller的停止
        {
            if (__poller_handle_pipe(poller))
                break;
        }

        __poller_handle_timeout(&time_node, poller); // 处理超时事件,如上所述从红黑树和链表里把所有超时的节点都处理掉
    }
    ...
}

六、还有什么优化Workflow目前不做

本质上,workflow优化的主要方向都是:

通用地用尽量少的系统资源去做尽可能多的事情。

所以,Workflow的通信器是全世界最快的通信器吗?很显然不是。

Workflow只是一个够快够稳够简单好用的、并且携带很多新思路的企业级框架,而且其异步特点在于可以做到计算+网络通讯的调度无损耗。

这个得益于很多方面,上述这些折中的选择其实非常重要,另外还得益于架构层面对资源的一视同仁、接口设计的对称性、任务流的编程范式设计等。

并且,刚才有提到,Workflow做的优化决策,都是面向全局的,这样的例子还有很多。

有很多高性能优化方向,Workflow目前没有用到,并不是东西不好,而是很多时候目前还没有必要(或者通过实际测试得出对比数据,发现必要性没有想象中的高),或者优化思路不太通用,包括以下这些:

  1. 消息收回来,一定会切一次线程,过一次消息队列。(某些空跑场景下显然不切会更好,但Workflow还是走实用路线~
  2. workstealing队列(这个很多调度系统包括go内核都在使用,但Workflow的队列依然只是一个简单的队列,我原先有写过多种简单队列的对比,目前用的双队列模式是简单队列里最快的。
  3. 其他无锁技术,以及有很多框架都会使用eventfd去替代cond,或者对cond进行cache优化(这也是一个很有趣的方向,但前者我还没尝试。后者实践过,cache优化听上去很美丽但没有测出实际的性能提升。
  4. cpu亲和性相关的优化(在服务器已经够繁忙的时候这个优化并不是那么有必要,但后续有空我会去学习一下~
  5. 各种用户态的优化(用户态协程、用户态协议栈~~~
  6. 各种内核态的优化(比如出来十几年最近突然又火了的技能树eBPF

七、最后

希望这篇优化,除了新的epoll使用方式、新的异步写方式等新思路以外,更多地是分享一些做事情的方法。

 

==============

500 Lines or Less        https://github.com/aosabook/500lines

 

基本介绍篇  https://zhuanlan.zhihu.com/p/165638263

消息队列优化 -- 几种基本实现   https://zhuanlan.zhihu.com/p/110556031  

消息队列优化 -- 鶸的介绍篇  https://zhuanlan.zhihu.com/p/110550451

消息队列新实现:Workflow msgqueue代码详解    https://zhuanlan.zhihu.com/p/525985268

一个逻辑完备的线程池        https://zhuanlan.zhihu.com/p/503733481

任务的生命周期与server任务callback        https://zhuanlan.zhihu.com/p/391013518

转发服务器与series上下文的使用       https://zhuanlan.zhihu.com/p/390437518

延迟返回的server       https://zhuanlan.zhihu.com/p/388958241

定时器的使用        https://zhuanlan.zhihu.com/p/388520793

抓取网站首页        https://zhuanlan.zhihu.com/p/388519231

 

=============== End

 

posted @ 2022-09-21 23:50  lsgxeva  阅读(1488)  评论(0编辑  收藏  举报