概念说明

在进行解释之前,首先要说明几个概念: - 用户空间和内核空间 - 进程切换 - 进程的阻塞 - 文件描述符 - 缓存 I/O

用户空间与内核空间

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

进程切换

  • 为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换
  • 很耗资源
进程切换的过程
  1. 保存处理器上下文,包括程序计数器和其他寄存器
  2. 更新进程控制块(PCB Process Control Block)信息
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列
  4. 选择另一个进程执行,并更新其PCB
  5. 更新内存管理的数据结构
  6. 恢复处理器上下文

进程的阻塞

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

文件描述符fd

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

缓存 I/O

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

I/O模式

当一个read操作发生时,它会经历两个阶段: 1. 等待数据准备 (Waiting for the data to be ready) 2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正是因为这两个阶段,linux系统产生了下面五种网络模式的方案: - 阻塞 I/O(blocking IO) - 非阻塞 I/O(nonblocking IO) - I/O 多路复用( IO multiplexing) - 信号驱动 I/O( signal driven IO) - 异步 I/O(asynchronous IO)

注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。

阻塞 I/O(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样 Block I/O Model

  • 当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。
  • 在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
  • 特点:
    • 进程在IO执行的两个阶段都被block了

非阻塞 I/O(nonblocking IO)

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

I/O 多路复用( IO multiplexing)

  • IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO
  • 好处就在于单个process就可以同时处理多个网络连接的IO
  • 基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程
  • 流程如下 IO多路复用
  • 当用户进程调用了select,那么整个进程会被block
  • kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回,用户进程再调用read操作,将数据从kernel拷贝到用户进程
  • 特点:
    • 一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回
  • 这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom),如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大,select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接
  • 实际中,对于每一个socket,一般都设置成为non-blocking,但是进程一直被select这个函数block,而不是被socket IO给block

异步 I/O(asynchronous IO)

  • 流程如下: 异步IO
  • 用户进程发起read操作之后,立刻就可以开始去做其它的事
  • 从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block
  • kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了

总结

blocking和non-blocking的区别
  • blocking IO会一直block住对应的进程直到操作完成
  • non-blocking IO在kernel还在准备数据的情况下会立刻返回
synchronous IO和asynchronous IO的区别
  • POSIX标准的定义是这样子的:

    • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
    • An asynchronous I/O operation does not cause the requesting process to be blocked;
  • 两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO

  • 定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

  • asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block

  • 各个I/O Model的比较如图所示: I/O Model比较

  • 在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存

  • asynchronous IO则完全不同,它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据

I/O 多路复用之select、poll、epoll详解

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

select

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。
  • 当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符
  • 优点:
    • select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点
  • 缺点:
    • 单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同于select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};
  • pollfd结构包含了要监视的event和发生的event
  • pollfd并没有最大数量限制(但是数量过大后性能也是会下降)
  • 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符

select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本 - epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

epoll操作过程
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  1. int epoll_create(int size)
  • 参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
  • 在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽
  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 函数是对指定描述符fd执行op操作
  • epfd : 是epoll_create()的返回值
  • op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件
  • fd: 是需要监听的fd(文件描述符)
  • epoll_event: 告诉内核需要监听什么事,struct epoll_event结构如下
struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
  1. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  2. 等待epfd上的io事件,最多返回maxevents个事件
  3. 参数events用来从内核得到事件的集合
  4. maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
  5. 参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)
  6. 该函数返回需要处理的事件数目,如返回0表示已超时
工作模式

epoll对文件描述符的操作有两种模式,LT模式是默认模式 - LT(level trigger)
- 当epoll_wait检测到描述符事件发生并将此事件通知应用程序, - 应用程序可以不立即处理该事件。 - 下次调用epoll_wait时,会再次响应应用程序并通知此事件 - 同时支持block和no-block socket - ET(edge trigger) - 当epoll_wait检测到描述符事件发生并将此事件通知应用程序 - 应用程序必须立即处理该事件 - 如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件 - 高速工作方式,只支持no-block socket - 很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高 - 必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死

epoll总结

  • 在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描
  • epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知
  • 去掉了遍历文件描述符,而是通过监听回调的的机制
  • epoll优点
    • 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大
    • IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数