首页 【译】Linux IO多路复用 - Select VS Poll VS Epoll
文章
取消

【译】Linux IO多路复用 - Select VS Poll VS Epoll

原文链接https://devarea.com/linux-io-multiplexing-select-vs-poll-vs-epoll/#.X7zGamgzaUk

Linux(实际Unix)的一个基本的概念是 Unix/Linux 中一切皆文件。每个进程都有一个指向磁盘文件、socket、硬件设备和其他操作系统对象的文件描述符表。

典型的运行着众多IO来源的系统有一个初始化阶段,进入某种待机模式,等待任意一个客户端来发送请求然后响应它。

img

一个简单的方案是为每个客户端创建一个线程(或进程),阻塞读取,直到有请求被发送和响应。这对于少量的客户端来说是可行的,但如果我们想扩展到上百的客户端,给每个客户端创建一个线程是个非常糟糕的主意。

IO 多路复用

解决方案是使用一种内核机制来轮询一组文件描述符。在 Linux 中有三种选择可以使用:

  • select(2)
  • poll(2)
  • epoll

上面这几种方法解决的是同一个问题,创建一组文件描述符,告诉内核你想对每个文件描述符做什么(读,写……),以及使用一个线程阻塞函数调用,直到至少一个文件描述符可操作。

Select 系统调用

select() 系统调用提供了一种实现异步IO多路复用的机制。

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

select() 调用在指定的文件描述符IO就绪,或者可选地时间超时之前会一直阻塞。

被监视的文件描述符会放到3个集合中:

  • readfds 集合中的文件描述符,用来检查数据是否已经可读。
  • writefds 集合中的文件描述符,用来检查写操作是否会无阻塞地完成。
  • exceptfds 集合中的文件描述符,用来检查异常是否发生了,或者带外数据是否可用(这些状态仅适用于socket)。

集合可能为 NULL,这种情况下,select() 不会监视那个相关的事件。

一旦成功返回,每个集合会被修改,这样集合就只包含划定的 I/O 类型的就绪文件描述符。

示例:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <wait.h>
#include <signal.h>
#include <errno.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>

#define MAXBUF 256

void child_process(void)
{
  sleep(2);
  char msg[MAXBUF];
  struct sockaddr_in addr = {0};
  int n, sockfd,num=1;
  srandom(getpid());
  /* Create socket and connect to server */
  sockfd = socket(AF_INET, SOCK_STREAM, 0);
  addr.sin_family = AF_INET;
  addr.sin_port = htons(2000);
  addr.sin_addr.s_addr = inet_addr("127.0.0.1");

  connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));

  printf("child { %d} connected \n", getpid());
  while(1){
        int sl = (random() % 10 ) +  1;
        num++;
     	sleep(sl);
  	sprintf (msg, "Test message %d from client %d", num, getpid());
  	n = write(sockfd, msg, strlen(msg));	/* Send message */
  }

}

int main()
{
  char buffer[MAXBUF];
  int fds[5];
  struct sockaddr_in addr;
  struct sockaddr_in client;
  int addrlen, n,i,max=0;;
  int sockfd, commfd;
  fd_set rset;
  for(i=0;i<5;i++)
  {
  	if(fork() == 0)
  	{
  		child_process();
  		exit(0);
  	}
  }

  sockfd = socket(AF_INET, SOCK_STREAM, 0);
  memset(&addr, 0, sizeof (addr));
  addr.sin_family = AF_INET;
  addr.sin_port = htons(2000);
  addr.sin_addr.s_addr = INADDR_ANY;
  bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
  listen (sockfd, 5);

  for (i=0;i<5;i++)
  {
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    if(fds[i] > max)
    	max = fds[i];
  }

  while(1){
	FD_ZERO(&rset);    // 清空文件描述符读集合(下简称读集合)并初始化创建,译者注
  	for (i = 0; i< 5; i++ ) {
  		FD_SET(fds[i],&rset);   // 添加文件描述符到读集合,译者注
  	}

  puts("round again");
	select(max+1, &rset, NULL, NULL, NULL);  // 只检查读就绪的文件描述符,译者注

	for(i=0;i<5;i++) {
		if (FD_ISSET(fds[i], &rset)){  // 如果文件描述符在读集合中,即读就绪,译者注
			memset(buffer,0,MAXBUF);
			read(fds[i], buffer, MAXBUF);   // 读取文件描述符中的数据到buffer,译者注
			puts(buffer);    // 打印出来,译者注
		}
	}
  }
  return 0;
}

我们用5个子进程启动,每个子进程连接并发送数据到服务端。服务端进程使用 accept(2) 来为每个客户端创建一个不同的文件描述符。select(2) 的首个参数在3个集合中应该是最大的文件描述符加1,我们可以检查最大的文件描述符编号。

main 函数中的死循环创建了一组文件描述符,调用 select 然后检查哪个读就绪。 为了简化说明,我没有添加异常检查。

一旦返回,select 修改集合为只包含就绪的文件描述符,这样我们需要每次迭代都再次创建集合。

之所以我们需要告诉 select 哪个是编号最大的文件描述符是因为 fd_set 的内部实现。文件描述符使用 bit 来定义,所以 fd_set 是32位整数数组(32*32bit=1024bits)。这个函数通过检查任意 bit 来检查其集合是否达到了上限。这意味着如果我们有5个文件描述符,但是最大的编号是900,这个函数会从0到900检查所有 bit 来找到监视的文件描述符。posix 有个 select 的替代方案——pselect ,当等待时增加了一个信号掩码(可查看用户手册)。

select 小结:

  • 我们需要在每次调用前创建文件描述符集合。
  • 这个函数检查所有 bit 直到一个更大的值——\(O(n)\)。
  • 我们需要迭代完所有的文件描述符来检查 select 返回的集合中是否有该文件描述符。
  • select 主要的优点是它非常容易移植——每个类 unix 的操作系统都有。

Poll 系统调用

不像 select() ,使用了三个低效的基于位掩码的文件描述符集合,poll() 使用了一个 nfds pollfd 结构体数组。原型更简单些:

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

pollfd 结构体有和事件、返回的事件相关的不同属性,于是我们不需要每次创建。

struct pollfd {
      int fd;
      short events;
      short revents;
};

为每个文件描述符创建一个 pollfd 类型的对象,填充需要的事件。poll 返回后,检查 revents 属性。

使用 poll 修改上面的示例:

for (i = 0; i < 5; i++) {
        memset(&client, 0, sizeof(client));
        addrlen = sizeof(client);
        pollfds[i].fd = accept(sockfd, (struct sockaddr *) &client, &addrlen);
        pollfds[i].events = POLLIN;   // POLLIN事件为普通或优先级带数据可读,译者注
    }
    sleep(1);
    while (1) {
        puts("round again");
        poll(pollfds, 5, 50000);

        for (i = 0; i < 5; i++) {
            if (pollfds[i].revents & POLLIN) {  // 就绪的文件描述符可读,如果是要检测有错误的话使用 &POLLERR,译者注
                pollfds[i].revents = 0;
                memset(buffer, 0, MAXBUF);
                read(pollfds[i].fd, buffer, MAXBUF);   // 读取文件描述符中的数据,译者注
                puts(buffer);    // 打印出数据来,译者注
            }
        }
}

就像我们使用 select 时那样,我们需要检查每个 pollfd 对象来检查其文件描述符是否就绪但是我们不需要每次迭代都创建集合。

Poll VS Select

  • poll() 不需要开发者对最大文件描述符编号 +1 计算。
  • poll() 对于大数据的文件描述符更高效。想象下使用 nfds=900 的 select() 监视单个文件描述符,系统内核不得不检查每个传递的集合的每位,直到第900个。
  • select() 的文件描述符集合是固定大小的。
  • 使用 select(),文件描述符集合一等到返回就要重建,所以每个随后的调用必须重新初始化他们。poll() 系统调用将输入(events 属性)和输出(revents 属性)剥离了。
  • select() 的超时参数在返回时没有定义,移植代码需要重新初始化。这对 pselect() 来说不是问题。
  • select() 移植性更好,然而一些 Unix 系统不支持 poll()

Epoll* 系统调用

使用 selectpoll 时,我们在用户空间管理着所有东西,我们每次调用时要发送文件描述符集合,然后等待。为了增加一个 socket 我们需要将其加到集合中然后再调用 select/poll

Epoll* 系统调用能让我们在内核中创建和管理上下文。这个问题分为3步:

  • 使用 epoll_create 在内核中创建一个上下文。
  • 使用 epoll_ctl 添加文件描述符到上下文或从上下文删除文件描述符。
  • 在上下文中使用 epoll_wait 等待事件。

我们用 epoll 修改下上面示例:

/*
 epoll_event 结构体定义如下,译者注:
  struct epoll_event {
    uint32_t events;  // Epoll 事件
    epoll_data_t data;   
} __attribute__ ((__packed__));

typedef union epoll_data {
    void *ptr;
    int fd;   // 文件描述符
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;
 */
struct epoll_event events[5];
int epfd = epoll_create(10);  //epoll_create函数定义: int epoll_create (int size),size为告知内核需要监听的文件描述符数量,译者注
...
...
for (i = 0; i < 5; i++) {
    static struct epoll_event ev;
    memset(&client, 0, sizeof(client));
    addrlen = sizeof(client);
    ev.data.fd = accept(sockfd, (struct sockaddr *) &client, &addrlen); // 指定文件描述符,译者注
    ev.events = EPOLLIN;  // EPOLLIN事件为读取来自文件描述符的数据,译者注
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd,
              &ev);  //epoll_ctl函数定义:int epoll_ctl (int epfd, int op, int fd, struct epoll_event *event);译者注
}

while (1) {
    puts("round again");
    // epoll_wait函数定义:int epoll_wait (int epfd, struct epoll_event * events, int maxevents, int timeout);译者注
    nfds = epoll_wait(epfd, events, 5, 10000);  // 等待事件就绪的文件描述符,译者注

    for (i = 0; i < nfds; i++) {
        memset(buffer, 0, MAXBUF);
        read(events[i].data.fd, buffer, MAXBUF);  // 读取数据到 buffer,译者注
        puts(buffer);  // 打印数据,译者注
    }
}

我们首先创建一个上下文(参数忽略了,但必须是正数)。当一个客户端连接的时候,我们创建一个 epoll_event 对象,然后加到上下文,再然后在死循环中我们只需要等待。

Epoll VS Select/Poll

  • 我们可以在等待时添加、删除文件描述符。
  • epoll_wait 只返回就绪的文件描述符。
  • epoll 有更好的性能——\(O(1)\),而不是\(O(n)\)。
  • epoll 能够实现水平触发或边缘触发(可查看使用手册)。
  • epoll 是 Linux 特有的,所以不可移植。

译者注:

示例源码:https://github.com/dev-area/Multiplexing-IO

水平有限,翻译可能会有错误,欢迎交流指正。

本文由作者按照 CC BY 4.0 进行授权