Linux高性能服务器编程——进程池和线程池

进程池和线程池

池的概念

由于服务器的硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池的概念。池是一组资源的集合,这组资源在服务器启动之初就完全被创建并初始化,这称为静态资源分配。当服务器进入正是运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无需动态分配。很显然,直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用来释放资源。从最终效果来看,池相当于服务器管理系统资源的应用设施,它避免了服务器对内核的频繁访问。

池可以分为多种,常见的有内存池、进程池、线程池和连接池。

进程池和线程池概述

进程池和线程池相似,所以这里我们以进程池为例进行介绍。如没有特殊声明,下面对进程池的讨论完全是用于线程池。

进程池是由服务器预先创建的一组子进程,这些子进程的数目在3~10个之间(当然这只是典型情况)。线程池中的线程数量应该和CPU数量差不多。

进程池中的所有子进程都运行着相同的代码,并具有相同的属性,比如优先级、PGID等。

当有新的任务来到时,主进程将通过某种方式选择进程池中的某一个子进程来为之服务。相比于动态创建子进程,选择一个已经存在的子进程的代价显得小得多。至于主进程选择哪个子进程来为新任务服务,则有两种方法:

  1. 主进程使用某种算法来主动选择子进程。最简单、最常用的算法是随机算法和Round Robin(轮流算法)。
  2. 主进程和所有子进程通过一个共享的工作队列来同步,子进程都睡眠在该工作队列上。当有新的任务到来时,主进程将任务添加到工作队列中。这将唤醒正在等待任务的子进程,不过只有一个子进程将获得新任务的“接管权”,它可以从工作队列中取出任务并执行之,而其他子进程将继续睡眠在工作队列上。

当选择好子进程后,主进程还需要使用某种通知机制来告诉目标子进程有新任务需要处理,并传递必要的数据。最简单的方式是,在父进程和子进程之间预先建立好一条管道,然后通过管道来实现所有的进程间通信。在父线程和子线程之间传递数据就要简单得多,因为我们可以把这些数据定义为全局,那么它们本身就是被所有线程共享的。

综上所述,进程池的一般模型如下所示:

处理多客户

在使用进程池处理多客户任务时,首先要考虑的一个问题是:监听socket和连接socket是否都由进程统一管理这两种socket。这可以一下介绍的并发模式解决。服务器主要有两种并发编程模式:半同步/半异步模式和领导者/追随者模式。

其次,在设计进程池时还需要考虑:一个客户连接上的所有任务是否始终由一个子进程来处理。如果说客户任务是无状态的,那么我们可以考虑使用不同的子进程来为该客户的不同请求服务。但如果客户是存在上下文关系的,则最好一直用同一个子进程来为之服务,否则实现起来比较麻烦,因为我们不得不在各子进程之间传递上下文数据。epoll的EPOLLONESHOT事件能够确保一个客户连接在整个生命周期中仅被一个线程处理。

半同步/半异步模式

在并发模式中,同步指的是程序完全按照代码序列的顺序执行;异步指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。如下描述了同步的读操作和异步的读操作。

按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。显然,异步线程的执行效率高,实时性强,但编程相对复杂,难于调试和扩展,不适合大量的并发。二同步线程则相反,虽然效率较低,实时性较差,但逻辑简单。因此,对于像服务器这种既要求较好的实时性,又要求能处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式实现。

半同步/半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理I/O时间。异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理请求对象。具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。比如简单的轮流选取工作线程的Round
Robin算法,也可以通过条件变量或信号量来随机地选择一个工作线程。

半同步/半反应堆(half-sync/half-reactive)模式

上图中异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有读写事件发生,即由新的客户请求到来或者有数据发送至客户端,主线程就将该连接socket插入请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们通过竞争获得任务的接管权。这种竞争机制使得空闲的工作线程才有机会来处理新任务,这是很合理的。

主线程插入请求队列中的任务是就绪的连接socket。这说明该图所示的半同步/半反应堆模式采用的时间处理模式是Reactor模式。它要求工作线程自己从socket上读取客户请求和往socket写入服务器应答。这就是该模式的名称中half-reactive的含义。实际上,也可以使用Proactor时间处理模式,即由主线程来完成数据的读写。在这种请求下,主线程一般会将应用程序数据、任务类型等信息封装为一个任务对象,然后将其插入请求队列。工作线程从请求队列中取得任务对象中之后,即可直接处理之,而无须执行读写操作了。

半同步/半反应堆存在如下缺点:

  1. 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而浪费CPU时间。
  2. 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。

高效的半同步/半异步模式

上图中,。主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该新socket上的任何I/O操作都被选中的工作线程处理,知道客户关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来,如果是,则把该新的socket上的读写事件注册到自己的epoll内核事件表中。

每个线程都维持自己的时间循环,他们各自独立地监听不同的时间。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所有它并非严格意义上的半同步/半异步模式。

领导者/追随者模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听I/O时间。而其他线程则都是追随者,他们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,二者实现了并发。

多个子进程。

半同步/半异步进程池实现

这里我们实现一个基于高效的半同步/半异步并发模式的进程池。为了避免父、子进程之间传递文件描述符,我们将接受新连接的操作放到子进程中。很显然,对于这种模式而言,一个客户连接上的所有任务始终由一个子进程来处理。代码清单见https://github.com/walkerczb/processpool下的processpool.h

用进程池实现的CGI服务器

然后利用建立的进程池,实现了一个CGI服务器。代码清单见https://github.com/walkerczb/processpool下的processpool.c

执行服务器程序如下:

[email protected]:~/LinuxServer Programming$
./processpool192.168.73.129 54321

客户端执行telnet 192.168.73.129123123后显示结果如下:

[email protected]:~$ telnet 192.168.73.129 54321

Trying192.168.73.129...

Connected to192.168.73.129.

Escape characteris ‘^]‘.

printHelloworld (服务器有printHelloworld程序执行后显示HelloWorld)

Hello World!

Connectionclosed by foreign host.

服务器上显示:

send request tochild 0

user contentis:printHelloworld

上面显示中粗体为敲入命令,其余为执行结果。

半同步/半反应堆线程池的实现

这里我们实现了一个半同步/半反应堆并发模式的线程池,代码清单见https://github.com/walkerczb/threadpool中的threadpool.h
。该线程池使用一个工作队列完全解除了主线程和工作线程的耦合关系:主线程往工作队列中插入任务,工作线程通过竞争来取得任务并执行它。不过,如果要将该线程池应用到实际服务器程序中,那么我们必须保证所有客户请求都是无状态的,因为同一个连接上的不同请求可能会由不同的线程处理。

这里值得一提的是,在C++程序中使用pthread_create函数时,该函数的第3个参数必须指向一个静态函数。而要在一个静态函数中使用类的动态成员(包括成员函数和成员变量),则只能通过如下两种方式实现:

  1. 通过类的静态对象来调用。
  2. 将类的对象作为参数传递给该静态函数,然后在静态函数中引入这个对象,并调用其动态方法。

代码清单threadpool.h使用的第二种方式:将线程参数设置为this指针,然后在worker函数中获取该指针并调用其动态方法run。

用线程实现的简单Web服务器

这里我们用前面的线程池来实现一个并发的Web服务器

http_conn类

首先,我们需要准备线程池的模板参数类,用以封装对逻辑任务的处理,这个类是http_conn,代码清单见头文件https://github.com/walkerczb/threadpool 中的http_conn.h,
http_conn.cpp和locker.h。

main函数

定义好任务之后,main函数就变得很简单了,它只需要负责I/O读写,如代码清单https://github.com/walkerczb/threadpool 中的main.cpp。

Linux高性能服务器编程——进程池和线程池

时间: 2024-08-09 14:48:30

Linux高性能服务器编程——进程池和线程池的相关文章

linux高性能服务器编程

<Linux高性能服务器编程>:当当网.亚马逊 目录: 第一章:tcp/ip协议族 第二章:ip协议族 第三章:tcp协议详解 第四章:tcp/ip通信案例:访问Internet 第五章:linux网络编程基础API 第六章:高级IO函数 第七章:linux服务器程序规范 第八章:高性能服务器框架 第九章:IO复用 第十章:信号 第十一章:定时器 第十二章:高性能IO框架库libevent 第十三章:多进程编程 第十四章:多线程编程 第十五章:进程池和线程池 第十六章:服务器调制.调试和测试

Linux高性能服务器编程——多线程编程(上)

多线程编程 Linux线程概述 线程模型 线程是程序中完成一个独立任务的完整执行序列,即一个可调度的实体.根据运行环境和调度者的身份,线程可分为内核线程和用户线程.内核线程,在有的系统上也称为LWP(Light Weigth Process,轻量级进程),运行在内核空间,由内核来调度:用户线程运行在用户空间,由线程库来调度.当进程的一个内核线程获得CPU的使用权时,它就加载并运行一个用户线程.可见,内核线程相当于用于线程运行的容器.一个进程可以拥有M个内核线程和N个用户线程,其中M≤N.并且在一

Linux高性能服务器编程——多线程编程(下)

多线程编程 条件变量 如果说互斥锁是用于同步线程对共享数据的访问的话,那么条件变量则是用于线程之间同步共享数据的值.条件变量提供了一种线程间的通信机制:当某个共享数据达到某个值得时候,唤醒等待这个共享数据的线程. 条件本身是由互斥量保护的.线程在改变条件状态前必须首先锁住互斥量,其他现成在获得互斥量之前不会察觉到这种变化,因为必须锁住互斥量以后才能计算条件. 条件变量的相关函数主要有如下5个: #include <pthread.h> int pthread_cond_destroy(pthr

Linux高性能服务器编程——信号及应用

 信号 信号是由用户.系统或者进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常.Linux信号可由如下条件产生: 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号.比如输入Ctrl+C通常会给进程发送一个终端信号. 2.系统异常 系统状态变化 运行kill命令或调用kill函数 Linux信号概述 发送信号 Linux下,一个进程给其他进程发送信号的API是kill函数.其定义如下: #include <sys/types.h> #include <sign

Linux高性能服务器编程——多进程编程

多进程编程 多进程编程包括如下内容: 复制进程影映像的fork系统调用和替换进程映像的exec系列系统调用. 僵尸进程以及如何避免僵尸进程 进程间通信(Inter-Process Communication,IPC)最简单的方式:管道 3种进程间通信方式:信号量,消息队列和共享内存 fork系统调用 #include<unistd.h> pid_tfork(void); 该函数的每次都用都返回两次,在父进程中返回的是子进程的PID,在子进程中返回的是0.该返回值是后续代码判断当前进程是父进程还

Linux高性能服务器编程——系统检测工具

系统检测工具 tcpdump tcpdump是一款经典的转包工具,tcpdump给使用者提供了大量的选项,泳衣过滤数据报或者定制输出格式. lsof lsof是一个列出当前系统打开的文件描述符的工具.通过它我们可以了解感兴趣的进程打开了哪些文件描述符,或者我们感兴趣的文件描述符被哪些进程打卡了. nc nc命令主要被用来快速构建网络连接.我们可以让它以服务器方式运行,监听某个端口并接收客户连接,因此它可用来调试客户端程序.我们也可以使之以客户端方式运行,向服务器发起连接并收发数据,因此它可以用来

Linux高性能服务器编程——I/O复用

 IO复用 I/O复用使得程序能同时监听多个文件描述符,通常网络程序在下列情况下需要使用I/O复用技术: 客户端程序要同时处理多个socket 客户端程序要同时处理用户输入和网络连接 TCP服务器要同时处理监听socket和连接socket,这是I/O复用使用最多的场合 服务器要同时处理TCP请求和UDP请求.比如本章将要讨论的会社服务器 服务器要同时监听多个端口,或者处理多种服务. I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的.并且当多个文件描述符同时就绪时,如果不采用额外措施

Linux高性能服务器编程——高级I/O函数

 高级I/O函数 pipe函数 pipe函数用于创建一个管道,实现进程间的通信. #include <unistd.h> int pipe(int pipefd[2]); 通过pipe函数创建的文件描述符fd[0]和fd[1]分别构成管道的两端,往fd[1]写入的数据可以从fd[0]读出,不能反过来.管道内部传输的数据时字节流,和TCP字节流概念相同,但有区别,管道本身拥有一个容量限制,它规定如果应用程序不将数据从管道读走的话,该管道最多能被写入多少字节的数据.管道容量阿东小默认是6553

Linux高性能服务器编程——定时器

 定时器 服务器程序通常管理着众多定时事件,因此有效组织这些定时事件,使之能在预期的时间点被触发且不影响服务器的主要逻辑,对于服务器的性能有着至关重要的影响.位置我们要将每个定时事件封装成定时器,并使用某种容器类型的数据结构,比如链表.排序链表和时间轮将所有定时器串联起来,以实现对定时事件的统一管理. Linux提供三种定时方法: 1.socket选项SO_RECVTIMEO和SO_SNDTIMEO. 2.SIGALRM信号 3.I/O复用系统调用的超时参数 socket选项SO_RCVTI