Linux C 线程池实现
学习网络编程时,自己动手实现一个Web Server
是一个很有意思的经历。大多数Web Server
都有一个特点:在单位时间内需要处理大量的请求,并且处理这些请求的时间往往还很短。《深入理解计算机系统》 (CSAPP
) 在讲解网络编程时实现了一个经典的Web Server
,这个Web Server
不仅满足了静态请求,同时还满足了动态请求 (CGI
)。虽然这个Web Server
能够正常使用,但是仍存在一个明显的缺陷:它是一个迭代式的Web Server
,这意味着在一个请求处理完毕前,不能同时处理另一个请求,而我们之前提到Web Server
的一个重要特点就是在单位时间内可能会有大量的请求,所以如果投入工业界,这种情况自然是无法容忍的。
多进程 Web Server 模型
解决上面提到的Web Server
只能一个接着一个处理请求的第一个方案是:当accept
到一个请求时,fork
一个子进程去处理这个请求,而主进程仍然在监听是否有新的连接请求。多进程模型在表面上看似乎解决了问题,但是我们都知道fork
一个进程的开销是非常大的,基于以下几个事实。
-
从概念上说,可以将
fork
认作对父进程程序段、数据段、堆段以及栈段创建拷贝。但是如果真的只是简单的将父进程虚拟内存页拷贝到子进程,那就太浪费了。现代UNIX
(Linux
) 在实现fork
时往往会采用两种技术来避免这种浪费。一是内核将每一进程的代码段标记为只读,从而使得父进程和子进程都无法修改代码段。这样,父进程和子进程可以共共享同一代码段。二是对于父进程数据段、堆段和栈段中的各页,内核采用写时复制(copy-on-write
) 的方式,这么做的原因之一是:fork
之后常常伴随着exec
,这会用新程序替换进程的代码段,并重新初始化其数据段、堆段和栈段。但是无论如何,仍存在复制页表的操作,这也是为什么在UNIX
(Linux
) 下创建进程要比创建线程开销大的原因。 -
并发量一大,此时系统内便会有存在大量的进程,这会导致
CPU
花费大量的时间在进程调度上,并且进程上下文的切换开销也很大。
因此,相比于多进程模型,多线程是一个更优的模型:创建线程要快于创建进程,线程间的上下文切换消耗的时间一般也比进程要短。
多线程 Web Server 模型
换用多线程Web Server
模型:每accept
一个请求,创建一个线程,将请求交由该线程处理。换用多线程模型可以解决由fork
带来的开销问题,但是调度问题依然还是存在的。因此,一个显而易见的解决办法是使用线程池,将线程的数量固定下来。基本的实现思路如下。
-
将每个请求封装为一个
Job
,每个Job
包含线程要执行的方法、传递给线程的参数以及用于描述该Job
处于Job
队列的位置的参数。 -
线程池维护着一个
Job
队列,每个线程从Job
队列中取下一个Job
执行。因为该Job
队列是一个共享资源,因此需要控制线程的同步。 -
初始化线程池时,马上创建一定数量的线程。此时,这些线程都是阻塞状态的,因为
Job
队列为空。
代码实现
tinyhttpd是我为了更有效的学习网络编程而实现的一个轻量级的Web Server
,目前仍有部分问题需要解决以及优化。按照上面的思路,我实现了一个简单的线程池,并将其引入到tinyhttpd中。具体的代码实现请参考threadpool.h和threadpool.c。
剩余问题
当固定了线程池的线程数量后,仍然存在一个严重的问题:实际情况下,很多连接都是长连接,这意味着一个线程在处理一个请求时,read
到的数据将会是是不连续的。当线程处理完一批数据后,如果继续read
,而下一批数据还未到来时,由于默认情况下file descriptor
是blocking
的,因此该线程就会进入阻塞状态。所以,如果线程池中所有的线程都处于阻塞状态,此时如果有新的请求到来,那么是无法处理的。
解决方案是将file descriptor
设置为non-blocking
,利用事件驱动(Event-driven
)来处理连接。