Ref

原文:LINUX – IO MULTIPLEXING – SELECT VS POLL VS EPOLL

正文

对于Unix(Linux)系统我们有一个基本的设定:系统中的任何对象都是个文件everything in Unix/Linux is a file。每个进程都维护了指向文件、socket、设备以及其他对象的描述指针列表。

IO资源处理的基本模式:

  • 资源有一个初始化阶段
  • 接着进入待命模式
  • 等待客户端处理请求、响应

最简单的实现是:为每个客户端都创建一个线程(或者进程),阻塞一直等到请求过来并且把响应发出去。客户端少的时候这个方式是可行的,但是一旦我们想拓展到成百上千请求时,这个方案就很低效了。

Unix内核中取出一堆文件描述符的机制主要有主流的三种思路:

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

三个方法目标一致:

  • 创建一个文件描述符集合
  • 告诉内核每个描述符对应的操作(读还是写)
  • 使用一个线程阻塞在函数调用上,直到有可处理的操作返回

Select系统调用

select()函数实现了同步多播multiplexing I/O。

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

select()调用后会一直阻塞等待,直到文件描述符可以处理此事件,或者超时停止。

被监听的描述符有三个状态:

  • readfds set监听读事件
  • writefds set监听写事件
  • exceptfds set监听异常事件,负责处理异常或者out-of-band带外数据(只有网络socket会有)

上述状态集合可以为NULL,此时select()不做处理。

事件成功返回后,集合更新状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#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;
}

示例中我们创建了五个子进程,每个进程连接到服务端并且发送消息。服务端进程使用accept(2)为每个客户端创建一个不同的文件描述符。select(2)中的第一个参数是三个集合中最大的数字。

主循环中创建一组文件描述符,调用select(2)有结果返回时检查是否可读。这里为了简化代码未做异常检查。

有结果返回的时候,select改变了集合,只包含就绪的描述符。因此每次迭代我们需要重置set。

这里我们需要告诉select函数最大的文件描述符数字的原因是fd_set内部实现决定的。每个文件描述符声明为一位bit,所以fd_set是个int32的数组。函数内判断当前集合是否达到了最大值。比如我们有五个文件描述符但是最大值是900,函数就会监听0-900的任意位。POSIX(可移植操作系统接口)中提供了pselect这个选项,这个等待的时候增加了一个信号掩码。

小结select

  • 每次调用前需要创建描述符集合
  • 函数会检查最大值
  • 我们需要遍历每个文件描述符,以此来检查是否有数据就绪待处理
  • select主要的优势是可移植,每个Unix系统上都有对应实现

Poll系统调用

select()中低效的三位掩码文件描述符集合不同,poll()使用了一个nfds pollfd的数组结构,方法定义更加简洁:

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

pollfd结构中定义了不同事件的字段以及响应事件的字段:

1
2
3
4
5
struct pollfd {
      int fd;
      short events; 
      short revents;
};

我们使用的时候也很简单,为每个fd都创建一个pollfd对象,放到对应事件中,返回时检查对应事件对象。代码实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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对象看是否有数据就绪,但是每轮迭代我们不需要创建fd集合了。

Poll对比Select

  • poll不需要用户这端维护当前读到的描述符指针数,fd+1
  • poll在处理更多fd的时候更加有效率。
  • select的fd集合时静态声明大小的
  • poll入参使用数组传递,内部对象可以重用,无需每次创建
  • select的超时参数在返回的时候是未定义的,我们需要自己编码来重新初始化它。而pselect则没有这个问题
  • select更加轻便,因为有些系统不支持poll

Epoll系统调用

使用select与poll的时候,我们在自己的代码中管理状态,并且我们需要在等待调用的时候需要传递fd集合。如果需要添加一个新的socket事件,我们就需要把它传递给fd集合并且再调用一次select或者poll。

而Epoll可以帮助我们创建、管理内核上下文。我们把一个任务的步骤分成三步:

  1. 使用epoll_create创建一个内核上下文
  2. 使用epoll_ctl将fd(文件描述符)从上下文添加或者移除
  3. 使用epoll_wait等待事件

epoll的代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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);
	}
  }

首先我们创建了epfd上下文(参数必须为正数)。当有客户端连接时,我们创建epoll_event对象,并且把它丢到上下文中,在主循环中我们等待其返回响应。

Epoll vs Select/Poll

  • 我们在等待的时候可以添加或者移除fd
  • epoll_wait只会返回就绪的fd
  • epoll有更好的性能:O(1)
  • epoll只有Linux实现

小结

可以看到epoll的实现更加高效,用户端使用也更加便捷。

三者作为操作系统不同的IO多路复用的实现,可以总结为以下区别与联系。

区别:

  • 连接数量限制:
    • select:在大多数系统中,最大可同时监控的文件描述符数量有限制,通常为1024(取决于系统配置)。
    • poll:理论上,poll没有固定的最大文件描述符数量限制,但受限于系统资源和内存大小。
    • epoll:同样没有固定的最大文件描述符数量限制,只受限于系统内存。
  • 数据结构和效率:
    • select:使用一个由用户空间传递到内核空间的固定大小的数组来存储需要监控的文件描述符集合,每次调用都需要进行全集扫描,效率较低。
    • poll:与select类似,但使用链表存储文件描述符,避免了固定大小数组的限制,但仍需要全集扫描。
    • epoll:引入了水平触发和边缘触发两种模式,并且内核维护了一个红黑树来存储文件描述符, epoll_wait() 只返回就绪的文件描述符,不需要全集扫描,大大提高了效率。
  • 通知机制:
    • select 和 poll:在轮询过程中,需要遍历所有文件描述符来检查是否有事件发生。
    • epoll:提供了 epoll_ctl() 函数,可以注册和修改对特定文件描述符的关注事件,当有事件发生时,epoll会通过回调函数或者返回就绪的文件描述符来通知用户空间,减少了不必要的上下文切换。
  • 操作方式:
    • select 和 poll:都需要在每次调用时将全部关注的文件描述符集合传递给系统调用。
    • epoll:只需要在初始化时创建一个epoll实例,然后通过epoll_ctl()添加或删除关注的文件描述符,epoll_wait()则用于等待事件发生。

联系:

  • 它们都是为了实现I/O多路复用,提高网络编程的效率和性能。
  • 都支持水平触发(Level Triggered,只要有数据可读/写,就会一直通知)模式。
  • 在Linux系统中,epoll是作为select和poll的替代品出现的,旨在解决它们在处理大量并发连接时的效率问题。

总的来说,epoll在处理大量并发连接和高负载场景下具有更高的性能和效率,而select和poll更适合连接数量相对较小的情况。

类比

本质上我对IO这个场景可以类比到饭店服务上。

我们将一个饭店的工作人员细化分开:

  1. 迎客员(两个)
  2. 服务员(三个)
  3. 厨师(十个)

迎客员就是做了IO事件监听的事,只需要少量人员即可完成事件的循环、监听动作,而服务员的任务相对重一点,耗时多一点,而我们用户的程序则是厨师的角色,用于响应IO处理,做菜耗时比服务点菜、迎宾要多,所以在顾客多的时候(IO多)我们的厨师需要多一点,但是迎客员只需要少量即可应付任务。

不同的角色分开,是职责上解耦的设计,厨师不必一个人顾及前端IO的事件状态变化,可以更专注的后端逻辑的处理。

看了多路复用,可以多对比一下AIO。