面试考察的是如何把书本上的知识转化为自己的理解
前前后后也面试了大小快二十场面试,总的来看感觉面试的过程其实没有想象的那么难,很多知识是自己平时遇到的问题,要么是没有用心去理解,要么是理解了,心里知道是什么意思,但是无法表达清楚,抓不住关键点。
所以打算以后学习一个知识,先仔细阅读理解,然后不看书本,用自己的话写下来总结成一篇博客。
知识回顾
- 套接字
套接字就是IP:端口号
,是用于 TCP 连接的端点
- I/O 模型
一个输入操作大概就是分两步:应用程序请求并等待数据到达;从内核向进程复制数据。而对于套接字上的输入操作,第一步类似,就是等待数据从网络中到达,数据到达以后为了防止数据丢失,会先复制到内核中的某个缓冲区;第二步就是将内核缓冲区中的数据复制到应用程序缓冲区。
Unix 下有五种 I/O 模型:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 复用(select poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O(AIO)
阻塞式 I/O
具体过程就是首先应用进程发起系统调用,会进入等待,一直等到数据报就绪,这时候数据还是在内核缓冲区中,需要将数据报返回给应用进程缓冲区。
需要注意的是,阻塞式 I/O 不是意味着系统进入阻塞,而仅仅是当前应用程序阻塞,其他应用程序还是可以继续运行的,因此不消耗 CPU 时间,执行效率较高
非阻塞式 I/O
应用进程执行系统调用以后,不同于阻塞式进程直接进入阻塞,而是如果数据没有准备好,就会返回一个错误码,应用进程可以继续执行,但是需要不断进行系统调用来获得 I/O 是否完成,这种方式称为轮询(polling)
我这里的理解就是其实非阻塞式 I/O 和阻塞式 I/O 实际上是相同的,都需要等待数据,只不过一个是被动进入阻塞,另一个是主动请求看数据有没有好,就比如是高中学习,一个是在想学习新知识过程中等待老师上课讲,在老师没有上课讲之前就一直等待,而另外一个同学是不停去办公室问,老师上不上课,什么时候上课。
I/O 复用
I/O 复用其实流程和阻塞式有很大相同之处,只不过 I/O 复用会先调用 select,这个时候系统会监听所有 select 负责的数据报,一旦有某个数据准备就绪,就会将其返回,然后进行 recvfrom 系统调用,执行同阻塞式 I/O 相同的处理。对比阻塞式 I/O,这里需要调用两个系统调用,所以效率肯定不如前者,但是最大的特点就是可以同时处理多个 connection。
多说一句。所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
信号驱动式 I/O
不常用,基本不会涉及到
异步 I/O
进行 aio_read 系统调用会立即返回, 应用进程继续执行, 不会被阻塞, 内核会在所有操作完成之后向应用进程发送信号。
异步 I/O 与信号驱动 I/O 的区别在于, 异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。
简单说就是,当用户应用进程请求数据时,不会进入阻塞,而是继续执行其他任务,等到该应用进程数据处理完毕,那么相应的系统会给用户进程传回一个信号,表示应用进程已经执行完毕,这是和信号驱动最大的不同,这个返回的信号是通知进程已经执行完毕,而信号驱动返回信号是通知进程可以开始执行。
比较
阻塞式和非阻塞式区别?同步异步区别?
先说简单的,阻塞式和非阻塞式最大的区别就是阻塞式在等待数据阶段会进入阻塞,而非阻塞式不会,但是对于非阻塞式,在获得数据存在内核缓冲区后,将内核缓冲区中数据复制到应用程序缓冲区这个阶段是阻塞的。
在说同步异步区别之前先要了解什么叫同步,什么叫异步?区别就是在进行 I/O 操作时候会将进程阻塞,根据这个定义就知道,阻塞式、非阻塞式、信号驱动式、I/O 复用式都属于同步,为什么非阻塞式也是呢?这就涉及到前面说的,虽然在开始是没有阻塞,但是后面将数据从内核到应用程序是阻塞的。
引用博主中很好的一个例子来理解:
最后,再举几个不是很恰当的例子来说明这四个IO Model: 有A,B,C,D四个人在钓鱼:
A用的是最老式的鱼竿,所以呢,得一直守着,等到鱼上钩了再拉杆;
B的鱼竿有个功能,能够显示是否有鱼上钩,所以呢,B就和旁边的MM聊天,隔会再看看有没有鱼上钩,有的话就迅速拉杆;
C用的鱼竿和B差不多,但他想了一个好办法,就是同时放好几根鱼竿,然后守在旁边,一旦有显示说鱼上钩了,它就将对应的鱼竿拉起来;
D是个有钱人,干脆雇了一个人帮他钓鱼,一旦那个人把鱼钓上来了,就给D发个短信
面试必问
select/poll/epoll 都是 I/O 多路复用的具体实现, select 出现的最早, 之后是 poll, 再是 epoll。
select
有三种类型的描述符类型: readset、 writeset、 exceptset, 分别对应读、 写、 异常 条件的描述符集合。 fd_set 使用数组实现, 数组大小使用 FD_SETSIZE 定义。
timeout 为超时参数, 调用 select 会一直阻塞直到有描述符的事件到达或者等待的 时间超过 timeout。
成功调用返回结果大于 0, 出错返回结果为 -1, 超时返回结果为 0。
关键代码如下:
fd_set fd_in, fd_out;
struct timeval tv;
// Reset the sets
FD_ZERO( &fd_in );
FD_ZERO( &fd_out );
// Monitor sock1 for input events
FD_SET( sock1, &fd_in );
// Monitor sock2 for output events
FD_SET( sock2, &fd_out );
// Find out which socket has the largest numeric value as select requires it
int largest_sock = sock1 > sock2 ? sock1 : sock2;
// Wait up to 10 seconds
tv.tv_sec = 10;
tv.tv_usec = 0;
// Call the select
int ret = select( largest_sock + 1, &fd_in, &fd_out, NULL, &tv );
// Check if select actually succeed
if ( ret == -1 )
// report error and abort
else if ( ret == 0 )
// timeout; no event detected
else
{
if ( FD_ISSET( sock1, &fd_in ) )
// input event on sock1
if ( FD_ISSET( sock2, &fd_out ) )
// output event on sock2
}
复制代码
select 详细过程:
当用户 process 调用 select 的时候,select 会将需要监控的 readfds 集合拷贝到内核空间(假设监控的仅仅是 socket 可读),然后遍历自己监控的 socket sk,挨个调用 sk 的 poll 逻辑以便检查该 sk 是否有可读事件,遍历完所有的 sk 后,如果没有任何一个 sk 可读,那 select 会调用 schedule_timeout 进入 schedule 循环,使得 process 进入睡眠。如果在 timeout 时间内某个 sk 上有数据可读了,或者等待 timeout 了,则调用 select 的 process 会被唤醒,接下来 select 就是遍历监控的 sk 集合,挨个收集可读事件并返回给用户了
到这里,我们有三个问题需要解决:
(1)被监控的fds集合限制为1024,1024太小了,我们希望能够有个比较大的可监控fds集合
(2)fds集合需要从用户空间拷贝到内核空间的问题,我们希望不需要拷贝
(3)当被监控的fds中某些有数据可读的时候,我们希望通知更加精细一点,就是我们希望能够从通知中得到有可读事件的fds列表,而不是需要遍历整个fds来收集。
poll
poll 的机制与 select 类似,与 select 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll 没有最大文件描述符数量的限制。poll改变了fds集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的fds集合限制远大于select的1024。其中 pollfd 使用链表实现
int poll(struct pollfd *fds, unsigned int nfds, int timeout);
复制代码
select poll 比较
- 功能
实现大致相同,但是一些细节还是存在不同:
- select 的描述符类型使用数组实现,FD_SETSIZE 默认大小为 1024,不过这个值可以改变,如果需要修改的话要重新编译;而 poll 使用链表实现,没有描述符大小的限制
- poll 提供更多的事件类型,并且对描述符的重复利用比 selec 高
- 如果一个线程对某个描述符调用了 selec 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。
- 速度
速度都很慢
- 共同的就是在调用时都需要将全部描述符从应用进程缓冲区复制到内核缓冲区
- 两者返回结果中没有声明哪些描述符已经准备好,所以如果返回值大于 0 时,应用进程都需要使用轮询的方式来找到 I/O 完成的描述符
- 可移植性
select 出现比较早,所以基本上所有的系统都支持,而只有比较新的系统才支持 poll
epoll(太强了)
epoll_ctl() 用于向内核注册新的描述符或者是改变某个文件描述符的状态。 已注册的描述符在内核中会被维护在一棵红黑树上, 通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理, 进程调用 epoll_wait() 便可以得到事件完成的描述符。
从上面的描述可以看出, epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次, 并且进程不需要通过轮询来获得事件完成的描述符。
epoll 仅适用于 Linux OS。
epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。
epoll 对多线程编程更有友好, 一个线程调用了 epoll_wait() 另一个线程关闭了同一个描述符也不会产生像 select 和 poll 的不确定情况。
epoll 工作模式
epoll 的描述符事件有两种触发模式: LT( level trigger) 和 ET( edge trigger)
- LT 模式
当 epoll_wait() 检测到描述符事件到达时,将此时间通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 时会再次通知进程,这是默认一种模式,并且同时支持阻塞和非阻塞
- ET 模式
和 LT 模式不同的是,通知之后必须立即处理事件,下次再调用 epoll_wait() 不会再得到时间到达的通知。
减少了 epoll 事件被重复触发的次数,因此效率比 LT 高,只支持非阻塞式,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
应用场景
通过上面的对比,很容易理解是既然 epoll 这么强大,那么都使用 epoll 不就够了?实际上不是这样的,其实都各自有各自的使用场景
- select 使用场景
- select 的 timeout 精度为 1ns,而其他两种为 1ms,所以 select 更适用于实时要求很高的场景,比如核反应堆的控制
- select 可移植性好,几乎被所有主流平台支持
- poll 使用场景
- poll 与 select 相比没有最大描述符数量的限制,并且如果平台对实时性要求不是很高,一般使用poll
- 需要同时监控小于 1000 个描述符,就没必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势
- 需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll,因为 epoll 中的所有描述符都是存储在内核中,造成每次对描述符状态的改变都需要通过系统调用,频繁系统调用肯定会降低效率,并且 epoll 的描述符存储在内核中不容易调试
- epoll 使用场景
只需要运行在 Linux 平台,并且有非常大量的描述符需要同时轮询,而且这些连接最好是长连接
参考
CS-Notes
IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)
大话 Select、Poll、Epoll
Linux下I/O多路复用系统调用(select, poll, epoll)介绍