一般进程池和线程池都是并发编程中常见的, 如nginx
采用进程池, 也有实现协程降低上下文切换的代价等等, 使用和实现这些方法都是为了提高我们服务端的并发能力.
进程池和线程池都是避免服务端频繁的创建进程(线程), 毕竟创建进程(线程)的代价很大. 所以可先在程序运行时便分配出一定的进程(线程)数, 如果有事件就绪便可以直接调用分配好的进程, 让进程池中的进程区处理事件, 事件处理结束后进程不会被释放而是继续回到池等待事件, 这样就可以让池循环的利用.
本节先介绍进程池的编写, 下一节分析线程池.
进程池
因为进程池的代码很多, 粘贴看也很不方便, 所以我还是只粘贴出部分重要的代码进行分析.
源码文件位置 : /network_code/seriver_client/Fork/processpoll
1. 所要用到的结构体
enum {
MAX_PROCESSPOOL = 10, /* 线程池个数 */
MAX_USER_PROCESS_NUM = 65535, /* 子进程处理事件的个数 */
MAX_EPOLL_PROCESS = 10000, /* epoll 一次性能够处理的事件数 */
};
struct process{
pid_t pid; /* 当前进程ID */
int pipe[2]; /* 管道, 用来统一事件源 */
};
struct processpool{
int pool_id; /* 进程 ID */
int epoll_fd; /* epoll 文件描述符 */
int listen_fd; /* 监听文件描述符 */
int stop; /* 进程状态 */
struct process sub_process[MAX_PROCESSPOOL];
};
2. 初始化进程池
因为使用 半同步/半异步 中图二的模式, 所以父进程需要与每个子进程之间建立管道, 以便之后有连接到来后通知指定子进程调用 accept
保持连接. 父进程关闭管道的读端, 子进程关闭管道的写端.
需要注意 : 用于本地内部进程通讯的套接字需要将协议族设置为 XX_UNIX 或者 XX_LOCAL[1] .
// 初始化 processpool 结构体
void init(struct processpool * init){
init->stop = 0;
init->pool_id = -1;
for(int i = 0; i < MAX_PROCESSPOOL; ++i){
// 因为是主机进程间的通信, 所有协议族应该使用 XX_UNIX
socketpair(PF_UNIX, SOCK_STREAM, 0, init->sub_process[i].pipe);
pid_t pid = init->sub_process[i].pid = fork();
if(pid > 0){
close(init->sub_process[i].pipe[1]); // 父进程关闭读端
printf("id = %d, pid = %d\n", i, pid);
continue;
}
else if(pid == 0){
close(init->sub_process[i].pipe[0]); // 子进程关闭写端
init->pool_id = i; // 子进程保存所在进程数组中的 id
break;
}
}
}
3. 执行与初始化
// 执行监听和处理, 其实就是执行父进程
void run(struct processpool * pool){
init(pool);
if(pool->pool_id != -1){
run_client(pool);
return;
}
run_paren(pool);
}
4. 父进程负责监听TCP连接并通知子进程
有TCP连接就绪, 父进程就通过向管道写入数据通知指定的子进程.
void run_paren(struct processpool * pool){
....
add_event(epollfd, pool->listen_fd); // 注册监听事件
while(!pool->stop){
n = epoll_wait(epollfd, evs, MAX_EPOLL_PROCESS, -1);
for(int i = 0; i < n; ++i){
int fd = evs[i].data.fd;
// 有连接就绪
if(fd == pool->listen_fd && (evs[i].events & EPOLLIN)){
// 从进程中寻找一个进程
int id = next_id;
do{
if(pool->sub_process[id].pid != -1)
break;
id = (id + 1) % MAX_PROCESSPOOL;
}while(id != next_id);
if(pool->sub_process[id].pid == -1){
pool->stop = 1;
break;
}
write(pool->sub_process[id].pipe[0], (char *)&informClient,
sizeof(informClient));
next_id = (id + 1) % MAX_PROCESSPOOL;
printf("send request to child %d\n", id);
}
....
}
}
close(epollfd);
}
5. 子进程负责进程管道和其他就绪描述符
子进程监听管道, 如果管道就绪, 则有就绪的TCP连接. 所以子进程调用 accept
获取连接并将其注册到epoll的监听事件中, 如果事件就绪就调用 processing
函数进行处理.
void run_client(struct processpool * pool){
...
// 保存子进程与父进程的管道描述符, 以便后面直接使用并直接注册管道监听
int pipefd = pool->sub_process[pool->pool_id].pipe[1];
add_event(epollfd, pipefd);
while(!pool->stop){
n = epoll_wait(epollfd, evs, sizeof(evs), -1);
for(int i = 0; i < n; ++i){
int fd = evs[i].data.fd;
// 如果是父进程通过管道发的消息, 则表示有连接就绪
if(fd == pipefd && (evs[i].events & EPOLLIN)){
int retinform;
int ret;
ret = read(pipefd, (char *)&retinform, sizeof(retinform));
if(ret < 0) break;
clientfd = accept(pool->listen_fd, NULL, NULL);
if(clientfd < 0){
fprintf(stderr, "accept error\n");
break;
}
add_event(epollfd, clientfd);
printf("accept success, clientfd = %d\n", clientfd);
// 将连接TCP描述符保存, 以便之后可以直接使用
fdsinit(&userfds[clientfd], epollfd, clientfd);
}
// 是就绪文件描述符, 就直接调用处理函数即可
else if(evs[i].events & EPOLLIN){
if(processing(&userfds[fd]) != 0){
del_event(epollfd, fd);
}
}
...
}
}
close(epollfd);
close(pipefd);
}
在 main 函数目录下执行 make
. 可通过 lsof -i:端口
查看进程的监听和连接状态.
小结
在代码中会看到将信号也注册到 epoll 监听事件中, 这种做法其实是统一事件源, 这中统一事件源是 libevent[2]高效处理的方法.
- 进程池实现的过程