手撸一款简单高效的线程池(三)—— 性能优化!

简介: 在上一章中,我们给大家介绍了一些 C++线程池中的优化思路和实现方案。这一章中,我们将继续这个主题,接着聊线程池中还有可以“压榨”的空间。为实现我们吹过的牛 B,而继续编程

在上一章中,我们给大家介绍了一些 C++线程池中的优化思路和实现方案。这一章中,我们将继续这个主题,接着聊线程池中还有可以“压榨”的空间。为实现我们吹过的牛 B,而继续编程


大家好,我是不会写代码的纯序员——Chunel Feng。上一章中,我们主要聊到了线程池中的 thread-local 机制、lock-free 机制和 work-stealing 机制。今天,继续说说线程池优化的一些方法和实践,主要会涉及到:自动扩缩容机制、批量处理机制和负载均衡机制


还是那句话,以下这些内容,仅局限于我个人的认知。如果有什么不对或者不合理的地方,很欢迎大家随时批评指正,也很希望大家可以提出自己意见、建议和看法。


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


自动扩缩容机制


上一章,我们聊了一个问题:如果线程池中,忽然来了很多任务 (比如:吴亦凡出事了,大家都来微博疯狂围观点赞),这个时候怎么办?那过了一阵子,又长时间没有任务可执行了 (比如:吴亦凡微博被官方屏蔽了)怎么办?还能怎么办,当然是心疼凡凡三秒钟了!!!


一个很通用的思路,就是自动扩缩容机制


翻译过来,就是:在任务繁忙的时候,pool 中多加入几个 thread;而在清闲的时候,对 thread 进行自动回收CGraph的实现逻辑中,包含了两种线程:PrimaryThread(主线程,简称PT)和 SecondaryThread(辅助线程,简称ST),默认在程序运行的时候,启动CGRAPH_DEFAULT_THREAD_SIZE个 PT 去执行任务。在程序运行的过程中,PT 的数量是恒定不变的,增/减的仅可能是 ST——这一点,是参考 Java 中 ThreadPool 的机制。


如何判断 pool 是忙还是闲?可以使用 running 标记的方法 + TTL(time to live)计数的方法。除了 PT 和 ST,pool 中还开辟了一个 MonitorThread(监控线程,简称MT)。MT 每隔固定的时间,会去轮询监测所有的 PT 是否都在 running 状态。如果是,就认定当前 pool 处于忙碌状态,则添加一个 ST 帮忙分担任务执行。同样的,MT 还会去监测每个 ST 的状态。如果连续 TTL 次监测到 ST 没有在执行任务,则认为 pool 处于空闲状态,则会销毁当前 ST。


这样就做到了线程数量随着 pool 的忙碌和空闲,动态调整了。当然,ST 的数量也不会无限增加的。当 PT 和 ST 的数量之和,达到CGRAPH_MAX_THREAD_SIZE值的时候,ST 数量就只减不增了。


7.png


千万要避免的一个误区是:并不是线程开辟的越多,性能就越好


线程调度过程也是有损耗的。比如,在 pool 中开辟了 1 爽(约 200+w)的线程,cpu 就需要通过调度算法,来尽可能的保障每个线程都有时间片可以运行。那这其中线程来回切换的成本是很高的——很可能远高于任务执行的成本。讲通俗一点就是:一个和尚挑水喝,两个和尚抬水喝,200w 个和尚没水喝


在其他文章中,也看到过一些意见,比如:


计算密集型任务,开辟nn+1个(其中,n=cpu 核数)线程数IO 密集型任务,开辟2n+1个线程


也看到有童鞋列出来一个公式,根据任务的耗时等参数,来预估合理的线程数量。类似:


最佳线程数目 = ((线程等待时间+线程 CPU 时间)/线程 CPU 时间 )* CPU 数目


我想说的是,这些有道理,也值得参考。但适合的配置参数,最好还是带入实际任务中测试和检验。实测,是检验性能的唯一标准。Talk is cheap, show me the test.


PT 和 ST 功能上的一些异同


首先说明,PT 和 ST 都是线程,都可以执行插入的任务。不过,CGraph 的线程池源码中,给 PT 和 ST 做出一些差别对待。PT 中有自己的任务队列,执行 task 的顺序依次是:local -> pool -> steal,即:先执行自己任务队列中的 task;如果自身的 queue 为空,则执行 pool 中的 task;如果仍没有任务,就从相邻的 n 个 PT 中去 steal(具体逻辑请看上一章内容)。


/* 主线程执行task的逻辑 */void runTask() {    UTaskWrapper task;    if (popTask(task) || popPoolTask(task) || stealTask(task)) {        is_running_ = true;        task();        is_running_ = false;    } else {        std::this_thread::yield();    }}


而 ST 没有属于自己任务队列,仅可以执行 pool 中的 queue 的任务,对应代码也就是popPoolTask(task)这部分。


这样做的原因,一方面是可以从代码层面,突出主辅的层次感。更重要的一方面是保证了 ST 释放的时候不容易出错或丢失任务,并且不让 work-stealing 的过程变得更复杂。


批量处理机制


上一章,提到线程池优化的一个基本出发点就是 增加扇入扇出。为了在这一点上做到更优,CGraph 在从 queue 中获取 task 的时候,提供了批量获取 tasks 的功能。在开启CGRAPH_BATCH_TASK_ENABLE参数之后,PT 和 ST 在从 queue 中获取/盗取 task 的时候,就不再是one by one的获取,而是batch by batch的获取。这样做的最大好处,是可以减少线程获取 task 时候,争抢锁的次数,从而提升性能。


8.png


这种机制也可能会打乱 queue 中任务的执行顺序,单独使用的时候需要慎重考虑。但是,针对图化框架这种情况,并没有这种担忧。放入线程池中执行的任务,均是“互不依赖”的,也就是“无序执行”的——因为有依赖的节点,会等待被依赖节点执行完毕后,才被放入 pool 中。


即便是这样,也不能无脑使用。设想一种情况,pool 中有 4 个 PT,任务队列中共有 100 个sleep 1s;的任务。一个 PT 过来一批拉走了这 100 个任务,然后就默默的运行了 100s,而其他所有的线程都在旁边默默鼓掌么?


这样做的好处,是原先需要有 100 次的抢锁过程被缩减到了 1 次。但是坏处吗,原先约 25s 就可以执行完的任务,硬生生的被执行 100s。


/** * 从头部开始批量获取可执行任务信息 * @param taskArr * @return */bool tryMultiPop(UTaskWrapperArr& taskArr) {    bool result = false;    if (mutex_.try_lock()) {        int i = CGRAPH_MAX_TASK_BATCH_SIZE;        while (!queue_.empty() && i--) {            taskArr.emplace_back(std::move(queue_.front()));            queue_.pop_front();            result = true;        }        mutex_.unlock();    }    return result;}


为了对这样的情况做出权衡,CGraph 中提供了CGRAPH_MAX_TASK_BATCH_SIZE参数。在开启批量拉取功能后,每个线程每次拉取 task 的数量不能超过该数值,这样在一定程度上在兼顾了增加扇出和减少抢锁次数这两个方面,又尽可能的缓解了刚才提到的那种极端情况。


需要说明的是,真实的多线程情况远比我们刚才讨论的复杂。现代计算机的调度理论何其深奥和精密,我想即便是Bill GatesLinus Torvalds,甚至是Kris Wu,也无法穷尽其中奥秘。而多线程自身又有很多的不确定性。所以,具体采用哪种执行策略,还是尽可能的去模拟真实环境进行实测和压测,这样得出来的结果才最有说服力——这话,我好像说了三遍了。


负载均衡机制


负载均衡问题,是调度方面性能优化的一个永恒的话题。针对不同任务和不同情况,有不同的对应策略。之所以放在最后一点说,主要是因为个人水平和见识有限,CGraph 的线程池中,在调度层面做太多的优化。只是将一部分任务均匀的写入 PT 的 queue 中,另一部分统一放到 pool 的 queue 中。但即便是这样,也已经比传统的线程池做法,在性能上优化了不少(关于性能测试,我会在后面的文章中介绍)。


9.png


如果是要针对这方面做一些优化的话,我提两点我的看法吧:


尽可能的保证当前 PT 中产生的 task,放入本 PT 的 queue 中执行,也算是迎合 thread-local 的概念。

尽可能保证每个 PT 的 queue 中,任务耗时总和基本一致。不要频繁出现 work-stealing 的情况。


本章小结


本章内容,主要跟大家介绍了线程池的一些优化思路,包括:自动扩缩容、批量处理和负载均衡。梳理了它们在使用过程中,能够解决的一些痛点和可能遇到的一些坑。


面对不同数量、不同耗时、不同功能(IO 密集、计算密集)、不同顺序的任务的各种实际情况,很难说有可以一招打遍天下的诀窍。很多情况下,还是需要靠实测、压测和专业分析工具,来看出性能瓶颈点。为此,CGraph 也在源码中开放出来了一些配置参数供大家选择尝试。


推荐一个我平时用的性能监测工具:Profile,它可以集成在CLion中一键执行,并生成火焰图。看下面这张图,基本上一眼就可以看出来,任务执行的耗时基本上卡在runTask()函数中。至于为什么会这样?能否优化?如何优化?这些问题就要自己一点点根据代码分析了——测试,永远也不会告诉你答案了。


10.png


再多说两句,最近比较流行的协程的概念,应该也比较适合做这种调度的场景,而且人为可控性更强。我们上面提到的一些优化方法,也参考 Java 中的一些现有实现逻辑。如果有懂 Go 大佬可以指教一下,我想那就更好了。


下一章,我们会给大家介绍一些 CGraph 在线程池中,做的一些工程层面的优化,比如:减少无用 copy、提供 task 包装器、避免 busy waiting等,希望大家继续关注。


推荐阅读


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

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

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

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

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

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


引用链接


[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

相关实践学习
部署高可用架构
本场景主要介绍如何使用云服务器ECS、负载均衡SLB、云数据库RDS和数据传输服务产品来部署多可用区高可用架构。
负载均衡入门与产品使用指南
负载均衡(Server Load Balancer)是对多台云服务器进行流量分发的负载均衡服务,可以通过流量分发扩展应用系统对外的服务能力,通过消除单点故障提升应用系统的可用性。 本课程主要介绍负载均衡的相关技术以及阿里云负载均衡产品的使用方法。
目录
相关文章
|
16天前
|
安全 Java 开发者
深入理解Java并发编程:线程安全与性能优化
【4月更文挑战第9天】本文将深入探讨Java并发编程的核心概念,包括线程安全和性能优化。我们将详细解析Java中的同步机制,包括synchronized关键字、Lock接口以及并发集合等,并探讨它们如何影响程序的性能。此外,我们还将讨论Java内存模型,以及它如何影响并发程序的行为。最后,我们将提供一些实用的并发编程技巧和最佳实践,帮助开发者编写出既线程安全又高效的Java程序。
22 3
|
1月前
|
安全 Java
深入理解Java并发编程:线程安全与性能优化
【2月更文挑战第22天】在Java并发编程中,线程安全和性能优化是两个重要的主题。本文将深入探讨这两个主题,包括线程安全的基本概念,如何实现线程安全,以及如何在保证线程安全的同时进行性能优化。
15 0
|
15天前
|
算法 Java 开发者
Java中的多线程编程:概念、实现与性能优化
【4月更文挑战第9天】在Java编程中,多线程是一种强大的工具,它允许开发者创建并发执行的程序,提高系统的响应性和吞吐量。本文将深入探讨Java多线程的核心概念,包括线程的生命周期、线程同步机制以及线程池的使用。接着,我们将展示如何通过继承Thread类和实现Runnable接口来创建线程,并讨论各自的优缺点。此外,文章还将介绍高级主题,如死锁的预防、避免和检测,以及如何使用并发集合和原子变量来提高多线程程序的性能和安全性。最后,我们将提供一些实用的性能优化技巧,帮助开发者编写出更高效、更稳定的多线程应用程序。
|
13天前
|
安全 算法 Java
深入理解Java并发编程:线程安全与性能优化
【4月更文挑战第11天】 在Java中,高效的并发编程是提升应用性能和响应能力的关键。本文将探讨Java并发的核心概念,包括线程安全、锁机制、线程池以及并发集合等,同时提供实用的编程技巧和最佳实践,帮助开发者在保证线程安全的前提下,优化程序性能。我们将通过分析常见的并发问题,如竞态条件、死锁,以及如何利用现代Java并发工具来避免这些问题,从而构建更加健壮和高效的多线程应用程序。
|
9天前
|
设计模式 运维 安全
深入理解Java并发编程:线程安全与性能优化
【4月更文挑战第15天】在Java开发中,多线程编程是提升应用程序性能和响应能力的关键手段。然而,它伴随着诸多挑战,尤其是在保证线程安全的同时如何避免性能瓶颈。本文将探讨Java并发编程的核心概念,包括同步机制、锁优化、线程池使用以及并发集合等,旨在为开发者提供实用的线程安全策略和性能优化技巧。通过实例分析和最佳实践的分享,我们的目标是帮助读者构建既高效又可靠的多线程应用。
|
15天前
|
监控 安全 Java
深入理解Java并发编程:线程安全与性能优化
【4月更文挑战第10天】 在Java开发中,并发编程是提升应用性能和响应能力的关键手段。然而,线程安全问题和性能调优常常成为开发者面临的挑战。本文将通过分析Java并发模型的核心原理,探讨如何平衡线程安全与系统性能。我们将介绍关键的同步机制,包括synchronized关键字、显式锁(Lock)以及并发集合等,并讨论它们在不同场景下的优势与局限。同时,文章将提供实用的代码示例和性能测试方法,帮助开发者在保证线程安全的前提下,实现高效的并发处理。
|
22天前
|
Java 测试技术 开发者
Java中的多线程实现与性能优化
【4月更文挑战第3天】 在现代软件开发中,多线程技术是提高程序执行效率和响应速度的重要手段。Java语言提供了丰富的多线程支持,包括原生的Thread类和Runnable接口,以及java.util.concurrent包中的高级并发工具。然而,不当的多线程使用可能导致程序性能下降、死锁等问题。本文将探讨Java中多线程的具体实现方法,并着重讨论如何通过合理的设计和编码实践来优化多线程应用的性能。我们将分析线程创建与管理的基本概念,深入理解同步机制,并探讨并发集合、线程池等高级特性的使用。文章的目的是为开发者提供一套实用的多线程性能优化策略,帮助他们在面对并发编程挑战时能够编写出更加高效、稳定
15 1
|
1月前
|
安全 Java 程序员
深入理解Java并发编程:线程安全与性能优化
【2月更文挑战第19天】本文将深入探讨Java并发编程的核心概念,包括线程安全和性能优化。我们将首先介绍线程安全的基本概念,然后通过实例分析如何实现线程安全。接下来,我们将讨论性能优化的策略,包括减少锁的开销、使用无锁数据结构和利用并行流。最后,我们将总结一些最佳实践,以帮助你在Java项目中实现高效的并发编程。
|
1月前
|
安全 Java 调度
深入理解Java并发编程:线程安全与性能优化
【2月更文挑战第17天】本文旨在探讨Java并发编程的核心概念,包括线程安全和性能优化。我们将详细分析线程安全问题的根源,以及如何通过各种技术和策略来解决这个问题。同时,我们还将讨论如何在保证线程安全的前提下,提高程序的性能。
|
1月前
|
算法 Java API
Java并发编程中的线程安全性与性能优化
【2月更文挑战第10天】 在当今软件开发领域,多线程编程已经成为不可或缺的一部分。本文将深入探讨Java并发编程中的线程安全性和性能优化问题。通过分析常见的线程安全性挑战和性能瓶颈,结合实际案例和优化技巧,帮助开发者更好地理解和应对Java多线程环境下的挑战,提升程序的稳定性和性能。
13 0