回顾 read && write 系统调用
- 在编写 TCP 服务器时,应用层调用 read && write 的时候,read 的本质是把内核的接收缓冲区的数据拷贝到用户缓冲区,write 的本质是把用户缓冲区的数据拷贝到发送缓冲区。所以 read && write 本质就是拷贝函数。
- 一次 IO = 等待 + 拷贝。IO 时等待的是什么呢?等待的是条件成立。我们把这个条件称为读写事件 。条件成立称为读写事件就绪。对于 read,如果接收缓冲区不为空,那么读事件就绪。对于 write,如果内核发送缓冲区有剩余空间(send 和 write 的本质是将用户发送缓冲区的数据拷贝到内核发送缓冲区),那么写事件就绪。
- 什么叫做高效的 IO 呢?如果单位时间内,拷贝的字节数越多,即等待的时间越少,我们称 IO 的效率越高。几乎所有提高 IO 效率的策略,都是围绕如何减少等待的时间展开的
五种 IO 模型
1. 阻塞 IO(Blocking IO)
比喻 :你去餐厅点菜,然后一直坐在座位上,什么事都不干,眼巴巴等服务员把菜端上来,才离开。
流程:
用户进程发起
recvfrom系统调用。内核等待数据准备好(比如网络数据包到达)。
数据从内核拷贝到用户空间。
返回成功,进程继续运行。
特点 :简单,但进程在等待 IO 期间被挂起,浪费 CPU。
常见例子:传统 Java OIO、最简单的 socket 编程。
2. 非阻塞 IO(Non-blocking IO)
比喻:你每隔 3 秒跑去问服务员"菜好了吗?",没好就回来刷手机,反复轮询,直到菜好了才坐着等上菜。
流程:
用户进程发起
recvfrom,如果内核数据没准备好,立刻返回EWOULDBLOCK错误。进程不断轮询(重复系统调用)。
直到某次数据准备好了,再阻塞(或拷贝数据)完成。
特点:进程不会被挂起,但频繁轮询消耗 CPU,一般不单独使用。
3. 信号驱动 IO(Signal-driven IO)
比喻:你跟餐厅说"菜好了按门铃叫我",你回房间玩手机。门铃响了你再去取菜。
流程:
进程通过
sigaction注册信号处理函数,立即返回(不阻塞)。内核在数据准备好时发送
SIGIO信号。信号处理函数中调用
recvfrom读取数据。特点:不轮询,也不一直阻塞;但信号处理复杂,实际网络编程中用得很少(主要用于一些异步 I/O 场景或特定设备驱动)。
4. IO 多路转接(IO Multiplexing)
比喻 :你请一个服务员(
select/poll/epoll)帮你同时看着 10 个餐桌,哪个桌的菜好了就通知你,你再去那个桌处理吃饭(读取数据)。你一个人能同时"盯着"很多个 IO。流程:
进程调用
select或epoll,阻塞等待多个 socket 中的任意一个就绪。内核返回可读/可写的 socket 集合。
进程再调用
recvfrom从就绪 socket 读取数据。特点 :单线程可管理大量连接;阻塞点从单个 IO 变成多路复用器 。
常见例子:Redis、Nginx、Java NIO。
很多人疑惑:为什么它比阻塞 IO 好?因为阻塞 IO 要一个连接一个线程,多路复用只需一个线程监控所有连接,对高并发友好。
5. 异步 IO(Asynchronous IO)
比喻:你点完菜,告诉餐厅"菜做好后直接帮我打包送到我家"。你完全不用管什么时候做好、怎么取,最后直接拿到结果。
流程:
进程调用
aio_read系统调用,立即返回,不阻塞。内核自行等待数据、拷贝数据到用户空间(全过程不用进程参与)。
完成后通过信号或回调通知进程。
特点 :真正的异步 ------ 进程发起 IO 后可以做别的事,数据已在内核完成拷贝。
常用实现 :Windows 的 IOCP(完成端口),Linux 的 native AIO(对普通文件支持,网络 Socket 支持不完善),目前高性能网络库常用 io_uring(Linux 5.1+)。
对比总结表
| IO 模型 | 第一阶段(等待数据) | 第二阶段(拷贝数据到用户空间) | 是否阻塞进程 |
|---|---|---|---|
| 阻塞 IO | 阻塞 | 阻塞 | 全程阻塞 |
| 非阻塞 IO | 不阻塞(轮询) | 阻塞 | 仅在拷贝阶段阻塞 |
| IO 多路复用 | 阻塞在 select/epoll | 阻塞 | select & read 阻塞 |
| 信号驱动 IO | 不阻塞(信号通知) | 阻塞 | 仅在拷贝时阻塞 |
| 异步 IO | 不阻塞 | 不阻塞 | 全程不阻塞 |
五种 IO 模型(阻塞、非阻塞、多路复用、信号驱动、异步)主要是针对 socket 和管道等"慢设备"定义的。 磁盘文件的行为有很大差异。
同步 IO VS 异步 IO
同步 IO:只要进程参与了 IO 的过程,不管是参与了等待数据的过程还是拷贝数据的过程的 IO,都叫同步 IO。
异步 IO:进程全程不参与 IO 的过程,只获取 IO 的数据
POSIX 规范将:
阻塞 IO、非阻塞 IO、多路复用、信号驱动 IO 归类为 同步 IO(它们都参与了拷贝数据的过程)。
异步 IO 是真正的异步 IO。
注意:IO 的同步与线程的同步是两个完全不同的概念,它们毫无关系。
系统调用 - fcntl
- TCP 的读写操作的系统调用 recv/send 的第四个参数是 int flags,默认为 0 表示阻塞式,如果设置为 MSG_DONTWAIT ,表示非阻塞式。
- 用 open 打开文件时,也可以设置 flag 包含 O_NONBLOCK 来设置该文件描述符为非阻塞式。
- 但这些将文件描述符设置为非阻塞式的方法不通用,下面介绍一种通用的方法。
fcntl(file control)是 Linux/Unix 系统中一个非常重要的文件控制系统调用,用来对已打开的文件描述符执行各种操作。
cpp
#include <fcntl.h>
#include <unistd.h>
int fcntl(int fd, int cmd, ... /* arg */);
fd:文件描述符(由open、socket等返回)
cmd:操作命令(告诉fcntl要做什么)
arg:可选参数,取决于cmd
常用 cmd
| 功能类别 | 常用 cmd | 作用 |
|---|---|---|
| 复制文件描述符 | F_DUPFD、F_DUPFD_CLOEXEC |
复制 fd 到新的编号 |
| 获取/设置文件状态标志 | F_GETFL、F_SETFL |
读取/修改 O_NONBLOCK、O_APPEND 等标志 |
| 获取/设置文件锁 | F_GETLK、F_SETLK、F_SETLKW |
记录锁(进程间同步) |
| 获取/设置所有者 | F_GETOWN、F_SETOWN |
设置接收 SIGIO/SIGURG 信号的进程/进程组 |
| 获取/设置管道容量 | F_GETPIPE_SZ、F_SETPIPE_SZ |
修改管道缓冲区大小 |
如果要使用上面的系统调用设置一个文件描述符为非阻塞,我们只需要使用第二个功能获取/设置文件状态标志,常用 cmd 是 F_GETFL、F_SETFL。
使用方法:
cpp
// 1. 获取当前标志
int flags = fcntl(fd, F_GETFL);
if (flags < 0) {
perror("fcntl F_GETFL");
}
// 2. 添加 O_NONBLOCK(设为非阻塞)
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
// 将上面的步骤封装为函数 SetNoBlock
// 函数作用:传入文件描述符,设置为非阻塞
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
下面以用 read 系统调用来读取标准输入为例使用 SetNoBlock:
- 如果用 SetNoBlock 设置标准输入为非阻塞式:SetNoBlock(0);
- 此时用 read 读取标准输入,如果我们不输入数据,read 会立刻返回,返回值不是 0,而是 -1,即出错返回,我们用 strerrno 查看错误信息为:Resource temporarily unavailable,表示资源暂时未就绪。但我们认为资源暂时未就绪不是出错了,而是正常的非阻塞返回。
- 哪如果 read 真的出错了呢?我们该如何区分 read 是非阻塞返回还是其他出错返回呢?(因为 read 出错返回都是 -1)。答案是通过错误码 errno 区分。
cpp
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstdio>
#include <cerrno>
#include <cstring>
using namespace std;
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
cout << " set " << fd << " nonblock done" << endl;
}
int main()
{
char buffer[1024];
SetNonBlock(0);
sleep(1);
while (true)
{
// printf("Please Enter# ");
// fflush(stdout);
ssize_t n = read(0, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n - 1] = 0; // n - 1:去掉输入时的回车
cout << "echo : " << buffer << endl;
}
else if (n == 0)
{
cout << "read done" << endl;
break;
}
else
{
// 1. 设置成为非阻塞,如果底层fd数据没有就绪,
// recv/read/write/send, 返回值会以出错的形式返回
// 2. a. 真的出错 b. 底层没有就绪
// 3. 我怎么区分呢?通过errno区分!!!
if (errno == EWOULDBLOCK)
{
cout << "0 fd data not ready, try again!" << endl;
// do_other_thing();
sleep(1);
}
else
{
cerr << "read error, n = " << n << "errno code: "
<< errno << ", error str: " << strerror(errno) << endl;
}
// TODO 信号中断IO?
}
}
return 0;
}
IO 多路转接 - select
select 是 Linux 中实现 IO 多路转接 的系统调用,允许程序同时监视多个文件描述符,等待其中任意一个变为就绪状态(可读、可写或发生异常)。
cpp
#include <sys/select.h>
#include <sys/time.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
nfds:最大文件描述符值 + 1(比如要监听的 fd 有 1 2 3 4 5,那么 nfds 就是 6)
readfds:要监听的可读事件的 fd 集合,这是一个位图,下标从右往左从 0 开始,这是一个输入输出型参数,在输入时如果某一位为 1,表示要求内核监听 fd = 该位下标的文件描述符的读事件是否就绪,如果某一位为 0,表示要求内核不监听;在输出时,如果某一位为 1,表示 fd = 该位下标的文件描述符的读事件已经就绪,如果某一位为 0,表示不监听或没有就绪。如果不关心任何一个 fd 的读事件,该参数可以为 nullptr。
writefds:要监听的可写事件的 fd 集合,具体含义类比readfds。一个 fd 既可以设置进readfds,也可以同时设置进writefds,表示既关心该 fd 的读事件是否就绪,也关心该 fd 的写事件是否就绪。如果先把 fd 设置进readfds,后把 fd 设置进writefds,表示先关心该 fd 的读事件,后关心该 fd 的写事件,下面的exceptfds同理。
exceptfds:要监听的异常事件的 fd 集合(一般不常用),具体含义类比readfds
timeout:这是一个输入输出型参数。首先了解 timeval 结构体的定义:
cppstruct timeval { long tv_sec; // 秒 long tv_usec; // 微秒(1 秒 = 1,000,000 微秒) };该参数的含义为超时时间,NULL = 永久阻塞,直到有就绪或出错再返回,0 = 有没有就绪或出错都立即返回,函数返回值是就绪的 fd 数量(如果 > 0),5 = 最多阻塞等待 5 秒,5 秒内如果没有就绪或出错就立刻返回。具体如何设置:
cppstruct timeval tv; // 阻塞 3 秒 tv = {3,0}; // 阻塞 500 毫秒 tv = {0,500000}; // 阻塞 1.5 秒 tv = {1,500000}; // 非阻塞轮询 tv = {0,0}易错点:这是一个输入输出型参数,
select返回后,timeout的值会被内核修改为剩余未等待的时间 (但并非所有 Unix 系统都这样,Linux 会修改)。比如设置 tv = {5,0},在等待时,如果第 2 秒有 fd 就绪,那么返回时 tv 被修改为了 {3,0}。实际影响:如果要在循环中使用,每次循环必须重新设置 timeout。
返回值:
> 0:就绪的文件描述符总数
0:超时(没有错误,也没有就绪)
-1:出错(检查 errno)
fd_set 操作函数
cpp
void FD_ZERO(fd_set *set); // 清空集合
void FD_SET(int fd, fd_set *set); // 添加 fd 到集合
void FD_CLR(int fd, fd_set *set); // 从集合删除 fd
int FD_ISSET(int fd, fd_set *set); // 检查 fd 是否在集合中(就绪)
select 同时等待的文件描述符的个数是有上限的 ,因为 fd_set是 Linux 操作系统下具体的一种数据类型,有具体的大小,我们打印出 sizeof(fd_set) * 8 的值是 1024,即 fd_set 最多有 1024 个比特位,即 select最多同时等待文件描述符为 0 到 1023 的所有文件。
基础使用示例:同时监听两个 socket
cpp
#include <sys/select.h>
#include <unistd.h>
int sock1, sock2; // 假设已创建并连接
fd_set readfds;
int max_fd = (sock1 > sock2 ? sock1 : sock2);
while (1) {
// 使用前记得清零
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);
// 阻塞等待任意 fd 可读
int ret = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (ret < 0) {
perror("select error");
break;
}
// 检查哪个 fd 就绪
if (FD_ISSET(sock1, &readfds)) {
// 从 sock1 读取数据
}
if (FD_ISSET(sock2, &readfds)) {
// 从 sock2 读取数据
}
}
使用 select 的多路转接版本的 tcp 服务器
由于 select 的 readfds 是输入输出型参数,也就是说 select 会更改 readfds,所以每次调用 select 之前都要重新设置 readfds,哪我们怎么记得上次的 readfds 长啥样呢?而且服务器的客户 fd 数量是动态变化的,最大 fd 是动态变化的,即每次调用 select 之前都要获取最大客户 fd,以便设置 select 函数的第一个参数,如何知道最大客户 fd?解决这些问题的方法是创建一个辅助数组 fd_array,这个数组的大小就是 sizeof(fd_set) * 8,即 fd_set 的比特位个数,如果 fd_arrayi == default_fd,说明 fd_arrayi 不是存的有效数据,如果 fd_arrayi != default_fd,说明 fd_arrayi 存着一个需要被 select 等待的文件描述符,我们规定 fd_arry0 默认是 listen_sock。
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include <iostream>
#include <sys/select.h>
#include <sys/time.h>
#include "Socket.hpp"
using namespace std;
static const uint16_t defaultport = 8888;
static const int fd_num_max = (sizeof(fd_set) * 8);
int defaultfd = -1;
class SelectServer
{
public:
SelectServer(uint16_t port = defaultport) : _port(port)
{
for (int i = 0; i < fd_num_max; i++)
{
fd_array[i] = defaultfd;
// std::cout << "fd_array[" << i << "]" << " : " << fd_array[i] << std::endl;
}
}
bool Init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();
return true;
}
void Accepter()
{
std::string clientip;
uint16_t clientport = 0;
int client_sock = _listensock.Accept(&clientip, &clientport);
// 会不会阻塞在Accept?不会
if (client_sock < 0) return;
lg(Info, "accept success, %s: %d, sock fd: %d", clientip.c_str(), clientport, sock);
// accept 之后不能直接 read,可能会阻塞
// 要把 client_sock 写入 fd_array 中,让 select 来等待!
// 接下来在 fd_array 中寻找一个值为 defaultfd 的位置,写入 client_sock
int pos = 1; // 从下标为 1 的位置开始寻找,pos = 0 默认是 listensock
for (; pos < fd_num_max; pos++) // 第二个循环
{
if (fd_array[pos] != defaultfd) continue;
else break;
}
if (pos == fd_num_max) // fd_array 已经满了
{
lg(Warning, "server is full, close %d now!", sock);
close(sock);
}
else
{
fd_array[pos] = client_sock;
PrintFd();
// TODO
}
}
void Recver(int fd, int pos)
{
// demo
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1); // bug?
if (n > 0)
{
buffer[n] = 0;
cout << "get a messge: " << buffer << endl;
}
else if (n == 0)
{
lg(Info, "client quit, me too, close fd is : %d", fd);
close(fd);
fd_array[pos] = defaultfd; // 这里本质是从select中移除
}
else
{
lg(Warning, "recv error: fd is : %d", fd);
close(fd);
fd_array[pos] = defaultfd; // 这里本质是从select中移除
}
}
void Dispatcher(fd_set& rfds) // rfds:由 select 输出的的 rfds
{
for (int i = 0; i < fd_num_max; i++) // 这是第三个循环
{
int fd = fd_array[i];
if (fd == defaultfd) continue;
if (FD_ISSET(fd, &rfds))
{
if (fd == _listensock.Fd()) // 有新链接到来
{
Accepter(); // 连接管理器
}
else // 客户 fd 就绪
{
Recver(fd, i);
}
}
}
}
void Start()
{
int listensock = _listensock.Fd();
fd_array[0] = listensock;
for (;;)
{
fd_set rfds;
FD_ZERO(&rfds);
int maxfd = fd_array[0];
// 用 fd_array 设置 rfds,同时记录最大客户 fd
for (int i = 0; i < fd_num_max; i++) // 第一次循环
{
if (fd_array[i] == defaultfd) continue;
FD_SET(fd_array[i], &rfds);
if (maxfd < fd_array[i])
{
maxfd = fd_array[i];
lg(Info, "max fd update, max fd is: %d", maxfd);
}
}
// 每次调用 select,都要重新设置 rfds,上面的代码不能在 for (;;) 循环之外
struct timeval timeout = { 0, 0 } // 非阻塞式
int n = select(maxfd + 1, &rfds, nullptr, nullptr,&timeout);
switch (n)
{
case 0:
cout << "time out, timeout: " << timeout.tv_sec << "." << timeout.tv_usec << endl;
break;
case -1:
cerr << "select error" << endl;
break;
default:
// 有事件就绪了,TODO
cout << "get a new link or new message" << endl;
Dispatcher(rfds); // 事件派发器,由它判断是新链接到来还是客户发送了数据
break;
}
}
}
void PrintFd()
{
cout << "online fd list: ";
for (int i = 0; i < fd_num_max; i++)
{
if (fd_array[i] == defaultfd)
continue;
cout << fd_array[i] << " ";
}
cout << endl;
}
~SelectServer()
{
_listensock.Close();
}
private:
Sock _listensock;
uint16_t _port;
int fd_array[fd_num_max]; // 数组, 用户维护的!
// int wfd_array[fd_num_max];
};
select 的优缺点
优点
select的主要优势在于其广泛的兼容性和简单的模型:
跨平台性极佳 :
select是 POSIX 标准的一部分,几乎在所有操作系统(包括 Linux、Unix、macOS 和 Windows)上都支持。如果你编写的程序需要跨平台编译运行,select是 I/O 多路复用中唯一可靠的选择。使用模型简单 :相比
epoll这种需要多个函数(create,ctl,wait)配合的复杂机制,select的接口相对直观,易于理解和使用。缺点
随着并发量的提升,
select的性能瓶颈会变得非常突出,主要体现在以下三个方面:
缺点维度 具体表现与原因 说明与影响 描述符数量限制 默认最多只能监控 1024 个文件描述符(受 FD_SETSIZE限制)。对于需要处理数千甚至上万个连接的高并发服务器而言,这个硬性限制是致命的。 频繁的"拷贝"开销 每次调用 select,都需要将整个文件描述符集合从用户态 内存拷贝到内核态内存,经过内核修改后,又要将整个文件描述符集合从内核态内存拷贝到用户态内存。并且每次调用 select,必须重新设置那些输入输出型参数当监控的描述符数量很多时(比如上千个),这种反复的数据拷贝会消耗大量 CPU 时间。 O(n) 的"遍历"效率 内核需要线性扫描所有被监控的描述符,才能确定哪些描述符已经就绪;程序收到返回后,也需要遍历查找就绪的描述符。 这导致 select的效率随着监控的文件描述符数量增加而线性下降。
IO 多路转接 - poll
poll 可以看作是 select 的改进版 ,它解决了 select 最让人头疼的两个问题:文件描述符数量上限 和每次都要重新初始化集合 。但在性能的核心瓶颈上(如效率随连接数增多而下降),它与 select 仍然处于同一层级。
核心数据结构:struct pollfd
poll 不再使用 select 那套基于位掩码(fd_set)的方式,而是引入了一个结构体数组:
cpp
struct pollfd {
int fd; // 要监控的文件描述符
short events; // 感兴趣的事件(输入:POLLIN, POLLOUT 等)
short revents; // 实际发生的事件(输出,由内核填充)
};
// 注意:
// 如果 fd == -1,poll 会忽略,若 fd >= 0 但 fd 未打开或已关闭
// 返回的 revents 就是 POLLNVAL,并且整个 poll 调用不会失败(返回值仍可能大于 0)
每个描述符独立 :每个
struct pollfd对应一个需要监控的 fd,不再有1024的硬限制。输入输出分离 :
events是用户告诉内核"我想监控什么",revents是内核告诉用户"发生了什么"。这解决了select每次调用前都必须重新设置全部描述符集合的痛点。
cpp
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- struct pollfd *fds:pollfd 类型的结构体数组
- nfds:结构体数组的大小
- timeout:最大等待时间,单位是毫秒
- 返回值:>0 返回就绪的文件描述符数量(即
revents非 0 的pollfd个数), =0 表示在指定的timeout时间内没有任何事件发生,<0 调用出错,具体错误码保存在errno中
events 的设置非常直接:使用按位或 | 操作符,将一个或多个事件宏组合起来 ,赋给 struct pollfd 结构体中的 events 字段。
cpp
struct pollfd fds;
fds.fd = sockfd; // 要监控的 socket 或文件描述符
fds.events = POLLIN; // 只监控可读事件
// 或者同时监控多个事件,用 | 组合
fds.events = POLLIN | POLLOUT; // 同时监控可读和可写
fds.events = POLLIN | POLLRDHUP; // 监控可读 或 对端关闭连接
常用 events 事件宏
| 事件宏 | 含义 | 典型使用场景 |
|---|---|---|
POLLIN |
普通数据可读(最常见) | 监听 socket 有新连接到达;已建立连接的 socket 收到数据 |
POLLOUT |
可写(缓冲区有空闲空间) | 当发送缓冲区满导致阻塞后,等待缓冲区有空间时再次发送 |
POLLRDHUP |
对端关闭连接或半关闭写端 | TCP 连接中,对方调用 shutdown(SHUT_WR) 或 close() 时触发。需要定义 _GNU_SOURCE 宏 |
POLLPRI |
紧急数据可读(带外数据 Out-of-Band Data) | TCP 紧急指针或某些协议的高优先级数据 |
POLLERR |
错误 (通常不需要设置,内核会自动在 revents 中返回) |
如连接被重置(RST)或设备错误 |
POLLHUP |
挂断(不需要设置,自动返回) | 对端正常关闭连接(写端已关闭),一般表示连接断开 |
POLLNVAL |
无效请求(不需要设置,自动返回) | fd 未打开(如已经 close) |
优点:相对于 select 的进步
| 改进点 | 说明 |
|---|---|
| 无 1024 硬限制 | 不再受 FD_SETSIZE 限制,理论上可以监控任意数量的文件描述符(受系统资源限制)。 |
| 避免重复初始化 | select 每次调用后 fd_set 集合会被内核修改,下次调用必须重新添加全部 fd。而 poll 的 events 字段由用户维护,内核只修改 revents,因此下次调用时无需重新设置。 |
| 事件类型更丰富 | 支持更多的事件类型,如 POLLRDHUP(对端关闭连接)、POLLPRI(紧急数据)等,比 select 的读、写、异常更精细。 |
仍未解决的问题
O(n) 的线性扫描:
poll每次调用时,内核仍然需要线性遍历整个pollfd数组,检查每个 fd 对应的设备是否就绪。poll返回后,应用程序需要重新遍历整个数组 ,找到哪些 fd 的revents非 0。大量数据拷贝: 每次调用
poll时,用户程序需要将整个pollfd数组从用户态 到内核态来回拷贝
使用 poll 的多路转接版本的 tcp 服务器
与使用 select 的 tcp 服务器相比较,该版本的服务器将 int _fd_array 数组换成了 struct pollfd _fd_array 结构体数组,每个元素的 fd 如果 == -1,表示无效的元素,poll 会直接忽略。我们直接在 Select_server.hpp 的基础上修改
cpp
#pragma once
//#include <sys/select.h>
#include <poll.h>
#include "Socket.hpp"
//const static int fd_num_max = sizeof(fd_set) * 8;
const static int fd_num_max = 64;
const static uint16_t default_port = 8080;
const static int default_fd = -1;
const static int non_event = 0;
class Poll_server
{
public:
Poll_server(uint16_t port = default_port):_port(port)
{
// for(int i = 0; i < fd_num_max; i++)
// {
// _fd_array[i] = default_fd;
// }
for(int i = 0; i < fd_num_max; i++)
{
_fd_array[i].fd = default_fd;
_fd_array[i].events = non_event;
_fd_array[i].revents = non_event;
}
}
void Init()
{
_Listen_sock.Socket();
_Listen_sock.Bind(_port);
_Listen_sock.Listen();
}
void Start()
{
while(true)
{
// fd_set rdset;
// FD_ZERO(&rdset);
// int max_fd = _fd_array[0];
// for(int i = 0; i < fd_num_max; i++)
// {
// if(_fd_array[i] != default_fd)
// {
// FD_SET(_fd_array[i], &rdset);
// }
// max_fd = std::max(max_fd, _fd_array[i]);
// }
// struct timeval tv = {5, 0};
// int ret = select(max_fd + 1, &rdset, nullptr, nullptr, &tv);
_fd_array[0].fd = _Listen_sock.FD();
_fd_array[0].events = POLLIN;
while(true)
{
int ret = poll(_fd_array, fd_num_max, 5000);
switch(ret)
{
case -1:
std::cerr << "poll error" << std::endl;
break;
case 0:
std::cout << "poll timeout" << std::endl;
break;
default:
Dispatcher(_fd_array);
break;
}
}
}
}
void Dispatcher(const pollfd* fd_array)
{
for(int i = 0; i < fd_num_max; i++)
{
// if(FD_ISSET(_fd_array[i], &rdset))
// {
// if(_fd_array[i] == _Listen_sock.FD())
// {
// Accepter();
// }
// else
// {
// Recver(_fd_array[i],i);
// }
// }
if(fd_array[i].revents & POLLIN)
{
if(fd_array[i].fd == _Listen_sock.FD())
{
Accepter();
}
else
{
Recver(fd_array[i].fd, i);
}
}
}
}
void Accepter()
{
uint16_t client_port;
std::string client_ip;
int client_fd = _Listen_sock.Accept(&client_ip, &client_port);
// int pos = 1;
// for(; pos < fd_num_max; pos++)
// {
// if(_fd_array[pos] == default_fd)
// {
// _fd_array[pos] = client_fd;
// break;
// }
// }
// if(pos == fd_num_max)
// {
// std::cerr << "too many clients" << std::endl;
// close(client_fd);
// return;
// }
// _fd_array[pos] = client_fd;
int pos = 1;
for(;pos < fd_num_max; pos++)
{
if(_fd_array[pos].fd == default_fd) break;
}
if(pos == fd_num_max)
{
std::cerr << "too many clients" << std::endl;
close(client_fd);
return;
}
_fd_array[pos].fd = client_fd;
_fd_array[pos].events = POLLIN;
}
void Recver(int fd, int pos)
{
char buf[1024];
int ret = read(fd, buf, sizeof(buf) - 1);
if(ret == -1)
{
std::cerr << "read error" << std::endl;
close(fd);
//_fd_array[pos] = default_fd;
_fd_array[pos].fd = default_fd;
_fd_array[pos].events = non_event;
_fd_array[pos].revents = non_event;
}
else if(ret == 0)
{
std::cout << "client close" << std::endl;
close(fd);
_fd_array[pos].fd = default_fd;
_fd_array[pos].events = non_event;
_fd_array[pos].revents = non_event;
}
else
{
buf[ret] = '\0';
std::cout << "client say: " << buf << std::endl;
}
}
private:
uint16_t _port;
Sock _Listen_sock;
//int _fd_array[fd_num_max];
struct pollfd _fd_array[fd_num_max];
};
IO 多路转接 - epoll
epoll 是 Linux 下高性能网络编程的基石 。它专门为了解决 select 和 poll 在高并发场景下的性能瓶颈而设计,是目前 Linux 平台上处理海量连接的事实标准。
select/poll:每次都要把全部待监控的文件描述符(fd)列表传给内核,内核再暴力遍历一遍,效率随连接数线性下降 → O(n)。
epoll:把"关注 fd"和"等待事件"拆成两步,在内核中维护一个红黑树 和就绪链表 ,事件发生时只返回真正就绪的 fd → O(1)。
epoll 在内核中维护了三个核心数据结构(epoll 模型 ),理解它们就知道 epoll 为什么快了:
红黑树(rbtree) :存储所有被监控的 fd。添加 (
EPOLL_CTL_ADD)、删除 (EPOLL_CTL_DEL)、修改 (EPOLL_CTL_MOD) 操作的复杂度都是 O(log n) ,比select/poll每次全量拷贝要高效得多。就绪链表(rdlist) :双向链表,存储已经发生事件的就绪 fd。一旦 fd 就绪,内核通过回调机制将其放入此链表。
回调机制(callback) :这是
epoll性能的核心。当向内核添加一个 fd 时,会注册一个回调函数,与设备(网卡)驱动程序建立回调关系。当 fd 上有事件发生时(比如数据到达),内核自动调用这个回调函数,将该 fd 放入就绪链表。这就避免了poll/select那种"每次都要扫描全部 fd 才能找出哪些就绪"的笨办法。
epoll_create() -- 创建 epoll 模型
cpp
int epoll_fd = epoll_create(1); // 参数 size 已废弃,但必须 > 0
// 或者使用更现代的 epoll_create1(0)
返回值:返回一个文件描述符,指向内核中新创建的 eventpoll 对象(里面包含红黑树和就绪链表)。如果创建失败,返回 -1,设置 errno。
epoll_ctl() -- 管理监控列表
这是 epoll 的"配置接口"
cpp
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd:epoll_create 返回的 epoll fd
op操作类型:
EPOLL_CTL_ADD:添加一个 fd 到红黑树
EPOLL_CTL_MOD:修改 fd 上监控的事件(比如从只读改为读写都监控)
EPOLL_CTL_DEL:从红黑树中删除 fd(不再监控)
struct epoll_event结构体:
cppstruct epoll_event { uint32_t events; // 事件掩码:EPOLLIN, EPOLLOUT, EPOLLET 等 epoll_data_t data; // 用户数据(通常放 fd 或自定义指针) }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;struct epoll_event 的 uint32_t events 事件掩码:
事件宏 含义 输入(设置) 输出(检查) 说明 EPOLLIN 普通数据可读 ✅ ✅ socket 收到数据;监听 socket 有新连接 EPOLLOUT 可写 ✅ ✅ 发送缓冲区有空间(通常一开始就就绪) EPOLLRDHUP 对端关闭连接 ✅ ✅ TCP 对端调用了 shutdown(SHUT_WR)或close()EPOLLPRI 紧急数据可读 ✅ ✅ 带外数据(Out-of-Band Data) EPOLLERR 错误发生 ❌ ✅ 连接错误(如 RST),自动返回,无需设置 EPOLLHUP 挂断 ❌ ✅ 对端正常关闭,自动返回,无需设置 EPOLLET 边缘触发模式 ✅ ❌ 设置后触发模式从 LT(默认)变为 ET EPOLLONESHOT 一次性事件 ✅ ❌ 事件触发一次后自动删除该 fd EPOLLWAKEUP 防止系统休眠 ✅ ❌ 事件处理期间阻止系统进入休眠 EPOLLEXCLUSIVE 避免惊群 ✅ ❌ 多线程/多进程下,只唤醒一个等待者 注意事项:
fd 必须有效 :即
fd必须是一个已经打开的文件描述符(不能是-1或已关闭的)。如果传入无效的 fd,epoll_ctl会返回-1,errno设置为EBADF。epfd 必须有效 :即由
epoll_create返回的 epoll 实例的 fd。操作必须合法 :比如不能对一个已经添加过的 fd 再次执行
EPOLL_CTL_ADD(会返回EEXIST错误),也不能对一个未添加的 fd 执行EPOLL_CTL_MOD或EPOLL_CTL_DEL(会返回ENOENT错误)。也不能对一个已经添加过的但已关闭的(使用 close())fd 执行EPOLL_CTL_DEL也,即建议先把要关闭的 fd 从 epoll 模型中移除,再使用 close 关闭
epoll_wait() -- 等待事件发生
这是 epoll 的"工作接口",返回时只带回真正就绪的 fd。
cpp
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd:epoll_create 返回的 epoll fd
events:输出型参数,是一个用户态数组,由内核填充就绪的事件。
maxevents:**events的**数组大小,即每次最多捞出多少个就绪 fd,其余未被捞出的但已经就绪的 fd,要等到下一轮再被捞出。
timeout:-1 阻塞等待,0 立即返回,>0 等待毫秒超时。返回值:就绪的 fd 数量。
epoll 的优点
- 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
- 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
- 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
- 没有数量限制: 文件描述符数目无上限
使用 epoll 的简易 TCP 服务器
先对上面的 epoll 相关的接口做一个简单的封装:
Epoller.hpp:
cpp
#pragma once
#include "nocopy.hpp"
#include "Log.hpp"
#include <cerrno>
#include <cstring>
#include <sys/epoll.h>
class Epoller : public nocopy
{
static const int size = 128;
public:
Epoller()
{
_epfd = epoll_create(size);
if (_epfd == -1)
{
lg(Error, "epoll_create error: %s", strerror(errno));
}
else
{
lg(Info, "epoll_create success: %d", _epfd);
}
}
int EpollerWait(struct epoll_event revents[], int num)
{
int n = epoll_wait(_epfd, revents, num, /*_timeout 0*/ -1);
return n;
}
int EpllerUpdate(int oper, int sock, uint32_t event)
{
int n = 0;
if (oper == EPOLL_CTL_DEL)
{
n = epoll_ctl(_epfd, oper, sock, nullptr);
if (n != 0)
{
lg(Error, "epoll_ctl delete error!");
}
}
else
{
// EPOLL_CTL_MOD || EPOLL_CTL_ADD
struct epoll_event ev;
ev.events = event;
ev.data.fd = sock; // 目前,方便我们后期得知,是哪一个fd就绪了!
n = epoll_ctl(_epfd, oper, sock, &ev);
if (n != 0)
{
lg(Error, "epoll_ctl error!");
}
}
return n;
}
~Epoller()
{
if (_epfd >= 0)
close(_epfd);
}
private:
int _epfd; // epoll 模型的文件描述符
int _timeout{3000}; // 等待时间
};
Epoller_server.hpp:
cpp
#pragma once
#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "Socket.hpp"
#include "Epoller.hpp"
#include "Log.hpp"
#include "nocopy.hpp"
uint32_t EVENT_IN = (EPOLLIN);
uint32_t EVENT_OUT = (EPOLLOUT);
class EpollServer : public nocopy
{
static const int num = 64;
public:
EpollServer(uint16_t port)
: _port(port),
_listsocket_ptr(new Sock()),
_epoller_ptr(new Epoller())
{}
void Init()
{
_listsocket_ptr->Socket();
_listsocket_ptr->Bind(_port);
_listsocket_ptr->Listen();
lg(Info, "create listen socket success: %d\n", _listsocket_ptr->Fd());
}
// 链接管理器
void Accepter()
{
// 获取了一个新连接
std::string clientip;
uint16_t clientport;
int sock = _listsocket_ptr->Accept(&clientip, &clientport);
if (sock > 0)
{
// 我们能直接读取吗?不能
_epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, sock, EVENT_IN);
lg(Info, "get a new link, client info@ %s:%d", clientip.c_str(), clientport);
}
}
// for test
void Recver(int fd)
{
// demo
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1); // bug?
if (n > 0)
{
buffer[n] = 0;
std::cout << "get a messge: " << buffer << std::endl;
// wrirte
std::string echo_str = "server echo $ ";
echo_str += buffer;
write(fd, echo_str.c_str(), echo_str.size());
}
else if (n == 0)
{
lg(Info, "client quit, me too, close fd is : %d", fd);
//细节3
_epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
else
{
lg(Warning, "recv error: fd is : %d", fd);
_epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
}
// 事件派发器
void Dispatcher(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++)
{
uint32_t events = revs[i].events;
int fd = revs[i].data.fd;
if (events & EVENT_IN)
{
if (fd == _listsocket_ptr->Fd())
{
Accepter(); // 链接管理器
}
else
{
// 其他fd上面的普通读取事件就绪
Recver(fd); //
}
}
else if (events & EVENT_OUT)
{}
else
{}
}
}
void Start()
{
// 将listensock添加到epoll中 -> listensock和他关心的事件,
// 添加到内核epoll模型中的rb_tree.
_epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, _listsocket_ptr->Fd(), EVENT_IN);
struct epoll_event revs[num]; // 待会从epoll模型捞出的就绪fd
for (;;)
{
int n = _epoller_ptr->EpollerWait(revs, num);
// 返回值 n 就是就绪的fd的数量
if (n > 0)
{
// 有事件就绪
lg(Debug, "event happened, fd is : %d", revs[0].data.fd);
Dispatcher(revs, n); // 事件派发器
}
else if (n == 0)
{
lg(Info, "time out ...");
}
else
{
lg(Error, "epll wait error");
}
}
}
~EpollServer()
{
_listsocket_ptr->Close();
}
private:
std::shared_ptr<Sock> _listsocket_ptr;
std::shared_ptr<Epoller> _epoller_ptr;
uint16_t _port;
};
epoll 的工作模式
epoll 具有两种工作模式:
水平触发 (LT) :只要条件满足,就会一直通知你 。这是
epoll的默认模式 ,行为类似于select/poll,安全且易于使用。边缘触发 (ET) :只在状态发生变化的那一刻通知你一次 。这是
epoll独有的高性能模式,效率极高,但编程也更复杂。
两种工作模式的区别:
水平触发 (LT) :你的秘书每隔 5 分钟就去检查一次邮箱。只要邮箱里还有未读邮件,她就会通知你:"你有新邮件!"。哪怕这些邮件一小时前就到了,只要没读完,她就一直提醒。
边缘触发 (ET) :你的秘书只在邮件到达的那一刻 通知你一次:"新邮件到了!"。然后她就走了。哪怕你只读了其中一封,剩下的没读,她也不会再提醒 。你必须养成习惯,一听到通知,就立刻去把邮箱里所有未读邮件全部处理完。
| 对比维度 | 水平触发 (LT) - 默认 | 边缘触发 (ET) - 高性能 |
|---|---|---|
| 通知条件 | 只要 read 操作不会阻塞(即有数据可读),就会持续通知。 |
只在 fd 上有新数据到达(状态从无到有)的那一刻通知一次。 |
| 工作风格 | 主动检查:内核反复检查条件是否满足。 | 事件驱动:内核只在事件发生时通知。 |
| 编程复杂度 | 低:可以一次只读一部分,下次再读剩下的,不用担心漏掉事件。 | 高:必须一次性将数据读完,否则可能永远丢失事件。 |
| fd 要求 | 可以是阻塞或非阻塞,通常使用阻塞 fd 也不会出大问题。 | 必须使用非阻塞 fd,否则可能阻塞整个进程。 |
| 性能 | 一般。可能因重复通知而产生一些不必要的系统调用。但是如果每次通知时都一次性将数据都取完,性能与 ET 模式相同。 | 极高 。大大减少了 epoll_wait 的调用次数和事件重复通知。 |
| 适用场景 | 绝大多数通用场景,特别是对编程效率要求高于极致性能时。 | 高并发、大流量场景,如 Web 服务器、网关、游戏服务器。 |
边缘触发 (ET) 为什么要求文件 fd 必须是非阻塞的?
在 ET 模式下,我们如何做到在有通知时将缓冲区的数据全部取走呢?答案是循环读取,一直到读取出错。使用 read/recv 时,如果我们一种循环读取接收缓冲区,一直到没有数据了,此时read/recv 会阻塞住,一直到接收缓冲区有数据,此时服务器就挂起了,这不是我们所期望的,所以我们必须将套接字设置为非阻塞模式
为什么边缘触发 (ET) 模式下,网络 IO 的效率更高?
因为在 ET 模式下,我们在在有通知时将缓冲区的数据全部取走,tcp 的流量控制机制会向对方通告一个更大的窗口,从而让对方更有可能一次性向我发送更多的数据。
边缘触发 (ET) 模式一定比水平触发 (LT) 模式高效吗?
如果我们在水平触发 (LT) 模式下,将所有的文件 fd 都设置成非阻塞模式,并且循环读取,其 IO 效率与边缘触发 (ET) 模式不相上下。