[译文] LINUX – IO MULTIPLEXING – SELECT VS POLL VS EPOLL

1,185 阅读9分钟

原文: devarea.com/linux-io-mu…

Linux(Unix*)操作系统的一个基本的事实就是“一切皆文件”。每个进程都有一个文件描述符的表,记录了文件,sockets,打开的设备以及其他的操作系统对象。

一个拥有大量IO资源的典型系统是有一个初始阶段而后进入某种后备阶段-等待任一客户端发送的请求然后返回响应。

一个简单的解决方案是为每一个客户端(连接)创建一个线程(或者进程), 在读取的时候阻塞直到接收到一个请求然后返回一个响应。这种方式在客户端(连接)数量较少的情况下工作的很好,但如果我们想要扩展到上百个客户端,为每一个客户端创建一个连接的方式就很糟糕了。

IO 分时复用

这种方案是用一种内核机制来轮询一个文件描述符的集合。在Linux上有3种方式。

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

上述方案都遵循同一个想法,创建一个文件描述符的集合,告诉内核你想要对每一个文件描述符做什么(读,写,...)并且用一个线程阻塞在方法调用上直到至少一个文件描述符可用。

Select 系统调用

select()系统调用提供了一种机制来实现同步IO分时复用

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

一个select()调用会阻塞直到给定的文件描述符已经准备好了执行IO操作,或者直到一个可选的定时器到期了。

被监听的文件描述符被划分成了3个集合:

  • 被划分进readfds集合的文件描述符等待是否有数据可读
  • 被划分进writefds集合的文件描述符等待写操作在未阻塞的情况下是否写操作已完成
  • 被划分进集合中的文件描述符会查看是否有异常发生,或者是否有意料之外的数据到来(这种状态仅仅发生在sockets下)

一个给定的集合可以是NULL,这表示select()不会监听这种事件。

在成功返回的情况下,每一个集合都会修改自身的状态直到仅仅包含满足自身特性的文件描述符。

示例:

#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);
			puts(buffer);
		}
	}	
  }
  return 0;
}
#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);
			puts(buffer);
		}
	}	
  }
  return 0;
}

我们创建了5个子进程,每个进程连接到服务器然后向服务器发送消息。服务器端的进程使用accept(2)来为每一个客户端的连接创建不同的文件描述符。 select(2)中的第一个参数应该是3个集合中文件描述符最大的那个值加1, 这样我们就可以得到最大的fd的值了。

主循环创建了一个集合用来存放全部的文件描述符,调用select然后在返回的时候查看哪一个文件描述符可读。简单起见,我没有添加错误检查。

在返回的时候,select 修改了集合使其只包含已经准备就绪的文件描述符所以我们需要在每次遍历的时候重建这个集合。

我们需要告知select最大的文件描述符的值的原因是由内部实现fd_set造成的。每一个文件描述符被抽象成一个比特,所以fd_set是一个32个整数的数组(32 * 32bit = 1024bit)。函数会检查任何一个比特是否被置为1直到达到了最大值。这意味着如果我们有5个文件描述符但是最高的数字是900,函数也会检查从0到900的任何比特来查找需要监听的文件描述符。这里有另外一个posix的select实现- pselect,在等待的时候添加了信号掩码。

Select - 总结:

  • 我们需要在每次调用之前重建每一个集合
  • Select函数会检查最高数字前的任何比特 - O(n)的时间复杂度
  • 我们需要遍历文件描述符来检查集合中是否存在select返回的文件描述符
  • select最主要的优势就是用select实现的代码可以移植性非常好,任何一个类uninx的系统都支持。

Poll 系统调用

和select()这种低效的基于掩码表示的3个文件描述符的集合不同,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;
  }
  sleep(1);
  while(1){
  	puts("round again");
	poll(pollfds, 5, 50000);

	for(i=0;i<5;i++) {
		if (pollfds[i].revents & POLLIN){
			pollfds[i].revents = 0;
			memset(buffer,0,MAXBUF);
			read(pollfds[i].fd, buffer, MAXBUF);
			puts(buffer);
		}
	}
  }
  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;
  }
  sleep(1);
  while(1){
  	puts("round again");
	poll(pollfds, 5, 50000);
 
	for(i=0;i<5;i++) {
		if (pollfds[i].revents & POLLIN){
			pollfds[i].revents = 0;
			memset(buffer,0,MAXBUF);
			read(pollfds[i].fd, buffer, MAXBUF);
			puts(buffer);
		}
	}
  }

就像我们在select做的一样,我们需要检查每一个pollfd对象来查看他的文件描述符是否已经准备就绪了,但是我们不必去在每一次遍历中重新初始化这个集合。

Poll 对比 Select

  • poll() 不需要用户去计算最大的文件描述符的值然后加1
  • poll() 对值较大的文件描述符更加高效。想象一下通过select监听一个值为900的文件描述符-这需要内核去检查集合中的每一个比特直到第900个。
  • select()的文件描述符集合大小是静态的(32 * 32 = 1024, 不可修改)。
  • 在select()模式下,文件描述符的集合会在每次返回的时候重建(初始化),所以每次后续的调用都要重新初始化这些文件描述符。poll()系统调用将输入事件(events 字段)和输出(revents字段)事件分离开来,使得数组可以在无需修改的情况下重复使用。
  • The timeout parameter to select() is undefined on return. 编写的(可移植)代码需要重新初始化这个参数。这在pselect()中并不是一个问题。
  • select()可移植性更高,一些unix系统并不支持poll().

Epoll 系统调用

当使用select或者poll的时候我们需要在用户空间管理一切。我们需要在每一次调用的时候传入文件描述符集合然后等待。为了加入其它socket我们需要 先将其加入集合然后再次调用select/poll。

Epoll系统调用帮助我们创建并且在内核空间管理“上下文”。我们将这个过程分成3步:

  1. 使用epoll_create在kernel中创建一个“上下文”
  2. 使用epoll_ctl从“上下文”中添加或者删除文件描述符
  3. 使用epoll_wait在“上下文”中等待事件发生

让我们使用epoll将上述示例改写一下:

  struct epoll_event events[5];
  int epfd = epoll_create(10);
  ...
  ...
  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;
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
  }
  
  while(1){
  	puts("round again");
  	nfds = epoll_wait(epfd, events, 5, 10000);
	
	for(i=0;i<nfds;i++) {
			memset(buffer,0,MAXBUF);
			read(events[i].data.fd, buffer, MAXBUF);
			puts(buffer);
	}
  }
  struct epoll_event events[5];
  int epfd = epoll_create(10);
  ...
  ...
  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;
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
  }
  
  while(1){
  	puts("round again");
  	nfds = epoll_wait(epfd, events, 5, 10000);
	
	for(i=0;i<nfds;i++) {
			memset(buffer,0,MAXBUF);
			read(events[i].data.fd, buffer, MAXBUF);
			puts(buffer);
	}
  }

我们首先创建了一个“上下文”(参数被省略了但是应该是一个正数)。当一个客户端的连接到来我们创建一个epoll_event对象并且将其加入“上下文”中,然后在无限循环中我们只需在“上下文”上等待。

Epoll vs Select/Poll

  1. 我们可以在等待的时候添加或是删除文件描述符
  2. epoll_wait只返回准备就绪的文件描述符
  3. epoll有更好的表现-O(1)的时间复杂度而不是O(n)
  4. epoll在水平触发和边缘触发中表现都很好
  5. epoll是linux特有的,所以不可移植