概念说明

用户空间与内核空间
现在操作系统都是采用虚拟存储器,那么对于32位操作系统而言,它的寻址空间为4G(2^32)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对Linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x0000000到0xBFFFFFFF),供各个进程使用,成为用户空间。

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

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

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

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

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

缓存IO
缓存IO又被称为标准IO,大多数文件系统的默认IO都是缓存IO。在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。

IO模式

当一个read操作发生时,它会经历两个阶段:

  • 等待数据准备(Waiting for the data to be ready)
  • 将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

正是因为这两个阶段,Linux系统产生了下面五种网络模式的方案:

  • 阻塞IO(blocking IO)
  • 非阻塞IO(nonblocking IO)
  • IO多路复用(IO multiplexing)
  • 信号驱动IO(signal driven IO)
  • 异步IO(asynchronous IO)

阻塞IO

在Linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概如下:
Image_20210811141032.png
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO第一阶段:准备数据。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。
而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

非阻塞IO

Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程如下:
image.png
当用户进程发出read操作,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。
从用户进程角度讲,它发起一个read操作后,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次受到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,非阻塞IO的特点是用户进程要不断的主动询问kernel数据好了没有。

IO多路复用

IO multiplexing就是我们说的select、poll、epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select、poll和epoll这个函数会不断的轮询所负责的socket,当某个socket有数据到达了,就通知用户进程。
image.png
当用户进程调用了select,那么整个进程会被block,而同时,kernel会监视所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

IO多路复用的特点是通过一个机制一个进程能同时等待多个文件描述符,而这些文件描述符,而这些文件描述符其中的任意一个进入就绪状态,select()函数就可以返回。

在上图的调用过程中,进行两次system call(select和recvfrom),而blocking IO只调用了一个system call(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
在连接数不是很高的情况下,多线程+blocking io的性能可能更好。

异步IO

Linux下的asynchronous IO其实用得很少:
image.png
用户进程发起read操作之后,立刻就可以开始去做其他的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成。

多路复用详解

select、poll和epoll都是IO多路复用的机制。IO多路复用就是通过一种机制。一个进程通过多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select、poll和epoll本质上都是同步IO,因为他们都需要在读写事件就绪后自己负责读写,也就是说这个读写过程是阻塞的,而异步IO则无需自己负责进行读写,异步IO的实现会负责把数据从内核拷贝到用户空间。

区别


select poll epoll
时间复杂度 O(n) O(n) O(1)
一个进程能打开的最大连接数 FD_SETSIZE宏定义,32位机器默认1024,64位机器默认2048 基于链表存储,没有限制 有上限,1G内存机器的上限为10万,2G内存机器的上限为20万
FD剧增后的IO效率 线性下降 线性下降 socket活跃数比较少的情况下,不会导致效率下降;当大量的socket活跃的时候,会导致性能问题
消息传递方式 内核将消息传递到用户空间,需要CPU拷贝 内核将消息传递到用户空间,需要CPU拷贝 内核空间和用户空间共享内存

select

对socket进行扫描是线性扫描,效率较低。当socket较多时,每次socket都要通过遍历FD_SETSIZE个socket来完成调度,不管哪个socket是活跃的,都遍历一遍,浪费很多CPU时间。

优点:

  • select目前几乎在所有的平台上支持

缺点:

  1. 单个进程可监视的fd数量被限制,32位机默认是1024
  2. 对socket进行扫描时采用线性扫描,效率较低
  3. 需要维护一个用来存放大量fd的数据结构,使得用户空间和内核空间在传递这个结构时复制开销巨大
  1. // 返回就绪描述符的数目,超时返回0,出错返回-1
  2. int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select函数监视的文件描述符分3类,使用bitmap表示,分别是writefds、readfds和excepfds,或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

poll

本质上与select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态。

优点:

  • 采用链表进行存储,所以并没有最大数量限制

缺点:

  • 大量的fd数组被整体复制于用户空间和内核空间之间,而不管这样的复制是否有意义
  • poll是“水平触发”,如果报告了fd上的事件,没有被处理,那么下次poll时会再次报告该fd的事件
  1. int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同于select的位图,poll使用一个pollfd的指针实现:

  1. struct pollfd {
  2. int fd; /* file descriptor */
  3. short events; /* requested events to watch */
  4. short revents; /* returned events witnessed */
  5. };

pollfd结构包含了要监视的events和发生的revents,poll返回后,需要轮询pollfd来获取就绪的描述符。

epoll

epoll是Linux2.6内核中提出的,是select和poll的增强版本。

LT模式:缺省模式。当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:高速模式。当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件,如果不处理,下次调用epoll_wait时,下次再次响应应用程序并通知此事件。

优点:

  1. 没有最大并发连接的限制
  2. 采用callback函数,只有活跃的fd才会回调
  3. 通过mmap在用户空间和内核空间共享数据,减少复制开销

epoll操作过程需要三个接口,分别如下:

  1. int epoll_create(int size);
  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

(1)int epoll_create(int size)
功能:创建一个epoll的句柄。当创建好epoll句柄后,它就会占用一个fd值,在Linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
参数:

  • size:监听的文件描述符最大数量

(2)int epoll_ctl(intg epfd, int op, int fd, struct epoll_event *event)
功能:操作控制epoll对象,主要涉及epoll红黑树上节点的一些操作,比如添加节点、删除节点和修改节点事件。
参数:

  • epfd:通过epoll_create()创建的epoll句柄
  • op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD,分别添加、删除和修改对fd的监听事件
  • fd:是需要监听的fd
  • event:需要监听事件

(3)int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
功能:阻塞一段时间等待IO事件发生,返回事件集合,也就是获取内核的事件通知。
参数:

  • epid:epoll_create返回的epoll对象描述符
  • events:存放就绪的事件集合
  • maxevents:代表可以存放的事件个数,events数组的大小
  • timeout:阻塞等待的时间,单位为毫秒,传入-1代表阻塞等待

返回:

  • 0:希望监听的事件发生

  • =0:已超时
  • <0:出错,可通过errno获取原因

流程:

  1. 调用epoll_create建立一个epoll句柄
  2. 调用epoll_ctl向epoll句柄中添加一定数量连接的套接字
  3. 调用epoll_wait收集发生的事件的连接