实现一个简单的线程池

这篇文章根据16年写线程池时写的笔记扩展而来的。

在经典的C10K问题的解决方案中,有一种是多线程的解决思路。给出的解决思路是对到达的每一个请求,都动态的生成一个线程,来做单独的服务和和处理,如果是keep-alive的TCP连接(这个用的好像也不多),服务线程就会一直工作到连接断开才退出。

使用c或者c++来编写多线程的服务器有很多比较trick的问题需要注意,比如何时能安全的释放多个线程共同操作的资源等。在这里我不想深入的探讨这些问题,那是并发编程的范畴了。

抛开并发中的竞争问题,多线程的服务器还有另外一个性能的问题,对于每一个连接都要生成一个线程去服务,当连接到达和断开的很频繁的时候就会产生非常大的开销。比如,HTTP协议中是一个无状态的协议,现代浏览器现在也都默认开启了多线程请求用来同时请求页面中不同部分的资源,那么,用户仅仅每次只是访问一个页面都会产生多个TCP请求,并且,如果客户端或者服务端不支持WebSocket协议,就要不断的建立,关闭HTTP连接,这会带来极大的线程生成和释放的开销(尽管HTTP1.1里所有连接默认都是持久连接但是在服务器的实现上过期时间一般都[很短][1])。

如何减少这些开销呢?没错,就是使用[线程池(Thread Pool)][2]。

线程池的基本原理其实非常的简单,一句话来解释就是,为了将要到来的请求,提前生成准备好固定数量的线程,等连接建立后,从存放的线程中挑选一个空闲的,来处理请求,当连接断开后,将处理其的线程放回存储池中不释放。

使用线程池就避免了大量的动态生成和释放线程的开销。原理是这样的这样清晰易懂,刚好我又复习了下Unix环境高级编程中关于线程的部分,想要动动手简单实现一下,语言就先使用C语言,调用Pthreads库,参考了[这里][3]。

后面觉得这里更加适合使用C++的类封装还有RAII等特性,就用C++重构了一下。

用C来实现

整体的结构很清晰甚至说是简单,主要考虑的是消费者-生产者模型,同时使用队列来进行平衡速率。所以数据结构的设计上可以简单的考虑两种类型,一种是存储的让线程运行起来的参数thread_,另外一种就是线程池threads_pool。

在thread_中,仅仅用来存放两个指针,一个指向线程将要工作的function,另外一个指向函数需要的参数,只需要这两个就足够让线程运行起来了。

在threadspool中,需要存放的就比较多了,这里,我使用的是一个链表来存放所有的thread,同时还得有size和头尾指针,使用另外一个链表来存放空闲的已经分配好的线程。这里要注意的是,线程池是一个竞争资源,所以在存取线程的时候一定要加锁,同时,线程池可能会被取空,那样的话,接下来的所有请求都必须被阻塞,直到有线程运行完毕,被重新放回线程池中。这就需要一个手段来通知所有被阻塞的请求,在Phtreads库中,可以用条件变量pthread_cond_t来达到着一点。

如何初始化和释放线程池呢?初始化就是一项项的分配资源而已。要注意的是,在释放线程池分配的资源之前,一定要使用pthread_cond_broadcast将所有的空闲线程唤醒,然后用pthread_join等待所有的线程执行完,才可以释放资源。

如何让线程去执行任务呢?在这里,可以把每一个分配好的空闲线程当做一个主体,它们可以竞争线程池中的thread_资源。通过互斥锁,可以保证一次只有一个线程获取一个任务,如果现在没有要执行的任务,就使用条件变量等待(pthread_cond_wait)。这个函数会释放之前获取的锁,这样如果有新的请求就可以获取线程池的锁,添加需要执行的任务,然后使用pthread_cond_signal 唤醒在阻塞中等待的空闲线程,重新去争夺线程池的锁,第一个获取到的线程执行任务,剩下的继续阻塞等待。

用C++来重构一下

基本的原理同用C语言来实现是一样的,可是C++提供了更加丰富的语言特性,比如RAII。在用C语言实现的版本中,我们要记得在获取锁之后释放锁,不然就会造成一直占用锁,其他线程没有办法执行。

但是,在C++中,我们可以用类包装锁资源,在类对象初始化的时候获取锁,在类对象析构的释放锁,将程序员的任务交给语言去完成,极大的减轻了程序员的负担和出错误的可能性。而且,使用类可以有更好的封装性。

基本的实现就不多说了,提一下使用的类。可以将之前使用的互斥锁和条件变量包装成类。同时将前面的任务和线程池也该用类来实现。

Reference

HTTP 1.1 keepalive
线程池 Wiki
Thread Pools