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

一.IO 的基本概念

I/O(input/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 结构:

cpp 复制代码
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. 张三:拿 1 根鱼竿,鱼钩抛入水中后,一直盯着浮漂,不做其他任何事,直到鱼上钩,再挥竿把鱼钓上来。(阻塞 IO)
  2. 李四:拿 1 根鱼竿,鱼钩抛入水中后,可以去做别的事,每隔一段时间就过来查看浮漂状态。如果鱼上钩就起竿,没鱼就继续忙自己的事。(非阻塞轮询 IO)
  3. 王五:拿 1 根鱼竿,鱼钩抛入水中,在竿梢绑上铃铛,之后就可以去做别的事情。铃铛响代表鱼上钩,此时再来起竿;铃铛不响就不用管这根鱼竿。(信号驱动 IO)
  4. 赵六:准备 100 根鱼竿,全部抛入水中,然后定期查看这 100 个浮漂。哪根鱼竿有鱼上钩,就拉起对应的鱼竿。(IO 多路复用 / 多路转接)
  5. 田七:作为负责人,他想钓鱼,但需要立刻赶回公司开会。于是拿出一根鱼竿,交给司机去垂钓。等司机把鱼桶装满之后,再打电话通知他。(异步 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 的进程或线程,在等待和拷贝的整个阶段函数都不会返回,表现为程序卡住,这就是阻塞 IO


2.非阻塞 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函数以及存储映射IO(mmap),这些统称为⾼级IO.

我们此处重点讨论的是I/O多路转接


4.妖怪蒸唐僧的例子(4 种组合,对应同步 / 异步 + 阻塞 / 非阻塞)

区分两组独立概念: 同步 / 异步:谁通知结果 阻塞 / 非阻塞:等待时线程会不会挂起

  1. 同步阻塞 妖怪点着火,一直守在蒸锅旁边,原地不动,什么别的事都不干。直到锅烧开、唐僧蒸熟,才开始下一步。等待期间妖怪完全卡住,不能做任何其他工作。
  2. 同步非阻塞 妖怪点着火,去山洞干别的活。每隔一段时间过来查看蒸锅,没熟就回去继续干活,隔一会再来检查。不会原地卡住,但必须自己反复主动轮询查看状态。
  3. 异步阻塞(几乎不用,了解即可) 妖怪安排小妖盯着蒸锅,熟了之后来通知妖怪。但妖怪不离开,原地坐着等小妖消息,什么事都不干。(现实中基本不会这么用)
  4. 异步非阻塞 妖怪吩咐小妖看管蒸锅,蒸熟之后再来通知自己。交代完事情妖怪直接离开,去做别的工作,不用守在蒸锅旁边,也不用主动过来查看。等全部完成,小妖再来通知妖怪。

小结:

  • 同步:妖怪自己要参与后续处理;
  • 异步:交给小妖完成全部流程,做完通知妖怪;
  • 阻塞:等待的时候原地不动,不能干别的;
  • 非阻塞:等待的时候可以去做其他任务。
相关推荐
iCxhust1 小时前
51单片机电力载波应用开发(3)载波模块点对点收发
网络·嵌入式硬件·51单片机
一条破秋裤1 小时前
Linux 线程分离与主动取消:pthread_detach、pthread_cancel
java·linux·jvm
treesforest1 小时前
网络安全中的IP地址查询:从日志里的一条线索到溯源的关键证据
网络·web安全·php
IT大白鼠1 小时前
彭大帅的AI运维助手——自然语言管理 Linux 集群与网络设备——第 0 篇 · 导读:把 Linux 运维交给 AI,到底靠谱吗
linux·运维·人工智能
分布式存储与RustFS2 小时前
自托管对象存储的三种 TLS 签发:自签、Let‘s Encrypt、内网 CA 的选择与轮换
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
AI智讯中枢2 小时前
高性能 C++ 实战 (五):perf+FlameGraph 火焰图生产实战,精准定位 CPU / 缓存 / 锁瓶颈,避坑 + 完整实操案例
linux·c++·性能调优·性能分析·perf·flamegraph·火焰图
liangshanbo12152 小时前
面试题:HTTP/2有哪些升级
网络·网络协议·http
Li-Yongjun2 小时前
fuser
运维
William Dawson2 小时前
kkFileView 内网 ARM 服务器全链路部署:从「找不到 office」到全绿通关(超详细排障实录)
运维·服务器