Epoll:高性能网络编程的必备利器

1,108 阅读22分钟

一.epoll概念

epoll 是一个在 Linux 操作系统上不同 I/O 模型选择中常用的事件通知机制。它是一种高效的、可扩展的 I/O 事件通知机制,用于监视文件描述符上的事件并进行异步事件驱动的 I/O 操作。

以下是 epoll 的一些关键概念:

  1. 事件驱动: epoll 通过监听文件描述符上的事件来实现事件驱动的异步 I/O 操作。可以监听多个文件描述符,并根据文件描述符上发生的事件进行相应的处理。

  2. 高性能和可扩展性: epoll 使用了基于事件的设计,通过提供高效的内核级别事件通知机制,可以在大规模并发的网络应用中处理成千上万个连接而不受性能影响。

  3. 文件描述符: 在 epoll 中,需要注册要监视的文件描述符。文件描述符可以是套接字、管道、文件等,用于标识打开的文件或 I/O 通道。

  4. 事件类型: epoll 中存在几种不同类型的事件,例如可读(EPOLLIN)、可写(EPOLLOUT)、错误(EPOLLERR)等。每个事件都与一个文件描述符相关联,指示该文件描述符上发生了相应的事件。

  5. 就绪列表: 通过调用 epoll_wait 函数来获取已经就绪的事件列表。这个函数会阻塞,直到有事件到达或超时。一旦事件到达,该函数返回就绪的文件描述符及相应的事件类型,供应用程序进一步处理。

epoll 在与非阻塞 I/O 结合使用时,可以实现高效的事件驱动网络编程。它相对于传统的 select 和 poll 等事件通知机制来说,在大规模并发应用中具有更好的性能和可扩展性。

二.epoll的特点

epoll 具有以下几个主要特点:

  1. 高效的事件通知机制:epoll 使用了基于事件的设计,通过集中式的内核事件表来管理文件描述符上的事件,避免了遍历整个文件描述符集合的开销。这样可以提高事件通知的效率,尤其在大规模并发的网络应用中表现出色。

  2. 大规模并发支持:epoll 支持大规模并发连接,可以同时监听成千上万的文件描述符,而不受性能影响。这使得它特别适用于高性能、高并发的服务器应用,如Web服务器、消息队列等。

  3. 边沿触发和水平触发:epoll 提供了两种监听模式:边沿触发(Edge-Triggered)和水平触发(Level-Triggered)。边沿触发模式会在事件状态发生变化时通知应用程序,而水平触发模式会在文件描述符处于就绪状态时持续通知应用程序。开发者可以根据实际需求选择适当的触发模式。

  4. 支持多种文件描述符类型:epoll 不仅可以用于网络套接字(socket),还可以用于管道(pipe)、定时器(timerfd)和设备文件等其他类型的文件描述符。

  5. 内核级编程接口:epoll 提供了一套内核级的编程接口,可以在用户空间直接使用。开发者可以通过 epoll_createepoll_ctlepoll_wait 等函数来创建 epoll 实例,注册和取消事件,并等待就绪事件。

总的来说,epoll 是一种高效且可扩展的事件通知机制,适用于高性能的网络编程。它的优势在于可同时监听大量的文件描述符,而不会有性能瓶颈,并能根据事件状态变化或可读/可写状态持续通知应用程序。这使得开发者可以使用异步的事件驱动方式来处理并发连接。

三.epoll机制与原理

epoll机制的实现主要依赖于红黑树和就绪链表两个核心数据结构。

  1. 红黑树:在Linux内核中,epoll中的每个监视的文件描述符都会维护一个对应的红黑树节点。这些红黑树节点按照文件描述符的大小有序排列,方便进行快速的查找和插入操作。红黑树的特点是平衡性和快速的查找、插入和删除操作。

  2. 就绪链表:为了提高效率,epoll维护了一个就绪链表,用于记录所有已经就绪的文件描述符。当一个文件描述符上的事件被触发时,内核会将该文件描述符添加到就绪链表中。

epoll的工作流程如下:

  1. 应用程序使用epoll_create函数创建一个epoll文件描述符,用于管理需要监视的文件描述符集合。

  2. 使用epoll_ctl函数将需要监视的文件描述符添加到epoll文件描述符中,指定关注的事件类型(如可读、可写等)。

  3. 调用epoll_wait函数阻塞当前线程,并等待注册的事件发生。当有一个或多个文件描述符上的事件就绪时,内核会将就绪的文件描述符添加到就绪链表中。

  4. 应用程序从就绪链表中获取就绪的文件描述符,并对其进行处理,如读取或写入数据。

通过使用红黑树和就绪链表,epoll能够高效地进行文件描述符的管理和事件的触发。红黑树提供了快速的查找和插入操作,保证了监视的文件描述符集合的高效访问;而就绪链表提供了快速的事件通知,避免了遍历整个文件描述符集合的开销。这种机制使得epoll在高并发场景下具有很好的性能。

2023-09-21T16:09:28.png

四.epoll的函数接口

1.epoll_create

epoll_create函数是Linux系统中用于创建epoll实例的函数。epoll是一种高效的I/O事件通知机制,用于监听大量文件描述符上的事件。

函数原型如下:

int epoll_create(int size);

参数size是epoll实例的大小,指定了可以同时监听的最大文件描述符数量。然而,自从Linux 2.6.8内核版本开始,这个参数已经不再生效,可以传入任意值。

函数返回一个非负整数,表示创建的epoll实例的文件描述符。如果返回值为负数,则表示创建失败,通过查看errno变量获取具体的错误信息。

使用epoll实例进行事件监听的主要步骤如下:

  1. 创建epoll实例:调用epoll_create函数创建一个epoll实例,获取其文件描述符。
  2. 添加事件:使用epoll_ctl函数将需要监听的文件描述符添加到epoll实例中,并指定需要监听的事件类型。
  3. 等待事件发生:调用epoll_wait函数等待事件的发生,该函数会阻塞直到有事件发生或超时。
  4. 处理事件:当epoll_wait函数返回时,表示有事件发生,可以通过遍历返回的事件队列来获取具体的事件信息。
  5. 重复步骤3和4,实现对多个文件描述符的事件监听。

使用epoll机制可以实现高效的事件驱动编程,并且可以同时监听大量的文件描述符,避免了传统的轮询方式的性能问题。相比其他I/O事件通知机制,如select和poll,epoll在大规模并发环境下表现更出色。通过合理运用epoll的各种接口,可以编写高性能的网络服务器、多线程应用程序等。

2.epoll_ctl

epoll_ctl函数是在epoll实例中添加、修改或删除文件描述符的监听事件的函数。

函数原型如下:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数说明:

  • epfd:epoll实例的文件描述符。
  • op:操作类型,可以取以下三个值之一:
    • EPOLL_CTL_ADD:将文件描述符fd添加到epoll实例中进行监听。
    • EPOLL_CTL_MOD:修改已经在epoll实例中监听的文件描述符fd的监听事件。
    • EPOLL_CTL_DEL:将文件描述符fd从epoll实例中移除,停止监听该文件描述符上的事件。
  • fd:需要被添加、修改或删除的文件描述符。
  • event:指向epoll_event结构的指针,该结构用于指定需要监听的事件类型。

struct epoll_event结构定义如下:

typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event {
    uint32_t events;      // 事件类型
    epoll_data_t data;    // 用户数据
};

events字段是需要监听的事件类型,可以通过EPOLLINEPOLLOUTEPOLLERR等常量进行设置。常用的事件类型有:

  • EPOLLIN:表示可读事件。
  • EPOLLOUT:表示可写事件。
  • EPOLLERR:表示错误事件。
  • EPOLLHUP:表示挂起事件。 详细的事件类型可以通过man epoll_ctl命令在终端上查看。

data字段用于存储与文件描述符相关的用户数据,可以是一个指针、整数或64位无符号整数。

epoll_ctl函数用于管理epoll实例中的文件描述符的监听事件,根据不同的操作类型(op),它可以添加、修改或删除文件描述符的监听事件配置。在添加或修改操作时,需要通过epoll_event结构来指定需要监听的事件类型。通过调用epoll_ctl可以实现动态地添加、修改和删除文件描述符的事件监听,从而灵活地对不同的I/O事件进行响应。

3.epoll_wait

epoll_wait函数是在epoll实例上等待事件发生的函数。它会阻塞当前线程,直到有事件就绪或者超时。

函数原型如下:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数说明:

  • epfd:epoll实例的文件描述符。
  • events:指向epoll_event结构数组的指针,用于接收发生的事件信息。
  • maxevents:指定最大返回事件数量,即events数组的大小。
  • timeout:等待超时时间,单位为毫秒。传入-1表示无限等待,传入0表示立即返回,传入正整数表示等待超时时间。

函数返回一个非负整数,表示就绪事件的数量。如果返回值为0,表示超时;如果返回值为负数,表示出现错误,可以通过查看errno变量获取具体的错误信息。

在调用epoll_wait函数之前,需要先调用epoll_ctl函数将需要监听的文件描述符添加到epoll实例中。

epoll_wait函数返回时,即表示有事件就绪。用户可以使用循环遍历返回的events数组,获取具体的事件信息。每个事件结构包含以下字段:

  • events:表示发生的事件类型,可以通过与EPOLLINEPOLLOUT等事件类型进行按位与操作来判断具体事件。
  • data:是一个epoll_data联合体类型,存储了与事件相关的数据。

通过调用epoll_wait函数,可以实现高效地等待和处理多个事件,避免了传统的轮询方式的性能问题。使用epoll机制可以构建高性能的事件驱动程序,例如网络服务器、实时通信应用等。

五.编程实战

利用epoll实现一个高并发服务器,当服务连接成功后,客户端发送小写字母的字符串,服务器端发送其大写形式。

server.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
#include <signal.h>
#include <sys/select.h>
#include <poll.h>
#include <sys/epoll.h>

int main(int argc, char const *argv[])
{

    //1.创建套接字,返回建立链接的文件描述符
    int sockfp = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfp == -1)
    {
        perror("socket is err");
        exit(0);
    }
    printf("%d\n", sockfp);

    //2.绑定ip和端口号
    struct sockaddr_in saddr, caddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(atoi(argv[1]));
    saddr.sin_addr.s_addr = inet_addr("0.0.0.0");
    socklen_t len = sizeof(struct sockaddr_in);

    if (bind(sockfp, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    {
        perror("bind is err");
        exit(0);
    }

    //3.listen监听
    if (listen(sockfp, 5))
    {
        perror("liste err");
        exit(0);
    }

    //4.创建红黑树和就绪链表
    struct epoll_event event;
    struct epoll_event events[1024];
    int epfd = epoll_create(10);
    if (epfd < 0)
    {
        perror("epoll is err");
        exit(0);
    }

    //5.将关心的文件描述符上树
    event.events = EPOLLIN; //监听读事件
    event.data.fd = sockfp; //关心的文件描述符
    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfp, &event);

    //6.进行相应的逻辑操作
    int size = 0;
    char buf[1024] = {0};
    while (1)
    {
        int ret = epoll_wait(epfd, events, 1024, -1);
        if (ret < 0)
        {
            perror("epoll_wait is err");
            exit(-1);
        }
        for (int i = 0; i < ret; ++i)
        {
            if (events[i].data.fd == sockfp)
            {
                int accepted = accept(sockfp, (struct sockaddr *)(&caddr), &len);
                if (accepted < 0)
                {
                    perror("accept is err");
                    exit(0);
                }
                printf("port:%d   ip:  %s\n", ntohs(caddr.sin_port), inet_ntoa(caddr.sin_addr));
                event.events = EPOLLIN;   //监听读事件
                event.data.fd = accepted; //关心的文件描述符
                epoll_ctl(epfd, EPOLL_CTL_ADD, accepted, &event);
            }
            else
            {
                int flage = recv(events[i].data.fd, buf, sizeof(buf), 0);
                if (flage < 0)
                {
                    perror("recv is err");
                }
                else if (flage == 0)
                {
                    printf("ip:%s is close\n", inet_ntoa(caddr.sin_addr));
                    close(events[i].data.fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
                }
                else
                {
                    size = strlen(buf);

                    for (int i = 0; i < size; ++i)
                    {
                        if (buf[i] >= 'a' && buf[i] <= 'z')
                            buf[i] = buf[i] + ('A' - 'a');
                        else
                            buf[i] = buf[i] + ('a' - 'A');
                    }
                    printf("%s\n", buf);
                    send(events[i].data.fd, buf, sizeof(buf), 0);
                }
            }
        }
    }

    return 0;
}

client.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char const *argv[])
{
    //1.socket建立文件描述符
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket is err");
    }

    //2.connect连接服务器
    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(atoi(argv[2]));
    saddr.sin_addr.s_addr = inet_addr(argv[1]);
    int flage = connect(fd, (struct sockaddr *)(&saddr), sizeof(saddr));
    if (flage < 0)
    {
        perror("connect is err");
    }

    //3.服务器端不断发送数据,接受服务器转化后的数据
    char buf[1024] = {0};
    while (1)
    {
        //memset(buf,0,sizeof(buf));
        fgets(buf, sizeof(buf), stdin);
        if (strncmp(buf,"quit#",5)==0)
        {
            break;
        }
        
        if (buf[strlen(buf) - 1] == '\n')
            buf[strlen(buf) - 1] = '\0';
        send(fd, buf, sizeof(buf), 0);
        flage = recv(fd, buf, sizeof(buf), 0);
        if (flage < 0)
        {
            perror("recv is err");
        }
        else
        {
            fprintf(stdout, "%s\n", buf);
        }
    }
    close(fd);
    return 0;
}

六.ET模式和LT模式

6.1概念

ET 模式和 LT 模式是在计算机编程中用来描述事件触发方式的两种模式,特别是在网络编程和操作系统中广泛应用。下面是对这两种模式的解释:

  1. ET 模式(边缘触发):

    • ET 模式是指当监视事件的文件描述符上发生状态变化时,只会通知一次,无论是否对事件进行处理。
    • 在 ET 模式下,如果有多个事件同时发生,而只读取其中一个事件,那么下次检查到事件时仍然会收到通知。
    • ET 模式需要使用非阻塞 I/O 操作,以确保在事件处理期间没有阻塞。
  2. LT 模式(水平触发):

    • LT 模式是指当监视事件的文件描述符上发生状态变化时,只要状态仍然处于活动状态,就会持续通知。
    • 在 LT 模式下,如果有多个事件同时发生,而只读取其中一个事件,那么下次检查到事件时将不会再次通知。
    • LT 模式可以使用阻塞和非阻塞 I/O 操作,但需要小心处理可能引起阻塞的情况。

总体来说,ET 模式对事件的处理要求更加高效,需要确保在处理事件期间不会发生阻塞,否则可能会错过部分事件。而 LT 模式则允许在处理事件期间进行阻塞操作,但需要处理好每次通知的事件,以避免重复处理已经处理过的事件。

ET模式:

  • 边沿触发: 缓冲区剩余未读尽的数据会导致epo11_wait返回。

LT模式:

  • 水平触发--默认采用模式。 缓冲区剩余未读尽的数据不会导致epo11_wait返回。

在实际编程中,具体选择使用 ET 模式还是 LT 模式,要根据应用的要求和场景来决定。

6.2设置方式

 event.events = EPOLLIN;           //LT模式
 event.events = EPOLLIN|EPOLLET;   //ET模式

6.3ET非阻塞模式

ET非阻塞模式是指事件触发(Event-Triggered)的非阻塞模式,通常在编程中用于异步操作和事件驱动的场景。在非阻塞模式下,程序可以继续执行其他任务而不必等待当前任务完成。

ET非阻塞模式的实现通常依赖于时间循环(Event Loop)。在事件驱动的架构中,程序会监听各种事件,并在事件触发时执行相应的回调函数或任务。这种模式可以提高程序的并发性能和响应能力。

常见的应用场景包括网络编程中的异步IO操作,例如使用非阻塞Socket进行网络通信、处理大量客户端连接;以及图形界面编程中的事件处理,例如用户点击按钮、鼠标移动等。

在ET非阻塞模式下,程序需要通过轮询或回调的方式监听事件,并及时响应。相比于阻塞模式,在非阻塞模式下程序可以同时处理多个任务,提高系统的资源利用率和吞吐量。

简单来说,就是把recv/send设置为非堵塞模式,在缓冲区一直读或者一直写,直到不能写和读为止,减少epoll_wait的调用,提高效率。

6.4设置文件描述符非堵塞

fcntl 是一个在 Unix-like 系统下使用的函数,用于对文件描述符进行控制操作。它的原型如下:

#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* argument */);

其中,fd 是要操作的文件描述符,cmd 是要执行的操作命令,... 是可选的额外参数,取决于具体的命令。

fcntl 函数主要用于执行以下几种操作:

  1. 文件描述符复制(F_DUPFD、F_DUPFD_CLOEXEC):使用 F_DUPFD 命令可以复制一个文件描述符,返回一个新的文件描述符,指向与原始文件描述符所指向的文件相同的位置和状态。而 F_DUPFD_CLOEXEC 命令除了复制文件描述符外,还将新的文件描述符设置为在 exec 调用时自动关闭。

  2. 获取和设置文件状态标志(F_GETFL、F_SETFL):使用 F_GETFL 命令可以获取与文件描述符相关联的状态标志,例如是否是非阻塞模式、是否是追加模式等。而 F_SETFL 命令可以用于设置文件描述符的状态标志。常用的状态标志包括 O_NONBLOCK(非阻塞模式)、O_APPEND(追加模式)等。

  3. 文件控制操作(F_GETFD、F_SETFD):使用 F_GETFD 命令可以获取与文件描述符相关的文件描述符标志,例如 FD_CLOEXEC(在执行 exec 调用时关闭文件描述符)等。而 F_SETFD 命令可以设置文件描述符的标志。

  4. 文件锁(F_SETLK、F_SETLKW、F_GETLK):使用 F_SETLKF_SETLKW 命令可以给文件加锁,以防止其他进程对文件的并发访问。文件锁可以是共享锁(读锁)或独占锁(写锁)。F_SETLK 是非阻塞方式加锁,如果锁已经被其他进程持有,则返回错误。而 F_SETLKW 则是阻塞方式加锁,如果锁已经被其他进程持有,则会等待直到获取到锁。使用 F_GETLK 命令可以获取文件的当前锁状态。

上述列举的仅是 fcntl 函数的一些常见操作,实际上 fcntl 可以支持更多的操作命令,具体取决于所在的操作系统和文件系统的特性。在使用 fcntl 函数时,可以通过查阅系统的相关文档或 man 页面来获取更详细的信息和支持的操作命令。 要将文件描述符设置为非阻塞模式,可以使用 fcntl 函数以及命令 F_SETFL 和标志 O_NONBLOCK。以下是一个示例代码片段:

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    int fd = open("example.txt", O_RDONLY); // 打开文件(示例)

    // 获取当前文件描述符的状态标志
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl error");
        return -1;
    }

    // 设置非阻塞模式
    flags |= O_NONBLOCK;
    int result = fcntl(fd, F_SETFL, flags);
    if (result == -1) {
        perror("fcntl error");
        return -1;
    }

    // 使用非阻塞模式进行读取操作
    char buffer[1024];
    ssize_t bytesRead = read(fd, buffer, sizeof(buffer));
    if (bytesRead == -1) {
        perror("read error");
        return -1;
    }

    // 关闭文件描述符
    close(fd);

    return 0;
}

在上述代码中,首先通过 open 函数打开了一个文件(示例中为 "example.txt")。然后使用 fcntl 函数和 F_GETFL 命令获取当前文件描述符的状态标志。接着,将 O_NONBLOCK 标志添加到状态标志中,以设置为非阻塞模式。最后,使用 fcntl 函数和 F_SETFL 命令将更新后的状态标志设置回文件描述符。

之后,你可以使用非阻塞模式进行读取、写入等操作,例如使用 read 函数读取数据。需要注意的是,在非阻塞模式下,读操作可能会立即返回,即使没有数据可用。因此,在读取时需要处理返回值为 -1(表示出错)和 0(表示暂时没有数据可读)的情况。

最后,使用 close 函数关闭文件描述符,释放相关资源。

请注意,以上是一个简单示例,具体的使用方式和处理方法可能根据实际需求有所不同。在实际应用中,你可能需要根据具体的错误码和返回值进行适当的错误处理和重试机制。

七.epoll反应堆模型

当使用epoll的反应堆模型时,我们可以结合ET模式、void*指针、自定义结构体、盲轮询和回调函数来构建高效的事件驱动系统。

  1. ET模式(边缘触发模式): 在ET模式下,只有当与文件描述符关联的状态发生变化时,才会触发一次事件通知。与LT模式(水平触发模式)相比,ET模式需要立即对事件做出响应,否则可能会丢失一些事件。这可以有效地减少事件通知的次数,提高系统效率。

  2. void指针和自定义结构体: 为了能够在处理事件时传递自定义的数据,我们可以使用void指针和自定义结构体。void指针可以存储任意类型的数据,而自定义结构体可以组织和管理更复杂的信息。当注册文件描述符的事件时,可以将自定义结构体作为void指针的值传递给epoll_ctl函数,以便在事件发生时获取相应的数据。

  3. 盲轮询: 在某些情况下,可能需要进行盲轮询,即在没有获取到任何事件时,等待一定的时间后再次调用epoll_wait函数。这样可以避免过于频繁地进行系统调用,以提高性能。通过设置合适的超时时间,可以控制盲轮询的频率。

  4. 回调函数: 在epoll的反应堆模型中,通常会使用回调函数来处理事件。当读写事件发生时,可以调用相应的回调函数来处理数据,进行逻辑操作,并做出相应的响应。回调函数可以根据自定义结构体中存储的信息来执行特定的操作,这样可以实现事件驱动的编程。

使用上述技术手段,可以搭建一个高效的epoll反应堆模型。通过ET模式,只触发关键的事件通知;利用void*指针和自定义结构体,传递相关的数据;使用盲轮询来节约系统调用的开销;同时利用回调函数来处理具体的事件操作。这些技术共同作用,可以构建出高性能、可扩展的事件驱动系统。

以下是一个简单的示例代码,演示了如何使用epoll的反应堆模型来实现一个基于TCP/IP的网络服务器:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define MAX_EVENTS 10
#define BUFFER_SIZE 1024

void handle_request(int client_sock) {
    char buffer[BUFFER_SIZE];
    memset(buffer, 0, BUFFER_SIZE);

    // 读取客户端发送的数据
    ssize_t recv_len = recv(client_sock, buffer, BUFFER_SIZE, 0);

    if (recv_len > 0) {
        printf("Received message from client: %s\n", buffer);
    } else if (recv_len == 0) {
        printf("Client disconnected\n");
        close(client_sock);
    } else {
        perror("Error in recv");
        close(client_sock);
    }
}

int main() {
    int server_sock, client_sock;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len;

    // 创建监听socket
    server_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (server_sock < 0) {
        perror("Error in socket");
        exit(1);
    }

    // 绑定地址和端口
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(8080);

    if (bind(server_sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("Error in bind");
        exit(1);
    }

    // 监听连接请求
    if (listen(server_sock, 5) < 0) {
        perror("Error in listen");
        exit(1);
    }

    // 创建epoll实例
    int epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("Error in epoll_create1");
        exit(1);
    }

    struct epoll_event event, events[MAX_EVENTS];
    event.events = EPOLLIN;
    event.data.fd = server_sock;

    // 将监听socket添加到epoll的事件集合中
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_sock, &event) == -1) {
        perror("Error in epoll_ctl");
        exit(1);
    }

    while (1) {
        // 等待事件发生
        int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (num_events == -1) {
            perror("Error in epoll_wait");
            exit(1);
        }

        for (int i = 0; i < num_events; i++) {
            if (events[i].data.fd == server_sock) {
                // 有新的连接请求
                client_sock = accept(server_sock, (struct sockaddr*)&client_addr, &client_addr_len);
                if (client_sock == -1) {
                    perror("Error in accept");
                    exit(1);
                }
                printf("Client connected\n");
                event.events = EPOLLIN;
                event.data.fd = client_sock;

                // 将客户端socket添加到epoll的事件集合中
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_sock, &event) == -1) {
                    perror("Error in epoll_ctl");
                    exit(1);
                }
            } else {
                // 读写事件发生
                handle_request(events[i].data.fd);
            }
        }
    }

    // 关闭监听socket
    close(server_sock);

    return 0;
}

上述代码首先创建了一个监听socket,并将其绑定到本地地址和端口上。然后,它进入一个无限循环,通过epoll_wait函数等待事件发生。当有新的连接请求到达时,会将客户端socket添加到epoll的事件集合中。当读写事件发生时,会调用handle_request函数来处理请求。在该函数中,我们使用recv函数读取客户端发送的数据,并输出到控制台上。

通过使用epoll的反应堆模型,服务器能够高效地处理多个客户端连接,并异步地处理事件,提高系统的性能和响应能力。