linux中多路复用IO:select、poll和epoll

目录

一、Setect函数

1.函数介绍

2.代码示例

3.缺点

二、poll函数

1.函数介绍

2.代码示例

3.缺点

三、epoll函数

1.函数介绍

2.代码示例

3.总结


在早期,为了同时处理多个网络连接,最直接的方式是 多线程/多进程模型 :来一个连接,就开一个线程去服务它。这个线程会一直卡在 read()accept() 这样的操作上,直到有数据到来。当连接很多时,成千上万的线程会消耗巨大的系统资源。

但现在有了多路复用IO,就可去检测对应的文件描述符,就好比:每个老师对应一个学生批改作业改进到了一个老师修改多个做个作业,谁作业做完了,叫老师,老师就来批改。

一、Setect函数

首先,大概说一下select的原理:就是创建一个包含文件描述符集合,然后函数不断的去检测这个集合中的文件描述符。

1.函数介绍

下面是select的基本信息:

函数头文件 #include <sys/select.h>

函数原型

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

函数功能

监控一组文件描述符, 阻塞当前进程, 由内核检测相应的文件描述符是否就绪,一旦有文件描述符就绪,将就绪的文件描述符拷贝给进程, 唤醒进程处理

函数参数

nfds:最大文件描述符加1

readfds:读文件描述符集合的指针

writefds:写文件描述符集合的指针

exceptfds:其他文件描述符集合的指针

timeout:超时时间结构体变量的指针

函数返回值 成功:返回已经就绪的文件描述符的个数。 如果设置timeout, 超时就会返回0失败:-1, 并设置errno

简单介绍一下:

nfds是最大文件描述符+1,因为文件描述符是 0(标准输入)开始的,加1后等于对应的数量。

readfds是可读事件监测:比如,Socket - 有新连接到达 - 对端发送了数据(已连接socket) - 对端关闭连接(read返回0)。

writefds可写事件监测:TCP连接已完成三次握手 ,发送缓冲区有空间(可以立即发送数据), 非阻塞connect已完成连接等。

exceptfds是异常事件的类型。

最后timeout超时的时间,为NULL时阻塞。

void FD_CLR(int fd,fd_set *set) //将fd从文件描述符集合中删除

int FD_ISSET(int fd,fd_set *set) //判断fd是否在文件描述符集合中

void FD_SET(int fd,fd_set *set) //将文件描述符添加到文件描述符集合中

void FD_ZERO(fd_set *set) //将文件描述符集合清空

fd_set类型就是一个集合,我们要做的就是将文件描述符添加到这个集合,然后通过select监测。

2.代码示例

下面是一个服务器端连接多个客户端的示例:

先创建三个文件server.cpp、client.cpp、Makefile

bash 复制代码
//Makefile文件
server:                                                                                                                        
	g++ client.cpp -o client && \
	g++ server.cpp -o server
clean:
	rm server && rm client
cpp 复制代码
//server.cpp
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int client_count = 0;

int main() {
    //创建服务器socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    //绑定和监听
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(8888);//端口号转换为网络字节序
    
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 5);

    // 创建一个集合用于select
    fd_set read_fds;
    int max_fd = server_fd;
    
    while(1) {
        FD_ZERO(&read_fds);             // 清空集合
        FD_SET(server_fd, &read_fds);   // 添加服务器socket到集合

        // 更新max_fd为最大的文件描述符
        int ret = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        
        if (FD_ISSET(server_fd, &read_fds)){
            // 处理新连接
            int client_fd = accept(server_fd, NULL, NULL);
            client_count++;
            printf("New connection: %d,Client_Sum num is %d\n", client_fd,client_count);
        }
    }
    
    return 0;
}
cpp 复制代码
//client.cpp
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    //创建一个结构体
    struct sockaddr_in serv_addr;

    //将serv_addr结构体置0
    bzero(&serv_addr, sizeof(serv_addr));
    
    //给serv_addr赋值
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");//将点分十进制的IP字符串转换为网络字节序的32位整数
    serv_addr.sin_port = htons(8888);   //htons() 将主机字节序转换为网络字节序

    connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));    
    
    return 0;
}

执行结果:

1.先执行make,然后打开服务器:

2.在执行客户端代码:

3.可以看到已经连接:

4.多次执行客户端代码护发现多个客户端都已经连接:

为了简短和方便阅读,这个代码中没有做错误处理,也没有客户端向服务端发送消息,只有连接的提示。

3.缺点

对于select的方式是有很多缺点的:

  1. 文件描述符数量限制(通常1024)。

  2. 线性扫描所有fd,效率低。在内核中: 每次调用 select,内核都必须线性扫描所有传入的fd,从0到最大值。即使只有1个fd就绪,它也要扫描1024个(如果最大fd是1023)。在用户态: select 返回后,应用程序也不知道是哪几个fd就绪了,只能再次线性扫描整个集合,使用 FD_ISSET 来检查。

  3. 每次调用都需要在用户态和内核态之间拷贝整个 fd_set。

二、poll函数

1.函数介绍

函数头文件 #include <poll.h>
函数原型 int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数功能 监控多个文件描述符的变化

函数参数 fds:sturct pollfd结构体指针nfds:fds结构体的数量timeout:超时时间,单位为ms

函数返回值

成功:大于0, 返回就绪的文件描述符数量; 等于0, 超时返回,。-1,发生错误,并设置errno
struct pollfd 结构体说明

struct pollfd {

int fd; //文件描述符
short events; //输入事件
short revents; //输出事件

};

|------------|-----------|
| 事件定义 | 说明 |
| POLLIN | 普通数据可读 |
| POLLOUT | 普通数据可写 |
| POLLRDNORM | 相当于POLLIN |
| POLLERR | 发生错误 |

这个结构体就是用来检测和发生,输入事件 就是应用程序关心的事件,输出事件就是实际发生的事件。

看到这篇文章的大家基础都不差,那就不多介绍,直接看代码示例。

2.代码示例

cpp 复制代码
//server.cpp
#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>

#define MAX_CLIENTS 1000

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(8888);
    
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 5);
    
    struct pollfd fds[MAX_CLIENTS];
    int nfds = 1;  // 初始只有server_fd
    
    // 初始化pollfd数组
    fds[0].fd = server_fd;
    fds[0].events = POLLIN;         //可读的参数设置
    
    for(int i = 1; i < MAX_CLIENTS; i++) {
        fds[i].fd = -1;  // 标记为未使用
    }
    
    while(1) {
        int ret = poll(fds, nfds, -1);  // 无限等待
        
        // 检查server_fd是否有新连接
        if (fds[0].revents & POLLIN){
            int client_fd = accept(server_fd, NULL, NULL);
            printf("New connection: %d\n", client_fd);
            
            // 找到空闲位置添加新客户端
            for(int i = 1; i < MAX_CLIENTS; i++) {
                if (fds[i].fd == -1) {
                    fds[i].fd = client_fd;
                    fds[i].events = POLLIN;
                    if (i >= nfds) nfds = i + 1;
                    break;
                }
            }
        }
        
        // 检查所有客户端fd
        for(int i = 1; i < nfds; i++) {
            if (fds[i].fd == -1) continue;
            
            if (fds[i].revents & POLLIN) {
                char buffer[1024];
                int n = read(fds[i].fd, buffer, sizeof(buffer));
                if (n > 0) {
                    printf("Received from fd %d,message is %s\n", fds[i].fd,buffer);
                    write(fds[i].fd, buffer, sizeof(buffer)); // 回显
                } else if(n == 0 ){
                    printf("Client disconnected: %d\n", fds[i].fd);
                    // 连接关闭
                    close(fds[i].fd);
                    fds[i].fd = -1;
                }else{
                    perror("read error");
                }
            }
        }
    }
    
    return 0;
}
cpp 复制代码
//client.cpp
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>

#define BUFFER_SIZE 1024 

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);


    struct sockaddr_in serv_addr;
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(8888);

    connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
    
    while(true){
        char buf[BUFFER_SIZE];
        bzero(&buf, sizeof(buf));
        fgets(buf, sizeof(buf), stdin);  // 读取整行,包括空格
        buf[strlen(buf) - 1] = '\0';  // 换行符,替换为字符串结束符

        ssize_t write_bytes = write(sockfd, buf, sizeof(buf));
        if(write_bytes == -1){
            printf("socket already disconnected, can't write any more!\n");
            break;
        }
        bzero(&buf, sizeof(buf));
        ssize_t read_bytes = read(sockfd, buf, sizeof(buf));
        if(read_bytes > 0){
            printf("message from server: %s\n", buf);
        }else if(read_bytes == 0){
            printf("server socket disconnected!\n");
            break;
        }else if(read_bytes == -1){
            close(sockfd);
        }
    }
    close(sockfd);
    return 0;
}

结果如下:

客户端2连接服务端:

客户端1向服务器发消息:

服务器接收消息:

客户端2发送消息:

服务端接收:

客户端1发消息:

服务端接收:

3.缺点

  1. 线性扫描所有fd,效率低。
  2. 调用 poll 都需要在用户态和内核态之间拷贝整个 pollfd 数组。

与传统的select对比,poll解决了数量上的问题,可以自定义任意数量的结构体数组来进行检测。但是在性能上依然是轮询检测这段数组中的文件描述符。也就是是说,相比于select,poll实际上就只是解决了数量上的问题。从性能来看,依然存在问题。

三、epoll函数

1.函数介绍

epoll优点:

epoll底层使用红黑树,没有文件描述符数量的限制,并且可以动态增加与删除节点,不用重复拷贝epoll底层使用callback机制,没有采用遍历所有描述符的方式,效率较高。

函数头文件 #include <sys/epoll.h>

函数原型 int epoll_create(int size);

函数功能 创建一个epoll实例,分配相关的数据结构空间

函数参数 size:需要填一个大于0的数, 从Linux 2.6.8开始, size参数被忽略

函数返回值 成功:返回epoll文件描述符失败:返回-1,并设置errno
函数头文件 #include <sys/epoll.h>

函数原型 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

函数参数 epfd: epoll 实例

op:epoll 操作命令字(如下)

EPOLL_CTL_ADD:在epoll实例中添加新的文件描述符(相当于向红黑树中添加节点),并将事件与fd关联

EPOLL_CTL_MOD:更改与目标文件描述符fd相关联的事件

EPOLL_CTL_DEL:从epoll实例中删除目标文件描述符fd ,事件参数被忽略

在系统中定义如下:

#define EPOLL_CTL_ADD 1 /* Add a file descriptor to the interface. */

#define EPOLL_CTL_DEL 2 /* Remove a file descriptor from the interface. */

#define EPOLL_CTL_MOD 3 /* Change file descriptor epoll_event structure. */

参数fd : 操作的文件描述符

事件event : struct 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; //一般是文件描述符

};

2.代码示例

cpp 复制代码
//server.cpp
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <errno.h>

#define MAX_EVENTS 1024
#define READ_BUFFER 1024

void setnonblocking(int fd){
    //设置文件描述符的状态为非阻塞的
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}

int main() {
    //创建一个socket节点
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    //绑定IP和端口
    struct sockaddr_in serv_addr;
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(8888);

    //绑定socket
    bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));

    //监听socket
    listen(sockfd, SOMAXCONN);
    
    //创建epoll实例
    int epfd = epoll_create1(0);

    //声明事件数组,和事件结构体
    struct epoll_event events[MAX_EVENTS], ev;
    bzero(&events, sizeof(events));
    bzero(&ev, sizeof(ev));
    ev.data.fd = sockfd;
    ev.events = EPOLLIN | EPOLLET;  //设置可读事件边沿触发模式
    
    setnonblocking(sockfd);      //设置为非阻塞IO

    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);    //将监听socket添加到epoll实例中

    while(true){
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for(int i = 0; i < nfds; ++i){
            if(events[i].data.fd == sockfd){        //如果是socked, 那便是新客户端连接
                struct sockaddr_in clnt_addr;
                bzero(&clnt_addr, sizeof(clnt_addr));
                socklen_t clnt_addr_len = sizeof(clnt_addr);

                int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
                printf("new client fd %d! IP: %s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));

                bzero(&ev, sizeof(ev));
                ev.data.fd = clnt_sockfd;
                ev.events = EPOLLIN | EPOLLET;
                setnonblocking(clnt_sockfd);
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sockfd, &ev);   //将新的客户端socket添加到epoll实例中
            } else if(events[i].events & EPOLLIN){      //如果是可读事件,那就是客户端发送了数据过来
                char buf[READ_BUFFER];
                while(true){    //由于使用非阻塞IO,读取客户端buffer,一次读取buf大小数据,直到全部读取完毕
                    bzero(&buf, sizeof(buf));
                    ssize_t bytes_read = read(events[i].data.fd, buf, sizeof(buf));
                    if(bytes_read > 0){
                        printf("message from client fd %d: %s\n", events[i].data.fd, buf);
                        write(events[i].data.fd, buf, sizeof(buf));
                    } else if(bytes_read == -1 && errno == EINTR){  //客户端正常中断、继续读取
                        printf("continue reading");
                        continue;
                    } else if(bytes_read == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))){//非阻塞IO,这个条件表示数据全部读取完毕
                        printf("finish reading once, errno: %d\n", errno);
                        break;
                    } else if(bytes_read == 0){  //EOF,客户端断开连接
                        printf("EOF, client fd %d disconnected\n", events[i].data.fd);
                        close(events[i].data.fd);   //关闭socket会自动将文件描述符从epoll树上移除
                        break;
                    }
                }
            } else{         //其他事件,之后的版本实现
                printf("something else happened\n");
            }
        }
    }
    close(sockfd);
    return 0;
}
cpp 复制代码
//client.cpp
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>

#define BUFFER_SIZE 1024 

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);;

    struct sockaddr_in serv_addr;
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(8888);

    connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
    
    while(true){
        char buf[BUFFER_SIZE];  //在这个版本,buf大小必须大于或等于服务器端buf大小,不然会出错,想想为什么?
        bzero(&buf, sizeof(buf));
        scanf("%s", buf);
        ssize_t write_bytes = write(sockfd, buf, sizeof(buf));
        if(write_bytes == -1){
            printf("socket already disconnected, can't write any more!\n");
            break;
        }
        bzero(&buf, sizeof(buf));
        ssize_t read_bytes = read(sockfd, buf, sizeof(buf));
        if(read_bytes > 0){
            printf("message from server: %s\n", buf);
        }else if(read_bytes == 0){
            printf("server socket disconnected!\n");
            break;
        }else if(read_bytes == -1){
            close(sockfd);
        }
    }
    close(sockfd);
    return 0;
}

结果如下:

开始建立连接:

发送消息:

接收消息:

代码中有一个点需要注意,需要使用fcntl函数将文件描述符设置为非阻塞的,如果设置为阻塞的文件当传输的数据较多,一次读不完,需要多次读取,但事件只会触发一次。在 EPOLLET 模式下,epoll_wait 只会在文件描述符状态发生变化时通知一次。

在上述的代码中,如果有可读事件发生,就会进入while循环进入不断读取的状态。如果设置为阻塞 状态,那当数据读取结束时,这个代码就会卡死在read函数中,无法进行while的跳出,线程卡死。

3.总结

epoll使用在对性能要求高的场景,大多的高性能服务器都基于epoll实现。对于简单情况下,select和poll也可以满足需求。

相关推荐
---学无止境---8 小时前
Linux中完成根文件系统的最终准备和切换prepare_namespace函数的实现
linux
郝学胜-神的一滴8 小时前
QAxios研发笔记(二):在Qt环境下基于Promise风格简化Http的Post请求
开发语言·c++·笔记·qt·网络协议·程序人生·http
大白的编程日记.8 小时前
【Linux学习笔记】线程安全问题之单例模式和死锁
linux·笔记·学习
---学无止境---8 小时前
Linux 2.6.10 调度器负载均衡机制深度解析:从理论到实现
linux
馨谙8 小时前
Linux 安全文件传输完全指南:sftp 与 scp 的深度解析引言
linux·运维·服务器
姓蔡小朋友8 小时前
Linux网络操作
linux·运维·服务器
晨非辰8 小时前
《数据结构风云》:二叉树遍历的底层思维>递归与迭代的双重视角
数据结构·c++·人工智能·算法·链表·面试
linmengmeng_13148 小时前
【Centos】服务器硬盘扩容之新加硬盘扩容到现有路径下
linux·服务器·centos
边疆.8 小时前
【Linux】版本控制器Git和调试器—gdb/cgdb的使用
linux·服务器·git·gdb调试·cgdb