高级IO
什么是IO ?什么是高效的IO
IO的定义及核心组成
IO本质:IO操作包含两个主要阶段:
等待:资源(例如数据)从内核态到用户态的可用性。
数据拷贝:数据从内核空间移动到用户空间,或反之。
高效IO的关键:
减少等待的时间比重。
优化数据的传输和拷贝过程。
IO的 方式
为什么多路复用IO是高效的代名词?
统一管理多个IO操作:单线程处理多个连接,减少线程开销。
减少阻塞时间:通过事件驱动的方式集中等待数据可用。
高扩展性:
epoll
等机制避免了select
/poll
的大量文件描述符复制开销,适合高并发场景。
IO的几种方式与效率分析
阻塞式IO和非阻塞式IO的主要区别在于等待数据的方式和进程的行为
同步与异步的关系
阻塞式IO和非阻塞式IO都属于同步IO,即每个IO操作完成后,进程才能继续执行接下来的任务。
异步IO(与阻塞式IO、非阻塞式IO不同)则是完全不同的模型。异步IO允许操作系统在后台处理IO请求,进程或线程通过回调或事件通知得知操作的完成,进程在等待期间不会被阻塞。
非阻塞 IO
fcntl
它提供了一种灵活的方式来管理文件描述符(fd
)的属性,比如锁定文件、设置文件描述符的标志等。
SYNOPSIS
#include <fcntl.h>
#include <unistd.h>
int fcntl(int fd, int op, ... /* arg */ );
基于fcntl, 实现一个SetNoBlock函数, 将文件描述符设置为非阻塞
void SetNoBlock(int fd)
{
int fl = fcntl(fd,f_GETFL);
if(fl<0){
printf("FL error");
}
fcntl(fd,SETFL,fl|O_NOBLOCK);
}
// F_GETFL将当前的文件描述符的属性取出来 在 从取出GETFL的基础上 设置回去直接天机O_NOBLOACK 参数
Select
select : IO = 等 + 拷贝
select 只负责等待 可以 一次性等待多个 fd select 本身没有 数据拷贝的能力,拷贝要用 read,write 来完成
select
是一种 I/O 多路复用技术,允许单线程监控多个文件描述符的状态(如是否可读、可写、发生异常),以便在一个线程中处理多个客户端连接。
网络接口补充
#include <sys/socket.h>
int setsockopt(int socket, int level, int option_name, const void *option_value, socklen_t option_len);
函数用于设置指定套接字的选项。它允许程序控制套接字的行为,例如设置超时时间、启用或禁用某些功能等
主要解决 客户端为断开 连接 所占用端口 不能重复使用的问题
参数:
socket : 套接字描述符
level : 选项所在的协议层。例如,
SOL_SOCKET
表示通用套接字选项,IPPROTO_TCP
表示TCP协议选项option_name : 要设置的具体选项。例如,
SO_REUSEADDR
表示允许重用本地地址,SO_RCVTIMEO
表示接收超时时间。option_value : 指向包含新选项值的缓冲区。
open_len :
option_value
缓冲区的长度返回值 : 成功返回 0 失败返回 -1 并设置 errno 指示错误原因 。
Select 接口
#include <sys/select.h> typedef /* ... */ fd_set; int select(int nfds, fd_set *_Nullable restrict readfds, fd_set *_Nullable restrict writefds, fd_set *_Nullable restrict exceptfds, struct timeval *_Nullable restrict timeout);
nfds select 要监听多个 fd 中 值最大的fd + 1
readfds : 输入输出形参数
writefds : 输入输出形参数
exceptfds : 输入输出型参数
struct timeval * timeout : 输入输出型 参数 {秒 ,毫秒} | 当设置为{0,0} 的时候为 非阻塞 当设置为 {5,0}的时候 5s 以内阻塞 超出 5s 非阻塞返回一次|null 阻塞
struct timeval { time_t tv_sec; /* seconds / suseconds_t tv_usec; / microseconds */ };
RETURNVAl : 接收到几个文件描述符已经就绪了 ret==0 超时返回了 ret<0 select 调用失败了
SELECT 未来只关心的事件 , a 读 ,b 写, c 异常 对于任何一个 fd 就是这三种
fd_set : 位图结构 表示 文件描述符的集合
readfds 参数: 让用户和 内核之间可相互沟通 互相告诉对方自己关系的
输入:输入时,用户设置希望关注的
fd
输出:内核会将已经准备好读的
fd
对应比特位置为 1
其他 readfds | execptfds 相同
void FD_CLR(int fd, fd_set *set); // 从文件描述符集合中移除指定的文件描述符 fd int FD_ISSET(int fd, fd_set *set); // 检查文件描述符 fd 是否在集合中。 void FD_SET(int fd, fd_set *set); // 将文件描述符 fd 添加到集合中。 void FD_ZERO(fd_set *set); // 清空集合,将所有比特位置为 0。
select 的 特点
select 可以 同时等待的文件 fd 的 个数 是 有上限的 除非 修改 内核 否则 无法解决
必须借助 第三方的数组 来维护 合法的fd
大部分的 参数是 输入输出型的 调用select之前重新设置所有的fd 调用之后要遍历所有的fd (遍历所有的fd 成本很高)
select 的 第一个文件描述符 是 fd + 1 告诉内核 的遍历范围
select 采用位图 , 用户->内核 内核 -> 用户 来回的数据考本 的成本很高
Poll
poll
相对于 select
的改进
解决文件描述符上限的问题
select
的问题:select
函数在使用时,文件描述符的最大数量受到FD_SETSIZE
常量的限制,通常为1024。这对处理大量并发连接时是一个限制。poll
的改进:poll
使用的是一个pollfd
结构体数组,没有文件描述符数量的硬性限制,能够更好地处理大量并发连接。
每次调用都要重新设置关心的文件描述符
select
的问题:每次调用select
都需要重新设置关心的文件描述符集合,这样会增加代码复杂度和调用开销。poll
的改进:poll
的pollfd
结构体数组可以在多次调用之间保持不变,只需要更新需要修改的部分,提高了效率。
输入输出分离
select
的问题:select
函数的读、写和异常事件都需要通过不同的文件描述符集合来管理,代码上可能会比较复杂。poll
的改进:poll
使用pollfd
结构体中的events
字段来统一表示读、写和异常事件,简化了代码。
poll接口
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); #define _GNU_SOURCE /* See feature_test_macros(7) */ #include <poll.h> int ppoll(struct pollfd *fds, nfds_t nfds, const struct timespec *_Nullable tmo_p, const sigset_t *_Nullable sigmask);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd *fds // 动态数组
struct pollfd {
int fd; /* file descriptor / // 文件描述符
short events; / requested events / // 请求的事件
short revents; / returned events */ // 返回的事件
};用户输入 看 fd + events // 告诉内核 要关系你的 事务
内核输出 看 fd + revents // 用户关心的事件已经 就绪了
nfds表示fds数组的长度
int timeout
timeout > 0 // timeout 时间以内 阻塞
timeout < 0 // 阻塞
timeout = 0 // 非阻塞
events 和 revents 的取值
值
poll
的缺点
需要轮询
pollfd
结构体:poll
返回后,仍需轮询pollfd
数组来获取就绪的文件描述符,增加了处理复杂性。数据拷贝开销大:每次调用
poll
都需要将大量的pollfd
结构从用户态拷贝到内核态,导致性能开销较大。效率线性下降:当同时连接的大量客户端中只有少数处于就绪状态时,
poll
的效率会随着监视的文件描述符数量的增长而线性下降。
epoll
epoll
是 Linux 提供的一种高效 IO 多路复用机制,其核心是通过红黑树和就绪队列的组合高效管理文件描述符的事件
epoll 相关 接口
相关结接口 使用简单 原理 有些抽象
#include <sys/epoll.h>
int epoll_create(int size); // 表示期望处理的最大事件数量
int epoll_ctl(int epfd, int op, int fd,struct epoll_event *_Nullable event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
epoll_data | epoll_event
#include <sys/epoll.h> struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; };
eveents
宏
epoll 原理
epoll 的 核心 组件 、
红黑树
管理文件描述符 每次 EPOLL_CTL_ADD 都会向 红黑树中添加文件fd 或者 删除
二叉平衡树 高效
就绪队列
当某个 文件描述符 事件触发的时候 fd 会 添加到就绪队列
epoll_wait
从就绪队列中取出已就绪的
fd
epoll 的 工作流程
初始化 :
调用
epoll_create
创建一个 epoll 实例,内核会为此分配一个结构体,其中包含红黑树和就绪队列。
添加文件描述符
调用
epoll_ctl
,将文件描述符添加到红黑树。 文件描述符会被与指定的事件类型(EPOLLIN
/EPOLLOUT
/...)一起注册。
事件检测
内核监听所有红黑树上的文件描述符 当文件描述符上的事件发生时,将该描述符加入就绪队列。
获取事件
调用
epoll_wait
,从就绪队列中获取已触发的事件,并返回给用户
epoll 如何 检测 文件就绪 ?
检测工作由底层内核通过回调机制完成
文件描述符在内核层绑定了一个回调函数。 当文件描述符有状态变化时内核会通过回调通知 epoll
事件触发后,epoll 将文件描述符从红黑树映射到就绪队列中。
为什么 epoll 性能高?
内核事件驱动:
epoll 使用事件驱动模型,而不是轮询模型。
只有当事件发生时,内核才通知 epoll,这大大减少了不必要的系统调用和资源消耗。
就绪队列机制:
只将已就绪的文件描述符放入就绪队列,避免了遍历所有文件描述符的开销
红黑树高效管理:
红黑树使得文件描述符的动态增删查操作高效,能够应对大量文件描述符的场景
支持大规模并发:
epoll 的设计允许同时管理数以万计的文件描述符("1-N" 模型),并且性能不会因文件描述符数量增加而明显下降。
事件批量返回:
epoll_wait
能够一次性返回多个就绪的文件描述符,减少了系统调用的次数
epoll的 ET/LT 模式
LT 水平触发 ET 边缘触发
水平模式
默认模式
如果一个文件描述符就绪,epoll_wait
会一直返回该事件,直到用户处理完成
简单但可能导致重复通知,降低效率
垂直模式
高效模式
当文件描述符就绪时,仅通知一次
用户需要一次性读取或写入所有数据,否则可能丢失通知
对程序要求较高,需要通过非阻塞 IO 配合使用
EPoll 能不能 直接 发送呢 ?
epoll
是事件驱动的机制,只有当某个事件触发时,内核才会通知用户程序。而在发送数据时,我们必须确保发送条件是满足的
发送缓冲区有空间。
网络通道可以正常发送数据
用户程序不能直接判断发送缓冲区是否有空间,因此只能依赖 epoll
通过事件通知来判断。
当发送缓冲区有空间时,
EPOLLOUT
事件会触发。如果没有空间,直接发送会导致阻塞(在阻塞模式)或失败(在非阻塞模式)
什么是写事件就绪?
写事件就绪(EPOLLOUT
)的条件是:
发送缓冲区有可用空间。
表示当前可以向这个
socket
写入数据,而不会阻塞写事件默认 是就绪的: 大多数发送缓冲区的空间是充足的写事件一直是可用的
启动服务器时,写事件几乎总是就绪的,因为发送缓冲区尚未被填满
按需设置写事件:
因为写事件通常是就绪的,所以对于大部分程序来说,不需要一直监听
EPOLLOUT
。当遇到需要多次发送数据且发送缓冲区可能会满的情况时,才需要按需注册
EPOLLOUT
事件。
为什么 文件描述符需要 自己的发送缓冲区 ?
当发送数据较多时,可能无法一次性发送完成
用户程序的发送缓冲区 :
如果数据未能一次性发完,需要把剩余的数据存储在用户程序的缓冲区中,等待下次写事件就绪时继续发送
内核发送缓冲区:
这是由 TCP 协议栈维护的,当用户程序通过
send
将数据写入时,数据会先进入内核的发送缓冲区
处理失败
当内核发送缓冲区满时,写操作可能会失败(返回
EAGAIN
),需要等待写事件再次触发。用户程序需要保存未发送的数据,并在下一次
EPOLLOUT
事件触发时,继续尝试发送。
写事件的使用场景
发送时的初始状态:
用户程序尝试直接发送数据。
如果发送成功并且数据全部发送完,则不需要注册
EPOLLOUT
。发送未完成:
如果发送缓冲区满,导致部分数据未发送:
注册
EPOLLOUT
,等待写事件就绪。保存未发送的数据到用户缓冲区。
写事件触发:
当发送缓冲区有空间时,内核触发
EPOLLOUT
。用户程序从用户缓冲区取出剩余数据,继续发送。
数据发送完成:
取消
EPOLLOUT
的监听,避免不必要的系统调用。
两种高效的事件处理模式:Reactor模式和Proactor模式_proactor模式和reactor模式应用场景-CSDN博客