之前的章节中,我们通过动态创建子进程或子线程来实现并发服务器,这样做的缺点有:
- 动态创建进程或线程比较耗时,这将导致较慢的客户响应。
- 动态创建的子进程或子线程通常只用来为一个客户服务(除非我们做特殊处理),这将导致系统上产生大量的细微进程或线程,进程或线程间的切换将消耗大量CPU时间。
- 动态创建的子进程是当前进程的完整映像,当前进程必须谨慎地管理其分配的文件描述符和堆内存等系统资源,否则子进程会复制这些资源,从而使系统的可用资源急剧下降,进而影响服务器性能。
进程池和线程池可以用于解决上述问题。
进程池和线程池概述
进程池和线程池相似,所以接下来以进程池为例进行介绍。
如果没有特别声明,下面对进程池的讨论也适用于线程池。
进程池是由服务器预先创建的一组子进程。线程池中的线程数量应该和CPU数量差不多,防止高负载下有CPU核心未被使用。
进程池中的所有子进程都运行着相同的代码,并具有相同的属性,如优先级、PGID等。因为进程池在服务器启动之初时就创建好了,所以每个子进程都相对“干净”,即它们没有打开不必要的文件描述符(从父进程继承而来),也不会错误地使用大块的堆内存(从父进程复制得到)。
当有新任务到来时,主进程将通过某种方式选择进程池中某一子进程来为之服务。相比动态创建子进程,选择一个已经存在的子进程的代价小很多,主进程选择哪个子进程来为新任务服务主要有两种方式:
- 主进程使用某种算法主动选择子进程。最简单、最常用的算法是随机算法和 Round Robin(轮流选取)算法,但更优秀、更智能的算法将使任务在各个工作进程中更均匀地分配,从而减轻服务器的整体压力。
- 主进程和所有子进程通过一个共享的工作队列来同步,子进程都睡眠在该工作队列上,当有新任务到来时,主进程将任务添加到工作队列中。这将唤醒正在等待任务的子进程,但只有一个子进程能获得新任务的接管权,它可以从工作队列中取出任务并执行之,而其他子进程将继续睡眠在工作队列上。
选好子进程后,主进程还需使用某种通知机制来告诉目标子进程有新任务需要处理,并传递必要的数据。(进程间通信 13. C++ TinyWebServer项目总结(13. 多进程编程))
最简单的方法是,在父进程和子进程之间先创建好一条管道,然后通过该管道来实现所有的进程间通信(需要预先定义好一套协议来规范管道的使用)。在父线程和子线程之间传递数据就要简单得多,因为我们可以把这些数据定义为全局的,那么它们本身就是被所有线程共享的。
进程池的一般模型为:
处理多客户
使用进程池处理多客户任务时,首先要考虑的一个问题是:监听socket和连接socket是否都由主进程来统一管理。
两种高效的并发模式中,半同步 / 半反应堆模式是由主进程统一管理这两种socket的,而更高效的半同步 / 半异步模式和领导者 / 追随者模式,则是由主进程管理所有监听socket,而各个子进程分别管理属于自己的连接socket的。
半同步 / 半反应堆模式中,主进程接受新的连接以得到连接socket,然后它需要将该socket传递给子进程(对于线程池而言,父线程将socket传递给子线程是简单的,因为它们可以共享该socket,而对于进程池,我们需要用UNIX域套接字来传递socket 实战 10: 在进程间传递文件描述符);
半同步 / 半异步模式和领导者 / 追随者模式的灵活性更大一点,因为子进程可以自己调用accept
来接受新连接,这样父进程就无须向子进程传递socket,而只需简单地向子进程通知一声:“我检测到了新连接,你来接受它。”
常连接指一个客户的多次请求可以复用一个TCP连接,因此,在设计进程池时还要考虑:一个客户连接上的所有任务是否始终由同一个子进程来处理。如果客户任务是无状态的,那么我们可以考虑使用不同的子进程来为该客户的不同请求服务,如下图所示:
但如果客户任务是存在上下文关系的,则最好一直用同一个子进程来为之服务,否则将不得不在各子进程之间传递上下文数据。在EPOLLONESHOT 事件中,我们使用 epoll
的 EPOLLONESHOT
事件,确保一个客户连接在整个生命周期中仅被一个线程处理。
半同步 / 半异步进程池实现
为了避免在父、子进程间传递文件描述符,我们将接受新连接的操作放到子进程中,对于这种模式而言,一个客户连接上的所有任务始终是由一个子进程来处理的:
/* filename: processpool.h */
/* 模板化设计:使用模板类可以很容易地适应不同类型的请求处理,使得进程池更加通用 */
/* 信号处理:集成信号处理以处理如SIGCHLD、SIGTERM等信号,以实现进程管理和平滑退出 */
/* 非阻塞I/O与Epoll:使用非阻塞I/O和 Epoll 事件驱动模型,提高了I/O处理的效率 */
/* 子进程管理:通过管道与子进程通信,并用Round Robin策略分配客户端请求,均衡负载 */
#ifndef PROCESSPOOL_H
#define PROCESSPOOL_H
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/stat.h>
// 描述一个子进程的类
class process {
public:
process() : m_pid(-1) { }
pid_t m_pid; // 目标子进程的PID
int m_pipefd[2]; // 父进程和子进程通信用的管道
};
// 进程池类,将它定义为模板类是为了代码复用,其模板参数是处理逻辑任务的类
template <typename T>
class processpool {
private:
// 私有构造函数,只能通过后面的create静态方法来创建processpool实例
processpool(int listenfd, int process_number = 8);
public:
// 单例模式,以保证进程最多创建一个processpool实例,这是程序正确处理信号的必要条件
static processpool<T> *create(int listenfd, int process_number = 8) {
// 此处有bug,默认new失败会抛异常,而非返回空指针
if (!m_instance) {
m_instance = new processpool<T>(listenfd, process_number);
}
return m_instance;
}
~processpool() {
delete[] m_sub_process;
}
void run(); // 启动进程池
private:
void setup_sig_pipe();
void run_parent();
void run_child();
private:
// 进程池允许的最大子进程数量
static const int MAX_PROCESS_NUMBER = 16;
// 每个子进程最多能处理的客户数量
static const int USER_PER_PROCESS = 65536;
// epoll最多能处理的事件数
static const int MAX_EVENT_NUMBER = 10000;
// 进程池中的进程总数
int m_process_number;
// 子进程在池中的序号,从0开始
int m_idx;
// 每个进程都有一个epoll内核事件表,用m_epollfd标识
int m_epollfd;
// 监听socket
int m_listenfd;
// 子进程通过m_stop决定是否停止运行
int m_stop;
// 保存所有子进程的描述信息
process *m_sub_process;
// 进程池静态实例
static processpool<T>* m_instance;
};
template<typename T>
processpool<T> *processpool<T>::m_instance = NULL;
// 用来处理信号的管道,以实现统一事件源,后面称之为信号管道
static int sig_pipefd[2];
static int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
static void addfd(int epollfd, int fd) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}
// 从epollfd参数标识的epoll内核事件表中删除fd上的所有注册事件
static void removefd(int epollfd, int fd) {
epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, 0);
close(fd);
}
static void sig_handler(int sig) {
int save_errno = errno;
int msg = sig;
// 发送的sig的低位1字节,如果主机字节序是大端字节序,则发送的永远是0
send(sig_pipefd[1], (char *)&msg, 1, 0);
errno = save_errno;
}
static void addsig(int sig, void handler(int), bool restart = true) {
struct sigaction sa;
memset(&sa, '\0', sizeof(sa));
sa.sa_handler = handler;
if (restart) {
sa.sa_flags |= SA_RESTART;
}
sigfillset(&sa.sa_mask);
assert(sigaction(sig, &sa, NULL) != -1);
}
// 进程池的构造函数,参数listenfd是监听socket,它必须在创建进程池前被创建
// 否则子进程无法直接引用它,参数process_number指定进程池中子进程的数量
template<typename T> processpool<T>::processpool(int listenfd, int process_number)
: m_listenfd(listenfd), m_process_number(process_number), m_idx(-1), m_stop(false) {
assert((process_number > 0) && (process_number <= MAX_PROCESS_NUMBER));
// 此处有bug,默认new失败会抛异常,而非返回空指针
m_sub_process = new process[process_number];
assert(m_sub_process);
// 创建process_number个子进程,并建立它们和父进程之间的管道
for (int i = 0; i < process_number; ++i) {
int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_sub_process[i].m_pipefd);
assert(ret == 0);
m_sub_process[i].m_pid = fork();
assert(m_sub_process[i].m_pid >= 0);
if (m_sub_process[i].m_pid > 0) {
close(m_sub_process[i].m_pipefd[1]);
continue;
} else {
close(m_sub_process[i].m_pipefd[0]);
m_idx = i;
break;
}
}
}
// 统一事件源
template<typename T> void processpool<T>::setup_sig_pipe() {
// 创建epoll事件监听表
m_epollfd = epoll_create(5);
assert(m_epollfd != -1);
// 创建信号管道
int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, sig_pipefd);
assert(ret != -1);
setnonblocking(sig_pipefd[1]);
addfd(m_epollfd, sig_pipefd[0]);
// 设置信号处理函数
addsig(SIGCHLD, sig_handler);
addsig(SIGTERM, sig_handler);
addsig(SIGINT, sig_handler);
addsig(SIGPIPE, SIG_IGN);
}
// 父进程中m_idx值为-1,子进程中m_idx值大于等于0,我们据此判断要运行的是父进程代码还是子进程代码
template<typename T> void processpool<T>::run() {
if (m_idx != -1) {
run_child();
return;
}
run_parent();
}
template<typename T>
void processpool<T>::run_child() {
setup_sig_pipe();
// 每个子进程都通过其在进程池中的序号值m_idx找到与父进程通信的管道
int pipefd = m_sub_process[m_idx].m_pipefd[1];
// 子进程需要监听管道文件描述符pipefd,因为父进程将通过它通知子进程accept新连接
addfd(m_epollfd, pipefd);
epoll_event events[MAX_EVENT_NUMBER];
// 此处有bug,默认new失败会抛异常,而非返回空指针
T *users = new T[USER_PER_PROCESS];
assert(users);
int number = 0;
int ret = -1;
while (!m_stop) {
number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
if ((number < 0) && (errno != EINTR)) {
printf("epoll failure\n");
break;
}
for (int i = 0; i < number; ++i) {
int sockfd = events[i].data.fd;
if ((sockfd == pipefd) && (events[i].events & EPOLLIN)) {
int client = 0;
ret = recv(sockfd, (char *)&client, sizeof(client), 0);
if (((ret < 0) && (errno != EAGAIN)) || ret == 0) {
continue;
} else {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address,
&client_addrlength);
if (connfd < 0) {
printf("errno is: %d\n", errno);
continue;
}
addfd(m_epollfd, connfd);
// 模板类T必须实现init方法,以初始化一个客户连接,我们直接使用connfd来索引逻辑处理对象(T对象)
// 这样效率较高,但比较占用空间(在子进程的堆内存中创建了65535个T对象)
users[connfd].init(m_epollfd, connfd, client_address);
}
// 处理子进程接收到的信号
}
else if ((sockfd == sig_pipefd[0]) && (events[i].events & EPOLLIN))
{
int sig;
char signals[1024];
ret = recv(sig_pipefd[0], signals, sizeof(signals), 0);
if (ret <= 0)
{
continue;
}
else
{
for (int i = 0; i < ret; ++i)
{
switch (signals[i])
{
case SIGCHLD:
pid_t pid;
int stat;
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
{
continue;
}
break;
case SIGTERM:
case SIGINT:
m_stop = true;
break;
default:
break;
}
}
}
// 如果是客户发来的请求,则调用逻辑处理对象的process方法处理之
}
else if (events[i].events & EPOLLIN)
{
users[sockfd].process();
} else
{
continue;
}
}
}
delete[] users;
users = NULL;
close(pipefd);
// 我们将关闭监听描述符的代码注释掉,以提醒读者:应由m_listenfd的创建者来关闭这个文件描述符
// 即所谓的对象(如文件描述符或一段堆内存)应由创建函数来销毁
// close(m_listenfd);
close(m_epollfd);
}
template<typename T>
void processpool<T>::run_parent()
{
setup_sig_pipe();
// 父进程监听m_listenfd
addfd(m_epollfd, m_listenfd);
epoll_event events[MAX_EVENT_NUMBER];
int sub_process_counter = 0;
int new_conn = 1;
int number = 0;
int ret = -1;
while (!m_stop)
{
number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
if ((number < 0) && (errno != EINTR))
{
printf("epoll failure\n");
break;
}
for (int i = 0; i < number; ++i)
{
int sockfd = events[i].data.fd;
if (sockfd == m_listenfd)
{
// 如果有新连接到来,就用Round Robin方式将其分配给一个子进程处理
int i = sub_process_counter;
do
{
if (m_sub_process[i].m_pid != -1)
{
break;
}
i = (i + 1) % m_process_number;
}
while (i != sub_process_counter);
if (m_sub_process[i].m_pid == -1)
{
m_stop = true;
break;
}
sub_process_counter = (i + 1) % m_process_number;
send(m_sub_process[i].m_pipefd[0], (char *)&new_conn, sizeof(new_conn), 0);
printf("send request to child %d\n", i);
}
else if ((sockfd == sig_pipefd[0]) && (events[i].events & EPOLLIN))
{
int sig;
char signals[1024];
ret = recv(sig_pipefd[0], signals, sizeof(signals), 0);
if (ret <= 0)
{
continue;
}
else
{
for (int i = 0; i < ret; ++i)
{
switch (signals[i])
{
case SIGCHLD:
{
pid_t pid;
int stat;
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
{
for (int i = 0; i < m_process_number; ++i)
{
// 如果进程池中第i个进程退出
if (m_sub_process[i].m_pid == pid)
{
printf("child %d join\n", i);
// 关闭与该子进程的通信管道
close(m_sub_process[i].m_pipefd[0]);
// 将该子进程的m_pid设为-1,表示该子进程已退出
m_sub_process[i].m_pid = -1;
}
}
}
// 如果所有子进程都已退出,则父进程也退出
m_stop = true;
for (int i = 0; i < m_process_number; ++i)
{
if (m_sub_process[i].m_pid != -1)
{
m_stop = false;
}
}
break;
}
case SIGTERM:
case SIGINT:
{
// 如果父进程接收到终止信号,就杀死所有子进程,并等待它们全部结束
// 通知子进程结束更好的方式是向父子进程之间的通信管道发送特殊数据
printf("kill all the child now\n");
for (int i = 0; i < m_process_number; ++i)
{
int pid = m_sub_process[i].m_pid;
if (pid != -1)
{
kill(pid, SIGTERM);
}
}
break;
}
default:
{
break;
}
}
}
}
}
else
{
continue;
}
}
}
// close(m_listenfd); /* 由创建者关闭这个文件描述符 */
close( m_epollfd );
}
#endif
实战 16:用进程池实现简单的 CGI 服务器
在实战 3:实现一个简单的 CGI 服务器中,我们描述了 CGI 服务器的工作原理和简单实现。
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/wait.h>
#include <sys/stat.h>
/* 引用进程池 */
#include "processpool.h"
// 用于处理客户CGI请求的类,它可作为processpool类的模板参数
class cgi_conn {
public:
cgi_conn() { }
~cgi_conn() { }
// 初始化客户连接,清空读缓冲区
void init(int epollfd, int sockfd, const sockaddr_in& client_addr) {
m_epollfd = epollfd;
m_sockfd = sockfd;
m_address = client_addr;
memset(m_buf, '\0', BUFFER_SIZE);
m_read_idx = 0;
}
void process() {
int idx = 0;
int ret = -1;
// 循环读取和分析客户数据
while (true) {
idx = m_read_idx;
ret = recv(m_sockfd, m_buf + idx, BUFFER_SIZE - 1 - idx, 0);
// 如果读操作发生错误,则关闭客户连接;如果暂时无数据可读,则退出循环
if (ret < 0) {
if (errno != EAGAIN) {
removefd(m_epollfd, m_sockfd);
}
break;
// 如果对方关闭连接,则服务器也关闭连接
} else if (ret == 0) {
removefd(m_epollfd, m_sockfd);
break;
} else {
m_read_idx += ret;
printf("user content is: %s\n", m_buf);
// 如果遇到字符\r\n,则开始处理客户请求
for (; idx < m_read_idx; ++idx) {
if ((idx >= 1) && (m_buf[idx - 1] == '\r') && (m_buf[idx] == '\n')) {
break;
}
}
// 如没有遇到\r\n,则需要读取更多数据
if (idx == m_read_idx) {
continue;
}
m_buf[idx - 1] = '\0';
char *file_name = m_buf;
// 判断客户要运行的CGI程序是否存在
// access函数用于检测file_name参数表示的文件,F_OK表示检测文件是否存在
if (access(file_name, F_OK) == -1) {
removefd(m_epollfd, m_sockfd);
break;
}
// 创建子进程执行CGI程序
ret = fork();
if (ret == -1) {
removefd(m_epollfd, m_sockfd);
break;
} else if (ret > 0) {
// 父进程只需关闭连接
removefd(m_epollfd, m_sockfd);
break;
} else {
// 子进程将标准输出重定向到m_sockfd,并执行CGI程序
close(STDOUT_FILENO);
dup(m_sockfd);
execl(m_buf, m_buf, 0);
exit(0);
}
}
}
}
private:
static const int BUFFER_SIZE = 1024;
static int m_epollfd;
int m_sockfd;
sockaddr_in m_address;
char m_buf[BUFFER_SIZE];
// 标记读缓冲中已经读入的客户数据的最后一个字节的下一个位置
int m_read_idx;
};
int cgi_conn::m_epollfd = -1;
int main(int argc, char *argv[]) {
if (argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
processpool<cgi_conn> *pool = processpool<cgi_conn>::create(listenfd);
if (pool) {
pool->run();
delete pool;
}
close(listenfd); // main函数创建了listenfd,就由它来关闭
return 0;
}
暂时还没有测试。