https://www.cnblogs.com/guxuanqing/p/10482066.html
网络编程里常听到阻塞 IO, 非阻塞 IO, 同步 IO, 异步 IO 等概念, 总听别人装 13 不如自己下来钻研一下. 不过, 搞清楚这些概念之前, 还得先回顾一些基础的概念.
回到顶部
1 基础知识回顾
注意: 咱们下面说的都是 Linux 环境下, 跟 Windows 不一样哈~~~
1.1 用户空间和内核空间
现在操作系统都采用虚拟寻址, 处理器先产生一个虚拟地址, 通过地址翻译成物理地址(内存的地址), 再通过总线的传递, 最后处理器拿到某个物理地址返回的字节.
对 32 位操作系统而言, 它的寻址空间 (虚拟存储空间) 为 4G(2 的 32 次方). 操作系统的核心是内核, 独立于普通的应用程序, 可以访问受保护的内存空间, 也有访问底层硬件设备的所有权限. 为了保证用户进程不能直接操作内核(kernel), 保证内核的安全, 操心系统将虚拟空间划分为两部分, 一部分为内核空间, 一部分为用户空间. 针对 Linux 操作系统而言, 将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF), 供内核使用, 称为内核空间, 而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF), 供各个进程使用, 称为用户空间.
补充: 地址空间就是一个非负整数地址的有序集合. 如{0,1,2...}.
1.2 进程上下文切换(进程切换)
为了控制进程的执行, 内核必须有能力挂起正在 CPU 上运行的进程, 并恢复以前挂起的某个进程的执行. 这种行为被称为进程切换(也叫调度). 因此可以说, 任何进程都是在操作系统内核的支持下运行的, 是与内核紧密相关的.
从一个进程的运行转到另一个进程上运行, 这个过程中经过下面这些变化:
1. 保存当前进程 A 的上下文.
上下文就是内核再次唤醒当前进程时所需要的状态, 由一些对象 (程序计数器, 状态寄存器, 用户栈等各种内核数据结构) 的值组成.
这些值包括描绘地址空间的页表, 包含进程相关信息的进程表, 文件表等.
2. 切换页全局目录以安装一个新的地址空间.
...
3. 恢复进程 B 的上下文.
可以理解成一个比较耗资源的过程.
1.3 进程的阻塞
正在执行的进程, 由于期待的某些事件未发生, 如请求系统资源失败, 等待某种操作的完成, 新数据尚未到达或无新工作做等, 则由系统自动执行阻塞原语(Block), 使自己由运行状态变为阻塞状态. 可见, 进程的阻塞是进程自身的一种主动行为, 也因此只有处于运行态的进程(获得 CPU), 才可能将其转为阻塞状态. 当进程进入阻塞状态, 是不占用 CPU 资源的.
1.4 文件描述符
文件描述符 (File descriptor) 是计算机科学中的一个术语, 是一个用于表述指向文件的引用的抽象化概念.
文件描述符在形式上是一个非负整数. 实际上, 它是一个索引值, 指向内核为每一个进程所维护的该进程打开文件的记录表. 当程序打开一个现有文件或者创建一个新文件时, 内核向进程返回一个文件描述符. 在程序设计中, 一些涉及底层的程序编写往往会围绕着文件描述符展开. 但是文件描述符这一概念往往只适用于 UNIX,Linux 这样的操作系统.
1.5 直接 I/O 和缓存 I/O
缓存 I/O 又被称作标准 I/O, 大多数文件系统的默认 I/O 操作都是缓存 I/O. 在 Linux 的缓存 I/O 机制中, 以 write 为例, 数据会先被拷贝进程缓冲区, 在拷贝到操作系统内核的缓冲区中, 然后才会写到存储设备中.
缓存 I/O 的 write:
直接 I/O 的 write:(少了拷贝到进程缓冲区这一步)
write 过程中会有很多次拷贝, 知道数据全部写到磁盘. 好了, 准备知识概略复习了一下, 开始探讨 IO 模式.
回到顶部
2 I/O 模式
对于一次 IO 访问(这回以 read 举例), 数据会先被拷贝到操作系统内核的缓冲区中, 然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区, 最后交给进程. 所以说, 当一个 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 模型.
2.1 block I/O 模型(阻塞 I/O)
阻塞 I/O 模型示意图:
read 为例:
(1)进程发起 read, 进行 recvfrom 系统调用;
(2)内核开始第一阶段, 准备数据(从磁盘拷贝到缓冲区), 进程请求的数据并不是一下就能准备好; 准备数据是要消耗时间的;
(3)与此同时, 进程阻塞(进程是自己选择阻塞与否), 等待数据 ing;
(4)直到数据从内核拷贝到了用户空间, 内核返回结果, 进程解除阻塞.
也就是说, 内核准备数据和数据从内核拷贝到进程内存地址这两个过程都是阻塞的.
2.2 non-block(非阻塞 I/O 模型)
可以通过设置 socket 使其变为 non-blocking. 当对一个 non-blocking socket 执行读操作时, 流程是这个样子:
(1)当用户进程发出 read 操作时, 如果 kernel 中的数据还没有准备好;
(2)那么它并不会 block 用户进程, 而是立刻返回一个 error, 从用户进程角度讲 , 它发起一个 read 操作后, 并不需要等待, 而是马上就得到了一个结果;
(3)用户进程判断结果是一个 error 时, 它就知道数据还没有准备好, 于是它可以再次发送 read 操作. 一旦 kernel 中的数据准备好了, 并且又再次收到了用户进程的 system call;
(4)那么它马上就将数据拷贝到了用户内存, 然后返回.
所以, nonblocking IO 的特点是用户进程在内核准备数据的阶段需要不断的主动询问数据好了没有.
2.3 I/O 多路复用(JAVA NIO 就是采用此模式)
I/O 多路复用实际上就是用 select, poll, epoll 监听多个 io 对象, 当 io 对象有变化 (有数据) 的时候就通知用户进程. 好处就是单个进程可以处理多个 socket. 当然具体区别我们后面再讨论, 现在先来看下 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.
2.4 asynchronous I/O(异步 I/O)
真正的异步 I/O 很牛逼, 流程大概如下:
(1)用户进程发起 read 操作之后, 立刻就可以开始去做其它的事.
(2)而另一方面, 从 kernel 的角度, 当它受到一个 asynchronous read 之后, 首先它会立刻返回, 所以不会对用户进程产生任何 block.
(3)然后, kernel 会等待数据准备完成, 然后将数据拷贝到用户内存, 当这一切都完成之后, kernel 会给用户进程发送一个 signal, 告诉它 read 操作完成了.
2.5 小结
(1)blocking 和 non-blocking 的区别
调用 blocking IO 会一直 block 住对应的进程直到操作完成, 而 non-blocking IO 在 kernel 还准备数据的情况下会立刻返回.
(2)synchronous IO 和 asynchronous IO 的区别
在说明 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.
有人会说, non-blocking IO 并没有被 block 啊. 这里有个非常 "狡猾" 的地方, 定义中所指的 "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.
(3)non-blocking IO 和 asynchronous IO 的区别
可以发现 non-blocking IO 和 asynchronous IO 的区别还是很明显的.
-- 在 non-blocking IO 中, 虽然进程大部分时间都不会被 block, 但是它仍然要求进程去主动的 check, 并且当数据准备完成以后, 也需要进程主动的再次调用 recvfrom 来将数据拷贝到用户内存.
-- 而 asynchronous IO 则完全不同. 它就像是用户进程将整个 IO 操作交给了他人 (kernel) 完成, 然后他人做完后发信号通知. 在此期间, 用户进程不需要去检查 IO 操作的状态, 也不需要主动的去拷贝数据.
sellect,poll,epoll 三者的区别
select
select 最早于 1983 年出现在 4.2BSD 中, 它通过一个 select()系统调用来监视多个文件描述符的数组, 当 select()返回后, 该数组中就绪的文件描述符便会被内核修改标志位, 使得进程可以获得这些文件描述符从而进行后续的读写操作.
select 目前几乎在所有的平台上支持, 其良好跨平台支持也是它的一个优点, 事实上从现在看来, 这也是它所剩不多的优点之一.
select 的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制, 在 Linux 上一般为 1024, 不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制.
另外, select()所维护的存储大量文件描述符的数据结构, 随着文件描述符数量的增大, 其复制的开销也线性增长. 同时, 由于网络响应时间的延迟使得大量 TCP 连接处于非活跃状态, 但调用 select()会对所有 socket 进行一次线性扫描, 所以这也浪费了一定的开销.
poll
poll 在 1986 年诞生于 System V Release 3, 它和 select 在本质上没有多大差别, 但是 poll 没有最大文件描述符数量的限制.
poll 和 select 同样存在一个缺点就是, 包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间, 而不论这些文件描述符是否就绪, 它的开销随着文件描述符数量的增加而线性增大. 另外, select()和 poll()将就绪的文件描述符告诉进程后, 如果进程没有对其进行 IO 操作, 那么下次调用 select()和 poll()的时候将再次报告这些文件描述符, 所以它们一般不会丢失就绪的消息, 这种方式称为水平触发(Level Triggered).
epoll
直到 Linux2.6 才出现了由内核直接支持的实现方法, 那就是 epoll, 它几乎具备了之前所说的一切优点, 被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法.
epoll 可以同时支持水平触发和边缘触发(Edge Triggered, 只告诉进程哪些文件描述符刚刚变为就绪状态, 它只说一遍, 如果我们没有采取行动, 那么它将不会再次告知, 这种方式称为边缘触发), 理论上边缘触发的性能要更高一些, 但是代码实现相当复杂.
epoll 同样只告知那些就绪的文件描述符, 而且当我们调用 epoll_wait()获得就绪文件描述符时, 返回的不是实际的描述符, 而是一个代表就绪描述符数量的值, 你只需要去 epoll 指定的一个数组中依次取得相应数量的文件描述符即可, 这里也使用了内存映射 (mmap) 技术, 这样便彻底省掉了这些文件描述符在系统调用时复制的开销.
另一个本质的改进在于 epoll 采用基于事件的就绪通知方式. 在 select/poll 中, 进程只有在调用一定的方法后, 内核才对所有监视的文件描述符进行扫描, 而 epoll 事先通过 epoll_ctl()来注册一个文件描述符, 一旦基于某个文件描述符就绪时, 内核会采用类似 callback 的回调机制, 迅速激活这个文件描述符, 当进程调用 epoll_wait()时便得到通知.
epoll 是 Linux 内核为处理大批量文件描述符而作了改进的 poll, 是 Linux 下多路复用 IO 接口 select/poll 的增强版本, 它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率. 原因就是获取事件的时候, 它无须遍历整个被侦听的描述符集, 只要遍历那些被内核 IO 事件异步唤醒而加入 Ready 队列的描述符集合就行了.
来源: http://www.bubuko.com/infodetail-3300978.html