Linux网络io模型

Linux下的五个基本io模型

基本概念

➡️内核空间和用户空间

➡️Linux的进程地址空间

进程地址空间表示位用户进程看到的内存地址空间

➡️32位系统的进程地址空间

32位系统下虚拟地址空间范围位为0~2³²(4GB),其中0~3GB为用户空间user space3~4GB为内核空间kernel space
因为内核的虚拟地址空间只有1GB,但是需要访问整个4GB的物理空间,因此从物理地址0~896MB的部分,直接加上3GB的偏移,就得到了对应的虚拟地址,这种映射方式被称为线性/直接映射Direct Map

896M~4GB的物理地址部分ZONE_HIGHMEM需要映射到3G+896M~4GB128MB的虚拟地址空间。
采用的是做法是,ZONE_HIGHMEM中的一段段物理内存和这128M中的一段虚拟空间建立映射完成后断开与这部分虚拟空间的映射关系,然后ZONE_HIGHMEM中其他的物理内存可以继续往这个区域映射,即动态映射

用户空间虽然只能访问虚拟地址0~3G的空间,但是处于性能的考虑Linux中内核使用的地址也是映射到进程地址空间的,虽然不能访问内核所属的3G~4G,但还是可以看到这一段的地址空间,因此进程的虚拟地址空间可视为整个4GB。

➡️64位系统的进程地址空间

➡️地址空间大小不是232,也不是264,而一般是248

因为并不需要 264 这么大的寻址空间,过大空间只会导致资源的浪费。64位Linux一般使用48位来表示虚拟地址空间,40位表示物理地址(我这台机器是46位物理地址),这可通过一下命令来查看。

1
root in ~ λ cat /proc/cpuinfo
➡️用户空间和内核空间。
  • 0x0000000000000000~0x00007fffffffffff表示用户空间
  • 0xFFFF800000000000~0xFFFFFFFFFFFFFFFF表示内核空间

共提供 256TB(2^48) 的寻址空间。这两个区间的特点是,第 47位48~63位相同,若这些位为 0 表示用户空间,否则表示内核空间。

➡️内存映射

在64位系统中,内核空间的映射变的简单了,因为内存空间已经足够大了,所以需要映射什么可以直接映射
用户空间的映射和32位没有什么区别

➡️进程切换

无论是在多核还是单核系统中,一个CPU看上去都像是在并发的执行多个进程,这是通过处理器在进程间切换来实现的。

操作系统实现这种交错执行的机制称为上下文切换

在这种机制下,从进程的视角来看就像是所有进程同时执行的效果。

在任一时刻,单一处理器核心只能处理一个进程(超线程除外,超线程可以把每一个物理核心当成两个用)。

当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文,恢复新进程的上下文,然后将控制权传递到新进程,新进程就会从上次停止的地方开始。

➡️进程

  • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占的使用处理器
  • 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统

而实际上,进程是轮流使用处理器的。
每个进程执行他的流的一部分,然后被暂时挂起,然后轮到其他进程。
对于一个运行在这些进程之一的上下文中的程序,他看上去就像是在独占的使用处理器

➡️进程切换流程

  • 保存上下文。
  • 更新PCB信息。
  • 把进程的PCB移入相应的队列。
  • 选择另一个进程执行,并更新其PCB。
  • 更新内存管理的数据结构。
  • 恢复上下文。

➡️用户模式和内核模式

为了使操作系用内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。

处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。

当设置了模式位时,进程就运行在内核模式中,即超级用户模式。

一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何存储器位置。

没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器,改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。

运行应用程序代码的进程初始时是在用户模式中的。

进程切换只发生在内核态!

➡️进程阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。

阻塞原语的执行过程是:

  1. 找到将要被阻塞进程的标识号对应的PCB。
  2. 若该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行。
  3. 把该PCB插入到相应事件的等待队列中去。

当被阻塞进程所期待的事件出现时,如它所启动的I/O操作已完成或其所期待的数据已到达,则由有关进程(比如,提供数据的进程)调用唤醒原语(Wakeup),将等待该事件的进程唤醒。

唤醒原语的执行过程是:

  1. 在该事件的等待队列中找到相应进程的PCB。
  2. 将其从等待队列中移出,并置其状态为就绪状态。
  3. 把该PCB插入就绪队列中,等待调度程序调度。

需要注意的是,Block原语和Wakeup原语是一对作用刚好相反的原语,必须成对使用。 Block原语是由被阻塞进程自我调用实现的,而Wakeup原语则是由一个与被唤醒进程相合作或被其他相关的进程调用实现的。

➡️进程的基本状态

  • 就绪状态 :一个进程获得了除处理机外的一切所需资源,一旦得到处理机即可运行,则称此进程处于就绪状态。
  • 执行状态:当一个进程在处理机上运行时,则称该进程处于运行状态。
  • 阻塞状态:一个进程正在等待某一事件发生(例如请求I/O而等待I/O完成等)而暂时停止运行,这时即使把处理机分配给进程也无法运行,故称该进程处于阻塞状态。
  • 挂起状态:由于IO的速度慢于CPU的运算速度,经常出现CPU等待I/O的情况。这时OS需要将主存中的进程对换至外存。在进程行为模式中需要增加一个新的挂起(suspend)状态。当内存中所有进程阻塞时,OS可将一进程置为挂起态并交换到外存,再调入另一个进程执行。
  • 新建状态:进程刚创建,但还不能运行,OS还没有把它加到可执行进程组中,通常是还没有加载到主存中的新进程。
  • 退出状态:OS从可执行进程组中释放出的进程,或者是因为它自身停止了,或者是因为某种原因被取消。进程不在适合执行,但与作业相关的表和其它信息临时被OS保留起来,为其他程序提供所需信息。
  • 活跃就绪:指进程在主存并旦可被调度的状态。
  • 静止就绪:指进程被对换到辅存时的就绪状态,是不能被直接调度的状态,只有当主存中没有活跃就绪态进程,或者是挂起态进程具有更高的优先级,系统将把挂起就绪态进程调回主存并转换为活跃就绪。
  • 活跃阻塞:指进程在主存中。一旦等待的事件产生,便进入活跃就绪状态。
  • 静止阻塞:指进程对换到辅存时的阻塞状态。一旦等待的事件产生,便进入静止就绪状态。

➡️文件描述符fd

➡️概念

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。

➡️文件描述符、文件、进程间的关系

  • 每个文件描述符会与一个打开的文件相对应
  • 不同的文件描述符也可能指向同一个文件
  • 相同的文件可以被不同的进程打开,也可以在同一个进程被多次打开

➡️系统为维护文件描述符,建立了三个表

➡️文件描述符限制

永久修改用户级限制时有三种设置类型:

➡️查看某个进程的文件描述符

  • 找到进程ID
  • ls -lh /proc/{刚刚找到的进程ID}/fd

➡️缓存 IO

缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。

读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。

写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令。

延迟写减少了磁盘读写次数,但是却降低了文件内容的更新速度,使得欲写到文件中的数据在一段时间内并没有写到磁盘上。当系统发生故障时,这种延迟可能造成文件更新内容的丢失。为了保证磁盘上实际文件系统与缓冲区高速缓存中内容的一致性,UNIX系统提供了syncfsyncfdatasync三个函数。

  1. sync函数只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘操作结束。通常称为update的系统守护进程会周期性地(一般每隔30秒)调用sync函数。这就保证了定期冲洗内核的块缓冲区。命令sync(1)也调用sync函数。
  2. fsync函数只对由文件描述符filedes指定的单一文件起作用,并且等待写磁盘操作结束,然后返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保将修改过的块立即写到磁盘上。
  3. fdatasync函数类似于fsync,但它只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。

fsync不但更新数据,还会更新元数据-文件的属性,但fdatasync仅更新数据。

对于提供事务支持的数据库,在事务提交时,都要确保事务日志(包含该事务所有的修改操作以及一个提交记录)完全写到硬盘上,才认定事务提交成功并返回给应用层。

缺点:不能直接在应用程序地址空间和磁盘之间进行数据传输。

Linux 网络IO模型

I/O是计算机的输入输出,通俗一点讲是计算机数据的流动,包括CPU、内存、磁盘、网络、外设的数据流程,是针对不同主体而言的数据的输入和输出。

磁盘控制器是典型的设备控制器,与计算机总线相连,主要负责把数据写入磁盘和从磁盘读出数据,CPU通过总线将数据传送给磁盘控制器,再由磁盘进行处理,从而产生磁盘I/O。

网络适配器,即网卡,是计算机之间通过网络传送数据的控制器,位于OSI模型的物理层和数据链路层,简单来说,网卡是将计算机的数据封装为帧,并通过网线(对无线网络来说就是电磁波)将数据发送到网络上去;还负责接收网络上其它设备传过来的帧,并将帧重新组合成数据,发送到所在的电脑中。
具体来说就是在网络协议支持下,一个网络主机的进程通过网络与网络中其他主机的进程进行数据传输的过程,这一数据的传输过程就是网络I/O。

➡️socket

在操作系统中,所有的I/O设备(磁盘、外设、网络等)都被模型化为文件,所有的输入和输出动作都被当成相应的文件的读和写来执行,这些文件通过操作系统的VFS机制(虚拟文件系统),以文件系统形式挂载在Linux内核中,对外提供一致的文件操作接口。

在网络通信中,为了适配各种网络协议的复杂性,而使操作系统能够统一操作网络中的数据,在网络与进程间增加了一个抽象层,即套接字socket
客户端和服务器通过使用socket接口建立连接,连接以文件描述符形式提供给进程,套接字接口提供了打开和关闭套接字描述符的函数,客户端和服务器通过读写这些描述符来实现彼此间的通信。所以,socket是一种特殊的文件。

➡️I/O模型

I/O操作的过程可以大概总结如下:

输入:

  • 进程向内核发起一个系统调用(readreadvrecvrecvfromrecvmsg);
  • 内核收到系统调用,通知I/O设备读取数据;
  • 设备将数据载入内核缓冲区;
  • 内核缓冲区接到数据后,复制到用户进程的缓冲区;
  • 进程缓冲区得到数据,通知内核;
  • 内核将控制权交给应用进程,进程继续下一步操作;


    输出:
  • 进程向内核发起一个系统调用(writewritevsendsendtosendmsg);
  • 内核收到系统调用,内核将数据从应用进程的缓冲区到内核缓冲区(或设备缓冲区,如Socket缓冲区);
  • 内核将控制权交给应用进程,由设备执行下一步操作(如磁盘将数据写到磁盘;网卡将数据通过网络发出);

操作系统对于这些I/O操作有几种特定的处理方式,也就是I/O模型,包括阻塞式I/O非阻塞式I/OI/O复用信号驱动式I/O异步I/O

➡️同步阻塞 IO(blocking IO)

不管有无数据报到来,进程(线程)是阻塞于 recvfrom 系统调用的。
假如我们需要调用read方法来读取套接字,这个read方法就会触发操作系统内核的一次 recvfrom 系统调用。此时有以下两种情况:

  1. 内核还未接收到远端数据。此时会阻塞在等待数据阶段
  2. 内核已经接收到远端数据。内核需要复制数据到用户空间,在复制过程中会阻塞在将数据复制到用户空间阶段。

➡️同步非阻塞 IO(nonblocking IO)

根据内核中的数据报有无准备好,有以下两种情形:

  1. 当内核中的数据报还没准备好,此时 recvfrom 系统调用立即返回一个 EWOULDBLOCK 错误,即不会将用户进程(线程)置于阻塞状态。

  2. 当内核中的数据报已经准备好时,此时 recvfrom 系统调用,用户进程(线程)还是会阻塞,直到内核中的数据报已经拷贝到了用户空间,此时用户进程(线程)才会被唤醒来处理接收的数据报。

➡️IO 多路复用( IO multiplexing)

所谓 I/O 多路复用指的就是 select/poll/epoll 这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知。 I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个 thread of control 能够处理多个连接(I/O 事件)。

整体流程上来说和同步阻塞 IO差不多,只是多路复用在select的时候可以监听多个文件描述符,以此来实现一个线程监听多个io事件,节省cpu的线程切换进程切换消耗。

➡️信号驱动式IO(signal-driven IO)

信号驱动 IO 模型在等待数据报期间是不会阻塞的,即用户进程(线程)发送一个 sigaction 系统调用后,此时立刻返回,并不会阻塞,然后用户进程(线程)继续执行(这一期间是不阻塞的);当数据报准备好时,此时内核就为该进程(线程)产生一个 SIGIO 信号,此时该进程(线程)就发生一次 recvfrom 系统调用将数据报从内核复制到用户空间,注意,这个阶段是阻塞的。

➡️异步非阻塞 IO(asynchronous IO)

等待数据报到来和拷贝数据报到用户空间是完全交给内核来操作的,这一段时间用户是可以进行自己的操作的,等到数据报拷贝完成再通知用户进行数据处理操作。

和信号驱动io的区别是信号驱动模式在数据报到达后内核就会通知用户,然后由用户来主动发起调用来拷贝到用户空间,而异步模式则完全交给内核处理,用户在接到信号后就可以直接处理数据。

➡️5种io模型的比较

golang io模型

在go语言里,通过对I/O 多路复用的封装,且借助于 Go runtime scheduler对协程的高效调度,这个原生网络模型不论从适用性还是性能上都足以满足绝大部分的应用场景。

Go netpoll 在不同的操作系统,其底层使用的 I/O 多路复用技术也不一样,可以从 Go 源码目录结构和对应代码文件了解 Go 在不同平台下的网络 I/O 模式的实现。比如,在 Linux 系统下基于 epollfreeBSD 系统下基于 kqueue,以及 Windows 系统下基于 iocp


一个典型的go tcp server。

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
26
27
28
29
30
31
32
33
34
35
36
37
package main

import (
"fmt"
"net"
)

func main() {
listen, err := net.Listen("tcp", "0.0.0.0:8888")
if err != nil {
fmt.Println("listen error: ", err)
return
}

for {
conn, listenErr := listen.Accept()
if listenErr != nil {
fmt.Println("accept error: ", err)
break
}

// start a new goroutine to handle the new connection
go func(conn net.Conn) {
defer func() {
_ = conn.Close()
}()

packet := make([]byte, 1024)
for {
// 如果没有可读数据,也就是读 buffer 为空,则阻塞
_, _ = conn.Read(packet)
// 同理,不可写则阻塞
_, _ = conn.Write(packet)
}
}(conn)
}
}

这是一个基于netpoll来编写的TCP Server,对于开发者来说无需关心协程的调度和上下文切换。

Go netpoll 通过在底层对 epoll/kqueue/iocp 的封装,从而实现了使用同步编程模式达到异步执行的效果。总结来说,所有的网络操作都以网络描述符 netFD 为中心实现。netFD 与底层 PollDesc 结构绑定,当在一个 netFD 上读写遇到 EAGAIN 错误时,就将当前 goroutine 存储到这个 netFD 对应的 PollDesc 中,同时调用 gopark 把当前 goroutine 给 park 住,直到这个 netFD 上再次发生读写事件,才将此 goroutine 给 ready 激活重新运行。显然,在底层通知 goroutine 再次发生读写等事件的方式就是 epoll/kqueue/iocp 等事件驱动机制。

参考链接