IO多路复用


前言

本篇重点学习理解IO多路复用的底层实现机制。


一、基础概念

IO 多路复用有三种实现,在介绍select、poll、epoll之前,首先介绍一下Linux操作系统中基础的概念:

  • 用户空间和内核空间
  • 进程切换
  • 进程的阻塞
  • 文件描述符
  • 缓存 I/O

(一)用户空间 / 内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

(二)进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

(三)进程阻塞

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

(四)文件描述符

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

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

(五)缓存I/O

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

缓存 I/O 的缺点: 数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU以及内存开销是非常大的。


二、IO多路复用

(一)什么是IO多路复用

IO多路复用(IOMultiplexing)一种同步IO模型,单个进程/线程就可以同时处理多个IO请求。
一个进程/线程可以监视多个文件句柄;
一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
没有文件句柄就绪时会阻塞应用程序,交出cpu。
多路是指网络连接,复用指的是同一个进程/线程。

一个进程/线程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程/线程,这就是多路复用,这种思想很类似一个 CPU并发多个进程,所以也叫做时分多路复用。


IO:在操作系统中,数据在内核态和用户态之间的读、写操作,大部分情况下是指网络IO;

多路:大部分情况下,指多个tcp连接(多个socket或多个channel);
复用:一个或多个线程处理多个tcp连接,无需创建和维护过多的进程/线程。

IO多路复用意思就是说,一个或多个线程处理多个 TCP连接。尽可能地减少系统开销,无需创建和维护过多的进程/线程。


(二)为什么出现IO多路复用机制

在没有使用IO多路复用机制时,有BIO、NIO两种实现方式,但是会出现阻塞或者开销大的问题。

  1. 同步阻塞(BIO)

(1)服务端采用单线程,当accept一个请求后,在recv和send调用阻塞时,将无法accept其他请求(必须等上一个请求处理完recv或send),不能处理并发。

	// 伪代码描述
	while(1) {
	  // accept阻塞
	  client_fd = accept(listen_fd)
	  fds.append(client_fd)
	  for (fd in fds) {
	    // recv阻塞(会影响上面的accept)
	    if (recv(fd)) {
	      // logic
	    }
	  }  
	}

(2)服务器端采用多线程,当accept一个请求后,开启线程进行recv,可以完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000个线程真正发生读写事件的线程数不会超过20%,每次accept都开一个线程也是一种资源浪费。

	// 伪代码描述
	while(1) {
	  // accept阻塞
	  client_fd = accept(listen_fd)
	  // 开启线程read数据(fd增多导致线程数增多)
	  new Thread func() {
	    // recv阻塞(多线程不影响上面的accept)
	    if (recv(fd)) {
	      // logic
	    }
	  }  
	}

  1. 同步非阻塞(NIO)

服务器端当accept一个请求后,加入fds集合,每次轮询一遍fds集合recv(非阻塞)数据,没有数据则立即返回错误,每次轮询所有fd(包括没有发生读写事件的fd)会很浪费CPU。

	setNonblocking(listen_fd)
	// 伪代码描述
	while(1) {
	  // accept非阻塞(cpu一直忙轮询)
	  client_fd = accept(listen_fd)
	  if (client_fd != null) {
	    // 有人连接
	    fds.append(client_fd)
	  } else {
	    // 无人连接
	  }  
	  for (fd in fds) {
	    // recv非阻塞
	    setNonblocking(client_fd)
	    // recv 为非阻塞命令
	    if (len = recv(fd) && len > 0) {
	      // 有读写数据
	      // logic
	    } else {
	       无读写数据
	    }
	  }  
	}

  1. IO多路复用(现在的做法)

服务器端采用单线程通过select/epoll等系统调用获取fd列表,遍历有事件的fd进行accept/recv/send,使其能支持更多的并发连接请求。

	fds = [listen_fd]
	// 伪代码描述
	while(1) {
	  // 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞
	  // 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞
	  for (fd in select(fds)) {
	    if (fd == listen_fd) {
	        client_fd = accept(listen_fd)
	        fds.append(client_fd)
	    } elseif (len = recv(fd) && len != -1) { 
	      // logic
	    }
	  }  
	}

(三)实现IO多路复用的三种模型

实现IO多路复用的模型有三种,分别是Select、poll 和epoll。下面详细介绍一下三种多路复用模型的基本原理和优缺点:

1. select模型

在这里插入图片描述

select模型,它的基本原理是,采用轮询和遍历的方式。也就是说,在客户端操作服务器时,会创建三种文件描述符,简称FD。分别是writefds(写描述符)、readfds(读描述符)和exceptfds(异常描述符)。

在这里插入图片描述


而select会阻塞监视这三种文件描述符,等有数据、可读、可写、出异常或超时都会返回;

在这里插入图片描述


返回后通过遍历fdset,也就是文件描述符的集合,来找到就绪的FD,然后,触发相应的IO操作。

在这里插入图片描述


它的优点跨平台支持性好,几乎在所有的平台上支持。

它的缺点也很明显,由于select是采用轮询的方式进行全盘扫描,因此,随着FD数量增多而导致性能下降。因此,每次调用select()方法,都需要把FD集合从用户态拷贝到内核态,并进行遍历。而操作系统对单个进程打开的FD数量是有限制的,一般默认是1024个。虽然,可以通过操作系统的宏定义FD_SETSIZE修改最大FD数量限制,但是,在IO吞吐量巨大的情况下,效率提升仍然有限。

2. poll模型

在这里插入图片描述

poll 模型的原理与select模型基本一致,也是采用轮询加遍历,唯一的区别就是 poll 采用链表的方式来存储FD。

所以,它的优点没有最大FD的数量限制

它的缺点和select一样,也是采用轮询方式全盘扫描,同样也会随着FD数量增多而导致性能下降

3. epoll模型

由于select和poll都会因为吞吐量增加而导致性能下降,因此,才出现了epoll模型。 epoll模型是采用时间通知机制来触发相关的IO操作。它没有FD个数限制,而且从用户态拷贝到内核态只需要一次。它主要通过系统底层的函数来注册、激活FD,从而触发相关的 IO 操作,这样大大提高了性能。

主要是通过调用以下三个系统函数:

(1)epoll_create()函数,在系统启动时,会在Linux内核里面申请一个B+树结构的文件系统,然后,返回epoll对象,也是一个FD。在这里插入图片描述

(2)epoll_ctl()函数,每新建一个连接的时候,会同步更新epoll对象中的FD,并且绑定一个 callback回调函数。 在这里插入图片描述

(3)epoll_wait()函数,轮询所有的callback集合,并触发对应的 IO 操作。在这里插入图片描述

所以,epoll模型最大的优点是将轮询改成了回调,大大提高了CPU执行效率,也不会随FD数量的增加而导致效率下降。当然,它也没有FD数量限制,也就是说,它能支持的FD上限是操作系统的最大文件句柄数。一般而言,1G内存大概支持 10 万个句柄。分布式系统中常用的组件如Redis、Nginx都是优先采用epoll模型。

它的缺点只能在Linux下工作

4. 综合对比

下表是三种多路复用模型的综合对比。

selectpollepoll
数据结构数组链表B+树
最大连接数1024无上限无上限
FD拷贝每次调用select拷贝每次调用poll拷贝FD首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝
工作效率轮询:O(n)轮询:O(n)回调:O(1)

总结

IO多路复用技术可以用有限的资源处理更多的请求。
IO多路复用主要有三种实现的核心思想:①select:轮询数组②poll:轮询链表③epoll:回调

  • 5
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值