前三段疯狂CV😚😚😚😚
IO
在计算机操作系统中,所谓的I/O就是 输入(Input)和输出(Output),也可以理解为读(Read)和写(Write),针对不同的对象,I/O模式可以划分为磁盘IO模型和网络IO模型。
IO操作会涉及到用户空间和内核空间的转换,先来理解以下规则:
- 内存空间分为用户空间和内核空间,也称为用户缓冲区和内核缓冲区;
- 用户的应用程序不能直接操作内核空间,需要将数据从内核空间拷贝到用户空间才能使用;
- 无论是read操作,还是write操作,都只能在内核空间里执行;
- 磁盘IO和网络IO请求加载到内存的数据都是先放在内核空间的;
再来看看所谓的读(Read)和写(Write)操作:
- 读操作:操作系统检查内核缓冲区有没有需要的数据,如果内核缓冲区已经有需要的数据了,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,对于磁盘IO,直接从磁盘中读取到内核缓冲区(这个过程可以不需要cpu参与)。而对于网络IO,应用程序需要等待客户端发送数据,从网卡中去取数据。
- 写操作:用户的应用程序将数据从用户空间copy到内核空间的缓冲区中(如果用户空间没有相应的数据,则需要从磁盘—>内核缓冲区—>用户缓冲区依次读取),这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘或通过网络发送出去,由操作系统决定。除非应用程序显示地调用了sync 命令,立即把数据写入磁盘,或执行flush()方法,通过网络把数据发送出去。
用户空间&内核空间
Linux 操作系统中的概念里,虚拟内存(操作系统中的概念,和物理内存是对应的)被操作系统划分成两块:User Space(用户空间 和 Kernel Space(内核空间),本质上电脑的物理内存是不划分这些的,只是操作系统开机启动后在逻辑上虚拟划分了地址和空间范围。
操作系统会给每个进程分配一个独立的、连续的虚拟内存地址空间(物理上可能不连续),以32位操作系统为例,该大小一般是4G,即232 。其中将高地址值的内存空间分配给系统内核占用(网上查资料得知:Linux下占1G,Windows下占2G),其余的内存地址空间分配给用户进程使用。
简单说,内核空间 是操作系统 内核代码运行的地方,用户空间 是 用户程序代码运行的地方。当应用进程执行系统调用陷入内核代码中执行时就处于内核态,当应用进程在运行用户代码时就处于用户态。
同时内核空间可以执行任意的命令,而用户空间只能执行简单的运算,不能直接调用系统资源和数据。必须通过操作系统提供接口,向系统内核发送指令。
一旦调用系统接口,应用进程就从用户空间切换到内核空间了,因为开始运行内核代码了。
了解一种设备与内存的数据传输方式:DMA
DMA(直接内存访问,Direct Memory Access)。
- 用户进程通过read等系统调用接口向操作系统(即CPU)发出IO请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态。
- 操作系统收到用户进程的请求后,进一步将IO请求发送给DMA,然后CPU就可以去干别的事了。
- DMA将IO请求转发给磁盘。
- 磁盘驱动器收到内核的IO请求后,把数据读取到自己的缓冲区中,当磁盘的缓冲区被读满后,向DMA发起中断信号告知自己缓冲区已满。
- DMA收到磁盘驱动器的信号,将磁盘缓冲区中的数据copy到内核缓冲区中,此时不占用CPU( PIO 这里是占用CPU的)。
- 如果内核缓冲区的数据少于用户申请读的数据,则重复步骤3、4、5,直到内核缓冲区的数据符合用户的要求为止。
- 内核缓冲区的数据已经符合用户的要求,DMA停止向磁盘发IO请求。
- DMA发送中断信号给CPU。
- CPU收到DMA的信号,知道数据已经准备好,于是将数据从内核空间copy到用户空间,系统调用返回。
- 用户进程读取到数据后继续执行原来的任务。
跟PIO模式相比,DMA就是CPU的一个代理,它负责了一部分的拷贝工作(磁盘/网卡缓存区与内核缓存区之间的拷贝),从而减轻了CPU的负担。而内核缓冲区到用户缓冲区之间的拷贝工作仍然由CPU负责。
以c#Receive()为例,展示不同网络IO模型的伪代码
梳理一次Receive()的过程
客户端发送数据,保存在网卡缓存区中
用户进程向CPU发送IO请求,CPU转手给DMA,DMA转发IO请求到网卡(用户态到内核态的一次转换)
网卡将socket buffer Copy到内核缓存区中
内核缓存区数据已满或者已达到用户需要,将buffer 拷贝到用户缓存区中(内核态到用户态的一次转换)
注意的是不同缓存区之间的消息copy都是阻塞式的(DMA负责的部分非阻塞b),同时内核态与用户态之间的转换是存在不可忽视的cpu消耗的.
阻塞IO
1 | byte[] buffer=new byte[1024]; |
当调用Receive时, 不同缓存区间的数据拷贝,和客户端数据的等待会阻塞当前线程,通常会新开一个线程来进行此操作.
1 | Socket s= socket.Accept(); |
当连接数很多时,其开辟线程的消耗和多线程的占用通常难以接受;
非阻塞IO
为了解决阻塞的问题,我们只能依靠操作系统提供的接口,他会帮助我们检查内核缓存区数据是否满足需求,若满足,则拷贝内核缓存区的数据到用户态,否则立即返回一个失败标记(c#会返回一个SocketException 如ErrorCode==10035),以达到非阻塞的效果.
1 | Socket s= socket.Accept(); |
当多连接时,我们通常会保存一个client socket组,以遍历非阻塞式轮询.
1 | Socket s= socket.Accept(); |
当我们可以看到,每次调用Receive()时都会是一次对CPU资源的消耗,涉及用户态内核态的转换.
有没有可能只调用一次cpu命令,让内核态帮我们遍历所有的client socket的状态呢?
答案就是下面的多路复用IO.
IO多路复用
多路复用指的是复用一个线程来检查多个Socket的就绪状态.
c#提供了两个方式来在规定时间内确定Socket状态,两者都是同步阻塞的.
Poll(Int32 timeout, SelectMode): 检查当前SocketSelect (System.Collections.IList? checkRead, System.Collections.IList? checkWrite, System.Collections.IList? checkError, TimeSpan timeout):检查多个Socket
select 调用需要传入 socket 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。
1 | private void SelectSocket(object? args) |
异步IO
c#异步网络IO旨在于使用c#的异步模式,让c#自己负责对IO的监听,若完成,则自动调用我们向前订阅的回调方法.回调方法在其他线程中进行,不阻塞当前线程
c#对此提供了三种异步IO方式
早期的APM机制下的
BeginReceive()EndReceive()等方法:官方文档明写着不推荐,
The Begin/End design pattern currently implemented by the class requires a object be allocated for each asynchronous socket operation.每次异步操作时都需要分配一个IAsyncResult 对象,资源浪费,加剧GC.SocketAsyncEventArgs类在
SocketAsyncEventArgs和 基于任务的异步模式基础上封装的SocketTaskExtensions
一个高性能 .NET I/O库 System.IO.Pipelines
参考
这是一份
[1]: https://zhuanlan.zhihu.com/p/473639031 “很全很全的IO基础知识与概念”
[2]: https://zhuanlan.zhihu.com/p/473639031 “IO多路复用到底是不是异步的? - 无聊的闪客的回答 “