欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

select/poll/epoll学习

程序员文章站 2022-06-13 12:58:57
...

IO模型

一次网络IO会涉及两个系统对象:

  1. 等待数据准备好
  2. 将数据从内核空间的buffer拷贝到用户空间进程的buffer
    而这五种IO模型的特点就在于以怎样的方式来处理这两个系统对象和两个阶段.

Unix 有五种 I/O 模型:

  1. 阻塞式 I/O
  2. 非阻塞式 I/O
  3. I/O 复用(select 和 poll)
  4. 信号驱动式 I/O(SIGIO)
  5. 异步 I/O(AIO)

1. 阻塞式IO

应用进程被阻塞,直到数据从内核buffer复制到应用进程buffer中才返回。

特点:

  • 在准备数据阶段:被阻塞
  • 数据从内核buffer复制到用户态buffer阶段:被阻塞
  • recvfrom执行结束之后才能之后后面的程序
    select/poll/epoll学习

2. 非阻塞式IO

用户态程序执行IO调用后,无论IO是否完成,都会立刻返回结果,应用程序需要不断的执行这个系统调用去获知IO是否完成。(注意,这里会返回IO是否已经完成的状态,而不是数据是否准备好)

特点:

  • 在数据准备阶段:非阻塞式(会立刻返回一个错误码)
  • 数据从内核buffer复制到用户态buffer阶段:阻塞式
  • recvfrom会立刻返回结果,一般会用一个循环来不停的去判断IO是否完成。
  • 实时性会比较好,但CPU利用率比较低
    select/poll/epoll学习

3. IO复用

让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。

如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。

特点:

  • 数据准备阶段:阻塞,并会返回一个“事件已经发生的信号”(这里的事件就是数据已经准备好了)
  • 数据从内核buffer复制到用户态buffer阶段:阻塞

IO复用的实现有select/poll/epoll,后面会详细说
select/poll/epoll学习

4. 信号驱动 I/O

个人理解 信号驱动IO = 事件驱动机制 + 非阻塞式IO

信号驱动IO是指:进程预先告知内核,使得 当某个socketfd有events(事件)发生时,内核使用信号通知相关进程。

因此通知完了之后,并不会被阻塞。

当内核通知相关进程,它感兴趣的事件发生了的时候(这里就是数据已经准备好了),然后再去做recvfrom,将数据从内核态复制到用户态。

特点:

  • 数据准备阶段:非阻塞式。(因为只是向OS发送一个通知,立刻就返回了)
  • 数据从内核buffer复制到用户态buffer阶段:阻塞式。
  • 相比于前面的非阻塞式IO的轮询,信号驱动IO的CPU利用率更高

select/poll/epoll学习

5. 异步IO

理解异步IO,先要理解异步,通俗来说就是,只要我触发了一IO调用,那么这个IO在这之后的任何一个时刻完成对我的程序都不会有影响,因此我就没必要非等你IO完了才继续往下执行,而是利用“委派”的思想,让内核去帮我完成.

因此,在调用完异步IO的系统调用(例如aio_read)之后,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。

注意,异步IO也是 “事件驱动 + 非阻塞” , 但它和信号驱动IO的区别是, 异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

特点:

  • 数据准备阶段:非阻塞
  • 数据从内核buffer复制到用户态buffer阶段:非阻塞
  • 相当于把IO操作给委派出去了,所以自己完全不会被阻塞
    select/poll/epoll学习

五大 I/O 模型比较

  • 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
  • 异步 I/O:第二阶段应用进程不会阻塞。

同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,

它们的主要区别在第一个阶段。

非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。
select/poll/epoll学习

IO复用——select/poll/epoll

这三种对IO复用的实现方式的区别主要在于:

  • 对socketfd的存储方式
  • 以怎样的方式去通知用户级进程去获取已经发生的事件(或者说用户级进程用怎样的方式去获取已经放发生的事件)

1. select

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); 
  // ==================从这里开始=====================
  // 初始化rset(32个长整型 = 一个1024的bitmap)
  // 主要目的是获取最大的文件描述符
  // 将打开的文件描述符fds_i对应的位置为1(linux默认最多打开1024个文件,因此这个文件描述符小于1024)
  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");
   	// 在这里会阻塞,当有事件发生时,会修改rset的值,
   	// 即发生事件的对应bit会被置位1,其他都被置为0
   	// 因此,每循环一轮都要对rset重新初始化一次
	select(max+1, &rset, NULL, NULL, NULL); 
 
 	// 遍历所有监听的文件描述符,若被置位了,那么就去进行IO
	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;
}

优点:

  1. 每个操作系统都实现了select模式,可移植性较强
  2. 在一些监听事件个数比较少的情况下,也比较优秀

缺点:

  1. 因为对文件描述符的存储是一个1024的bitmap, 所以一个进程最多监听的事件个数被限制。
  2. 因为select每次都会对传入的readset进行一个修改,所以在每次轮询的时候,都要重新进行一次初始化,这也是比较低效的
  3. 事件监听的种类是通过传入fd_set类型的参数来进行的,因此不太好扩展.
  4. 每次都需要遍历所有的文件描述符才能知道事件发生的是哪一个socket,如果监听的事件比较多,且只有一个事件发生,那么也需要遍历全部,这样显然是很低效的。
  5. 在进行IO的时候,需要将数据从内核态复制到用户态,这一复制过程开销也比较大.

2. poll

poll的实现大体上和select类似,只是改变了对事件的存储形式,不再使用fd_set, 而是使用一个结构体来保存. 这个结构体中保存的数据主要有:

  • 对应的文件描述符
  • 被监听的事件类型
  • 事件是否发生的一个标志位
struct pollfd {
      int fd; // 对应的文件描述符
      short events; // 被监听的事件类型
      short revents; // 事件是否发生的一个标志位
};
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的监听事件个数存在上限问题,和每处理一轮请求需要重新初始化的问题。

缺点:

  • 并不是所有系统都实现了poll模型,因此跨平台性较差
  • 依然存在遍历所有事件 和 内核态数据复制到用户态的问题

3. epoll

3.1 epoll_create

int epoll_create(int size); 

epoll_create() 该 函数生成一个epoll专用的文件描述符。它其实是在内核申请一空间,用来存放你想关注的socket fd上是否发生以及发生了什么事件。

3.1 epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

epoll_ctl() ⽤于向内核注册新的描述符或者是改变某个文件描述符的状态。

3.1 epoll_wait

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

已注册的描述符在内核中会被维护在 ⼀棵红⿊树上,通过回调函数内核会将 I/O 准备好的描述符加入到⼀个链表中管理(这个链表就是传入的第二个指针参数events),进程调⽤ epoll_wait() 便可 以得到事件完成的描述符。

  struct epoll_event events[5];
  // epfd是一个文件描述符,指向一个内核中的文件区域
  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;
    // 向epfd指向的那块区域中添加( EPOLL_CTL_ADD )一条 监听记录信息:
    // 		< 需要被监听的文件描述符 (ev.data.fd), 正在监听的事件(ev) >
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
  }
  
  while(1){
  	puts("round again");
  	// 等待正在监听的事件发生
  	// 若返回-1,则说明在监听时出现了中断或错误
  	// 若返回0,则说明超时,没有事件发生
  	// 若大于0,则表示正在监听的所有事件中,发生了的事件个数,并且会把发生了的事件放到
  	// 		传入的这个events数组中
  	// ps: 这里的监听-复制,是使用硬中断来做的(  网卡收到新数据的时候,会向cpu发送一个中断信号,然后内核会帮我们把发生的事件直接复制到events数组中,这就省去了内核态到用户态复制的一个开销 )
  	// 原本是: 监听->保存到内核态空间->复制到用户态空间->使用
  	// 现在是: 监听->直接保存到用户态空间events
  	nfds = epoll_wait(epfd, events, 5, 10000);
	
	// 读取events数组里的数据,然后进行处理
	for(i=0;i<nfds;i++) {
			memset(buffer,0,MAXBUF);
			read(events[i].data.fd, buffer, MAXBUF);
			puts(buffer);
	}
  }

优点:

  • 不需要每次遍历所有监听的events,而只用去遍历已经发生的events
  • 内核直接将准备好的数据写到了用户态,节省了将数据从内核态复制到用户态的开销

缺点:

  • 只有linux上实现了epoll

select/poll/epoll的应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。

1. select 应用场景

  1. select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。

  2. select 可移植性更好,几乎被所有主流平台所支持。

2. poll 应用场景

poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

3. epoll 应用场景

  1. 只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。

  2. 需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。

  3. 需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。 因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。