基于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 的运行效果了,看起来还行。在我第一次把它成功跑起来的时候,感觉很奇妙。好了,看完效果,接下来看代码吧,如下:

#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 的目的。大家如果有兴趣,可以下载源码看看。当然,纸上学来终觉浅,还是要自己动手写才行。本文的测试代码是本人现学现卖写的,仅测试使用,写的不好的地方望谅解。

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

参考

本文在知识共享许可协议 4.0 下发布,转载需在明显位置处注明出处 作者:coolblog 本文同步发布在我的个人博客:http://www.coolblog.xyz

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏编程坑太多

『高级篇』docker之了解kubernetes(31)

PS:(梳理概念)pod里面包括N个容器,service里面包括pod,Deployment可能包括service或者是pod。

17140
来自专栏高性能服务器开发

(八)高性能服务器架构设计总结1——以flamigo服务器代码为例

这篇文章算是对这个系列的一个系统性地总结。我们将介绍服务器的开发,并从多个方面探究如何开发一款高性能高并发的服务器程序。

19620
来自专栏云计算教程系列

如何使用Git版本控制系统

Git是一个分布式版本控制软件,最初由林纳斯·托瓦兹创作,于2005年以GPL发布。最初目的是为更好地管理Linux内核开发而设计。git最初只是作为一个可以被...

10320
来自专栏喵了个咪的博客空间

[Golang软件推荐] Frp内网穿透

在一个IP紧缺的时代,连电信也不分配固定IP给到你用,一条专网专用线路贵的不行,那么作为软件开发人员常常要使用到外网,比如和微信调试程序,给到不在同一网段的朋友...

1.3K40
来自专栏高性能服务器开发

(八)高性能服务器架构设计总结1——以flamigo服务器代码为例

这篇文章算是对这个系列的一个系统性地总结。我们将介绍服务器的开发,并从多个方面探究如何开发一款高性能高并发的服务器程序。 所谓高性能就是服务器能流畅地处理各个客...

48160
来自专栏腾讯Bugly的专栏

Android 进程保活招式大全

目前市面上的应用,貌似除了微信和手Q都会比较担心被用户或者系统(厂商)杀死问题。本文对 Android 进程拉活进行一个总结。 Android 进程拉活包括两个...

80670
来自专栏Java成神之路

Java程序员常用工具集

我发现很多人没办法高效地解决问题的关键原因是不熟悉工具,不熟悉工具也还罢了,甚至还不知道怎么去找工具,这个问题就大条了。我想列下我能想到的一个Java程序员会用...

18530
来自专栏企鹅号快讯

入门干货之用DVG打造你的项目主页-Docfx、Vs、Github

由于这三项技术涉及到的要点以及内容较多,希望大家有空能自己挖掘一下更多更深的用法。 0x01、介绍 VS,即VS2017以及以上版本,宇宙最好的IDE,集成了宇...

23660
来自专栏沃趣科技

ASM 翻译系列第十一弹:高级知识 Offline or drop?

原作者:Bane Radulovic 译者: 庄培培 审核: 魏兴华 DBGeeK社群联合出品 Offline or drop? 当一个ASM磁盘不...

39740
来自专栏炉边夜话

对中断的一点思考

    对于X86的单处理器机器,一般采用可编程中断控制器8259A做为中断控制电路。传统的PIC(Programmable Interrupt Contr...

14720

扫码关注云+社区

领取腾讯云代金券