5. 线程池原理与C++实现

1 什么是线程池

如果你使用过线程就会知道,每当你想要启用一个任务时,就必须手动去启动一个线程来执行指定的任务。

这是很自然的想法,并且也并没有什么不对。

当问题就出在,电脑CPU可以同时执行的线程数量并不是无限多的,比如我目前电脑CPU是16核16线程的,意思就是同时最多也只能执行16个线程。

如果超过了这个数量,那么CPU就需要不断切换线程,调动资源,让其它线程也能进来运行一会。

这个过程相当损耗性能。

为了解决这个问题,就有了线程池的概念。

既然电脑本身最多可同时运行的线程数是有限的,那我只要保证我整个程序最多只开启指定个数的线程数不就行了吗?

无论当前应用程序中有多少任务,我都交给这指定个数的线程来完成,如果任务数量少,那就让线程休眠,如果任务数量比线程多,那就等在后面排队即可,这样就不会出现多线程去竞争CPU、影响效率了。

这就是线程池的概念:提前准备好指定个数的线程,无论你想要执行多少个任务,都把任务交给线程池去做就行了,至于线程是如何调度的,你就不用管了,那是线程池的事情。

2 手写一个线程池

写一个通用、可靠的线程池并不是一件容易的事,但写一个简单、实用版本的线程池我们还是可以做到的。

但在开始写之前,我们还需要了解一下线程同步的概念。

2.1 线程同步

一旦你开启了一个线程,那这个线程你是无法控制其执行进度的,但有些时候我们又需要知道其执行情况,比如下载文件的进度、是否出现了错误等等。

这就涉及到了多线程访问同一资源的问题,如果都是访问那还没什么,但就怕有线程会去更改它的数据。

比如4个线程都依靠某个全局变量来获取数据,而其中两个线程都去更改了它,那么最后这个全局变量的结果就是不可预测的。

所以此时我们就需要让不同线程达到同步,也就是同一时间只能有一个线程去修改、使用某个变量。

一般在C++中用的比较多的就是原子、互斥体。

原子很简单,它本事就是最小操作单元,它只有两个状态:要么执行成功、要么执行失败,而不会出现执行出问题的情况。

比如前面两个线程想要修改同一个全局变量,如果是原子操作那就不会出现问题,比如都+1,那最后的结果一定是+2,但如果不是原子操作却不一定了。

虽然只是一个+1操作,但在计算机底层同样得分几步完成,不是原子操作的话这一过程就无法控制,导致最后的结果不可控。

一个示例代码如下:

#include <iostream>
#include <thread>
#include <vector>

int global_counter = 0; // 全局变量

void worker(int times) {
    for (int i = 0; i < times; i++) {
        global_counter++; // 存在竞争条件
    }
}

int main() {
    const int thread_count = 4;
    const int times = 10000;
    std::vector<std::thread> threads;

    for (int i = 0; i < thread_count; i++) {
        threads.emplace_back(worker, times);
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "最终结果(可能小于期望): " << global_counter << std::endl;
    std::cout << "理论期望值: " << thread_count * times << std::endl;
    return 0;
}

这里开启了四个线程同时对全局变量进行修改,每个线程累加10000,理论上来说最后结果是40000,但实际却并非如此:

image.png

其原因就在于,如果当前这个全局变量为1,由于多个线程是同步执行的,那么它们都可能拿到1,然后对其+1得到2,最后将其放回全局变量,此时虽然多个线程都执行了+1,但最后的结果却是2。

对于这类基本数据类型,原子操作就可以解决这个问题。

它所在头文件为:

#include<atomic>

使用方法很简单,比如要一个原子操作的int数据:

std::atomic<int> num

它是一个模板,之后你就可以正常将它当作一个数字变量使用即可。

但很多时候我们想要在不同线程之间共享的不仅仅只是基本数据类型,所以就需要互斥体。

它所在的头文件为:

#include<mutex>

使用方法为:

std::mutex m_mutex;

它并不是一个模板,所以只需要将其申明为一个对象即可,在你想要访问共享数据的时候,你就必须要像下面这样写:

m_mutex.lock();
//访问、否则修改多线程共享数据
m_mutex.unlock();

总共就两个函数,一个是lock,代表你想要访问数据,如果你当前不能访问数据(比如其它线程正在使用),那这个线程就会阻塞在这里。

在你使用完成后,也一定要记得用unlock把互斥体打开,这样其它线程才能使用,否则就进入了死锁状态。

所谓死锁状态,就是所有线程都在等,但却不存在资源可以被利用的时候,比如上面这个例子就是,如果你不解锁,那么其它线程想要使用资源就只能一直等待下去。

所以这两个函数都是成对出现的。

另一个使用互斥体的要点是,互斥体本身并不保护任何东西,我们仅仅只是借用了它这个特性:同一时间只能有一个线程去锁住它,以此来保护我们的数据。

所以如果你的多线程共享数据(状态、或者操作),你都得自己记得添加互斥体,这也是使用它比较麻烦的地方。

2.2 线程池类

上面的内容只是看还是有点抽象,所以下面还是直接来实战吧。

首先我们要定义一个线程池类:

class ThreadPool {
}
作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux