程序员潇然 发表于 2022-7-11 11:28:17

深入分析select、poll、epoll模型 (二)

NIO模型与IO多路复用的设计理念息息相关,可以说NIO模型就是对于IO多路复用这一逻辑的抽象,而Linux中 IO多路复用中几个最重要的select、poll、epoll,本文将会深入分析,将模型梳理清楚(非源码级分析,只是分析模型)。

在计算机的世界里,很多时候技术的发展,大多都来自于他所依赖的技术的发展。
比如操作系统的能力,依赖于硬件发展,而对于编程语言的能力,又依赖操作系统的发展。

对于NIO的起源与发展,个人认为是操作系统提供了更高效的IO生产力,而java最初的IO 已经不足以支撑高并发量,一面是日益增长的需求一面是日趋完善的底层支撑,两者都具备,Java提供NIO也就成了水到渠成。

尤其是网络编程,阻塞式的IO应对高并发,是非常糟糕的一件事情。

比如:

```java
package com.crazybytex.hello.socket;

import java.io.*;
import java.net.Socket;

/**
* @description client
* @Author 本文作者 程序员潇然 疯狂的字节X https://www.crazybytex.com/
* @Date 11:59 2022/7/4
**/
public class SocketClient {

    public static void main(String[] args) {
      try {
            //1、创建客户端Socket,指定服务器地址和端口
            Socket socket = new Socket("127.0.0.1", 9527);
            //2、获取输出流,向服务器端发送信息
            //字节输出流
            OutputStream os = socket.getOutputStream();
            //将输出流包装成打印流
            PrintWriter pw = new PrintWriter(os);
            pw.write("hello server ,i'm client");
            pw.flush();
            socket.shutdownOutput();

            //3、获取输入流,并读取服务器端的响应信息
            InputStream is = socket.getInputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(is));
            String info = null;
            while ((info = br.readLine()) != null) {
                System.out.println("Receive from server message=: " + info);
            }

            //4、关闭资源
            br.close();
            is.close();
            pw.close();
            os.close();
            socket.close();
      } catch (IOException e) {
            System.out.println(e);
      }
    }
}
```

```java
package com.crazybytex.hello.socket;

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @description server
* @Author 本文作者 程序员潇然 疯狂的字节X https://www.crazybytex.com/
* @Date 11:59 2022/7/4
**/
public class SocketServer {
    public static void main(String[] args) throws IOException {
      ServerSocket serverSocket = null;
      Socket socket = null;
      Reader reader = null;
      OutputStream os = null;
      PrintWriter pw = null;
      try {
            //1、创建一个服务器端Socket,即ServerSocket,指定绑定的端口,并监听此端口
            //1024-65535的某个端口
            serverSocket = new ServerSocket(9527);
            //2、调用accept()方法开始监听,等待客户端的连接
            socket = serverSocket.accept();
            //3、获取输入流,并读取客户端信息
            reader = new InputStreamReader(socket.getInputStream());
            char chars[] = new char;
            int len;
            String strClient = "";
            while ((len = reader.read(chars)) != -1) {
                strClient = new String(chars, 0, len);
                System.out.println("Receive from client message=: " + strClient);
            }
            //关闭输入流
            socket.shutdownInput();
            //4、获取输出流,响应客户端的请求
            os = socket.getOutputStream();
            pw = new PrintWriter(os);
            pw.write("Hello client ,response from server");
            pw.flush();
            //5、关闭资源
            pw.close();
            os.close();
            reader.close();
            socket.close();
            serverSocket.close();
      } catch (IOException e) {
            e.printStackTrace();
      }

    }
}
```

对于每一个新的网络连接, 都通过线程池分配给一个专门线程去负责IO处理。每个线程都独自处理自己负责的socket连接的输入和输出。

当然,服务器的监听线程也是独立的,任何的socket连接的输入和输出处理,不会阻塞到后面新socket连接的监听和建立,这样,服务器的吞吐量就得到了提升。
典型的一个连接一个线程的模型。

在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题。

1. 线程的创建和销毁成本很高, 线程的创建和销毁都需要通过重量级的系统调用去完成。
2. 线程本身占用较大内存,像Java的线程的栈内存,一般至少分配512K~1M的空间,如果系统中的线程数过千,整个JVM的内存将被耗用1G。
3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。 过多的线程频繁切换带来的后果是,可能执行线程切换的时间甚至会大于线程执行的时间, 这时候带来的表现往往是系统CPU sy值特别高(超过20%以上)的情况,导致系统几乎陷入不可用的状态。

### IO多路复用

select、poll 以及 epoll 是 Linux 系统的三个系统调用,也是 IO 多路复用模型的具体实现。

但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

#### 文件

在 Linux 环境中,任何事物都是用文件来表示,设备是文件,目录是文件, socket 也是文件。

用来表示所处理对象的接口和唯一接口就是文件。

应用程序在读/写一个文件时,首先需要打开这个文件,打开的过程其实质就是在进程与文件之间建立起连接,句柄的作用就是唯一标识此连接。
此后对文件的读/写时,由这个句柄作为代表。最后关闭文件其实就是释放这个句柄的过程, 也就是进程与文件之间的连接断开。

#### 生产环境文件句柄

在生产环境Linux系统中,基本上都需要解除文件句柄数的限制。
因为 Linux的系统默认值为1024,也就是说,一个进程最多可以接受1024个socket连接,很显然这是远远不够的。

###### 文件句柄,也叫文件描述符。

在Linux系统中,文件可分为:
普通文件、目录文件、链接文件和设备文件。

文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,它是一个非负整数(通常是小整数),用于指代被打开的文件。

所有的IO系统调用,包括socket的读写调用,都是通过文件描述符完成的。

```
ulimit -n
```

该命令可以看到一个进程能够打开的最大文件句柄数量

文件句柄数不够,会导致什么后果呢?
当单个进程打开的文件句柄数量超过了系统配置的上限值时,就会发出“Socket/File:Can't open so many files”的错误提示。

```
ulimit -n 1000000
```

通过这个命令可以进行设置,n的设置值越大,可以打开的文件句柄数量就越大。
建议以root用户来执行此命令。

ulimit命令只能用于临时修改,如果想永久地把最大文件描述符数量值保存下来,可以编辑/etc/rc.local开机启动文件,在文件中添加如下内容:

```
ulimit -SHn 1000000
```

以上示例增加-S和-H两个命令选项。选项-S表示软性极限值, -H表示硬性极限值。
硬性极限是实际的限制,就是最大可以是100万,不能再多了。
软性极限值则是系统发出警告(Warning)的极限值,超过这个极限值,内核会发出警告。

普通用户通过ulimit命令,可将软极限更改到硬极限的最大设置值。如果要更改硬极限,必须拥有root用户权限。
终极解除Linux系统的最大文件打开数量的限制,可以通过编辑Linux的极限配置文件

```/etc/security/limits.conf```来解决,修改此文件,加入如下内容:

```
* soft nofile 1000000
* hard nofile 1000000
```

soft nofile表示软性极限, hard nofile表示硬性极限。

除了修改应用进程的文件句柄上限之外,还需要修改内核基本的全局文件句柄上限,通过修改```/etc/sysctl.conf```配置文件来更改,参考的配置如下:

```
fs.file-max = 2048000
fs.nr_open = 1024000
```

`本文作者:程序员潇然 疯狂的字节X https://crazybytex.com/`

fs.file-max表示系统级别的能够打开的文件句柄的上限,可以理解为全局的句柄数上限。是对整个系统的限制,并不是针对用户的。
fs.nr_open指定了单个进程可打开的文件句柄的数量限制, nofile受到这个参数的限制, nofile值不可用超过fs.nr_open值。

**hard limit不能大于/proc/sys/fs/nr_open,假如hard limit大于nr_open,注销后无法正常登录。**

上述参数的大小关系是:soft limit < hard limit < nr_open < file-max

### select

```c
#include <sys/select.h>
int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,
          const struct timeval *timeout);
参数说明:
maxfdp:集合中所有文件描述符的范围,需设置为所有文件描述符中的最大值加1。
readset:要进行监听的是否可以读文件的文件描述符集合。
writeset:要进行监听的是否可以写文件的文件描述符集合。
exceptset:要进行监听的是否发生异常的文件描述符集合。
timeval:select的超时时间,它可以使select处于三种状态:
1、若将NULL以形参传入,即不传入时间结构,就是将select至于阻塞状态,一定要等到监视的文件描述符集合中某个文件描述符发生变化为止。
2、若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否发生变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值。
3、timeout的值大于0,这就是等待的超时时间,即select在timeout时间内阻塞,超时时间之内有事件到来就返回,否则在超时后不管怎样一定返回。
返回值:
>0:表示被监视的文件描述符有变化。
-1:表示select出错。
0:表示超时。
```

return:表示此时有多少个监控的描述符就绪,若超时则为0,出错为-1。
select 函数监视的文件描述符分三类:

* write 可写
* read可读
* exception异常

select依赖相关的驱动程序,比如从网卡读数据,依赖网卡驱动。

支持阻塞操作的设备驱动通常会实现一组自身的等待队列如读/写等待队列用于支持上层(用户层)所需的BLOCK或NONBLOCK操作。
当应用程序通过设备驱动访问该设备时(默认为BLOCK操作),若该设备当前没有数据可读或写,则将该用户进程插入到该设备驱动对应的读/写等待队列让其睡眠一段时间,等到有数据可读/写时再将该进程唤醒。
!(data/attachment/forum/202207/11/115807a2zcteuaztaen2gk.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")
select就是巧妙的利用等待队列机制让用户进程适当在没有资源可读/写时睡眠,有资源可读/写时唤醒。

驱动程序实现poll函数。select通过每个设备文件对应的poll函数提供的信息判断当前是否有资源可用(如可读或写),如果有的话则返回可用资源的文件描述符个数,没有的话则睡眠,等待有资源变为可用时再被唤醒继续执行。

select会循环遍历它所监测的fd_set(一组文件描述符(fd)的集合)内的所有文件描述符对应的驱动程序的poll函数。
驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。
当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。
!(data/attachment/forum/202207/11/143054kgndhfxh2ghzfm2j.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

驱动程序维护了针对自身资源读写的等待队列。
当设备驱动发现自身资源变为可读写并且有进程睡眠在自身资源的等待队列上时,就会唤醒自身资源等待队列上的进程。

select管理的所有的socket,只要有一个OK就会唤醒select,那么到底是哪个呢?
不知道
所以只能进行轮询,所以这个效率。。。

#### 整体流程概括如下

1)用户进程需要访问某些资源,也就是监控某些资源 fds,在调用 select 函数后会阻塞,操作系统会将用户线程加入这些资源的等待队列中。

2)直到有描述符就绪(有数据可读、可写或有 except)或超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。

3)select 函数返回后,中断程序唤起用户线程。用户可以遍历 fds,通过 FD_ISSET 判断具体哪个 fd 收到数据,并做出相应处理。

select 函数优点明显,实现起来简单有效,且几乎所有操作系统都有对应的实现。

#### select 的缺点

1)每次调用 select 都需要将进程加入到所有监视 fd 的等待队列,每次唤醒都需要从每个队列中移除。
这里涉及了两次遍历,而且每次都要将整个 fd_set 列表传递给内核,有一定的开销。

2)当函数返回时,系统会将就绪描述符写入 fd_set 中,并将其拷贝到用户空间。
进程被唤醒后,用户线程并不知道哪些 fd 收到数据,还需要遍历一次。

3)受 fd_set 的大小限制,Posix接口限制为1024个文件描述符。

有些朋友或许会在某些点纠结,对于细节来说,就是代码的实现逻辑,只不过比较底层的代码的实现逻辑影响较为广泛而已,其实有的时候根本没有答案,答案就是代码就是那样写的,不然代码设计基本完美,也就不会有后面的poll 和epoll 实现了,就是因为代码写的不够好,设计的不够巧妙,所以才会进一步发展。

### poll

```c
#include <poll.h>
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
//fds 指向struct pollfd类型的数组,其中记录了要监听的文件的描述符、要监听的事件、返回的事件
//nfds 这是第一个参数数组中最后一个有效元素的下标 +1 ,也就是个数
/**
       参数:
                - fds: 这是一个struct pollfd数组, 这是一个要检测的文件描述符的集合
                - nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1
                - timeout: 阻塞时长
              0: 不阻塞
              -1: 阻塞, 检测的fd有变化解除阻塞
              >0: 阻塞时长
      返回值:
                -1: 失败
                >0(n): 检测的集合中有n个文件描述符发送的变化
                0:超时
*/
struct pollfd {
        int   fd;         /* 委托内核检测的文件描述符 */
        short events;   /* 委托内核检测文件描述符的什么事件 等什么? */
        short revents;    /* 文件描述符实际发生的事件 发生了什么? */
};
```

每一个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。

poll 函数与 select 原理相似,都需要来回拷贝全部监听的文件描述符,不同的是:

1)poll 函数采用**链表**的方式替代原来 select 中 fd_set 结构,因此可监听文件描述符数量不受限。

2)poll 函数返回后,可以通过 pollfd 结构中的内容进行处理就绪文件描述符,相比 select 效率要高。

3)新增水平触发:也就是通知程序 fd 就绪后,这次没有被处理,那么下次 poll 的时候会再次通知同个 fd 已经就绪。

#### poll 缺点

和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。
事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
从select问题的角度看poll,它既有select 拷贝问题,也有轮询问题,只是解决了fd个数限制问题。

题外话:
poll的地位很尴尬,从技术角度看,只是比select强了一点而已,但是地位却不如select,因为select可以跨平台(多平台都有select的实现方案),而且性能又远不如epoll。

尽管select和poll 看起来效率很差,但是如果需要监控的fd比较少,而且都非常的活跃,他们的弊端又是可以相对的忽略的,因为大家都很活跃,我遍历也并没有损失很大的性能。
只有量很大,活跃很少,遍历才会造成大量性能浪费。

### epoll

鉴于select和poll的对于高并发下的种种弊端,epoll应运而生。

epoll 在 2.6 内核中提出,是之前的 select 和 poll 的增强版。
核心为三个函数

```
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
```

```
/*创建一个 epoll 的句柄,参数 size 并非限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
当 epoll 句柄创建后,它就会占用一个 fd 值
在 linux 中查看/proc/进程id/fd/,能够看到这个 fd
所以 epoll 使用完后,必须调用 close() 关闭,否则可能导致 fd 被耗尽。
*/
int epoll_create(int size);
```

```
/*
epoll的事件注册函数,第一个参数是 epoll_create() 的epoll 文件的描述符,即epoll fd

第二个参数表示动作,使用如下三个宏来表示
EPOLL_CTL_ADD   //注册新的fd到epfd中;
EPOLL_CTL_MOD//修改已经注册的fd的监听事件
EPOLL_CTL_DEL //从epfd中删除一个fd;
分别表示添加、修改和删除 fd 的监听事件

第三个参数是需要监听的fd

第四个参数是告诉内核需要监听什么事
*/
typedef union epoll_data
{
    void      *ptr;
    int          fd;
    __uint32_t   u32;
    __uint64_t   u64;
} epoll_data_t;

struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
};

/**
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
通过 epoll_ctl 函数添加进来的事件都会被放在红黑树的某个节点内
所以,重复添加是没有用的
*/
```

当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备驱动程序建立回调关系。
当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为 ep_poll_callback。
这个回调函数其实就所把这个事件添加到 rdllist 这个双向链表中。
一旦有事件发生,epoll 就会将该事件添加到双向链表中。
那么当我们调用 epoll_wait 时,epoll_wait 只需要检查 rdlist 双向链表中是否有存在注册的事件,效率非常可观。这里也需要将发生了的事件复制到用户态内存中即可。

!(data/attachment/forum/202207/11/153517i93yy03botzdpd9i.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")
所有 FD 集合采用红黑树存储,就绪 FD 集合使用链表存储。
这是因为就绪 FD 都需要处理,业务优先级需求,最好的选择便是线性数据结构。

```
/*
参数events用来从内核得到事件的集合

maxevents 告之内核这个events有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的size

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

等待 epfd 上的 io 事件,最多返回 maxevents 个事件。

1)epoll_wait调用ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。

2)文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用。

3)ep_poll_callback将相应fd对应epitem加入rdlist,导致rdlist不空,进程被唤醒,epoll_wait得以继续执行。

4)ep_events_transfer函数将rdlist中的epitem拷贝到txlist中,并将rdlist清空。

5)ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对用的poll方法。此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。

上面介绍了一些细节,跳脱出来看,其实分为下面几个部分

!(data/attachment/forum/202207/11/154829z01tigs856gbdf5d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")
epoll 精巧的使用了 3 个方法来实现 select 方法要做的事,分离了频繁调用和不频繁调用的操作。

!(data/attachment/forum/202207/11/162323fst4hdfb28rmsvaa.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

对于需要监听的fd,使用红黑树保存,红黑树典型的特点就是插入删除操作高效,插入和删除性能比较好,时间复杂度O(logN),而且也不在会有个树上的限制。

select poll需要循环遍历,从上图可以看到,epoll模式下,只需要看链表是否为空,对于就绪队列,线性数据结构也是最高效的处理方式。

通过注册回调的形式,仅仅需要注册时一次拷贝fd,不再需要加入等待队列了。
所以select存在的三大缺点,epoll都很好的解决了。

再补充下:

!(data/attachment/forum/202210/10/134658v14li0966x06z2ll.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

对于上层应用层语言编程,比如java,完全不需要关注到epoll的所谓create ctl wait,底层都会进行处理,只需要理解`注册事件`

这几个字就可以了

对于上层,事件就是读就绪,写就绪,新连接请求,连接建立,需要做的也就是告知底层你关注的事件,事件的处理就好了。

#### 两种工作模式

**LT模式(水平触发)**
假设委托内核检测读事件-检测fd的读缓冲区
都缓冲区有数据-epoll检测到了会给用户通知

用户不读数据,数据一直在缓冲区,epoll会一直通知
用户只读了一部分数据,epoll会通知
缓冲区的数据读完了,不通知
LT模式是默认的工作模式,同时支持阻塞和非阻塞套接字两种模式。
内核告诉你一个文件描述符是否就绪,然后你可以对这个就虚的fd进行IO操作,如果你不做任何操作的话,内核还是会继续通知你。(只要是缓冲区内有数据,内核就通知)。

**ET模式(边沿触发)**
假设委托内核检测读事件-检测fd的读缓冲区
都缓冲区有数据-epoll检测到了会给用户通知

用户不读数据,数据一直在缓冲区,epoll不通知
用户只读了一部分数据,epoll不通知
缓冲区的数据读完了,不通知
只支持非阻塞套接字。当描述符变为就绪时,内核会通过EPOLL告诉你。然后他会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。除非文件描述符又重回非就绪态。

如果EPOLLLT,系统中一旦有大量无需读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这大大降低处理程序检索自己关心的就绪文件描述符的效率。

而采用EPOLLET,当被监控的文件描述符上有可读写事件发生时,poll_wait会通知处理程序去读写。
如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用poll_wait时,它不会通知你。
即只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。
这比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

!(data/attachment/forum/202206/16/141330jha7st9soow8772i.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "common_log.png")
`转载务必注明出处:程序员潇然,疯狂的字节X,https://crazybytex.com/thread-50-1-1.html `
页: [1]
查看完整版本: 深入分析select、poll、epoll模型 (二)