现在操作系统都采用虚拟寻址,处理器先产生一个虚拟地址,通过地址翻译成物理地址(内存的地址),再通过总线的传递,最后处理器拿到某个物理地址返回的字节。

对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。

核空间:在liunx中,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为“内核空间”。

用户空间: 在liunx中,将较低的3G字节(从虚拟地址 0x00000000到0xBFFFFFFF),供各个进程使用,称为“用户空间)。

内核态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态

用户态:每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)

因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统 内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。

  • 进程寻址空间0~4G

  • 进程在用户态只能访问0~3G,只有进入内核态才能访问3G~4G

  • 进程通过系统调用进入内核态

  • 每个进程虚拟空间的3G~4G部分是相同的

进程上下文切换(进程切换)

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

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

  1. 保存当前进程A的上下文。 上下文就是内核再次唤醒当前进程时所需要的状态,由一些对象(程序计数器、状态寄存器、用户栈等各种内核数据结构)的值组成。 这些值包括描绘地址空间的页表、包含进程相关信息的进程表、文件表等。

  2. 切换页全局目录以安装一个新的地址空间。

  3. 恢复进程B的上下文。

可以理解成一个比较耗资源的过程。

直接I/O和缓存I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,以write为例,数据会先被拷贝进程缓冲区,在拷贝到操作系统内核的缓冲区中,然后才会写到存储设备中。
1.3

IO模式

对于一次IO访问(这回以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区,最后交给进程。所以说,当一个read操作发生时,它会经历两个阶段:

    1. 等待数据准备 (Waiting for the data to be ready)
    1. 将数据从内核拷贝到进程中 (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)

block I/O模型(阻塞I/O)

read为例:
block IO

(1)进程发起read,进行recvfrom系统调用;

(2)内核开始第一阶段,准备数据(从磁盘拷贝到缓冲区),进程请求的数据并不是一下就能准备好;准备数据是要消耗时间的;

(3)与此同时,进程阻塞(进程是自己选择阻塞与否),等待数据ing;

(4)直到数据从内核拷贝到了用户空间,内核返回结果,进程解除阻塞。

也就是说,内核准备数据和数据从内核拷贝到进程内存地址这两个过程都是阻塞的。

non-block(非阻塞I/O模型)

可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
non-block IO

(1)当用户进程发出read操作时,如果kernel中的数据还没有准备好;

(2)那么它并不会block用户进程,而是立刻返回一个error,从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果;

(3)用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call;

(4)那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程在内核准备数据的阶段需要不断的主动询问数据好了没有。

I/O多路复用

I/O多路复用实际上就是用select, poll, epoll监听多个io对象,对一个IO端口,两次调用,两次返回,比阻塞IO并没有什么优越性;关键是能实现同时对多个IO端口进行监听;当io对象有变化(有数据)的时候就通知用户进程。现在先来看下I/O多路复用的流程:
I/O多路复用
(1)当用户进程调用了select,那么整个进程会被block;

(2)而同时,kernel会“监视”所有select负责的socket;

(3)当任何一个socket中的数据准备好了,select就会返回;

(4)这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

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

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。 但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + 阻塞 IO的web server性能更好,可能延迟还更大。

select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

信号驱动I/O模型

sigio

我们首先开启套接口的信号驱动I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。

无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间,进程不被阻塞。主循环可以继续执行,只要不时等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

asynchronous I/O(异步 I/O)

异步I/O(asynchronous I/O)由POSIX规范定义。一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到我们自己的缓冲区)完成后通知我们。这种模型与信号驱动模型的主要区别在于:信号驱动I/O是由内核通知我们何时启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

asyncio

我们调用aioread函数(POSIX异步I/O函数以aio或lio_开头),给内核传递描述字、缓冲区指针、缓冲区大小(与read相同的三个参数)、文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,在等待I/O完成期间,我们的进程不被阻塞。

(1)用户进程发起read操作之后,立刻就可以开始去做其它的事。

(2)而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。

(3)然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

select/poll/epoll

在服务器端,每次通过accept系统调用接收客户端请求,建立连接,接着服务器端会获取代表这个连接的文件描述符(Linux下socket也被视为文件),并处理这个文件描述符上的相关请求。当与服务器端连接的大量客户端同时请求时,服务端需要能够感知到请求的到来并及时处理。一个很简单的思路是使用CPU轮询已建立连接的文件描述符上是否有新的请求到来,类似如下的代码:

1
2
3
for descriptor in open_connections:
if has_new_input(descriptor):
process_input(descriptor)

这种方法理论上是可行的,但是当大量连接中只有少量是活跃的连接时,这无疑会浪费很多CPU时间,在Linux下,与其在服务端对这些文件描述符做轮询,不如把文件描述符交给内核,让它负责告诉服务端哪个文件描述符上有新的请求。让Linux处理大量文件描户符的操作其实就是I/O多路复用,I/O多路复用的本质就是用select/poll/epoll,去监听多个socket文件描述符,如果其中的socket文件描述符有变化,只要有变化,服务器进程就知道了。

在任意unix机器上,select和poll都是可用的,epoll则只能够在Linux上使用。select和poll的工作方式如下:

  1. 将多个文件描述符传递给select/poll
  2. select/poll会在任一文件描述符有新的请求到来时告知服务端进程

需要注意的是select和poll的源码几乎是相同的,不同的是select支持的操作不如poll支持的丰富,如在文件描述符有新的请求到来时,select只会返回有新的输入有新的输出有新的错误;而poll则支持POLLRDNORM | POLLRDBAND | POLLIN | POLLHUP | POLLERR,select会将poll所支持的多种输出统一转换为一种输出:可写

select的缺点

  • 单个进程能够监视的文件描述符数量存在大量限制,通常是1024,可以通过重新编译Linux内核的方式修改数量,另外由于select采用轮询的方式扫描文件描述符,所以文件描述符越多,性能越差
  • 内核/用户空间内存拷贝问题,select需要复制大量的句柄数据结构,会产生较大开销
  • select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能知晓哪些句柄发生了时间
  • select触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO,那么之后再次select调用还是会将这些文件描述符通知进程

相比于select模型,poll使用链表保存文件描述符,因此没有监视文件描述符数量的限制,同时当文件描述符数量较少时,poll的性能表现也会好于select

nodejs使用epoll模型的原因是:epoll并不是采用轮询方式去监听了,而是当socket有变化时通过回调的方式主动告知用户进程。能够有效提升CPU利用率。

epoll实现机制

epoll通过在Linux内核中申请一个简易的文件系统,把原先的select/poll调用分成了三个部分:

  • 调用epoll_create()建立一个epoll对象
  • 调用epoll_ctl()向epoll对象中体检连接套接字
  • 调用epoll_wait()收集发生事件的连接

可以看到epoll比select/poll的优越之处:因为后者每次调用时都要传递你所要监控的所有socket给select/poll系统调用,这意味着需要将用户态的socket列表copy到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。而我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。

所以,实际上在你调用epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。

性能对比:

1
2
3
4
5
# operations | poll | select | epoll
10 | 0.61 | 0.73 | 0.41
100 | 2.9 | 3.0 | 0.42
1000 | 35 | 35 | 0.53
10000 | 990 | 930 | 0.66

需要注意的是:epoll并不是在所有的应用场景都会比select和poll高很多。尤其是当活动连接比较多的时候,回调函数被触发得过于频繁的时候,epoll的效率也会受到显著影响!所以,epoll特别适用于连接数量多,但活动连接较少的情况。

LT(水平触发)和ET(边缘触发)

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

  • LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

  • ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

参考资料

IO 模式和 IO 多路复用 · Ruby China
Select is fundamentally broken — Idea of the day
Async IO on Linux: select, poll, and epoll - Julia Evans
[高并发网络编程之epoll详解]http://www.cnblogs.com/wuchanming/p/4349743.html