美文网首页
关于Linux下的select/epoll

关于Linux下的select/epoll

作者: Wannay | 来源:发表于2021-04-26 21:13 被阅读0次

    1.关于select的编程

    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <stdlib.h>
    #include <string.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <sys/select.h> //引入select的头文件
    
    int main()
    {
        int fd[1024];   //存储就绪fd
        //第一个参数指定AF_INET(ipv4),第二个参数指定为流式套接字,第三个参数指定具体的协议类型
        int sockfd = socket(AF_INET, SOCK_STREAM, 0); //创建服务端的socket fd
        if (sockfd == -1)                             //如果它返回-1,则创建失败
        {
            perror("socket");
            exit(1); //退出
        }
        int opt = 1;
        //设置socket的参数
        setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
        //保存服务器的信息,也就是保存协议族、端口、ip地址
        struct sockaddr_in server_addr;
        //给内存区域初始化为0
        memset(&server_addr, 0, sizeof(server_addr));
        server_addr.sin_family = AF_INET;                     //协议族
        server_addr.sin_port = 9000;                          //端口号
        server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //设置ip地址
    
        // 给服务器的socket绑定端口号,第一个参数是sockfd
        // 第二个参数是struct sockaddr类型,第三个参数则是长度
        int ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
        if (ret == -1) //如果bind出错
        {
            perror("bind");
            exit(1);
        }
        ret = listen(sockfd, 10); //指定要监听的socket的数量
        if (ret == -1)            //如果listen失败
        {
            perror("listen");
            exit(1);
        }
    
        fd_set readfd, tempfd;          //定义fd_set
        FD_ZERO(&readfd);               //将fd_set全部填0
        FD_ZERO(&tempfd);               //将fd_set全部填0
        FD_SET(sockfd, &readfd);        //将服务器的socket添加到readfd中
        int maxfd = sockfd;             //maxfd
        struct sockaddr_in client_addr; //client addr
        char buffer[32];                //buffer
        while (1)
        {
            int i;           //计数器
            tempfd = readfd; //将readfd赋值给tempfd,拷贝一份副本
            //第一个参数需要传入最大的fd+1,最后一个参数NULL表示阻塞,可以传入具体的数字表示阻塞的时间
            //第2、3、4个参数传递的是readfds,writefds,exceptfds,一般传入readfds即可
            //传入tempfd,select会去一个个检查,如果不是活跃的fd,会给移除掉,因此不能传入readfd的引用,那样readfd的内容就发生改变了
            ret = select(maxfd + 1, &tempfd, NULL, NULL, NULL); //判断监听集合是否可读
            if (ret == -1)                                      //如果出错
            {
                perror("select");
                exit(1);
            }
            //判断sockfd还留在tempfd中,也就是判断server socket是否有接收到客户端的连接
            if (FD_ISSET(sockfd, &tempfd))
            {
                for (i = 0; i < 1024; i++)
                {
                    if (fd[i] == 0) //找到合适的地方存储fd,找到一个为空的槽位
                    {
                        break;
                    }
                }
                unsigned int length = sizeof(client_addr); //客户端的addr结构体大小
                // 在这里找到了合适的槽位,去进行存放fd
                fd[i] = accept(sockfd, (struct sockaddr *)&client_addr, &length);
                if (fd[i] == -1)
                {
                    perror("accept");
                    exit(1);
                }
                //打印接收到客户端请求的消息,打印ip和fd
                printf("接收到来自%s的客户端的连接,fd=%d\n", inet_ntoa(client_addr.sin_addr), fd[i]);
                FD_SET(fd[i], &readfd);                //将fd[i]这个fd加到readfd中
                maxfd = maxfd < fd[i] ? fd[i] : maxfd; //判断maxfd是否更新
            }
            else //如果sockfd没留在tempfd中,那么就是接受到消息了
            {
                // 还需要进行一次O(n)的操作才能对每个消息进行处理
                for (i = 0; i < 1024; i++)
                {
                    //一个个判断fd是否还在tempfd中
                    if (FD_ISSET(fd[i], &tempfd))
                    {
                        //从某个fd中接收数据,保存到buffer中
                        ret = recv(fd[i], buffer, sizeof(buffer), 0);
                        if (ret == -1)
                        {
                            perror("recv");
                            exit(1);
                        }
                        else if (ret == 0) //返回0代表客户端异常退出,需要将这个socketfd关掉
                        {
                            close(fd[i]);           //关闭fd
                            FD_CLR(fd[i], &readfd); //从readfd中清除掉这个fd
                            fd[i] = 0;              //将这个槽位清空
                        }
                        else //其他情况则收到了消息
                        {
                            printf("收到%d客户端的消息\n", fd[i]);
                        }
                        memset(buffer, 0, sizeof(buffer)); //将buffer清掉
                        break;
                    }
                }
            }
        }
        return 0;
    }
    

    2.关于select的总结

    2.1 select系统调用的参数说明

    select这个系统调用的原型如下

           int select(int nfds, fd_set *readfds, fd_set *writefds
                              ,fd_set *exceptfds, struct timeval *timeout); 
    

    第一个参数nfds用来告诉内核要扫描的socket fd的数量+1,select系统调用最大接收的数量是1024,但是如果每次都去扫描1024,实际上的数量并不多,则效率太低,这里可以指定需要扫描的数量。最大数量为1024,如果需要修改这个数量,则需要重新编译Linux内核源码。
    第2、3、4个参数分别是readfds、writefds、exceptfds,传递的参数应该是fd_set 类型的引用,内核会检测每个socket的fd,如果没有读事件,就将对应的fd从第二个参数传入的fd_set中移除,如果没有写事件,就将对应的fd从第二个参数的fd_set中移除,如果没有异常事件,就将对应的fd从第三个参数的fd_set中移除。这里我们应该要将实际的readfds、writefds、exceptfds拷贝一份副本传进去,而不是传入原引用,因为如果传递的是原引用,某些socket可能就已经丢失
    最后一个参数是等待时间,传入0表示非阻塞,传入>0表示等待一定时间,传入NULL表示阻塞,直到等到某个socket就绪

    2.2关于一些常见的操作的函数

    
           void FD_CLR(int fd, fd_set *set);  //将某个bit置0,fd传入bit的索引
           int  FD_ISSET(int fd, fd_set *set); //判断某个bit是否被置1了,fd传入索引
           void FD_SET(int fd, fd_set *set);  //将bitmap某个bit置1,fd传入bit的索引
           void FD_ZERO(fd_set *set);  //将bitmap中的所有bit归0,一般用来进行初始化
    

    FD_ZERO()这个函数将fd_set中的所有bit清0,一般用来进行初始化等。
    FD_CLR()这个函数用来将bitmap(fd_set )中的某个bit清0,在客户端异常退出时就会用到这个函数,将fd从fd_set中删除。
    FD_ISSET()用来判断某个bit是否被置1了,也就是判断某个fd是否在fd_set中。
    FD_SET()这个函数用来将某个fd加入fd_set中,当客户端新加入连接时就会使用到这个函数。

    3.关于epoll的编程

    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <stdlib.h>
    #include <string.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <sys/epoll.h> //macos下没有epoll.h文件
    
    #define MAXSIZE 256
    
    int main()
    {
        //第一个参数指定AF_INET(ipv4),第二个参数指定为流式套接字,第三个参数指定具体的协议类型
        int sockfd = socket(AF_INET, SOCK_STREAM, 0); //创建服务端的socket fd
        if (sockfd == -1)                             //如果它返回-1,则创建失败
        {
            perror("socket");
            exit(1); //退出
        }
        int opt = 1;
        //设置socket的参数
        setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
        //保存服务器的信息,也就是保存协议族、端口、ip地址
        struct sockaddr_in server_addr;
        //给内存区域初始化为0
        memset(&server_addr, 0, sizeof(server_addr));
        server_addr.sin_family = AF_INET;                     //协议族
        server_addr.sin_port = 9000;                          //端口号
        server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //设置ip地址
    
        // 给服务器的socket绑定端口号,第一个参数是sockfd
        // 第二个参数是struct sockaddr类型,第三个参数则是长度
        int ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
        if (ret == -1) //如果bind出错
        {
            perror("bind");
            exit(1);
        }
        ret = listen(sockfd, 10); //指定要监听的socket的数量
        if (ret == -1)            //如果listen失败
        {
            perror("listen");
            exit(1);
        }
        int epfd = epoll_create(MAXSIZE); //创建epoll对象
        if (epfd == -1)                   //如果创建失败
        {
            perror("epoll_create");
            exit(1);
        }
        struct epoll_event ev;                             //epoll_event
        struct epoll_event events[MAXSIZE];                //events,用来接收内核拷贝给用户空间的fd
        ev.events = EPOLLIN;                               //监听读事件
         //ev.events = EPOLLIN | EPOLLOUT;                    //监听读/写事件
        ev.data.fd = sockfd;                               //将ev的fd设为sockfd
        ret = epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); //将这个sockfd加入ev
        if (ret == -1)                                     //出错
        {
            perror("epoll_ctl");
            exit(1);
        }
        struct sockaddr_in client_addr;   //用来保存client的信息
        int length = sizeof(client_addr); //获取长度
        char buffer[32];                  //buffer
        while (1)
        {
            //返回可读的fd数量,第二个参数为内核拷贝给用户空间的的fd列表,最后一个参数为-1表示为阻塞
            int nums = epoll_wait(epfd, events, MAXSIZE, -1);
            int i = 0;      //计数器
            if (nums == -1) //返回-1,退出
            {
                perror("epoll_wait");
                exit(1);
            }
            for (; i < nums; i++)
            {
                //如果fd是sockfd,说明是客户端发起请求
                if (events[i].data.fd == sockfd)
                {
                    int fd = accept(sockfd, (struct sockaddr *)&client_addr, &length);
                    if (fd == -1) //出错
                    {
                        perror("accept");
                        exit(1);
                    }
                    ev.data.fd = fd; //设置ev的fd
                    //为新的fd注册事件
                    ev.events = EPOLLIN; //接收写事件
                    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
                    if (ret == -1)
                    {
                        perror("epoll_ctl");
                        exit(1);
                    }
                }
                else if (events[i].events & EPOLLIN) //如果事件可读
                {
                    ret = recv(events[i].data.fd, buffer, sizeof(buffer), 0);
                    if (ret == -1)
                    {
                        perror("recv");
                        exit(1);
                    }
                    else if (ret == 0) //客户端异常退出
                    {
                        ev.data.fd = events[i].data.fd;
                        ev.events = EPOLLIN;
                        epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, &ev); //注销事件
                    }
                    else //接受到客户端的正常读事件
                    {
                        printf("接受到客户端%d的消息%s\n", events[i].data.fd, buffer); //打印消息
                    }
                    memset(buffer, 0, sizeof(buffer)); //将buffer清空
                }
            }
        }
    
        return 0;
    }
    

    4.关于epoll的说明

    4.1 epoll_create系统调用的参数说明

    int epoll_create(int size);  
    

    epoll_create系统调用用来创建epfd,会在开辟一块内存空间(epoll的结构空间)。size为epoll上能关注的最大描述符数,不够会进行扩展,size只要>0就行,早期的设计size是固定大小,但是现在size参数没什么用,会自动扩展。
    返回值是epfd,如果为-1则说明创建epoll对象失败

    4.2 epoll_ctl系统调用的参数说明

    int epoll_ctl(int epfd,  //epoll_create的返回值epfd
                int op,      //指定对应操作的宏
                int fd,      //下侧结构体epoll_data里面的fd
                struct epoll_event *event);   //要监听fd的哪些事件
    

    第一个参数epfd传入的就是epoll_create返回的epfd。
    第二个参数传入对应操作的宏,包括增删改(EPOLL_CTL_ADD、EPOLL_CTL_DEL、EPOLL_CTL_MOD)
    第三个参数传入的是需要增删改的socket的fd
    第四个参数传入的是需要操作的fd的哪些事件,具体的事件可以看后续。
    返回值是一个int类型,如果为-1则说明操作失败

    4.3 epoll_wait系统调用的参数说明

    int epoll_wait(int epfd,                     //epoll_create的返回值,也就是epoll的fd(文件描述符)
                    struct epoll_event* events,  // 结构体指针,发生改变的文件描述符元素是存在epoll_event结构体里,
                                                 //当发生改变,内核会把改变了的epoll_event拷贝到epoll_wait函数的第二个参数里面
                    int maxevents,               //数组的容量
                    int timeout                  //函数是否阻塞
    );
    

    第一个参数是epfd,也就是epoll_create的返回值。
    第二个参数是一个epoll_event类型的指针,也就是传入的是一个数组指针。内核会将就绪的socket的事件拷贝到这个数组中,用户可以根据这个数组拿到事件和消息等
    第三个参数是maxevents,传入的是第二个参数的数组的容量
    第四个参数是timeout,如果设为-1一直阻塞直到有就绪数据为止,如果设为0立即返回,如果>0那么阻塞一段时间
    返回值是一个int类型,也就是就绪的socket的事件的数量(内核拷贝给用户的events的元素的数量),通过这个数量可以进行遍历处理每个事件

    4.4 关于epoll_event-事件结构体的结构

    typedef union epoll_data {
          void         *ptr;
           int           fd;
           uint32_t     u32;
           uint64_t     u64;
    } epoll_data_t;
     
    struct epoll_event {
            uint32_t     events;                          
            epoll_data_t   data; 
    };
     
    events:
        - EPOLLIN - 读
        - EPOLLOUT - 写
        - EPOLLERR - 异常
        - EPOLLHUP - 挂断
        - EPOLLET - 边缘触发
        - EPOLLONESHOT - 只监听一次,事件触发后自动将该fd从epoll删除
    

    一般需要传入ev.data.fdev.events,也就是fd和需要监控的fd的事件。事件如果需要传入多个,可以通过按位与来连接,比如需要监控读写事件,只需要像如下这样操作即可:ev.events=EPOLLIN | EPOLLOUT

    4.5 关于epoll的工作模式的说明

    LT(水平触发),默认的工作模式,事件就绪后用户可以选择处理和不处理,如果用户不处理,内核会对这部分数据进行维护,那么下次调用epoll_wait()时仍旧会打包出来
    ET(边缘触发),事件就绪之后,用户必须进行处理,因为内核把事件打包出来之后就把对应的就绪事件给清掉了,如果不处理那么就绪事件就没了。ET可以减少epoll事件被重复触发的次数,效率比LT高。
    如果需要设置为边缘触发只需要设置事件为类似ev.events=EPOLLIN | EPOLLET即可

    5.关于更多面试中可能会用到的知识

    5.1 为什么会有select/poll/epoll的存在?

    select/poll/epoll是nio多路复用技术,传统的bio无法实现C10K/C100K,也就是无法满足1w/10w的并发量,在这么高的并发量下,在进行上下文切换就很容易将服务器的负载拉飞。

    5.2 关于select系统调用的执行流程

    1.将fd_set从用户态拷贝到内核态
    2.根据fd_set扫描内存中的socket的fd的状态,时间复杂度为O(n)
    3.检查fd_set,如果有已经就绪的socket,就给对应的socket的fd打标记,那么就return 就绪socket的数量并唤醒当前线程,如果没有就绪的socket就继续阻塞当前线程直到有socket就绪才将当前线程唤醒。
    4.如果想要获取当前已经就绪的socket列表,则还需要进行一次系统调用,使用O(n)的时间去扫描socket的fd列表,将已经打上标记的socket的fd返回。

    5.3 如果第一次检查时socket就绪列表为空,但是第二次去检查的时候socket继续列表不为空了,那么是第二次是怎么发现的这些就绪的socket的?难道是因为CPU会一直轮询去检查吗?

    CPU在同一个时刻只能执行一个程序,通过RR时间片轮转去切换执行各个程序。没有被挂起的进程(线程)则在工作队列中排队等待CPU的执行,将进程(线程)从工作队列中移除就是挂起,反映到Java层面的就是线程的阻塞。

    什么是中断?当我们使用键盘、鼠标等IO设备的时候,会给主板一个电流信号,这个电流信号就给CPU一个中断信号,CPU执行完当前的指令便会保存现场,然后执行键盘/鼠标等设备的中断程序,让中断程序获取CPU的使用权,在中断程序后又将现场恢复,继续执行之前的进程。

    如果第一次没检测到就绪的socket,就要将其进程(线程)从工作队列中移除,并加入到socket的等待队列中。

    socket包含读缓冲区+写缓冲区+等待队列(放线程或eventpoll对象)

    当从客户端往服务器端发送数据时,使用TCP/IP协议将通过物理链路、网线发给服务器的网卡设备,网卡的DMA设备将接收到的的数据写入到内存中的一块区域(网卡缓冲区),然后会给CPU发出一个中断信号,CPU执行完当前指令则会保存现场,然后网卡的中断程序就获得了CPU的使用权,然后CPU便开始执行网卡的中断程序,将内存中的缓存区中的数据包拿出,判断端口号便可以判断它是哪个socket的数据,将数据包写入对应的socket的读(输入)缓冲区,去检查对应的socket的等待队列有没有等待着的进程(线程),如果有就将该线程(进程)从socket的等待队列中移除,将其加入工作队列,这时候该进程(线程)就再次拥有了CPU的使用权限,到这里中断程序就结束了。

    之后这个进程(线程)就执行select函数再次去检查fd_set就能发现有socket缓冲区中有数据了,就将该socket的fd打标记,这个时候select函数就执行完了,这时候就会给上层返回一个int类型的数值,表示已经就绪的socket的数量或者是发生了错误。这个时候就再进行内核态到用户态的切换,对已经打标记的socket的fd进行处理。

    5.4 poll相对于select的改进?

    将原本1024bit长度的bitmap(fd_set)换成了数组的方式传入,可以解决原本1024个不够用的情况,因为传入的是数组,长度可以不止是1024了,因此socket数量可以更多,在Kernel底层会将数组转换成链表。

    5.5 为什么有epoll的存在?

    在十多年前,linux2.6之前,不支持epoll,当时可能会选择用Windows/Unix用作服务器,而不会去选择Linux,因为select/poll会随着并发量的上升,性能变得越来越低,每次都得检查所有的Socket列表。

    1.select/poll每次调用都必须根据提供所有的socket集合,然后就会涉及到将这个集合从用户空间拷贝到内核空间,在这个过程中很耗费性能。但是其实每次的socket集合的变化也许并不大,也许就1-2个socket,但是它会全部进行拷贝,全部进行遍历一一判断是否就绪。

    2.select/poll的返回类型是int,只能代表当前的就绪的socket的数量/发生了错误,如果还需要知道是哪些socket就绪了,则还需要再次使用系统调用去检查哪些socket是就绪的,又是一次O(n)的操作,很耗费性能

    5.6 epoll是怎么去改进select/poll的缺点的?

    1.epoll在Kernel内核中存储了对应的数据结构(eventpoll)。我们可以使用epoll_create()这个系统调用去创建一个eventpoll对象,并返回eventpoll的对象id(epfd),eventpoll对象主要包括三个部分:需要处理的正在监听的socket_fd列表(红黑树结构)、socket就绪列表以及等待队列(线程)。

    2.我们可以使用epoll_ctl()这个系统调用对socket_fd列表进行CRUD操作,因为可能频繁地进行CRUD,因此socket_fd使用的是红黑树的结构,让其效率能更高。epoll_ctl()传递的参数主要是epfd(eventpoll对象id)。

    3.epoll_wait()这个系统调用默认会将当前进程(线程)阻塞,加入到eventpoll对象的等待队列中,直到socket就绪列表中有socket,才会将该进程(线程)重新加入工作队列,并返回就绪队列中的socket的数量。

    5.7 使用epoll_ctl()对eventpoll对象的socket_fd列表进行维护,那么怎么维护就绪socket的列表呢?

    socket包含读缓冲区、写缓冲区和等待队列。当使用epoll_ctl()系统调用将socket新加入socket_fd列表时,就会将eventpoll对象引用加到socket的等待队列中,当网卡的中断程序发现socket的等待队列中不是一个进程(线程),而是一个eventpoll对象的引用,就将socket引用追加到eventpoll对象的就绪列表的尾部。而eventpoll对象中的等待队列存放的就是调用了epoll_wait()的进程(线程),网卡的中断程序执行会将等待队列中的进程(线程)重新加入工作队列,让其拥有占用CPU执行的资格。epoll_wait()的返回值是int类型,返回的是就绪的socket的数量/发生错误,-1表示发生错误。

    5.8 epoll_wait()的返回值仅仅是int数值而已,那么我们要怎么拿到就绪的socket列表呢?

    epoll的参数有传入一个epoll_event的数组指针(作为输出参数),在调用epoll_wait()返回的同时,Kernel内核还会将就绪的socket列表添加到epoll_event类型的数组当中。

    相关文章

      网友评论

          本文标题:关于Linux下的select/epoll

          本文链接:https://www.haomeiwen.com/subject/vxzerltx.html