I/O多路转接(复用)之epoll.md
IO多路转接(复用)之epoll
1.概述
epoll 全称 eventpoll,是 linux 内核实现IO多路转接/复用(IO multiplexing)的一个实现。IO多路转接的意思是在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作。epoll是select和poll的升级版,相较于这两个前辈,epoll改进了工作方式,因此它更加高效。
-
对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的。
-
select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降
-
select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是共享内存(基于mmap内存映射区实现),省去了不必要的内存拷贝。
-
程序猿需要对select和poll返回的集合进行判断才能知道哪些文件描述符是就绪的,通过epoll可以直接得到已就绪的文件描述符集合,无需再次检测
-
使用epoll没有最大文件描述符的限制,仅受系统中进程能打开的最大文件数目限制
-
当多路复用的文件数量庞大、IO流量频繁的时候,一般不太适合使用select()和poll(),这种情况下select()和poll()表现较差,推荐使用epoll()。
2.操作函数
在epoll中一共提供是三个API函数,分别处理不同的操作,函数原型如下:
#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
select/poll低效的原因之一是将“添加/维护待检测任务”和“阻塞进程/线程”两个步骤合二为一。每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket个数相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl()维护等待队列,再调用epoll_wait()阻塞进程(解耦)。通过下图的对比显而易见,epoll的效率得到了提升。

当我们创建了这个epoll树实例之后,咱们可以通过epoll_ctl来进行节点的相关操作。在需要的时候,你就调用epoll_ctl来添加节点或者是删除节点,又或者说来修改这个epoll树上的节点。如果我们不需要对树上的节点进行添加删除或者修改操作,咱们只需要调用epoll_wait对树上节点进行状态的检测就可以了当树上有了这个就绪状态的节点之后,这个epoll就会通知我们,并且告诉我们是树上的哪一个节点就绪了。我们就可以基于这个就绪节点对应的事件做后续的状态处理了。
具体差异如下:
| 特性 | select/poll | epoll |
|---|---|---|
| 任务处理方式 | 单个函数同时完成 “添加任务 + 阻塞检测” | 拆分为 3 个函数,分工明确 |
| 待检测集合管理 | 线性结构(数组 / 链表),需频繁扫描 | 红黑树(epoll 实例维护),高效增删改 |
| 数据拷贝 | 内核 / 用户空间频繁拷贝待检测集合 | 共享内存(mmap),无额外拷贝 |
| 就绪 fd 获取 | 需遍历全部待检测集合判断就绪状态 | 直接返回已就绪 fd 集合,无需二次判断 |
强调:select/poll 的 “单函数包办” 导致每次检测都需重复初始化待检测集合,效率低下;而 epoll 通过 “创建实例→维护任务→检测就绪” 的三步分工,大幅提升了高并发场景下的性能。
epoll_create()函数的作用是创建一个红黑树模型的实例,用于管理待检测的文件描述符的集合。
int epoll_create(int size);
- 函数参数 size:在Linux内核2.6.8版本以后,这个参数是被忽略的,只需要指定一个大于0的数值就可以了。
- 函数返回值:
- 失败:返回-1
- 成功:返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的epoll实例了
调用epoll_create后,内核会创建:① 红黑树(存储待检测 fd 及事件);② 就绪链表(存储已就绪的 fd);③ 共享内存(用户空间与内核共享数据,减少拷贝)。
- 核心作用:创建一个epoll 实例,底层通过 “红黑树” 管理待检测的文件描述符(fd)集合,返回一个用于操作该实例的 fd。
epoll_ctl()函数的作用是管理红黑树实例上的节点,可以进行添加、删除、修改操作。
// 联合体, 多个变量共用同一块内存
typedef union epoll_data
{
void *ptr;
int fd; // 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event
{
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
函数参数:
- epfd:epoll_create() 函数的返回值,通过这个参数找到epoll实例
- op:这是一个枚举值,控制通过该函数执行什么操作
- EPOLL_CTL_ADD:往epoll模型中添加新的节点
- EPOLL_CTL_MOD:修改epoll模型中已经存在的节点
- EPOLL_CTL_DEL:删除epoll模型中的指定的节点
- fd:文件描述符,即要添加/修改/删除的文件描述符
- event:epoll事件,用来修饰第三个参数对应的文件描述符的,指定检测这个文件描述符的什么事件
- events:委托epoll检测的事件
- EPOLLIN:读事件,接收数据, 检测读缓冲区,如果有数据该文件描述符就绪
- EPOLLOUT:写事件,发送数据, 检测写缓冲区,如果可写该文件描述符就绪
- EPOLLERR:异常事件
- data:用户数据变量,这是一个联合体类型,通常情况下使用里边的fd成员,用于存储待检测的文件描述符的值,在调用epoll_wait()函数的时候这个值会被传出。
- 函数返回值:
- 失败:返回-1
- 成功:返回0
- events:委托epoll检测的事件
-
核心作用:对 epoll 实例的红黑树进行节点管理:添加新 fd、修改已有 fd 的事件、删除 fd。
epoll_wait()函数的作用是检测创建的epoll实例中有没有就绪的文件描述符。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
-
函数参数:
- epfd:epoll_create() 函数的返回值,通过这个参数找到epoll实例
- events:传出参数,这是一个结构体数组的地址,里边存储了已就绪的文件描述符的信息
- maxevents:修饰第二个参数,结构体数组的容量(元素个数)
- timeout:如果检测的epoll实例中没有已就绪的文件描述符,该函数阻塞的时长, 单位ms 毫秒
- 0:函数不阻塞,不管epoll实例中有没有就绪的文件描述符,函数被调用后都直接返回
- 大于0:如果epoll实例中没有已就绪的文件描述符,函数阻塞对应的毫秒数再返回
- -1:函数一直阻塞,直到epoll实例中有已就绪的文件描述符之后才解除阻塞
-
函数返回值:
- 成功:
- 等于0:函数是阻塞被强制解除了,没有检测到满足条件的文件描述符
- 大于0:检测到的已就绪的文件描述符的总个数
- 失败:返回-1
- 成功:
-
核心作用:阻塞检测 epoll 实例(红黑树)中是否有 fd 就绪,若有则将 “就绪 fd 的事件信息” 传出,无需遍历全部 fd。
调用epoll_wait可以对树上节点进行状态的检测,当树上有了这个就绪状态的节点之后,这个epoll就会通知我们,并且告诉我们是树上的哪一个节点就绪了。我们就可以基于这个就绪节点对应的事件做后续的状态处理了。
3.epoll的使用
3.1 操作步骤
在服务器端使用epoll进行IO多路转接的操作步骤如下:
①创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
②设置端口复用(可选)
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
③使用本地的IP与端口和监听的套接字进行绑定
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
④给监听的套接字设置监听
int epfd = epoll_create(100);
⑤创建epoll实例对象
int epfd = epoll_create(100);
⑥将用于监听的套接字添加到epoll实例中
struct epoll_event ev;
ev.events = EPOLLIN; // 检测lfd读读缓冲区是否有数据
ev.data.fd = lfd;
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
⑦检测添加到epoll实例中的文件描述符是否已就绪,并将这些已就绪的文件描述符进行处理
int num = epoll_wait(epfd, evs, size, -1);
-
如果是监听的文件描述符,和新客户端建立连接,将得到的文件描述符添加到epoll实例中
int cfd = accept(curfd, NULL, NULL); ev.events = EPOLLIN; ev.data.fd = cfd; // 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了 epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); -
如果是通信的文件描述符,和对应的客户端通信,如果连接已断开,将该文件描述符从epoll实例中删除
int len = recv(curfd, buf, sizeof(buf), 0); if(len == 0) { // 将这个文件描述符从epoll模型中删除 epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL); close(curfd); } else if(len > 0) { send(curfd, buf, len, 0); }
⑧重复第7步的操作
3.2 示例代码
#include <stdio.h> // 标准输入输出(printf/perror)
#include <ctype.h> // 字符处理(本代码未实际使用,属于冗余引入)
#include <unistd.h> // 系统调用(close/sleep等)
#include <stdlib.h> // 通用工具(exit/malloc等)
#include <sys/types.h> // 系统数据类型(pid_t/socklen_t等)
#include <sys/stat.h> // 文件状态(本代码未使用,冗余引入)
#include <string.h> // 字符串操作(memset/strlen等)
#include <arpa/inet.h> // 网络地址转换(htons/htonl/inet_pton等)
#include <sys/socket.h> // 套接字核心函数(socket/bind/listen/accept等)
#include <sys/epoll.h> // epoll 相关函数(epoll_create/epoll_ctl/epoll_wait)
int main(int argc, const char* argv[])
{
// 1. 创建监听套接字(IPv4 + 流式传输 + TCP协议)
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket error"); // 错误打印:输出失败原因
exit(1); // 异常退出程序
}
// 2. 初始化服务器地址结构体
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); // 清空结构体,避免脏数据
serv_addr.sin_family = AF_INET; // IPv4 协议族
serv_addr.sin_port = htons(9999); // 端口号(主机字节序转网络字节序)
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定本机所有IP(0.0.0.0)
// 3. 设置端口复用(避免程序重启后端口被占用)
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 4. 绑定监听套接字到指定IP和端口
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if(ret == -1)
{
perror("bind error");
exit(1);
}
// 5. 监听连接(backlog=64 表示半连接队列最大长度)
ret = listen(lfd, 64);
if(ret == -1)
{
perror("listen error");
exit(1);
}
// 6. 创建epoll实例(参数100在2.6.8后无意义,仅需>0)
int epfd = epoll_create(100);
if(epfd == -1)
{
perror("epoll_create");
exit(0);
}
// 7. 将监听fd(lfd)添加到epoll模型,检测其读事件
struct epoll_event ev;
ev.events = EPOLLIN; // 检测lfd的读缓冲区是否有数据(有新连接时就绪)
ev.data.fd = lfd; // 记录该事件对应的fd是lfd(用户自用数据)
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1)
{
perror("epoll_ctl");
exit(0);
}
// 8. 创建epoll_event数组,用于接收内核返回的就绪事件(最多1024个)
struct epoll_event evs[1024];
int size = sizeof(evs) / sizeof(struct epoll_event);
// 9. 持续检测就绪事件(无限循环)
while(1)
{
// 9.1 阻塞检测就绪事件(timeout=-1 表示永久阻塞,直到有事件就绪)
int num = epoll_wait(epfd, evs, size, -1);
// 9.2 遍历所有就绪事件(num是就绪事件的个数)
for(int i=0; i<num; ++i)
{
// 取出当前就绪事件对应的fd
int curfd = evs[i].data.fd;
// 9.3 区分:是监听fd就绪(新连接)还是通信fd就绪(数据收发)
if(curfd == lfd)
{
// 情况1:监听fd就绪 → 有新客户端连接,接受连接
int cfd = accept(curfd, NULL, NULL); // 接受连接,返回通信fd
// 将新的通信fd(cfd)添加到epoll模型,检测其读事件
ev.events = EPOLLIN; // 检测cfd的读缓冲区(客户端发数据时就绪)
ev.data.fd = cfd; // 记录该事件对应cfd
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
}
else
{
// 情况2:通信fd就绪 → 客户端发数据/断开连接
char buf[1024];
memset(buf, 0, sizeof(buf)); // 清空缓冲区
// 接收客户端数据
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == 0)
{
// len=0 → 客户端主动断开连接
printf("客户端已经断开了连接\n");
// 从epoll模型中删除该fd(不再检测)
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd); // 关闭通信fd
}
else if(len > 0)
{
// len>0 → 成功接收数据,回显给客户端
printf("客户端say: %s\n", buf);
send(curfd, buf, len, 0); // 回发数据
}
else
{
// len=-1 → 接收数据失败
perror("recv");
exit(0);
}
}
}
}
return 0;
}
当在服务器端循环调用epoll_wait()的时候,就会得到一个就绪列表,并通过该函数的第二个参数传出:
struct epoll_event evs[1024];
int num = epoll_wait(epfd, evs, size, -1);
每当epoll_wait()函数返回一次,在evs中最多可以存储size个已就绪的文件描述符信息,但是在这个数组中实际存储的有效元素个数为num个,如果在这个epoll实例的红黑树中已就绪的文件描述符很多,并且evs数组无法将这些信息全部传出,那么这些信息会在下一次epoll_wait()函数返回的时候被传出。
通过evs数组被传递出的每一个有效元素里边都包含了已就绪的文件描述符的相关信息,这些信息并不是凭空得来的,这取决于我们在往epoll实例中添加节点的时候,往节点中初始化了哪些数据:
struct epoll_event ev;
// 节点初始化
ev.events = EPOLLIN;
ev.data.fd = lfd; // 使用了联合体中 fd 成员
// 添加待检测节点到epoll实例中
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
在添加节点的时候,需要对这个struct epoll_event类型的节点进行初始化,当这个节点对应的文件描述符变为已就绪状态,这些被传入的初始化信息就会被原样传出,这个对应关系必须要搞清楚。
这段代码是一个基于 epoll 多路 IO 复用实现的 TCP 服务器程序,核心功能是高效处理多个客户端的并发连接,实现 “客户端发什么,服务器回显什么” 的简单回显功能。相比传统的多进程 / 多线程模型,epoll 模型通过 “内核检测就绪事件 + 事件驱动” 的方式,大幅降低了系统开销,更适合高并发场景。
代码逐模块详细解析
(1)头文件引入(程序的基础依赖)
#include <stdio.h> // 标准输入输出(printf/perror)
#include <ctype.h> // 字符处理(本代码未实际使用,属于冗余引入)
#include <unistd.h> // 系统调用(close/sleep等)
#include <stdlib.h> // 通用工具(exit/malloc等)
#include <sys/types.h> // 系统数据类型(pid_t/socklen_t等)
#include <sys/stat.h> // 文件状态(本代码未使用,冗余引入)
#include <string.h> // 字符串操作(memset/strlen等)
#include <arpa/inet.h> // 网络地址转换(htons/htonl/inet_pton等)
#include <sys/socket.h> // 套接字核心函数(socket/bind/listen/accept等)
#include <sys/epoll.h> // epoll 相关函数(epoll_create/epoll_ctl/epoll_wait)
- 注:
ctype.h和sys/stat.h在本代码中未实际使用,属于可删除的冗余头文件,不影响程序运行。
(2)主函数入口 & 监听套接字创建(TCP 服务器基础)
int main(int argc, const char* argv[])
{
// 1. 创建监听套接字(IPv4 + 流式传输 + TCP协议)
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket error"); // 错误打印:输出失败原因
exit(1); // 异常退出程序
}
socket()作用:创建一个用于监听客户端连接的 “文件描述符(lfd)”,AF_INET 表示 IPv4 协议,SOCK_STREAM 表示流式传输(TCP),第三个参数 0 表示默认使用 TCP 协议。- 错误处理:如果创建失败(返回 -1),通过
perror打印错误原因,exit(1)终止程序。
(3)端口复用 & 地址绑定(解决端口占用问题)
// 2. 初始化服务器地址结构体
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); // 清空结构体,避免脏数据
serv_addr.sin_family = AF_INET; // IPv4 协议族
serv_addr.sin_port = htons(9999); // 端口号(主机字节序转网络字节序)
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定本机所有IP(0.0.0.0)
// 3. 设置端口复用(避免程序重启后端口被占用)
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 4. 绑定监听套接字到指定IP和端口
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if(ret == -1)
{
perror("bind error");
exit(1);
}
- 端口复用:
setsockopt()设置SO_REUSEADDR选项,核心作用是 “程序退出后,端口可以立即被重新使用”,避免出现 “Address already in use” 错误。 - 地址绑定:
bind()将监听套接字lfd与服务器的 IP(INADDR_ANY 表示本机所有网卡 IP)和端口(9999)绑定,让客户端能通过该地址找到服务器。
(4)监听客户端连接(进入被动连接状态)
// 5. 监听连接(backlog=64 表示半连接队列最大长度)
ret = listen(lfd, 64);
if(ret == -1)
{
perror("listen error");
exit(1);
}
listen()作用:将监听套接字lfd从 “主动态” 转为 “被动态”,开始监听客户端的连接请求;第二个参数 64 是内核允许的最大半连接数(未完成三次握手的连接)。
(5)创建 epoll 实例(核心:委托内核检测 fd 状态)
// 6. 创建epoll实例(参数100在2.6.8后无意义,仅需>0)
int epfd = epoll_create(100);
if(epfd == -1)
{
perror("epoll_create");
exit(0);
}
// 7. 将监听fd(lfd)添加到epoll模型,检测其读事件
struct epoll_event ev;
ev.events = EPOLLIN; // 检测lfd的读缓冲区是否有数据(有新连接时就绪)
ev.data.fd = lfd; // 记录该事件对应的fd是lfd(用户自用数据)
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1)
{
perror("epoll_ctl");
exit(0);
}
epoll_create(100):创建一个 epoll 实例,返回操作该实例的文件描述符epfd;参数 100 是历史遗留值(2.6.8 内核后忽略),仅需传入大于 0 的整数即可。epoll_ctl():向 epoll 实例中 “添加待检测的 fd”:EPOLL_CTL_ADD:操作类型为 “添加节点”;ev.events = EPOLLIN:告诉内核 “检测 lfd 的读事件”(当有客户端发起连接时,lfd 的读缓冲区会有数据,事件就绪);ev.data.fd = lfd:将 fd 绑定到事件结构体,后续 epoll 检测到就绪事件时,能通过该字段知道是哪个 fd 就绪。
(6)循环检测就绪事件(epoll 核心逻辑)
// 8. 创建epoll_event数组,用于接收内核返回的就绪事件(最多1024个)
struct epoll_event evs[1024];
int size = sizeof(evs) / sizeof(struct epoll_event);
// 9. 持续检测就绪事件(无限循环)
while(1)
{
// 9.1 阻塞检测就绪事件(timeout=-1 表示永久阻塞,直到有事件就绪)
int num = epoll_wait(epfd, evs, size, -1);
// 9.2 遍历所有就绪事件(num是就绪事件的个数)
for(int i=0; i<num; ++i)
{
// 取出当前就绪事件对应的fd
int curfd = evs[i].data.fd;
// 9.3 区分:是监听fd就绪(新连接)还是通信fd就绪(数据收发)
if(curfd == lfd)
{
// 情况1:监听fd就绪 → 有新客户端连接,接受连接
int cfd = accept(curfd, NULL, NULL); // 接受连接,返回通信fd
// 将新的通信fd(cfd)添加到epoll模型,检测其读事件
ev.events = EPOLLIN; // 检测cfd的读缓冲区(客户端发数据时就绪)
ev.data.fd = cfd; // 记录该事件对应cfd
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
}
else
{
// 情况2:通信fd就绪 → 客户端发数据/断开连接
char buf[1024];
memset(buf, 0, sizeof(buf)); // 清空缓冲区
// 接收客户端数据
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == 0)
{
// len=0 → 客户端主动断开连接
printf("客户端已经断开了连接\n");
// 从epoll模型中删除该fd(不再检测)
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd); // 关闭通信fd
}
else if(len > 0)
{
// len>0 → 成功接收数据,回显给客户端
printf("客户端say: %s\n", buf);
send(curfd, buf, len, 0); // 回发数据
}
else
{
// len=-1 → 接收数据失败
perror("recv");
exit(0);
}
}
}
}
return 0;
}
这部分是 epoll 服务器的核心逻辑,拆解关键细节:
epoll_wait(epfd, evs, size, -1):- 作用:委托内核检测 epoll 实例中所有待检测 fd 的状态;
- 参数:
epfd是 epoll 实例 fd,evs是 “传出参数”(存储就绪事件),size限制最多接收 1024 个就绪事件,-1表示永久阻塞(直到有事件就绪); - 返回值
num:就绪事件的个数,evs数组前num个元素是有效就绪事件。
- 事件处理分支:
- 监听 fd(lfd)就绪:
accept()接受新连接,返回 “通信 fd(cfd)”,并将 cfd 添加到 epoll 模型(后续检测其读事件); - 通信 fd(cfd)就绪:
len=0:客户端调用close()断开连接,需将 cfd 从 epoll 模型中删除(EPOLL_CTL_DEL),并关闭 cfd;len>0:成功接收客户端数据,通过send()回显给客户端;len=-1:接收数据失败,程序异常退出。
- 监听 fd(lfd)就绪:
总结
核心关键点回顾
- epoll 三大函数分工:
epoll_create:创建 epoll 实例(红黑树),用于管理待检测 fd;epoll_ctl:增删改 epoll 中的待检测 fd(添加 lfd/cfd、删除断开的 cfd);epoll_wait:阻塞检测就绪事件,直接返回就绪 fd 列表,无需遍历全部 fd。
- 事件驱动逻辑:
- 仅当 fd 就绪(lfd 有新连接、cfd 有数据)时,程序才处理对应 fd,无无效遍历,高并发下效率远高于 select/poll;
EPOLLIN是核心事件:检测 fd 读缓冲区是否有数据(lfd 就绪 = 新连接,cfd 就绪 = 客户端发数据)。
- 关键细节:
- 端口复用(
SO_REUSEADDR)是服务器必备,避免端口占用问题; epoll_event.data.fd是用户自用数据,必须初始化,否则无法识别就绪事件对应的 fd;- 客户端断开连接时,需先从 epoll 模型删除 fd,再关闭 fd,避免 epoll 检测无效 fd。
- 端口复用(
4.epoll的工作模式
4.1 水平模式
水平模式可以简称为LT模式,LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核通知使用者哪些文件描述符已经就绪,之后就可以对这些已就绪的文件描述符进行IO操作了。如果我们不作任何操作,内核还是会继续通知使用者。
水平模式的特点:
- 读事件:如果文件描述符对应的读缓冲区还有数据,读事件就会被触发,epoll_wait()解除阻塞
- 当读事件被触发,epoll_wait()解除阻塞,之后就可以接收数据了
- 如果接收数据的buf很小,不能全部将缓冲区数据读出,那么读事件会继续被触发,直到数据被全部读出,如果接收数据的内存相对较大,读数据的效率也会相对较高(减少了读数据的次数)
- 因为读数据是被动的,必须要通过读事件才能知道有数据到达了,因此对于读事件的检测是必须的
- 写事件:如果文件描述符对应的写缓冲区可写,写事件就会被触发,epoll_wait()解除阻塞
- 当写事件被触发,epoll_wait()解除阻塞,之后就可以将数据写入到写缓冲区了
- 写事件的触发发生在写数据之前而不是之后,被写入到写缓冲区中的数据是由内核自动发送出去的
- 如果写缓冲区没有被写满,写事件会一直被触发
- 因为写数据是主动的,并且写缓冲区一般情况下都是可写的(缓冲区不满),因此对于写事件的检测不是必须的
服务端
#include <stdio.h>
#include <ctype.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
// server
int main(int argc, const char* argv[])
{
// 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket error");
exit(1);
}
// 绑定
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(9999);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本地多有的IP
// 设置端口复用
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定端口
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if(ret == -1)
{
perror("bind error");
exit(1);
}
// 监听
ret = listen(lfd, 64);
if(ret == -1)
{
perror("listen error");
exit(1);
}
// 现在只有监听的文件描述符
// 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll
// 创建一个epoll模型
int epfd = epoll_create(100);
if(epfd == -1)
{
perror("epoll_create");
exit(0);
}
// 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符
struct epoll_event ev;
ev.events = EPOLLIN; // 检测lfd读读缓冲区是否有数据
ev.data.fd = lfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1)
{
perror("epoll_ctl");
exit(0);
}
struct epoll_event evs[1024];
int size = sizeof(evs) / sizeof(struct epoll_event);
// 持续检测
while(1)
{
// 调用一次, 检测一次
int num = epoll_wait(epfd, evs, size, -1);
printf("num = %d\n", num);
for(int i=0; i<num; ++i)
{
// 取出当前的文件描述符
int curfd = evs[i].data.fd;
// 判断这个文件描述符是不是用于监听的
if(curfd == lfd)
{
// 建立新的连接
int cfd = accept(curfd, NULL, NULL);
// 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了
ev.events = EPOLLIN; // 读缓冲区是否有数据
ev.data.fd = cfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
}
else
{
// 处理通信的文件描述符
// 接收数据
char buf[5];
memset(buf, 0, sizeof(buf));
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == 0)
{
printf("客户端已经断开了连接\n");
// 将这个文件描述符从epoll模型中删除
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd);
}
else if(len > 0)
{
printf("客户端say: %s\n", buf);
send(curfd, buf, len, 0);
}
else
{
perror("recv");
exit(0);
}
}
}
}
return 0;
}
客户端
cmt@cmt-virtual-machine:~/LinuxStudy/practise$ cat tcp_client2.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{
// 1. 创建用于通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET; // ipv4
addr.sin_port = htons(9999); // 服务器监听的端口, 字节序应该是网络字节序
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}
// 通信
while(1)
{
// 读数据
char recvBuf[1024];
// 写数据
fgets(recvBuf, sizeof(recvBuf), stdin);
write(fd, recvBuf, strlen(recvBuf)+1);
// 如果客户端没有发送数据, 默认阻塞
int len = read(fd, recvBuf, sizeof(recvBuf));
if(len == -1)
{
perror("read error!");
exit(1);
}
printf("recv buf: %s\n", recvBuf);
}
// 释放资源
close(fd);
return 0;
}
cmt@cmt-virtual-machine:~/LinuxStudy/practise$
启动服务器
cmt@cmt-virtual-machine:~/LinuxStudy/practise$ gcc -o tcp_server2 tcp_server2.c
cmt@cmt-virtual-machine:~/LinuxStudy/practise$ ./tcp_server2
num = 1
num = 1
客户端say: sdfgs
num = 1
客户端say: dfgdf
num = 1
客户端say: gdfdf
num = 1
客户端say: asd
启动客户端
cmt@cmt-virtual-machine:~/LinuxStudy/practise$ gcc -o tcp_client2 tcp_client2.c
cmt@cmt-virtual-machine:~/LinuxStudy/practise$ ./tcp_client2
sdfgsdfgdfgdfdfasd
recv buf: sdfgsdfgdfgdfdfasd
(1)代码整体功能总结
- 服务端:基于 epoll IO 多路复用(默认 LT 模式)实现的 TCP 服务器,监听 9999 端口,支持多客户端连接(通过 epoll 管理所有文件描述符);为验证 LT 模式特性,特意将接收数据的缓冲区设为 5 字节,每次最多读取 5 字节数据,读取后原封不动回发给客户端。
- 客户端:简单的 TCP 客户端,连接服务端 9999 端口后,循环读取用户输入的字符串并发送给服务端,然后阻塞读取服务端的回复并打印。
(2)服务端代码详细分析(核心是 LT 模式体现)
按代码执行流程拆解,重点标注和 LT 模式强相关的关键逻辑:
①基础网络初始化(socket/bind/listen/ 端口复用)
这是 TCP 服务器的标准初始化流程,无 epoll/LT 模式相关特殊逻辑:
// 创建监听套接字、设置端口复用、绑定端口、监听
int lfd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 端口复用
bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
listen(lfd, 64);
- 端口复用(
SO_REUSEADDR):避免服务器重启时出现 “端口被占用” 的错误,不影响 LT 模式逻辑。
②epoll 模型初始化(LT 模式的核心配置)
// 创建epoll实例
int epfd = epoll_create(100);
// 配置监听fd的检测事件(默认LT模式)
struct epoll_event ev;
ev.events = EPOLLIN; // 仅检测读事件,**未设置EPOLLET** → 默认LT模式
ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); // 将监听fd加入epoll检测
- 关键核心:
ev.events仅设置了EPOLLIN(读事件),未添加EPOLLET标志(边沿触发的开关),因此 epoll 默认工作在水平触发(LT)模式—— 这是后续所有运行现象的核心前提。
③主循环:epoll_wait 检测就绪 fd(LT 模式的核心体现)
struct epoll_event evs[1024];
int size = sizeof(evs) / sizeof(struct epoll_event);
while(1)
{
// 阻塞检测就绪fd,返回值num是就绪的fd数量
int num = epoll_wait(epfd, evs, size, -1);
printf("num = %d\n", num); // 打印就绪数,直观体现LT模式的持续通知
for(int i=0; i<num; ++i)
{
int curfd = evs[i].data.fd;
// 情况1:处理监听fd(客户端连接)
if(curfd == lfd)
{
int cfd = accept(curfd, NULL, NULL); // 建立新连接
// 新增通信fd到epoll,仍为LT模式(未设EPOLLET)
ev.events = EPOLLIN;
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
// 情况2:处理通信fd(数据收发,LT模式核心验证)
else
{
char buf[5]; // 故意设置5字节小缓冲区,模拟“只读部分数据”
memset(buf, 0, sizeof(buf));
// 每次最多读5字节,若客户端发长数据则无法一次性读完
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == 0) { // 客户端断开连接
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd);
} else if(len > 0) { // 读到数据,原封不动回发
printf("客户端say: %s\n", buf);
send(curfd, buf, len, 0);
}
}
}
}
- 核心关键点(和 LT 模式强相关):
char buf[5]:这是验证 LT 模式的关键操作 —— 故意缩小缓冲区,让服务端每次只能读取 5 字节数据。若客户端发送超过 5 字节的字符串,服务端无法一次性读完,读缓冲区会残留数据,触发 LT 模式的 “持续通知” 特性。- 无循环读取:服务端处理通信 fd 时,仅调用一次
recv,未循环读取直到recv返回 0/-1。这是为了验证 LT 模式的核心逻辑:即使只读部分数据,epoll 下一次epoll_wait仍会检测到该 fd 就绪(因为读缓冲区有数据),并再次通知。 - 通信 fd 的 LT 模式:新增通信 fd 到 epoll 时,
ev.events仍仅设EPOLLIN,未加EPOLLET,因此通信 fd 也工作在 LT 模式。
(3)客户端代码详细分析
客户端逻辑简单,核心是 “一次输入→一次发送→一次接收→打印” 的循环,需注意和运行现象相关的细节:
while(1)
{
char recvBuf[1024];
fgets(recvBuf, sizeof(recvBuf), stdin); // 读取用户输入(含换行符)
write(fd, recvBuf, strlen(recvBuf)+1); // 发送数据(+1是包含'\0')
// 阻塞读取服务端回复,缓冲区1024字节足够容纳所有回复
int len = read(fd, recvBuf, sizeof(recvBuf));
printf("recv buf: %s\n", recvBuf); // 打印回复
}
- 关键细节:
read是阻塞调用:只有收到服务端的数据才会返回,因此客户端一次输入后,会等待服务端所有回复都返回后才打印。- TCP 字节流特性:服务端分多次
send的 5 字节数据,会被 TCP 内核缓冲区拼接成连续的字节流,客户端一次read(1024 字节)会一次性读取所有回复 ------ 这是客户端仅打印一次回复的核心原因(和 LT 模式无关)。
(4)运行输出现象分析
①服务端输出分析
num = 1 → 第一次:监听fd(lfd)就绪,客户端发起连接,执行accept
num = 1 → 第二次:通信fd(cfd)就绪,客户端发送长字符串,服务端读5字节(sdfgs)
num = 1 → 第三次:通信fd仍就绪(读缓冲区有剩余数据),服务端读5字节(dfgdf)
num = 1 → 第四次:通信fd仍就绪,服务端读5字节(gdfdf)
num = 1 → 第五次:通信fd仍就绪,服务端读剩余字节(asd)
客户端say: sdfgs → 对应第二次的5字节
客户端say: dfgdf → 对应第三次的5字节
客户端say: gdfdf → 对应第四次的5字节
客户端say: asd → 对应第五次的剩余字节
-
现象本质:
LT 模式的持续通知特性客户端发送的长字符串(
sdfgsdfgdfgdfdfasd)被拆分成多段,每段 5 字节(最后一段不足)。服务端每次只读 5 字节后,读缓冲区仍有剩余数据,通信 fd 保持 “就绪状态”;根据 LT 模式逻辑,epoll 每次epoll_wait都会检测到该 fd 就绪,返回num=1,直到读缓冲区数据被全部读完。
②客户端输出分析
sdfgsdfgdfgdfdfasd → 用户输入的长字符串(超过5字节)
recv buf: sdfgsdfgdfgdfdfasd → 一次性读取服务端分多次send的所有数据
-
现象本质:
TCP 字节流协议特性TCP 是无消息边界的字节流,服务端分多次
send的 5 字节数据会被内核拼接成完整的字符串;客户端read的缓冲区是 1024 字节(远大于发送的数据长度),因此一次read就读取了所有回复,仅打印一次 —— 这和 LT 模式无关,是 TCP 协议的特性。
(5)关键细节与潜在问题
- LT 模式的优势与劣势体现:
- 优势:无需循环读数据也能保证数据全部接收(epoll 反复通知),代码简单、不易丢失数据;
- 劣势:频繁的
epoll_wait返回(多次num=1)增加了内核 - 用户态切换的开销,效率低于 ET 模式。
- 服务端的潜在优化:若要减少 LT 模式的通知次数,可在处理通信 fd 时循环
recv直到返回EAGAIN(需将 fd 设为非阻塞),但会增加代码复杂度。 - 客户端
fgets的特性:fgets会读取用户输入的换行符(\n),因此客户端发送的数据包含换行符,服务端回发后,客户端打印的回复也会包含换行符。
总结
- 服务端代码是 epoll LT 模式 的典型实现(未设置
EPOLLET),通过buf[5]模拟 “只读部分数据”,直观验证了 LT 模式 “读缓冲区有数据则 fd 持续就绪,epoll 反复通知” 的核心特性。 - 服务端多次打印
num=1的本质是 LT 模式的持续通知:客户端发送的长字符串无法被 5 字节缓冲区一次性读完,读缓冲区剩余数据使通信 fd 始终就绪,epoll_wait反复检测到并返回。 - 客户端仅打印一次回复的原因是 TCP 字节流特性:服务端分多次
send的 5 字节数据被内核拼接,客户端一次read(1024 字节)读取全部回复,与 LT 模式无关。 - LT 模式的核心特点(代码简单、数据不丢失、系统开销较高)在这份代码中均有完整体
4.2 边沿模式
边沿模式可以简称为ET模式,ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当文件描述符从未就绪变为就绪时,内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知(only once)。如果我们对这个文件描述符做IO操作,从而导致它再次变成未就绪,当这个未就绪的文件描述符再次变成就绪状态,内核会再次进行通知,并且还是只通知一次。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
边沿模式的特点:
- 读事件:当读缓冲区有新的数据进入,读事件被触发一次,没有新数据不会触发该事件
- 如果有新数据进入到读缓冲区,读事件被触发,epoll_wait()解除阻塞
- 读事件被触发,可以通过调用read()/recv()函数将缓冲区数据读出
- 如果数据没有被全部读走,并且没有新数据进入,读事件不会再次触发,只通知一次
- 如果数据被全部读走或者只读走一部分,此时有新数据进入,读事件被触发,并且只通知一次
- 写事件:当写缓冲区状态可写,写事件只会触发一次
- 如果写缓冲区被检测到可写,写事件被触发,epoll_wait()解除阻塞
- 写事件被触发,就可以通过调用write()/send()函数,将数据写入到写缓冲区中
- 写缓冲区从不满到被写满,期间写事件只会被触发一次
- 写缓冲区从满到不满,状态变为可写,写事件只会被触发一次
综上所述:epoll的边沿模式下 epoll_wait()检测到文件描述符有新事件才会通知,如果不是新的事件就不通知,通知的次数比水平模式少,效率比水平模式要高。
4.2.1 ET的设置
边沿模式不是默认的epoll模式,需要额外进行设置。epoll设置边沿模式是非常简单的,epoll管理的红黑树示例中每个节点都是struct epoll_event类型,只需要将EPOLLET添加到结构体的events成员中即可:
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 设置边沿模式
示例代码如下:
nt num = epoll_wait(epfd, evs, size, -1);
for(int i=0; i<num; ++i)
{
// 取出当前的文件描述符
int curfd = evs[i].data.fd;
// 判断这个文件描述符是不是用于监听的
if(curfd == lfd)
{
// 建立新的连接
int cfd = accept(curfd, NULL, NULL);
// 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了
// 读缓冲区是否有数据, 并且将文件描述符设置为边沿模式
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = cfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
}
}
服务器代码
// 引入必要的头文件:基础IO、字符串、网络编程、epoll相关
#include <stdio.h>
#include <ctype.h> // 包含toupper()函数(字符转大写)
#include <unistd.h> // 包含close()、read()、write()等系统调用
#include <stdlib.h> // 包含exit()、malloc()等函数
#include <sys/types.h> // 包含基本数据类型(如pid_t、fd_set等)
#include <sys/stat.h>
#include <string.h> // 包含memset()、strlen()等字符串操作
#include <arpa/inet.h> // 包含网络字节序转换(htons()、htonl())、inet_pton()等
#include <sys/socket.h>// 包含socket、bind、listen、accept等网络函数
#include <sys/epoll.h> // 包含epoll_create、epoll_ctl、epoll_wait等epoll函数
// server:程序入口(epoll ET模式服务端,刻意保留数据残留的学习演示版)
int main(int argc, const char* argv[])
{
// 1. 创建监听套接字(用于接收客户端连接请求)
// AF_INET:IPv4协议 | SOCK_STREAM:流式套接字(TCP) | 0:默认协议(TCP)
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1) // 套接字创建失败
{
perror("socket error"); // 打印错误信息
exit(1); // 退出程序(1表示异常退出)
}
// 2. 配置服务器地址结构体(绑定端口和IP)
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); // 初始化结构体为0
serv_addr.sin_family = AF_INET; // 地址族:IPv4
serv_addr.sin_port = htons(9999); // 端口号:9999(htons转网络字节序)
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定本机所有IP(INADDR_ANY=0.0.0.0)
// 3. 设置端口复用(避免服务器重启时"端口被占用"的错误)
int opt = 1; // 启用端口复用
// setsockopt:设置套接字选项 | SOL_SOCKET:套接字层 | SO_REUSEADDR:端口复用选项
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 4. 绑定监听套接字到指定IP和端口
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if(ret == -1) // 绑定失败
{
perror("bind error");
exit(1);
}
// 5. 将监听套接字设为监听状态(开始接收客户端连接)
// listen参数2:64表示半连接队列+已连接队列的最大长度(backlog)
ret = listen(lfd, 64);
if(ret == -1) // 监听失败
{
perror("listen error");
exit(1);
}
// 6. 创建epoll实例(epoll模型的核心句柄)
// epoll_create参数:100是历史遗留的"大小提示",现在无实际意义(只要>0即可)
int epfd = epoll_create(100);
if(epfd == -1) // epoll实例创建失败
{
perror("epoll_create");
exit(0);
}
// 7. 向epoll实例中添加"监听fd"(委托内核检测lfd的读事件)
struct epoll_event ev; // 描述epoll检测的事件和对应fd
ev.events = EPOLLIN | EPOLLET; // 检测事件:EPOLLIN(读事件)+ EPOLLET(ET模式)
ev.data.fd = lfd; // 关联的文件描述符:监听fd
// epoll_ctl:epoll控制函数 | EPOLL_CTL_ADD:添加fd到epoll实例
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1) // 添加失败
{
perror("epoll_ctl");
exit(0);
}
// 8. 定义epoll_wait的输出数组(存储就绪的fd事件)
struct epoll_event evs[1024]; // 最多存储1024个就绪事件
int size = sizeof(evs) / sizeof(struct epoll_event); // 数组长度
// 9. 持续检测epoll实例中的fd状态(服务端主循环)
while(1)
{
// epoll_wait:阻塞等待fd就绪 | 参数:epoll句柄、就绪事件数组、数组长度、超时时间(-1=永久阻塞)
// 返回值num:本次就绪的fd数量
int num = epoll_wait(epfd, evs, size, -1);
printf("num = %d\n", num); // 打印就绪fd数量(匹配学习演示的输出格式)
// 遍历所有就绪的fd,逐个处理
for(int i=0; i<num; ++i)
{
// 取出当前就绪fd的文件描述符
int curfd = evs[i].data.fd;
// 10. 处理"监听fd就绪"(有新客户端连接)
if(curfd == lfd)
{
// accept:接收客户端连接,返回"通信fd"(cfd)| 后两个参数为NULL表示不关心客户端地址
int cfd = accept(curfd, NULL, NULL);
// 将新的通信fd添加到epoll实例(同样设为ET模式)
ev.events = EPOLLIN | EPOLLET; // 检测读事件+ET模式
ev.data.fd = cfd; // 关联通信fd
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
}
// 11. 处理"通信fd就绪"(客户端发送数据/断开连接)
else
{
// 核心设计:单次读取5字节(刻意保留ET模式数据残留的学习演示)
char buf[5]; // 仅5字节缓冲区(放大数据残留现象)
memset(buf, 0, sizeof(buf));// 初始化缓冲区为0
// recv:从通信fd读取数据 | 参数:fd、缓冲区、缓冲区大小、0(默认阻塞)
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == 0) // len=0表示客户端主动断开连接
{
printf("客户端已经断开了连接\n");
// 从epoll实例中删除该通信fd(不再检测)
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd); // 关闭通信fd
}
else if(len > 0) // len>0表示读取到数据(len=实际读取字节数)
{
// 打印原始读取的数据(匹配学习演示的输出格式)
printf("read buf = %s\n", buf);
// 将读取的字符转为大写(仅处理实际读取的len个字节,避免越界)
for(int i = 0; i < len; i++)
{
buf[i] = toupper(buf[i]);
}
// 打印转换后的数据(匹配学习演示的输出格式)
printf("after buf = %s\n", buf);
// send:将大写后的数据回发给客户端
send(curfd, buf, len, 0);
}
else // len=-1表示recv读取失败
{
perror("recv");
exit(0);
}
}
}
}
return 0;
}
核心注释要点总结
- ET 模式关键标注:
ev.events = EPOLLIN | EPOLLET:明确标注 ET 模式的开启标志,对比 LT 模式(仅 EPOLLIN);- 通信 fd 仅单次 recv 读取 5 字节:注释说明这是 “刻意保留数据残留” 的学习设计,解释 ET 模式下 “仅通知一次” 导致残留数据无法被主动读取的核心特点。
- 基础知识点补充:
- 对
socket/bind/listen等函数的参数、返回值做解释,帮你理解 TCP 服务端的基础流程; - 对
epoll_create/epoll_ctl/epoll_wait的参数含义做通俗解释,避免死记硬背。
- 对
客户端
// 引入必要的头文件:基础IO、字符串、网络编程相关
#include <stdio.h> // 包含printf()、fgets()等输入输出函数
#include <stdlib.h> // 包含exit()等进程退出函数
#include <unistd.h> // 包含close()、read()、write()等系统调用
#include <string.h> // 包含memset()、strlen()等字符串操作函数
#include <arpa/inet.h> // 包含网络字节序转换(htons)、地址转换(inet_pton)等函数
// 客户端主函数:配合epoll ET模式服务端,实现"单次输入→单次发送→单次接收"的演示效果
int main()
{
// 1. 创建通信套接字(用于和服务端建立TCP连接)
// AF_INET:使用IPv4协议 | SOCK_STREAM:流式套接字(TCP协议) | 0:默认使用TCP协议
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1) // 套接字创建失败(返回-1)
{
perror("socket"); // 打印错误原因(比如权限不足、系统资源不够)
exit(0); // 退出程序(0表示正常退出,这里因错误退出仅作演示)
}
// 2. 配置服务端地址结构体(指定要连接的服务端IP和端口)
struct sockaddr_in addr; // 存储IPv4地址信息的结构体
bzero(&addr, sizeof(addr)); // 初始化结构体为0(等价于memset,更简洁)
addr.sin_family = AF_INET; // 地址族:IPv4
addr.sin_port = htons(9999); // 服务端端口:9999(htons转换为网络字节序)
// inet_pton:将点分十进制IP(127.0.0.1)转换为网络字节序的整数,存入addr.sin_addr.s_addr
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
// 3. 连接服务端(建立TCP连接,三次握手发生在此处)
// connect参数:通信fd | 服务端地址结构体(强转为通用地址类型) | 地址结构体长度
int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1) // 连接失败(比如服务端未启动、端口错误)
{
perror("connect"); // 打印连接失败原因
exit(0); // 退出程序
}
// 4. 主通信循环:单次输入→单次发送→单次接收(匹配服务端ET模式的演示效果)
while(1)
{
char recvBuf[1024] = {0}; // 定义接收缓冲区,初始化为0(足够大,避免溢出)
// 4.1 读取用户输入(从终端获取字符串)
// fgets参数:缓冲区 | 缓冲区大小 | 输入流(stdin表示标准输入/终端)
// 注意:fgets会读取换行符(用户按回车的'\n'),并存储在缓冲区末尾
fgets(recvBuf, sizeof(recvBuf), stdin);
// 4.2 处理输入:去掉fgets读取的换行符(避免发送给服务端造成干扰)
int sendLen = strlen(recvBuf); // 获取输入字符串的长度(包含换行符)
if(recvBuf[sendLen-1] == '\n') // 判断最后一个字符是否是换行符
{
recvBuf[sendLen-1] = '\0'; // 将换行符替换为字符串结束符'\0'
sendLen--; // 发送长度减1(不发送换行符)
}
// 4.3 发送数据到服务端
// write参数:通信fd | 要发送的数据缓冲区 | 发送的字节数
// 这里只发送用户输入的有效内容(去掉了换行符)
write(fd, recvBuf, sendLen);
// 4.4 清空接收缓冲区(避免上一次的数据残留干扰本次输出)
memset(recvBuf, 0, sizeof(recvBuf));
// 4.5 接收服务端返回的数据(阻塞读取,直到收到数据)
// read参数:通信fd | 接收缓冲区 | 缓冲区大小
// 返回值len:实际读取到的字节数(服务端每次只返回5字节)
int len = read(fd, recvBuf, sizeof(recvBuf));
if(len == -1) // 读取失败(比如连接异常断开)
{
perror("read error!"); // 打印读取失败原因
exit(1); // 异常退出程序
}
// 4.6 打印服务端返回的数据(匹配演示要求的输出格式:read buf = XXX)
printf("read buf = %s\n", recvBuf);
}
// 5. 关闭通信套接字(实际主循环是死循环,这里代码不会执行,仅作规范补充)
close(fd);
return 0;
}
核心注释要点总结
-
关键设计意图(贴合 ET 模式演示):
- 「单次输入→单次发送→单次接收」:客户端每次输入后只发一次数据、只收一次回复,配合服务端 ET 模式 “仅在新数据到来时通知一次” 的特性,能直观看到服务端读取残留数据的现象;
- 去掉换行符:
fgets会读取用户回车的\n,若不处理会导致发送的数据包含换行符,干扰服务端的 5 字节读取逻辑; - 清空接收缓冲区:避免上一次接收的残留数据影响本次输出,保证每次只打印服务端返回的 5 字节。
-
基础知识点补充:
socket/connect:解释 TCP 客户端建立连接的核心步骤,以及参数的含义;fgets/write/read:说明函数的输入输出特性(比如fgets读换行符、read阻塞特性);- 错误处理:标注每个系统调用的返回值判断逻辑,帮你理解网络编程中 “错误处理” 的必要性。
-
和服务端的配合逻辑:
客户端每次输入新内容→发送数据→触发服务端通信 fd 的 “未就绪→就绪” 状态变化→服务端读取缓冲区中残留的前 5 字节→客户端接收并打印这 5 字节,完美匹配你要的演示效果。
4.2.2 设置非阻塞
对于写事件的触发一般情况下是不需要进行检测的,因为写缓冲区大部分情况下都是有足够的空间可以进行数据的写入。对于读事件的触发就必须要检测了,因为服务器也不知道客户端什么时候发送数据,如果使用epoll的边沿模式进行读事件的检测,有新数据达到只会通知一次,那么必须要保证得到通知后将数据全部从读缓冲区中读出。那么,应该如何读这些数据呢?
-
方式1:准备一块特别大的内存,用于存储从读缓冲区中读出的数据,但是这种方式有很大的弊端:
-
内存的大小没有办法界定,太大浪费内存,太小又不够用
-
系统能够分配的最大堆内存也是有上限的,栈内存就更不必多言了
-
-
方式2:循环接收数据
int len = 0; while((len = recv(curfd, buf, sizeof(buf), 0)) > 0) { // 数据处理... }这样做也是有弊端的,因为套接字操作默认是阻塞的,当读缓冲区数据被读完之后,读操作就阻塞了也就是调用的read()/recv()函数被阻塞了,当前进程/线程被阻塞之后就无法处理其他操作了。
要解决阻塞问题,就需要将套接字默认的阻塞行为修改为非阻塞,需要使用fcntl()函数进行处理:
// 设置完成之后, 读写都变成了非阻塞模式 int flag = fcntl(cfd, F_GETFL); flag |= O_NONBLOCK; fcntl(cfd, F_SETFL, flag);
通过上述分析就可以得出一个结论:epoll在边沿模式下,必须要将套接字设置为非阻塞模式,但是,这样就会引发另外的一个bug,在非阻塞模式下,循环地将读缓冲区数据读到本地内存中,当缓冲区数据被读完了,调用的read()/recv()函数还会继续从缓冲区中读数据,此时函数调用就失败了,返回-1,对应的全局变量 errno 值为 EAGAIN 或者 EWOULDBLOCK如果打印错误信息会得到如下的信息:Resource temporarily unavailable
// 非阻塞模式下recv() / read()函数返回值 len == -1
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == -1)
{
if(errno == EAGAIN)
{
printf("数据读完了...\n");
}
else
{
perror("recv");
exit(0);
}
}
4.2.3 示例代码
// 引入必要的头文件:基础IO、字符串、网络编程、epoll相关
#include <stdio.h>
#include <ctype.h> // 包含toupper()函数(字符转大写)
#include <unistd.h> // 包含close()、read()、write()等系统调用
#include <stdlib.h> // 包含exit()、malloc()等函数
#include <sys/types.h> // 包含基本数据类型(如pid_t、fd_set等)
#include <sys/stat.h>
#include <string.h> // 包含memset()、strlen()等字符串操作
#include <arpa/inet.h> // 包含网络字节序转换(htons()、htonl())、inet_pton()等
#include <sys/socket.h>// 包含socket、bind、listen、accept等网络函数
#include <sys/epoll.h> // 包含epoll_create、epoll_ctl、epoll_wait等epoll函数
#include <fcntl.h> // 【新增】设置非阻塞需要的头文件(fcntl函数)
#include <errno.h> // 【新增】获取错误码(EAGAIN需要)
// 【新增】学习版非阻塞设置函数:将文件描述符设置为非阻塞模式
// 作用:ET模式下必须将通信fd设为非阻塞,否则循环recv会卡住
void set_nonblock(int fd) {
// 1. 获取fd当前的属性标志(F_GETFL:Get File FLags)
int flag = fcntl(fd, F_GETFL);
if (flag == -1) { // 获取标志失败
perror("fcntl F_GETFL error");
exit(1);
}
// 2. 添加非阻塞标志(O_NONBLOCK)并设置回fd(F_SETFL:Set File FLags)
if (fcntl(fd, F_SETFL, flag | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL error");
exit(1);
}
}
// server:程序入口(epoll ET模式服务端,非阻塞处理版)
int main(int argc, const char* argv[])
{
// 1. 创建监听套接字(用于接收客户端连接请求)
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1) // 套接字创建失败
{
perror("socket error");
exit(1);
}
// 2. 配置服务器地址结构体(绑定端口和IP)
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(9999);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
// 3. 设置端口复用(避免服务器重启时"端口被占用"的错误)
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 4. 绑定监听套接字到指定IP和端口
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if(ret == -1)
{
perror("bind error");
exit(1);
}
// 5. 将监听套接字设为监听状态
ret = listen(lfd, 64);
if(ret == -1)
{
perror("listen error");
exit(1);
}
// 6. 创建epoll实例
int epfd = epoll_create(100);
if(epfd == -1)
{
perror("epoll_create");
exit(0);
}
// 7. 向epoll实例中添加"监听fd"(ET模式)
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // EPOLLIN(读事件)+ EPOLLET(ET模式)
ev.data.fd = lfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1)
{
perror("epoll_ctl");
exit(0);
}
// 8. 定义epoll_wait的输出数组
struct epoll_event evs[1024];
int size = sizeof(evs) / sizeof(struct epoll_event);
// 9. 持续检测epoll实例中的fd状态
while(1)
{
int num = epoll_wait(epfd, evs, size, -1);
printf("num = %d\n", num);
for(int i=0; i<num; ++i)
{
int curfd = evs[i].data.fd;
// 10. 处理"监听fd就绪"(有新客户端连接)
if(curfd == lfd)
{
int cfd = accept(curfd, NULL, NULL);
// 【关键修改1】将通信fd设置为非阻塞(ET模式必须)
set_nonblock(cfd);
// 将新的通信fd添加到epoll实例(ET模式)
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = cfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
}
// 11. 处理"通信fd就绪"(客户端发送数据/断开连接)
else
{
// 【关键修改2】ET模式核心:循环读取直到无数据(EAGAIN)
char buf[5]; // 保留5字节缓冲区,便于观察分段读取
while(1) { // 循环读取,直到recv返回EAGAIN(无数据)
memset(buf, 0, sizeof(buf));
// recv读取数据(非阻塞模式下,无数据时返回-1,errno=EAGAIN)
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == 0) // len=0表示客户端主动断开连接
{
printf("客户端已经断开了连接\n");
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd);
break; // 退出循环,处理下一个fd
}
else if(len > 0) // 读取到数据
{
printf("read buf = %s\n", buf);
// 转大写
for(int j = 0; j < len; j++)
{
buf[j] = toupper(buf[j]);
}
printf("after buf = %s\n", buf);
// 发送大写后的数据
send(curfd, buf, len, 0);
}
else // len=-1表示读取出错/无数据
{
// 【关键判断】errno=EAGAIN:非阻塞模式下无数据可读(正常情况)
if(errno == EAGAIN)
{
printf("当前fd数据已全部读取完毕\n");
break; // 退出循环,本次数据处理完成
}
else // 真正的读取错误(比如连接异常)
{
perror("recv error");
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd);
break;
}
}
} // 循环读取结束
}
}
}
return 0;
}
5.多线程版 epoll ET 非阻塞服务端代码
// 引入必要的头文件:新增pthread线程相关头文件
#include <stdio.h>
#include <ctype.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#include <pthread.h> // 【新增】多线程头文件
// 【新增】线程参数结构体:传递通信fd和epoll句柄(避免全局变量,学习版规范)
typedef struct
{
int cfd; // 通信文件描述符
int epfd; // epoll实例句柄
} ThreadData;
// 1. 非阻塞设置函数(保留原有逻辑)
void set_nonblock(int fd)
{
int flag = fcntl(fd, F_GETFL);
if (flag == -1)
{
perror("fcntl F_GETFL error");
exit(1);
}
if (fcntl(fd, F_SETFL, flag | O_NONBLOCK) == -1)
{
perror("fcntl F_SETFL error");
exit(1);
}
}
// 【新增】2. 线程处理函数:处理单个通信fd的读写逻辑(ET非阻塞+循环读取)
// 注意:pthread_create要求线程函数参数为void*,返回值为void*
void* handle_client(void* arg)
{
ThreadData* data = (ThreadData*)arg;
int cfd = data->cfd;
int epfd = data->epfd;
free(data); // 释放参数内存(避免内存泄漏)
// 设置线程分离:线程结束后自动释放资源,无需主线程pthread_join
pthread_detach(pthread_self());
char buf[5]; // 保留5字节缓冲区,便于观察分段读取
while (1)
{
memset(buf, 0, sizeof(buf));
int len = recv(cfd, buf, sizeof(buf), 0);
if (len == 0) // 客户端断开连接
{
printf("线程[%ld]:客户端断开连接,fd=%d\n", pthread_self(), cfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, cfd, NULL); // 从epoll删除fd
close(cfd); // 关闭通信fd
break;
}
else if (len > 0) // 读取到数据,转大写后发送
{
printf("线程[%ld]:read buf = %s\n", pthread_self(), buf);
for (int j = 0; j < len; j++)
{
buf[j] = toupper(buf[j]);
}
printf("线程[%ld]:after buf = %s\n", pthread_self(), buf);
send(cfd, buf, len, 0);
}
else // len == -1,判断是否为无数据可读
{
if (errno == EAGAIN)
{
printf("线程[%ld]:fd=%d数据已全部读取完毕\n", pthread_self(), cfd);
break; // 无数据,退出循环,线程结束
}
else // 真正的读取错误
{
perror("线程 recv error");
epoll_ctl(epfd, EPOLL_CTL_DEL, cfd, NULL);
close(cfd);
break;
}
}
}
return NULL;
}
// server:多线程epoll ET模式非阻塞服务端(学习版)
int main(int argc, const char* argv[])
{
// 1. 创建监听套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket error");
exit(1);
}
// 2. 配置服务器地址
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(9999);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
// 3. 端口复用
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 4. 绑定
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if (ret == -1)
{
perror("bind error");
exit(1);
}
// 5. 监听
ret = listen(lfd, 64);
if (ret == -1)
{
perror("listen error");
exit(1);
}
// 6. 创建epoll实例
int epfd = epoll_create(100);
if (epfd == -1)
{
perror("epoll_create");
exit(0);
}
// 7. 添加监听fd到epoll(ET模式)
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = lfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if (ret == -1)
{
perror("epoll_ctl");
exit(0);
}
// 8. epoll_wait事件数组
struct epoll_event evs[1024];
int size = sizeof(evs) / sizeof(struct epoll_event);
// 9. 主线程:持续检测epoll事件
while (1)
{
int num = epoll_wait(epfd, evs, size, -1);
printf("主线程:epoll就绪fd数量 num = %d\n", num);
for (int i = 0; i < num; ++i)
{
int curfd = evs[i].data.fd;
// 处理监听fd(新客户端连接)
if (curfd == lfd)
{
int cfd = accept(curfd, NULL, NULL);
set_nonblock(cfd); // 通信fd设为非阻塞
// 将通信fd添加到epoll(ET模式)
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = cfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if (ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
printf("主线程:新客户端连接,cfd=%d\n", cfd);
}
// 处理通信fd(客户端发数据):创建子线程处理
else
{
// 准备线程参数
ThreadData* data = (ThreadData*)malloc(sizeof(ThreadData));
data->cfd = curfd;
data->epfd = epfd;
// 创建子线程处理该fd的读写
pthread_t tid;
ret = pthread_create(&tid, NULL, handle_client, (void*)data);
if (ret != 0)
{
perror("pthread_create error");
free(data); // 创建失败,释放参数内存
continue;
}
printf("主线程:创建线程[%ld]处理fd=%d\n", tid, curfd);
}
}
}
close(lfd);
close(epfd);
return 0;
}
5.1关键修改点解释(学习核心)
(1)多线程核心设计思路
- 主线程:只负责 epoll 事件检测(监听新连接、检测通信 fd 就绪),不处理具体的读写逻辑,保证主线程的高效性;
- 子线程:每个就绪的通信 fd 对应一个子线程,负责该 fd 的循环读取、转大写、发送,以及断开连接处理;
- 优势:epoll 解决 “IO 多路复用” 问题,多线程解决 “单 fd 读写耗时” 问题,结合后提升并发处理能力。
(2)线程相关关键修改
①新增线程参数结构体ThreadData
- 作用:传递通信 fd(cfd)和 epoll 句柄(epfd),避免使用全局变量(学习版规范);
- 注意:参数通过
malloc分配,子线程中用完后需free,避免内存泄漏。
②线程处理函数handle_client
- 核心逻辑:保留原有 ET 模式非阻塞的循环读取逻辑,只是放到子线程中;
- 线程分离:
pthread_detach(pthread_self()),让线程结束后自动释放资源,无需主线程调用pthread_join(避免主线程阻塞); - 线程 ID 打印:
pthread_self()获取当前线程 ID,便于观察多线程处理过程。
③主线程创建子线程
- 时机:检测到通信 fd 就绪时;
- 函数:
pthread_create创建线程,传入handle_client作为处理函数,data作为参数; - 错误处理:创建线程失败时,释放参数内存,避免泄漏。
(3)保留的核心特性
- 通信 fd 仍为非阻塞模式(ET 模式要求);
- 子线程内循环读取直到
EAGAIN(ET 模式要求); - 保留 5 字节缓冲区,便于观察分段读取过程。
5.2编译 & 运行说明
(1)编译(必须加-pthread链接线程库)
gcc -o tcp_server_thread tcp_server_thread.c -pthread
(2)运行效果(客户端输入helloworld;123456
主线程:epoll就绪fd数量 num = 1
主线程:新客户端连接,cfd=5
主线程:epoll就绪fd数量 num = 1
主线程:创建线程[140123456784128]处理fd=5
线程[140123456784128]:read buf = hello
线程[140123456784128]:after buf = HELLO
线程[140123456784128]:read buf = world
线程[140123456784128]:after buf = WORLD
线程[140123456784128]:read buf = ;1234
线程[140123456784128]:after buf = ;1234
线程[140123456784128]:read buf = 56
线程[140123456784128]:after buf = 56
线程[140123456784128]:fd=5数据已全部读取完毕
5.3学习要点总结
- 多线程 + epoll 的核心配合:
- 主线程:epoll 检测事件(IO 多路复用),负责 “分发任务”;
- 子线程:处理具体的 fd 读写(CPU/IO 操作),负责 “执行任务”。
- ET 模式非阻塞的核心要求不变:
- 通信 fd 必须设为非阻塞;
- 必须循环读取直到
EAGAIN; - 多线程只是改变了 “处理位置”,未改变 ET 模式的核心规则。
- 线程安全注意(学习版简化):
- 本代码未加互斥锁:
printf是线程安全的(Linux 下),但如果涉及共享资源(如全局计数器),需加pthread_mutex_t互斥锁; - 实际开发中,可使用线程池替代 “每次创建新线程”,避免频繁创建销毁线程的开销(学习版先理解基础)。
- 本代码未加互斥锁:
- 资源管理:
- 子线程中需释放参数内存(
free(data)); - 客户端断开时,需从 epoll 删除 fd 并关闭,避免文件描述符泄漏。
- 子线程中需释放参数内存(
参考资料:IO多路转接(复用)之epoll

浙公网安备 33010602011771号