掘金 后端 ( ) • 2024-03-29 15:25

线程池的代码可以写的很复杂,这里就稍微简单一些

首先来看一下线程池的原则,下面的大框是服务器,而在服务器中维护一个任务队列。

然后在server中预先创建一批线程,这批线程和任务队列合在一起只用向外界提供一个入队列的接口。

未来如果任务队列中有任务,这批线程就去执行任务,如果没有任务这批线程就去阻塞。

这个模式不就是一个生产消费模型吗?

只不过这里没有提供生产者,而所有的线程都是消费者从一个共同的任务队列中拿取任务。原理就是这样的。

然后对于线程池还需要一个小组件,就是之前我写过的一个很简单的日志系统。

对于这个日志系统详细的实现,如若不介意,请看我的下面这篇文章:

完成后就可以将这个小组件放到线程池中了。对于线程池的总体思路上面已经说明过了,但是在这里再复习一下。在完成了基本的日志函数之后,在日志的实现函数中增加一些东西

这样都是便于我们去使用日志。

然后下面就是线程池的大体逻辑,创建一批线程,这批线程发现先任务队列中不为空就拿出任务然后去执行,如果任务队列为空就阻塞等待。

然后为了更好的创建线程,将我们之前封装好的线程拿过来使用,同时也就需要将lockguard一起拿过来使用了。

以下就是需要的文件:

其中的Main.cc中写的就是测试代码。

下面就来写线程池的类。

既然是一个类就需要有成员变量,那么线程池中要有什么呢?首先就是需要一个储存任务的空间,这里使用一个队列来储存任务(当然也可以将之前写的阻塞队列拿过来,这里就不那么使用了),然后就是线程了。因为存在多个线程,为了管理这些线程所以需要使用一个vector的数组来储存这些线程,同时因为我写的这个thread类中需要一个结构体用于当作线程的数据。所以还需要一个类作为ThreadData。

以下就是一个Threadpool类的大体框架了:

然后需要来完成线程池的任务了,第一个任务就是要将线程池启动起来,所以这提供一个start函数,用于启动线程池。然后线程池中既然存在一个任务队列,自然也要提供一个函数用于向任务队列中输入任务。如果线程池启动起来了,自然要有线程的存在才行,这里有两种写法,第一种:将对应数量线程的创建写到构造函数中。第二种:写到start函数中。这里我选择写到了构造函数中。

Thread的构造函数:

threadpool的构造

下面就是线程池的启动start函数了。既然线程池启动起来了。自然就是让所有的线程启动起来了。因为我封装的Thread中是由start这个方法的,所以这里可以这么写。如果使用的是c++11线程库中的线程则不能怎么写。

然后我们就来完成构建一个线程所需要的事物(线程需要执行的任务,线程的名字)。

对于线程需要执行的任务,这里可以直接写一个静态/类外的方法,使用静态方法的好处就是能够访问类内的成员,并且没有this指针。但是这里也可以写一个不是静态的方法,因为在Thread中回调函数是一个包装器,那么我们只需要传递一个可调用对象即可()。

这个可调用对象的返回值为void,参数为T&。

所以这里就可以这么写:

这个函数内部是包含了一个this指针的,所以需要做一些小的处理。使用一个bind绑定这个threadRun这个任务,做了这个处理之后,也就意味着,线程只会执行线程池内部给线程的任务了。

这样也就将类中的方法绑定给了线程去执行。

其中的​​std::bind(&threadpool<T>::threadRun, this, std::placeholders::_1)​​:这部分使用了 ​​std::bind​​ 来创建一个可调用对象。解释其中的部分:

  • ​&threadpool<T>::threadRun​​:这是一个成员函数指针,指向 threadRun 函数,该函数似乎是属于 threadpool 类的成员函数。
  • ​this​​:在这个上下文中,this 指向当前对象,即 threadpool 类的一个实例。
  • ​std::placeholders::_1​​:这是一个占位符,表示在调用可调用对象时将会提供的参数

除此之外bind还有一个作用就是将ThreadRun这个函数的this指针去除:

​std::bind​​ 的作用是将成员函数 ​​threadRun​​ 绑定到特定的对象上,同时去除 ​​this​​ 指针,以便在后续调用时作为一个普通函数对象使用。这样做的目的是为了使得 ​​threadRun​​ 能够符合 ​​Thread​​ 构造函数所需的函数类型,即返回类型为 ​​void​​,参数为 ​​T&​

现在为了证明线程一旦启动起来就会去执行ThreadRun函数。这里做一个简单的实验。

然后为了让这个实验能够运行下去,在threadpool类中在增加一个wait方法。让主线程能够join等待线程池中的线程。

运行截图:

现在已经基本能够运行起来了,下面就是要完成我们的线程需要完成的具体的任务了。也就是我们的线程需要从任务队列中拿取任务,然后去执行任务了,因为这个任务队列是能够被多个线程共享的,是临界资源,所以需要上锁。这里先使用原生的上锁函数,最后在修改为LockGuard

下面继续来写ThreadRun函数:

然后使用LockGuard优化一下上锁的过程

这里还可以将线程等待函数做一下简单的封装。

到这里为止我们都还没有使用过日志,对于日志在完成了大体的函数之后,再增加即可。

然后线程池还需要提供一个函数,这个函数的功能就是往任务队列中push任务。

在这个函数中完成往任务队列中push任务,同时要去唤醒在等待的线程。

然后就是push函数了

但是现在的问题就是没有任务啊,这里就要将之前写的那个Task类拿过来继续使用了(实现了一个简单的+-乘除取模的任务)。

然后就可以让主线程去构建一些简单的任务了。

运行截图:

现在就能完成对应任务的派生和处理了。

然后不是还有一个ThreadData的类还没有写吗?下面就来写一下这个类。为了方便打印日志,需要知道当前执行的这个线程的名字是什么,所以这里就暂时只写一个name作为成员了,这样的话在线程执行对应的任务的时候也能拿到名字。

然后就能打印日志了。也就是将log使用上来。

同时在修改一下push函数:

这里使用emplace_back()对任务进行插入,这里的底层就是使用了移动拷贝,减少对象的拷贝次数。然后在push这里再打印一个信息。

然后启动的时候再打印一下日志

然后就是push任务的时候再打印一下日志:

然后因为这里将休眠函数做了封装,所以这里也能完成再休眠函数和唤醒函数中写日志。

不能让唤醒函数去打印唤醒信息,因为唤醒函数是主线程做的,这里主线程不能拿到子线程的name。

然后不要忘了如果这里的日志是打印在显示器上的,而显示器也是一个临界资源也是需要加锁去访问的。

运行结果:

但是这样式有极大的概率出现打印问题的,原因之前也已经说明过了,显示器也是一个临界资源。这里我不想再去给显示器加锁了,所以我让这些日志信息打印到文件中去。

调用log中的Eable函数修改一下显示方式即可。

此时所有的日志信息就已经写到文件中了。

而这个线程池其实本质也就是一个生产消费模型。只不过线程池的任务,并不是由创建出来的线程生产的,而是由主线程生产的。而当后面学习了网络之后,就可以通过网络去获取任务了。

对于这个线程池还存在可以扩展的部分:

在线程池中会存在两种数据,第一个是线程的个数,第二个数任务的个数。

这里可以在增加两个变量第一个变量是线程数量的低水位线,一个是线程数量的高水线(就是两个整型变量)。对于这个下限和上限的具体的数量,是根据业务情况而定的,这里就可以写一个配置文件,在配置文件中写明低水位线为多少,高水位线为多少,然后在构造函数中读取这个文件,将低水位线和高水位线的值获取到即可。

还有一种数据也就是任务的数量,依旧是有着任务的低水位线,和任务的高水位线。导入的方法也是一样的。

有了这些之后,在一个线程获取任务之前,可以进行一次自我检查。

如果当前的任务个数已经超过了高水位线,但是线程数量还没有到达高水位线,此时这个线程就是创建更多的线程。

创建的逻辑很简单就是往线程数组中push,然后启动这个线程就可以了(不要忘了增加_thread_num)。

下一种情况,如果任务的个数本来就不多(小于了任务低水位线),而且线程的个数已经大于等于高水位线(或者是高水位线的一半等等),此时任务少线程多,此时就让这个线程直接退出即可(更新线程的个数)。完成这个任务 之后这个线程池就是一个浮动的线程池了。

然后拓展2:就是可以将这个线程池和之前写的进程池结合起来变成一个多进程版本的多线程池。

最后还有一些额外的点:

stl容器在多线程的使用中都是线程不安全的。

但是虽然智能指针是线程安全的,但是智能指针指向的对象不一定是线程安全的。

然后还有一个单例模式(饿汉和懒汉模式),下面先将上面的线程池改成一个单例模式的线程池(懒汉模式【使用时创建对象】)

在单例模式中构造函数是需要的,但是需要设定为私有的,而拷贝构造/赋值直接设定为不生成。

增加单例指针

最后增加一个获取单例的函数

但是这样写的单例是存在问题的,这个问题之后会解决这里先去测试一下这个单例是否正确。这里再在GetInstance处增加一个日志信息,显示单例被创建。

运行一下:

这里因为线程调度的原因导致了这个信息在后面才被打印,单例的创建一定是在之前的。

但是这里的Getinstance也是存在线程安全问题的,这里获取单例这个函数只有被main函数调用,那么在未来这个获取单例的函数会不会被多个线程一起调用呢?当然是可能的。所以这里还需要增加一把锁,这里我选择了一把静态锁。

然后将这个锁使用到Getinstance函数中:

这样写有几个好处,当有好几个线程检测到这里的_instance为nullptr时,会都进入到第一个if中,然后在这里获取锁,而只有一个线程能够得到锁,然后去到第二个if中创建单例对象,之后这个线程会释放锁,其它线程获取到锁之后,此时_instance已经不是nullptr了自然就不会继续创建单例对象了。

这里通过双if判断的方式提高了获取单例对象的效率。

最后还有一些其它的锁: