【Linux高级全栈开发】2.1高性能网络-网络编程——2.1.1 网络IO与IO多路复用——select/poll/epoll
【Linux高级全栈开发】2.1高性能网络-网络编程
高性能网络学习目录
基础内容(两周完成):
-
2.1网络编程
- 2.1.1多路复用select/poll/epoll
- 2.1.2事件驱动reactor
- 2.1.3http服务器的实现
-
2.2网络原理
- 百万并发
- PosixAPI
- QUIC
-
2.3协程库
- NtyCo的实现
-
2.4dpdk
- 用户态协议栈的实现
-
2.5高性能异步io机制
项目内容(两周完成):
- 9.1 KV存储项目
- 9.2 RPC项目
- 9.3 DPDK项目
2.1.1 网络IO与IO多路复用——select/poll/epoll
1 基础知识
1.1 什么是网络IO
网络 I/O(Input/Output) 是指计算机通过网络与其他设备进行数据交换的过程。在编程中,网络 I/O 主要涉及:
- 建立连接:通过
socket()
、bind()
、listen()
、accept()
等系统调用创建 TCP/UDP 连接。 - 数据传输:使用
send()
、recv()
等函数发送和接收数据。 - 连接管理:处理连接建立、断开、超时等状态。
网络 I/O 的特点是耗时较长(相比 CPU 操作),因为数据需要在网络中传输,可能受到带宽、延迟等因素影响。
1.2 「一请求一线程」模型的缺点
-
线程资源开销大:频繁创建 / 销毁线程会消耗大量 CPU 时间
-
可扩展性差,线程数量受限:操作系统对线程数量有限制(例如 Linux 默认最大线程数约为 32,000),无法支持海量连接(如 C10K 问题),在高并发(如 10 万 + 连接)下,线程开销会成为瓶颈
-
某个线程崩溃可能导致整个进程退出,影响其他连接。
因此,「一请求一线程」模型存在严重的性能瓶颈,仅适合连接数少、I/O 耗时短的场景,性能不适合高并发场景,改进方式是:
- 使用线程池(预先创建固定数量的线程,避免频繁创建 / 销毁线程);
- 基于事件驱动的模型(使用
select()
、poll()
、epoll()
(Linux)或kqueue()
(BSD/macOS)等机制,让单个线程处理多个连接); - 异步 I/O(Asynchronous I/O)(使用
aio_read()
、aio_write()
等接口,让内核完成 I/O 后通知应用程序);
1.3 socket与文件描述符的关联
-
在 Unix 及类 Unix 系统(如 Linux、macOS 等)中,“一切皆文件” 是一个重要的概念,这意味着包括网络套接字、设备、管道等在内的多种资源都可以像普通文件一样进行操作,而文件描述符(fd)就是用于标识这些资源的一个非负整数。
-
文件描述符是一个非负整数,它是操作系统内核为了标识进程打开的文件或其他资源而分配的一个索引值。例如,当你打开一个普通文件时,
open
函数会返回一个文件描述符,后续对该文件的读写操作(如read
、write
等)都需要使用这个文件描述符作为参数。 -
在网络编程中,使用
socket
函数创建一个套接字时,操作系统会在内核中为这个套接字分配相应的数据结构,并返回一个文件描述符。这个文件描述符可以用于后续的操作,如connect
(客户端连接服务器)、send
(发送数据)、recv
(接收数据)等,就如同对普通文件进行读写操作一样。 -
综上所述,在 Unix 及类 Unix 系统中,当创建 TCP 连接时,操作系统通过分配文件描述符来标识这个连接,进程通过这个文件描述符来对 TCP 连接进行各种操作,从而实现网络通信。可以使用
ls /dev/fd
查看已经使用的文件描述符
1.3+ 如何修改文件描述符相关参数
- 调整进程级 FD 限制(
ulimit
)
每个进程默认最多可打开 1024 个 FD,可通过以下方式临时修改:
# 查看当前限制
ulimit -n
# 临时提高限制(仅对当前 shell 及子进程有效)
ulimit -n 4096
# 永久修改:编辑 /etc/security/limits.conf,添加以下内容
your_username hard nofile 65535
your_username soft nofile 65535
- 调整系统级 FD 限制(
fs.file-max
)
系统全局最大 FD 数量由 fs.file-max
控制:
# 查看当前值
cat /proc/sys/fs/file-max
# 临时修改(重启失效)
sysctl -w fs.file-max=1000000
# 永久修改:编辑 /etc/sysctl.conf,添加或修改
fs.file-max = 1000000
# 使配置生效
sysctl -p
- 调整网络套接字
TIME_WAIT
参数(可选)
网络套接字(socket)的 TIME_WAIT
状态是 TCP 协议层面的延迟关闭机制,默认持续时间是 2 倍最大段生存时间(MSL),通常为 60 秒,但这仅影响套接字资源,不影响 FD 本身的回收。
若需减少网络套接字占用的资源,可调整 TIME_WAIT
相关参数:
# 启用 TCP 快速回收(可能影响网络稳定性)
sysctl -w net.ipv4.tcp_tw_recycle=1
# 缩短 TIME_WAIT 超时时间(默认 60 秒)
sysctl -w net.ipv4.tcp_fin_timeout=30
# 允许重用处于 TIME_WAIT 的套接字
sysctl -w net.ipv4.tcp_tw_reuse=1
1.4 多路复用select/poll/epoll
多路复用(Multiplexing)是一种让单个进程同时监视多个文件描述符(如套接字、管道等)的技术,当其中任何一个或多个文件描述符变为可读或可写状态时,进程能够及时处理。
select
函数
-
概念:
select
是最早出现的 I/O 多路复用函数,它允许进程监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知进程进行相应的读写操作。 -
函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
nfds
:需要监视的最大文件描述符值加 1。readfds
、writefds
、exceptfds
:分别是被监视的读、写和异常处理的文件描述符集合。timeout
:超时时间,控制select
函数的阻塞行为。
-
工作流程:
- 调用
select
函数前,需要先将要监视的文件描述符添加到对应的集合(readfds
、writefds
等)中。 - 调用
select
函数后,进程会被阻塞,直到有文件描述符就绪或超时。 select
返回后,需要遍历所有可能的文件描述符,检查哪个文件描述符在就绪集合中,然后进行相应的处理。
- 调用
-
缺点:
- 单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 系统上一般为 1024。
- 每次调用
select
时都需要将文件描述符集合从用户空间拷贝到内核空间,开销较大。 - 当
select
返回后,需要遍历所有文件描述符来找到就绪的那些,效率较低。
poll
函数
-
概念:
poll
是为了克服select
的一些缺点而设计的,它和select
类似,也是用于监视多个文件描述符的状态变化。 -
函数原型
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds
:一个struct pollfd
类型的数组,每个元素包含文件描述符、要监视的事件和发生的事件。nfds
:数组中元素的个数。timeout
:超时时间(毫秒)。
-
工作流程:
- 准备一个
struct pollfd
数组,每个元素指定要监视的文件描述符和事件。 - 调用
poll
函数,进程会被阻塞直到有事件发生或超时。 poll
返回后,遍历pollfd
数组,检查每个元素的revents
字段,确定哪些事件发生了,然后进行相应处理。
- 准备一个
-
优点:
- 没有最大文件描述符数量的限制(仅受限于系统资源)。
- 使用
pollfd
结构而不是位掩码来表示文件描述符集合,更加直观和灵活。
-
缺点:
- 仍然需要遍历所有的文件描述符来找到就绪的那些,在文件描述符数量很多时效率依然不高。
- 每次调用
poll
时,仍需要将pollfd
数组从用户空间拷贝到内核空间。
epoll
函数
-
事件驱动机制:
epoll
使用事件通知机制,当文件描述符就绪时,主动通知应用程序,无需遍历所有描述符。 -
红黑树 + 链表:
- 红黑树:存储所有被监视的文件描述符。
- 链表:存储就绪的文件描述符,避免遍历所有描述符。
-
三种系统调用
int epoll_create(int size); // 创建 epoll 实例(size 参数已废弃,填大于 0 的值即可) int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除事件 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件发生
EPOLL_CTL_ADD
EPOLL_CTL_DEL
EPOLL_CTL_MOD
IO管理的三种操作epoll_event
就是处理数据的小车maxevents
是小车有多大
-
工作模式
-
水平触发(LT,Level Triggered,默认模式):
- 只要文件描述符就绪(如可读),
epoll_wait
就会一直返回该事件。 - 若应用程序未处理完数据,下次调用
epoll_wait
仍会触发。
- 只要文件描述符就绪(如可读),
-
边缘触发(ET,Edge Triggered):
- 仅在文件描述符状态变化(如从无数据变为有数据)时触发一次。
- 要求应用程序必须一次性处理完所有数据,否则剩余数据不会再次通知。
-
-
优点:
epoll
无最大连接数限制(仅受系统文件描述符上限约束)。- 时间复杂度为 O (1)(
poll
为 O (n)),适合高并发场景。
三者对比
特性 | select | poll | epoll |
---|---|---|---|
文件描述符上限 | 受 FD_SETSIZE 限制(通常为 1024) | 无硬性限制,取决于系统资源 | 无上限,仅受系统资源限制 |
数据结构 | 位掩码(bitmap) | 数组(struct pollfd ) | 红黑树(存储监视对象) + 链表(就绪列表) |
事件通知机制 | 轮询(遍历所有描述符) | 轮询(遍历所有描述符) | 事件驱动(回调函数 + 就绪链表) |
内存拷贝 | 每次调用需从用户空间到内核空间拷贝 | 每次调用需从用户空间到内核空间拷贝 | 仅在注册事件时拷贝一次(epoll_ctl ) |
时间复杂度 | O(n) | O(n) | O (1)(仅遍历就绪链表) |
工作模式 | 仅支持水平触发(LT) | 仅支持水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
适用场景 | 小规模连接(描述符少)Windows系统 | 小规模连接(描述符少)(连接数中等) | 大规模高并发(如百万级连接)(如 Nginx、Redis 等高性能服务器) |
- select/poll 的瓶颈
- 用户态与内核态频繁拷贝:每次调用
select/poll
时,需将全部描述符集合从用户空间拷贝到内核空间。 - 轮询遍历开销:返回后需遍历所有描述符以找到就绪者,时间复杂度为 O (n)。
- 描述符数量限制:
select
受FD_SETSIZE
限制,难以处理大量连接。
- epoll 的优化
- 事件回调机制:内核通过回调函数直接将就绪描述符放入链表,无需遍历所有描述符。(通俗来说,就是根据就绪事件一个一个创建,一个一个删除,而不是一开始就将整集全部创建完了)
- 内存映射:使用
mmap
实现用户空间和内核空间的内存共享,避免频繁拷贝。 - 零拷贝设计:仅在注册事件(
epoll_ctl
)时拷贝一次数据,后续epoll_wait
无需重复拷贝。 - 总结:
epoll
通过内核级的事件表和回调机制,实现了从 “被动轮询” 到 “主动通知” 的质变,这使其成为现代高性能网络服务器的核心技术之一
1.5 IO事件触发——LT/ET
-
LT 和 ET 的核心区别
-
水平触发(LT)
-
触发条件:只要文件描述符(FD)处于就绪状态(如可读缓冲区有数据),就会持续触发事件。
-
特性:
- 事件会重复通知,直到应用程序处理完所有数据。
- 编程简单,不易遗漏事件(但可能导致不必要的系统调用)。
-
-
边缘触发(ET)
-
触发条件:仅在 FD 状态变化时触发一次(如数据从无到有)。
-
特性:
- 事件仅触发一次,必须一次性处理完所有数据(否则剩余数据不会再通知)。
- 要求应用程序使用非阻塞 I/O,并在事件触发后尽可能读 / 写完整数据。
-
-
-
select/poll仅支持 LT 模式;
-
epoll同时支持 LT 和 ET:默认是 LT 模式,通过
EPOLLET
标志可启用 ET 模式。 -
为什么 ET 模式要求非阻塞 I/O?
-
阻塞 I/O 与 ET 的矛盾:
- 若使用阻塞 I/O,当应用程序在 ET 模式下读取数据时,若数据未读完,线程会被阻塞在
read
调用中。 - 此时内核认为应用程序正在处理数据,不会再次触发事件,导致剩余数据被 “饿死”。
- 若使用阻塞 I/O,当应用程序在 ET 模式下读取数据时,若数据未读完,线程会被阻塞在
-
正确做法:
- 设置 FD 为非阻塞模式(如
fcntl(fd, F_SETFL, O_NONBLOCK)
)。- 在事件触发后,循环读取 / 写入数据,直到返回
EAGAIN
(表示缓冲区已空 / 满)。
- 在事件触发后,循环读取 / 写入数据,直到返回
- 设置 FD 为非阻塞模式(如
-
场景 | LT 模式 | ET 模式 |
---|---|---|
编程复杂度 | 低(无需循环处理) | 高(必须循环处理 + 非阻塞 I/O) |
性能 | 中等(可能有冗余通知) | 高(减少系统调用次数) |
适用场景 | 简单应用(如小规模连接) | 高性能服务器(如 Nginx、Redis) |
数据处理要求 | 可部分处理数据 | 必须一次性处理完所有数据 |
2 「代码实现」TCP连接(一请求一线程方式)
2.1 「一请求一线程」实现过程
#include <stdio.h>
// #include <winsock2.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#include <string.h>int main() {int sockfd = socket(AF_INET, SOCK_STREAM, 0);/*struct sockaddr_in {short sin_family; // 地址族,通常为 AF_INETunsigned short sin_port; // 端口号struct in_addr sin_addr; // IPv4 地址char sin_zero[8]; // 填充字节,使 sockaddr_in 和 sockaddr 大小相同};struct in_addr {unsigned long s_addr; // IPv4 地址,以网络字节序存储};*///sockaddr_in的结构体struct sockaddr_in servaddr;servaddr.sin_family = AF_INET;// htonl是 “Host to Network Long” 的缩写,// 其作用是将一个 32 位的无符号整数从主机字节序转换为网络字节序。servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // htons是 “Host to Network Short” 的缩写,// 用于将一个 16 位的无符号整数从主机字节序转换为网络字节序servaddr.sin_port = htons(2000);// 绑定文件描述符和地址,并检错// 注意这里第三个参数是结构体的长度而不是指针的长度if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {printf("bind failed: %s\n", strerror(errno));}// 开始监听listen(sockfd, 10);printf("listen finished\n");struct sockaddr_in clientaddr;socklen_t len = sizeof(clientaddr);printf("accept\n");int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);printf("accept finished\n");char buffer[1024] = {0};int count = recv(clientfd, buffer, 1024, 0);printf("RECV: %s\n", buffer);count = send(clientfd, buffer, count, 0);// 把程序阻塞在这里不要往下走getchar();printf("exit\n");return 0;
}
TIPS:
如果不知道某个函数的头文件在哪里,可以用
man
来查找该函数的手册,比如man strerror
发现该函数在string.h
中如果需要查看某个网络端口的服务有没有启动,可以用
netstat -anop | grep 2000(端口号)
来查询该服务有没有启动
- 3306 mysql端口
- 6709 redis端口
cv@ubuntu:~$ netstat -anop | grep 2000
(Not all processes could be identified, non-owned process infowill not be shown, you would have to be root to see it all.)
tcp 0 0 0.0.0.0:2000 0.0.0.0:* LISTEN - off (0.00/0/0)
tcp 80 0 192.168.21.129:2000 192.168.21.1:57037 ESTABLISHED - off (0.00/0/0)
初代代码实现了一个简单的 TCP 服务器,它创建套接字、绑定到本地端口 2000 并监听连接,接受一个客户端连接后接收其发送的数据并原样返回,最后等待用户输入才退出程序。需要解决的问题:
-
现象观察:如果此时再启动一个服务器段的,network程序连接网关,会发现端口占用:
bind failed: Address already in use
如果此时再用网络助手连接2000端口,会出现以下现象,端口没有被占用,连接成功:
cv@ubuntu:~$ netstat -anop | grep 2000 (Not all processes could be identified, non-owned process infowill not be shown, you would have to be root to see it all.) tcp 0 0 0.0.0.0:2000 0.0.0.0:* LISTEN - off (0.00/0/0) tcp 80 0 192.168.21.129:2000 192.168.21.1:57037 ESTABLISHED - off (0.00/0/0)
-
原因是一个端口在同一时刻只能被一个进程绑定,当服务器在某个端口上进行监听时,它可以同时接受多个客户端的连接。
-
每当有一个客户端请求连接到服务器的指定端口时,服务器就会创建一个新的连接套接字(在代码中通常用新的文件描述符表示)来与该客户端进行通信,而服务器监听的端口仍然保持监听状态,继续接受其他客户端的连接请求。
-
-
程序优化:端口被绑定以后,不能再次被绑定。(如何在一个端口建立多个连接)
-
因此建立一个while循环,建立一次连接,就创建一个新的fd。
while (1) {printf("accept\n");int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);printf("accept finshed\n");char buffer[1024] = {0};int count = recv(clientfd, buffer, 1024, 0);printf("RECV: %s\n", buffer);count = send(clientfd, buffer, count, 0);printf("SEND: %d\n", count);}
-
-
程序优化:进入listen可以被连接,需要马上收发一次,然后再建立新的连接
-
可以每次建立连接时新开一个线程,专门处理这个线程内的连接
while (1) {printf("accept\n");int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);printf("accept finished\n");pthread_t pthread_id;pthread_create(&pthread_id, NULL, client_thread, &clientfd);}
-
-
程序优化:发送消息后只能收发一次
-
recv处加上一个while循环
void *client_thread(void *arg) {int clientfd = *(int*)arg;while (1) {char buffer[1024] = {0};int count = recv(clientfd, buffer, 1024, 0);if (count == 0) { // disconnectprintf("client disconnect: %d\n", clientfd);close(clientfd);break;}// parserprintf("RECV: %s\n", buffer);count = send(clientfd, buffer, count, 0);printf("SEND: %d\n", count);} }
-
-
程序优化:客户端断开后,程序进入死循环
-
加入处理断开
recv()返回0
的逻辑void *client_thread(void *arg) {int clientfd = *(int*)arg;while (1) {char buffer[1024] = {0};int count = recv(clientfd, buffer, 1024, 0);// 加入处理断开 `recv()返回0` 的逻辑if (count == 0) { // disconnectprintf("client disconnect: %d\n", clientfd);close(clientfd);break;}// parserprintf("RECV: %s\n", buffer);count = send(clientfd, buffer, count, 0);printf("SEND: %d\n", count);} }
-
-
现象观察:文件描述符fd依次递增
cv@ubuntu:~/share/0voice/2.High_Performance_Network/2.1.1Network_Io$ sudo ./network listen finished: 3 accept accept finished: 4 accept accept finished: 5 accept accept finished: 6 accept RECV: Welcome to NetAssist SEND: 20
ls /dev/fd
目录下的文件是文件描述符的符号链接,输出为0 1 2
,分别代表标准输入、标准输出和标准错误输出,它们是系统默认的文件描述符, 通过ls /dec/stdin -l
可以查看他们的信息- 因为文件描述符fd的数量是有限制的,所以实现百万并发的时候需要设置
open files
的数量,用ulimit -a
查看
2.2 「select多路复用」实现过程
核心逻辑: 通过 select
监听套接字 sockfd
的可读事件,当有数据可读时(如客户端连接或数据到达),select
返回并通知程序处理。
// 定义主要\工作文件描述符集合fd_set rfds, rset;// 清空文件描述符集合 rfdsFD_ZERO(&rfds);// 将套接字 sockfd 添加到 rfds 集合中,表示需要监听该套接字的可读事件。FD_SET(sockfd, &rfds);// select 的第一个参数需要传入最大文件描述符值 + 1。// 由于当前集合中只有 sockfd,因此 maxfd 初始化为 sockfd。int maxfd = sockfd;while (1) {// 每次循环开始时,将主集合 rfds 复制到工作集合 rset,因为 select 会修改工作集合。rset = rfds;int nready = select(maxfd+1, &rset, NULL, NULL, NULL);// accept部分,检查sockfd是否可读集合if (FD_ISSET(sockfd, &rset)) {// 接受新的客户端连接int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);// 将新客户端的文件描述符添加到主监听集合中FD_SET(clientfd, &rfds);// 更新maxfd为所有监听描述符中的最大值if (clientfd > maxfd) maxfd = clientfd;}// recv部分int i = 0;for (i = sockfd + 1; i <= maxfd; i++) {if (FD_ISSET(i, &rset)) {char buffer[1024] = {0};int count = recv(i, buffer, 1024, 0);// 这里应该连接fd的值为iif (count == 0) { // disconnectprintf("client disconnect: %d\n", i);close(i);// 断开时在集合中应该把客户端的fd清空FD_CLR(i, &rfds);break;}// parserprintf("RECV: %s\n", buffer);count = send(i, buffer, count, 0);printf("SEND: %d\n", count);}}}
-
代码解释:
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
- 调用
select
函数监听文件描述符集合rset
中的可读事件。 - 参数说明:
maxfd+1
:指定监听的文件描述符范围(从 0 到maxfd
)。&rset
:监听可读事件的文件描述符集合。NULL
:不监听可写事件。NULL
:不监听异常事件。NULL
:阻塞模式,直到有文件描述符就绪。
- 返回值
nready
:就绪的文件描述符总数。 select
返回后,需要遍历文件描述符集合检查哪些就绪
- 调用
-
FD_ZERO
- 用法:
FD_ZERO(fd_set *set)
。 - 作用:将
fd_set
类型的集合set
初始化为空集,即把集合中表示各个文件描述符的位都清零 ,确保集合中不包含任何文件描述符。
- 用法:
-
FD_SET
- 用法:
FD_SET(int fd, fd_set *set)
。 - 作用:把指定的文件描述符
fd
添加到集合set
中 ,也就是将集合中对应fd
的位设置为 1 ,表示该文件描述符在集合内,后续可对其进行相关状态检测。
- 用法:
-
FD_CLR
- 用法:
FD_CLR(int fd, fd_set *set)
。 - 作用:从集合
set
中移除指定的文件描述符fd
,即将集合中对应fd
的位设置为 0 ,表示该文件描述符不在集合内了。
- 用法:
-
FD_ISSET
- 用法:
FD_ISSET(int fd, fd_set *set)
。 - 作用:用于检测文件描述符
fd
是否在集合set
中。如果fd
在集合set
中,返回值为非零(表示真) ;如果不在集合中,返回值为 0(表示假) 。常配合select
等函数使用,在select
返回后,判断哪些文件描述符满足了相应条件。
- 用法:
-
fd_set
是一种用于在多路复用 I/O 操作中存储文件描述符集合的数据结构 ,常与select
函数配合使用。fd_set
是一个 bit 位集合,它采用类似位图(Bitmap)的方式,其中每一位对应一个文件描述符。若某一位被置为 1 ,代表对应的文件描述符在集合内;若为 0 ,则表示不在集合内。- 比如系统中文件描述符范围是 0 - 1023 ,
fd_set
就有 1024 个位与之对应 ,某位为 1 代表对应文件描述符在集合内,为 0 则不在。
-
缺点:
- 单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 系统上一般为 1024。
- 每次调用
select
时都需要将文件描述符集合fd_set
从用户空间拷贝到内核空间,开销较大。 - 当
select
返回后,需要遍历所有文件描述符fd_set
来找到就绪的那些,效率较低。
2.3 「poll多路复用」实现过程
核心逻辑: 使用 poll
函数实现了一个简单的 TCP 服务器,它持续监听客户端连接和数据收发,当有新连接或数据到来时进行相应处理。
// 代码初始化了一个包含 1024 个 pollfd 结构的数组,并设置监听套接字 sockfd 关注可读事件(即新连接到来)。struct pollfd fds[1024] = {0};fds[sockfd].fd = sockfd;fds[sockfd].events = POLLIN;int maxfd = sockfd;while (1) {// poll 返回发生事件的文件描述符总数,存储在 nready 中int nready = poll(fds. maxfd+1, -1);// 当 sockfd 上有可读事件(POLLIN)发生时,表示有新的客户端连接if (fds[sockfd].revents & POLLIN) {// accept 函数接受连接并返回新的客户端套接字 clientfdint clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);fds[clientfd].fd = clientfd;fds[clientfd].events = POLLIN;// 更新 maxfd 为当前最大的文件描述符值if (clientfd > maxfd) maxfd = clientfd;}// 遍历所有可能有数据可读的客户端套接字(从 sockfd+1 到 maxfd)for (i = sockfd+1; i <= maxfd; i++) {// 当某个客户端套接字有可读事件时if (fds[i].revents & POLLIN) {// 使用 recv 接收数据int count = recv(i, buffer, 1024, 0);// 如果 recv 返回 0,表示客户端关闭连接,// 此时关闭套接字并从 pollfd 数组中移除(将 fd 设为 - 1,events 设为 0)if (count == 0) {close(i);fds[i].fd = -1;fds[i].events = 0;} else {send(i, buffer, count, 0);}}}
-
struct pollfd是poll函数使用的数据结构,包含三个成员:
fd
:文件描述符events
:要监听的事件(如POLLIN
表示可读事件)revents
:实际发生的事件(由poll
函数填充)
-
poll(fds, maxfd+1, -1)
- 第一个参数
fds
是要监听的文件描述符数组 - 第二个参数
maxfd+1
表示数组中有效元素的数量(从 0 到maxfd
) - 第三个参数
-1
表示无限等待,直到有事件发生 poll
返回发生事件的文件描述符总数,存储在nready
中
- 第一个参数
-
优点:
- 没有最大文件描述符数量的限制(仅受限于系统资源)。
- 使用
pollfd
结构而不是位掩码来表示文件描述符集合,更加直观和灵活。
-
缺点:
- 仍然需要遍历所有的文件描述符来找到就绪的那些,在文件描述符数量很多时效率依然不高。
- 每次调用
poll
时,仍需要将pollfd
数组从用户空间拷贝到内核空间。
2.4 「epoll多路复用」实现过程
核心逻辑: epoll
实现了一个高效的 TCP 服务器,通过事件驱动方式同时处理多个客户端连接的读写操作,避免了轮询开销。
// 创建一个 epoll 实例,返回文件描述符 epfd。// 传入参数必须为正数(历史上表示初始事件表大小)int epfd = epoll_create(1);// 注册监听事件struct epoll_event ev;ev.events = EPOLLIN; // 监听读事件ev.data.fd = sockfd; // 绑定监听套接字epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);while (1) {// 等待事件触发struct epoll_event events[1024] = {0};int nready = epoll_wait(epfd, events, 1024, -1);for (int i = 0; i < nready; i++) {int connfd = events[i].data.fd;// 当 sockfd 就绪时,表示有新连接if (connfd == sockfd) {int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);ev.events = EPOLLIN;ev.data.fd = clientfd;epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);} // 当客户端套接字就绪时,读取数据else if (events[i].events & EPOLLIN) {char buffer[1024] = {0};int count = recv(connfd, buffer, 1024, 0);if (count == 0) { // 客户端关闭连接close(connfd);epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);} else { // 正常数据接收send(connfd, buffer, count, 0);}// parserprintf("RECV: %s\n", buffer);count = send(i, buffer, count, 0);printf("SEND: %d\n", count);}}
-
int epfd = epoll_create(1)
创建一个 epoll 实例 -
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev)
:注册监听事件参数:epfd
:epoll_create
返回的句柄。EPOLL_CTL_ADD
:添加事件。sockfd
:要监听的套接字。&ev
:事件结构体,包含监听类型(EPOLLIN
)和自定义数据(data.fd
)。
-
epoll_wait(epfd, events, 1024, -1)
:等待事件触发参数epfd
:epoll
实例句柄。events
:输出参数,存储就绪事件的数组。1024
:数组最大容量。-1
:阻塞等待(直到有事件发生)。
-
关键点总结
-
高效事件通知:
epoll
使用事件表(而非轮询),仅返回就绪的文件描述符,适合处理大量连接。 -
水平触发模式(LT):默认模式下,只要数据未读完,
EPOLLIN
会持续触发。 -
数据结构:
struct epoll_event
包含events
(事件类型)和data
(自定义数据,通常存fd
)。
-
对比
poll
:epoll
无最大连接数限制(仅受系统文件描述符上限约束)。- 时间复杂度为 O (1)(
poll
为 O (n)),适合高并发场景。
-
2.5 「TCP连接」完整代码
- 为什么服务器多用linux,主要原因就是linux2.6版本以后引入了epoll,使得可以实现百万连接的服务器
#include <stdio.h>
// #include <winsock2.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/select.h>
#include <poll.h>
#include <sys/epoll.h>void *client_thread(void *arg) {int clientfd = *(int*)arg;while (1) {char buffer[1024] = {0};int count = recv(clientfd, buffer, 1024, 0);if (count == 0) { // disconnectprintf("client disconnect: %d\n", clientfd);close(clientfd);break;}// parserprintf("RECV: %s\n", buffer);count = send(clientfd, buffer, count, 0);printf("SEND: %d\n", count);}
}int main() {int sockfd = socket(AF_INET, SOCK_STREAM, 0);/*struct sockaddr_in {short sin_family; // 地址族,通常为 AF_INETunsigned short sin_port; // 端口号struct in_addr sin_addr; // IPv4 地址char sin_zero[8]; // 填充字节,使 sockaddr_in 和 sockaddr 大小相同};struct in_addr {unsigned long s_addr; // IPv4 地址,以网络字节序存储};*///sockaddr_in的结构体struct sockaddr_in servaddr;servaddr.sin_family = AF_INET;// htonl是 “Host to Network Long” 的缩写,// 其作用是将一个 32 位的无符号整数从主机字节序转换为网络字节序。servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // htons是 “Host to Network Short” 的缩写,// 用于将一个 16 位的无符号整数从主机字节序转换为网络字节序servaddr.sin_port = htons(2000);// 绑定文件描述符和地址,并检错// 注意这里第三个参数是结构体的长度而不是指针的长度if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {printf("bind failed: %s\n", strerror(errno));}// 开始监听listen(sockfd, 10);printf("listen finished: %d\n", sockfd);struct sockaddr_in clientaddr;socklen_t len = sizeof(clientaddr);#if 0printf("accept\n");int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);printf("accept finished\n");char buffer[1024] = {0};int count = recv(clientfd, buffer, 1024, 0);printf("RECV: %s\n", buffer);count = send(clientfd, buffer, count, 0);printf("SEND: %s\n", count);#elif 0
// 端口被绑定以后,不能再次被绑定。(如何在一个端口建立多个连接)
// 因此建立一个循环,进行一次收发,建立一次连接while (1) {printf("accept\n");int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);printf("accept finshed\n");char buffer[1024] = {0};int count = recv(clientfd, buffer, 1024, 0);printf("RECV: %s\n", buffer);count = send(clientfd, buffer, count, 0);printf("SEND: %d\n", count);}// 还有新的问题,和建立连接的顺序有关系,导致一些逻辑上的连接阻塞
// 可以每次建立连接时新开一个线程,专门处理这个线程内的连接
#elif 0 // 这就是一请求一线程的连接方式while (1) {printf("accept\n");int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);printf("accept finished: %d\n", clientfd);pthread_t pthread_id;pthread_create(&pthread_id, NULL, client_thread, &clientfd);}#elif 0// 定义主要\工作文件描述符集合fd_set rfds, rset;// 清空文件描述符集合 rfdsFD_ZERO(&rfds);// 将套接字 sockfd 添加到 rfds 集合中,表示需要监听该套接字的可读事件。FD_SET(sockfd, &rfds);// select 的第一个参数需要传入最大文件描述符值 + 1。// 由于当前集合中只有 sockfd,因此 maxfd 初始化为 sockfd。int maxfd = sockfd;while (1) {// 每次循环开始时,将主集合 rfds 复制到工作集合 rset,因为 select 会修改工作集合。rset = rfds;int nready = select(maxfd+1, &rset, NULL, NULL, NULL);// accept部分,检查sockfd是否可读集合if (FD_ISSET(sockfd, &rset)) {// 接受新的客户端连接int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);// 将新客户端的文件描述符添加到主监听集合中FD_SET(clientfd, &rfds);// 更新maxfd为所有监听描述符中的最大值if (clientfd > maxfd) maxfd = clientfd;}// recv部分int i = 0;for (i = sockfd + 1; i <= maxfd; i++) {if (FD_ISSET(i, &rset)) {char buffer[1024] = {0};int count = recv(i, buffer, 1024, 0);// 这里应该连接fd的值为iif (count == 0) { // disconnectprintf("client disconnect: %d\n", i);close(i);// 断开时在集合中应该把客户端的fd清空FD_CLR(i, &rfds);break;}// parserprintf("RECV: %s\n", buffer);count = send(i, buffer, count, 0);printf("SEND: %d\n", count);}}}#elif 0// 代码初始化了一个包含 1024 个 pollfd 结构的数组,并设置监听套接字 sockfd 关注可读事件(即新连接到来)。struct pollfd fds[1024] = {0};fds[sockfd].fd = sockfd;fds[sockfd].events = POLLIN;int maxfd = sockfd;while (1) {// poll 返回发生事件的文件描述符总数,存储在 nready 中int nready = poll(fds, maxfd+1, -1);// 当 sockfd 上有可读事件(POLLIN)发生时,表示有新的客户端连接if (fds[sockfd].revents & POLLIN) {// accept 函数接受连接并返回新的客户端套接字 clientfdint clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);fds[clientfd].fd = clientfd;fds[clientfd].events = POLLIN;// 更新 maxfd 为当前最大的文件描述符值if (clientfd > maxfd) maxfd = clientfd;}// 遍历所有可能有数据可读的客户端套接字(从 sockfd+1 到 maxfd)for (int i = sockfd+1; i <= maxfd; i++) {// 当某个客户端套接字有可读事件时if (fds[i].revents & POLLIN) {// 使用 recv 接收数据char buffer[1024] = {0};int count = recv(i, buffer, 1024, 0);// 如果 recv 返回 0,表示客户端关闭连接,// 此时关闭套接字并从 pollfd 数组中移除(将 fd 设为 - 1,events 设为 0)if (count == 0) {close(i);fds[i].fd = -1;fds[i].events = 0;} else {send(i, buffer, count, 0);}// parserprintf("RECV: %s\n", buffer);count = send(i, buffer, count, 0);printf("SEND: %d\n", count);}}}
#else// 创建一个 epoll 实例,返回文件描述符 epfd。// 传入参数必须为正数(历史上表示初始事件表大小)int epfd = epoll_create(1);// 注册监听事件struct epoll_event ev;ev.events = EPOLLIN; // 监听读事件ev.data.fd = sockfd; // 绑定监听套接字epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);while (1) {// 等待事件触发struct epoll_event events[1024] = {0};int nready = epoll_wait(epfd, events, 1024, -1);for (int i = 0; i < nready; i++) {int connfd = events[i].data.fd;// 当 sockfd 就绪时,表示有新连接if (connfd == sockfd) {int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);ev.events = EPOLLIN;ev.data.fd = clientfd;epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);} // 当客户端套接字就绪时,读取数据else if (events[i].events & EPOLLIN) {char buffer[1024] = {0};int count = recv(connfd, buffer, 1024, 0);if (count == 0) { // 客户端关闭连接close(connfd);epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);} else { // 正常数据接收send(connfd, buffer, count, 0);}// parserprintf("RECV: %s\n", buffer);count = send(i, buffer, count, 0);printf("SEND: %d\n", count);}}}#endif// 把程序阻塞在这里不要往下走getchar();printf("exit\n");return 0;
}
下一章:2.1.2 事件驱动reactor的原理与实现
https://github.com/0voice