C++面试总结之linux(三):select和epoll(重要)

1. 5种IO模型:

(1)blocking IO - 阻塞IO

(2)nonblocking IO - 非阻塞IO

(3)IO multiplexing - IO多路复用

(4)signal driven IO - 信号驱动IO

(5) asynchronous IO - 异步IO

其中前面4种IO都可以归类为synchronous IO - 同步IO,signal driven IO平时用的比较少。

2.Select:

不断地轮询所负责的所有socket,当某个socket有数据到达时,就通知用户进程。(默认是1024)

(1)实现:

当用户process调用select的时候,select会将需要监控的fds集合拷贝到内核空间(假设监控的仅仅是socket可读),然后遍历自己监控的socket sk,挨个调用sk的poll逻辑以便检查该sk是否有可读事件,遍历完所有的sk后,如果没有任何一个sk可读,那么select会调用schedule_timeout进入schedule循环,使得process进入睡眠。如果在timeout时间内某个sk上有数据可读了,或者等待timeout了,则调用select的process会被唤醒,接下来select就是遍历监控的sk集合,挨个收集可读事件并从内核态拷贝到用户态。

select为每个socket引入一个poll逻辑,该poll逻辑用于收集socket发生的事件。poll函数返回一个描述读写是否就绪的mass掩码,根据掩码对fd_set赋值

通过上面的select逻辑过程分析,select存在两个问题:

A. 被监控的fds需要从用户空间拷贝到内核空间。为了减少数据拷贝带来的性能损坏,内核对被监控的fds集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)。

B. 被监控的fds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次调用sk的poll函数收集可读事件。由于当初的需求是朴素,仅仅关心是否有数据可读这样一个事件,当事件通知来的时候,由于数据的到来是异步的,我们不知道事件来的时候,有多少个被监控的socket有数据可读了,于是,只能挨个遍历每个socket来收集可读事件。

3. epoll

Int epoll_creat( int size);  创建一个epoll fd。
Int epoll_ctl( int epfd, int op, int fd, struct epoll_evevt *event); 
Int epoll_wait( int epfd, struct epoll_evevt *events, int maxevents, int timeout);

epoll引入了epoll_ctl系统调用,将高频调用的epoll_wait和低频的epoll_ctl隔离开。同时,epoll_ctl通过(EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)三个操作来分散对需要监控的fds集合的修改,做到了有变化才变更,将select或poll高频、大块内存拷贝(集中处理)变成epoll_ctl的低频、小块内存的拷贝(分散处理),避免了大量的内存拷贝。同时,对于高频epoll_wait的可读就绪的fd集合返回的拷贝问题,epoll通过内核与用户空间mmap(内存映射)同一块内存来解决。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。

另外,epoll通过epoll_ctl来对监控的fds集合来进行增、删、改,那么必须涉及到fd的快速查找问题。在linux 2.6.8之前的内核,epoll使用hash来组织fds集合,于是epoll_create(int size)有一个参数size,以便内核根据size的大小来分配hash的大小。在linux 2.6.8以后的内核中,epoll使用红黑树来组织监控的fds集合,于是epoll_create(int size)的参数size实际上已经没有意义了。

(1)Epoll高效的原因:

A. select/poll每次调用都要将监控的fd传递给select/poll系统调用,需要从用户态拷贝到内核态,而调用epoll_wait时不需要。Epoll_creat在内核态准备数据结构存放fd,epoll_ctl对此数据结构进行监控。(内存映射)

B. 内核使用了slab机制,为epoll提供了快速的数据结构。

在内核里,一切皆文件。Epoll向内核注册了一个文件系统,用于存储上述被监控的fd。当调用epoll_creat时,就会在这个虚拟的epoll文件系统里创建一个file结点。Epoll在被内核初始化时,开辟出epoll自己的高速cache区,用来安置每一个我们想要监控的fd,以红黑树保存在内核中。

C. 当调用epoll_ctl塞入百万个fd时,epoll_wait仍然可以快速返回,并有效的将发生时间的fd给用户(建立了一个list链表,存储准备就绪的事件。)

List链表维护:epoll_ctl将fd放到epoll文件系统里file对应的红黑树外,还在内核中断处理程序注册了一个回调函数epoll_callback_sk sk,告诉内核如果该fd中断到了,就把它放到ready list链表里。Epoll_wait只需要观察该链表有没有数据,如果有数据就返回就绪链表里面的数据。

(2)Epoll的两种模式:

A. 水平触发(LT Level Triggered):使用此种模式,当数据可读的时候,epoll_wait()将会一直返回就绪事件。如果你没有处理完全部数据,并且再次在该epoll实例上调用epoll_wait()才监听描述符的时候,它将会再次返回就绪事件,因为有数据可读。ET只支持非阻塞socket。

B. 边缘触发(ET Edge Triggered):使用此种模式,只能获取一次就绪通知,如果没有处理完全部数据,并且再次调用epoll_wait()的时候,它将会阻塞,因为就绪事件已经释放出来了。

ET的效能更高,但是对程序员的要求也更高。在ET模式下,我们必须一次干净而彻底地处理完所有事件。LT两种模式的socket都支持。

sk从ready_list移除的时机正是区分两种事件模式的本质。因为,通过上面的介绍,我们知道ready_list是否为空是epoll_wait是否返回的条件。于是,在两种事件模式下,步骤5如下:

对于Edge Triggered (ET) 边沿触发: 遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件

对于Level Triggered (LT) 水平触发:遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件。如果该sk的poll函数返回了关心的事件(对于可读事件来说,就是POLL_IN事件),那么该sk被重新加入到epoll的ready_list中。

对于可读事件而言,在ET模式下,如果某个socket有新的数据到达,那么该sk就会被排入epoll的ready_list,从而epoll_wait就一定能收到可读事件的通知(调用sk的poll逻辑一定能收集到可读事件)。于是,我们通常理解的缓冲区状态变化(从无到有)的理解是不准确的,准确的理解应该是是否有新的数据达到缓冲区。

而在LT模式下,某个sk被探测到有数据可读,那么该sk会被重新加入到read_list,那么在该sk的数据被全部取走前,下次调用epoll_wait就一定能够收到该sk的可读事件(调用sk的poll逻辑一定能收集到可读事件),从而epoll_wait就能返回。

对于可读事件而言,LT比ET多了两个操作:(1)对ready_list的遍历的时候,对于收集到可读事件的sk会重新放入ready_list;(2)下次epoll_wait的时候会再次遍历上次重新放入的sk,如果sk本身没有数据可读了,那么这次遍历就变得多余了。

在服务端有海量活跃socket的时候,LT模式下,epoll_wait返回的时候,会有海量的socket sk重新放入ready_list。如果,用户在第一次epoll_wait返回的时候,将有数据的socket都处理掉了,那么下次epoll_wait的时候,上次epoll_wait重新入ready_list的sk被再次遍历就有点多余,这个时候LT确实会带来一些性能损失。然而,实际上会存在很多多余的遍历么?

先不说第一次epoll_wait返回的时候,用户进程能否都将有数据返回的socket处理掉。在用户处理的过程中,如果该socket有新的数据上来,那么协议栈发现sk已经在ready_list中了,那么就不需要再次放入ready_list,也就是在LT模式下,对该sk的再次遍历不是多余的,是有效的。同时,我们回归epoll高效的场景在于,服务器有海量socket,但是活跃socket较少的情况下才会体现出epoll的高效、高性能。因此,在实际的应用场合,绝大多数情况下,ET模式在性能上并不会比LT模式具有压倒性的优势,至少,目前还没有实际应用场合的测试表面ET比LT性能更好。

(3) ET vs LT - 复杂度

我们知道,对于可读事件而言,在阻塞模式下,是无法识别队列空的事件的,并且,事件通知机制,仅仅是通知有数据,并不会通知有多少数据。于是,在阻塞模式下,在epoll_wait返回的时候,我们对某个socket_fd调用recv或read读取并返回了一些数据的时候,我们不能再次直接调用recv或read,因为,如果socket_fd已经无数据可读的时候,进程就会阻塞在该socket_fd的recv或read调用上,这样就影响了IO多路复用的逻辑(我们希望是阻塞在所有被监控socket的epoll_wait调用上,而不是单独某个socket_fd上),造成其他socket饿死,即使有数据来了,也无法处理。

接下来,我们只能再次调用epoll_wait来探测一些socket_fd,看是否还有数据可读。在LT模式下,如果socket_fd还有数据可读,那么epoll_wait就一定能够返回,接着,我们就可以对该socket_fd调用recv或read读取数据。然而,在ET模式下,尽管socket_fd还是数据可读,但是如果没有新的数据上来,那么epoll_wait是不会通知可读事件的。这个时候,epoll_wait阻塞住了,这下子坑爹了,明明有数据你不处理,非要等新的数据来了在处理,那么我们就死扛咯,看谁先忍不住。

等等,在阻塞模式下,不是不能用ET的么?是的,正是因为有这样的缺点,ET强制需要在非阻塞模式下使用。在ET模式下,epoll_wait返回socket_fd有数据可读,我们必须要读完所有数据才能离开。因为,如果不读完,epoll不会在通知你了,虽然有新的数据到来的时候,会再次通知,但是我们并不知道新数据会不会来,以及什么时候会来。由于在阻塞模式下,我们是无法通过recv/read来探测空数据事件,于是,我们必须采用非阻塞模式,一直read直到EAGAIN。因此,ET要求socket_fd非阻塞也就不难理解了。

另外,epoll_wait原本的语意是:监控并探测socket是否有数据可读(对于读事件而言)。LT模式保留了其原本的语意,只要socket还有数据可读,它就能不断反馈,于是,我们想什么时候读取处理都可以,我们永远有再次poll的机会去探测是否有数据可以处理,这样带来了编程上的很大方便,不容易死锁造成某些socket饿死。相反,ET模式修改了epoll_wait原本的语意,变成了:监控并探测socket是否有新的数据可读。

于是,在epoll_wait返回socket_fd可读的时候,我们需要小心处理,要不然会造成死锁和socket饿死现象。典型如listen_fd返回可读的时候,我们需要不断的accept直到EAGAIN。假设同时有三个请求到达,epoll_wait返回listen_fd可读,这个时候,如果仅仅accept一次拿走一个请求去处理,那么就会留下两个请求,如果这个时候一直没有新的请求到达,那么再次调用epoll_wait是不会通知listen_fd可读的,于是epoll_wait只能睡眠到超时才返回,遗留下来的两个请求一直得不到处理,处于饿死状态。

总结一下,ET和LT模式下epoll_wait返回的条件

ET - 对于读操作

A.  当接收缓冲buffer内待读数据增加的时候时候(由空变为不空的时候、或者有新的数据进入缓冲buffer)

B. 调用epoll_ctl(EPOLL_CTL_MOD)来改变socket_fd的监控事件,也就是重新mod socket_fd的EPOLLIN事件,并且接收缓冲buffer内还有数据没读取。(这里不能是EPOLL_CTL_ADD的原因是,epoll不允许重复ADD的,除非先DEL了,再ADD)

因为epoll_ctl(ADD或MOD)会调用sk的poll逻辑来检查是否有关心的事件,如果有,就会将该sk加入到epoll的ready_list中,下次调用epoll_wait的时候,就会遍历到该sk,然后会重新收集到关心的事件返回。

ET - 对于写操作

A. 发送缓冲buffer内待发送的数据减少的时候(由满状态变为不满状态的时候、或者有部分数据被发出去的时候)

B.  调用epoll_ctl(EPOLL_CTL_MOD)来改变socket_fd的监控事件,也就是重新mod socket_fd的EPOLLOUT事件,并且发送缓冲buffer还没满的时候。

LT - 对于读操作

接收缓冲buffer内有可读数据的时候

LT - 对于写操作

发送缓冲buffer还没满的时候

参考:https://cloud.tencent.com/developer/article/1005481

猜你喜欢

转载自blog.csdn.net/lxin_liu/article/details/89312906