Linux Socket IO —— select/poll/epoll

582 阅读3分钟

UNIX中IO操作的两个阶段:(1) 等待数据准备就绪;(2) 数据从内核复制到用户空间。

select/poll/epoll都是IO多路复用模型,select/poll需要轮询fd是否就绪,epoll基于事件驱动,性能更高。 select使用数组保存文件描述符,每次都要把所有fd从用户空间复制到内核空间,内核遍历所有fd以判断是否准备好;poll使用链表保存文件描述符,没有了fd数量的限制;epoll使用红黑树和双向链表。

select

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

select系统调用可以让程序监控多个文件描述符(fd)。进程通过将一个或多个fd传递给select系统调用,阻塞在select操作上,直到:(1)一个或多个fd变为就绪;(2)调用被信号打断;(3)超时时间到了。

fd_set结构体中包含一个fd数组,系统提供FD_SETFD_CLRFD_ISSETFD_ZERO方法进行操作。

特点

用户通过3个参数分别传入感兴趣的可读、可写以及异常事件,内核通过修改这些参数来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数。

select采用轮询fd的方式来检测事件就绪,而且支持的fd数量有限。

select使用例子:

int main()
{
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    bind(sockfd, ...);
    listen(sockfd, ...);
    int fds[] = 存放需要监听的socket;
    while (1) {
        int n = select(..., fds, ...);
        for (int i = 0; i < fds.count; i++) {
            if (FD_ISSET(fds[i], ...)) {
                // fds[i]的数据处理
            }
        }
    }
}

poll

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd; // file descriptor
    short events; // requested events
    short revents; // returned events
};

fds是一个pollfd结构体数组,存放需要监控的socket fd。

poll类似于select,也是阻塞的。

特点

poll统一处理所有事件类型,因此只需要一个事件参数。用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中的就绪事件。

epoll

// 创建一个epoll实例,返回指向新创建的epoll实例的fd。
int epoll_create(int size);
int epoll_create1(int flags);
// 事件注册。添加、修改、删除要监控的socket和事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// op: EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL
// fd: 监听端口的socket对应的fd
// events: EPOLLIN/EPOLLOUT/EPOLLRDHUP/EPOLLPRI/EPOLLERR/EPOLLHUP/EPOLLET/EPOLLONESHOT/EPOLLWAKEUP
// 等待事件产生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// events
// maxevents本次可以返回的最大事件数目

创建epoll实例就是在内核申请一块空间(eventpoll结构体),其中包含了一个红黑树和双向链表rdlist。红黑树中存放要监控的fd事件,链表中存放就绪的fd事件。

struct eventpoll {
    struct rb_root rbr;
    struct list_head rdlist;
}

通过epoll_ctl来注册、修改、删除要监控的socket fd和事件类型,也就是把这些参数交给内核来维护红黑树。

epoll_wait等待事件的发生,然后将rdlist中的内容从内核空间拷贝到用户空间events数组中。

epoll使用例子:

imt main() {
    // 获取监听的socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    // 绑定端口
    bind(listenfd, addr, addrlen);
    // 开始监听
    listen(listenfd, 20);
    // socket设置为nonBlock的
    setNonBlock(listenfd);
    // 创建epoll并添加监听事件
    int epollfd = epoll_create(1);
    epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev); // ev是sockfd的EPOLLIN事件
    // 处理事件
    for (;;) {
        struct epoll_event activeEvs[100];
        int n = epoll_wait(epollfd, activeEvs, MAX_EVENTS, waitms);
        for (int i = 0; i < n; i++) {
            int fd = activeEvs[i].events;
            if (events & (EPOLLIN | EPOLLERR)) {
                if (fd == listenfd) { // 监听socket
                    // handleAccept
                    int connectfd = accept(fd, raddr, rsz);
                    setNonBlock(connectfd);
                    epoll_ctl(epollfd, EPOLL_CTL_ADD, connectfd, &ev); // ev是connectfd的EPOLLIN事件
                } else { // 普通socket
                    handleRead(epollfd, fd);
                }
            } else if (events & EPOLLOUT) {
                handleWrite(epollfd, fd);
            }
        }
    }
}

监听socket是服务端用来处理连接请求的socket;普通socket是已建立好连接的socket。 通过socket()函数创建的监听socket,本质就是一个文件描述符,对该文件描述符的IO操作方式分为阻塞和非阻塞。

水平触发和边缘触发

epoll除了提供select/poll那种IO事件的水平触发LT外,还提供了边缘触发ET。epoll默认的模式是LT。

水平触发(也叫条件触发):当epoll_wait检测到某fd事件就绪时会通知应用程序,应用程序可不立即处理该事件,下次调用epoll_wait时,会再次通知。

边缘触发:当epoll_wait检测到某fd事件就绪时会通知应用程序,如果应用程序未作处理,下次调用epoll_wait时不会再次通知。 ET模式下只能使用非阻塞IO,因为ET模式下每次都需要循环write或read直到返回EAGAIN错误,如果使用阻塞IO会导致程序阻塞在最后一次write或read。

速记:LT-条件满足则一直触发;ET-由不满足变为满足才会触发一次。

数字电路中,电平触发是在高或低电平保持的事件内触发;边沿触发是由高到低或由低到高的一瞬间触发。

应用

对于监听的fd,最好使用LE,高并发情况下ET模式会导致有的客户端连接不上。 对于读写fd,ET必须配合非阻塞IO,并要求一次性地完整读写全部数据。

Nginx中监听socket是LT模式,而普通(accept)socket采用ET模式。 监听socket采用LT模式是因为ET模式高并发下可能丢失连接请求。 普通socket采用ET模式是因为在LT模式下,如果写数据量常常比较大,无法一次写完,就得频繁监听和移除EPOLLOUT事件。

EPOLLOUT: 可写
EPOLLIN: 可读