基于epoll实现简单的web服务器

1. 简介

epoll 是 Linux 平台下特有的一种 I/O 复用模型实现,于 2002 年在 Linux kernel 2.5.44 中被引入。在 epoll 之前,Unix/Linux 平台下的 I/O 复用模型包含 select 和 poll 两个系统调用。随着因特网的发展,因特网的用户量越来越大,C10K 问题出现。基于 select 和 poll 编写的网络服务已经不能满足不能满足用户的需求了,业界迫切希望更高效的系统调用出现。在此背景下,FreeBSD 的 kqueue 和 Linux 的 epoll 被研发了出来。kqueue 和 epoll 的出现,终结了 C10K 问题,C10K 问题就此作古。

因为 Linux 系统的广泛应用,所以大家在说 I/O 复用时,更多的是想到了 epoll,而不是 kqueue,本文也不例外。本篇文章不会涉及 kqueue,大家有兴趣可以自己看看。

2. 基于 epoll 实现 web 服务器

在 Linux 中,epoll 并不是一个系统调用,而是 epoll_create、epoll_ctl 和 epoll_wait 三个系统调用的统称。关于这三个系统调用的细节,这里就不说明了,大家可以自己去查 man-page。接下来,我们来直接看一个例子,这个例子基于 epoll 和 TinyHttpd 实现了一个 I/O 复用版的 HTTP Server。在上代码前,我们先来演示这个玩具版 HTTP Server 的效果。

上面就是玩具版 HTTP Server 的运行效果了,看起来还行。在我第一次把它成功跑起来的时候,感觉很奇妙。好了,看完效果,接下来看代码吧,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/sysinfo.h>
#include <sys/epoll.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/types.h>
#include "httpd.h"

#define DEFAULT_PORT 8080
#define MAX_EVENT_NUM 1024
#define INFTIM -1

void process(int);

void handle_subprocess_exit(int);

int main(int argc, char *argv[])
{
struct sockaddr_in server_addr;
int listen_fd;
int cpu_core_num;
int on = 1;

listen_fd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(listen_fd, F_SETFL, O_NONBLOCK); // 设置 listen_fd 为非阻塞
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(DEFAULT_PORT);

if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind error, message: ");
exit(1);
}

if (listen(listen_fd, 5) == -1) {
perror("listen error, message: ");
exit(1);
}

printf("listening 8080\n");

signal(SIGCHLD, handle_subprocess_exit);

cpu_core_num = get_nprocs();
printf("cpu core num: %d\n", cpu_core_num);
// 根据 CPU 数量创建子进程,为了演示“惊群现象”,这里多创建一些子进程
for (int i = 0; i < cpu_core_num * 2; i++) {
pid_t pid = fork();
if (pid == 0) { // 子进程执行此条件分支
process(listen_fd);
exit(0);
}
}

while (1) {
sleep(1);
}

return 0;
}

void process(int listen_fd)
{
int conn_fd;
int ready_fd_num;
struct sockaddr_in client_addr;
int client_addr_size = sizeof(client_addr);
char buf[128];

struct epoll_event ev, events[MAX_EVENT_NUM];
// 创建 epoll 实例,并返回 epoll 文件描述符
int epoll_fd = epoll_create(MAX_EVENT_NUM);
ev.data.fd = listen_fd;
ev.events = EPOLLIN;

// 将 listen_fd 注册到刚刚创建的 epoll 中
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl error, message: ");
exit(1);
}

while(1) {
// 等待事件发生
ready_fd_num = epoll_wait(epoll_fd, events, MAX_EVENT_NUM, INFTIM);
printf("[pid %d] 😱 震惊!我又被唤醒了...\n", getpid());
if (ready_fd_num == -1) {
perror("epoll_wait error, message: ");
continue;
}
for(int i = 0; i < ready_fd_num; i++) {
if (events[i].data.fd == listen_fd) { // 有新的连接
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_addr_size);
if (conn_fd == -1) {
sprintf(buf, "[pid %d] ❌ accept 出错了: ", getpid());
perror(buf);
continue;
}

// 设置 conn_fd 为非阻塞
if (fcntl(conn_fd, F_SETFL, fcntl(conn_fd, F_GETFD, 0) | O_NONBLOCK) == -1) {
continue;
}

ev.data.fd = conn_fd;
ev.events = EPOLLIN;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) {
perror("epoll_ctl error, message: ");
close(conn_fd);
}
printf("[pid %d] 📡 收到来自 %s:%d 的请求\n", getpid(), inet_ntoa(client_addr.sin_addr), client_addr.sin_port);

} else if (events[i].events & EPOLLIN) { // 某个 socket 数据已准备好,可以读取了
printf("[pid %d] ✅ 处理来自 %s:%d 的请求\n", getpid(), inet_ntoa(client_addr.sin_addr), client_addr.sin_port);
conn_fd = events[i].data.fd;
// 调用 TinyHttpd 的 accept_request 函数处理请求
accept_request(conn_fd, &client_addr);
close(conn_fd);
} else if (events[i].events & EPOLLERR) {
fprintf(stderr, "epoll error\n");
close(conn_fd);
}
}
}
}

void handle_subprocess_exit(int signo)
{
printf("clean subprocess.\n");
int status;
while(waitpid(-1, &status, WNOHANG) > 0);
}

上面的代码有点长,不过还好,基本上都是模板代码,没什么特别复杂的逻辑。希望大家耐心看一下。

上面的代码基于epoll + 多进程的方式实现,开始,主进程会通过系统调用获取 CPU 核心数,然后根据核心数创建子进程。为了演示“惊群现象”,这里多创建了一倍的子进程。关于惊群现象,下一章会讲到,大家先别急哈。创建好子进程后,主进程不需再做什么事了,核心逻辑都会在子线程中执行。首先,每个子进程都会调用 epoll_create 在内核创建 epoll 实例,然后再通过 epoll_ctl 将 listen_fd 注册到 epoll 实例中,由内核进行监控。最后,再调用 epoll_wait 等待感兴趣的事件发生。当 listen_fd 中有新的连接时,epoll_wait 会返回。此时子进程调用 accept 接受连接,并把客户端 socket 注册到 epoll 实例中,等待 EPOLLIN 事件发生。当该事件发生后,即可接受数据,并根据 HTTP 请求信息返回相应的页面了。

这里说明一下,上面代码中处理 HTTP 请求的逻辑是写在 TinyHttpd 项目中的,TinyHttpd 是一个只有 500 行左右的超轻量型Http Server,很适合学习使用。为了适应需求,我对其源码进行了一定的修改,并添加了一些注释。本章的测试代码已经放到了 github 上,需要的同学自取,传送门 -> epoll_multiprocess_server.c

3. 惊群及演示

“惊群现象”是指并发环境下,多线程或多进程等待同一个 socket 事件,当这个事件发生时,多线程/多进程被同时唤醒,这就是“惊群现象”。对应上面的代码,多个子进程通过调用 epoll_wait 等待 listen_fd 上某个事件发生。当有新连接进来时,多个进程会被同时唤醒去处理这个事件。但最终只有一个进程可以去处理事件,其他进程重新进入等待状态。使用上面的代码可以演示惊群现象,如下:

从上图可以看出,当 listen_fd 上有新连接事件发生时,进程19571和19573被唤醒。但最终进程19573成功处理了新连接事件,进程19571则失败了。

惊群现象会影响服务器性能,因为多个进程被唤醒,但最终只有一个进程可以成功处理事件。而 CPU 需要为一个事件的发生调度数个进程,因此会浪费 CPU 资源。

对于惊群现象,处理的思路一般有两种。一种是像 Lighttpd 那样,无视惊群。另一种是像 Nginx 那样,使用全局锁避免惊群。简单起见,本文测试代码采用的是 Lighttpd 的处理方式,即无视惊群。对于这两种思路的细节,由于本人未读过两个开源软件的代码,这里就不多说了。如果大家有兴趣,可以参考网上的一些博文。

4. 总结

epoll 是 I/O 复用模型重要的一个实现,性能优异,应用广泛。像 Linux 平台下的 JVM,NIO 部分就是基于 epoll 实现的。再如大名鼎鼎 Nginx 也是使用了 epoll。由此可以看出 epoll 的重要性,因此我们有很有必要去了解 epoll。本文通过一个测试程序简单演示了一个基于 epoll 的 HTTP Server,总体上也达到了学习 epoll 的目的。大家如果有兴趣,可以下载源码看看。当然,纸上学来终觉浅,还是要自己动手写才行。本文的测试代码是本人现学现卖写的,仅测试使用,写的不好的地方望谅解。

好了,本文到此结束,谢谢阅读!

参考