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

本文涉及的产品
传统型负载均衡 CLB,每月750个小时 15LCU
网络型负载均衡 NLB,每月750个小时 15LCU
应用型负载均衡 ALB,每月750个小时 15LCU
简介: 在上一章中,我们给大家介绍了一些 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

相关实践学习
SLB负载均衡实践
本场景通过使用阿里云负载均衡 SLB 以及对负载均衡 SLB 后端服务器 ECS 的权重进行修改,快速解决服务器响应速度慢的问题
负载均衡入门与产品使用指南
负载均衡(Server Load Balancer)是对多台云服务器进行流量分发的负载均衡服务,可以通过流量分发扩展应用系统对外的服务能力,通过消除单点故障提升应用系统的可用性。 本课程主要介绍负载均衡的相关技术以及阿里云负载均衡产品的使用方法。
目录
相关文章
|
6月前
|
安全 Java 调度
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第28天】本文将深入探讨Java并发编程的核心概念,包括线程安全和性能优化。我们将详细分析线程安全的重要性,以及如何在保证线程安全的同时,提高程序的性能。文章将通过实例代码和理论知识的结合,帮助读者深入理解Java并发编程。
|
2月前
|
安全 Java 调度
Java 并发编程中的线程安全和性能优化
本文将深入探讨Java并发编程中的关键概念,包括线程安全、同步机制以及性能优化。我们将从基础入手,逐步解析高级技术,并通过实例展示如何在实际开发中应用这些知识。阅读完本文后,读者将对如何在多线程环境中编写高效且安全的Java代码有一个全面的了解。
|
4月前
|
Java 程序员 调度
Java中的多线程编程:概念、实现及性能优化
【5月更文挑战第85天】本文主要探讨了Java中的多线程编程,包括其基本概念、实现方式以及如何进行性能优化。首先,我们将介绍多线程的基本概念,然后详细讨论如何在Java中实现多线程,包括继承Thread类和实现Runnable接口两种方式。最后,我们将探讨一些提高多线程程序性能的策略,如使用线程池和减少同步开销等。
|
4月前
|
安全 Java 开发者
掌握Java并发编程:线程安全与性能优化之道
在多核处理器普及的今天,充分利用并发编程技术是提升应用性能的关键。本文将深入探讨Java中的并发编程,从基本概念到高级技巧,揭示如何通过正确的同步机制和锁策略来确保线程安全,同时避免常见的并发陷阱。我们将一起探索高效利用线程池、减少锁竞争、以及使用现代并发工具类等方法,以达到性能的最优化。
|
4月前
|
安全 Java 开发者
Java并发编程中的线程安全性与性能优化
在Java编程中,处理并发问题是至关重要的。本文探讨了Java中线程安全性的概念及其在性能优化中的重要性。通过深入分析多线程环境下的共享资源访问问题,结合常见的并发控制手段和性能优化技巧,帮助开发者更好地理解和应对Java程序中的并发挑战。 【7月更文挑战第14天】
53 4
|
3月前
|
安全 Java API
揭秘Java并发编程的神秘面纱:线程安全与性能优化之间的微妙舞蹈,如何让你的程序在多核时代中翱翔!
【8月更文挑战第12天】随着多核处理器的普及,Java并发编程越发重要。线程安全确保多线程环境下的程序一致性,而性能优化则让程序高效运行。通过同步机制如`synchronized`关键字或`ReentrantLock`接口,我们可以实现线程安全,如在银行账户存款操作中限制并发访问。然而,过度同步会导致性能下降,因此采用细粒度锁和利用Java并发工具类(如`ConcurrentHashMap`)可提高程序的并发能力。理解这些概念并加以实践,是每个Java开发者提升技能的关键。
46 0
|
4月前
|
存储 安全 算法
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第72天】 在现代软件开发中,尤其是Java应用开发领域,并发编程是一个无法回避的重要话题。随着多核处理器的普及,合理利用并发机制对于提高软件性能、响应速度和资源利用率具有重要意义。本文旨在探讨Java并发编程的核心概念、线程安全的策略以及性能优化技巧,帮助开发者构建高效且可靠的并发应用。通过实例分析和理论阐述,我们将揭示在高并发环境下如何平衡线程安全与系统性能之间的关系,并提出一系列最佳实践方法。
|
5月前
|
安全 算法 Java
Java并发编程中的线程安全与性能优化
在Java应用程序开发中,线程安全和性能优化是至关重要的方面。本文探讨了Java并发编程中常见的线程安全问题,并提供了实用的性能优化技巧。通过深入分析多线程环境下的共享资源访问、锁机制、并发集合等关键概念,帮助开发者有效提升程序的稳定性和执行效率。
85 15
|
4月前
|
监控 安全 Java
Java中的线程调度与性能优化技巧
Java中的线程调度与性能优化技巧
|
5月前
|
存储 安全 算法
Java并发编程中的线程安全性与性能优化
在Java编程中,特别是涉及并发操作时,线程安全性及其与性能优化是至关重要的问题。本文将深入探讨Java中线程安全的概念及其实现方式,以及如何通过性能优化策略提升程序的并发执行效率。
49 1