【Linux网络】四十六.《高级 IO (上篇)》-- 详解

发布时间:2026/9/26 3:03:35
【Linux网络】四十六.《高级 IO (上篇)》-- 详解 一.IO 的基本概念I/Oinput/output也就是我们说的输入和输出在冯诺依曼体系结构当中将数据从输入设备拷贝到内存就叫作输入将数据从内存拷贝到输出设备就叫作输出。对文件执行读写操作本质就是 IO 操作文件 IO 对应的硬件外设是磁盘。对网络进行读写操作同样属于 IO 操作网络 IO 对应的硬件外设是网卡。总结IO 就是主机和外部设备之间的数据传输。OS 如何得知外设中有数据可读取输入操作本质是操作系统把数据从外设拷贝到内存。操作系统需要有一种机制判断对应外设是否已经准备好数据。并不是操作系统发起读取时外设就一定有数据可读。举个例子访问服务器时客户端发出请求报文后需要等待网卡收到服务器返回的响应。此时服务器可能还没收到请求、正在处理请求或是响应报文还在网络上传输。操作系统不会持续主动轮询检测外设是否有数据。轮询会严重降低系统效率绝大多数查询都是无效的空检测白白消耗 CPU 资源。操作系统实际依靠中断机制感知外设数据就绪。当外设准备好了数据外设会向 CPU 内的中断控制器发送中断信号中断控制器再根据中断信号的优先级提交给 CPU。每个中断信号都绑定了对应的中断处理程序。记录中断信号与中断处理程序映射关系的表叫做中断向量表。CPU 收到中断信号后会暂停当前正在执行的程序查询中断向量表执行对应的中断处理程序中断处理完成之后再回到原来被暂停的程序继续执行。注意CPU 不直接和外设进行数据层面的交互。但是外设可以直接向 CPU 内部的控制器发送控制信号中断信号就属于这类控制信号目前IO 最主要的问题就是效率问题IO 的效率很低。以读取数据为例调用 read/recv 时如果底层缓冲区没有数据read/recv 就会阻塞等待。 调用 read/recv 时如果底层缓冲区存在数据read/recv 就执行数据拷贝学习 TCP 时会了解read/recv 这类接口本质就是完成数据拷贝。所以IO 的本质分为两部分等待等待 IO 条件就绪 数据拷贝IO 就绪后把数据拷贝到内存或者向外设拷贝数据。 只要缓冲区没有数据read /recv 就会持续阻塞等待直到缓冲区收到数据再执行拷贝。read/recv 的大量时间都消耗在等待上这就是阻塞 IO 低效的根源。所有 IO 过程都包含等待和拷贝两个阶段。在大多数实际场景里等待耗费的时间远大于数据拷贝耗费的时间。OS 如何处理从网卡中读取到的数据包操作系统随时都可能收到大量数据包内核需要对数据包进行统一管理。管理思路就是先描述再组织在内核中存在sk_buff结构体专门用来保存、管理收发数据包的相关信息。简化版的 sk_buff 结构struct sk_buff{ char* transport_header; char* network_header; char* mac_header; char* data; struct sk_buff* next; struct sk_buff* prev; };操作系统从网卡读取数据包之后会逐层把数据包交给链路层、网络层、传输层、应用层完成解包与分用最后把数据包里的数据交付给上层应用。那结合sk_buff结构体来看数据包的解包和分用具体是怎么实现的操作系统从网卡拿到数据包会创建一个sk_buff结构体。用sk_buff里的data指针指向这个数据包同时把多个sk_buff通过next、prev指针组织成双向链表。操作系统对数据包的管理本质就是对这个双向链表做增删改查。首先交给链路层处理、解包分用让sk_buff的mac_header指针指向数据包起始位置。向后读取链路层头部剩下的数据载荷交给上层。到此完成链路层解包。链路层向上交付给网络层解包分用。这里的向上交付不会拷贝数据只需要修改指针让sk_buff中的network_header指针指向链路层头部之后的数据。向后读取网络层头部完成网络层解包。传输层处理同理修改sk_buff的transport_header指针指向网络层头部之后的数据。读取传输层头部完成传输层解包。传输层解包完成后根据 TCP/UDP 协议把剩余的数据拷贝到对应传输层的接收缓冲区等待上层用户读取。发送数据时也会对数据进行封装操作系统会按照协议层次依次在数据前面添加对应的协议报头。但要注意应用层以下的封装和解包不能简单理解为 “每层都在频繁拷贝数据”。实际上数据包的主体存储位置通常并没有发生变化内核主要是通过sk_buff结构体中的不同指针来标记和操作同一组网络数据。例如mac_header指向链路层报头起始位置network_header指向网络层报头起始位置transport_header指向传输层报头起始位置data指向实际数据载荷。每层协议处理时并不需要把整个数据重新复制一遍而是让指针指向不同位置从而快速定位本层协议头部。这是sk_buff提升处理效率的重要原因。不过内核中的sk_buff并不像上述模型这么简单它需要同时满足两个核心目标1. 处理效率要高网络报文需要在链路层、网络层、传输层之间快速传递。如果每层都拷贝一次数据会产生很大的性能开销。因此sk_buff的设计必须尽量减少数据拷贝通过指针移动、内存区域共享和灵活的缓冲区管理提高协议处理速度。2. 兼容所有网络协议sk_buff不是为某一种协议单独设计的而是内核网络协议栈的通用数据结构。它必须能够同时支持 Ethernet、IP、TCP、UDP、ICMP、ARP 等不同协议因此结构设计需要足够通用既能描述各层报头又能管理复杂的报文操作。简单记忆sk_buff 像一张多功能标签纸贴在同一块网络数据上不同指针分别指向 MAC 头、IP 头、TCP/UDP 头和数据区。如何提高 IO 效率想办法在单位时间内让等待的比重降低这样 IO 的效率就提高了。二.五种 IO 模型我们可以把IO 的过程跟钓鱼的过进行类比的。钓鱼这件事同样可以拆成等和拷贝两个步骤。这里的等就是等待鱼咬钩拷贝就是鱼上钩之后把鱼从河里转移到鱼桶里。IO 操作里等待消耗的时间通常远大于数据拷贝的耗时钓鱼也是一样。钓鱼大部分时间都在静静等待鱼上钩一旦鱼咬钩只需要很短时间就能把鱼 “拷贝” 上岸。IO等待外设数据就绪等 拷贝数据到内存拷贝 钓鱼等待鱼咬钩等 把鱼捞进桶拷贝考点IO 的瓶颈大多在等待而不是拷贝数据。看下面五个人的不同钓鱼方式张三拿 1 根鱼竿鱼钩抛入水中后一直盯着浮漂不做其他任何事直到鱼上钩再挥竿把鱼钓上来。阻塞 IO李四拿 1 根鱼竿鱼钩抛入水中后可以去做别的事每隔一段时间就过来查看浮漂状态。如果鱼上钩就起竿没鱼就继续忙自己的事。非阻塞轮询 IO王五拿 1 根鱼竿鱼钩抛入水中在竿梢绑上铃铛之后就可以去做别的事情。铃铛响代表鱼上钩此时再来起竿铃铛不响就不用管这根鱼竿。信号驱动 IO赵六准备 100 根鱼竿全部抛入水中然后定期查看这 100 个浮漂。哪根鱼竿有鱼上钩就拉起对应的鱼竿。IO 多路复用 / 多路转接田七作为负责人他想钓鱼但需要立刻赶回公司开会。于是拿出一根鱼竿交给司机去垂钓。等司机把鱼桶装满之后再打电话通知他。异步 IO补充一下阻塞 IO全程死等不能干别的非阻塞轮询不断主动检查反复询问有没有就绪信号驱动外设就绪主动发信号通知IO 多路复用一次性监控多个 IO哪个就绪处理哪个异步 IO全程交给内核完成等待 拷贝完成后通知应用。张三、李四和王五钓鱼的效率一样吗张三、李四和王五钓鱼的整体效率本质是相同的。 他们钓鱼的底层流程没有区别都需要先等待鱼咬钩鱼上钩之后再把鱼钓上来。其次三人都只使用一根鱼竿等待鱼上钩。河里的鱼咬任意一个鱼钩的概率是均等的。所以张三、李四、王五三人钓鱼的效率相同差异只在于等待鱼上钩的方式不同。张三原地阻塞等待李四定时主动查看浮漂王五依靠铃铛收到通知。阻塞 IO、非阻塞轮询 IO、信号驱动 IO三者的等待时间和数据拷贝耗时基本一致效率上限相同只是等待的实现方式不同。它们都需要应用程序自己完成后续的数据拷贝操作。谁的效率更高赵六的效率更高。赵六可以同时监控多根鱼竿让等待的时间重叠在一起单位时间内有鱼上钩的概率大幅提升。假设赵六准备 97 根鱼竿张三、李四、王五各持 1 根总共 100 根鱼竿。鱼咬任意鱼钩的概率均等鱼咬到张三、李四、王五单根鱼竿的概率各为 1%而咬到赵六鱼竿的总概率达到 97%。在单位时间内赵六这边出现鱼上钩事件的概率是张三、李四、王五单人的 97 倍。高效钓鱼的核心就是减少无效等待的占比增加实际捞鱼拷贝的时间。赵六可以一次性等待多个鱼竿实现等待时间复用所以四个人当中赵六的效率最高。如何看待田七钓鱼方式田七把钓鱼这件事交给司机去完成自己返回公司处理别的事务。他并不关心司机采用哪种方式钓鱼司机可以用张三、李四、王五、赵六任意一种方案田七只需要等司机通知确认鱼桶是否装满。田七本人不参与钓鱼的全过程只是下发钓鱼任务实际钓鱼的是司机。在司机钓鱼的这段时间里田七可以去做其他任何工作。如果把钓鱼类比成 IO 操作田七这种模式就是异步 IO。反观张三、李四、王五、赵六都需要自己等待鱼上钩鱼上钩之后还要自己完成捞鱼动作。映射到 IO 模型里代表应用程序需要自己完成数据拷贝所以这四种都属于同步 IO。同步 IO应用进程需要亲自处理等待或者数据拷贝。异步 IO内核负责完成等待 数据拷贝全部工作任务全部完成后通知应用程序应用全程不用参与等待和拷贝。简单记忆同步 IO 要自己动手等或拷贝异步 IO 全部交给内核做完通知你。通过这个钓鱼例子可以看出阻塞 IO、非阻塞 IO 和信号驱动 IO并不能提升 IO 本身的效率。但非阻塞 IO 和信号驱动 IO可以提升程序整体的任务处理效率。在这个钓鱼场景里所有事物都可以和 IO 模型里的概念一一对应鱼 -- 数据河 -- 内核浮标 -- 文件描述符的就绪事件每一个人 -- 执行流进程 / 线程司机 -- 操作系统鱼竿 -- 文件描述符 / 套接字装鱼的桶 -- 用户缓冲区1.阻塞 IO在内核将数据准备好之前系统调用会一直等待。阻塞 IO 是最常见的 IO 模型所有的套接字默认都是阻塞方式。图的过程如下调用 recvfrom 从套接字读取数据时如果底层数据还没有准备好进程就需要等待数据就绪数据就绪之后再把数据从内核拷贝到用户空间最后 recvfrom 函数才会返回。在 recvfrom 等待数据就绪的这段时间用户视角下进程 / 线程处于阻塞状态。本质上是操作系统把该进程或线程修改为非就绪状态放入等待队列。等数据就绪后操作系统再把它从等待队列唤醒接着进程完成内核到用户空间的数据拷贝。以阻塞方式执行 IO 的进程或线程在等待和拷贝的整个阶段函数都不会返回表现为程序卡住这就是阻塞 IO2.非阻塞 IO如果内核还未将数据准备好, 系统调⽤仍然会直接返回, 并且返回EWOULDBLOCK错误码.⾮阻塞IO往往需要程序员循环的⽅式反复尝试读写⽂件描述符, 这个过程称为轮询. 这对CPU来说是较⼤的浪费, ⼀般只有特定场景下才使⽤.当调用 recvfrom以非阻塞方式从套接字读取数据时如果底层数据尚未就绪recvfrom 会立刻返回错误不会让进程 / 线程阻塞等待。因为本次没有读到数据进程 / 线程需要反复调用 recvfrom主动检查底层数据是否就绪。每次检查如果数据依旧未就绪函数就继续报错返回直到某次调用检测到数据就绪才会把数据从内核拷贝到用户空间然后成功返回。每次调用 recvfrom 读取数据哪怕数据没有准备好函数都会立即返回。在用户视角进程或线程不会卡住这就是非阻塞 IO。阻塞 IO 和非阻塞 IO 的核心区别阻塞 IO 在数据未就绪时由操作系统负责等待和检测非阻塞 IO 在数据未就绪时由用户进程主动反复发起检测轮询。阻塞 IO 让 OS 帮你等非阻塞 IO 自己循环反复查查到就绪为止。3.信号驱动 IO内核将数据准备好的时候, 使⽤SIGIO信号通知应⽤程序进⾏IO操作当底层数据就绪后会向当前进程 / 线程发送 SIGIO 信号。我们可以使用signal或者sigaction函数自定义 SIGIO 的信号处理函数把要执行的 IO 操作写在这个处理函数里面。一旦底层数据就绪系统就会自动执行这个信号处理函数。举例如果要调用recvfrom从套接字读取数据就可以把这个读取操作写进 SIGIO 的信号处理程序。 当底层数据就绪操作系统递送 SIGIO 信号会自动执行预先定义好的信号处理函数由进程完成将数据从内核拷贝到用户空间的操作。信号的产生是异步的但信号驱动 IO 属于同步 IO。 信号产生是异步的信号可以在任意时刻触发。但信号驱动 IO 仍然归类为同步 IO当数据就绪触发信号后进程需要暂停当前工作去执行数据拷贝操作进程依旧需要亲自参与 IO 的拷贝环节。判断一个 IO 过程是同步还是异步的其本质就是看当前进程或线程是否需要参与 IO 过程若参与即为同步 IO否则为异步 IO。4.IO 多路转接从流程图上看起来和阻塞 IO 有些类似IO 多路转接也被称为 IO 多路复用实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。IO 多路转接的思想IO 的过程分为等待和拷贝两个步骤recvfrom这类接口底层实际上会完成两件事数据未就绪时执行等待数据就绪后执行数据拷贝。虽然recvfrom本身具备等待能力但这类接口一次只能等待一个文件描述符的就绪事件IO 处理效率很低。因此系统提供了三组接口select、poll、epoll。这一组多路转接接口的核心工作就是专门负责等待我们可以把全部等待工作交给它们。多路转接接口可以一次性同时等待多个文件描述符实现多个 IO 的等待时间重叠。当检测到某个文件描述符数据就绪后再调用对应的recvfrom函数完成数据拷贝此时调用recvfrom不再需要等待直接执行拷贝。IO 多路转接可以类比成帮多人排队的黄牛多路转接接口本身不做数据拷贝只负责等待。黄牛可以一次性帮多个人排队让多个人排队等待的时间重叠在一起。5.异步 IO由内核在数据拷贝完成时通知应用程序而信号驱动是告诉应用程序何时可以开始拷贝数据。进行异步 IO 时需要调用异步 IO 专用接口。异步 IO 接口调用之后会立刻返回。异步 IO 不需要调用者自己做 “等待” 和 “拷贝”等待数据就绪、内核向用户空间拷贝数据这两步全部由操作系统完成应用程序只需要发起 IO 请求。当整套 IO 操作全部完成之后操作系统再来通知应用程序。所以执行异步 IO 的进程 / 线程不需要参与 IO 过程里的任何细节。6.小结任何 IO 过程都包含两个步骤等待 和 拷贝。在真实业务场景里等待消耗的时间通常远大于拷贝耗时。想要提升 IO 效率最核心思路就是尽可能减少单位时间内的等待耗时。三.高级 IO 的重要概念1.同步通信 VS 异步通信Synchronous Communication / Asynchronous Communication同步与异步关注的是消息通信的机制。所谓同步发起调用之后如果没有拿到结果调用就不会返回。一旦调用返回就能拿到返回值。简单来说由调用者主动等待调用结果。异步刚好相反调用发出后会直接返回此时还没有得到最终结果。也就是说发起异步调用后调用者无法立刻获取结果。后续由被调用方通过状态标记、通知或者回调函数的方式告知调用者完成这次调用的后续处理。为什么非阻塞 IO 在没有得到结果之前就返回了IO 分为等待和拷贝两个步骤。调用recvfrom执行非阻塞 IO 时如果数据还未就绪函数会直接返回。这次返回并不代表完成了一次完整 IO属于错误返回。所以进程 / 线程需要反复调用recvfrom通过轮询持续检测数据是否就绪。直到某次轮询发现数据就绪将数据从内核拷贝到用户空间才算完成一次完整的 IO。因此非阻塞 IO 虽然在没拿到数据时就提前返回但进程后续还要不断轮询检测。可以理解为这次 IO 任务并没有真正结束只有当轮询检测到数据就绪并且完成数据拷贝才算这次调用真正完成。在学习多进程、多线程的时候我们也提到过同步和互斥但这里的同步通信和进程间的同步是完全不相干的概念。进程 / 线程同步是在保证数据安全的前提下让进程、线程按照特定顺序访问临界资源避免饥饿问题描述的是多个进程或线程之间的协作关系。同步 IO描述的是进程 / 线程和操作系统之间的关系关注进程 / 线程是否需要主动参与 IO 的处理过程。注意尤其是在访问临界资源的时候一定要分清这个 “同步”到底是同步 / 异步通信里的同步还是同步与互斥里的同步。2.阻塞 VS 非阻塞阻塞和非阻塞关注的是程序在等待调用结果消息返回值时的状态。阻塞调用是指调用结果返回之前当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前该调用不会阻塞当前线程。3.其他⾼级IO⾮阻塞IO纪录锁系统V流机制I/O多路转接也叫I/O多路复⽤,readv和writev函数以及存储映射IOmmap这些统称为⾼级IO.我们此处重点讨论的是I/O多路转接4.妖怪蒸唐僧的例子4 种组合对应同步 / 异步 阻塞 / 非阻塞区分两组独立概念 同步 / 异步谁通知结果 阻塞 / 非阻塞等待时线程会不会挂起同步阻塞妖怪点着火一直守在蒸锅旁边原地不动什么别的事都不干。直到锅烧开、唐僧蒸熟才开始下一步。等待期间妖怪完全卡住不能做任何其他工作。同步非阻塞妖怪点着火去山洞干别的活。每隔一段时间过来查看蒸锅没熟就回去继续干活隔一会再来检查。不会原地卡住但必须自己反复主动轮询查看状态。异步阻塞几乎不用了解即可妖怪安排小妖盯着蒸锅熟了之后来通知妖怪。但妖怪不离开原地坐着等小妖消息什么事都不干。现实中基本不会这么用异步非阻塞妖怪吩咐小妖看管蒸锅蒸熟之后再来通知自己。交代完事情妖怪直接离开去做别的工作不用守在蒸锅旁边也不用主动过来查看。等全部完成小妖再来通知妖怪。小结同步妖怪自己要参与后续处理异步交给小妖完成全部流程做完通知妖怪阻塞等待的时候原地不动不能干别的非阻塞等待的时候可以去做其他任务。