开发者社区> yanglbme> 正文

手撸一款简单高效的线程池(四)

简介: 在前几章的内容中,我们给大家介绍了一些 C++线程池中的优化思路和实现方案。这一章中,我们来聊一聊在编程实现过程中,一些工程层面的优化。让我们的代码执行的速度,跟得上自己的思路。
+关注继续查看

在前几章的内容中,我们给大家介绍了一些 C++线程池中的优化思路和实现方案。这一章中,我们来聊一聊在编程实现过程中,一些工程层面的优化。让我们的代码执行的速度,跟得上自己的思路


大家好,我是不会写代码的纯序员——Chunel Feng,有两周的时间没有更新文章了哈。主要是上上周啊,我随便 copy 了一段功能代码,也不知道执行后会是啥结果,还没测试就直接发布到线上了。然后这两周就一直在改 bug 了。当然了,如果不是组里朋友一起来帮忙,可能还会耽搁的更久。事后,我就想啊,这样一个程序员毁了一个组的狗血剧情,也可能发生在多线程开发过程中。有必要进行一些针对性的优化。


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


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


避免 busy waiting


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


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


11.png


我举个例子:


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);}


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


12.png


先来简单的说一下程序执行的流程哈。我们上面的那段代码虽然是线性的流程,但是在执行的时候,程序在if那个地方,并不会等着判断条件(例中为:(nullptr == ptr || nullptr == param),也可能是一个非常复杂的逻辑)得出一个 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是 C++自带的函数,这个没什么好说的(主要是我也说不出来啥,嘿嘿)。一句话介绍一下为什么是!!(x):目的就是为了将 x 值变成一个 bool 类型。例:


•!!(5) = 1

•!!(1) = 1

•!!(0) = 0


关于这一点,我们来做个有意思的小实验


#include <iostream>#define likely(x)   __builtin_expect(!!(x), 1)#define unlikely(x) __builtin_expect(!!(x), 0)int main() {    long long outLoop = 10000;    long long inLoop = 10000000;    while (outLoop--) {        long long loop = inLoop;        static long long i = 1;    // tag-1        while (loop--) {            if (unlikely(0 == i % 2)) {    // tag-2                i += 1;            } else {                i += 2;            }        }    }    return 0;}


大家看一下上面这段又臭又长的代码哈,具体逻辑没啥好说的——就是循环里面套循环。主要看 unlikely 那句话:如果直接运行的话,执行耗时是 35.69 秒;但如果删除unlikely修饰的话,则执行耗时是 100.44 秒——接近 3 倍的差距。


我再说几个问题,有兴趣的朋友可以自己试试看:


•如果tag-1那句话,不加static,会怎样?

•如果tag-2那句话,使用likely修饰,会怎样?


如果你没亲身试过,结果很有可能跟你想的不一样哦。


减少无用 copy


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


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


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


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


13.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 功能的过程中,用到了一些实用工程侧的小技巧。其实都蛮简单的,很多技巧在写其他工程逻辑的时候,都可以被用到。


14.png


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


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


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


15.png


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


推荐阅读


纯序员给你介绍图化框架的简单实现——执行逻辑[2]

纯序员给你介绍图化框架的简单实现——循环逻辑[3]

纯序员给你介绍图化框架的简单实现——参数传递[4]

纯序员给你介绍图化框架的简单实现——条件判断[5]

纯序员给你介绍图化框架的简单实现——线程池优化(一)[6]

纯序员给你介绍图化框架的简单实现——线程池优化(二)[7]

纯序员给你介绍图化框架的简单实现——线程池优化(三)[8]


引用链接


[1] CGraph 源码链接: https://github.com/ChunelFeng/CGraph

[2] 纯序员给你介绍图化框架的简单实现——执行逻辑: http://www.chunel.cn/archives/cgraph-run-introduce

[3] 纯序员给你介绍图化框架的简单实现——循环逻辑: http://www.chunel.cn/archives/cgraph-loop-introduce

[4] 纯序员给你介绍图化框架的简单实现——参数传递: http://www.chunel.cn/archives/cgraph-param-introduce

[5] 纯序员给你介绍图化框架的简单实现——条件判断: http://www.chunel.cn/archives/cgraph-condition-introduce

[6] 纯序员给你介绍图化框架的简单实现——线程池优化(一): http://www.chunel.cn/archives/cgraph-threadpool-1-introduce

[7] 纯序员给你介绍图化框架的简单实现——线程池优化(二): http://www.chunel.cn/archives/cgraph-threadpool-2-introduce

[8] 纯序员给你介绍图化框架的简单实现——线程池优化(三): http://www.chunel.cn/archives/cgraph-threadpool-3-introduce


版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
阿里云服务器怎么设置密码?怎么停机?怎么重启服务器?
如果在创建实例时没有设置密码,或者密码丢失,您可以在控制台上重新设置实例的登录密码。本文仅描述如何在 ECS 管理控制台上修改实例登录密码。
23526 0
阿里云服务器ECS远程登录用户名密码查询方法
阿里云服务器ECS远程连接登录输入用户名和密码,阿里云没有默认密码,如果购买时没设置需要先重置实例密码,Windows用户名是administrator,Linux账号是root,阿小云来详细说下阿里云服务器远程登录连接用户名和密码查询方法
22227 0
如何设置阿里云服务器安全组?阿里云安全组规则详细解说
阿里云安全组设置详细图文教程(收藏起来) 阿里云服务器安全组设置规则分享,阿里云服务器安全组如何放行端口设置教程。阿里云会要求客户设置安全组,如果不设置,阿里云会指定默认的安全组。那么,这个安全组是什么呢?顾名思义,就是为了服务器安全设置的。安全组其实就是一个虚拟的防火墙,可以让用户从端口、IP的维度来筛选对应服务器的访问者,从而形成一个云上的安全域。
18586 0
windows server 2008阿里云ECS服务器安全设置
最近我们Sinesafe安全公司在为客户使用阿里云ecs服务器做安全的过程中,发现服务器基础安全性都没有做。为了为站长们提供更加有效的安全基础解决方案,我们Sinesafe将对阿里云服务器win2008 系统进行基础安全部署实战过程! 比较重要的几部分 1.
11975 0
阿里云服务器安全组设置内网互通的方法
虽然0.0.0.0/0使用非常方便,但是发现很多同学使用它来做内网互通,这是有安全风险的,实例有可能会在经典网络被内网IP访问到。下面介绍一下四种安全的内网互联设置方法。 购买前请先:领取阿里云幸运券,有很多优惠,可到下文中领取。
21936 0
阿里云服务器如何登录?阿里云服务器的三种登录方法
购买阿里云ECS云服务器后如何登录?场景不同,云吞铺子总结大概有三种登录方式: 登录到ECS云服务器控制台 在ECS云服务器控制台用户可以更改密码、更换系统盘、创建快照、配置安全组等操作如何登录ECS云服务器控制台? 1、先登录到阿里云ECS服务器控制台 2、点击顶部的“控制台” 3、通过左侧栏,切换到“云服务器ECS”即可,如下图所示 通过ECS控制台的远程连接来登录到云服务器 阿里云ECS云服务器自带远程连接功能,使用该功能可以登录到云服务器,简单且方便,如下图:点击“远程连接”,第一次连接会自动生成6位数字密码,输入密码即可登录到云服务器上。
36349 0
使用SSH远程登录阿里云ECS服务器
远程连接服务器以及配置环境
14690 0
+关注
73
文章
0
问答
文章排行榜
最热
最新
相关电子书
更多
JS零基础入门教程(上册)
立即下载
性能优化方法论
立即下载
手把手学习日志服务SLS,云启实验室实战指南
立即下载