前三段疯狂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)。

  1. 用户进程通过read等系统调用接口向操作系统(即CPU)发出IO请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态。
  2. 操作系统收到用户进程的请求后,进一步将IO请求发送给DMA,然后CPU就可以去干别的事了。
  3. DMA将IO请求转发给磁盘。
  4. 磁盘驱动器收到内核的IO请求后,把数据读取到自己的缓冲区中,当磁盘的缓冲区被读满后,向DMA发起中断信号告知自己缓冲区已满。
  5. DMA收到磁盘驱动器的信号,将磁盘缓冲区中的数据copy到内核缓冲区中,此时不占用CPU( PIO 这里是占用CPU的)。
  6. 如果内核缓冲区的数据少于用户申请读的数据,则重复步骤3、4、5,直到内核缓冲区的数据符合用户的要求为止。
  7. 内核缓冲区的数据已经符合用户的要求,DMA停止向磁盘发IO请求。
  8. DMA发送中断信号给CPU。
  9. CPU收到DMA的信号,知道数据已经准备好,于是将数据从内核空间copy到用户空间,系统调用返回。
  10. 用户进程读取到数据后继续执行原来的任务。

跟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
2
byte[] buffer=new byte[1024];
int len= socket.Receive(buffer);

当调用Receive时, 不同缓存区间的数据拷贝,和客户端数据的等待会阻塞当前线程,通常会新开一个线程来进行此操作.

1
2
3
4
5
6
7
8
9
10
Socket s= socket.Accept();
Thread t = new Thread(R);
t.Start(s);

void R(object s)
{
Socket skt = s as Socket;
byte[] buffer=new byte[1024];
int len= skt.Receive(buffer);
}

当连接数很多时,其开辟线程的消耗和多线程的占用通常难以接受;

非阻塞IO

为了解决阻塞的问题,我们只能依靠操作系统提供的接口,他会帮助我们检查内核缓存区数据是否满足需求,若满足,则拷贝内核缓存区的数据到用户态,否则立即返回一个失败标记(c#会返回一个SocketException 如ErrorCode==10035),以达到非阻塞的效果.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Socket s= socket.Accept();
-------------------------------------------------------------------------------------
//设置非阻塞
s.Blocking = false;
Thread t = new Thread(R);
t.Start(s);
void R(object s)
{
Socket skt = s as Socket;

try
{
byte[] buffer = new byte[1024];
int len = skt.Receive(buffer);
//接收
}
catch(SocketException ex)
{
if(ex.ErrorCode==10035)
{
//处理
}
}

}

当多连接时,我们通常会保存一个client socket组,以遍历非阻塞式轮询.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Socket s= socket.Accept();
-------------------------------------------------------------------------------------
List<Socket> socketList= new List<Socket>();
socketList.Add(s);
foreach(var skt in socketList)
{

try
{
byte[] buffer = new byte[1024];
int len = skt.Receive(buffer);
//接收
}
catch (SocketException ex)
{
if (ex.ErrorCode == 10035)
{
//处理
}
}
}

当我们可以看到,每次调用Receive()时都会是一次对CPU资源的消耗,涉及用户态内核态的转换.

有没有可能只调用一次cpu命令,让内核态帮我们遍历所有的client socket的状态呢?

答案就是下面的多路复用IO.

IO多路复用

多路复用指的是复用一个线程来检查多个Socket的就绪状态.

c#提供了两个方式来在规定时间内确定Socket状态,两者都是同步阻塞的.

  1. Poll(Int32 timeout, SelectMode): 检查当前Socket
  2. Select (System.Collections.IList? checkRead, System.Collections.IList? checkWrite, System.Collections.IList? checkError, TimeSpan timeout):检查多个Socket

select 调用需要传入 socket 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void SelectSocket(object? args)
{
while(active)
{
ResetCheckRead();
Socket.Select(checkRead, null, null, 10000);
foreach(var a in checkRead)
{
if (a == server)
AcceptClient(a);
else
clients[a].ReadClientfd();
}
}
foreach(var c in clients.Values)
{
c.Close();
}
server.Shutdown(SocketShutdown.Both);
server.Close();
server = null;
}

异步IO

c#异步网络IO旨在于使用c#的异步模式,让c#自己负责对IO的监听,若完成,则自动调用我们向前订阅的回调方法.回调方法在其他线程中进行,不阻塞当前线程

c#对此提供了三种异步IO方式

  1. 早期的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.

  2. SocketAsyncEventArgs

    官方示例代码:SocketAsyncEventArgs类(System.Net.Sockets)|微软学习 — SocketAsyncEventArgs Class (System.Net.Sockets) | Microsoft Learn

  3. SocketAsyncEventArgs和 基于任务的异步模式基础上封装的SocketTaskExtensions

一个高性能 .NET I/O库 System.IO.Pipelines

参考

这是一份

[1]: https://zhuanlan.zhihu.com/p/473639031 “很全很全的IO基础知识与概念”
[2]: https://zhuanlan.zhihu.com/p/473639031 “IO多路复用到底是不是异步的? - 无聊的闪客的回答 “