网络编程select、poll、epoll函数的使用

0 阅读7分钟

一、为什么会使用这三个函数?

  首先,当在创建客户端与Sever连接时候,如果是单个客户端,则可以直接一对一连接即可。但是对于多个客户端连接Sever时候,为了保证每个客户端的数据都能相互传输,则需要用到以上三个函数可以实现。

二、网络的基本配置

    int sock_fd = socket(AF_INET, SOCK_STREAM, 0); // sock_fd不变
    typedef struct sockaddr_in SockAddrIn;
    SockAddrIn severaddr;
    memset(&severaddr, 0, sizeof(severaddr));

    severaddr.sin_family = AF_INET;                \\IPV4
    severaddr.sin_port = htons(2049);               \\端口
    severaddr.sin_addr.s_addr = htonl(INADDR_ANY);   \\接收发往本机所有IP的连接

    if (bind(sock_fd, (struct sockaddr *)&severaddr, sizeof(severaddr)) == -1)
    {
        perror("bind");
        return -1;
    }

    listen(sock_fd, 10); // 监听有没有客户端申请连接

  sock_fd是监听套接字,主要用于监听是否有新的客户端来进行连接,如果确实有新客户端连接进来,那么就通过产生这个新客户端事件对应的描述符和sock_fd进行判断是否确实有新客户端进入。sockaddr_in就是对于网络结构体一些基本的参数配置,以及bind是把结构体中的信息绑定到sock_fd上面。

三、select函数多客户端

// select多客户端
#if 0
    //select函数的基本配置
    fd_set rfds, rset;
    FD_ZERO(&rfds);
    FD_SET(sock_fd, &rfds);
    int maxfd = sock_fd;

    printf("ready\n");

    while (1)
    {
        rset = rfds;
        //select检查的是小于maxfd + 1的fd,而非小于等于,所以需要加一
        int nready = select(maxfd + 1, &rset, NULL, NULL, NULL);

        // 铃响有客,发编号
        if (FD_ISSET(sock_fd, &rset)) // 把 sock_fd 当位图下标,查 rset 第 sock_fd 位是不是 1
        {
            typedef struct sockaddr_in ClientAddr;
            ClientAddr clientaddr;
            socklen_t len = sizeof(clientaddr);
            int client_fd = accept(sock_fd, (struct sockaddr *)&clientaddr, &len); // 接收新的客户端

            // maxfd更新
            FD_SET(client_fd, &rfds);
            if (client_fd > maxfd)
            maxfd = client_fd;
        }

        // 客提要求,收数据
        for (int i = sock_fd + 1; i <= maxfd; i++) // i->fd的编号值
        {
            if (FD_ISSET(i, &rset)) // 判断新事件产生
            {
                char buffer[128] = {0};
                int count = recv(i, buffer, sizeof(buffer), 0);

                if (count == 0)
                {
                    printf("disconnect");
                    /*

                    为什么需要都关闭?
                    1.close(i);
                    2.FD_CLR(i, &rfds);
                    3.FD_CLR(i, &rfds);
                      close(i);

                    */
                    FD_CLR(i, &rfds); // 从监听名单除名
                    close(i);         // 关闭fd释放内核资源
                    continue;
                }

                send(i, buffer, count, 0); // 回传
                printf("client_fd:%d,count:%d,buffer:%s\n", i, count, buffer);
            }
        }
    }

  首先就是select函数的基本配置,当配置完成后就可以通过while循环来检测是否有新客户端进来和是否有数据传输,新客户端产生时候用监听套接字sock_fd发现,并用accept之后产生连接套接字client_fd,而连接套接字则是负责数据的传输。

  第二就是while循环里面的内容,设置rset原因是因为当传入地址是rfds到select中去时候此时,此时就会在检测过后把还未就绪fd的全部清0,当到了下一循环时候,又只从这些未清零的fd中进行检测,那么此时未就绪的fd名单都会一去不复返,包括监听的sock_fd。

  当进入第一个if判断是检查是否有新的客户端,接着通过accept函数返回连接套接字client_fd,(accept分配fd规则是从0开始扫描,找到第一个空位分配给client_fd,如果有客户端关闭使得中间fd有空缺,那么也会在此补上)通过client_fd来更新maxfd。

  在for循环中第二个if当有新事件产生时候,正常通过recv来接收数据,如果count等于0则客户端已经断开。此时需要关闭两个部分,如果只关一个情况1则会出现下一次循环还会监听这个已经关闭的客户端,如果是情况2则会出现内存泄漏,所以需要全部关闭。

四、poll函数多客户端

// poll多客户端
#elif 0
    struct pollfd fds[100] = {0};
    fds[sock_fd].fd = sock_fd;    // 把fds数组的第sock_fd位置装着sock_fd的编号
    fds[sock_fd].events = POLLIN; // 只读

    int maxfd = sock_fd;
    printf("ready\n");

    while (1)
    {

        int nready = poll(fds, maxfd + 1, -1); // 阻塞一直等待有新事件到来(遍历 fds 里每个 fd,检查它现在有没有事件)

        if (fds[sock_fd].revents & POLLIN) // 监听套接字检查是否有新的客户端
        {
            typedef struct sockaddr_in ClientAddr;
            ClientAddr clientaddr;

            socklen_t len = sizeof(clientaddr);
            int client_fd = accept(sock_fd, (struct sockaddr *)&clientaddr, &len);
            printf("Client:%d", client_fd);

            fds[client_fd].fd = client_fd;
            fds[client_fd].events = POLLIN;
            maxfd = client_fd;
        }

        for (int i = sock_fd + 1; i <= maxfd; i++)
        {
            if (fds[i].revents & POLLIN)
            {
                char buffer[128] = {0};
                int count = recv(i, buffer, sizeof(buffer), 0);
                if (count == 0)
                {
                    printf("disconnect\n");
                    
                    fds[i].fd = -1; // 移除断开连接的客户端fd
                    fds[i].events = 0; // 清空事件
                    close(i); // 防止内存泄露
                }

                send(i, buffer, count, 0);
                printf("client:%d,count:%d,buffer:%s\n", i, count, buffer);
            }
        }
    }

  首先最先关于poll函数的基本配置,第一个if同理用sock_fd直接定位看它的events里面有没有POLLIN,有就说明监听套接字可读,也就是有新客户端连进来,其余的同理,第二部分也同理接收客户端产生事件时候的如果有数据传输则接收数据。

五、epoll函数多客户端(最常用)

#else
    int epfd = epoll_create(1); // 同样占用fd,因此第一个客户端连接进入时候显示clientfd是5

    typedef struct epoll_event EV;
    EV ev;

    ev.data.fd = sock_fd;
    ev.events = POLLIN; // 事件选择可读事件

    epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev);
    struct epoll_event events[1024] = {0}; // 所有客户端总共一起最多处理1024个事件

    printf("ready accept\n");
    while (1)
    {
        int nepoll = epoll_wait(epfd, events, 1024, -1); // -1表示一直等待事件到来

        for (int i = 0; i < nepoll; i++)
        {
            if (events[i].data.fd == sock_fd) // 首先判断是否有新客户端加入进来
            {
                typedef struct sockaddr_in ClientAddr;
                ClientAddr clientaddr;
                socklen_t len;

                int client_fd = accept(sock_fd, (struct sockaddr *)&clientaddr, &len);

                ev.data.fd = client_fd;
                ev.events = EPOLLIN;

                epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); // 登记一个新的fd
            }
            else if (events[i].events & EPOLLIN)
            {
                char buffer[128] = {0};
                int count = recv(events[i].data.fd, buffer, sizeof(buffer), 0);
                if (count == 0)
                {
                    printf("disconnect\n");
                    epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL); // 关掉的客户端取消监视
                    close(events[i].data.fd);                                //防止内存泄露
                    continue;
                }
                send(events[i].data.fd, buffer, count, 0);
                printf("client:%d,count:%d,buffer:%s\n", events[i].data.fd, count, buffer);
            }
        }
    }

#endif

    getchar();

  首先最先关于epoll函数的基本配置,其余同理,区别在于对于事件遍历处理的方式不一样,如下所示,select和poll都是有maxfd用于遍历,但是对于epoll则查询的方式不一样,它是通过操控内核中的红黑树。select和poll就好像保安每天都要收到一份名单,每次都需要按照名单一个一个点名;但是对于epoll则是给每个业主装上了闹铃,当业主有事情时候就可以直接按铃就可以自动把这个事件消息登记到保安电脑上,保安只需要查看电脑即可。

//select
FD_SET(client_fd, &rfds);
if (client_fd > maxfd)
maxfd = client_fd;

//poll
fds[client_fd].fd = client_fd; 
fds[client_fd].events = POLLIN;
maxfd = client_fd;

//epoll
ev.data.fd = client_fd; 
ev.events = EPOLLIN; 
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); // 登记一个新的fd

六、程序问题

水平触发LT,边沿触发ET

    ev.events = EPOLLIN; // 水平触发

  此时表示的是水平触发,当选择水平触发时候,此时当客户端一次性数据过多超过buffer的最大值时候,就会自动拆成两部分发送,如下图所示:

屏幕截图 2026-10-23 184425.png

此时的逻辑为当一次性发送不完时候,当进入下一次循环时epoll_wait不会一直阻塞,它会判断可读成立,此时则又继续调用recv接收和发送剩余的数据。
    ev.events = EPOLLIN | EPOLLET; // 边沿触发

  对于边沿触发,ET通常可以避免一直关注可读或者可写事件时候,导致程序空转的问题

七、总结

select缺点

1.参数多

2.每次查找新事件都需要进行全量拷贝,再遍历所有的fd,当客户端过多时效率太低

3.IO口有限制(硬伤1024)

poll缺点

主要也是全量拷贝和全量遍历效率太低

epoll优点

1.不需要全量拷贝

2.不需要全量遍历

3.没有1024限制