我们都知道unix世界里、一切皆文件、而文件是什么呢?文件就是一串二进制流而已、不管socket、还是FIFO、管道、终端、对我们来说、一切都是文件、一切都是流、在信息交换的过程中、我们都是对这些流进行数据的收发操作、简称为I/O操作(input and output)、往流中读出数据、系统调用read、写入数据、系统调用write、不过话说回来了、计算机里有这么多的流、我怎么知道要操作哪个流呢?做到这个的就是文件描述符、即通常所说的fd(file descriptor)、一个fd就是一个整数、所以对这个整数的操作、就是对这个文件(流)的操作、我们创建一个socket、通过系统调用会返回一个文件描述符、那么剩下对socket的操作就会转化为对这个描述符的操作、不能不说这又是一种分层和抽象的思想、
这里的
I/O
指缓冲I/O
; 根据UNIX网络编程对I/O模型的分类,UNIX提供了5种I/O模型,分别如下。
当某个程序或已存在的进程/线程(后文将不加区分的只认为是进程)需要某段数据时,它只能在用户空间中属于它自己的内存中访问、修改,这段内存暂且称之为application buffer
。假设需要的数据在磁盘上,那么进程首先得发起相关系统调用,通知内核去加载磁盘上的文件。但正常情况下,数据只能加载到内核的缓冲区,暂且称之为kernel buffer
。数据加载到kernel buffer
之后,还需将数据复制到application buffer
。到了这里,进程就可以对数据进行访问、修改。
实际上是可以的,有些程序或者硬件为了提高效率和性能,可以实现内核旁路的功能,避过内核的参与,直接在存储设备和application buffer
之间进行数据传输,例如RDMA技术就需要实现这样的内核旁路功能。
但是,最普通也是绝大多数的情况下,为了安全和稳定性,数据必须先拷入内核空间的kernel buffer
,再复制到application buffer
,以防止进程串进内核空间进行破坏。
不一样。现在的存储设备(包括网卡)基本上都支持DMA操作。什么是DMA(direct memory access,直接内存访问)?简单地说,就是内存和设备之间的数据交互可以直接传输,不再需要计算机的CPU参与,而是通过硬件上的芯片(可以简单地认为是一个小cpu)进行控制。
假设,存储设备不支持DMA,那么数据在内存和存储设备之间的传输,必须通过计算机的CPU计算从哪个地址中获取数据、拷入到对方的哪些地址、拷入多少数据(多少个数据块、数据块在哪里)等等,仅仅完成一次数据传输,CPU都要做很多事情。而DMA就释放了计算机的CPU,让它可以去处理其他任务。
再说kernel buffer
和application buffer
之间的复制方式,这是两段内存空间的数据传输,只能由CPU来控制。
所以,在加载硬盘数据到kernel buffer
的过程是DMA拷贝方式,而从kernel buffer
到app buffer
的过程是CPU参与的拷贝方式。
例如:web服务对客户端的响应数据,需要通过TCP连接传输给客户端。
TCP/IP协议栈维护着两个缓冲区:send buffer
和recv buffer
,它们合称为socket buffer
。需要通过TCP连接传输出去的数据,需要先复制到send buffer
,再复制给网卡通过网络传输出去。如果通过TCP连接接收到数据,数据首先通过网卡进入recv buffer
,再被复制到用户空间的application buffer
。
同样,在数据复制到send buffer
或从recv buffer
复制到appliction buffer
时,是CPU参与的拷贝。从send buffer
复制到网卡或从网卡复制到recv buffer
时,是DMA操作方式的拷贝。
如下图所示,是通过TCP连接传输数据时的过程。
其实并不是。如果进程不需要修改数据,就直接发送给TCP连接的另一端,可以不用从kernel buffer
复制到application buffer
,而是直接复制到send buffer
。这就是零复制技术。
例如httpd不需要访问和修改任何信息时,将数据原原本本地复制到application buffer
再原原本本地复制到send buffer
然后传输出去,但实际上复制到app buffer
的过程是可以省略的。使用零复制技术,就可以减少一次拷贝过程,提升效率。
以下是以httpd进程处理文件类请求时比较完整的数据操作流程。
根据上面拓扑解释:客户端发起对某个文件的请求,通过TCP连接,请求数据进入TCP 的recv buffer
,再通过recv()
函数将数据读入到application buffer
,此时httpd工作进程对数据进行一番解析,知道请求的是某个文件,于是发起某个系统调用(例如要读取这个文件,发起read()
),于是内核加载该文件,数据从磁盘复制到kernel buffer
再复制到application buffer
,此时httpd就要开始构建响应数据了,可能会对数据进行一番修改,例如在响应首部中加一个字段,最后将修改或未修改的数据复制(例如send()
函数)到send buffer
中,再通过TCP连接传输给客户端。
最常用的I/O模型就是阻塞IO模型,缺省情况下,所有文件操作都是阻塞的。
当用户进程调用了recvfrom
这个系统调用、内核就开始了IO的第一个阶段:准备数据、对于网络IO来说、很多时候数据在一开始还没有到达(比如、还没有收到一个完整的UDP包)、这个时候内核就要等待足够的数据到来、而在用户进程这边、整个进程会被阻塞、当内核一直等到数据准备好了、它就会将数据从内核中拷贝到用户内存、然后返回结果、用户进程才解除阻塞的状态、重新运行起来、几乎所有的程序员第一次接触到的网络编程都是从listen()
、send()
、recv()
等接口开始的、这些接口都是阻塞型;
blocking IO
的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被阻塞了;因此称为阻塞IO模型。举个例子:A拿着一支鱼竿在河边钓鱼,并且一直在鱼竿前等,在等的时候不做其他的事情,十分专心。只有鱼上钩的时,才结束掉等的动作,把鱼钓上来。这种方式叫做阻塞I/O模型;
recvfrom
从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个EWOULDBLOCK
错误,一般都对非阻塞IO模型进行轮询检查这个状态,看内核是不是有数据到来。
举个栗子:B在河边钓鱼,但是B不想将自己的所有时间都花费在钓鱼上,在等鱼上钩这个时间段中,B也在做其他的事情(一会看看书,一会读读报纸,一会又去看其他人的钓鱼等),但B在做这些事情的时候,每隔一个固定的时间检查鱼是否上钩。一旦检查到有鱼上钩,就停下手中的事情,把鱼钓上来。 其实,B在检查鱼竿是否有鱼,是一个轮询的过程。但是,轮寻对于CPU来说是较大的浪费,一般只有在特定的场景下才使用。
首先开启套接口信号驱动I/O功能,并通过系统调用sigaction
执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据准备就绪时,就为该进程生成一个SIGIO信号,通过信号回调通知应用程序调用recvfrom
来读取数据,并通知主循环函数处理数据。
在举个栗子:C也在河边钓鱼,但与A、B不同的是,C比较聪明,他给鱼竿上挂一个铃铛,当有鱼上钩的时候,这个铃铛就会被碰响,C就会将鱼钓上来。
由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是进程的用户态,而是有人帮忙就好了。那么这就是所谓的 “IO 多路复用”。
Linux提供了select/poll
,进程通过将一个或多个fd
传递给select
或poll
系统调用,阻塞在select
操作上,这样select/poll
可以帮我们侦测多个fd
是否处于就绪状态。select/poll
是顺序扫描fd
是否就绪,而且支持的fd
数量有限,因此它的使用受到了一些制约。Linux
还提供了一个epoll
系统调用,epoll
使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd
就绪时,立即回调函数rollback
。
select
调用是内核级别的,select
轮询相对非阻塞的轮询的区别在于—前者可以等待多个socket
,能实现同时对多个IO端口进行监听,当其中任何一个socket
的数据准好了,就能返回进行可读,然后进程再进行recvform
系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。select
或poll
调用之后,会阻塞进程,与blocking IO
阻塞不同在于,此时的select
不是等到socket
数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。
需要注意一点的是:IO多路转接是多了一个select
函数,select
函数有一个参数是文件描述符集合,对这些文件描述符进行循环监听,当某个文件描述符就绪时,就对这个文件描述符进行处理。其中,select
只负责等,recvfrom
只负责拷贝。IO多路复用是属于阻塞IO,但可以对多个文件描述符进行阻塞监听,所以效率较阻塞IO的高。
在IO复用模型中、对于每一个
socket
、一般都设置成为非阻塞、但是、如上图所示、整个用户的进程其实是一直被阻塞的、只不过进程是被select
这个函数阻塞、而不是被socket
IO给阻塞 举个栗子而言:D同样也在河边钓鱼,但是D生活水平比较好,D拿了很多的鱼竿,一次性有很多鱼竿在等,D不断的查看每个鱼竿是否有鱼上钩。增加了效率,减少了等待的时间。
相对于同步IO,异步IO不是顺序执行。用户进程发起aio_read(POSIX异步IO函数aio_或者lio_开头)操作之后、给内核传递描述符、缓冲区指针、缓冲区大小和read相同的三个参数以及文件偏移(与lseek类似)、告诉内核当整个操作完成时、如何通知我们、立刻就可以开始去做其它的事、而另一方面、从内核的角度、当它受到一个aio_read之后、首先它会立刻返回、所以不会对用户进程产生任何阻塞、然后、内核会等待数据准备完成、然后将数据拷贝到用户内存、当这一切都完成之后、内核会给用户进程发送一个信号、告诉它aio_read操作完成了IO两个阶段,进程都是非阻塞的。
Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent
、libev
、libuv
。
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动模型由内核通知我们何时可以开始一个IO操作;异步IO模型由内核通知我们IO操作何时完成。
这和前面的信号驱动式IO模型很容易混淆、需要理解IO交互并结合五种IO模型的比较阅读
POSIX
描述的异步IO、是五种IO模型中唯一的异步模型
Windows的IOCP使用的就是这个模型;
通过上面的图片,可以发现non-blocking IO
和asynchronous IO
的区别还是很明显的。在non-blocking IO
中,虽然进程大部分时间都不会被block
,但是它仍然要求进程去主动的check
,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom
来将数据拷贝到用户内存。而asynchronous IO
则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
前面的介绍中其实已经很明确的说明了这两者的区别、调用阻塞会一直阻塞住对应的进程直到操作完成、而非阻塞IO在内核还没准备数据的情况下会立刻返回、阻塞和非阻塞关注的是进程在等待调用结果时的状态、阻塞是指调用结果返回之前、当前进程会被挂起、调用进程只有在得到结果才会返回、非阻塞调用指不能立刻得到结果、该调用不会阻塞当前进程;
两者的区别就在于同步做IO操作的时候会将进程阻塞、按照这个定义、之前所述的阻塞IO、非阻塞IO、IO复用、信号驱动都属于同步IO、有人可能会说、非阻塞IO并没有被阻塞啊、这里有个非常狡猾的地方、定义中所指的IO操作是指真实的IO操作、就是例子中的recvfrom
这个系统调用、非阻塞IO在执行recvfrom
这个系统调用的时候、如果内核的数据没有准备好、这时候不会阻塞进程、但是、当内核中数据准备好的时候、recvfrom
会将数据从内核拷贝到用户内存中、这个时候进程是被阻塞了、信号驱动也是同样的道理、在这段时间内、进程是被阻塞的、而异步IO则不一样、当进程发起IO操作之后、就直接返回再也不理睬了、直到内核发送一个信号、告诉进程说IO完成、在这整个过程中、进程完全没有被阻塞、
同异步IO的根本区别在于、同步IO主动的调用recvfrom
来将数据拷贝到用户内存、而异步则完全不同、它就像是用户进程将整个IO操作交给了他人(内核)完成、然后他人做完后发信号通知、在此期间、用户进程不需要去检查IO操作的状态、也不需要主动的去拷贝数据
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.
这里之所以单独拿出来是因为如果还没有清除IO概念很容易混淆、所以理解IO模型之前一定要理解IO概念、如果看完前面两个问题、相信也能理解信号驱动IO与异步IO的区别在于启用异步IO意味着通知内核启动某个IO操作、并让内核在整个操作(包括数据从内核复制到用户缓冲区)完成时通知我们、也就是说、异步IO是由内核通知我们IO操作何时完成、即实际的IO操作也是异步的、信号驱动IO是由内核通知我们何时可以启动一个IO操作、这个IO操作由用户自定义的信号函数来实现;
I/O究竟什么时候能用这个信息实际上只有内核才能事先知道、因为是内核在最终处理系统中的所有打开的描述符; 信号驱动IO模型: 内核:IO能用了 进程:接受到IO能用的消息并执行接下来的操作 异步I/O模型: 内核:等待这个IO有消息了、接受到数据 进程:从缓存(用户空间)中得到数据
在IO编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者IO多路复用技术进行处理。IO多路复用技术通过把多个IO的阻塞复用到同一个select
的阻塞上,从而使得系统在单线程环境下可以同时处理多个客户端请求。
与传统的多线程/多进程模型相比,IO多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源,IO多路复用的应用场景如下:
目前支持IO多路复用的系统调用有select
、pselect
、poll
、epoll
,在Linux网络编程过程中,很长一段时间都使用select
做轮询和网络事件通知,然而select
的一些固有缺陷导致了它的应用受到了很大的限制,最终Linux不得不在新的内核版本中寻找select
的替代方案,最终选择了epoll
。
epoll
与select
的原理比较类似,为了克服select
的缺点,epoll
做了很多重大改进,总结如下。
socket
描述符(fd)不受限制(仅受限于操作系统的最大文件句柄数)select
最大的缺陷就是单个进程所打开的FD是有一定限制的,它有FD_SETSIZE
设置,默认值是1024
。对于那些需要支持上万个TCP连接的大型服务器来说显然太少了。可以选择修改这个宏然后重新编译内核,不过这会带来网络效率的下降。
我们也可以通过选择多进程的方案(传统的Apache方案解决这个问题),不过虽然在Linux上创建进程的代价比较小,但仍旧是不可忽视的。另外进程间数据交互非常麻烦,对于Java来说,由于没有共享内存,需要通过socket通信或者其他方式进行数据同步,这带来了额外的性能损耗,增加了程序复杂度,所以也不是一种完美的解决方案。
值得庆幸的是,epoll
并没有这个限制它所支持的FD上限是操作系统的最大文件句柄数,这个数字远远大于1024。例如,在1GB内存的机器上大约是10万个句柄左右,具体的值可以通过cat /proc/sys/fs/file-max
查看,通常情况下这个值跟系统的内存关系比较大。
传统select/poll
的另一个致命弱点是当你拥有一个很大的socket
集合时,由于网络延时或者链路空闲,任一时刻只有少部分的socket
是“活跃的”,但是select/poll
每次调用都会线性扫描全部的集合,导致效率呈现线性下降。
epoll
不存在上述问题。它只会对“活跃”的socket
进行操作-这是因为在内核实现中,epoll
是根据每个fd上面的callback
函数实现的。那么只有“活跃”的socket
才会去主动调用callback
函数,其他idle
状态的socket
则不会。在这点上epoll
实现了一个伪AIO
。
针对epoll
和select
性能对比的benchmark
测试表明:如果所有的socket
都处于活跃状态–例如一个高速的LAN环境,epoll
并不比select/poll
效率高太多;相反,如果过多使用epoll_ctl
,效率相比还有稍微地降低。但是一旦使用idle connections
模拟WAN环境,epoll
的效率就远在select/poll
之上了。
无论是select、poll
还是epoll
都需要内核把FD消息通知给用户空间,如何避免不必要的内存复制就显得非常重要,epoll
是通过内核和用户空间mmap同一块内存来实现的。
包括创建一个epoll
描述符、添加监听事件、阻塞等待所监听的事件发生、关闭epoll
描述符等。
值得说明的是,用来克服select/poll
缺点的方法不只有epoll
,epoll
只是一种Linux的实现方案。在FreeBSD下有kqueue
,而dev/poll
是最古老的Solaris的方案,使用难度依次递增。
kqueue
是FreeBSD的宠儿,它实际上一个功能相当丰富的kernel事件队列,它不仅仅是select/poll
的升级,而且可以处理signal
、目录结构变化、进程等多种事件。kqueue
是边缘触发的。
/dev/poll
是Solaris的产物,是这一系列高性能API中最早出现的。Kernel提供了一个特殊的设备文件/dev/poll
,应用程序打开这个文件得到操作fd_set
的句柄,通过写入pollfd
来修改它,一个特殊的ioctl
调用用来替换select
。不过由于出现的年代比较早,所以/dev/poll
的接口实现比较原始。
epoll
既然是对select
和poll
的改进,就应该能避免上述的三个缺点。那epoll
都是怎么解决的呢?在此之前,我们先看一下epoll
和select
和poll
的调用接口上的不同,select
和poll
都只提供了一个函数——select
或者poll
函数。而epoll
提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create
是创建一个epoll
句柄;epoll_ctl
是注册要监听的事件类型;epoll_wait
则是等待事件的产生。
对于第一个缺点,epoll
的解决方案在epoll_ctl
函数中。每次注册新的事件到epoll
句柄中时(在epoll_ctl
中指定EPOLL_CTL_ADD
),会把所有的fd拷贝进内核,而不是在epoll_wait
的时候重复拷贝。epoll
保证了每个fd在整个过程中只会拷贝一次。
对于第二个缺点,epoll
的解决方案不像select
或poll
一样每次都把current
轮流加入fd对应的设备等待队列中,而只在epoll_ctl
时把current
挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait
的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()
实现睡一会,判断一会的效果,和select
实现中的第7步是类似的)。
现在操作系统都是采用虚拟存储器
,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间
。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)
,供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)
,供各个进程使用,称为用户空间。
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
进程切换参考文档:https://osr507doc.xinuos.com/en/PERFORM/context_switching_cpu.html Quora文档:https://www.quora.com/Whats-the-difference-between-a-context-switch-a-process-switch-and-a-thread-switch-in-Linux
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的
。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表
。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 IO 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
select,poll
实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait
不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait
中进入睡眠的进程。虽然都要睡眠和交替,但是select
和poll
在“醒着”的时候要遍历整个fd集合,而epoll
在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。select,poll
每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current
往设备等待队列中挂一次,而epoll
只要一次拷贝,而且把current
往等待队列上挂也只挂一次(在epoll_wait
的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll
内部定义的等待队列)。这也能节省不少的开销。
版权属于:龙之介大人