可能和你想象中不太一样的“IO多路复用扫盲贴”

1,020 阅读21分钟

IO多路复用是怎么来的

先是BIO

NIO之前,客户端与服务端通过BIO通信。采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理没处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答通信模型。

c10k问题

大家都知道互联网的基础就是网络通信,早期的互联网可以说是一个小群体的集合。互联网还不够普及,用户也不多,一台服务器同时在线100个用户估计在当时已经算是大型应用了,所以并不存在什么 C10K 的难题。互联网的爆发期应该是在www网站,浏览器,雅虎出现后。最早的互联网称之为Web1.0,互联网大部分的使用场景是下载一个HTML页面,用户在浏览器中查看网页上的信息,这个时期也不存在C10K问题。 Web2.0时代到来后就不同了,一方面是普及率大大提高了,用户群体几何倍增长。另一方面是互联网不再是单纯的浏览万维网网页,逐渐开始进行交互,而且应用程序的逻辑也变得更复杂,从简单的表单提交,到即时通信和在线实时互动,C10K的问题才体现出来了。

然后是NIO和IO多路复用

如果使用BIO解决C10k问题,就要面临多线程的问题,但线程是很“贵”的资源,主要表现在:

  1. 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
  2. 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
  3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
  4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高,但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。

NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础。IO多路复用,一言以蔽之,就是"复用"一个线程或一个进程,同时监测若干个("多路")文件描述符是否可以执行IO操作的能力。 在I/O编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统维护的工作量,节省了系统资源。

IO多路复用的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。

那么IO多路复用是怎么解决C10k问题的呢?

背景知识

  1. 文件描述符

回顾一下inode:inode记录了文件的元信息,包括文件的(创建者、创建时间、文件大小、userid、groupID、RWX权限,链接数(多少个文件名指向这个inode)、文件数据block的位置...)。

  • Block:文件存储在硬盘上,最小的存储单位是扇区,每个扇区512字节(0.5KB),但是当操作系统读取硬盘的时候,如果从一个一个扇区里面读取,效率很低,所以会一次读取多个扇区,多个扇区组成了块(block),常见是4KB。
  • 系统打开一个文件的流程:每个inode都有自己的inode号码,系统内部使用inode来识别文件,而不是文件名。实际上,每打开一个文件,流程是这样:系统找到这个文件名对应的inode号码,通过inode号码找到inode,通过inode信息,找到文件数据所在的block,然后读取数据。

每个进程开启后,会创建一段内存用来存放进程的文件描述符表,而文件描述符可以认为是这个文件描述符表的索引。 系统为了维护文件描述符,建立了三个表:进程级别的文件描述表、系统级别的文件描述表(打开文件表)、inode表。

  1. 文件描述表:记录单个文件描述符的相关信息。
  2. 打开文件表:每一个条目称为打开文件句柄,描述了一个打开文件的全部信息。
  3. inode表:每个文件系统会为存储于其上的所有文件(目录)维护一个inode表。

简单描述一下进行IO操作时的流程,加深理解。当需要进行IO操作的时候,会先传入fd作为参数(文件描述符表的索引),进而通过文件指针找到打开文件表对应的记录,再通过inode指针找到该文件指向的inode,进而找到文件真正的位置,进行IO操作。通过 socket 通信,实际上就是通过文件描述符 fd 读写文件, 因此下方中会将socket和fd当作同义词使用。比如说到函数传入fd_set,可自行脑补为一系列等待数据的socket。 任意门:Linux的进程、线程、文件描述符是什么

  1. 系统中断

什么是系统中断?

“没有中断,操作系统几乎什么都做不了,操作系统是中断驱动的” 由于 CPU 获知了计算机中发生的某些事,CPU 暂停正在执行的程序,转而去执行处理该事件的程序。当这段程序执行完毕后,CPU 继续执行刚才的程序。整个过程称为中断处理,也称为中断。 中断包括外部中断(硬件中断),内部中断(软件中断、异常)。 针对外部中断, CPU 为大家提供了两条信号线。外部硬件的中断是通过两根信号线通知 CPU 的,这两根信号线就是 INTR(INTeRrupt)和 NMI(Non Maskable Interrupt)。 内部中断 软中断,就是由软件主动发起的中断,因为它来自于软件,所以称之为软中断。由于该中断是软件运 行中主动发起的,所以它是主观上的,并不是客观上的某种内部错误。

系统中断内核会做什么事?

以网卡为例,

  1. 网卡收到报文,通过DMA映射到内存中的网卡缓冲区;
  2. 网卡向CPU发出一个中断请求(IRQ,Interrupt ReQuest )
  3. 假设CPU此时在执行进程A,那么CPU会先保存该进程状态至进程描述符中
  4. CPU从用户态切换至内核态
  5. 执行网卡的中断处理程序
  6. CPU从内核态切换至用户态
  7. 从进程描述符中恢复进程A的现场
  1. 系统调用

啥是用户态与内核态

在说用户态与内核态之前,有必要说一下 C P U 指令集,指令集是 C P U 实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条 C P U 指令,而非常非常多的 C P U 指令 在一起,可以组成一个、甚至多个集合,指令的集合叫 C P U 指令集。 同时 C P U 指令集 有权限分级,大家试想,C P U 指令集 可以直接操作硬件的,要是因为指令操作的不规范`,造成的错误会影响整个计算机系统的。好比你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运行的程序,都可能会因为操作失误而受到不可挽回的错误,最后只能重启计算机才行。 而对于硬件的操作是非常复杂的,参数众多,出问题的几率相当大,必须谨慎的进行操作,对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些 C P U 指令集。 针对上面的需求,硬件设备商直接提供硬件级别的支持,做法就是对 C P U 指令集设置了权限,不同级别权限能使用的 C P U 指令集 是有限的,以 Inter C P U 为例,Inter把 C P U 指令集 操作的权限由高到低划为4级:

  • ring 0
  • ring 1
  • ring 2
  • ring 3

其中 ring 0 权限最高,可以使用所有 C P U 指令集,ring 3 权限最低,仅能使用常规 C P U 指令集,不能使用操作硬件资源的 C P U 指令集,比如 I O 读写、网卡访问、申请内存都不行,Linux系统仅采用ring 0 和 ring 3 这2个权限。 高情商

  • ring 0被叫做内核态,完全在操作系统内核中运行
  • ring 3被叫做用户态,在应用程序中运行

低情商

  • 执行内核空间的代码,具有ring 0保护级别,有对硬件的所有操作权限,可以执行所有 C P U 指令集 ,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机
  • 在用户模式下,具有ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调用系统接口(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发生崩溃也是可以恢复的,在电脑上大部分程序都是在,用户模式下运行的

什么时候会发生用户态和内核态的切换?

  1. 系统调用,在linux中系统调用是用户空间访问内核的唯一手段
  2. 中断
  3. 异常
  1. Socket基础

golang下的socket编程

server端:

func main() {
   serverSocket, err := net.Listen("tcp", "127.0.0.1:9090")
   if err != nil {
      fmt.Printf("serverSocket failed, err: %+v\n", err)
      return
   }

   for {
      socket, err := serverSocket.Accept()
      if err != nil {
         fmt.Printf("accept failed, err: %+v\n", err)
         continue
      }

      go process(socket)
   }
}

func process(socket net.Conn) {
   defer func() {
      _ = socket.Close()
   }()

   for {
      reader := bufio.NewReader(socket)
      var buf [128]byte

      n, err := reader.Read(buf[:])
      if err != nil {
         break
      }

      fmt.Printf("received data: %+v\n", convUtils.UnsafeBytesToStr(buf[:n]))

      _, _ = socket.Write(convUtils.UnsafeStrToBytes("ok"))
   }
}

client端

func main() {
   // 1、与服务端建立连接
   socket, err := net.Dial("tcp", "127.0.0.1:9090")
   if err != nil {
      fmt.Printf("socket server failed, err:%v\n", err)
      return
   }
   // 2、使用 socket 连接进行数据的发送和接收
   input := bufio.NewReader(os.Stdin)
   for {
      s, _ := input.ReadString('\n')
      s = strings.TrimSpace(s)
      if strings.ToUpper(s) == "Q" {
         return
      }

      _, err = socket.Write([]byte(s))
      if err != nil {
         fmt.Printf("send failed, err:%v\n", err)
         return
      }
      // 从服务端接收回复消息
      var buf [1024]byte
      n, err := socket.Read(buf[:])
      if err != nil {
         fmt.Printf("read failed:%v\n", err)
         return
      }
      fmt.Printf("收到服务端回复:%v\n", string(buf[:n]))
   }
}

socket读写缓冲区工作机制

app向对端发送数据,read()/recv() 、 write()/send() 是系统调用,

  1. 通过操作系统提供的write()/send()发起系统调用,将要发送的数据从用户态拷贝到内核态
  2. write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中
  3. OS中TCP/IP协议的实现将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情
  4. TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制
  5. 对端网卡收到数据后,通过触发中断,CPU处理read()/recv() 函数从输入缓冲区中读取数据
  6. 将数据拷贝回接收端的用户态,由上层应用渲染后呈现给用户。

TCP下的Socket默认情况下是阻塞模式,当使用 write()/send() 发送数据时:

  1. 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么 write()/send() 会被阻塞(暂停执行),直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒 write()/send() 函数继续写入数据。
  2. 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send() 也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。
  3. 如果要写入的数据大于缓冲区的最大长度,那么将分批写入。
  4. 直到所有数据被写入缓冲区,write()/send() 才能返回。

当使用 read()/recv() 读取数据时:

  1. 首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。
  2. 如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 read()/recv() 函数再次读取。
  3. 直到读取到数据后,read()/recv() 函数才会返回,否则就一直被阻塞。

这就是TCP套接字的阻塞模式。所谓阻塞,就是上一步动作没有完成,下一步动作将暂停,直到上一步动作完成后才能继续,以保持同步性。 非阻塞模式下流程相对简单,在write()/send()时,如果输出缓冲区已满,继续调用 send/recv 函数,send/recv 函数不会阻塞程序执行流,而是会立即出错返回,我们会得到一个相关的错误码;在read()/recv()时,如果输入缓冲区为空,函数将立即返回(返回值为**-1**,错误码为EWOULDBLOCK)。

正餐开始

BIO底层通信原理

  • CPU的运行队列中有进程A、B、C、D等若干进程,当然CPU还会运行内核进程
  • A进程中执行至某代码段,要阻塞式读取Socket,而此时Socket中没有数据

  • 进程A进入阻塞状态,从运行队列中出队,进入Socket的等待队列中

  • 对端发送数据至服务器网卡中,
  • 网卡通过DMA将报文存入RAM内
  • 此时CPU没有参与

  • 网卡发起硬件中断IRQ

  • CPU此时正在执行进程B,此时要影响中断
  • 保存进程B用户堆栈信息到进程描述符
  • 从用户态切换至内核态,即修改CPU寄存器,将堆栈指针指向当前进程B内核态堆栈
  • 根据IRQ向量到向量表中查找合适的中断处理程序
  • 执行网卡的中断处理程序
  • 看上去最终执行该中断程序的进程是进程B,虽然该中断程序与进程B其实没有什么关系

  • 中断处理程序会去RAM上的网卡缓冲区获取到相应的报文
  • 从报文中解析出来该报文对应的socket端口,从而将报文转移至对应socket的读缓冲区内
  • 当前socket的等待队列中已经有进程A,因此需要将进程A从等待队列中出队

  • 进程A回到CPU运行队列,会有机会获取到CPU的时间片

Select

函数签名:

int select(int nfds,
            fd_set *restrict readfds,
            fd_set *restrict writefds,
            fd_set *restrict errorfds,
            struct timeval *restrict timeout);

传递给select函数的参数会告诉内核:

  1. 我们所关心的文件描述符(三类,读、写、异常)
  2. 对每个描述符,我们所关心的状态
  3. 我们要等待多长时间

fd_set是bitmap结构,默认长度为1024,由内核中一个常量定义,因此select最多处理的fd长度就是1024,要修改该长度,需要重新编译内核。 fd_set 的使用涉及以下几个 API:

 #include <sys/select.h>
int FD_ZERO(int fd, fd_set *fdset);  // 将 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset);   // 将 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set);  // 将 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1

从select函数返回后,内核告诉我们以下信息:

  1. 对我们的要求已经做好准备的描述符的个数
  2. 对于三种条件(读、写、异常),哪些描述符已经做好准备

有了这些信息,我们可以调用合适的I/O函数,并且这些函数不会再阻塞。 select的缺点

  • fd_set中的bitmap是固定1024位的,也就是说最多只能监听1024个套接字。当然也可以改内核源码,不过代价比较大;
  • fd_set每次传入内核之后,都会被改写,导致不可重用,每次调用select都需要重新初始化fd_set;
  • 每次调用select都需要拷贝新的fd_set到内核空间,这里会做一个用户态到内核态的切换;
  • 拿到fd_set的结果后,应用进程需要遍历整个fd_set,才知道哪些文件描述符有数据可以处理。

Poll

poll 和 select 几乎没有区别。poll 采用链表的方式存储文件描述符,没有最大存储数量的限制。 从性能开销上看,poll 和 select 的差别不大。

epoll

epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。 简而言之,epoll 有以下几个特点:

  • 使用红黑树存储文件描述符集合
  • 使用队列存储就绪的文件描述符
  • 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态

select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_create、epoll_ctl 和 epoll_wait,我们分开介绍。

epoll_create

int epoll_create(int size);

epoll_create 会创建一个 epoll 实例,同时返回一个引用该实例的文件描述符。 返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl、epoll_wait 会使用这个文件描述符来操作相应的 epoll 实例。 当创建好 epoll 句柄后,它会占用一个 fd 值,在 linux 下查看 /proc/进程id/fd/,就能够看到这个 fd。所以在使用完 epoll 后,必须调用 close(epfd) 关闭对应的文件描述符,否则可能导致 fd 被耗尽。当指向同一个 epoll 实例的所有文件描述符都被关闭后,操作系统会销毁这个 epoll 实例。 epoll 实例内部存储:

  • 监听列表:所有要监听的文件描述符,使用红黑树
  • 就绪列表:所有就绪的文件描述符,使用链表

epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl 会监听文件描述符 fd 上发生的 event 事件。 参数说明:

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例

  • fd 表示要监听的目标文件描述符

  • event 表示要监听的事件(可读、可写、发送错误…)

  • op 表示要对 fd 执行的操作,有以下几种:

    • EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
    • EPOLL_CTL_MOD:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)
    • EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用

返回值 0 或 -1,表示上述操作成功与否。 epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

这是 epoll 模型的主要函数,功能相当于 select。 参数说明:

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
  • events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
  • maxevents 指定 events 的大小
  • timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回

返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。

epoll 的优点

一开始说,epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。 对于“文件描述符数量少”,select 使用整型数组存储文件描述符集合,而 epoll 使用红黑树存储,数量较大。 对于“性能开销大”,epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此 epoll 不需要像 select 那样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。

相当于时间复杂度从 O(n) 降为 O(1)

此外,每次调用 select 时都需要向内核拷贝所有要监听的描述符集合,而 epoll 对于每个描述符,只需要在 epoll_ctl 传递一次,之后 epoll_wait 不需要再次传递。这也大大提高了效率。

水平触发、边缘触发

水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。

边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。 select 只支持水平触发,epoll 支持水平触发和边缘触发。

区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。

水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。

参考文档

Java NIO浅析--美团技术团队

程序的用户态内核态及多路复用IO模型

IO多路复用

关于I/O多路复用的那些事

【硬核教程】IO多路复用底层原理全解

The C10K problem--Dan Kegel's Web Hostel

Linux的SOCKET编程详解

详解IO多路复用和其三种模式--select/poll/epoll

Linux 内核中断内幕【转】

《操作系统真象还原》-- 第7章 中断

从根上理解用户态与内核态

socket缓冲区以及阻塞模式详解

Blocking vs. non-blocking sockets---Scott Klement's web page

📔【操作系统】I/O 多路复用,select / poll / epoll 详解

彻底弄懂IO复用:IO处理杀手锏,带您深入了解select,poll,epoll