详解C/C++中的select、poll和epoll

来自:网络
时间:2023-07-24
阅读:
目录

1. select

1.1 概述

select函数是UNIX和Linux中常用的多路复用IO机制,它允许程序同时监控多个文件描述符(可以是套接字socket,也可以是普通文件)的读、写和异常事件。它使进程能够告诉内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定时间后才唤醒它。这样做的优点是,不需要应用程序自行检测和处理每个客户端连接的状态,可以节省大量的系统资源,提高应用程序的效率。

1.2 函数详解

首先,我们需要包含一些必要的头文件以使用select函数和相关的数据结构:

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

接下来是select函数的原型:

int select(int maxfdpl, fd_set *readset, fd_set *writeset,
            fd_set *exceptset, struct timeval *timeout);

select函数接收以下参数:

  • maxfdpl: 是需要检查的所有文件描述符中的最大值加1,因为这会告诉内核检查的文件描述符的数量。
  • readset: 是一个文件描述符集合,用于检查是否有可读的数据。这是输入-输出参数,select会改变其值。
  • writeset: 是一个文件描述符集合,用于检查是否可以写入数据。这也是输入-输出参数,select会改变其值。
  • exceptset: 是一个文件描述符集合,用于检查是否有异常情况发生(如带外数据到达)。这同样是输入-输出参数,select会改变其值。
  • timeout: 是一个timeval结构,用于指定select的阻塞等待时间。如果设定时间为NULL,select将会一直等待;如果设定时间为特定值,select将会等待指定时间;如果设定时间为0,select将立即返回,这称为轮询。
struct timeval {
		long tv_sec; /* seconds */
		long tv_usec; /* microseconds */
	};

关于文件描述符的操作,以下四个函数是用于处理fd_set类型数据的:

  • FD_ZERO(fd_set *set): 这个函数用于清除一个fd_set的所有位,即初始化一个fd_set。
  • FD_SET(int fd, fd_set *set): 这个函数用于将特定的文件描述符fd加入到fd_set中。
  • FD_CLR(int fd, fd_set *set): 这个函数用于将特定的文件描述符fd从fd_set中移除。
  • FD_ISSET(int fd, fd_set *set): 这个函数用于检查特定的文件描述符fd是否在fd_set中,如果在,函数返回非零值,否则返回0。

使用这些函数,我们可以方便地对文件描述符集合进行操作,以便于使用select函数进行IO操作的复用。

1.3 例子

下面给出一个使用select创建一个可以同时处理多个客户端的连接的服务器:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#define MAX_CLIENTS 5
#define BUFFER_SIZE 1024
int main(int argc , char *argv[])
{
    int listener, newsockfd, portno, clilen;
    char buffer[BUFFER_SIZE];
    fd_set master;    // 主文件描述符列表
    fd_set read_fds;  // 用select()的临时文件描述符列表
    struct sockaddr_in serv_addr, cli_addr;
    int FD_MAX;       // 最大文件描述符号
    listener = socket(AF_INET, SOCK_STREAM, 0);
    memset(&serv_addr, '0', sizeof(serv_addr));
    portno = 5000;
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(portno);
    bind(listener, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    listen(listener, 10);
    FD_ZERO(&master);    // 清除主监听输入端口
    FD_ZERO(&read_fds);  // 清除临时集合
    // 添加监听器到主集合
    FD_SET(listener, &master);
    // 追踪最大的文件描述符
    FD_MAX = listener;
    while(1)
    {
        read_fds = master; // 拷贝它
        if(select(FD_MAX+1, &read_fds, NULL, NULL, NULL) == -1)
        {
            perror("Select error!");
            exit(1);
        }
        for(int i = 0; i <= FD_MAX; i++)
        {
            if(FD_ISSET(i, &read_fds))
            {
                if(i == listener)
                {
                    // handle new connections
                    clilen = sizeof(cli_addr);
                    newsockfd = accept(listener, (struct sockaddr*)&cli_addr, &clilen);
                    if(newsockfd == -1)
                    {
                        perror("accept error");
                    }
                    else
                    {
                        FD_SET(newsockfd, &master); // 添加到主集合
                        if(newsockfd > FD_MAX)
                        {
                            FD_MAX = newsockfd; // 持续追踪最大的文件描述符
                        }
                        printf("selectserver: new connection from %s on socket %d\n",inet_ntoa(cli_addr.sin_addr), newsockfd);
                    }
                }
                else
                {
                    // 处理来自客户端的数据
                    if((recv(i, buffer, sizeof(buffer), 0)) <= 0)
                    {
                        // got error or connection closed by client
                        close(i);
                        FD_CLR(i, &master); // 从主集合中移除
                    }
                    else
                    {
                        // 我们得到了一些数据!
                        for(int j = 0; j <= FD_MAX; j++)
                        {
                            // 发送数据到所有连接
                            if(FD_ISSET(j, &master))
                            {
                                if(j != listener && j != i)
                                {
                                    send(j, buffer, strlen(buffer), 0);
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    return 0;
}

上面这个例子会创建一个在端口5000监听的服务器。使用select,我们可以在单个线程中同时处理多个客户端的连接。这就是使用select的优点:我们可以同时处理多个连接,而不需要为每个连接创建一个单独的线程或进程。

1.4 总结

  • 优点:
    • 跨平台性:select是遵循POSIX标准的,所以在多种平台上都可以使用,具有较好的跨平台性。
    • 精确的超时等待时间:select可以设置超时时间,对时间的精确度可以达到微秒级别。
  • 缺点:
    • 文件描述符上限:select所能监听的文件描述符的数量是有限的,取决于_FD_SETSIZE的值,默认为1024。如果需要处理的并发连接数过多,select可能无法满足需求。
    • 性能下降:select在内核中通过轮询所有文件描述符的方式来检查其状态,当监控的文件描述符数量增多时,性能会下降。
    • 使用复杂:select在返回时,只会告诉用户哪些描述符集合是就绪的,但并不会直接告诉用户哪一个具体的文件描述符就绪,用户需要自己去遍历这些集合,操作比较复杂。
    • 多次数据拷贝:每次调用select都需要将文件描述符集合从用户空间拷贝到内核空间,这增加了额外的开销。
    • 重复操作:每次select返回后,所有未就绪的文件描述符都会被移除,因此每次使用都需要重新向集合中添加描述符。
  • 注意事项:
    • 一般情况下,我们无法通过改变进程打开的文件描述符个数来改变select能够监听的文件描述符个数,这个数量受限于_FD_SETSIZE。
    • 当套接字上发生错误时,select会将其标记为既可读又可写。
    • 接收和发送低水位标记的目的在于,让应用程序可以控制在多少数据可读或有多少空间可写时唤醒select。例如,当有64字节的数据可读时,select才会被唤醒。这样可以避免频繁打断应用程序来处理IO操作,提高程序的效率。

2. poll

1.1 概述

poll函数提供了类似于select的功能,允许进程向内核指示等待多个事件中的任何一个发生,它只在有一个或多个事件发生或经历一段指定时间后才唤醒进程。不过,与select相比,poll在处理流设备时能够提供更丰富的信息。它能有效地管理多个输入/输出源,并且在特定事件发生时进行响应,这使得对多任务并发处理的支持更为高效。

1.2 函数详解

poll函数的声明如下:

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

这里是poll函数的参数列表:

  • fds:一个指向一个pollfd结构体数组的指针。这个数组中的每一个成员都代表一个特定的文件描述符以及对它感兴趣的事件和发生的事件。
  • nfds:fds数组的成员数量。
  • timeout:调用应该等待的最大毫秒数,以阻塞的方式等待文件描述符变为就绪。如果这个值是-1,poll将会无限期的阻塞。如果这个值是0,poll将立即返回。

pollfd结构体的定义如下:

struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 要监控的事件 */
    short revents;    /* 实际发生的事件 */
};
  • fd:需要被poll监控的文件描述符。
  • events:一组位标志,表示对应的文件描述符上我们感兴趣的事件。比如:POLLIN(有数据可读),POLLOUT(写数据不会阻塞),POLLERR(错误事件),POLLHUP(挂起事件)等。
  • revents:一组位标志,表示在对应的文件描述符上实际发生了哪些事件。这是poll调用返回后由内核填充的。

当调用poll函数时,内核会检查每个pollfd结构体中列出的文件描述符,看看是否有任何指定的事件发生。如果有,内核将会在revents字段中设置相应的位,以指示哪些事件已经发生。然后poll函数返回,应用程序可以检查每个pollfd结构体的revents字段来确定每个文件描述符上发生了哪些事件。

1.3 例子

在下面这个示例中,我们会创建两个管道(pipe),然后使用 poll 来等待这两个管道中的任何一个变得可读。

#include <stdio.h>
#include <unistd.h>
#include <poll.h>
#define TIMEOUT 5
int main (void)
{
    struct pollfd fds[2];
    int ret;
    // 创建两个管道
    int pipefd[2];
    pipe(pipefd);
    // watch stdin (fd 0) for input
    fds[0].fd = 0;
    fds[0].events = POLLIN;
    // watch pipe for input
    fds[1].fd = pipefd[0];
    fds[1].events = POLLIN;
    ret = poll(fds, 2, TIMEOUT * 1000);
    if (ret == -1) {
        perror ("poll");
        return 1;
    }
    if (!ret) {
        printf ("%d seconds elapsed.\n", TIMEOUT);
        return 0;
    }
    if (fds[0].revents & POLLIN)
        printf ("stdin is readable\n");
    if (fds[1].revents & POLLIN)
        printf ("pipe is readable\n");
    return 0;
}

在上述示例中,我们使用 poll 来同时监听标准输入和管道的输入。如果在5秒钟内,标准输入或管道有任何数据可读,那么 poll 就会返回,并通过检查 revents 标志来通知我们哪一个文件描述符已经就绪。如果在5秒内没有任何数据可读,那么 poll 也会返回,此时我们可以打印一个超时信息。这就是使用 poll 的优点:我们可以同时处理多个输入源,而不需要为每个输入源创建一个单独的线程或进程。

1.4 总结

  • 1.优点:
    • 无最大文件描述符限制:不像select有FD_SETSIZE限制,poll没有这个限制,所以可以处理更多的文件描述符。
    • 调用方式简单:poll使用一个pollfd的数组作为参数,而不是像select一样使用一组文件描述符集合。这使得设置和处理poll的调用更加简单。
    • 无需重新设置文件描述符:每次调用select后,由于select函数会改变文件描述符集合,所以需要重新设置。而在poll中,不需要这么做,因为poll并不会更改pollfd的数组。
    • 提供了更多的事件类型:除了常规的读、写和异常条件,poll还支持带外数据、优先数据等更多的事件类型。
  • 2.缺点:
    • 仍需要遍历所有文件描述符:虽然poll没有最大文件描述符限制,但返回后仍需要遍历整个文件描述符列表来找出哪些文件描述符已就绪,当文件描述符数量较大时,这也可能会影响性能。
    • 不支持文件描述符的优先级:poll函数处理所有的文件描述符是平等的,不像某些其他的I/O多路复用技术可以设置优先级。
    • 缺乏广泛的跨平台支持:虽然poll在许多系统上都可用,但它的实现并不总是完全可靠,特别是在一些老的系统上。
  • 3.注意事项:
    • 移除文件描述符:如果你不再关心某个文件描述符,可以将对应的pollfd结构中的fd设置为-1,这样poll将不再监视这个文件描述符,并且在返回时将会将revents设置为0。"事件-结果"模式:与select使用"值-结果"参数传递方式不同,pollfd包含了要监视的events和实际发生的revents,这样应用程序可以在同一地方查看它关心的事件,并确定哪些已经发生。
    • 处理大量文件描述符:虽然poll没有文件描述符数量的限制,但是如果监视的文件描述符过多,可能会导致性能下降,因为无论是否有事件发生,poll返回后都需要遍历整个文件描述符列表。
    • 在poll返回后处理结果:像select一样,当poll返回后,你需要遍历所有的pollfd来找出哪些文件描述符已经就绪。这是因为poll只告诉你有多少文件描述符就绪,但并不告诉你具体是哪些。

3. epoll

1.1 概述

在许多并发连接中只有少数活路的场景下,epoll是Linux下I/O多路复用接口select/poll的增强版本,能有效提升系统CPU的使用率。区别于select和poll每次等待事件之前都需要重新设置监视的文件描述符集,epoll能复用文件描述符集来传递结果,减少了重复的准备工作。

获取事件时,epoll无需像select和poll一样遍历整个被侦听的描述符集,只需遍历被内核IO事件异步唤醒并加入到就绪队列的描述符集即可。这使得处理大量文件描述符时,只有实际产生活动的文件描述符才需要被处理,从而大大提升了效率。

当前,在大规模并发网络程序中,epoll已经成为首选模型。除了提供select/poll的IO事件电平触发(Level Triggered)模式,epoll还额外提供了边沿触发(Edge Triggered)模式,这使得用户空间程序可以缓存IO状态,减少epoll_wait/epoll_pwait的调用,从而进一步提升了程序的运行效率。

1.2 函数详解

epoll 是 Linux 中的 I/O 多路复用接口,常用的API有 epoll_createepoll_ctlepoll_wait。以下是这些API的详细介绍:

1.epoll_create:创建一个epoll的句柄。

#include <sys/epoll.h>
int epoll_create(int size);

参数:size参数现在并不起作用,但是必须大于0。

返回值:如果成功,返回一个非负的文件描述符。失败时,返回-1。

2.epoll_ctl:控制某个epoll文件描述符上的事件,可以注册、修改、删除。

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数:

  • epfd:epoll_create函数返回的文件描述符。
  • op:要进行的操作。EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除)。
  • fd:关联的文件描述符。
  • event:指向epoll_event的指针。
​​​​​​struct epoll_event {
    uint32_t events;   /* Epoll events */
    epoll_data_t data; /* User data variable */
};
​
  • events成员是一组标记,系统所感兴趣的事件和可能发生的返回事件,如EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLET(设置为边缘触发模式)、EPOLLONESHOT(一次性的)、EPOLLRDHUP(对端断开连接或者关闭写操作的一种表示)等。
  • data成员用于存储用户数据,可以是一个指针,也可以是一个整型的标识符。

返回值:成功时,返回0。失败时,返回-1。

3.epoll_wait:等待epoll上的I/O事件。

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数:

  • epfd:epoll_create函数返回的文件描述符。
  • events:用来从内核得到事件的集合。
  • maxevents:告诉内核这个events的大小,不能大于创建epoll_create时的size。
  • timeout:等待I/O事件发生的超时值(单位:毫秒)。0表示立即返回,-1表示一直等待。

返回值:成功时,返回需要处理的事件数目。如返回0表示已经超时。失败时,返回-1。

1.3 例子

以下是一个使用epoll的示例,其中包含了epoll_createepoll_ctlepoll_wait等函数的使用,主要展示了epoll的IO多路复用和边缘触发(ET)模式特性:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <fcntl.h>
#define MAX_EVENTS 10
void set_nonblock(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    flags |= O_NONBLOCK;
    fcntl(fd, F_SETFL, flags);
}
int main() {
    struct epoll_event ev, events[MAX_EVENTS];
    int listen_sock, conn_sock, nfds, epollfd;
    /* Code to set up listening socket, 'listen_sock',
       (socket(), bind(), listen()) omitted */
    epollfd = epoll_create1(0);
    if (epollfd == -1) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = listen_sock;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
        perror("epoll_ctl: listen_sock");
        exit(EXIT_FAILURE);
    }
    for (;;) {
        nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait");
            exit(EXIT_FAILURE);
        }
        for (int n = 0; n < nfds; ++n) {
            if (events[n].data.fd == listen_sock) {
                conn_sock = accept(listen_sock, (struct sockaddr *) &local, &addrlen);
                if (conn_sock == -1) {
                    perror("accept");
                    exit(EXIT_FAILURE);
                }
                set_nonblock(conn_sock);
                ev.events = EPOLLIN | EPOLLET;
                ev.data.fd = conn_sock;
                if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {
                    perror("epoll_ctl: conn_sock");
                    exit(EXIT_FAILURE);
                }
            } else {
                do_use_fd(events[n].data.fd);
            }
        }
    }
    close(epollfd);
    return 0;
}

在上述代码中,首先创建了一个epoll对象,然后将监听套接字添加到epoll事件集合中,并注册了EPOLLINEPOLLET事件,EPOLLIN代表对应的文件描述符可读,EPOLLET代表以边缘触发模式对事件进行处理。

在无限循环中,调用epoll_wait来等待I/O事件的发生,当新的连接进来时,使用accept接受新的连接,然后将新的连接设为非阻塞模式,并添加到epoll事件集合中。当连接上有数据可读时,调用do_use_fd函数进行处理。

此代码展示了epoll可以动态地添加、修改和删除关注的文件描述符,也展示了边缘触发模式的使用,这些都是epoll的主要特点。

1.4 总结

1.优点:

  • 没有最大并发连接的限制,能打开的FD的上限远大于1024(一般来说是系统的最大文件句柄数)。
  • 效率提升,不会随着FD数目的增加而线性下降。不管有多少FD,都只有活跃的FD会调用callback(在epoll_wait中返回),所以在活动连接较少的情况下,使用epoll不仅在存储空间上有优势,且效率上也有优势。
  • 提供了更多的触发模式选择,除了水平触发模式外,还支持边缘触发模式,使得用户空间程序有可能缓存IO状态,减少epoll_wait的调用次数,提高应用程序效率。

2.缺点:

  • 只能在Linux系统上运行,不具备跨平台性。
  • 对于文件描述符较少的情况,使用select/poll的性能并不差。
  • 使用边缘触发模式时,需要更细心地处理各种情况,以防止消息遗漏。

3.注意事项:

  • 在使用epoll的ET(边缘触发)模式时,需要在读、写操作时都检查EAGAIN错误,确保所有可用数据或所有可写的空间都被使用。
  • 在使用ET模式时,如果一次读取的数据没有达到socket的接收缓冲区的大小,那么下一次epoll_wait可能不会返回该socket的读事件。

4. 三者的区别

选择方式selectpollepoll
操作方式遍历遍历回调
底层实现数组链表红黑树
IO效率每次调用都进行线性遍历,时间复杂度为O(n)每次调用都进行线性遍历,时间复杂度为O(n)事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
最大连接数1024(x86)或2048(x64)无上限无上限
fd拷贝每次调用select,都需要把fd集合从用户态拷贝到内核态每次调用poll,都需要把fd集合从用户态拷贝到内核态调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝
返回顶部
顶部