《Linux C编程实战》笔记:epoll多路复用

epoll 是 Linux 下提供的一种高效的 I/O 事件通知机制,全称是 event poll,用于处理大量文件描述符的可读写事件,是 selectpoll现代替代品

它由以下三个系统调用构成:

  • epoll_create():创建一个 epoll 实例;

  • epoll_ctl():注册、修改、删除感兴趣的文件描述符;

  • epoll_wait():等待发生事件的文件描述符。

epoll 支持 “水平触发(LT)”“边缘触发(ET)” 两种模式,更灵活高效。

epoll 相对于 select 的优势

特性selectepoll
支持文件描述符数量受限(默认 1024,上限受 FD_SETSIZE 限制)理论支持上万(只受系统最大 fd 限制)
事件检测机制每次调用都要遍历所有 fd,开销大只关心真正发生事件的 fd
内核与用户空间通信方式每次调用都复制整个 fd 集合使用内核事件就绪列表,只拷贝活跃 fd
是否边缘触发支持不支持支持
是否支持回调通知机制不支持支持(通过 EPOLLONESHOT, EPOLLET 等实现更强逻辑)
性能随 fd 数量线性下降与 fd 数量无关,表现更优(特别是高并发)

 那到底哪些因素是提高性能的更大障碍?是调用select函数后常见的针对所有文件描述符对象的循环语句?还是每次需要传递的监视对象信息?

只看代码的话很容易认为是循环。但相比于循环语句,更大的障碍是每次传递监视对象信息。因为传递监视对象信息具有如下含义∶

"每次调用select函数时向操作系统传递监视对象信息。"

应用程序向操作系统传递数据将对程序造成很大负担,而且无法通过优化代码解决,因此将成为性能上的致命弱点。

"那为何需要把监视对象信息传递给操作系统呢?"

有些函数不需要操作系统的帮助就能完成功能,而有些则必须借助于操作系统。假设各位定义了四则运算相关函数,此时无需操作系统的帮助。但select函数与文件描述符有关,更准确地说,是监视套接字变化的函数。而套接字是由操作系统管理的,所以select函数绝对需要借助于操作系统才能完成功能。

select函数的这一缺点可以通过这种方式弥补∶

"向操作系统传递1次监视对象,监视范围或内容发生变化只通知发生变化的事项。"

这样就无需每次调用select函数时都向操作系统传递监视对象信息,但前提是操作系统支持这种处理方式(每种操作系统支持的程度和方式存在差异)。Linux的支持方式是epoll,Windows 的支持方式是IOCP。

epoll_create / epoll_create1

#include<sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags); // 推荐使用这个

参数:

  • size(已废弃,仅用于兼容,epoll实例的大小)

  • flags:可设置为 0EPOLL_CLOEXEC(fork 后自动关闭)

返回值:

  • 成功:返回一个新的 epoll 文件描述符(epfd)

  • 失败:返回 -1,并设置 errno

功能:

创建一个 epoll 实例,相当于创建了一个“事件监听器”。后续所有的监听 fd 都要注册到它上面。

你可以把它理解成一个 内核中的“事件集合”管理器的句柄。它本质上是:

一个由内核维护的,保存你关注的 I/O 事件(比如哪些 socket 需要读取)的数据结构的“入口”。

epoll_ctl

#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

 参数:

  • epfd:通过 epoll_create 创建的 epoll 文件描述符

  • op:操作类型,取值如下:

    • EPOLL_CTL_ADD:注册新的 fd 到 epoll

    • EPOLL_CTL_MOD:修改已注册 fd 的监听事件

    • EPOLL_CTL_DEL:从 epoll 中删除一个 fd

  • fd:要操作的目标文件描述符

  • event:监听的事件结构体(如果是 DEL 操作,可以为 NULL)

struct epoll_event 结构体:

struct epoll_event {
    uint32_t events; // 要监听的事件类型
    epoll_data_t data; // 用户数据(比如可以放 fd)
};

typedef union epoll_data {
    void *ptr;
    int fd;//这里设置套接字
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

常用 events 类型:

事件含义应用场景
EPOLLIN表示对应的文件描述符可读(包括普通读、对端关闭、优先数据等)监听 socket 是否有新数据到达
EPOLLOUT表示对应的文件描述符可写检测发送缓冲区是否可写,例如延迟发送的场景
EPOLLPRI有紧急数据可读(带外数据)用于如串口、OOB(Out-Of-Band)通信
EPOLLRDHUP连接被对端关闭,或者半关闭(FIN)非常适合在边缘触发下判断连接是否断开
EPOLLERR发生错误(一般与 EPOLLIN/EPOLLOUT 一起返回)出错时用于辅助诊断,如 socket 出错
EPOLLHUP对端挂断常用于检测 socket 的异常断开
EPOLLET边缘触发(Edge Triggered)高性能场景下使用,必须配合非阻塞 I/O
EPOLLONESHOT只触发一次事件防止多线程同时处理同一个 socket,需要手动 epoll_ctl(..., EPOLL_CTL_MOD) 重设
EPOLLEXCLUSIVE(Linux 4.5+)多个 epoll 实例监听同一个 fd 时,只唤醒一个高性能多线程服务器优化用
EPOLLWAKEUP(Linux 3.5+)防止系统 suspend(需权限)保证 epoll 事件可以唤醒系统
EPOLLMSG(保留)目前没实际用处为 future 保留,使用无效

特别说明:

  • EPOLLINEPOLLOUT 是最基础的 I/O 事件。

  • EPOLLRDHUP 是一个 很实用 的扩展,在 边缘触发模式下判断连接关闭非常重要

  • EPOLLONESHOT 常用于多线程网络服务中,防止重复处理

  • EPOLLET 可减少系统调用次数,提高性能,但需要小心使用,必须完全读取或写入,否则可能收不到下一次事件。

  • EPOLLERREPOLLHUP 总是会触发,即使你没有监听它们,但需要你自己读取后处理

 返回值:

  • 成功:返回 0

  • 失败:返回 -1,并设置 errno

 功能:

管理 epoll 实例中要监听的文件描述符。可用于添加、修改或删除监听对象。

epoll_wait

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event*events,int maxevents,int timeout);

 参数:

  • epfd:epoll 文件描述符

  • events:一个数组,用于接收就绪事件(由内核写入)

  • maxeventsevents 数组的大小(>0)

  • timeout:超时时间(毫秒)

    • -1:无限等待

    • 0:立即返回(非阻塞)

    • !=0:等待指定毫秒时间

返回值:

  • 成功:返回就绪事件的数量(即 events[0...n-1] 是就绪事件)

  • 失败:返回 -1,并设置 errno

 功能:

等待监听的 fd 中有事件发生,相当于在“监听所有注册过的事件”,并将结果写入传入的 events 数组中。

epoll 返回的事件可能同时包括读和写,需要自己通过位运算检测。

int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; ++i) {
    uint32_t ev = events[i].events;
    if (ev & EPOLLIN)  { printf("fd %d 可读\n", events[i].data.fd); }
    if (ev & EPOLLOUT) { printf("fd %d 可写\n", events[i].data.fd); }
}

示例程序

服务器端:

#include <cstdio>
#include<iostream>
#include<cstring>
#include<unistd.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE 1024
#define EPOLL_SIZE 50

void error_handling(int res, const char* message) {//错误处理函数
	if (res == -1) {
		std::cerr << message << std::endl;
		exit(EXIT_FAILURE);
	}
}
int main(int argc, char* argv[]) {
	if (argc != 2) {
		printf("Usage : %s <port>\n", argv[0]);
		exit(EXIT_FAILURE);
	}
	int serv_sock, clnt_sock;
	sockaddr_in serv_adr, clnt_adr;
	socklen_t clnt_adr_sz;
	int str_len;
	char  buf[BUF_SIZE];
	epoll_event* ep_events;
	epoll_event event;
	int epfd, event_cnt;
	serv_sock = socket(AF_INET, SOCK_STREAM, 0);
	serv_adr.sin_family = AF_INET;
	serv_adr.sin_addr.s_addr = INADDR_ANY;
	serv_adr.sin_port = htons(atoi(argv[1]));
	error_handling(bind(serv_sock,reinterpret_cast<sockaddr*>(&serv_adr),sizeof(serv_adr)),"bind");
	error_handling(listen(serv_sock,5),"listen");
	epfd = epoll_create1(0);
	error_handling(epfd, "epoll create");
	ep_events = new epoll_event[EPOLL_SIZE];
	event.events = EPOLLIN;//默认是设置条件触发的方式
	event.data.fd = serv_sock;
	error_handling(epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event),"epoll control");
	while (1) {
		event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE,-1);
		error_handling(event_cnt, "epoll wait");
		for (int i = 0; i < event_cnt; i++) {
			if (ep_events[i].data.fd == serv_sock) {//处理服务器套接字
				clnt_adr_sz = sizeof(clnt_adr);
				clnt_sock = accept(serv_sock, reinterpret_cast<sockaddr*>(&clnt_adr), &clnt_adr_sz);
				error_handling(clnt_sock, "accept");
				event.events = EPOLLIN;
				event.data.fd = clnt_sock;
				error_handling(epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event),"epoll control");
				printf("connected client: %d \n", clnt_sock);
			}
			else {//处理客户端
				memset(buf, 0, BUF_SIZE);
				str_len = recv(ep_events[i].data.fd, buf, BUF_SIZE,0);
				if (str_len == 0)    // close request!
				{
					epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
					close(ep_events[i].data.fd);
					printf("closed client: %d \n", ep_events[i].data.fd);
				}
				else
				{
					send(ep_events[i].data.fd, buf, str_len,0);    // echo!
				}

			}
		}
	}
	close(serv_sock);
	close(epfd);
	delete[] ep_events;
	return 0;

}

客户端:

#include <cstdio>
#include<cstdlib>
#include<iostream>
#include<cstring>
#include<unistd.h>
#include<sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define BUF_SIZE 1024
void error_handling(int res, const char* message) {
	if (res == -1) {
		std::cerr << message << std::endl;
		exit(EXIT_FAILURE);
	}
}
int main(int argc, char* argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	sockaddr_in serv_adr;
	if (argc != 3) {//指定服务器ip和端口
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(EXIT_FAILURE);
	}
	sock = socket(PF_INET, SOCK_STREAM, 0);
	error_handling(sock, "socket");
	serv_adr.sin_family = AF_INET;
	inet_pton(AF_INET, argv[1], &serv_adr.sin_addr);//ip
	serv_adr.sin_port = htons(atoi(argv[2]));//端口
	error_handling(connect(sock, reinterpret_cast<sockaddr*>(&serv_adr), sizeof(serv_adr)), "connect");
	std::cout << "Connected......" << std::endl;
	while (1) {
		memset(message, 0, BUF_SIZE);
		std::cout << "Input message(Q to quit):" << std::endl;
		std::cin >> message;
		if (strcmp(message, "Q") == 0) break;//退出
		send(sock, message, strlen(message) + 1, 0);//发送
		memset(message, 0, BUF_SIZE);
		str_len = recv(sock, message, BUF_SIZE, 0);
		std::cout << "Message from server:" << message << std::endl;
	}
	close(sock);

}

epoll 和 select 在代码结构上非常相似 —— 都是“注册关注的事件”,然后 “等待事件发生”,接着 “遍历并处理事件”。

epoll 相比 select 更强大的地方,并不体现在代码结构,而是在 内核实现机制和性能扩展性上

简单例子说明

假设你要监听 10,000 个客户端 socket:

select

  • 每次 select() 都要把 10,000 个 fd 全部拷贝到内核,再线性扫描一次。

  • 即使只有 1 个 fd 就绪,系统也必须检查全部。

  • CPU 时间白白浪费。

epoll

  • 你只需通过 epoll_ctl 注册 10,000 个 fd 一次。

  • 之后 epoll_wait 只返回真正 “发生事件” 的那几个。

  • 这使得在高并发场景下效率非常高。

水平触发和边缘触发

水平触发/条件触发(LT)

  • 默认行为,类似 select/poll

  • 只要缓冲区中还有数据/空间,就会不断通知你

  • 你可以“慢慢处理”数据,不用一次读完写完

举例:

  • 你注册了一个 EPOLLIN 的 socket。

  • socket 收到数据 -> 内核通知你。

  • 你读了一部分,缓冲区还有数据。

  • 下次 epoll_wait()它会继续通知你这个 socket 还可读,直到你把它读空为止。

特点:

  • 稳妥、简单、易用

  • 不容易漏掉事件。

  • 性能一般,因为每次都可能通知你旧的事件。

边缘触发(ET)

  • 使用 EPOLLET 标志启用。

  • 只在状态发生“变化”的“边缘”时通知一次

  • 必须一次性处理干净缓冲区的数据(读光/写满)

举例:

  • socket 收到数据 -> 通知你一次。

  • 如果你没一次性读完,下次不会再提醒你,除非新数据再次到来。

特点:

  • 更高性能,适合高并发服务器

  • 但必须用 非阻塞IO + 循环读/写,一次性处理干净,不然容易遗漏数据(陷入“死等”)。

  • 易错但高效

如果你追求极致性能(如 10w+ 并发连接),可以尝试 ET 模式 + 非阻塞IO,同时确保:

  • 每次 recv() 时循环读取直到返回 EAGAIN

  • 每次 send() 尝试写满直到 EAGAIN

示例程序

我们原本的示例程序是默认水平触发模式。以下是边缘触发模式的服务器代码:

#include <cstdio>
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <cerrno>

#define BUF_SIZE 1024
#define EPOLL_SIZE 50

void error_handling(int res, const char* message) {
    if (res == -1) {
        perror(message);
        exit(EXIT_FAILURE);
    }
}

void set_non_blocking(int fd) {//将套接字设置为非阻塞模式
    int flags = fcntl(fd, F_GETFL, 0);//系统调用的文章有讲过该函数
    error_handling(flags, "fcntl F_GETFL");
    error_handling(fcntl(fd, F_SETFL, flags | O_NONBLOCK), "fcntl F_SETFL");
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        printf("Usage: %s <port>\n", argv[0]);
        return -1;
    }

    int serv_sock, clnt_sock;
    sockaddr_in serv_adr{}, clnt_adr{};
    socklen_t clnt_adr_sz;
    char buf[BUF_SIZE];
    epoll_event* ep_events;
    epoll_event event;
    int epfd, event_cnt;

    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    error_handling(serv_sock, "socket");

    set_non_blocking(serv_sock); // 设置非阻塞

    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = INADDR_ANY;
    serv_adr.sin_port = htons(atoi(argv[1]));

    error_handling(bind(serv_sock, reinterpret_cast<sockaddr*>(&serv_adr), sizeof(serv_adr)), "bind");
    error_handling(listen(serv_sock, 5), "listen");

    epfd = epoll_create1(0);
    error_handling(epfd, "epoll_create1");

    ep_events = new epoll_event[EPOLL_SIZE];

    event.events = EPOLLIN | EPOLLET;
    event.data.fd = serv_sock;
    error_handling(epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event), "epoll_ctl add server");

    while (true) {
        event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        error_handling(event_cnt, "epoll_wait");

        for (int i = 0; i < event_cnt; i++) {
            int fd = ep_events[i].data.fd;

            if (fd == serv_sock) {
                // 接收所有连接
                while (true) {
                    clnt_adr_sz = sizeof(clnt_adr);
                    clnt_sock = accept(serv_sock, reinterpret_cast<sockaddr*>(&clnt_adr), &clnt_adr_sz);//服务端套接字的accept也是非阻塞模式了
                    if (clnt_sock == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        else {
                            perror("accept");
                            break;
                        }
                    }

                    set_non_blocking(clnt_sock);//将与客户端通信的套接字设为非阻塞,这样recv和send也是非阻塞的
                    event.events = EPOLLIN | EPOLLET;
                    event.data.fd = clnt_sock;
                    error_handling(epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event), "epoll_ctl add client");

                    printf("Connected client: %d\n", clnt_sock);
                }
            } else {
                // 循环读取直到没有数据
                while (true) {
                    int str_len = recv(fd, buf, BUF_SIZE, 0);
                    if (str_len == 0) {
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                        close(fd);
                        printf("Closed client: %d\n", fd);
                        break;
                    } else if (str_len < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        else {
                            perror("recv");
                            close(fd);
                            break;
                        }
                    } else {
                        send(fd, buf, str_len, 0); // echo
                    }
                }
            }
        }
    }

    close(serv_sock);
    close(epfd);
    delete[] ep_events;
    return 0;
}

套接字设置为非阻塞的效果是什么?

阻塞 vs 非阻塞:

  • 阻塞模式(默认)
    如果你调用了 recv()accept() 等函数,但此时没有数据或连接,这个函数会卡在那里等,直到有结果。

  • 非阻塞模式(O_NONBLOCK)
    函数立即返回,如果此时没有数据或连接,就返回 -1,并设置 errnoEAGAINEWOULDBLOCK

在 Linux 下,一个套接字被设置为非阻塞(通过 fcntl(fd, F_SETFL, O_NONBLOCK)),所有对这个 fd 的系统调用都会变为非阻塞,包括:

  • accept()

  • recv()

  • send()

  • connect()(客户端)

errno 的 EAGAIN 和 EWOULDBLOCK 是什么意思?

这两个值其实本质上是 一样的,只是出现在不同的历史环境中。

错误码意义
EAGAIN暂时没有可用数据,现在不能进行操作
EWOULDBLOCK非阻塞操作暂时不能完成

在 Linux 上:

#define EAGAIN 11
#define EWOULDBLOCK EAGAIN

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值