Linux IO模式
概念
用户空间与内核空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对Linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000
到0xFFFFFFFF
),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000
到0xBFFFFFFF
),供各个进程使用,称为用户空间。
每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。
文件描述符fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
Socket:正如应用程序使用文件描述符访问文件一样,应用程序也使用套接字描述符(socket descriptors )访问套接字。套接字描述符在 UNIX 系统中作为文件描述符实现,这也符合UNIX系统“一切皆文件”的设计思想。
缓存 I/O
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
Linux IO 模式
对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
- 等待数据准备 (Waiting for the data to be ready)
- 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
在整个请求过程中,数据输入至buffer需要时间,而从buffer复制数据至进程也需要时间。因此根据在这两段时间内等待方式的不同,I/O动作可以分为以下五种模式:
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)
由于signal driven IO在实际中并不常用,这里不做介绍。
在数据传输的过程中,避免数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间进行拷贝。有的时候,应用程序在数据进行传输的过程中不需要对数据进行访问,那么,将数据从 Linux 的页缓存拷贝到用户进程的缓冲区中就可以完全避免,传输的数据在页缓存中就可以得到处理。在某些特殊的情况下,这种零拷贝技术可以获得较好的性能。Linux 中提供类似的系统调用主要有 mmap()
,sendfile()
以及 splice()
。
如果使用mmap()
,磁盘上的数据会通过 DMA
被拷贝的内核缓冲区,接着操作系统会把这段内核缓冲区与应用程序共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。应用程序调用 write()
时,操作系统直接将内核缓冲区的内容拷贝到 socket
缓冲区中,这一切都发生在内核态,最后, socket
缓冲区再把数据发到网卡去。相对于read()
很明显减少了一次拷贝,当拷贝数据量很大时,无疑提升了效率。
阻塞IO
在Linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
当用户进程调用了recvfrom
这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的,而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除阻塞的状态,重新运行起来。
所以,所以,阻塞IO的特点就是在IO执行的两个阶段都被block了。
非阻塞 I/O
Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子
当用户进程调用recvfrom
时,系统不会阻塞用户进程,而是立刻返回一个ewouldblock
错误,从用户进程角度讲 ,并不需要等待,而是马上就得到了一个结果。用户进程判断标志是ewouldblock
时,就知道数据还没准备好,于是它就可以去做其他的事了,于是它可以再次发送recvfrom
,一旦内核中的数据准备好了。并且又再次收到了用户进程的系统,那么它马上就将数据拷贝到了用户内存,然后返回。
当一个应用程序在一个循环里对一个非阻塞调用recvfrom
,我们称为轮询。应用程序不断轮询内核,看看是否已经准备好了某些操作。这通常是浪费CPU时间,但这种模式偶尔会遇到。
I/O 复用
IO multiplexing就是我们说的select,poll,epoll,有时候也称这种IO方式为事件驱动IO(event driven IO)。select/epoll的好处就在于单个进程就可以同时处理多个网络连接的IO。当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被阻塞,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和阻塞IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要进行两次系统调用 (select 和 recvfrom
),而阻塞IO只进行一次系统调用 (recvfrom
)。但是,用select的优势在于它可以同时处理多个连接。
所以处理的连接数不是很高的话,使用【select/epoll】的服务器不一定比使用【多线程 + 阻塞IO】的web server性能更好,可能延迟还更大。
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
select
基本原理:select 函数监视的文件描述符分3类,分别是writefds
、readfds
、和exceptfds
。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset
,来找到就绪的描述符。
缺点:
- select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由
FDSETSIZE
设置,32位机默认是1024个,64位机默认是2048。 一般来说这个数目和系统内存关系很大 - 对socket进行扫描时是线性扫描,效率较低。 当套接字比较多的时候,每次select()都要通过遍历
FDSETSIZE
个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。 - 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
poll
poll本质上和select基本没有区别。特点是没有最大连接数的限制,原因是它是基于链表来存储的,但是线性遍历和复制开销大的缺点仍然存在。poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,如果采用线性遍历方式,随着监视的描述符数量的增长,其效率也会线性下降。
水平触发模式(level trigger):当检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用会再次响应应用程序并通知此事件。
边缘触发模式(edge trigger):当检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用时,不会再次响应应用程序并通知此事件。
epoll
epoll是在Linux 2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活:
没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。 只有活跃可用的FD才会调用callback函数;即epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关
内存拷贝,利用
mmap()
文件映射内存加速与内核空间的消息传递,减少了无用的复制epoll支持水平触发和边缘触发
JDK1.5_update10版本使用epoll替代了传统的select/poll,极大的提升了NIO通信的性能。
异步I/O
应用进程执行 aio_read
系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。
总结
- 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
- 异步 I/O:第二阶段应用进程不会阻塞。
同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,它们的主要区别在第一个阶段。
非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。
从同步、异步,以及阻塞、非阻塞两个维度来划分来看,IO模式可以分为: