关于多路IO的总结

本文主要介绍了select、poll、epoll三种I/O多路复用机制。select和poll采用轮询机制,效率低,且select管理描述符数量有限;epoll改进了select的缺点,使用mmap、红黑树和rdlist,管理大量描述符时更高效。此外,还介绍了TCP保活机制,包括心跳包、乒乓包和设置TCP属性SO_KEEPALIVE。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1、select

首先,select的缺点1:是select管理的描述符的数量在不重新编译内核的情况下是一个固定的值:1024,当然,重新编译了Linux内核之后,这个数值可以继续增大到用户的需求,但是这是相对来说比较麻烦的一件事。

其次。select的缺点2:是select对于socket描述符的管理方式,因为Linux内核对select的实现方式为每次返回前都要对所有的描述符进行一遍遍历,然后将有事件发生的socket描述符放到描述符集合里,再将这个描述符集合返回。这种情况对于描述符的数量不是很大的时候还是可以的,当文件描述符很多的时候,因为每一次的轮询都要将这些所有的socket描述符从用户态拷贝到内核态,在内核态,进行轮询,查看是否有事件发生,这是select的底层需要做的。而这些拷贝完全是可以避免的。

2、poll

poll的实现机制和select是一样的,也是采用轮询机制来查看有事件发生的socket描述符,所以效率也是很低,但是poll对select有一项改进就是能够监视的描述符是任意大小的而不是局限在一个较小的数值上(当然这个描述符的大小也是需要操作系统来支持的)。

综上:在总结一下,select与poll的实现机制基本是一样的,只不过函数不同,参数不同,但是基本流程是相同的;

 

1、复制用户数据到内核空间

2、估计超时时间

3、遍历每个文件并调用f_op->poll()取得文件状态

4、遍历完成检查状态

如果有就绪的文件(描述符对应的还是文件,这里就当成是描述符就可以)则跳转到5,

如果有信号产生则重新启动poll或者select,否则挂起进程并等待超时或唤醒超时或再次遍历每个文件的状态

5、将所有文件的就绪状态复制到用户空间

6、清理申请的资源

3、epoll

epoll改进了select的两个缺点,使用了三个数据结构从而能够在管理大量的描述符的情况下,对系统资源的使用并没有急剧的增加,而只是对内存的使用有所增加(毕竟存储大量的描述符的数据结构会占用大量内存)。

epoll在实现上的三个核心点是:1、mmap,2、红黑树,3、rdlist(就绪描述符链表)接下来一一解释这三个并且解释为什么会高效;

1、mmap是共享内存,用户进程和内核有一段地址(虚拟存储器地址)映射到了同一块物理地址上,这样当内核要对描述符上的事件进行检查的时候就不用来回的拷贝了。

———————————————————————————

关于共享内存内存的说明:

我们先来看看如果不使用内存映射文件的处理流程是怎样的,首先我们得先读出磁盘文件的内容到内存中,然后修改,最后回写到磁盘上。第一步读磁盘文件是要经过一次系统调用的,它首先将文件内容从磁盘拷贝到内核空间的一个缓冲区,然后再将这些数据拷贝到用户空间,实际上是两次数据拷贝。第三步回写也一样也要经过两次数据拷贝。

所以我们基本上会有四次数据的拷贝了,因为大文件数据量很大,几十GB甚至更大,所以拷贝的开销是非常大的。

而内存映射文件是操作系统的提供的一种机制,可以减少这种不必要的数据拷贝,从而提高效率。它由mmap()将文件直接映射到用户空间,mmap()并没有进行数据拷贝,真正的数据拷贝是在缺页中断处理时进行的,由于mmap()将文件直接映射到用户空间,所以中断处理函数根据这个映射关系,直接将文件从硬盘拷贝到用户空间,所以只进行了一次数据拷贝 ,比read进行两次数据拷贝要好上一倍,因此,内存映射的效率要比read/write效率高。

水平触发与边沿触发

这点实际上涉及到epoll的具体实现了。内核/用户空间 内存拷贝问题,如何让内核把FD消息通知给用户空间呢?

对于poll来说需要将用户传入的 pollfd 数组拷贝到内核空间,因为拷贝操作和数组长度相关,时间上这是一个O(n)操作,当事件发生,poll返回将获得的数据传送到用户空间并执行释放内存和剥离等待队列等善后工作,向用户空间拷贝数据与剥离等待队列等操作的的时间复杂度同样是O(n)。而epoll是共享内存,拷贝都不用,相对来说应该会更快。epoll是通过内核与用户空间mmap同一块内存实现的。 

2、红黑树是用来存储这些描述符的,因为红黑树的特性,就是良好的插入,查找,删除性能O(lgN)。

     当内核初始化epoll的时候(当调用epoll_create的时候内核也是个epoll描述符创建了一个文件,毕竟在Linux中一切都是文件,而epoll面对的是一个特殊的文件,和普通文件不同),会开辟出一块内核高速cache区,这块区域用来存储我们要监管的所有的socket描述符,当然在这里面存储一定有一个数据结构,这就是红黑树,由于红黑树的接近平衡的查找,插入,删除能力,在这里显著的提高了对描述符的管理。

3、rdlist   就绪描述符链表这是一个双链表,epoll_wait()函数返回的也是这个就绪链表。

     当内核创建了红黑树之后,同时也会建立一个双向链表rdlist,用于存储准备就绪的描述符,当调用epoll_wait的时候在timeout时间内,只是简单的去管理这个rdlist中是否有数据,如果没有则睡眠至超时,如果有数据则立即返回并将链表中的数据赋值到events数组中。这样就能够高效的管理就绪的描述符,而不用去轮询所有的描述符。所以当管理的描述符很多但是就绪的描述符数量很少的情况下如果用select来实现的话效率可想而知,很低,但是epoll的话确实是非常适合这个时候使用。

      对与rdlist的维护:当执行epoll_ctl时除了把socket描述符放入到红黑树中之外,还会给内核中断处理程序注册一个回调函数,告诉内核,当这个描述符上有事件到达(或者说中断了)的时候就调用这个回调函数。这个回调函数的作用就是将描述符放入到rdlist中,所以当一个socket上的数据到达的时候内核就会把网卡上的数据复制到内核,然后把socket描述符插入就绪链表rdlist中

 

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <sys/epoll.h>
#define MAXLINE 10

int main(int argc, char *argv[])
{
    int efd, i;
    int pfd[2];
    pid_t pid;
    char buf[MAXLINE], ch = 'a';
    
    pipe(pfd);
    pid = fork();
    
    if (pid == 0) {
        close(pfd[0]);
        while (1) {
            for (i = 0; i < MAXLINE/2; i++)
                buf[i] = ch; 
            buf[i-1] = '\n';
            ch++;

            for (; i < MAXLINE; i++)
                buf[i] = ch; 
            buf[i-1] = '\n';
            ch++;
            write(pfd[1], buf, sizeof(buf));
            sleep(5);
        }   
        close(pfd[1]);
    
    } else if (pid > 0) {
        struct epoll_event event;
        struct epoll_event resevent[10];
        int res, len;
    
        close(pfd[1]);
        efd = epoll_create(10);
    
        event.events = EPOLLIN ;//默认是水平触发。。
        event.data.fd = pfd[0];
        epoll_ctl(efd, EPOLL_CTL_ADD, pfd[0], &event);
    
       while(1){
            printf("这是一个测试文件\n");
            res = epoll_wait(efd, resevent, 10, -1);
            printf("res %d\n", res);
            if (resevent[0].data.fd == pfd[0]) {
                len = read(pfd[0], buf, MAXLINE/2);
                write(STDOUT_FILENO, buf, len);
            }   
        }   
    
        close(pfd[0]);
        close(efd);
    
    } else {
        perror("fork");
        exit(-1);
    }

    return 0;
}


注:这个例子要说明的是,这种情况,如果在一次的数据读取中,并没有将缓冲区的数据读完,那么对边沿触发模式认为我的工作已经完成了,从而使得文件描述符处于未就绪状态,而水平出发模式则是认为没有读完的话,文件描述符依旧处于就绪状态,直到读完了缓冲区中的数据。默认使用的是水平触发模式。

LT模式即Level Triggered工作模式。

与ET模式不同的是,以LT方式调用epoll接口的时候,它就相当于一个速度比较快的poll,无论后面的数据是否被使用。

LT(level triggered):LT是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。

ET(edge-triggered):ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once).

4. TCP保活机制:

1.心跳包

 

由应用程序自己发送心跳包来检测连接是否正常,大致的方法是:服务器在一个 Timer事件中定时向客户端发送一个短小精悍的数据包,然后启动一个低级别的线程,在该线程中不断检测客户端的回应, 如果在一定时间内没有收到客户端的回应,即认为客户端已经掉线;同样,如果客户端在一定时间内没有收到服务器的心跳包,则认为连接不可用。

注意:心跳包一般都是很小的包,或者只包含包头的一个空包

 

2.乒乓包

 

举例:微信朋友圈有人评论,客户端怎么知道有人评论?服务器怎么将评论发给客户端的?

 

微信客户端每隔一段时间就向服务器询问,是否有人评论?

当服务器检查到有人给评论时,服务器发送一个乒乓包给客户端,该乒乓包中携带的数据是[此时有人评论的标志位]

注:步骤1和2,服务器和客户端不需要建立连接,只是发送简单的乒乓包。

当客户端接收到服务器回复的带有评论标志位的乒乓包后,才真正的去和服务器通过三次握手建立连接;建立连接后,服务器将评论的数据发送给客户端。

注意:乒乓包是携带很简单的数据的包

 

3.设置TCP属性: SO_KEEPALIVE

1.因为要考虑到一个服务器通常会连接多个客户端,因此由用户在应用层自己实现心跳包,代码较多 且稍显复杂,而利用TCP/IP协议层为内置的KeepAlive功能来实现心跳功能则简单得多。

2.不论是服务端还是客户端,一方开启KeepAlive功能后,就会自动在规定时间内向对方发送心跳包, 而另一方在收到心跳包后就会自动回复,以告诉对方我仍然在线。

3.因为开启KeepAlive功能需要消耗额外的宽带和流量,所以TCP协议层默认并不开启KeepAlive功 能,尽管这微不足道,但在按流量计费的环境下增加了费用,另一方面,KeepAlive设置不合理时可能会 因为短暂的网络波动而断开健康的TCP连接。并且,默认的KeepAlive超时需要7,200,000 MilliSeconds, 即2小时,探测次数为5次。对于很多服务端应用程序来说,2小时的空闲时间太长。

4.因此,我们需要手工开启KeepAlive功能并设置合理的KeepAlive参数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值