网络 IO 面对的最直接问题
其实网络编程面对的主要问题还是比较明确的,那就是:CPU的速度远远快于磁盘、网络等IO。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。
《Unix 网络编程》表述不清晰的 I/O 模型
对于这本书中提到的 IO 模型,学过网络编程的可能都了解过,书也有比较老了,这边只是将知识点罗列出来,大致知道一下这几个 IO 模型:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O复用
- 信号驱动式 I/O
- 异步 I/O
这本书对于 I/O 模型的讲解其实比较乱,因为 同步/异步
和 阻塞/非阻塞
这几个概念本身是清晰独立的,首先我们应该先要做到能够清晰区分这几个概念。其次它们更多的是组合搭配出现的 (如果 加上 单线程/多线程
的概念,这个 I/O 模型其实会更多)。因此在介绍 I/O 模型时,我认为不应该把 阻塞 I/O 、 非阻塞 I/O
与 异步 I/O
概念这样混淆讲解,要讲解阻塞 I/O 、 非阻塞 I/O
就应该表述当前是 同步
还是 异步
。应该表述更清晰一些。
- 同步阻塞 I/O
- 同步非阻塞 I/O
- 异步阻塞 I/O
- 异步非阻塞 I/O
- I/O多路复用( 同步阻塞于多路复用器: epoll_wait 对应用程序来说是阻塞的 )
- 异步I/O(真正意义上的异步非阻塞)
信号驱动 I/O 用的比较少,这里不需要对它有过多了解。
就绪通知 VS 真正的异步 IO
对于异步 I/O,书中对概念的解释,我认为是准确的:"告知内核启动某个操作,并让内核在 整个操作 (包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。"
这里注意是整个操作 ,因为 I/O 复用在内核通知时,其实就不是整个操作完成后才通知,而是 I/O 事件准备就绪后就通知我们(应用程序),然后由应用程序主动去进行 I/O 读、写 ......操作。 (这也是为什么我们手写的基础socket代码中一般会有两层 while 循环,外层while(true)循环主要是调用操作系统底层的epoll,此处虽然阻塞,但实则是进行高效的等待,而内层循环则是遍历就绪的事件来进行读写。整个拿到数据的操作并不是由操作系统整体完成的)
I/O 多路复用:你告诉内核"帮我监视这100个socket,如果哪个有数据可读了通知我"。当通知到来时,它只是告诉你"某个socket就绪了" ,但数据的读写操作(从内核缓冲区拷贝到用户缓冲区)仍然需要你的线程自己调用read()函数来完成。所以,这个"通知"只是一个"就绪通知 ",I/O 操作本身还是同步的。
真异步I/O:你告诉内核"去读这个socket,读满1KB数据到这个缓冲区,全部搞定后通知我"。数据的等待和拷贝都由内核完成,给你的通知是"完成通知"。
"同步/异步"和"阻塞/非阻塞"是不同维度的概念
"同步/异步"和"阻塞/非阻塞"是描述不同维度的概念
同步 vs 异步
关注的是 消息通信机制或任务完成的通知方式。
- 同步:调用者发起调用后,必须亲自等待结果(无论是死等还是不断询问),才能继续执行后续代码。强调的是"主动等待结果" 。
- 异步:调用者发起调用后,不必亲自等待结果。当有结果时,会通过某种机制(如回调函数、信号、事件) 来通知调用者。强调的是"被通知结果" 。(当然,人家告诉你不必亲自等待结果,因此你可以选择做任何别的事情,这样就非阻塞了。但你也可以选择啥也不干就在这儿死等,这就又阻塞了,相信肯定没人这么干的~ 😒。)
阻塞 vs 非阻塞
关注的是 调用者在等待结果期间的状态。
- 阻塞:调用发起后,在拿到结果之前,调用者所在的线程会被挂起,无法执行任何其他操作。
- 非阻塞:调用发起后,在拿到结果之前,调用者所在的线程不会被挂起,可以继续做其他操作。
必须掌握的概念
同步阻塞
这是最容易理解的。通知方式是:主动等待; 等待结果期间的状态:啥也不干,死等;
行为:你(线程) 去书店买一本书(发起 I/O 请求),书没到货。你就一直坐在书店里等,直到书到货了你拿到书(同步等待结果),在等待期间你啥也不干(阻塞),然后才回家。
同步非阻塞
通知方式是:主动轮训; 等待结果期间的状态:去干别的事儿;
行为:你还是去书店买书,书没到货。但这次你不坐着傻等,而是离开书店做别的事情了(非阻塞)。但你心里一直惦记看这事,于是你每隔5分钟就跑回书店问一次:"书到了吗?"(主动轮询)。这个过程是同步的,因为是你主动地、反复地去检查结果。
注意:"同步"不代表线程一定要被"阻塞"。
同步非阻塞的核心是"轮询",需要不断地主动去询问结果,这本身是一种同步行为。 而关于轮询的效率问题,后面会详细讲到 (比如 selector.select() 在调用操作系统底层的 IO 多路复用系统调用时,同步阻塞其实会释放 CPU,不至 CPU 飙升至 100%),只不过,这个阻塞的selector.select() 在操作系统内部依赖了事件通知和高效监控多事件的方式,效率非常高。
异步非阻塞
这是标准的、高效的异步模式。
通知方式是:被通知; 等待结果期间的状态:去干别的事儿;
行为:你再去书店买书。这次你告诉店员:"书到了以后,请打电话通知我(回调通知),我就不在这等了"。然后你直接回家该干嘛干嘛(非阻塞)。等书到了,店员打电话给你,你再去取。整个过程你既没有傻等,也没有反复跑腿。
异步阻塞
这个组合很反直觉,可以理解为一个"设计失误"。
通知方式是:被通知; 等待结果期间的状态:啥也不干,死等;
行为:你告诉店员"书到了通知我"(异步发起请求)。但说完之后,你却没走,反而选择坐在书店里干等(阻塞)。你虽然采用了异步的通信方式,但自己的行为却是阻塞的。这相当于浪费了异步的优势。
例子:很少见。一个可能的例子是,调用了一个异步API,但随后又调用了一个阻塞的函数去等待这个异步操作完成。例如,在Python的 asyncio 中,如果在异步函数里错误地使用了阻塞式的调用,就会导致整个事件循环被阻塞。
小结:"异步"几乎总是和"非阻塞"搭配使用才能发挥其价值。"异步阻塞"是一种不高效的、通常应该避免的模式。
不要混淆的概念
"同步"不代表线程一定要被"阻塞";同步也可以非阻塞,但核心是"需要主动轮询"(因为非阻塞,线程没有被挂起,但需要不断地主动去询问结果,这本身是一种同步行为)。
"异步"不代表一定是"非阻塞"的,你一不小心可能写出"异步阻塞"的模式,要小心。
windows 对异步I/O的支持成熟稳定
Windows IOCP: Windows的 I/O完成端口 是真正的、成熟的原生异步IO支持。
当你发起一个读写请求(如 ReadFile)时,系统内核会真正接管整个IO操作。你的应用程序线程可以完全不去管这个I0,当内核完成所有数据从设备到内核缓冲区,再到你指定的用户态缓冲区的拷贝后,会向IOCP队列投递一个完成通知。这个过程应用程序线程是完全自由的。
Linux 对异步I/O的支持尚未成熟
历史上,Linux的异步 I/O 主要有两套API:
- aio(libaio):这套API设计有缺陷,最主要的问题是:
- 仅对"直接 I/O "支持较好:对于缓冲式IO(比如普通的文件读写)支持不完整或仍然是模拟的。
- 限制多:有很多限制,比如IO请求必须对齐到特定边界、目标文件描述符有特定要求等。
- 因此,它虽然存在,但在很多场景下并不实用,导致大家形成了"Linux没有真异步"的印象。
- select/poll/epoll:它们本质上是 I/O 多路复用,而不是异步 I/O 。这是最关键的区别。
- I/O 多路复用:你告诉内核"帮我监视这100个socket,如果哪个有数据可读了通知我"。当通知到来时,它只是告诉你"某个socket就绪了" ,但数据的读写操作(从内核缓冲区拷贝到用户缓冲区)仍然需要你的线程自己调用read()函数来完成。所以,这个"通知"只是一个"就绪通知 ",I/O 操作本身还是同步的。
- 真异步I/O:你告诉内核"去读这个socket,读满1KB数据到这个缓冲区,全部搞定后通知我"。数据的等待和拷贝都由内核完成,给你的通知是"完成通知"。
现状:现代Linux内核(5.1+)大力发展的 io_uring技术,提供了不逊色于甚至优于IOCP的真正异步 I/O 支持。它通过两个无锁的环形队列与内核高效交互,同时支持存储IO和网络10,正在成为Linux上高性能异步编程的新标准。
选择稳定的 epoll
现在几乎大部分的网络编程框架,在 linux 操作系统上,底层都是基于 Linux 操作系统内核的 epoll I/O 多路复用来提升性能的。(nginx, redis, netty 底层都还是基于 I/O多路复用技术,并没有用Linux系统内核的异步)