百度统计
一面之猿网
让这个世界,因为我,有一点点的不一样
纯序员给你介绍图化框架的简单实现——线程池优化(四)

大家好,我是不会写代码的纯序员——Chunel。有两周的时间没有更新文章了哈,主要是这上上周我copy了一段功能代码,没测试就直接发布到线上了。然后这两周就一直在找bug了。当然了,如果不是组里朋友一起来帮忙,可能还会耽搁的更久。

在之前的几篇文章里,我们主要跟大家介绍了线程池中一些增加扇入扇出、增加负载的优化思路和方法。今天,我们跟大家聊一下CGraph中的threadpool实现过程中,在工程层面做的一些考量。主要会涉及到 避免busy waiting分支预测优化减少无用copy 等机制,主要是针对工程代码进行一些优化。有些东西看似跟多线程没有什么关系,但是落地后的提升是明显的——甚至会比线程调度理论上的优化,来的更加明显。

首先,还是照例,先上源码链接:CGraph源码链接
其中,线程池的实现在 /src/UtilsCtrl/ThreadPool/ 文件夹中


避免busy waiting

写多线程代码的童鞋会知道,单个线程在一些特定的流程中(比如:等着队列中来信息),会有可能陷入不定期的无效等待中去,并且这种等待是不会自动释放cpu资源的,这就是所谓的busy waiting逻辑。

image.png

这样做的缺点,是显而易见的:在自身无用等待的过程中,也阻碍了其他线程顺利执行——像极了自己写了bug却查不出来,还要拉着同事一起来查的纯序员本员了

我举个例子:

void push(UTaskWrapper&& task) {
    while (true) {
        if (mutex_.try_lock()) {
            // 可能出现长期无法抢到锁的情况
            queue_.emplace_front(std::move(task));
            mutex_.unlock();
            break;
        } else {
            // 让出cpu执行权
            std::this_thread::yield();
        }
    }
}

看上面一段代码,多个线程往一个queue_中push任务的时候,可能遇到一些情况,使得mutex_被其他的线程占着。这个时候,是等着抢到锁之后再进行接下来的操作,还是直接让出当前线程的执行权,过n个时间片再来重新尝试一次?我想绝大部分情况下,应该选择后者。而yield()函数的用途,就是使得当前线程让出cpu执行权。

之所以在最外面加了 while (true),是因为无论尝试多少次,最终总需要把这个任务push进queue_中——总不能半途而废吧。

顺便说一句,这种busy waiting的现象,在cas操作和atomic操作中会经常出现。cas是因为需要一直比较expectedptr是否一致(这里水很深,懂的都懂,不懂的朋友自己多查一下),而atomic是因为需要保证当前线程不退出。

分支预测优化

来看下面很简单的一个逻辑,函数入口处先判定一下传入的指针是否非空,非空的话则继续往下执行。

void function(CObject* ptr, CParam* param) {
    if (nullptr == ptr || nullptr == param) {
        return;
    }

    // do something
    ptr->doSomeThing(param);
}

这种写法,在一定程度上体现了编程的严谨性。但是有个小问题,如果所有函数的开头处,都对所有的指针入参做非空判断,这是不是一个很繁杂的逻辑。而且很多指针(比如:指针型成员变量)基本上是初始化一次,之后就都不会为空了。还要每次进入函数开头进行逻辑判断么?为此,我们引入了执行分支预测逻辑。

image.png

先来简单的说一下程序执行的流程哈。我们上面的那段代码虽然是线性的流程,但是在执行的时候,程序在if那个地方,并不会等着判断条件(例中为:nullptr == ptr,也可能是一个非常复杂的计算函数)得出一个true or false的结果后,再决定从哪个分支开始执行。

执行逻辑是:当遇到if判断的时候,“随便”选一个分支(反正是2选1嘛,蒙对的概率还不小)把对应的指令加载进来执行。如果蒙对了最好;蒙不对,就掉头回去,再执行另外分支的逻辑。从而在一定程度上,达到了加速执行的目的——像极了还不确定代码功能是否正常,就直接发布到线上,有问题再回滚的纯序员本员了

那我们再往后想一步,针对例子中这种入参为空逻辑,是不是应该极大概率不会出现呢?这个时候,如果我们能给编译器一个明确的提示,是不是就能从50%的命中率,提高到90%甚至是99.99%呢。

#define likely(x)   __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

bool stealTask(UTaskWrapperRef task) {
    if (unlikely(pool_threads_->size() < CGRAPH_DEFAULT_THREAD_SIZE)) {
        // 线程池还未初始化完毕的时候无法进行steal。确保程序安全运行
        return false;
    }

    // do something
    return true;
}

结合代码说一下,代码中 当且仅当程序刚开始运行,线程池中所有的Primary线程未初始化完毕,且未初始化线程又被steal的情况下 才会出现if那个分支为true的情况。这个时候,我们可以明确的告诉编译器,这里unlikely(不太可能)被执行。

__builtin_expect是cpp自带的函数,这个没什么好说的。一句话介绍一下为什么是!!(x):目的就是为了将x值变成一个bool类型。例:

  • !!(5) = 1
  • !!(1) = 1
  • !!(0) = 0

注:我再来解释一下我刚才随便说的那个“随便”哈。这其中的优化算法和调度逻辑,是很多资深的行业大佬和一些巨头科技公司一起合作研究出来的,复杂程度难以想象。如果不是专门研究这个方向,我认为知道有这事即可,术业有专攻嘛。


减少无用copy

C++ 中存在着各种形式的或默认或自定义的赋值构造,拷贝构造。搞不好的话,会出逻辑问题;搞好的话,来回赋值其实也是挺费时费力的。最好的情况,就是不用copy,直接转移当前对象——类似C++11中提出的emplace概念。

为此,CGraph中threadpool在开发过程中,全程传参和赋值中,采用的都是std::move和emplace的传递方式,尽可能的避免出现中间流程无意义copy的情况。

同时,在指针类型的选取方面,也是基本上采用原生指针,自行对资源进行分发和管理。仅个别会不定期申请/释放的资源会通过make_unique进行申请,采用unique_ptr进行生命周期管理,并且禁止在内部使用shared_ptr。这些,都是基于性能方面的考量。

注:shared_ptr和unique_ptr在反复多次申请和来回赋值的情况下,有一定的性能差距,同时,shared_ptr自身内存占用也比unique_ptr大(主要都是因为shared_ptr中的cas校验机制)。很多大型项目,是明文禁止使用shared_ptr的。大家平日写代码的时候,可以注意一下。


image.png

在这里跟大家分享一个我前段时间遇到的一个问题:

#include <iostream>

struct Message {
    Message(std::string msg) : msg_(std::move(msg)) {
    }

    Message(const Message& msg) : msg_(std::move(msg.msg_)) {
        std::cout << "copy construct\n";
    }

    Message(Message&& msg) : msg_(std::move(msg.msg_)) {
        std::cout << "move construct\n";
    }

    // 如果把这个函数注释掉,会如何执行??
    Message(const Message&& msg) : msg_(std::move(msg.msg_)) {
        std::cout << "right move construct\n";
    }

    std::string msg_;
};


int main() {
    const Message cm{"aaa"};    // 如果把const去掉,会如何执行?
    Message cm1 = std::move(cm);
}

大家可以看一下,上这段代码中,如果直接运行,应该是输出:

>> right move construct

这个应该没有什么疑问。但是,如果把第四个函数Message(const Message&& msg)注释掉,再重新执行,则是输出:

>> copy construct (打印)
>> move construct (不打印)

你以为它move了,实际上却是在copy。再如果,把main函数中,const Message cm{"aaa"};中的const字段删除,那结果又会不一样了,大家可以自己尝试一下。

聊这些的意思,主要目的就是提醒大家,尽可能不要在自己定义各种构造函数的时候踩坑——像极了反复ctrl c/v代码,但却又不知道这一段代码会不会被执行的纯序员本员

本章小结

本章内容,主要介绍了一些在实现CGraph框架中threadpool功能的过程中,用到了一些实用工程侧的小技巧。其实都蛮简单的,很多技巧在写其他工程逻辑的时候,都可以被用到。

image.png

再说一个事情哈,我们之前聊到过work-stealing机制。介绍了该机制的优劣,并且通过实验,证明了在线程数量远大于cpu数量情况下,仅steal相邻index的几个thread,会显著提升整体调度性能(调度时间降低)。

最近,有一位在国内顶尖AI公司做高性能计算的资深大佬跟我提到,之所以在限制stealing个数后会有性能提升,还跟CPU自身的构造和机制有关系,不同的系统架构上,也可能会有不同的效果。至于最底层的内容究竟怎样,作为上层的纯序员已经无法探究,甚至连能够探究的实验也不会设计,验证的专业工具也不会使用。

就是借此感慨一下,计算机知识的海洋深不见底,有时候亲眼所见所得,也未必就是全部的真相——甚至仅仅是最外层的一些皮毛而已。作为新晋民工的我们,更应该保持不断学习和充电,不断去打破自己思维和认知的边界,提高水平。这样才会让我们看到的世界更加完整真实,思维的武器更加强大,顺便认识更多的妹子

image.png

当然,今天我们聊到的所有内容也均仅限于本人的既有认知。欢迎大家加我微信,以便随时交流指教。我们也会在接下来的文章中,介绍CGraph中线程池的使用demo和一些性能测试数据。欢迎大家继续关注。

image

    					[2021.08.22 by Chunel]

推荐阅读


个人信息

微信: ChunelFeng
邮箱: chunel@foxmail.com
个人网站:www.chunel.cn
github地址: https://github.com/ChunelFeng

image