0%

select/poll/epoll学习

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执行结束之后才能之后后面的程序
    在这里插入图片描述

    2. 非阻塞式IO

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

特点:

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

    3. IO复用

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

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

特点:

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

IO复用的实现有select/poll/epoll,后面会详细说
在这里插入图片描述

4. 信号驱动 I/O

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

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

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

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

特点:

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

在这里插入图片描述

5. 异步IO

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

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

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

特点:

  • 数据准备阶段:非阻塞
  • 数据从内核buffer复制到用户态buffer阶段:非阻塞
  • 相当于把IO操作给委派出去了,所以自己完全不会被阻塞
    在这里插入图片描述

五大 I/O 模型比较

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

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

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

非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。
在这里插入图片描述

IO复用——select/poll/epoll

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

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

1. 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
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的时候,需要将存储文件描述符状态信息的bitmap从内核态复制到用户态,这一复制过程开销也比较大.

2. poll

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

  • 对应的文件描述符
  • 被监听的事件类型
  • 事件是否发生的一个标志位
    1
    2
    3
    4
    5
    struct pollfd {
    int fd; // 对应的文件描述符
    short events; // 被监听的事件类型
    short revents; // 事件是否发生的一个标志位
    };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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模型,因此跨平台性较差
  • 依然存在遍历所有事件 和 内核态数据复制到用户态的问题(用户态到内核态复制的数据,之前在select里面是一个bitmap,而在poll里面,是一个结构体数组)

3. epoll

3.1 epoll_create

1
int epoll_create(int size);

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

3.1 epoll_ctl

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

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

3.1 epoll_wait

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

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

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
 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) >
// 直接把需要监听的sockfd的信息写到了上面申请的那块内存区域中...
// 因为上面的那块内存区域是内核态和用户态共享的,所以省去了 用户态到内核态的复制开销!!!!
// ps: 这里多说两句,这里的epoll_ctl实际上是“注册新的描述符或者是改变某个文件描述符的状态”
// 已经注册过的描述符以红黑树的形式被存储维护起来, 当有数据准备好时,会通过硬中断进行一个回调,
// 把准备好了数据对应的那个socketfd给加入到一个链表中,
// 然后后面进行epoll_wait的时候,实际上就是获取这个链表,因为这个链表存储的就是已经准备好的数据的socketfd
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}

while(1){
puts("round again");
// 等待正在监听的事件发生
// 若返回-1,则说明在监听时出现了中断或错误
// 若返回0,则说明超时,没有事件发生
// 若大于0,则表示正在监听的所有事件中,发生了的事件个数,并且会把发生了的事件放到
// 传入的这个events数组中
// ps: 这里的监听-复制,是使用硬中断来做的
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
  • 因为申请的内存是内核态用户态共享的一块内存,所以在注册需要监听的socketfd信息的时候,省去了一次用户态->内核态的开销

缺点:

  • 只有linux上实现了epoll

  • epoll小结

  1. 使用epoll_create申请一块用户态和用户态共享的一块内存
  2. 使用epoll_ctl来注册一个socket文件描述符,或是更改一个socket文件描述的状态,注册的文件描述符信息保存在epoll_create申请的那块内存中,并且文件描述符是被维护在一个红黑树上。因为这块内存是用户态和内核态共享的,所以才能够省去一次用户态复制到内核态的开销。
  3. socket文件描述符被注册到了共享内存中了之后,当事件来临时,例如数据已经准备完毕了,会触发一个中断,会将这个已经发生了的事件(数据已经准备好了)对应的socketfd给加入到一个链表中尽心管理
  4. 在调用epoll_wait的时候,实际上就是从这个链表中,获取事件完成的描述符。 因此,epoll_wait将返回事件完成的个数(假设为k, k>0),并且传入的events数组的前k个就是事件已经发生了的对应的描述符,因此能够减少O(N)的遍历,从而变成O(K)的遍历.

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 的描述符存储在内核,不容易调试。


参考资料

IO多路复用select/poll/epoll介绍
CS-Note
讲解nio,epoll,多路复用