资料来源

刘丹冰的博客 他其他写的也不错
https://blog.csdn.net/FDS99999/article/details/138482044
https://cloud.tencent.com/developer/article/1981824
捡田螺的小男孩https://cloud.tencent.com/developer/article/1909094 他其他写的也不错
Hu先生的Linux 公众号【Linux服务器】 https://zhuanlan.zhihu.com/p/538257006 他其他写的也不错

基础

阅读前需要看

  • 用户空间 内核空间 DMA 零拷贝 PageCache等概念
  • c10k问题
  • socket详解
  • 阻塞非阻塞、同步异步的区别

io多路复用解决的是监听问题(前半段) 而类似dma、零拷贝 解决的是后半段的问题

什么是I\O

什么是普通IO

一句话总结 :IO就是内存和硬盘的输入输出

I/O 其实就是 input 和 output 的缩写,即输入/输出。

那输入输出啥呢?

比如我们用键盘来敲代码其实就是输入,那显示器显示图案就是输出,这其实就是 I/O。
但是这里少了一层,下面会讲

IO都是和内存的IO

而我们时常关心的磁盘 I/O 指的是硬盘和内存之间的输入输出。

读取本地文件的时候,要将磁盘的数据拷贝到内存中,修改本地文件的时候,需要把修改后的数据拷贝到磁盘中。

我们的指令最终是由 CPU 执行的,究其原因是 CPU 与内存交互的速度远高于 CPU 和这些外部设备直接交互的速度。

因此都是和内存交互,当然假设没有内存,让 CPU 直接和外部设备交互,那也算 I/O。

即使上面屏幕的例子,你的字符也要先发送到内存,然后再发送到屏幕

总结下:I/O 就是指内存与外部设备之间的交互(数据拷贝)。

什么是网络IO

网络 I/O 指的是网卡与内存之间的输入输出。

由于linux一切皆文件的设计思想,网卡和内存也是一个特殊的文件。

当网络上的数据到来时,网卡需要将数据拷贝到内存中。当要发送数据给网络上的其他人时,需要将数据从内存拷贝到网卡里。

一定要区分网络IO和普通IO(磁盘IO)

我们看下磁盘IO需要做的事情

  1. 用户进程调用 read 方法,向操作系统发出 I/0 请求,请求读取数据到自己的内存缓冲区中,进程进入.阻塞状态;
  2. 操作系统收到请求后,进一步将 I/0 请求发送 DMA,然后让 CPU 执行其他任务;·DMA 进一步将 I/0 请求发送给磁盘;
  3. 磁盘收到 DMA 的 IO 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  4. DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用CPU,CPU 可以执行其他任务;
  5. 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

在这里插入图片描述
由于网卡也有DMA,为了简单,我们将磁盘文件的DMA省略,写出步骤:

  1. 用户进程调用 read 方法,向操作系统发出 I/0 请求,请求读取数据到自己的内存缓冲区中,进程进入.阻塞状态;
  2. 操作系统收到请求后,进一步将 I/0 请求发送给磁盘;
  3. 磁盘收到 IO 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区
  4. 数据足够时,发送中断信号给 CPU;CPU 收到 信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

我们再看网络io,c10k那篇文章中说了:网络io,特别是tcp接收的延时的主要延时在于等待tcp的socket将数据发送到内核内存空间那里,高并发环境下会引发线程爆炸问题。
而文件io一般并发量少,而且你一般打开文件是要同步的去处理的,不像服务器接收请求这种,所以文件io一般就是阻塞的。而网络io追求非阻塞。

文件 I/O
对于普通文件(如硬盘上的文件),由于其数据存储在本地,通常读写操作是即时的,除非涉及到磁盘缓存或文件系统本身的性能瓶颈。在大多数情况下,对文件的读写操作默认是阻塞的。但是,可以通过设置文件描述符为非阻塞模式来改变其行为。例如,使用 O_NONBLOCK 标志打开文件:

int fd = open("file.txt", O_RDONLY | O_NONBLOCK);

这样,如果尝试读取的文件指针在文件末尾,read() 调用将返回 EAGAIN 而不是阻塞。

网络 I/O
网络 I/O(如 socket 操作)通常涉及到数据的传输延迟和网络拥塞,因此默认是非阻塞的。但是,你可以通过设置 socket 为阻塞模式或非阻塞模式来控制其行为。例如,使用 fcntl() 或 socket() 的 O_NONBLOCK 标志:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL) | O_NONBLOCK);

实际应用
在 Linux 中,无论是文件 I/O 还是网络 I/O,都可以设置为阻塞或非阻塞模式。关键的区别在于默认行为和通常的使用场景:

  • 文件 I/O:默认是阻塞的,但可以设置为非阻塞。
  • 网络 I/O:默认是非阻塞的(尽管可以通过套接字选项设置为阻塞),但通常在网络编程中更常用非阻塞 I/O 方法(如 select, poll, epoll)或者异步 I/O 方法。

所以,从大的概念来看,文件I/O和网络I/O本质上都是对资源的访问,只不过一个是本地设备,一个是远程设备。但是从访问方式和性能上来看,文件I/O和网络I/O无论是在I/O接口和系统调用上都有很大差别,因为网络存在很多不确定性和复杂点

网络通信为什么会阻塞

accept、connect、read、write 这几个方法都可能会发生阻塞。

阻塞会占用当前执行的线程,使之不能进行其他操作,并且频繁阻塞唤醒切换上下文也会导致性能的下降。

由于阻塞的缘故,起初的解决的方案就是建立多个线程,但是随着互联网的发展,用户激增,连接数也随着激增,需要建立的线程数也随着一起增加,到后来就产生了 C10K 问题。

接收请求会阻塞

读数据,从服务端来看就是等待客户端的请求,如果客户端不发请求,那么调用 read 会处于阻塞等待状态,没有数据可以读,这个应该很好理解。

c10k那篇文章的总结部分更详细解释了

发送数据也会阻塞

这里可能有人就会问 read 读不到数据阻塞等待可以理解,write 为什么还要阻塞,有数据不就直接发了吗?

因为我们用的是 TCP 协议,TCP 协议需要保证数据可靠地、有序地传输,并且给予端与端之间的流量控制。

所以说发送不是直接发出去,它有个发送缓冲区,我们需要把数据先拷贝到 TCP 的发送缓冲区,由 TCP 自行控制发送的时间和逻辑,有可能还有重传什么的。

如果我们发的过快,导致接收方处理不过来,那么接收方就会通过 TCP 协议告知:别发了!忙不过来了。发送缓存区是有大小限制的,由于无法发送,还不断调用 write 那么缓存区就满了,满了就不然你 write 了,所以 write 也会发生阻塞。

通用阻塞

内核空间 用户空间 DMA操作的阻塞
这个另一篇文章会介绍 零拷贝技术

所以

服务端顶不住了呀,咋办?

优化呗!

针对接收阻塞“后来就弄了个非阻塞套接字,然后 I/O多路复用、信号驱动I/O、异步I/O。
我们就来好好盘盘,这几种 I/O 模型!

网络IO模型

回顾流程

下文以TCP展开,注意 是TCP!

再回顾下,我们的流程

  • 连接建立阶段,建立成功后返回TCP套接字fd
  • 连接建立成功后:
    • 内核数据准备阶段:监听这个socket有没有数据发送过来,程序需要等待数据发送到网卡,并等待网卡将数据拷贝到内核空间(实际上三个子阶段)。
    • 内核数据拷贝到用户空间阶段:因为用户程序无法访问内核空间,所以内核又得把数据拷贝到用户空间,这样处于用户空间的程序才能访问这个数据。

在理解的时候,容易把监听并建立连接这个也当成IO,但是它不算。网络IO模型说的是建立完连接后,只用考虑已经建立的连接有没有数据进来,而不考虑请求进来建立连接这个问题。但是 内核准备阶段实际上也要“监听”数据准备好没,其实也类似监听。不过最大区别是网卡收包是有序的 而tcp的socket们接收数据是无序的

注意,一般只有内核数据拷贝到用户空间阶段属于我们必须开启服务端应用实际逻辑处理线程、协程的范畴。因为在这个阶段,你读或者写,实际上就类似普通文件的读和写了。回想我们从里面获取类似post参数,实际上是不是就是read?然后你返回数据 实际上就是write!

介绍这么多就是让你理解为什么会有两次拷贝,且系统调用是有开销的,因此最好不要频繁调用。

所以 以后有时间也要考虑下 tcp连接阶段的阻塞是否有优化的地方

为什么需要IO模型?传统的问题,以及线程池解决方案为何不行

多线程/进程服务器同时为多个客户机提供应答服务。模型如下:
在这里插入图片描述

上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。

由此可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如apache,mysql数据库等。

但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。

一个高效的IO模型要做到什么?

首先我们看下阻塞IO,由于存在传递关系,则用户程序对上次函数而言也表现为“阻塞”的了,所以线程会被暂停运行

当有很多个这样的IO请求的时候,我们希望做到:

  • 尽可能少的同时存在的线程
    • 一个是尽量少建立线程
    • 一个是尽量减少线程存在的总时间,包括晚点创建线程和早点让它消失
  • 尽可能少的线程切换,以减少上下文切换的成本

这里记得结合c10k那里看

阻塞IO模型 BIO

简称BIO,Blocking IO

假设应用程序的进程发起IO调用,但是如果内核的数据还没准备好的话,那应用程序进程就一直在阻塞等待,一直等到内核数据准备好了以及从内核拷贝到用户空间,才返回成功提示,此次IO操作,称之为阻塞IO。
在这里插入图片描述

这里用udp的recvfrom其实不太合适,应该用tcp的recv函数,但是由于recv的底层也用到了recvfrom 所以也行 但是你理解的时候一定要记得是tcp 下文同理

这里说的进程阻塞,进程是用户的程序,用户程序本身是同步的, 用户程序调用了接收函数,接收函数是阻塞的。而非指接收函数的子流程函数是阻塞的

阻塞IO比较经典的应用就是阻塞socket、Java BIO。
阻塞IO的缺点就是:如果内核数据一直没准备好,那用户进程将一直阻塞,浪费性能

由于存在传递关系,则用户程序对上次函数而言也表现为“阻塞”的了,所以线程会被暂停运行,当有多个这样的请求的时候 线程会爆炸

非阻塞IO模型 (NIO)

简称NIO,Non-Blocking IO

实际上我认为 叫部分非阻塞轮询模型更准确

如果内核数据还没准备好,可以先返回错误信息给用户进程,让它不需要等待,而是通过轮询的方式再来请求。这就是非阻塞IO,流程图如下:
在这里插入图片描述

这里 前半段用户调用的recv函数是非阻塞的,但是后半段是阻塞的。最后一次成功调用recv,数据从网卡发送到内核 以及 拷贝数据到用户空间这一步依然是阻塞的,只不过和之前的阻塞不同的是,之前可能要等待很久,而最后一次调用基本都准备好了,比较快,但是也是阻塞。

以下这句话很重要:进程把一个套接字设置成非阻塞是在通知内核,当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把进程投入睡眠,而是返回一个错误。
所以,非阻塞io的好处在于我们尽量让线程处于唤醒状态,除非时间超时再被切换走,不然太多的线程切换,时间都花在上下文切换上去了,还不如一个尽量多运行一会儿,完成了再下一个呢。
但是这样循环实在太多了 M的N次方个操作, 其实也是空耗cpu

非阻塞IO的流程如下:

  • 应用进程向操作系统内核,发起recv读取数据。
  • 操作系统内核数据没有准备好,立即返回EWOULDBLOCK错误码。
  • 应用程序进程轮询调用,继续向操作系统内核发起recv读取数据。
  • 操作系统内核数据准备好了,并从内核缓冲区拷贝到用户空间(后面这一步依然是阻塞的)
  • 完成调用,返回成功提示。

设置非阻塞常用方式:
方式一: 创建socket 时指定

int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);

方式二: 在使用前通过如下方式设定

fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) | O_NONBLOCK);

IO 多路复用

在多路复用 IO 模型中,会有一个线程不断去轮询多个 socket 的状态,只有当 socket 真正有读写事件时,才真正调用实际的 IO 读写操作。
上面看似好像和非阻塞io模型差不多,但是,我们的网络应用往往是多个请求发过来的!这就意味着 你需要n的m次方次轮询,这个数目一下就爆炸了!
所以,IO 多路复用本质上比阻塞IO并没有特别本质上的优越性;关键是能实现同时对多个IO端口进行监听。

  • 只需要使用一个线程就可以管理多个 socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用 IO 资源,所以它大大减少了资源占用
  • 对于epoll 在非阻塞 IO 中,不断地询问 socket 状态是通过用户线程去进行的,然后再系统调用。而在多路复用 IO 中,轮询每个 socket 状态是内核在进行的,这个效率要比用户线程要高的多。

不过要注意的是,多路复用IO模型的select和poll是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。

下面的图用select举例,实际上poll和epoll也算一样的流程
select函数,可以同时监控多个fd,在select函数监控的fd中,只要有任何一个数据状态准备就绪了,select函数就会返回可读状态
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

我们发现虽然第一步多了一个阻塞,但是对于多个请求而言,它只有一个!所以既减少了线程暂停时间 又减少了循环耗时问题
而第二步虽然也是阻塞的 但是因为数据已经都准备好了,复制很块

select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。

select、poll、epoll区别

select

select使用 bitmap 数组
有几个缺点:

  • 监听的IO最大连接数有限,在Linux系统上一般为1024。
  • select函数返回后,是通过遍历fdset,找到就绪的描述符fd。(仅知道有I/O事件发生,却不知是哪几个流,所以遍历所有流)
  • select 只有一个系统调用函数,每次要监听都要将fd(文件描述符)从用户态传到内核,有事件时返回整个集合。
poll

因为存在连接数限制,所以后来又提出了poll。与select相比,poll解决了连接数限制问题。但是呢,select和poll一样,还是需要通过遍历文件描述符来获取已经就绪的socket。如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。

epoll

为了解决select/poll存在的问题,多路复用模型epoll诞生,它采用事件驱动来实现
epoll先通过epoll_ctl()来注册一个fd(文件描述符),一旦基于某个fd就绪时,内核会采用回调机制,迅速激活这个fd,当进程调用epoll_wait()时便得到通知。这里去掉了遍历文件描述符的坑爹操作,而是采用监听事件回调的机制。这就是epoll的亮点。

epoll高效的核心是:1、一个线程监听以及数据到来采用事件通知机制(而不需要轮询)。2、用户态和内核太共享内存mmap。

  • 当创建一个 epoll 实例后,在内核中有个精心构建的数据结构,像是用红黑树来高效管理所有要监听的文件描述符,添加、删除操作那叫一个快,时间复杂度仅 O (log n);
  • 还有个就绪列表,通常用双向链表实现,专门存放已经就绪、有事件发生的文件描述符。当fd就绪的时候,内核会通过回调机制将其放进去。
  • 当调用 epoll_wait 时,压根不用像 select、poll 那样大海捞针般遍历所有描述符,只需瞅瞅这个就绪列表就行,轻松定位到 “有事” 的连接,大大节省了 CPU 时间。就好比在一个大型仓库里找几件特定物品,select 和 poll 是逐个货架、逐件货物查看,epoll 则是有个智能清单,直接指引到目标货物所在货架,效率高下立判。
  • epoll 监听的 fd(file descriptor)集合是常驻内核的,它有 3 个系统调用 函数(epoll_create, epoll_wait, epoll_ctl),通过 epoll_wait 可以多次监听同一个 fd 集合,只返回可读写那部分。不像select那样每次都要将文件描述符从用户空间拷贝到内核空间
  • epoll除了提供select/poll那种IO事件的电平触发 (Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
  • 此外,epoll还使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间

好像只有linux平台有 win好像没实现

使用方法

有三个关键函数:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_events* event);
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

其中,epoll_wait会返回活跃的fd就绪数,然后你就可以通过循环,去链表中循环这些fd,开启线程处理,可以看示例代码

正常情况 只用ctrl添加到里面即可 我猜的 没专门查

和select的对比

select 只有一个系统调用,每次要监听都要将其从用户态传到内核,有事件时返回整个集合。

从性能上看,如果 fd 集合很大,用户态和内核态之间数据复制的花销是很大的,所以 select 一般限制 fd 集合最大1024。

从使用上看,epoll 返回的是可用的 fd 子集,select 返回的是全部,哪些可用需要用户遍历判断。

尽管如此,epoll 的性能并不必然比 select 高,对于 fd 数量较少并且 fd IO 都非常繁忙的情况 select 在性能上有优势。

边缘触发与水平触发

LT(level triggered) 是默认/缺省的工作方式,同时支持 block和no_block socket。这种工作方式下,内核会通知你一个fd是否就绪,然后才可以对这个就绪的fd进行I/O操作。就算你没有任何操作,系统还是会继续提示fd已经就绪,不过这种工作方式出错会比较小,传统的select/poll就是这种工作方式的代表。

ET(edge-triggered) 是高速工作方式,仅支持no_block socket,这种工作方式下,当fd从未就绪变为就绪时,内核会通知fd已经就绪,并且内核认为你知道该fd已经就绪,不会再次通知了,除非因为某些操作导致fd就绪状态发生变化。如果一直不对这个fd进行I/O操作,导致fd变为未就绪时,内核同样不会发送更多的通知,因为only once。所以这种方式下,出错率比较高,需要增加一些检测程序。

LT可以理解为水平触发,只要有数据可以读,不管怎样都会通知。而ET为边缘触发,只有状态发生变化时才会通知,可以理解为电平变化。

与 poll 的事件宏相比,epoll 新增了一个事件宏 EPOLLET,这就是所谓的边缘触发模式(Edge Trigger,ET),而默认的模式我们称为 水平触发模式(Level Trigger,LT)。这两种模式的区别在于:

对于水平触发模式,一个事件只要有,就会一直触发;
对于边缘触发模式,只有一个事件从无到有才会触发。
这两个词汇来自电学术语,你可以将 fd 上有数据认为是高电平,没有数据认为是低电平,将 fd 可写认为是高电平,fd 不可写认为是低电平。那么水平模式的触发条件是状态处于高电平,而边缘模式的触发条件是新来一次电信号将当前状态变为高电平,即:

水平模式的触发条件

  1. 低电平 => 高电平

  2. 处于高电平状态
    边缘模式的触发条件

  3. 低电平 => 高电平
    说的有点抽象,以 socket 的读事件为例,对于水平模式,只要 socket 上有未读完的数据,就会一直产生 POLLIN 事件;而对于边缘模式,socket 上每新来一次数据就会触发一次,如果上一次触发后,未将 socket 上的数据读完,也不会再触发,除非再新来一次数据。对于 socket 写事件,如果 socket 的 TCP 窗口一直不饱和,会一直触发 POLLOUT 事件;而对于边缘模式,只会触发一次,除非 TCP 窗口由不饱和变成饱和再一次变成不饱和,才会再次触发 POLLOUT 事件。

socket 可读事件水平模式触发条件:

  1. socket上无数据 => socket上有数据

  2. socket处于有数据状态
    socket 可读事件边缘模式触发条件:

  3. socket上无数据 => socket上有数据

  4. socket又新来一次数据

socket 可写事件水平模式触发条件:

  1. socket可写 => socket可写

  2. socket不可写 => socket可写
    socket 可写事件边缘模式触发条件:

  3. socket不可写 => socket可写

也就是说,如果对于一个非阻塞 socket,如果使用 epoll 边缘模式去检测数据是否可读,触发可读事件以后,一定要一次性把 socket 上的数据收取干净才行,也就是说一定要循环调用 recv 函数直到 recv 出错,错误码是EWOULDBLOCK(EAGAIN 一样)(此时表示 socket 上本次数据已经读完);如果使用水平模式,则不用,你可以根据业务一次性收取固定的字节数,或者收完为止。

目前大家普遍认为 ET 模式要比 LT 模式更高效,原因在于 ET 能减少系统调用的次数,仅当新数据到达时才返回。在使用 ET 时,我们必须确保每次 epoll_wait 返回后,都将内核缓冲区中的数据读取干净,避免数据丢失。

总结
项目 select poll epoll
底层数据结构 bitmap 数组 链表 红黑树+双链表
获取就绪的fd方式 遍历 遍历 FD挂在红黑树,通过事件回调callback
事件复杂度 O(n) O(n) O(1)
最大连接数 1024 无限制 无限制
性能 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 随着连接数的增加,性能基本没有变化
fd数据用户到内核空间拷贝情况 每次调用select拷贝 每次调用poll拷贝 fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝
具体说明(拷贝) 每次调用select,需要将fd数据从用户空间拷贝到内核空间 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间
多路复用会阻塞么?

这个问题分两层,一个是第一层,也就是select、poll、epoll的时候会不会阻塞,这个当然是会的,但是相对来说很少,而且就一个线程阻塞 问题不大

第二个是第二层,使用read、recv、recvfrom的时候是否会阻塞。
答案是 取决于你当时怎么设置的socket,如果你设置的是非阻塞 那就是非阻塞 如果是阻塞 那就是阻塞
需要指出的是,对于epoll,由于其采用了mmap机制,所以即使是阻塞,它也很快。cpu阻塞中断是需要时间长度大于某个值的,如果实在很快,那即使它阻塞但是因为很块返回结果,最终这个线程也不会暂停。

这段资料说 epoll的mmap在read调用前就执行了,但是不确定对不对 好像不对,以后再找资料吧
更为重要的是, epoll 因为采用 mmap的机制, 使得 内核socket buffer和 用户空间的 buffer共享, 从而省去了 socket data copy, 这也意味着, 当epoll 回调上层的 callback函数来处理 socket 数据时, 数据已经从内核层 “自动” 到了用户空间, 虽然和 用poll 一样, 用户层的代码还必须要调用 read/write, 但这个函数内部实现所触发的深度不同了.
用 poll 时, poll通知用户空间的Appliation时, 数据还在内核空间, 所以Appliation调用 read API 时, 内部会做 copy socket data from kenel space to user space.
而用 epoll 时, epoll 通知用户空间的Appliation时?, 数据已经在用户空间, 所以 Appliation调用 read API 时?, 只是读取用户空间的 buffer, 没有 kernal space和 user space的switch了.

使用epoll时需要将socket设为非阻塞吗?
链接:https://www.zhihu.com/question/23614342/answer/184513538
我觉得只有边沿触发才必须设置为非阻塞。边沿触发的问题:1. sockfd 的边缘触发,高并发时,如果没有一次处理全部请求,则会出现客户端连接不上的问题。不需要讨论 sockfd 是否阻塞,因为 epoll_wait() 返回的必定是已经就绪的连接,所以不管是阻塞还是非阻塞,accept() 都会立即返回。2. 阻塞 connfd 的边缘触发,如果不一次性读取一个事件上的数据,会干扰下一个事件,所以必须在读取数据的外部套一层循环,这样才能完整的处理数据。但是外层套循环之后会导致另外一个问题:处理完数据之后,程序会一直卡在 recv() 函数上,因为是阻塞 IO,如果没数据可读,它会一直等在那里,直到有数据可读。但是这个时候,如果用另一个客户端去连接服务器,服务器就不能受理这个新的客户端了。3. 非阻塞 connfd 的边缘触发,和阻塞版本一样,必须在读取数据的外部套一层循环,这样才能完整的处理数据。因为非阻塞 IO 如果没有数据可读时,会立即返回,并设置 errno。这里我们根据 EAGAIN 和 EWOULDBLOCK 来判断数据是否全部读取完毕了,如果读取完毕,就会正常退出循环了。总结一下:1. 对于监听的 sockfd,最好使用水平触发模式,边缘触发模式会导致高并发情况下,有的客户端会连接不上。如果非要使用边缘触发,可以用 while 来循环 accept()。2. 对于读写的 connfd,水平触发模式下,阻塞和非阻塞效果都一样,建议设置非阻塞。3. 对于读写的 connfd,边缘触发模式下,必须使用非阻塞 IO,并要求一次性地完整读写全部数据。
另一个回答
使用epoll是否需要将socket设置为nonblocking?取决于你使用的触发方式, 如果你使用水平触发(Level-triggered) 那么此时的epoll相当于高级的select, 你的论述是对的, 是不需要一定将socket设置为非阻塞的; 然而, 当你使用边缘触发(Edge-triggered) 那么此时从业务的完整性考虑, 是建议将socket设置为nonbocking模式, 并且在读写触发EAGAIN之后再进行epoll_wait.

信号驱动IO模型

epoll已经做了很多优化了,但是在进程调用epoll_wait()时,仍然可能被阻塞。能不能酱紫:不用我老是去问你数据是否准备就绪,等我发出请求后,你数据准备好了通知我就行了,这就诞生了信号驱动IO模型。

信号驱动IO不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号(调用sigaction的时候建立一个SIGIO的信号),然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过SIGIO信号通知应用进程,数据准备好后的可读状态。应用用户进程收到信号之后,立即调用recv,去读取数据。

在这里插入图片描述
信号驱动IO模型,在应用进程发出信号后,是立即返回的,不会阻塞进程。它已经有异步操作的感觉了。但是你细看上面的流程图,发现数据复制到应用缓冲的时候,应用进程还是阻塞的。回过头来看下,不管是BIO,还是NIO,还是信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的。还有没有优化方案呢?AIO(真正的异步IO)!

其实没太多必要 网络io主要解决的是线程爆炸的问题,用这个增加了复杂度 但是实际没有这么多用
‌1编程复杂性增加‌:使用信号驱动IO需要处理信号的发送和接收,这增加了编程的复杂度。程序员需要编写信号处理函数,并且处理信号的合并和丢失问题,这可能导致错过一些I/O事件的通知‌
2‌在高并发场景下可能不够准确‌:在高并发的情况下,信号驱动IO模型可能不够稳定和准确,容易错过一些I/O事件的通知,从而影响系统的整体性能和可靠性‌

我的思考,正常cpu阻塞的异步应该是return实现的,而这里需要用户传入回调函数,可能是不安全?
以及另一个人写的
因为我们的应用通常用的都是 TCP 协议,而 TCP 协议的 socket 可以产生信号事件有七种。
也就是说不仅仅只有数据准备就绪才会发信号,其他事件也会发信号,而这个信号又是同一个信号,所以我们的应用程序无从区分到底是什么事件产生的这个信号。
那就麻了呀!
所以我们的应用基本上用不了信号驱动 I/O,但如果你的应用程序用的是 UDP 协议,那是可以的,因为 UDP 没这么多事件。

IO 模型之异步IO(AIO)

Asynchronous Input/Output

前面讲的BIO,NIO和信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的,因此都不算是真正的异步。AIO实现了IO全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是立即返回的不是处理结果,而是表示提交成功类似的意思。等内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程IO操作执行完毕。

流程如下:
在这里插入图片描述
异步IO的优化思路很简单,只需要向内核发送一次请求,就可以完成数据状态询问和数据拷贝的所有操作,并且不用阻塞等待结果。日常开发中,有类似思想的业务场景:
比如发起一笔批量转账,但是批量转账处理比较耗时,这时候后端可以先告知前端转账提交成功,等到结果处理完,再通知前端结果即可。

异步io最大程度上减少了线程量。看起来也不复杂 为啥很少这种io呢?
首先是有没有必要:
内核复制到用户空间,由于mmap技术 其实不算特别耗时了
epoll的性能已经很棒了
而且异步调用链条加长,复杂度也增加了,如果用的不好实际可能不如epoll

其次是能不能:
Linux 对异步 I/O 的支持不足,似乎是因为内核设计原因,具体原因暂时不关注吧。现在即使实现了AIO也是针对磁盘读写的IO,你可以认为还未完全实现,所以用不了异步 I/O。这里可能有人会说不对呀,像 Tomcat 都实现了 AIO的实现类,其实像这些组件或者你使用的一些类库看起来支持了 AIO(异步I/O),实际上底层实现是用 epoll 模拟实现的。
而 Windows 是实现了真正的 AIO,不过我们的服务器一般都是部署在 Linux 上的,所以主流还是 I/O 多路复用。

总结

5个I/O模型的比较:

在这里插入图片描述

多路复用 特别是epol不一定是阻塞的 前文讲到了 同理 信号驱动的read也可以不是阻塞的

阻塞、非阻塞、同步、异步IO划分

在这里插入图片描述

多路复用 特别是epol不一定是阻塞的 前文讲到了 同理 信号驱动的read也可以不是阻塞的

BIO:同步阻塞IO
NIO:同步部分非阻塞轮询IO
IO 多路复用:同步部分非阻塞还没想好叫什么IO
信号驱动IO:部分同步部分非阻塞IO
异步IO:异步非阻塞IO

网络IO模型之生活例子版解释

这里举得例子不是这么准确,分区什么是阻塞什么是同步那篇文章里已经讲了。还是看非例子版本解释那里更好

我们用一个例子来理解 I/O 模型。假设看到这里手机突然卡住了,任何操作都不起作用。但手机是刚买的,不敢强行重启,又想看下面的内容,你有哪些选择?

能采用的方法 方法的内容
阻塞 I/OBlocking I/O 坚持不懈,自己盯着手机,等它恢复
朴素非阻塞 I/ONon-Blocking I/O 把手机放到旁边,自己去读书,不时看下是否恢复
I/O 复用I/O MultiPlex 把手机交给朋友盯着,不时问下朋友,好了再去拿手机
事件驱动 I/OEvent-Driven I/O 手机恢复了自动给你发微信(假设提前装了App),好了再去拿手机
异步 I/OAsync I/O 手机恢复了会自动“飞”到你手上,自己不用管,想做什么都行

钓鱼的时候,刚开始鱼是在鱼塘里面的,我们的钓鱼动作的最终结束标志是鱼从鱼塘中被我们钓上来,放入鱼篓中。

这里面的鱼塘就可以映射成磁盘,中间过渡的鱼钩可以映射成内核空间,最终放鱼的鱼篓可以映射成用户空间。一次完整的钓鱼(IO)操作,是鱼(文件)从鱼塘(硬盘)中转移(拷贝)到鱼篓(用户空间)的过程。

两个步骤:鱼咬饵(内核数据准备好),放到鱼篓中(数据从内核态拷⻉到⽤户态)

同步阻塞模型

假如A在河边钓鱼的时候,非常的专心,生怕鱼儿溜掉,故此,A就一直盯着鱼竿,一直等着鱼儿上钩,专心的做这一件事情,直到鱼儿上钩,把鱼钓起来放入鱼篓中,才结束这个动作,这就是阻塞IO。在内核把数据准备好之前,系统调用会一直处于阻塞状态。

在这里插入图片描述
当用户程序的线程调用 read 获取网络数据的时候,首先这个数据得有,也就是网卡得先收到客户端的数据,然后这个数据有了之后需要拷贝到内核中,然后再被拷贝到用户空间内,这整一个过程用户线程都是被阻塞的。

假设没有客户端发数据过来,那么这个用户线程就会一直阻塞等着,直到有数据。即使有数据,那么两次拷贝的过程也得阻塞等着。

所以这称为同步阻塞 I/O 模型。

它的优点很明显,简单。调用 read 之后就不管了,直到数据来了且准备好了进行处理即可。

缺点也很明显,一个线程对应一个连接,一直被霸占着,即使网卡没有数据到来,也同步阻塞等着。

我们都知道线程是属于比较重资源,这就有点浪费了。

所以我们不想让它这样傻等着。

于是就有了同步非阻塞 I/O。

同步非阻塞 I/O

假如B也在河边钓鱼,B不想像A一样把所有的时间都花在等鱼儿上钩这件事情上,所以他的做法就是在等待鱼儿上钩的同时,自己也可以看看书,刷刷小编的博客,聊天等等。但是B也不是就不管鱼儿了,他会每隔一段固定时间都来看一下,有没有鱼儿上钩,如果有鱼儿上钩,他就结束这个动作,这就是非阻塞IO。
非阻塞IO往往需要程序员循环的方式反复尝试读取文件描述符,这个过程称为轮询,这对于cpu来说的话是较大的浪费,一般只有特定的场景下才能使用。
在这里插入图片描述
从图中我们可以很清晰的看到,同步非阻塞I/O 基于同步阻塞I/O 进行了优化:

在没数据的时候可以不再傻傻地阻塞等着,而是直接返回错误,告知暂无准备就绪的数据!

这里要注意,从内核拷贝到用户空间这一步,用户线程还是会被阻塞的。

这个模型相比于同步阻塞 I/O 而言比较灵活,比如调用 read 如果暂无数据,则线程可以先去干干别的事情,然后再来继续调用 read 看看有没有数据。

但是如果你的线程就是取数据然后处理数据,不干别的逻辑,那这个模型又有点问题了。

等于你不断地进行系统调用,如果你的服务器需要处理海量的连接,那么就需要有海量的线程不断调用,上下文切换频繁,CPU 也会忙死,做无用功而忙死。

那怎么办?

于是就有了I/O 多路复用。

多路复用IO

假如D也在河边钓鱼,但是D是一个土豪,他一个人就拿了好多鱼竿摆在哪里,这样很明显就增加了鱼儿上钩的机会。他只需要不断地查看每个鱼竿是否有鱼儿上钩就行了,提高了效率。 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。
在这里插入图片描述
从图上来看,好像和上面的同步非阻塞 I/O 差不多啊,其实不太一样,线程模型不一样。

既然同步非阻塞 I/O 在太多的连接下频繁调用太浪费了, 那就招个专员吧。

这个专员工作就是管理多个连接,帮忙查看连接上是否有数据已准备就绪。

也就是说,可以只用一个线程查看多个连接是否有数据已准备就绪。

具体到代码上,这个专员就是 select ,我们可以往 select 注册需要被监听的连接,由 select 来监控它所管理的连接是否有数据已就绪,如果有则可以通知别的线程来 read 读取数据,这个 read 和之前的一样,还是会阻塞用户线程。

这样一来就可以用少量的线程去监控多条连接,减少了线程的数量,降低了内存的消耗且减少了上下文切换的次数,很舒服。

IO多路复用详解

同步阻塞和非阻塞就是逐个收作业,同步阻塞按顺序来就阻塞收完再去下一个;非阻塞就是先跳过这个再去下一个。select的话就是学生写完了会主动举手,再下台去收作业,但是不知道是谁。

select(数组)

优点

  • 不需要每一个FD都进行一次系统调用,解决了频繁切换用户态内核态的问题。
  • 跨平台,linux、Mac、Windows都可以使用该函数。
    缺点
  • 单个进程监听的最大文件描述符数量有限制,最大1024.
  • 每次调用都要将文件描述符从用户态拷贝到内核态。
    且不知道是哪个文件描述符,要遍历一遍。
poll(链表)

优点

  • 主要是针对select1024的限制,改用数组实现,其他优点和select类似。
    缺点
  • 还是和select一样不知道是谁,需要所有的遍历一次。
  • 而且只能用在linux平台。
  • 每次都需要将文件描述符从用户态拷贝到内核态。
epoll(红黑树)

优点

  • 单进程监听没有文件描述符数量限制,一般3-6W和机器内存之类的有关。
  • 不需要每次都将文件描述符从用户态拷贝到内核态。
  • 可以直接知道是哪个具体的文件描述符,不用所有的文件描述符遍历一遍。
    缺点
  • 只支持linux,不能跨平台
    工作模式
  • 水平触发(默认)
    如果该事件没有处理完没有都会提醒
  • 边沿触发
    发了一次以后不管处理完没有都不会再发第二次了

想必到此你已经理解了什么叫 I/O 多路复用。

所谓的多路指的是多条连接,复用指的是用一个线程就可以监控这么多条连接。

看到这,你再想想,还有什么地方可以优化的?

信号驱动式IO

假如C也在河边钓鱼,我们可以给鱼竿安装一个报警器(比如铃铛),有鱼儿咬钩的时候立刻报警。然后我们再收到报警后,去把鱼钓起来。。信号驱动IO模型,应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号处理函数来获取数据报。
在这里插入图片描述
上面的 select 虽然不阻塞了,但是他得时刻去查询看看是否有数据已经准备就绪,那是不是可以让内核告诉我们数据到了而不是我们去轮询呢?

信号驱动 I/O 就能实现这个功能,由内核告知数据已准备就绪,然后用户线程再去 read(还是会阻塞)。

听起来是不是比 I/O 多路复用好呀?那为什么好像很少听到信号驱动 I/O?
为什么市面上用的都是 I/O 多路复用而不是信号驱动?

因为我们的应用通常用的都是 TCP 协议,而 TCP 协议的 socket 可以产生信号事件有七种。

也就是说不仅仅只有数据准备就绪才会发信号,其他事件也会发信号,而这个信号又是同一个信号,所以我们的应用程序无从区分到底是什么事件产生的这个信号。

那就麻了呀!

所以我们的应用基本上用不了信号驱动 I/O,但如果你的应用程序用的是 UDP 协议,那是可以的,因为 UDP 没这么多事件。

因此,这么一看对我们而言信号驱动 I/O 也不太行。

异步 I/O

假如E也想钓鱼,但是他又有点忙,所以他雇佣了一个人专门帮他看着鱼竿,一旦有鱼儿上钩,就让这个人通知他,他过来将鱼儿钓上来。由内核在数据拷贝完成时, 通知应用程序(信号驱动是告诉应用程序何时可以开始拷贝数据).
这一次我们雇了一个钓鱼高手。他不仅会钓鱼,还会在鱼上钩之后给我们发短信,通知我们鱼已经准备好了。我们只要委托他去抛竿,然后就能跑去干别的事情了,直到他的短信。我们再回来处理已经上岸的鱼。

在这里插入图片描述
信号驱动 I/O 虽然对 TCP 不太友好,但是这个思路对的:往异步发展,但是它并没有完全异步,因为其后面那段 read 还是会阻塞用户线程,所以它算是半异步。

因此,我们得想下如何弄成全异步的,也就是把 read 那步阻塞也省了。

其实思路很清晰:让内核直接把数据拷贝到用户空间之后再告知用户线程,来实现真正的非阻塞I/O!

所以异步 I/O 其实就是用户线程调用 aio_read ,然后包括将数据从内核拷贝到用户空间那步,所有操作都由内核完成,当内核操作完毕之后,再调用之前设置的回调,此时用户线程就拿着已经拷贝到用户控件的数据可以继续执行后续操作。

在整个过程中,用户线程没有任何阻塞点,这才是真正的非阻塞I/O。

那么问题又来了:

为什么常用的还是I/O多路复用,而不是异步I/O?
因为 Linux 对异步 I/O 的支持不足,你可以认为还未完全实现,所以用不了异步 I/O。

这里可能有人会说不对呀,像 Tomcat 都实现了 AIO的实现类,其实像这些组件或者你使用的一些类库看起来支持了 AIO(异步I/O),实际上底层实现是用 epoll 模拟实现的。

而 Windows 是实现了真正的 AIO,不过我们的服务器一般都是部署在 Linux 上的,所以主流还是 I/O 多路复用。

至此,想必你已经清晰五种 I/O 模型是如何演进的了。

server并发的五种模型

1.单线程Accept(无IO复用)

在这里插入图片描述
主线程执行阻塞accept,每次客户端connect过来就accept响应并连接。
创建连接成功以后,得到connfd套接字依然在主线程中串行执行套接字读写,并处理业务。
在处理上一步的时候如果来了新的连接是无法响应的。
客户端处理完成以后,再处理下一个客户端请求

2单线程Accept+多线程读写业务(无IO复用)

在这里插入图片描述

3.单线程多路IO复用

在这里插入图片描述

4.单线程多路IO复用+多线程读写业务

在这里插入图片描述

单线程IO复用+多线程IO复用(链接线程池)

在这里插入图片描述

附录io进阶

I/O 软件目标

设备独立性

现在让我们转向对 I/O 软件的研究,I/O 软件设计一个很重要的目标就是设备独立性(device independence)。

啥意思呢?这意味着我们能够编写访问任何设备的应用程序,而不用事先指定特定的设备。

比如你编写了一个能够从设备读入文件的应用程序,那么这个应用程序可以从硬盘、DVD 或者 USB 进行读入,不必再为每个设备定制应用程序。这其实就体现了设备独立性的概念。

计算机操作系统是这些硬件的媒介,因为不同硬件它们的指令序列不同,所以需要操作系统来做指令间的转换。

与设备独立性密切相关的一个指标就是统一命名(uniform naming)。设备的代号应该是一个整数或者是字符串,它们不应该依赖于具体的设备。

在 UNIX 中,所有的磁盘都能够被集成到文件系统中,所以用户不用记住每个设备的具体名称,直接记住对应的路径即可,如果路径记不住,也可以通过 ls 等指令找到具体的集成位置。

错误处理

除了设备独立性外,I/O 软件实现的第二个重要的目标就是错误处理(error handling)。

通常情况下来说,错误应该交给硬件层面去处理。如果设备控制器发现了读错误的话,它会尽可能的去修复这个错误。

如果设备控制器处理不了这个问题,那么设备驱动程序应该进行处理,设备驱动程序会再次尝试读取操作,很多错误都是偶然性的,如果设备驱动程序无法处理这个错误,才会把错误向上抛到硬件层面(上层)进行处理,很多时候,上层并不需要知道下层是如何解决错误的。

这就很像项目经理不用把每个决定都告诉老板;程序员不用把每行代码如何写告诉项目经理。这种处理方式不够透明。

同步和异步传输

I/O 软件实现的第三个目标就是 同步(synchronous) 和 异步(asynchronous,即中断驱动)传输。这里先说一下同步和异步是怎么回事吧。

同步传输中数据通常以块或帧的形式发送。发送方和接收方在数据传输之前应该具有同步时钟。

而在异步传输中,数据通常以字节或者字符的形式发送,异步传输则不需要同步时钟,但是会在传输之前向数据添加奇偶校验位。下面是同步和异步的主要区别
在这里插入图片描述
回到正题。大部分物理IO(physical I/O) 是异步的。物理 I/O 中的 CPU 是很聪明的,CPU 传输完成后会转而做其他事情,它和中断心灵相通,等到中断发生后,CPU 才会回到传输这件事情上来。

I/O 分为两种:物理I/O 和 逻辑I/O(Logical I/O)。
物理 I/O 通常是从磁盘等存储设备实际获取数据。逻辑 I/O 是对存储器(块,缓冲区)获取数据。

缓冲

I/O 软件的下一个问题是缓冲(buffering)。通常情况下,从一个设备发出的数据不会直接到达最后的设备。其间会经过一系列的校验、检查、缓冲等操作才能到达。

举个例子来说,从网络上发送一个数据包,会经过一系列检查之后首先到达缓冲区,从而消除缓冲区填满速率和缓冲区过载。

共享和独占

I/O 软件引起的最后一个问题就是共享设备和独占设备的问题。有些 I/O 设备能够被许多用户共同使用。

一些设备比如磁盘,让多个用户使用一般不会产生什么问题,但是某些设备必须具有独占性,即只允许单个用户使用完成后才能让其他用户使用。

控制io的方法

下面,我们来探讨一下如何使用程序来控制 I/O 设备。一共有三种控制 I/O 设备的方法

  • 使用程序控制 I/O
  • 使用中断驱动 I/O
  • 使用 DMA 驱动 I/O

使用程序控制 I/O 又被称为 可编程I/O,它是指由 CPU 在驱动程序软件控制下启动的数据传输,来访问设备上的寄存器或者其他存储器。CPU 会发出命令,然后等待 I/O 操作的完成。

由于 CPU 的速度比 I/O 模块的速度快很多,因此可编程 I/O 的问题在于,CPU 必须等待很长时间才能等到处理结果。CPU 在等待时会采用轮询(polling)或者 忙等(busy waiting) 的方式,结果,整个系统的性能被严重拉低。

可编程io:
鉴于上面可编程 I/O 的缺陷,我们提出一种改良方案,我们想要在 CPU 等待 I/O 设备的同时,能够做其他事情,等到 I/O 设备完成后,它就会产生一个中断,这个中断会停止当前进程并保存当前的状态。

dma io:
DMA 的中文名称是直接内存访问,它意味着 CPU 授予 I/O 模块权限在不涉及 CPU 的情况下读取或写入内存。也就是 DMA 可以不需要 CPU 的参与。

这个过程由称为 DMA 控制器(DMAC)的芯片管理。由于 DMA 设备可以直接在内存之间传输数据,而不是使用 CPU 作为中介,因此可以缓解总线上的拥塞。

DMA 通过允许 CPU 执行任务,同时 DMA 系统通过系统和内存总线传输数据来提高系统并发性。

I/O 层次结构

I/O 软件通常组织成四个层次,它们的大致结构如下图所示
在这里插入图片描述
每一层和其上下层都有明确的功能和接口。下面我们采用和计算机网络相反的套路,即自下而上的了解一下这些程序。

下面是另一幅图,这幅图显示了输入/输出软件系统所有层及其主要功能。
在这里插入图片描述

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐