Linux 高级IO(一)理解IO及其本质,理解五种IO模型,非阻塞IO,fcntl

目录

一、关于IO的理解

什么是IO?

IO的本质?

二、理解五种IO模型

故事:

结论:

三、深入理解五种IO模型

阻塞IO

非阻塞IO

信号驱动IO

多路转接

异步IO

四、非阻塞IO的实现

fcntl

编码实现

[阻塞 IO](#阻塞 IO)

设置为非阻塞

完整代码:

testNonBlock.cc

五、总结


前面我们已经完整讲完了网络协议栈和网络基础,接下来我们进入新的核心话题:Linux 高级 IO

本质上,网络通信本身就是一次 IO 操作 ------ 我们通过网络进行读写数据,就是在做网络 IO。所以接下来我们不再聚焦套接字、IP、端口这些网络连接的基础概念,而是把重心放在如何高效完成数据收发上,也就是学习高效的 IO 模型,搞懂怎样实现高性能的网络通信。接下来,我们就来真正认识 IO,深入理解 Linux 下的五种 IO 模型与非阻塞 IO。

一、关于IO的理解

什么是IO?

IO 的全称是 Input/Output,本质上就是内存和外设之间互相搬运数据的过程。我们平时接触的所有数据交互,都属于 IO 的范畴:如果数据是在内存和磁盘之间传输,那就是文件 IO;如果是和显示器交互,就是显示 IO;和键盘交互,就是输入 IO;而和网卡之间的数据收发,就是网络 IO。

可以说,我们前面聊的所有网络通信,本质上都属于 IO 的范畴。当我们调用 recv 接收数据、send 发送数据时,本质上就是在做网络 IO 操作。理解了这个核心定义,我们后面讲五种 IO 模型、非阻塞 IO,就都有了基础的理解前提。

IO的本质?

如上图,我们在应用层调用 read 读取网络数据时,本质是把操作系统内核中 TCP 接收缓冲区的数据拷贝到用户层缓冲区。如果此时 TCP 接收缓冲区里没有数据,read 就会阻塞,一直等待,直到接收缓冲区收到数据后再完成拷贝。也就是说,read 阻塞的本质,就是在等待 TCP 接收缓冲区有数据可读。

同理,调用 write 发送数据时,是把用户层缓冲区的数据拷贝到操作系统内核的 TCP 发送缓冲区。如果发送缓冲区已满、没有剩余空间,write 就会阻塞,一直等待,直到发送缓冲区腾出空间,再把数据拷贝进去;后续由操作系统负责实际的网络发送、流量控制、重传等细节。write 阻塞的本质,就是在等待 TCP 发送缓冲区有空闲空间可写。

可见,一次 IO 系统调用(read/write)包含两个阶段:等待缓冲区就绪 + 数据拷贝。因此 IO 的本质可以总结为:IO = 等待 + 拷贝。等待是有条件的:read 要求接收缓冲区有数据,write 要求发送缓冲区有空间;条件不满足时,系统调用就会阻塞等待。

IO = 等待 + 拷贝

什么叫高效的IO?

我们前面说过,IO 的本质是等待 + 拷贝 。而我们平时感觉 IO 慢,原因就是在一次 IO 过程中,等待的时间占比太高了 。比如用 scanf 等待用户输入,大部分时间程序都在空等,真正执行数据拷贝的时间很短。而数据拷贝的效率,主要取决于硬件性能,软件层面很难再做大幅优化。所以,提升 IO 效率的关键,根本不在 "拷贝" 环节,而在 "等待" 环节。

所以,高效 IO 的核心定义就是:在单位时间内,IO 过程中等待的时间占比越低,IO 的效率就越高。

如何设计高效的IO?

基于这个定义,我们设计高效 IO 的目标就很明确了:要尽可能降低 IO 操作中等待的比重,让程序在等待 IO 就绪的间隙,也能做其他事情,而不是一直阻塞在原地。

二、理解五种IO模型

  1. IO分为五种,即五种IO模型,既然都是模型了,所以也就意味着无论是何种方式进行的IO都无法逃脱这五种IO模型的范畴,为了让大家更好的理解五种IO模型,下面我们就以钓鱼的例子为场景引入五种IO模型,我们知道关于钓鱼,一般情况下要找个位置,鱼竿上放上鱼漂鱼饵之后,所以此时就开始进行钓鱼的过程了,然后陷入进行等待直到鱼漂上下浮动了之后,然后一拉鱼竿将鱼钓上来,所以既然IO = 等待 + 拷贝 ,同样的我们也可以将IO简单的替换理解为钓鱼,钓鱼 = 等待 + 钓。

故事:

  1. 张三是一个钓鱼新手,他拿着钓鱼的装备,去岸边钓鱼,鱼竿上放上鱼漂鱼饵之后,所以此时张三就开始钓鱼了,张三就目不转睛的盯着鱼竿上的鱼漂有没有上下浮动,来了电话张三也忽略,那么张三等待观察许久终于鱼漂开始上下浮动了,所以此时张三拿起鱼竿一提就将鱼钓上来了。

  2. 所以张三这种钓鱼方式是一旦开始钓鱼,那么就目不转睛的盯着鱼漂,观察等待鱼漂上下浮动,在这个过程中张三没有做其它事情,即使如上的电话打给张三,张三也忽略,所以这种钓鱼方式我们成为阻塞式钓鱼,由于IO可以理解为钓鱼,所以第一种IO模型,即阻塞式IO。

  3. 李四是一个有2年钓龄的钓友,他拿着钓鱼的装备去岸边钓鱼,发现了张三,然后喊一下张三,但是此时张三没有理他,别忘了此时张三是阻塞式钓鱼,所以对于除了观察鱼漂等待鱼漂上下浮动的事情张三都会忽略,此时李四想了一下,好吧,你不理我我就在你旁边钓鱼吧。

  4. 所以此时李四在鱼竿上放上鱼漂鱼饵之后,李四也开始等待鱼漂上下浮动,但是李四还拿了书,所以此时他打开书开始看书,那么每隔一会他就观察一下鱼漂有没有上下浮动,如果鱼漂没有上下浮动,那么李四就继续看书,如果鱼漂上下浮动了,那么李四就拉杆把鱼钓上来。

  5. 所以李四看一会儿书,然后观察一下鱼漂,看一会儿书,观察一会儿鱼漂,终于看完书之后,再观察鱼漂,鱼漂此时上下浮动了,所以此时李四就拉杆,也成功的把鱼钓上来了,所以李四这种钓鱼方式是非阻塞式钓鱼,由于IO可以理解为钓鱼,所以第二种IO方式,即非阻塞式IO,也叫做非阻塞轮询。

  6. 王五是一个有5年钓龄的钓友,他拿着钓鱼的装备去岸边钓鱼,那么王五在鱼竿上放上鱼漂鱼饵之后,特别的,王五带了一个独特的铃铛到鱼漂上,只要鱼漂上下浮动,那么铃铛就会响,所以王五也拿了一本书,那么他就不需要像李四一样,间隔一会儿就看一下鱼漂有没有上下浮动。

  7. 王五开始钓鱼后,王五可以一直看书,十分的悠闲,只要铃铛不响,那么就代表没有鱼咬钩,那么王五就开始悠闲的看书了,看了一会儿之后,突然,铃铛响了,所以此时王五就拉杆,成功将鱼钓上来了,所以铃铛的响声对于王五来讲是一种信号,当王五听到这种信号之后已经有了特定的动作,即王五就要拉杆,将鱼钓上来,所以王五这种钓鱼方式是信号驱动式钓鱼,由于IO可以理解为钓鱼,所以第三种钓鱼方式,即信号驱动式IO。

  8. 赵六是县里的首富,赵六也喜欢钓鱼,赵六比较有钱,所以赵六买了100根鱼竿以及对应的装备,所以赵六在鱼竿上放上鱼漂鱼饵之后,赵六也开始钓鱼了,所以赵六就在岸边遍历鱼竿,依次观察鱼漂有没有上下浮动,所以很快,赵六观察到了鱼漂上下浮动了,所以赵六一拉杆,将鱼钓上来了。

  9. 赵六和张三,李四,王五都不同,赵六使用的是100根鱼竿,张三,李四,王五使用的都是一根鱼竿,那么假设一个鱼在一定时间内咬钩的概率是1/1000,所以张三,李四,王五在一定时间内上鱼的概率是1/1000,那么由于赵六使用的是100根鱼竿,所以赵六在一定时间内上鱼的概率是100 * (1/1000) = 1 / 10。

  10. 相对于张三,李四,王五使用的都是一根鱼竿上鱼等待时间是串行的,赵六使用的100根鱼杆上鱼的时间是并发的,是重合的,所以也就注定了赵六的等待时间会变少,赵六的等待时间在整个钓鱼的过程中比重减少了,所以赵六的钓鱼效率就更高,IO也是类似的道理,所以赵六的钓鱼方式叫做多路复用式钓鱼,也叫做多路转接式钓鱼,IO可以理解为钓鱼,所以第五种IO模型,即多路复用,也叫做多路转接。

  11. 田七是整个市的首富,田七是一个公司的大老板,所以注定了田七有很多商务要忙,但是田七喜欢吃鱼,同样的田七也稍微学了一下钓鱼,田七略微喜欢钓鱼,田七有一个司机叫做小王,田七跟小王讲,走,拿着钓鱼的装备放到车上,小王你开着车带着我去钓鱼。

  12. 所以小王就拿着钓鱼的装备放到了车上,开着车去往岸边钓鱼,到了位置之后,田七正要下车,此时田七的公司来电话了,需要田七这个大老板去开一个公司里的会议,田七想了一下,好吧,感觉也不是那么喜欢钓鱼了,我田七只是喜欢吃鱼,所以田七告诉小王,小王呀,你拿着钓鱼的装备,去岸边钓鱼吧,当你钓完鱼之后打电话通知我,我来接你,车我开走了。

  13. 所以此时小王一想,不错呀,上班还可以钓鱼休闲,所以小王就直接答应了老板田七,所以田七开着车走了,小王也是一个钓鱼高手,所以经过几个小时,钓箱已经被掉满了,并且小王也估摸着老板田七的会议应该也差不多开完了,于是小王就给老板田七打电话,老板鱼我已经钓好了,老板说,好的,正好我的会议也开完了,我开着车来接你。

  14. 所以老板田七开着车来了见到了小王,老板田七说,干得不错小王,满满一钓箱都钓满了,所以老板田七就获得了满满一钓箱的鱼,正好大快朵颐吃好多的鱼了,在这个过程过程中,田七没有参与钓鱼的过程,但是收获了满满一钓箱的鱼,所以在这个过程中,田七只是钓鱼行为的发起者,让小王来帮我钓鱼,钓鱼的结果给我田七,田七要的只是鱼。

结论:

  1. 所以田七的钓鱼方式叫做异步钓鱼,由于IO可以理解为钓鱼,所以第五种IO模型,即异步IO,此时我们可以想一下,类似的,如果用户想要进行异步IO,那么这里的用户就相当于田七,那么用户告诉操作系统,你帮我进行等待,完成数据的拷贝,拷贝完成后,通知我一下就可以了,所以操作系统就相当于小王。

  2. 既然有异步IO,类似的,同步IO也应该有,同步IO就是前四种模型,即同步IO有阻塞式IO,非阻塞式IO,信号驱动式IO,多路复用(多路转接),所以异步IO和同步IO的区别是什么呢?核心区别在于有没有参与IO的过程。

  3. 对于异步IO来讲,田七只是让小王去钓鱼,田七本身并不参与钓鱼过程,田七只是拿小王钓鱼后满满一钓箱的鱼,异步IO仅仅是发起IO,不参与IO过程 ,异步IO只需要最后拿IO的结果就可以,同步IO(阻塞式IO,非阻塞式IO,信号驱动式IO,多路转接)都需要亲自参与IO这个过程,只要进行等待和拷贝了,那就是参与了IO的过程,就是同步IO。诸如张三,李四,王五,赵六都进行了等待,只不过各自等待的方式不同。

  4. 张三是一直进行等待检查鱼漂有没有上下浮动,李四看一会儿书,然后检查鱼漂有没有上下浮动,王五是看一会书,看书也是一种等待呀,等待的是鱼咬钩,赵六是遍历来回检查检查鱼漂有没有上下浮动也是一种等待,等待鱼漂上下浮动。

  5. 钓鱼 = 等待 + 钓,所以只要等待发现鱼漂上下浮动,那么一拉杆,将鱼钓上来,也是参与了钓鱼的过程,所以张三,李四,王五,赵六参与了钓鱼的过程,IO可以理解为钓鱼,所以阻塞式IO,非阻塞式IO,信号驱动式IO,多路复用(多路转接)参与了IO过程,所以阻塞式IO,非阻塞式IO,信号驱动式IO,多路复用(多路转接)是同步IO。那剩下的最后一个异步IO就是异步IO了。

  6. 阻塞式IO和非阻塞式IO有什么区别呢?IO = 等待 + 拷贝,无论是阻塞式IO还是非阻塞式IO都要进行等待和拷贝,拿钓鱼为例,张三是阻塞式钓鱼,张三会一直等待盯着鱼漂有没有上下浮动,李四是非阻塞式钓鱼,李四会看书,间隔一会,然后观察鱼漂有没有上下浮动,李四看书是等待是方式。

  7. 无论是张三还是李四在看到鱼漂进行了上下浮动之后都会拉杆然后将鱼钓上来,钓鱼 = 等待 + 钓,对于钓来讲都是相同的,所以阻塞式钓鱼和非阻塞式钓鱼的核心区别在于等待的方式不同,IO可以理解为钓鱼,所以阻塞式IO和非阻塞式IO的核心区别在于等待的方式不同,阻塞式IO当读写事件不满足的时候会一直阻塞在调用的系统调用中。

  8. 非阻塞式IO当读写事件不满足的时候,则会立即返回,然后可以做一些其它的事情,例如检测状态,打印日志等,所以对于非阻塞式IO来讲与其阻塞式的等待,非阻塞式IO直接返回去做其它一些事情,这里非阻塞式IO去做了其它的事情,但非阻塞式IO也进行了等待,只不过等待读写事件就绪的方式是去做一些其它的事情,当做完了其它事情之后,就会再轮询的去系统调用中看一下读写事件是否满足,如果满足了那么就进行拷贝数据,否则继续直返回然后去做其它事情,这样重复。

  9. 所以对于钓鱼来说,张三和李四的效率是一样的,也就是阻塞IO和非阻塞IO的效率还是一样的,但为什么非阻塞IO给人一种单位时间内能做更多事的感觉?的确,非阻塞IO确实做了更多的事,但是归根结底这里我们讨论的是IO的效率。所以总结一下就是阻塞IO和非阻塞IO的IO效率是一样,只是非阻塞可以在单位时间内做更多的事情(本质是把等待的时间利用起来了)。

  10. 我们之前还学过线程同步,那么线程同步和这里的同步IO有关系吗?没有关系,就像老婆和老婆饼一样,没有关系,线程同步是指两个线程在某些条件下两个线程谁先执行,谁后执行,而这里的同步IO是指参与了IO的过程,所以两个毫无关系,两者只是不同的领域恰好使用了相同的同步这个名词而已。

  11. 所以5种IO模型,分别是阻塞式IO,非阻塞式IO,信号驱动式IO,多路复用(多路转接),异步IO,那么由于这是模型,所以所有的IO方式都逃脱不开这5种IO模型的范畴,即所有的IO方式都是着5种IO模型的一种,那么其中最值得我们学习的,效率最高的IO模型是什么呢?

  12. 有的读者友友可能会想,应该是异步IO吧,自己不用做事情,交给其它人做事情效率多高,实则不然,在钓鱼的例子中,虽然田七让小王钓鱼,但是小王钓鱼的装备也只是一套,所以效率没有提高,并且在实际使用中,异步IO这种方式编写出来的代码逻辑一般都比较混乱,不易维护。

  13. 所有的 IO 方式,都逃不开这五种模型的范畴。那哪种模型是最值得我们学习的呢?很多人会觉得异步 IO 效率最高,但在实际使用中,异步 IO 的代码逻辑复杂、维护成本高,并不适合大多数场景。真正最实用、效率最高的,其实是多路复用(多路转接) 。它的优势在于能实现并发等待,让多个连接的等待时间重叠,大幅降低等待时间在整个 IO 过程中的占比,从而提升整体效率,这也是高性能网络编程的核心技术之一。

  14. 下面我们详细的再来深入认识五种IO模型。

三、深入理解五种IO模型

阻塞IO

阻塞 IO 是日常编程里默认的 IO 模型,我们最常使用的 read、write、recvfrom 这类系统调用,默认都是阻塞式的。

从执行流程来看:应用进程调用 recvfrom 发起系统调用,想要从内核 TCP 接收缓冲区读取数据。如果此时内核接收缓冲区里没有数据,内核不会立刻返回,而是让当前进程阻塞挂起 ------ 进程的 PCB 会从运行队列移到等待队列,进程全就会卡在这次系统调用上,什么事都做不了,一直等待数据就绪。

等到网卡收到数据、内核 TCP 接收缓冲区准备好数据后,内核会唤醒被阻塞的进程,接着把数据从内核缓冲区拷贝到用户空间缓冲区;拷贝完成后,recvfrom 调用才正式返回,应用进程拿到数据,继续往下执行后续业务逻辑。

整个过程清晰对应了 IO 的本质:内核等待数据就绪 + 用户空间数据拷贝,而阻塞 IO 的特点就是:全程阻塞等待和拷贝,进程在整个 IO 期间无法做其他工作。

非阻塞IO

我们顺着前面阻塞 IO 的逻辑,来看非阻塞 IO。

和默认的阻塞 IO 不同,非阻塞 IO 需要程序员手动设置文件描述符为非阻塞模式,最常用的方式就是通过 fcntl 函数来配置。一旦设置完成,后续的 read、recvfrom 这类系统调用,就不再会傻傻地等待数据就绪了。

非阻塞 IO 的核心逻辑是轮询。当应用进程调用 recvfrom 时,如果内核缓冲区里还没有数据,系统调用不会阻塞,而是立刻返回一个错误,错误码通常是 EWOULDBLOCK。这就告诉进程:"数据还没准备好,你先去忙别的吧。" 进程收到这个信号后,就可以去处理其他任务,比如处理业务逻辑、打印日志,而不是一直卡在原地。等忙完一轮后,进程会再次发起 recvfrom 调用,检查数据是否就绪。这个 "检查 - 返回 - 做其他事 - 再检查" 的循环,就是轮询。

就像我们钓鱼故事里的李四,他不会一直盯着鱼漂,而是看一会儿书,再抬头看一眼鱼漂。在非阻塞 IO 里,进程在两次轮询之间的时间,就是用来做这些 "看书" 的其他工作。这个过程会一直持续,直到内核缓冲区终于有了数据。这时,recvfrom 就会进入第二个阶段:将数据从内核缓冲区拷贝到用户空间缓冲区,拷贝完成后调用返回,进程拿到数据继续处理。

需要注意的是,虽然非阻塞 IO 让进程在等待数据时可以做其他事,但它并没有减少 "等待" 本身的时间,只是把这段时间利用了起来。而且,这种方式也有缺点:如果轮询频率太高,会持续占用 CPU 资源,导致性能下降。因此,非阻塞 IO 通常不会单独使用,而是会配合事件通知机制一起工作。

信号驱动IO

我们再来看信号驱动 IO,这是五种 IO 模型里设计最巧妙、但实际使用率很低的一种IO。

信号驱动 IO 的核心逻辑,是让内核用信号通知的方式,来告诉应用进程 "数据已经准备好了"。应用进程首先调用 sigaction,为 SIGIO 信号注册一个信号处理函数,然后进程就可以继续执行自己的业务代码,不用再阻塞或轮询等待。当内核把数据准备好后,会主动向进程发送 SIGIO 信号,进程收到信号后,会中断当前执行流程,跳转到预先注册的信号处理函数中,在函数里调用 recvfrom 把数据从内核缓冲区拷贝到用户空间。

这种方式看起来很理想,进程不用主动等待,数据就绪后内核会主动通知,看起来像是异步,但它依然属于同步 IO。因为数据就绪后,进程还是需要亲自调用系统调用来完成 "拷贝" 这一步,而不是由内核直接把数据送到用户空间。

但信号驱动 IO 在实际中很少被使用,主要有两个硬伤:

  1. 信号丢失风险高:内核记录信号的方式只有位图,同一时间多个 IO 事件触发的信号可能会被覆盖,导致进程收不到通知。
  2. 信号处理复杂:信号处理函数中能做的操作非常受限,而且会打断进程正常的执行流,容易引入竞态条件等问题。

总的来说,信号驱动 IO 的思路很巧妙,但受限于信号机制本身的缺陷,在高 IO 并发场景下并不稳定,所以现在很少有系统会直接采用这种模型。

多路转接

我们来看第四种 IO 模型:多路转接(多路复用),这也是高性能网络编程中最核心的模型之一。

多路转接的核心思路,就是解决前面几种模型的效率瓶颈。我们前面说过,IO 的本质是等待 + 拷贝,在阻塞 IO 中,拷贝数据的时间往往只占整个 IO 过程的千分之一,剩下的时间进程都在傻傻等待。多路转接的目标,就是让这些等待时间 "重叠" 起来,大幅降低等待的占比,从而提升整体效率。

它的实现方式,是通过 select、poll 或 epoll 这类系统调用,让内核同时监听多个文件描述符的就绪状态。应用进程会先调用 select,把所有关心的文件描述符都交给内核,然后阻塞在 select 调用上,等待其中任何一个描述符就绪。

当内核检测到某个文件描述符(比如某个 socket)的接收缓冲区有数据时,select 就会返回,并告诉进程:"第 N 个文件描述符已经就绪了,可以来读数据了"。这时进程再调用 recvfrom,直接从内核缓冲区拷贝数据到用户空间 ------ 因为数据已经准备好了,这次拷贝过程不会再有等待,能立刻返回。

所以我们可以这样理解:select 专门负责 "等待",recvfrom 专门负责 "拷贝"。多路转接的高效之处,就在于它用一次系统调用,同时等待了成百上千个文件描述符,让进程在同一个等待周期里处理多个连接的事件,而不是像阻塞 IO 那样,一个连接就占满整个进程的等待时间。这就像我们故事里同时盯 100 根鱼竿的赵六,虽然他也在等鱼上钩,但一次能看 100 根,整体效率自然高得多。

异步IO

我们来看最后一种 IO 模型 ------ 异步 IO,这也是和前面所有同步 IO 模型最本质不同的一种。

异步 IO 的核心,就是让用户进程完全从 IO 过程中解放出来。应用进程调用 aio_read 这类异步 IO 接口,把用户缓冲区地址和回调信号交给操作系统,然后就可以立刻返回,继续执行自己的业务代码,不需要关心数据有没有准备好。

操作系统收到请求后,会自己完成整个 IO 流程:先等待内核缓冲区数据就绪,再把数据从内核缓冲区拷贝到用户预先指定的缓冲区中。从等待到拷贝,全程都由操作系统完成,用户进程不需要参与任何一步。当拷贝彻底完成后,操作系统会向用户进程发送一个信号,通知它:"数据已经在你指定的缓冲区里了,可以直接处理了。"

这就是异步 IO 和信号驱动 IO 最关键的区别:信号驱动 IO 只是通知你 "数据准备好了,你自己来拷贝",而异步 IO 是直接帮你把 "等待 + 拷贝" 都做完,最后通知你 "IO 完成了,数据已经在你手里了"。

不过异步 IO 在实际开发中使用场景并不多,甚至可以说是 "鸡肋"。一方面,多路复用技术已经能很好地解决高并发问题;另一方面,C++ 协程等新特性也让同步 IO 的代码写法变得更高效。再加上异步 IO 的编程模型复杂、调试困难,因此在 Linux 服务端开发中,它并不是主流选择。

总结:

这里信号驱动IO和异步IO我们不做重点讲解,剩下的非阻塞IO和多路转接我们需要讲,我们先看非阻塞IO的实现。

四、非阻塞IO的实现

接下来,我们重点来看非阻塞 IO 在 Linux 中的具体实现方式。

要让系统调用以非阻塞模式运行,主要有两种常见的方法,这两种方式分别对应了 "单次调用生效" 和 "文件描述符全局生效" 两种不同的粒度。

第一种方式,是在调用 recv 或 recvfrom 这类 IO 系统调用时,通过传入特定的 flag 参数来开启非阻塞。比如,我们可以将 recv 的 flags 参数设置为 MSG_DONTWAIT。这个标志的作用,就是告诉内核:"这次调用我不想等数据就绪,如果数据没准备好,请直接返回错误,不要阻塞我。" 这种方式的好处是灵活,它是单次调用级别的设置,不会影响其他对同一个文件描述符的后续调用。我们可以在需要的时候临时用一次非阻塞,其他时候保持默认的阻塞行为。

第二种方式,则是在文件或套接字被打开时,就通过 open 系统调用的 flags 参数,或在 fcntl 函数中为文件描述符设置 O_NONBLOCK(或 O_NDELAY)标志。这种方式是文件描述符级别的设置,一旦设置完成,后续所有对该文件描述符的 IO 操作,都会默认以非阻塞模式执行。比如我们用 open 打开一个文件时加上 O_NONBLOCK,或者用 fcntl 给一个 socket 套接字设置标志位后,之后所有的 read、write 操作都不会再阻塞。
简单来说,MSG_DONTWAIT 就像是给单次调用开了一个 "临时非阻塞" 开关,用完即止;而 O_NONBLOCK 则是给文件描述符设置了 "永久非阻塞" 模式,一劳永逸。这两种方式都能实现非阻塞 IO,只是适用场景和影响范围不同,我们可以根据实际需求来选择使用。

下面我们可以让AI大模型帮我们生成设置为非阻塞的方法:

为什么要文件描述符设置为非阻塞?

我们之所以要把文件描述符设置为非阻塞,核心目的是为了打破阻塞 IO 的效率瓶颈。在默认的阻塞模式下,read、recv 这类系统调用会一直等待数据就绪,期间进程会被挂起,无法处理任何其他任务;而非阻塞模式下,当数据未准备好时,系统调用会立即返回 EAGAIN 或 EWOULDBLOCK 错误,进程可以先去处理其他业务逻辑,后续再轮询检查数据状态。这不仅能避免进程被单一 IO 操作 "卡死",更重要的是,它是多路复用技术实现高并发的基础 ------ 只有文件描述符处于非阻塞模式,进程才能在 select/poll/epoll 通知事件就绪后,立即读取数据而不阻塞,从而让单线程高效处理成千上万的并发连接。

文件描述符是怎么和读写联系在一起的?

在 Linux 系统中,文件描述符是进程用于标识各类 IO 对象的整数句柄,它也是进程和读写操作产生关联的核心纽带。Linux 遵循 "一切皆文件" 的设计思想,磁盘文件、网络 Socket、管道、键盘、显示器等所有需要读写的设备,都会被内核统一抽象为文件;当进程通过 open()、socket()、accept() 等系统调用打开或创建这些对象时,内核会在进程的文件描述符表中为其分配一个唯一的整数编号,这个编号就是文件描述符,同时内核会记录该描述符对应的内核缓冲区与底层硬件设备。

后续进程执行 read()、write()、recv()等 读写操作时,必须传入对应的文件描述符,内核会通过这个编号精准定位到目标对象的内核缓冲区,进而完成 IO 本质的等待 + 拷贝过程。无论是阻塞 IO、非阻塞 IO 还是多路复用,所有读写行为都依赖文件描述符:阻塞 IO 通过 fd 找到缓冲区,无数据则阻塞等待;非阻塞 IO 通过 fd 上的O_NONBLOCK 标志控制读写行为;多路复用的 select/epoll 也是通过监听多个 fd 对应的缓冲区状态,实现并发等待。简单来说,文件描述符就像是进程访问 IO 对象的通行证,它建立起用户进程、内核缓冲区与硬件设备之间的映射关系,没有它,进程便无法指定要对哪个对象执行读写,这也是所有 IO 模型都离不开文件描述符的根本原因。

这里的缓冲区,如果 fd 是 TCP socket套接字,那缓冲区就是我们之前讲的 TCP 接收缓冲区和TCP 发送缓冲区;如果是普通文件,就是文件对应的内核页缓存。在 Linux 内核里,不同类型的文件描述符 (fd) 对应不同的内核缓冲区,我们前面讲的 TCP 收发缓冲区只是网络 Socket 专属的那一种。

如果我们打开的是磁盘文件,fd 对应的就不是 TCP 缓冲区了,而是文件页缓存 (Page Cache)。进程调用 read 读文件时,内核会先把磁盘数据读到页缓存,再拷贝到用户空间;写文件时也是先写到页缓存,后续内核再异步刷到磁盘,这是文件 IO 的缓冲区。如果我们用的是管道(pipe),那 fd 对应的是管道缓冲区,内核会维护一块环形内存,一端写、一端读,进程通过 fd 读写管道,本质就是读写这块内存缓冲区。其他的 fd,比如终端/键盘、显示器这类 fd,就对应终端行缓冲、内核 TTY 缓冲区,我们输入字符不会立刻交给进程,会先存在 TTY 缓冲区,按回车后才会被读取。

除此之外,应用层自己也会定义用户缓冲区,就是我们代码里 inbuf[ ] 这种数组,用来接收read/recv 拷贝过来的数据。

简单总结:fd 只是索引,指向不同类型的内核缓冲区,TCP 缓冲区只是网络场景下的特例,文件、管道、终端都有各自独立的内核缓冲区,所有 IO 本质都是操作这些缓冲区,完成等待 + 拷贝。

通过 fd 就能找到对应的内核缓冲区吗?而我们的系统调用就是直接作用在这些内核缓冲区中的吗?

是的,文件描述符 fd 本质就是一个整数索引,内核通过这个整数,在进程的文件描述符表里找到对应的内核对象,再精准关联到它专属的内核缓冲区。无论是 TCP socket、普通文件还是管道,每个 fd 都会绑定一块对应的内核缓冲区,比如 TCP 的收发缓冲区、文件的页缓存、管道缓冲区等。

而我们调用 read、write、recv 这类系统调用,并不是直接操作硬件,而是直接作用在这块内核缓冲区上。以我们熟悉的 TCP 场景为例:read(fd) 就是从 fd 对应的 TCP 接收缓冲区里,把数据拷贝到用户缓冲区;write(fd) 就是把用户数据拷贝进 TCP 发送缓冲区,后续由内核自己负责和网卡、TCP 协议交互。

系统调用不会直接读写磁盘、网卡这些硬件设备,它只负责完成两件事:等待内核缓冲区就绪 + 在用户空间与内核缓冲区之间拷贝数据,这也正好对应了我们之前总结的 IO 本质:IO = 等待 + 拷贝。所以整个链路就是:fd(int)→ 找到内核缓冲区 → 系统调用操作缓冲区 → 完成IO。

fcntl

在 Linux 系统中,fcntl 是一个功能强大的文件描述符控制工具,它的全称是 "file control",顾名思义,就是专门用来操作和管理文件描述符的系统调用。正如我们前面所说,它是设置非阻塞 IO 最通用、最推荐的方式,因为它几乎能处理所有类型的文件描述符,无论是 socket、普通文件,还是管道、终端,都可以用它来修改状态,真正做到了 "一通百通"。

我们先来看它的参数:

第一个参数 fd 就是我们要操作的文件描述符,这是一切操作的起点,内核通过它找到对应的内核对象及内核缓冲区。

第二个参数 cmd 全称是 "command",它决定了让 fcntl 做什么操作。fcntl 之所以功能丰富,就是因为支持多种不同的命令。我们这里重点关注和设置非阻塞相关的两个命令**:F_GETFL 和 F_SETFL** 。F_GETFL 是用来获取文件描述符当前的状态标志,而 F_SETFL 则是用来修改这些标志。

第三个参数是一个可变参数,它是否需要传入、以及传入什么值,完全由第二个参数 cmd 决定。比如,当 cmd 是 F_GETFL 时,我们不需要额外参数;而当 cmd 是 F_SETFL 时,我们就需要传入要设置的新标志位。

最后我们来看它的返回值:fcntl 的返回值会根据 cmd 的不同而变化。当我们使用 F_GETFL 获取标志时,成功会返回当前文件描述符的状态标志;当我们使用 F_SETFL 设置标志时,成功会返回 0。无论哪种情况,如果调用失败,函数都会返回 - 1,并设置 errno 错误码,我们可以通过它来判断操作失败的具体原因。

总的来说,fcntl 就像是文件描述符的 "万能遥控器",而我们现在用它,只是按下了 "设置非阻塞" 这个按钮,让文件描述符从默认的阻塞模式切换到非阻塞模式,为后续实现高效 IO 打下基础。

这里我们再区分一下阻塞和非阻塞的概念:

阻塞 IO 的意思是,当内核缓冲区没有数据就绪时,read/recv 系统调用会让进程直接被挂起、卡住,CPU 不再给它分配时间片,进程什么都做不了,只能原地等待,直到数据来了才被唤醒继续执行。就像张三钓鱼,没鱼上钩就死死盯着鱼漂,全程被占用,干不了别的事。

而非阻塞 IO 并不是内核帮我们等待,而是数据没准备好时,系统调用会立刻返回,不卡住我们的进程,进程不会被挂起,CPU 依然可以继续执行代码,进程可以去做其他业务、处理别的连接、打印日志,之后再回来重新调用 read 检查数据。

但这里要纠正一个关键误区:非阻塞 ≠ 不占用 CPU。阻塞是进程休眠,不占 CPU;非阻塞是在while里循环轮询,反复调用 recv,数据没来就返回,再来一遍,CPU 会一直被占用,只是进程不会被 "卡死" 在系统调用里。

下面沃恩介绍一个函数,这个函数里封装了 fcntl 系统调用,这个函数就是把文件描述符设置为非阻塞模式的一个通用函数 SetNoBlock。

函数的第一步是通过 fcntl(fd, F_GETFL) 获取这个文件描述符当前的状态标志,并把结果保存到变量 fl 里。这里的 F_GETFL 就是 "获取文件状态标志" 的命令,内核会根据 fd 找到对应的 struct file,返回它里面的 f_flags 字段值。我们加上错误判断,如果获取失败就打印错误并返回,保证程序的健壮性。

第二步我们再次调用 fcntl,这次使用 F_SETFL 命令,把新的标志位写回去。这里的关键操作是 fl | O_NONBLOCK:我们在原来的标志位基础上,通过按位或操作,新增了 O_NONBLOCK 这个标志。O_NONBLOCK 就是 "非阻塞模式" 的意思,当这个标志被设置到 struct file 的 f_flags 中后,后续所有对该文件描述符的 IO 操作,都会变成非阻塞模式 ------ 数据未就绪时,系统调用会立刻返回 EAGAIN 或 EWOULDBLOCK 错误,而不是挂起进程。

所以,这段代码的本质就是通过 fcntl 操作 struct file 的 f_flags 字段,为文件描述符添加上非阻塞模式的开关。这也是为什么我们说 fcntl 是设置非阻塞最通用的方式,它的实现逻辑简单清晰,而且能适配所有类型的文件描述符,无论是 socket、普通文件还是管道都可以用。

在 Linux 内核中,当进程打开任何一个文件描述符时,内核都会为它创建一个 struct file 结构体,它是对所有 IO 对象(无论是普通文件、网络 Socket 还是管道)的通用抽象,体现了 "一切皆文件" 的设计思想。这个结构体里,包含了文件路径、文件操作函数指针、引用计数,以及我们今天的主角 ------ f_flags 标志位字段。

f_flags 是一个无符号整数,内核通过它来记录这个文件对象的状态属性,比如是否为只读、是否可写、是否开启了同步写入,而我们前面设置的非阻塞模式,本质上就是把 O_NONBLOCK 这个标志位,写入到了 struct file 的 f_flags 字段中。我们调用 fcntl(fd, F_GETFL),内核就是根据 fd 找到对应的 struct file,再返回它的 f_flags;调用 fcntl(fd, F_SETFL, fl | O_NONBLOCK),则是把新的标志位写回 struct file。

所以我们之前所有修改文件描述符为非阻塞的操作,最终都是作用在了这个内核对象的 f_flags 字段上。当后续进程调用 read、write 等系统调用时,内核会先检查 struct file 里的 f_flags,如果发现设置了 O_NONBLOCK,就会按照非阻塞的逻辑执行,数据未就绪时直接返回错误,而不是挂起进程。这就是非阻塞模式的底层实现原理,也是 fcntl 系统调用的真正工作方式。

这个就是不管将来是系统还是网络打开的一个文件对象,一切皆文件。所以我们未来的标志位的修改都是针对于这个标志位字段进行的。

编码实现

下面我们就来看非阻塞 IO 模型代码的实现 :

阻塞 IO

main 函数内在定义了一个大小为 1024 字节的字符数组 inbuffer,用来存放从标准输入读取的数据。接着程序进入循环,在循环中调用 read 系统调用,文件描述符 0 对应的就是标准输入 (stdin),它会尝试把数据读取到 inbuffer 中,读取的最大长度设置为数组大小 -1,这是为了给字符串结束符留出位置。读取完成后,程序会根据 read 的返回值进行不同处理:如果返回值 n > 0,说明成功读到了数据,程序会在读取到的数据末尾加上字符串结束符,然后通过printf 把读到的内容回显出来;如果返回值 n == 0,代表读到了文件末尾,程序会打印提示信息并跳出循环;如果返回值为负数,说明读取过程中出现了错误,程序原本的错误处理部分被注释掉了。
read 系统调用操作的文件描述符 0 在 Linux 里就是标准输入 stdin,对应的硬件就是键盘。当我们在键盘上输入字符时,数据先进入内核的终端输入缓冲区;之后 read(0, inbuffer, ...) 会从 fd=0 对应的这块内核缓冲区里把数据拷贝到我们定义的用户层数组 inbuffer 中;拷贝完成后,read 返回读到的字节数,程序拿到数据,再用 printf 把 inbuffer 里的内容打印出来。

运行结果 :

当我们编译运行这个程序,且不做任何键盘输入时,程序会直接卡在原地不动,没有任何输出。这是因为标准输入文件描述符默认是阻塞模式,当我们调用 read 读取数据,内核的终端输入缓冲区中没有任何数据时,系统调用会直接让进程挂起休眠,进程会一直停留在 read 这一行,直到用户输入内容并按下回车,数据进入内核缓冲区后,read 才会被唤醒并返回,程序继续执行后续的回显逻辑。

程序运行后,只要我们不输入内容,它就会一直卡在 read 调用上不动;当我们输入 aaa 并按下回车,内核终端缓冲区收到数据,read 才会从阻塞状态被唤醒,把数据拷贝到用户层的inbuffer 数组里,接着执行 printf 回显 echo # aaa;输入 bbb、ccc 时也是同样的流程,每次都要等我们输入完成,程序才会继续往下走。

上面的代码都是阻塞 IO 的体现,也就是说 read 系统调用会一直等待数据就绪,期间进程无法做任何其他操作,只能原地 "卡住"。这也是我们接下来要把标准输入设置为非阻塞模式的原因 ------ 非阻塞模式下,就算没有数据,read 也会立刻返回错误,进程不用被挂起,可以继续执行其他代码,不会出现 "一动不动" 的情况。

设置为非阻塞

这个函数就是上面我们讲解过的 SetNonBlock 函数,是一个把文件描述符设置为非阻塞模式的通用函数,接收的参数 fd 就是我们要操作的文件描述符,比如标准输入的 0 号文件描述符、socket 文件描述符都可以传进来。函数中我们先用 fcntl(fd, F_GETFL) 获取这个文件描述符当前的状态标志,把结果存在变量 fl 里。如果获取失败(fl < 0),就打印错误信息并返回,避免后续操作出错。再调用一次 fcntl(fd, F_SETFL, fl | O_NONBLOCK),把原来的标志位和 O_NONBLOCK 做按位或操作,再写回去。这一步就是给文件描述符加上 "非阻塞" 标志,告诉操作系统:以后对这个文件描述符的读写操作,都按非阻塞模式执行。设置完成后,这个文件描述符就不再是默认的阻塞模式了。当我们再调用 read、write 这类系统调用时,如果内核缓冲区没有数据(或写不下数据),系统调用会立刻返回错误,而不是让进程挂起卡住。

完整代码 :

main函数先定义了用户层缓冲区 inbuffer,接着调用 SetNonBlock(0),将标准输入 (文件描述符 0) 设置为非阻塞模式。随后进入循环,每次循环中调用 read 读取标准输入的数据,这里用 ssize_t 类型接收返回值,确保能正确表示-1这类负数结果。当 read 返回大于 0 的值时,说明成功读到了数据,程序会在数据末尾添加字符串结束符,再通过 printf 回显内容;返回 0 时代表读到文件末尾,程序打印提示并退出循环;返回负数时则进入 else 分支,打印错误提示信息。

现在我们看运行结果:

当前标准输入已经被设置为非阻塞模式,程序启动后进入循环,在没有任何键盘输入时,read 不会阻塞挂起进程,而是立刻返回 < 0 的负数,程序直接进入 else 分支,持续打印 error ...,当我们在键盘上输入 1111、333、444 并按下回车后,内核终端缓冲区收到数据,read 读取成功并返回大于 0 的字节数,程序进入 if(n>0) 分支,将输入内容正常回显打印,之后循环继续,无数据时又会回到打印 error 的逻辑,整体读写行为完全符合非阻塞 IO 的预期。

接下来我们仔细观察就会发现:为什么在没有输入、数据未就绪的情况下,程序会通过 std::cerr 以 "出错" 的形式打印提示?难道最开始没有输入、数据未就绪时就是错误吗?

首先我们需要明确,std::cerr 是 C++ 中标准错误输出流,专门用于输出程序的错误、异常类信息,而标准输出 std::cout 用于正常信息输出;在 Linux 系统调用的规则里,read 读出错或者读取时如果数据没有就绪时,也是以出错形式返回的!!但是它不是错误!!内核只是把这种 "无数据、暂不可读" 的状态归类为调用层面的返回异常,因此程序只能通过错误分支处理,用 cerr 输出提示。

也正因为如此,我们不能像阻塞 IO 那样只判断返回值,必须区分上面两种情况:一种是普通的错误码,代表真实的读写异常;另一种就是上面 "无数据、暂不可读" 的异常状态。

怎么区分?

通过 errno 来区分,在 C 语言中,我们主要是通过返回值来判断函数或系统调用是否执行成功,但如果想知道调用失败的具体原因,就需要借助 errno 这个全局变量来区分情况。我们平时用到的库函数,包括 read 这类系统调用,本质上都是 C 语言封装的接口,它们在执行出错时,都会把对应的错误码写入 errno 中,不同的错误码代表不同的失败原因,比如 EAGAIN 代表非阻塞模式下数据未就绪,而其他错误码则可能表示文件描述符无效、权限不足等真实的错误。

errno 就像是一个全局的 "错误日志标记",当系统调用返回 - 1 时,我们不能直接判定程序出现了故障,而是要检查 errno 的值:如果是 EAGAIN 或 EWOULDBLOCK,说明只是数据暂时没准备好,这是非阻塞 IO 的正常状态,程序可以稍后重试;如果是其他错误码,才代表真正的调用失败,就需要进行错误处理。这也是非阻塞编程和阻塞编程的核心区别之一,阻塞模式下我们不用处理这种情况,而在非阻塞模式下,必须通过 errno 区分 "正常的无数据" 和 "真正的错误",才能写出健壮的程序。

看一个简单的例子:

程序尝试以只读模式打开一个不存在的文件 nonexistent.txt,fopen 会失败并返回 NULL,此时 errno 会被自动设置为 2(对应 ENOENT),代表 "文件或目录不存在"。通过 printf 直接打印 errno,我们能看到错误码的数值;而 strerror(errno) 函数则能把这个错误码转换成人类可读的字符串提示,让我们一眼看懂失败原因。这个例子说明 errno 就像一个全局的 "错误说明牌",所有系统调用和库函数在失败时都会更新它的值,帮我们区分不同的错误场景。

而我们今天的 read 也是如此,read 调用出错时会给我们设置 errno,我们将 errno 打印出来看一下:

我们先看运行结果里的错误码,当非阻塞 read 没有读到数据时,errno 被自动设置为了 11,这个数字对应的宏就是 EAGAIN。我们可以借助 strerror() 函数来解读它的含义,strerror 是系统提供的库函数,它接收一个错误码作为参数,会返回对应的人类可读的错误描述字符串,帮我们把晦涩的数字错误码,翻译成能看懂的文字说明。调用 strerror(errno) 后,打印出的结果是 Resource temporarily unavailable,翻译过来就是资源暂时不可用。放在我们这个场景里,资源指的就是标准输入对应的内核缓冲区,它暂时没有数据就绪,暂时无法被读取,所以表示资源暂时不可用。

怎么修改?

我们现在对代码进行针对性修改,核心就是通过 errno 区分非阻塞无数据和真正读写错误这两种场景。首先我们来认识 EAGAIN 与 EWOULDBLOCK,在 Linux 中二者本质上是等价的,错误码都为 11,对应宏定义 EAGAIN,含义是 Try again(再试一次),EWOULDBLOCK 是它的别名,专门用于非阻塞 IO 场景,表示当前操作会被阻塞,也就是文件描述符对应的数据未就绪、资源暂时不可用,并非程序出现故障,只是暂时无法完成读取。

我们在 else 错误分支中增加判断:如果 errno 等于 EAGAIN 或 EWOULDBLOCK,就代表是非阻塞模式下数据还没准备好,我们不再把它当成错误,转而打印 data is not ready!,执行 sleep 后用 continue 跳过后续逻辑,直接进入下一轮循环;只有出现其他错误码时,才判定为真实的系统错误。
从运行结果能直观看到,程序启动后无键盘输入时,不再疯狂打印 error,而是持续输出 data is not ready!,代表进程检测到数据未就绪,转而执行其他逻辑、等待下一次轮询;当我们输入内容、数据进入内核缓冲区后,read 正常读到数据,就会执行回显打印,读取完成后再次回到循环,继续提示数据未就绪,实现了非阻塞 IO 中 "无数据不阻塞、可做其他事" 的核心效果。

我们这里还需要再完善一下非阻塞 read 的错误处理逻辑,这里新增判断 EINTR,是因为在 Linux 系统中,系统调用执行过程中如果收到信号,会被强制中断、提前返回,这种情况也会让 read 返回 -1。

EINTR 这个错误码的含义是 Interrupted function call,也就是系统调用被信号中断。哪怕我们用的是非阻塞 IO,进程在执行 read 的瞬间如果收到信号(比如闹钟信号、自定义信号),这次读取就会被打断,read直接返回 - 1,同时 errno 被设为 EINTR。

它和 EAGAIN 一样,都不是真正的读写错误,只是正常的中断场景,我们不需要终止程序,只需要 sleep 后用 continue 进入下一轮循环,重新尝试读取就可以。

所以我们要在代码里单独分出这个分支:如果 errno == EINTR,说明是信号打断了本次调用,直接重试;只有既不是 EAGAIN、也不是 EINTR 时,才是真正的读取失败,需要打印错误信息。这一步让我们的非阻塞 IO 程序更严谨、更健壮。

因此完整的出错判断逻辑如下:

完整代码:

testNonBlock.cc

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

void SetNonBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0)
    {
        std::cerr << "fcntl error" << std::endl;
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

int main()
{
    char inbuffer[1024];
    SetNonBlock(0);

    while (true)
    {
        ssize_t n = read(0, inbuffer, sizeof(inbuffer) - 1);
        if (n > 0)
        {
            inbuffer[n - 1] = 0;
            printf("echo # %s\n", inbuffer);
        }
        if (n == 0)
        {
            std::cout << "end of file" << std::endl;
            break;
        }
        else
        {
            // read error  || 如果一个文件fd,读取的时候,如果没有就绪,也是以出错形式返回的!!但是它不是错误!!
            // 必须区分这些情况了!
            if (errno == EWOULDBLOCK || errno == EAGAIN)
            {
                // 进程就可以做自己的事情了!
                std::cout << "data is not ready!" << std::endl;
                sleep(1);
                continue;
            }
            else if(errno == EINTR)
            {
                sleep(1);
                continue;
            }
            else
            {
                std::cerr << "read error ... :  " << errno << std::endl;
                std::cerr << "read error ... :  " << strerror(errno) << std::endl;
            }
        }
        sleep(1);
    }
}

下面我们再来看这幅图:

这幅图展示了非阻塞 IO 的轮询模型,和我们代码中的非阻塞 read 逻辑对应。进程会反复调用 recvfrom(和 read 一样是系统调用),主动向内核询问数据是否就绪。如果内核缓冲区里还没有数据报,就会立刻返回 EWOULDBLOCK 错误,也就是我们代码里判断的 EAGAIN,告诉进程 "数据还没准备好,你稍后再试"。进程不会被挂起,而是可以继续执行自己的逻辑,然后再次发起调用,直到某次内核返回 "数据报准备好",内核才会把数据拷贝到用户空间,进程收到成功返回后,开始处理数据。

同时,我们还补充了EINTR的判断,它也是轮询过程中可能出现的情况:如果进程在调用系统调用时被信号打断,也会返回-1,但这不是数据问题,也不是真的错误,只需要重试即可。

总结来说,非阻塞 IO 的核心就是进程主动轮询、不依赖内核挂起等待,通过errno区分EAGAIN (数据未就绪)、EINTR (信号中断)和真正的错误,让进程在等待数据的同时,不会被单一的 IO 调用卡死,保持运行状态,这也是非阻塞 IO 和阻塞 IO 最本质的区别。

五、总结

本文深入讲解了Linux下的五种IO模型及其实现原理。IO的本质是等待+拷贝,高效IO的关键在于减少等待时间占比。五种IO模型包括:阻塞IO(进程挂起等待)、非阻塞IO(轮询检查)、信号驱动IO(内核信号通知)、多路复用(并发等待多个连接)和异步IO(内核完成全部操作后通知)。重点分析了非阻塞IO的实现方式,包括通过fcntl设置O_NONBLOCK标志和使用MSG_DONTWAIT参数,并详细介绍了如何通过errno区分"数据未就绪"和真实错误。多路复用模型因其高效的并发处理能力成为网络编程的核心技术,而异步IO虽理念先进但实际应用较少。文章通过钓鱼的生动类比,帮助理解不同IO模型的特点和适用场景。

谢谢大家的观看!

相关推荐
雪度娃娃1 小时前
ASIO异步通信——服务器网络层和逻辑层设计
开发语言·网络·c++·php
RD_daoyi1 小时前
Google 官方调整抓取工具 IP 文件路径:SEO 与服务器安全策略要变了?
服务器·人工智能·学习·tcp/ip·搜索引擎·chatgpt
treesforest2 小时前
如何查IP归属地?IP地址归属地查询的三种方式与选型指南
网络·数据库·网络协议·tcp/ip
鬼才血脉2 小时前
IDEA中集成Tomcat后重新部署、重启服务器、更新资源、更新类和资源的使用
java·服务器·intellij-idea
zzzsde2 小时前
【Linux网络】传输层协议UDP
linux·服务器·开发语言·网络·算法·udp
星恒讯工业路由器2 小时前
WiFi 安全技术演进全解析:从 WEP 到 WPA3 的迭代与安全蜕变
网络·安全·wifi·信息与通信
中基数联软件造价2 小时前
第三方软件造价评估服务如何助力政务信息化合规?辽宁新规提供政策依据
网络·数据库·政务
格发许可优化管理系统2 小时前
解决Mentor许可冲突,让您的业务无缝运行
运维·服务器·c语言·c++·人工智能
上海云盾-小余2 小时前
服务器入侵应急处置:入侵排查与溯源恢复全流程
运维·服务器·github