select和poll

1 概念引入

I/O多路复用(multiplexing)的本质是通过一种机制(系统内核缓冲I/O数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。

Linux中基于socket的通信本质也是一种I/O,使用socket()函数创建的套接字默认都是阻塞的,这意味着当sockets API的调用不能立即完成时,线程一直处于等待状态,直到操作完成获得结果或者超时出错。会引起阻塞的socket API分为以下四种:

  • 输入操作: recv()、recvfrom()。以阻塞套接字为参数调用该函数接收数据时,如果套接字缓冲区内没有数据可读,则调用线程在数据到来前一直睡眠。
  • 输出操作: send()、sendto()。以阻塞套接字为参数调用该函数发送数据时,如果套接字缓冲区没有可用空间,线程会一直睡眠,直到有空间。
  • 接受连接:accept()。以阻塞套接字为参数调用该函数,等待接受对方的连接请求。如果此时没有连接请求,线程就会进入睡眠状态。
  • 外出连接:connect()。对于TCP连接,客户端以阻塞套接字为参数,调用该函数向服务器发起连接。该函数在收到服务器的应答前,不会返回。这意味着TCP连接总会等待至少服务器的一次往返时间。


使用阻塞模式的套接字编写网络程序比较简单,容易实现。但是在服务器端,通常要处理大量的套接字通信请求,如果线程阻塞于上述的某一个输入或输出调用时,将无法处理其他任何运算或响应其他网络请求,这么做无疑是十分低效的,当然可以采用多线程,但大量的线程占用很大的内存空间,并且线程切换会带来很大的开销。而I/O多路复用模型能处理多个connection的优点就使其能支持更多的并发连接请求。

Linux支持I/O多路复用的系统调用有select、poll、epoll,这些调用都是内核级别的。但select、poll、epoll本质上都是同步I/O,先是block住等待就绪的socket,再block住将数据从内核拷贝到用户内存空间。基于select调用的I/O复用模型如下:

2 select, poll, epoll系统调用详解

select,poll,epoll之间的区别如下图:

2.1 select详解

Linux提供的select相关函数接口如下:

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

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

(1)max_fd指定被监听的文件描述符的总数,设置为监听的所有文件描述符中最大值加1,因为文件描述符是从0开始计数的。 

扫描二维码关注公众号,回复: 2487333 查看本文章

(2) readfds、writefds和exceptfds参数分别指向可读、可写、异常事件对应的文件描述符集合。程序员通过这3个参数向该调用传入自己感兴趣的文件描述符。函数返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。原型如下:

#define XFD_SETSIZE     256
#define FD_SETSIZE      XFD_SETSIZE

typedef long fd_mask;
#define NBBY    8
#define NFDBITS (sizeof(fd_mask) * NBBY)

#define howmany(x,y)    (((x)+((y)-1))/(y))

#if defined(BSD) && BSD < 198911
typedef struct fd_set 
{
    fd_mask fds_bits[howmany(FD_SETSIZE, NFDBITS)];
} fd_set;
#endif

fd_set结构体仅包含一个long型数组,根据推导可见该数组为

long fds_bits[8];

fds_bit共占据32字节,即256位。该数组的每个元素的每一位(bit)标记一个文件描述符。fd_set能容纳的文件描述符由FD_SETSIZE指定,显然这限制了select()能同时处理的文件描述符的总数。该系统调用还提供了一些列宏方便程序员实现位操作:

void FD_CLR(int fd, fd_set *set); //清零set中的fd位 
int FD_ISSET(int fd, fd_set *set); //测试set中的fd位是否被设置 
void FD_SET(int fd, fd_set *set); //设置fd中的fd位 
void FD_ZERO(fd_set *set); //清零set中所有位

(3) timeout参数设置函数的超时时间。它是一个timeval的普通(非const)指针,内核可以修改此参数以告诉应用程序函数阻塞等待了多久。不过内核返回的该值不能完全信任,比如调用失败时timeout的值是不确定的。timeval结构体定义如下:

struct timeval 
{
long tv_sec; /* seconds */ long tv_usec; /* microseconds */ };

 当tv_sec(秒)和tv_usec(微秒)都设置为0则select()立即返回,当timeout为NULL则select()将会一直阻塞直到某个文件描描述符就绪。

  selece()执行成功时返回就绪的文件描述符的总数;若在超时时间内没有任何文件描述符就绪,select()将返回0;失败将返回-1并设置errno。若在select()阻塞等待期间程序收到信号,将立即返回-1并设置errno为EINTR。

整体的使用流程如下图:

基于select的I/O复用模型的是单进程执行,占用资源少,可以为多个客户端服务。但是select需要轮询每一个描述符,在高并发时仍然会存在效率问题,同时select能支持的最大连接数通常受限。

socket文件描述符就绪条件

  以Linux网络编程中,多路复用判断socket文件描述符可读的依据是: 
  (1) socket内核接收缓冲区中字节数>=其低水位标记SO_RCVLOWAT时,此时程序可以无阻塞地读该socket,返回读取到的字节数(>0) 
  (2) socket通信的对端关闭连接,此时对该socket的读操作将返回0表示对端关闭 
  (3) 监听socket上有新的连接请求 
  (4) socket上有未处理的错误,此时可以使用getsockopt()读取和清除该错误(使用SO_ERROR标记)

  socket文件描述符可写: 
  (1) socket内核发送缓冲区的空闲区域大于或等于其低水位标记SO_SNDLOWAT,此时程序可以无阻塞的写该socket,返回写入的字节数(>0) 
  (2) socket的写操作被关闭(使用shotdown(fd, SHUT_WR))后再对socket写,会触发一个SIGPIPE信号 
  (3) socket使用非阻塞connect()连接成功或者失败(超时)之后,对于后者将会收到RST报文段,若收到RST报文段后继续往该socket写则会触发SIGPIPE信号 
  (4) socket上未处理的错误

  socket文件描述符异常: 
  socket上接收到带外数据

利用select()同时收发普通数据和带外数据、对端关闭

#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <libgen.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/select.h>

#define ERRP(con, ret, ...) do                              \
{                                                           \
    if (con)                                                \
    {                                                       \
        perror(__VA_ARGS__);                                \
        ret;                                                \
    }                                                       \
}while(0)

#define BUFSIZE 1024
static const char* ip = "192.168.239.136";
static int port = 9660;

int main(void)
{
    int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
    ERRP(socket_fd <= 0, return -1, "socket");

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int ret = bind(socket_fd, (struct sockaddr* )&address, sizeof(address));
    ERRP(ret < 0, goto ERR1, "connect");

    ret = listen(socket_fd, 5);
    ERRP(ret < 0, goto ERR1, "listen");

    struct sockaddr_in client;
    socklen_t client_addrlen = sizeof(client);
    printf("Wait guest...\n\n");
    int connfd = accept(socket_fd, (struct sockaddr* )&client, &client_addrlen);
    ERRP(connfd < 0, goto ERR1, "accept");
    printf("connect success: %s:%d\n", inet_ntoa(client.sin_addr), ntohs(client.sin_port));

    char buf[BUFSIZE] = {};
    fd_set read_fds;
    fd_set exception_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&exception_fds);
    struct timeval timeout;
    unsigned int cnt = 0;

    while (1)
    {
        bzero(buf, BUFSIZE);

        //select()的参数在每次select()函数的返回会被内核修改,所以这里需要重新设置
        FD_SET(connfd, &read_fds);      //将connfd加入就绪读监听集合
        FD_SET(connfd, &exception_fds); //将connfd加入异常监听集合
        timeout.tv_usec = 0;            //超时时间为4s
        timeout.tv_sec = 4;

        ret = select(connfd + 1, &read_fds, NULL, &exception_fds, &timeout);
        ERRP(ret < 0, goto ERR2, "select");

        if (FD_ISSET(connfd, &read_fds))
        {
            ret = recv(connfd, buf, sizeof(buf) - 1, 0);    //recv返回0表示对端已经关闭
            ERRP(ret < 0, goto ERR2, "recv normal data");
            if (ret == 0)
            {
                printf("guest exit...\n");
                break;
            }
            printf("get %d bytes of normal data: %s\n", ret, buf);
        }
        else if (FD_ISSET(connfd, &exception_fds))
        {
            ret = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
            ERRP(ret < 0, goto ERR2, "recv OOB data");
            if (ret == 0)
            {
                printf("guest exit...\n");
                break;
            }
            printf("get %d bytes of OOB data: %s\n", ret, buf);
        }
        else if (ret == 0)  //select()返回0表示超时返回
        {
            printf("time out %d\n", ++cnt);
        }
    }

ERR2:
    close(connfd);
ERR1:
    close(socket_fd);  

    return 0;
}

2.2 poll详解

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。

Linux提供的poll函数接口如下

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

typedef struct pollfd 
{
        int fd;                         // 需要被检测或选择的文件描述符
        short events;                   // 对文件描述符fd上感兴趣的事件
        short revents;                  // 文件描述符fd上当前实际发生的事件*/
} pollfd_t;
poll()函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;
fds是一个struct pollfd类型的数组,用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds数组不会被清空;
nfds记录数组fds中描述符的总数量;
timeout是调用poll函数阻塞的超时时间,单位毫秒;
一个pollfd结构体表示一个被监视的文件描述符,通过传递fds[]指示 poll() 监视多个文件描述符。其中,结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域,结构体的revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。

fd成员指定文件描述符。event成员告诉内核要监听fd上的哪些事件,它可以是一系列事件的按位或。revents成员由内核修改,以通知应用程序fd上实际发生了哪些事件。event的取值为: 
这里写图片描述

  上面事件选项中: 
  a. POLLRDNORM(普通数据可读)、POLLRDBAND(优先级带数据可读)和POLLWRNORM(普通数据可写)、POLLWRBAND(优先级带数据可写)将POLLIN(数据可读)和POLLOUT(数据可写)划分得更明显,以区分优先级带数据和普通数据,但是Linux并不完全支持。 
  b. 一般应用程序调用recv()时,要判断接收到的是有效数据还是对端关闭连接后触发的是根据recv()的返回值(如上面的select()示例程序),在poll()系统调用中,有更直接的方法,监听描述符的POLLRDHUP事件即监听对端关闭事件,不过需要在代码开始处定义”_GNU_SOURCE”

  (2) fds数组成员的的个数由参数nfds指定(typedef unsigned long int nfds_t;)。显然,这个比select()的设计要灵活一点: 用户可以监测任意多数目文件描述符,但是poll()的实现也是依靠轮询的,从效率上来讲跟select()的实现是一致的。

  (3) timeout参数指定函数的超时事件,单位为毫秒。当timeout为-1时,poll调用将一直阻塞直到监听的目标事件发生;当timeout为0时,poll()调用立即返回。

  (4) poll()的返回值跟select()的返回值含义相同。


POLLWRNORM 写普通数据不会导致阻塞 POLLWRBAND 写优先数据不会导致阻塞 POLLMSGSIGPOLL 消息可用
当需要监听多个事件时,使用POLLIN | POLLRDNORM设置 events 域;当poll调用之后检测某事件是否发生时,fds[i].revents & POLLIN进行判断。

2.3 epoll详解

epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select和poll来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。优点如下:

  1. 没有最大并发连接的限制,能打开的fd上限远大于1024(1G的内存能监听约10万个端口)
  2. 采用回调的方式,效率提升。只有活跃可用的fd才会调用callback函数,也就是说 epoll 只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。
  3. 内存拷贝。使用mmap()文件映射内存来加速与内核空间的消息传递,减少复制开销。


epoll对文件描述符的操作有两种模式:LT(level trigger,水平触发)和ET(edge trigger)。

  • 水平触发:默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件。

  • 边缘触发:当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时通知一次)。

ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。

Linux中提供的epoll相关函数接口如下:

#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_create函数创建一个epoll句柄,参数size表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1。
epoll_ctl函数注册要监听的事件类型。四个参数解释如下:
 epfd表示epoll句柄;
 op表示fd操作类型:EPOLL_CTL_ADD(注册新的fd到epfd中),EPOLL_CTL_MOD(修改已注册的fd的监听事件),EPOLL_CTL_DEL(从epfd中删除一个fd)
 fd是要监听的描述符;
 event表示要监听的事件
epoll_event结构体定义如下:
struct epoll_event 
{ __uint32_t events;
/* Epoll events */ epoll_data_t data; /* User data variable */ }; typedef union epoll_data
{
void *ptr; int fd; __uint32_t u32; __uint64_t u64; } epoll_data_t; epoll_wait函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。 epfd是epoll句柄 events表示从内核得到的就绪事件集合 maxevents告诉内核events的大小 timeout表示等待的超时事件

上述三个系统调用的实际实例可参考IO多路复用:select、poll、epoll示例Linux高性能服务器编程

3 小结

epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。

猜你喜欢

转载自www.cnblogs.com/tianzeng/p/9397048.html