IO多路复用原理(select、poll and epoll)

IO多路复用首先要理解什么是多路?什么是复用?

多路:核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的多路是指需要处理的众多连接。

复用:核心需求是要求使用尽可能少的线程,尽可能减少系统开销去处理尽可能多的连接,那么这个复用是指利用有限的资源。也就是说利用有限的资源去处理尽可能多的任务。例如:在阻塞IO中,一个连接需要一个线程去处理,但是在IO多路复用的模型中,则可以使用一个线程去处理多个任务。

关键是如何去实现这个复用,也就是让一个独立的线程去处理众多连接上的读写事件。在非阻塞IO中,利用非阻塞的系统IO去不断的轮询众多连接socket上的接收缓存区是否有数据到达,如果有数据则处理,若没有则处理下一个socket,这样就实现了一个线程去处理多个连接上的读写事件。但是随之而来的就是非阻塞IO模型需要不断的发起系统调用去轮询各个socket上的接收缓冲区是否有数据到达,频繁的系统调用带来了大量的上下文切换的开销。随着并发量的增加,会导致严重的性能问题。

那怎么解决这个问题?我们可以联想到,频繁的系统调用会带来大量的开销?减少系统调用的想法就自然而然的出现,那怎么减少?我们知道我们要不断的轮询众多连接的socket,费阻塞IO是在用户空间,那么我们将其转移到内核空间,就减少了频繁的系统调用,也就是让操作系统来帮我们实现轮询的过程。

操作系统对这个轮询功能的实现(IO多路复用模型)

Select

select是操作系统提供的一个系统调用,它解决了在非阻塞IO模型中需要不断的发起系统IO调用去轮询各个连接的socket接收缓冲区所带来的用户空间和内核空间不断切换的系统开销。

而select系统调用将轮询的操作交给了内核来帮我们完成,从而避免在用户空间不断发起的轮询所带来的系统开销。

IO多路复用原理(select、poll and epoll)_第1张图片

 首先用户线程发起select系统调用的时候会阻塞在select系统调用上,此时用户线程从用户态切换到内核态完成了一次上下文切换

用户线程将需要监听的socket对应的文件描述符fd数组通过select系统调用传递给内核。此时,用户线程将用户 空间的fd数组拷贝拷贝到内核空间。

这里的文件文件描述符数组其实是一个BitMap,BitMap下标为文件描述符fd,下标对应的值为:1表示该fd上拥有读写事件,0表示该fd上没有读写事件

IO多路复用原理(select、poll and epoll)_第2张图片

文件描述符fd其实就是一个整数值,在linux中一切皆文件,socket也是一个文件。描述进程所有信息的数据结构task_struct中有一个属性 struct_files_struct *files,它最终指向了一个数组,数组存放了进程所打开的文件列表,文件信息封装在struct file 结构体中,这个数组存放的类型就是struct file结构体,数组的下标就是常说的文件描述符fd.

当用户线程调用完select后进入阻塞状态,内核开始轮询遍历fd数组,查看fd对应的socket接收缓冲区是否有数据到达,如果有数据到来,则将对应的Bitmap的值设置为1,若没有数据到来,则为0;

在内核遍历一遍fd数组后,如果发现有一些fd有数据到来,则将修改后的fd数组返回用户线程。此时会将fd数组从内核空间拷贝到用户空间。

当内核将修改后的fd数组给用户线程之后,用户线程接触阻塞,则用户线程开始遍历fd数组,找出fd数组值为1的socket文件描述符。最后对这些socket发起系统调用读取数据。

select不会告诉用户线程具体那些fd有IO数据到来,只是在活跃的fd打上标记,然后将标记完整fd数组返回给用户线程,所以用户线程还需要遍历fd数组已找出那些fd有IO数据到来。

由于内核在遍历的过程中修改了fd数组,所以在用户线程遍历完fd数组后获取到IO就绪的socket之后,就需要重置fd数组,并需要重新调用select传入充值后的fd数组,让内核发起新一轮遍历轮询。

API介绍

内核提供的select API

 int select(int maxfdp1,fd_set* readset,fd_set* writeset,fd_set* exceptset,const struct timeval* timeout)

 从以上的API我们可以看出,select的系统调用是在规定超时时间内,监听用户感兴趣的文件描述符上的可读,可写,异常事件。

maxfdp1:为了限定内核遍历的范围,它的值为传递给内核监听的文件描述符集合数值最大的文件描述符+1,例如:{f0,1,2,3,4},maxfdp1=5

fd_set* readset:可读事件的文件描述符集合

fd_set* writeset:可写事件的文件描述符集合

fd_set* exceptset:异常事件的文件描述符

const struct timeval* timeout :select系统调用的超时时间,在这段时间内,如果内核没有发现IO就绪的fd,那么就直接返回。

上面提到如果内核遍历fd数组以后,发现有IO 就绪的fd,就将其值设为1,然后将变化后的fd数组传递给用户线程,然后用户线程重新遍历fd数组,将IO就绪的fd找出来,然后发起读写调用。

下面介绍一些在用户线程中遍历fd数组的过程中,需要使用的API

void FD_ZERO(fd_set* fd_set):清空指定的文件描述符的集合,既是fd_set中不在包含任何文件描述符

void FD_SET(int fd,fd_set* fdset):将给定的文件描述符加入集合之中

每次调用select之前都要通过FD_ZERO和FD_SET重新设置文件描述符,因为文件描述符会在内核中被修改。

int FD_ISSET(int fd,fd_set* fdset):检查集合中指定的文件描述符是否可以读写,用户线程遍历文件描述符集合,调用该方法检查对应的文件描述符是否IO就绪。

void FD_CLR(int fd ,fd_set* fdset):将一个给定的文件描述符从集合中删除

性能开销

虽然select解决了非阻塞IO模型中频繁调用系统调用的问题,但是整个select的整个工作流程中,可以看出select有一些不足的地方。

1、在发起select系统调用以及返回的时候,用户线程各发生了一次从用户态到内核态的切换内核态到用户态的上下文切换开销,发生了两次上下文切换。

2、在发起select系统调用的时候以及返回时,用户线程需要将文件描述集合从用户空间拷贝到内核空间,以及在内核进行修改之后,从内核空间拷贝到用户空间,发生了两次的文件描述符的拷贝。

3、在用户空间发起轮询被优化成在内核空间发起轮询,但是select并不会告诉用户线程到底是那些socket发生IO就绪事件,只是对其进行标记,用户线程任然需要遍历文件描述符集合去查找具体的IO就绪的socket,事件复杂度认为O(n)

tips:大部分情况下,网络的连接并不活跃,如果select监听大量的客户端连接,而只有少量连接活跃的情况下,用这种轮询的方式会随着连接数的增大,效率会越来越低。

4、内核会对原始的文件描述符进行修改,导致每次用户线程发起select调用的时候,都需要重置文件描述符集合。

5、BitMap结构的文件描述符结合,长度为固定的1024,所以只能监听0-1023的文件描述符。

6、select调用不是线程安全的

以上select的不足所产生的的性能开销会随着并发量的增大而现行增长

select并不能解决C10k问题,只能解决约1000个左右的并发连接

poll

poll相当于select的改进版,其工作原理和select没有本质的区别。

int poll(struct pollfd* fds,unsigned int nfds,int timeout);

struct pollfd{
    int fd;  //文件描述符
    short events;    //需要发生的事件
    short revents;    //实际发生的事件,需要内核修改

};

select中采用的文件描述符集合是采用的固定长度的BitMap结构的数组fd_set,而poll换成了一个pollfd结构的没有固定长度的数组,这样就文件描述符的限制(受系统文件描述符的限制)

  1. poll只是改进了select只能监听1024个文件描述符的数量限制,但是并没有在性能方面做出改进。和select上本质并没有多大差别。
  2. 同样需要在内核空间用户空间中对文件描述符集合进行轮询,查找出IO就绪Socket的时间复杂度依然为O(n)
  3. 同样需要将包含大量文件描述符的集合整体在用户空间内核空间之间来回复制无论这些文件描述符是否就绪。他们的开销都会随着文件描述符数量的增加而线性增大。
  4. select,poll在每次新增,删除需要监听的socket时,都需要将整个新的socket集合全量传至内核

poll同样不适用高并发的场景。依然无法解决C10K问题。

epoll

在对以上两种方法进行分析之后,我们可以发现它们的性能瓶颈所在之地

  1. 内核空间不会保存用户线程所需要监听的socket结合,所以在调用select、poll的时候都需要全部传入、传出所有的文件描述符集合,这就导致了大量的文件描述符在内核和用户空间之间来回的复制。
  2. 由于内核不会 通知具体的IO就绪的socket,只是在浙西IO就绪的socket上标记,当系统调用返回后,用户线程任然需要遍历文件描述符集合来获取具体的IO就绪的socket.
  3. 在内核空间也是通过遍历来获取IO就绪的socket.

在详细说明epoll之前,在简述一些基础知识

IO多路复用原理(select、poll and epoll)_第3张图片

Socket的创建

服务器端在调用accept系统调用之后,开始阻塞,当有客户端连接上来并完成TCP三次握手之后,内核就会创建一个对应的socket作为socket和客户端通信的内核接口。在linux下一切皆文件,所以当内核创建一个socket以后,当前进程就会将这个socket加入文件打开列表进行管理。

进程中管理文件列表结构

IO多路复用原理(select、poll and epoll)_第4张图片

 struct tast_struct 是内核中用来表示进程的一个数据结构,包含了进程的所有信息。图中指出与文件管理相关的属性。在进程中打开的所有文件是通过一个数组fd_array来进行组织管理的,数组的下标既是我们提到的文件描述符。数组中提到的是文件数据结构struct_file.每一个打开的文件,内核都会创建对应的struct file,并在fd_array寻找一个空闲位置分配给它。

对于每一个进程,默认情况下,文件描述符0表示stdin标准输入,文件描述符1表示stdout标准输出,文件描述符2表示stderr标准错误输出。

进程打开的文件列表fd_array定义在内核数据结构struct files_struct 中,在struct fdtable结构中有一个指针struct fd** fd,指向fd_array.

我们使用socket 文件类型来举例说明

用于封装文件元信息的内核数据strcut file 中的private_data指针指向具体的socket结构。

struct file 中的file_operations属性定义了文件的操作函数,不同的文件类型,对应的file_operations是不同的,针对socket文件类型,这里的file_operations 指向socket_file_ops.

我们在用户空间对socket发起读写等系统调用,进入内核首先会调用的是socket对应的strcut file中指向的socket_file_ops.比如:对socket发起write写操作,内核中首先被调用socket_file_ops中定义的sock_write_iter,read->sock_read_iter.

static const struct file_operations scoket_file_ops={
    .owner=THIS_MODULE,
    .llseek=no_llseek,
    .read_iter=sock_read_iter,
    .write_iter=sock_write_iter,  
    .poll=sock_poll
    .unlocked_ioctl=scok_ioctl,
    .mmap=sock_mmap;
    .release=sock_close,
    .fasync=scok_fasync,
    .sendpage=scok_sendpage,
    .splice_write=generic_splice_sendpage,
    .splice_read=sock_splice_read,
};

Socket内核结构

IO多路复用原理(select、poll and epoll)_第5张图片

 在我们进行网络编程的时候,首先会创建有一个socket,然后基于这个socket进行bind,listen,我们将这个socket称为监听socket

1、当我们调用accept之后,内核就会基于监听socket创建一个新的socket专门用于与客户端之间的网络通信。并将监听socket中的socket操作函数集合(inet_stream_ops)ops复制到新的socket的ops属性中。

const struct proto_ops inet_stream_ops={
    .bind=inet_bind,
    .connect=inet_stream_connect,
    .accept=inet_accept,
    .poll=tcp_poll,
    .listen=inet_listen,
    .sendmsg=inet_sendmsg,
    .recvmsg=inet_recvmsg,
    ......
}

这里就需要注意的是,监听的socket和真正用来网络通信的socket,这是两个socket,一个叫监听socket,一个叫做已连接的socket。

1、接着内核会为已连接的socket创建struct file并初始化,并把socket文件操作函数集合(socket_file_ops)赋值给struct file中的f_ops指针。然后将struct_socket中的file指针指向这新分配的struct file 结构体。

内核会维护两个队列:

  1. 一个是已完成TCP三次握手,连接状态处于established的连接队列,内核中为icsk_accept_queue.
  2. 一个是还没有完成的TCP三次握手,连接状态处于syn_rcvd的半连接队列

2、然后调用socket->ops->accept,从scoket内核结构图中,我们可以看出实际调用的是inet_accept,该函数会在icsk_accept_queue中获取已将创建好的strcut sock,并将这个创建好的struct sock对象赋值给struct socket中的sock指针。

struct sock是struct socket中一个非常核心的内核对象,正式在这里定义网络包的接收发送流程中提到的接收队列,发送队列,等待队列,数据就绪回调指针,内核协议操作函数集合

3、根据创建的socket发起的系统调用sock_create中的protocol参数(对于TCP,参数为SOCK_STREAM)查找对于tcp定义的操作方法集合inet_stream_ops和tcp_prot.并分别设置到socket_ops和scok->sk_port。

socket相关的操作接口定义在inet_stream_ops函数集合中,负责对用户提供接口。而socket与内核协议栈之间的操作接口定义在struct sock中的sk_prot指针上,这里指向tcp_prot协议操作函数集合。

struct proto tcp_prot={
    .name="TCP",
    .owner=THIS_MODULE,
    .close=tcp_close,
    .connect=tcp_v4_connect,
    .disconnect=tcp_disconnect,
    .accept=inet_csk_accept,
    .keepalive=tcp_set_keepalive,
    .recvmsg=tcp_recvmsg,
    .sendmsg=tcp_sendmsg,
    .backlog_rev=tcp_v4_do_rev,
    .......

}

之前提到的对socket发起的系统调用,在内核中首先会调用socket的文件结构strcut file中的file_operations文件操作集合,然后调用strcut scoket中的ops指向的inet_stream_ops socket操作函数,最终调用struct_scok中的sk_prot指针指向的tcp_prot内核协议栈操作函数接口集合。

IO多路复用原理(select、poll and epoll)_第6张图片

 将strcut scok对象中的sk_data_ready函数指针设置为sock_def_readable,在socket数据就绪的时候内核会回调函该函数。

strcut sock中的等待队列存放的是系统IO调用发生阻塞的进程fd,以及相应的回调函数。介绍epoll的时候还会提到。

当strcut file,struct socket,struct sock这些内核对象创建好之后,最后就是把socket对象对应的struct file放在进程打开的文件列表fd_array中。随后系统调用accept返回socket的文件描述符fd给用户程序。

阻塞IO中用户进程阻塞及唤醒原理

介绍阻塞IO 的时候,当用户进程发起系统调用,例如read,用户会在内核态查看对应的socket接受缓存区是否有数据到来。

  1. socket接收缓存区有数据,则拷贝数据到用户空间,系统调用返回。
  2. socket接收缓存区没有数据,则用户进程让出cpu进入阻塞状态,当数据到达接收缓冲区是,则唤醒用户进程,从阻塞状态转为就绪状态,等待cpu调度

接下来介绍用户进程如何阻塞在socket上,又是如何在socket被唤醒。

首先,当我们的用户进程对socket进程read系统调用的时候,用户进程就会从用户态转为内核态。

在进程的struct task_struct中找到fd_array,并根据socket的文件描述符fd找到对应的struct file,调用struct file中的文件操作函数file_operations,read系统调用对应的是sock_read_iter.

在sock_read_iter函数中找到struct file指向struct  socket,并调用socket->ops->recvmsg,我们知道这里调用的是inet_stream_ops 集合中定义的inet_recvmsg.

在inet_recvmsg中找到struct sock,并调用sock->skprot->recvmsg,这里调用的是tcp_prot集合张定义的tcp_recv.

接下来我们看一下系统调用IOtcp_recvmsg是怎么样阻塞用户进程的

IO多路复用原理(select、poll and epoll)_第7张图片

int tcp_recvmsg(struct kiocb* iocb,struct sock* sk,struct msghdr* msg,
                size_t len,int nonblock,int flags,int* addr_len){

    //访问sock对象的定义的接收队列
    skb_queue_walk(&sk->sk_receive_queue,skb){
        //省略非核心代码
        sk_wait_data(sk,&timeo);
    
}

int sk_wait_data(struct sock* sk,long* timeo){
    //创建struct sock中等待队列上的元素wait_queue_t
    //将进程描述符和回调函数autoremove_wake_function关联到wait_queue_t中DEFINE_WAIT(wait)

    //调用sk_sleep获取sock对象下的等待队列的头指针wait_queue_head_t
    //调用prepare_to_wait将新创建的wait_queue_t插入到等待队列中,并将进程状态设置为prepare_to_wait(sk_sleep(sk),&wait,TASK_INTERRUPTIBLE);
    set_bit(SOCK_ASYNC_WAITDATA,&sk->sk_socket->flags);

    //通过调用schedule_timeout让出CPU,然后进程睡眠,导致一次上下文切换
    rc=sk_wait_event(sk,timeo,!skb_queue_empty(&sk->sk_receive_queue));
}

首先会在DEFINE_WAIT中创建strcut sock中等待队列上的等待类型wait_queue_t

#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name,autoremove_wake_function)

#define DEFINE_WAIT_FUNC(name,function) \
    wait_queue_t name={   \
        .private=current,   \
        .func=function,     \
        .task_list=LIST_HEAD_INIT(name).task_list,\


    }
}

等待类型wait_queue_t中的private用来关联阻塞在当前socket上的用户进程fd,func用来关联等待项上注册的回调函数。这里注册的是autoremove_wake_function

调用sk_sleep(sk)获取strcut sock对象中等待队列头指针wait_queue_head_t

调用prepare_to_wait将新创建的等待项wait_queue_t插入到等待队列中,并将进程设置为可打断INTERRUPTIBL.

调用sk_wait_event让出CPU,进程进入睡眠状态。

用户进程的阻塞状态我们就介绍完了,关键是要记住strcut sock 定义的等待队列上的等待类型wait_queue_t的结构。

当数据就绪后,用户进程是如何被唤醒的?

当网络数据包到达网卡时,网卡通过DMA的方式将数据放到RingBuffer中。

然后CPU发起硬中断,在硬中断相应程序中创建sk_buffer,并将网络数据拷贝到sk_buffer.

随后发起软中断,内核线程ksoftirqd响应软中断,调用poll函数将sk_buffer送往内核协议栈做层层协议处理。

在传输层tcp_rcv函数中,去掉tcp头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的socket。

最后将sk_buffer放到socket对应的接收队列中。

IO多路复用原理(select、poll and epoll)_第8张图片

 当软中断将sk_buffer放到socket的接收队列上时,接着就会调用数据就绪函数回调指针sk_data_ready,这个函数指针在初始化的时候指向了sock_def_readable函数。

在scok_def_readable函数中去获取socket->sock->sk_wq等待队列。在wake_up_common函数中从等待队列sk_wq中找出一个等待项wait_queue_t,回调注册在该等待项的func回调函数(wait_queue_t->func),这里注册的回调函数是autoremove_wake_function.

既是是有多个进程阻塞在同一个socket上,值只唤醒一个进程。其作用为了避免惊群。

遭autoremove_wake_function函数中,根据等待项wait_queue_t上的private关联的阻塞进程fd调用try_to_wake_up唤醒阻塞在socket上的进程。

记住wait_queue_t中的func函数指针,在epoll中会注册epoll的回调函数。

epoll_create创建epoll对象

epoll_create是内核给我们提供常见epoll对象的一个系统调用,当我们在用户线程中调用epoll_create时,内核会为我们创建一个struct eventpoll对象,并且也有对应的struct file相关联,这里也需要把struct eventpoll对象所关联的struct file放入进程打开的文件列表fd_array中管理。

struct eventpoll 对象关联的struct file中的file_operations指针指向的是eventpoll_fps操作函数集合。

static const struct file_operations eventpoll_fops={

     .release=ep_eventpoll_release;
     .poll=ep_eventpoll_poll,
}

IO多路复用原理(select、poll and epoll)_第9张图片

struct eventpoll{

    //等待队列,阻塞在epoll上的进程会放在这里
    wait_queue_head_t wq;

    //就绪队列,IO就绪的socket连接会放在这里
    struct list_head rdlist;

    //红黑树用来管理监听的socket连接

    struct rb_root rbr;

    ......



}

wait_queue_head_t wq:epoll中的等待队列,队列存放的是阻塞在epoll上的用户进程,在IO就绪的时候,epoll可以通过这个队列找到这些阻塞的进程并进行唤醒,从而执行IO调用读写socket上的数据。

struct list_head rdllist:epoll中的就绪队列,队列存放的是IO就绪的socket,被唤醒的用户进程可以直接读取这个队列以获取IO活跃的socket.无需再次遍历整个socket集合。

struct rb_root rbr:由于红黑树在查找,插入,删除等综合性能方面是最优的,所以epoll内部使用一个红黑树来关联socket连接。

select用数组管理连接,poll用链表管理连接。

epoll_ctl向epoll对象中添加监听的socket

当我们调用epoll_create 在内核中创建出epoll对象struct eventpoll后,我们就可以利用epoll_ctl向epoll中添加我们需要管理的连接。

1、首先要在epoll内核中创建一个socket连接的数据结构struct epitem,而在epoll中为了综合性能的考虑,采用一颗红黑树来管理这些海量的socket连接。所以struct epitem是一个红黑树的节点。

IO多路复用原理(select、poll and epoll)_第10张图片

struct epitem{

    //指向所属的epoll对象
    struct eventpoll* ep;

    //注册用户感兴趣的时间,也就是用户空间的epoll_event;
    struct epoll_event event;

    //指向epoll对象中的就绪队列
    struct list_head rdllink;

    //指向epoll中对应的红黑树节点

    struct rb_node rbn;

    //指向epitem所表示的socket->file结构以及对应的fd

    struct epoll_filefd ffd;

}

这里主要是struct epitem 结构中的rdllink以及epoll_filed成员,后面我们会用到。

1、在内核创建完表示socket连接的数据结构struct epitem后,我们就需要在socket中的等待队列上创建等待项wait_queue_t并注册epoll的回调函数ep_poll_callback.

epoll的回调函数ep_poll_callback正是epoll同步IO事件通知机制的核心所在,也是区别于select、poll采用内核轮询方式的根本性能差异所在。

IO多路复用原理(select、poll and epoll)_第11张图片

 这里出现一个新的数据结构struct eppoll_entry,我们知道socket->sock->sk_wq等待队列中的类型wait_queue_t,我们需要在struct epitem所表示socket的等待队列上注册的epoll回调函数ep_poll_callback,

这样当数据到达的socket中的接收队列时,内核会回调sk_data_ready,在阻塞IO中用户进程阻塞以及唤醒原理,我们知道这个sk_data_ready函数指针会指向sk_def_readble函数,在sk_def_readble会回调注册在等待队列里的等待项wait_queue_t->func回调函数ep_poll_callback.在ep_poll_callback中需要找到epitem,将IO就绪放入epoll中的就绪队列中。

而scoket等待队列中的类型是wait_queue_t无法关联到epitem.所以就出现了struct eppoll_entry结构体,它的作用就是关联socket等待队列中的等待项wait_queue_t 和epitem。

struct eppoll_entry{
    //指向关联的epitem
    struct epitem* base;

    //关联监听socket中等待队列中的等待项(private=null func=ep_poll_callback)
    wait_queue_t wait;

    //监听socket中等待队列头指针
    wait_queue_head_t* whead;

    ......


}

这样在ep_poll_callback回调函数就可以socket等待队列中的等待项wait,通过container_of宏找到eppoll_entry,继而找到epitem。

container_of在linux内核中是一个常用的宏,用于从包含在某个结构中的指针获得结构本身的指针,也就是说通过结构体变量中某个成员的首地址获得整个结构体变量的首地址。

这里需要注意等待项wait_queue_t中的private设置为null,因为这里socket是交给epoll来管理的,阻塞在socket上的进程也是epoll唤醒。在等待项wait_queue_t注册的func是ep_poll_callback而不是autoremove_wake_function,阻塞进程并不需要autoremove_wake_function来唤醒,所以这里设置的private为null

1、当socket的等待对了中创建好等待项wait_queue_t 并注册epoll的回调函数ep_poll_callback,然后又通过eppoll_entry关联epitem后。剩下要做的就是将epitem插入到epoll中的红黑树struct rb_root rbr中。

这里可以看出epoll另一个优化的地方,epoll将所以的socket连接通过内核中的红黑树来集中关联。每次添加或者删除socket连接都是增量添加删除,而不是像select,poll那样每次调用都是全量socket连接

epoll_wait 同步阻塞获取IO就绪的socket

1、用户程序调用epoll_wait后,内核首先会查找epoll中的就绪队列eventpoll->rdllist是否有IO就绪的epitem.epitem里封装了socket的信息。如果就绪队列中有就绪的epitem,就将就绪的socket信息封装到epoll_event返回。

2、如果eventpoll_rdllist就绪队列中没有IO就绪的epitem,则会创建等待项wait_queue_t,将用户进程的fd关联到wait_queue_t->private上,并在等待项wait_queue_t->func上注册回调函数default_wake_function.最后将等待项添加到epoll中的等待队列,用户进程让出cpu,进入阻塞状态。

IO多路复用原理(select、poll and epoll)_第12张图片

 这里和阻塞IO模型的阻塞原理一样,只不过在阻塞IO模型中注册到等待项wait_queue_t->func上的是autoremove_wake_function,并将等待项添加到socket中的等待队列中。这里注册的是default_wake_function,并将等待项添加到epoll 的等待队列中。

IO多路复用原理(select、poll and epoll)_第13张图片

IO多路复用原理(select、poll and epoll)_第14张图片 

 当网络数据包在 软中断经过内核协议栈的处理到达socket的接收缓冲区时,紧接着会调用socket的数据就绪回调指针sk_data_ready,回调函数为sock_def_readable.在socket的等待队列中找出等待项,其中等待项中注册的回调函数为ep_poll_callback.

在回调函数ep_poll_callback中,根据struct epoll_entry 中struct wait_queue_t wait 通过container_of 宏找到eppoll_entry对象并通过它的base指针找到封装socket的数据结构epitem,并将它加入epoll的就绪队列rdllist中。

随后查看epoll中等待队列是否有等待项,也就是说查看是否有进程阻塞在epoll_wait上等待IO就绪的 socket,如果没有等待项,则软中断处理完成。

如果有等待项,则回到注册在等待项中的回调函数default_wake_function,在回调函数中唤醒阻塞进程,并将就绪队列rdllist中的epitem的IO就绪socket信息封装到struct epoll_event中返回。

用户进程拿到epoll_event获取IO就绪的socket,发起系统IO调用读取数据。

水平触发和边缘触发

经过对epoll的解读,当监听的socket有数据到来时,软中断会执行epoll的回调函数ep_poll_callback,在回调函数中会将epoll中描述socket信息的数据结构epitem插入到epoll的就绪队列rdllist中。随后用户进程从epoll的等待队列中被唤醒,epoll_wait将IO就绪的socket返回给用户进程,随后epoll_wait会清空rdllist.

水平触发和边缘触发的区别在于当socket中的接收缓冲区还有数据可读时,epoll_wait是否会清空rdllist。

水平触发:在这种情况下,用户线程调用epoll_wait获取到 IO就绪的socket,对socket进行系统IO调用读取数据,假设socket中的数据只读了一部分没有全部读完,这时再次调用epoll_wait,epoll_wait会检查这些socket中的接收缓冲区是否还有数据可读,如果还有,就将socket重新放回rdllist,所以当socket上的IO没有被处理完,再次调用epoll_wait依然可以获得这些socket,用户进程可以接着除了socket上的IO事件。

边缘触发:这种情况下,epoll_wait会直接清空rdllist,不管socket上是否有数据可读。所以在这种模式下,当你没有来得及处理接收缓冲区的剩下的可读数据,再次调用epoll_wait,因为这时rdllist已经被清空,socket不会再次从epoll_wait中返回。所以用户进程不会再次获取这个socket,也无法进行IO处理。除非这个socket有新的数据到达。

所以在边缘触发的情况下,处理剩下的数据,就只有等到这个socket上再有数据到达。

你可能感兴趣的:(网络编程,服务器,linux)