NIO select poll和epoll

套接字编程

Socket,表示进程间网络通信的特殊文件类型。本质是内核借助缓冲区形成的伪文件。Linux套接字封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致

在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程

“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

socket

一个文件描述符指向一个套接字

一个套接字内部由内核借助两个缓冲区实现,一个写缓冲、读缓冲。

在通信过程中, 套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区

sokect1

  1. socket()创建一个socket
  2. bind()为socket绑定ip+port
  3. listen()设置监听上限,即同时跟服务器建立socket连接的数量
  4. accept():阻塞监听客户端连接,创建一个新的socket用来与客户端通信
  5. connect():客户端使用现有的socket与服务器建立连接,如果不使用bind绑定客户端地址结构,采用“隐式绑定”,系统自动分配ip+port

网络IO

同步:同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。也就是说,调用会等待返回结果计算完成才能继续执行。BIO,NIO,select,poll,epoll都是同步的,只不过会有阻塞,非阻塞的区别。

异步:异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。也就是说,其实异步调用会直接返回,但是这个结果不是计算的结果,当结果计算出来之后,才通知被调用的程序。

阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。

非阻塞:不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应errno状态码,根据这个errno可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动。

BIO

即阻塞IO,一个socket对应一个线程,若无数据发送则会一直阻塞等待,造成资源浪费

特别是在JAVA环境下,线程的创建,切换,代价高昂

结构简单,适合规模小,低并发的情况

BIO

特点:面向流的,阻塞的

java1.4以前的io模型,一连接对一个线程,原始的IO是面向流的,不存在缓存的概念。面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区,流是阻塞的,这意味着当一个线程调用read或 write方法时,该线程被阻塞,直到有一些数据被读取,或数据完全写入,该线程在此期间不能再干任何事情了。

NIO

非阻塞IO,顾名思义就是所有IO操作都是立刻返回而不会阻塞当前用户进程

non-blocking-io

当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 EAGAIN error。

从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,Non-blocking I/O 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。

JAVA NIO——NEW IO

JAVA底层对于NIO的实现,配合了多路复用机制:

使用一个线程不断轮询来管理所有套接字,即一个while死循环来不断遍历,一旦发现有数据便进行处理。

做到了单线程也能管理所有套接字。这是非阻塞的,就算没有数据也不会停下来,而是返回无数据的标识,然后再进行下一次的轮询。

IO多路复用是面向缓冲区的,每个连接都有一个缓冲区来暂存数据,由管道来进行双向传输

reactor2

selector

多路复用器,底层使用了操作系统自身的多路复用系统调用——select/poll/epoll

一个selector 对应一个线程, 多个channel以事件的方式注册于selector,一个进程便可以处理多个连接

selector 会在各个通道上切换,只有在连接/通道真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程,避免了多线程之间的上下文切换导致的开销

channel

通道类似于流,但有些区别如下:

  1. 通道可以同时进行读写,而流只能读或者只能写
  2. 通道可以实现异步读写数据
  3. 通道可以从缓冲读数据,也可以写数据到缓冲

channel 是双向的, Channel封装了对数据源的操作,数据源可以看成是操作系统的文件描述符,用于提供平台独立操作功能,数据源的底层就是磁盘、网卡等IO设备

buffer

缓冲区,对应操作系统的用户空间中的用户缓冲区(user buffer)。Java程序在读I/O流时,需要先将数据读取到Buffer中,写I/O流时,需要先将数据写到Buffer中。JDK提供了IntBufferLongBufferCharBufferByteBuffer等多种针对基础数据类型的Buffer;

channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer

和BIO不同 , BIO 中要么是输入流,或者是输出流,不能双向,但是NIO的Buffer 是可以读也可以写

特点:面向块,非阻塞

NIO是面向缓冲区的。数据被读取到缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性。

Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。

NIO是可以做到用一个线程来处理多个操作的。selector会不断循环监听channel,如果通道中没有数据即没有请求时它可以去处理别的通道或者做其他的事情,如果通道中有数据它就会选择这个通道然后进行处理,实现了一个线程处理多个连接。

I/O 多路复用

所谓 I/O 多路复用指的就是 select/poll/epoll 这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知。

I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个 thread能够处理多个连接(I/O 事件)。

select

select 是 epoll 之前 Linux 使用的 I/O 事件驱动技术。

select监听文件描述符的数据结构是一个位图,即fd_set

如果取 fd_set 长度为 1 字节,fd_set 中的每bit可以对应一个文件描述符fd,则1字节长的fd_set最大可以对应 8 个fd

  1. 执行FD_ZERO(&set), 则 set 用位表示是 0000,0000
  2. 若 fd=5, 执行 FD_SET(fd, &set); 后 set 变为 0001,0000(第 5 位置为 1)
  3. 再加入 fd=2, fd=1,则 set 变为 0001,0011 执行 select(6, &set, 0, 0, 0) 阻塞等待
  4. 若 fd=1, fd=2 上都发生可读事件,则 select 返回,此时 set 变为 0000,0011 (没有事件发生的 fd=5 被清空)

可监控的文件描述符个数取决于 sizeof(fd_set) 的值。假设服务器上 sizeof(fd_set)=512,每 bit 表示一个文件描述符,则服务器上支持的最大文件描述符是 512*8=4096。

执行select系统调用后,内核会返回一个整数,若小于零,代表没有任何数据或请求到达,若大于零,代表可以进行处理。若等于零,代表等待超时。

每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

fd_set的大小是有限制的,即单个进程能维护的套接字的大小有限,32位为1024,64位机默认是2048。可以修改。

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
while (1) {
read_fd_set = all_fd_set;
printf("阻塞中... lastfd=%d\n", lastfd);
//获取标志位
int nready = select(lastfd+1, &read_fd_set, NULL, NULL, NULL);
switch (nready) {
case 0 :
printf("select time out ......\n");
break;
case -1 :
perror("select error \n");
break;
default:
// 监听到新的客户端连接
if (FD_ISSET(lfd, &read_fd_set)) {
struct sockaddr_in client_addr;
socklen_t cliaddr_len = sizeof(client_addr);
char cli_ip[INET_ADDRSTRLEN] = "";
// 肯定有连接不会阻塞
int clientfd = accept(lfd, (struct sockaddr*)&client_addr, &cliaddr_len);
inet_ntop(AF_INET, &client_addr.sin_addr, cli_ip, INET_ADDRSTRLEN);
printf("----------------------------------------------\n");
printf("client ip=%s,port=%d\n", cli_ip, ntohs(client_addr.sin_port));
// 将clientfd加入读集合
FD_SET(clientfd, &all_fd_set);
lastfd = clientfd;
if(0 == --nready) {
continue;
}
}
int i;
//遍历所有的fdset来处理事件
for (i = lfd + 1;i <= lastfd; i++) {
// 处理读事件
if (FD_ISSET(i, &read_fd_set)) {
char recv_buf[512] = "";
int rs = read(i, recv_buf, sizeof(recv_buf));
if (rs == 0 ) {
close(i);
FD_CLR(i, &all_fd_set);
} else {
printf("%s\n",recv_buf);
// 给每一个服务端写数据
int j;
for (j = lfd + 1;j <= lastfd; j++) {
if (j != i) {
write(j, recv_buf, strlen(recv_buf));
}
}
}
}
}
}

}

poll

poll 的实现和 select 非常相似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而不是 select 的 fd_set 结构,poll 解决了最大文件描述符数量限制的问题,但是同样需要从用户态拷贝所有的 fd 到内核态,也需要线性遍历所有的 fd 集合,所以它和 select 只是实现细节上的区分,并没有本质上的区别。

epoll

select&poll 错误预估了一件事,当数十万并发连接存在时,可能每一毫秒只有数百个活跃的连接,同时其余数十万连接在这一毫秒是非活跃的。当监控的文件描述符数量很多时,就会造成效率损失。

与前面所有的轮询机制不同,epoll使用的是事件驱动

即当发生事件时,每个fd上有注册有回调函数,当网卡接收到数据时会回调该函数,同时将该fd的引用放入rdlist就绪链表中。

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。

如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

避免了遍历所有的fd,只检查发生事件的fd

1
2
3
4
5
6
7
8
9
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
struct eventpoll {
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
};

执行流程

  1. 调用epoll_create()创建一个ep对象,在内核空间中开辟了一块空间存储eventpoll,返回一个文件句柄,即红黑树的根节点
  2. 调用epoll_ctl()向红黑树中添加、删除、修改fd
  3. 调用epoll_wait()等待,当有事件发生时网卡驱动会调用fd上注册的回调函数并将该fd添加到rdlist中。epoll_wait 实际上就是去检查 rdllist 双向链表中是否有就绪的 fd,当 rdllist 为空(无就绪 fd)时挂起当前进程,直到 rdllist 非空时进程才被唤醒并返回。

epoll

总结:

  1. EPOLL支持的最大文件描述符上限是整个系统最大可打开的文件数目, 1G内存理论上最大创建10万个文件描述符
  2. 每个文件描述符上都有一个callback函数,当socket有事件发生时会回调这个函数将该fd的引用添加到就绪列表中,select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可
  3. select、poll采用轮询的方式来检查所有文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多
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
//开辟内核空间,返回文件描述符
int epfd = epoll_create(1);
event.data.fd = lfd;
event.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &event);
while (1) {
printf("阻塞中....\n");
//从事件集中获取文件描述符
int nready = epoll_wait(epfd, events, 20, -1);
int i;
for (i = 0; i < nready; ++i) {
// 监听到新的客户端连接
if (events[i].data.fd == lfd) {
struct sockaddr_in client_addr;
socklen_t cliaddr_len = sizeof(client_addr);
char cli_ip[INET_ADDRSTRLEN] = "";
// 肯定有连接不会阻塞
int clientfd = accept(lfd, (struct sockaddr*)&client_addr, &cliaddr_len);
inet_ntop(AF_INET, &client_addr.sin_addr, cli_ip, INET_ADDRSTRLEN);
event.data.fd = clientfd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &event);

printf("----------------------------------------------\n");
printf("client ip=%s,port=%d\n", cli_ip, ntohs(client_addr.sin_port));
} else {
char recv_buf[512] = "";
int rs = read(events[i].data.fd, recv_buf, sizeof(recv_buf));
if (rs < 0) {
close(events[i].data.fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, &event);
continue;
}
printf("%s\n",recv_buf);
}
}
}

C10K问题

服务器如何支持10k个并发连接?

为每个连接分配一个独立的线程/进程

这一思路最为直接。但是,由于申请进程/线程会占用相当可观的系统资源,同时对于多进程/线程的管理会对系统造成压力,因此,这种方案不具备良好的可扩展性。这一思路在服务器资源还没有富裕到足够程度的时候,是不可行的。即便资源足够富裕,效率也不够高。

总之,此思路技术实现会使得资源占用过多,可扩展性差,在实际应用中已被抛弃。

IO多路复用

  1. 循环逐个处理各个连接,每个连接对应一个 socket。当所有 socket 都有数据的时候,这种方法是可行的。但是,当应用读取某个 socket 的文件数据不 ready 的时候,整个应用会阻塞在这里,等待该文件句柄ready,即使别的文件句柄 ready,也无法往下处理。任一文件句柄的不成功会阻塞住整个应用。
  2. 使用select方法解决上面阻塞的问题。在读取文件句柄之前,先查下它的状态,如果ready 了,就进行处理;如果不 ready, 就不进行处理;于是,有了 select 方案。用一个 fd_set 结构体来告诉内核同时监控多个文件句柄,当其中有文件句柄的状态发生指定变化(例如某句柄由不可用变为可用)或超时,则调用返回。同时,在使用上,因为只有一个字段记录关注和发生事件,所以每次调用之前,要重新初始化fd_set结构体。并且因为监听的是所有fd,所以当fd过多时,检查状态就会很慢
  3. poll 和select原理基本相同,不过消除了文件句柄上限。使用不同字段分别标注“关注事件和发生事件”,来避免重复初始化。
  4. 使用epoll,“poll逐个排查所有文件句柄状态”效率不高。如果在调用返回的时候,只给应用提供发生了状态变化(很可能是数据 ready)的文件句柄,进行排查的效率就高很多。epoll 采用了这种设计,适用于大规模的应用场景。实验表明:当文件句柄数目超过10之后,epoll 性能将优于 select 和 poll;当文件句柄数目达到 10K 的时候,epoll 已经超过 select 和 poll 两个数量级。

C10M:服务器如何支持10M个并发连接?

内核是问题所在,要高效地去除阻塞,让CPU更多地处理核心任务。不要让内核执行所有繁重的任务。将数据包处理、内存管理、处理器调度等任务从内核转移到应用程序,由应用程序高效地完成。让Linux只处理控制层,数据层完全交给应用程序来处理。

当连接很多时,首先需要大量的进程/线程。同时,系统中的应用进程/线程可能大量地都处于 ready 状态,需要系统不断地进行快速切换,系统的上下文的切换是有代价的。

所以我们面临的瓶颈有两个:一个是进程/线程作为处理单元,还是太厚重了;另一个是系统调度的代价太高了。

如果有一种更轻量级的进程/线程作为处理单元,而且它们的调度可以做到很快,也许就能解决问题,这就是协程

协程

协程在实现上都是试图用一组少量的线程来实现多个任务,一旦某个任务阻塞,则可能用同一线程继续运行其他任务,避免大量上下文的切换。每个协程所独占的系统资源往往只有栈部分。各个协程之间的切换,往往是用户通过代码来显式指定的(跟各种callback 类似),不需要内核参与,可以很方便实地现异步。

这个技术本质上是异步非阻塞技术,它是将事件回调进行了包装,让程序员看不到里面的事件循环。程序员就像写阻塞代码一样简单。

比如调用 client->recv() 等待接收数据时,就像阻塞代码一样写。实际上是,底层库在执行recv时悄悄保存了一个状态,比如代码行数、局部变量的值。然后,就跳回到EventLoop中了。什么时候真的数据到来时,它再把刚才保存的代码行数、局部变量值取出来,又开始继续执行。

这就是协程的本质。协程是异步非阻塞的另外一种展现形式。Golang、Erlang、Lua协程都是这个模型。


NIO select poll和epoll
http://example.com/post/NIO-select-poll和epoll.html
作者
SamuelZhou
发布于
2022年11月2日
许可协议