Linux的I/O 模式之多路复用

导读:本篇文章讲解 Linux的I/O 模式之多路复用,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

2021/7/8 更新Linux下select、poll、epoll的讲解

如果你觉得内容对你有帮助的话,不如给个赞,鼓励一下更新😂。

Socket 是什么?

Socket 编程模型

我们知道 Socket 在操作系统中,有一个非常具体的从 Buffer 到文件的实现。但是对于进程而言,Socket 更多是一种编程的模型。接下来我们讨论作为编程模型的 Socket。
Linux的I/O 模式之多路复用
如果应用层的程序想要传输数据,就创建一个 Socket。应用向 Socket 中写入数据,相当于将数据发送给了另一个应用。应用从 Socket 中读取数据,相当于接收另一个应用发送的数据。而具体的操作就是由 Socket 进行封装。
但是如果从另一个角度去分析,比如对于 UNIX 系的操作系统,Socket 还是一种文件,准确来说是一种双向管道文件:每个都是一个双向的管道。一端是应用,一端是缓冲区。
**那么作为一个服务端的应用,如何知道有哪些 Socket 呢?**也就是,哪些客户端连接过来了呢?这是就需要一种特殊类型的 Socket,也就是服务端 Socket 文件。
Linux的I/O 模式之多路复用
程序员实现一个网络服务器的时候,会先手动去创建一个服务端 Socket 文件。比如:

var serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(80));

看起来我们创建的是一个服务端 Socket 对象,但这太过于抽象,我们应该理解为这是一个文件并且它里面存的是所有客户端 Socket 文件的文件描述符。
当一个客户端连接到服务端的时候,操作系统就会创建一个客户端 Socket 的文件。然后操作系统将这个文件的文件描述符写入服务端程序创建的服务端 Socket 文件中。服务端 Socket 文件,是一个管道文件。如果读取这个文件的内容,就相当于从管道中取走了一个客户端文件描述符。
Linux的I/O 模式之多路复用
如上图所示,服务端 Socket 文件相当于一个客户端 Socket 的目录,线程可以通过 accept() 操作每次拿走一个客户端文件描述符。拿到客户端文件描述符,就相当于拿到了和客户端进行通信的接口。前面我们提到 Socket 是一个双向的管道文件,如果要向某个特定的客户端发送数据,就写入这个客户端的 Socket 文件。以上就是 Socket 的编程模型。

IO多路复用的概念

在通信和硬件设计中存在频分复用、时分复用、波分复用、码分复用等。从本质上来说就是一个线程需要处理所有关注的 Socket 产生的变化,或者说消息。实际上一个线程要处理很多个文件的 I/O。

IO和NIO的区别

举个非常简单的生活中的例子说明 IO 与 NIO 的区别。
在一家幼儿园里,小朋友有上厕所的需求,小朋友都太小以至于你要问他要不要上厕所,他才会告诉你。幼儿园一共有 100 个小朋友,有两种方案可以解决小朋友上厕所的问题:

  1. 每个小朋友配一个老师。每个老师隔段时间询问小朋友是否要上厕所,如果要上,就领他去厕所,100 个小朋友就需要 100 个老师来询问,并且每个小朋友上厕所的时候都需要一个老师领着他去上,这就是IO模型,一个连接对应一个线程。
  2. 所有的小朋友都配同一个老师。这个老师隔段时间询问所有的小朋友是否有人要上厕所,然后每一时刻把所有要上厕所的小朋友批量领到厕所,这就是 NIO 模型,所有小朋友都注册到同一个老师,对应的就是所有的连接都注册到一个线程,然后批量轮询。

这就是 NIO 模型解决线程资源受限的方案,实际开发过程中,我们会开多个线程,每个线程都管理着一批连接,相对于 IO 模型中一个线程管理一条连接,消耗的线程资源大幅减少。

多路复用IO

内核如何知道该把哪个消息给哪个进程呢?

处理 I/O 多路复用的问题,需要操作系统提供内核级别的支持。Linux 下有三种提供 I/O 多路复用的 API,分别是:

  • select(线性结构)
  • poll(线性结构)
  • epoll(索引结构)

Linux的I/O 模式之多路复用
一个 Socket 文件,可以由多个进程使用;而一个进程,也可以使用多个 Socket 文件。进程和 Socket 之间是多对多的关系。另一方面,一个 Socket 也会有不同的事件类型。因此操作系统很难判断,将哪样的事件给哪个进程。
这样在进程内部就需要一个数据结构来描述自己会关注哪些 Socket 文件的哪些事件(读、写、异常等)。通常有两种考虑方向,一种是利用线性结构,比如说数组、链表等,这类结构的查询需要遍历。每次内核产生一种消息,就遍历这个线性结构。看看这个消息是不是进程关注的?另一种是索引结构,内核发生了消息可以通过索引结构马上知道这个消息进程关不关注。

select()

select 允许用户传入 3 个集合,首先需要将fd_set从用户空间拷贝到内核空间,每次 select 操作会阻塞当前线程,在阻塞期间所有操作系统产生的每个消息,都会通过遍历的手段查看是否在 3 个集合当中。

fd_set read_fd_set, write_fd_set, error_fd_set;
while(true) {
  select(..., &read_fd_set, &write_fd_set, &error_fd_set); 
}

上面程序read_fd_set中放入的是当数据可以读取时进程关心的 Socket;write_fd_set是当数据可以写入时进程关心的 Socket;error_fd_set是当发生异常时进程关心的 Socket。

Linux的I/O 模式之多路复用
需要注意的是select 模式能够一次处理的文件描述符是有上限的,通常是 1024。当并发请求过多的时候, select 就无能为力了。
在多路复用IO模型中,会有一个线程(Java中的selector)不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作,才会使用IO资源,所以它大大减少了资源占用。

poll()

poll本质上和select没有区别,依然需要在用户空间和内核空间的频繁复制,效率低,依然是基于轮询来实现,但区别就是,select使用的是fd数组,而poll则是维护了一个链表,所以从理论上,poll方法中,单个进程能监听的fd不再有数量限制。但是轮询,复制等select存在的问题,poll依然存在。

epoll()

epoll操作实际上对应着有三个函数:epoll_createepoll_ctrepoll_wait。

  • epoll_create:相当于在内核中创建一个存放fd的数据结构。
  • epoll_ctr:当需要添加一个新的fd时,会调用epoll_ctr,给这个fd注册一个回调函数,然后将该fd结点注册到内核中的红黑树中。当该fd对应的设备活跃时,会调用该fd上的回调函数,将该结点存放在一个就绪链表中。这也解决了在内核空间和用户空间之间进行来回复制的问题。

为了解决轮询遍历从操作系统订阅消息的问题。epoll 将进程关注的文件描述符存入一棵二叉搜索树,通常是红黑树的实现。在这棵红黑树当中,Key 是 Socket 的编号,值是这个 Socket 关注的消息。因此,当内核发生了一个事件:比如 Socket 编号 1000 可以读取。这个时候,可以马上从红黑树中找到进程是否关注这个事件。
它的核心思想是基于事件驱动来实现的,实现起来也并不难,就是给每个fd注册一个回调函数,当fd对应的设备发生IO事件时,就会调用这个回调函数,将该fd放到一个队列中,然后当用户调用epoll_wait时候,就会从队列中返回一个消息。epoll 函数本身是一个构造函数,只用来创建红黑树和队列结构。epoll_wait调用后,如果队列中没有消息,也可以马上返回。因此epoll是一个非阻塞模型。

总结

select/poll 是阻塞模型,epoll 是非阻塞模型。当然,并不是说非阻塞模型性能就更好。在多数情况下,epoll 性能更好是因为内部有红黑树的实现。

NIO的多路复用

同步与异步

同步和异步关注的是消息通信机制。 也就是调用者和被调用者之间,消息是如何进行通知的。如果是调用者主动等待调用的结果,那么就是同步。如果是被调用者主动去通知调用者,就是异步。
从上面的描述中,我们可以看到同步还是异步,主要是看的消息通知的方式,一个是调用者主动等待,一个是调用者被通知。

阻塞与非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
这两个概念都是针对于调用者来说的,如果调用者在等待调用结果时,不能做其他的事情,就是阻塞;否则就是非阻塞。也就是看调用过程中,调用者线程的状态,调用线程被挂起,无法进行其他的操作,就是阻塞;如果不被挂起,还能继续其他的操作,就是非阻塞。

NIO的同步与非阻塞

首先我们要先明确一点,Java最底层的IO操作是同步的,但是NIO是一个经过包装的IO操作,是在selector机制实现的事件驱动包装下,对外提供同步非阻塞的功能。
具体体现在,Selector的select()方法会轮询Channel中的事件是否就绪(这是同步),在主线程中的Selector可以监听多个Channel注册的事件,如果有事件发生自然会分配Channel去处理,这样主线程并不会阻塞(这是非阻塞)

Java NIO实现案例

用 JDK 原生的 NIO 来实现

/**
 * @author 南街
 * @program JavaAdvanced
 * @classname NIOServer
 * @description NIO非阻塞案例
 * @create 2020-04-18 20:02
 **/
public class NIOServer {
    public static void main(String[] args) throws IOException {
        // 创建ServerSocketChannel -> ServerSocket
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 绑定一个端口6666,在服务器端监听
        serverSocketChannel.socket().bind(new InetSocketAddress(6666));
        // 设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
        // 得到一个selector对象
        Selector selector = Selector.open();
        // 把 serverSocketChannel 注册到 selector 关心事件为OP_ACCEPT
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("selector.keys() = " + selector.keys().size());
        // 循环等待客户端连接
        while(true){
            // 等待一秒,如果没有事件发生
            if (selector.select(1000) == 0){
                System.out.println("无连接就不等了");
                continue;
            }
            // 如果返回的大于0 获取有时间发生的key
            Set<SelectionKey> selectionKeySet = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeySet.iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                // 根据key 对应的通道进行相应的处理
                if (key.isAcceptable()) {   // 如果是OP_ACCEPT说明有客户端来连接
                    // 给该客户端生成一个SocketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    socketChannel.configureBlocking(false);
                    System.out.println("客户端连接成功,生成了一个socketChanel:" + socketChannel.hashCode());
                    // 将当前的socketChannel注册到selector,关注事件为OP_READ,同时关联一个Buffer
                    socketChannel.register(selector,SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                    System.out.println("selector.keys() = " + selector.keys().size());
                }
                // 发生了OP_READ
                if (key.isReadable()){
                    // 通过key反向获取到对应Channel
                    SocketChannel channel = (SocketChannel) key.channel();
                    // 获取到该channel关联的buffer
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    channel.read(buffer);
                    System.out.println("form 客户端" + new String(buffer.array()));
                }
                // 手动从集合中移除当前的selectionKey
                iterator.remove();
            }
        }
    }
}
/**
 * @author 南街
 * @program JavaAdvanced
 * @classname NIOClient
 * @description
 * @create 2020-04-20 16:58
 **/
public class NIOClient {
    public static void main(String[] args) throws IOException {
        // 得到一个网络通道
        SocketChannel socketChannel = SocketChannel.open();
        // 设置非阻塞模式
        socketChannel.configureBlocking(false);
        // 提供服务器的ip和端口
        InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
        // 连接服务器
        if (!socketChannel.connect(inetSocketAddress)) {
            while (!socketChannel.finishConnect()){
                System.out.println("因为连接需要时间,客户端不会阻塞,我们可以做其他工作");
            }
        }
        // ...如果连接成功,就发送数据
        String str = "hello,南街";
        // 自动判断字节数组大小,生成一致大小的ByteBuffer并包裹传入的字节数组,这样就不需要自己指定大小了
        ByteBuffer buffer = ByteBuffer.wrap(str.getBytes());
        // 发送数据,将buffer 数据写入channel
        socketChannel.write(buffer);
        System.in.read();
    }
}

Reactor的三种实现

说到NIO、Netty,Reactor模型一定是绕不开的,它也称为Dispatcher模型,因为这种模式架构太经典了,我们简单了解一下吧。

Reactor单线程

Linux的I/O 模式之多路复用
Reactor 对象监听客户端请求事件,收到事件后通过 Dispatch 进行分发。如果是连接建立的事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立的事件,而是数据的读写事件,则 Reactor 会将事件分发对应的 Handler 来处理,由这里唯一的线程调用 Handler 对象来完成读取数据、业务处理、发送响应的完整流程。
单 Reactor 单线程的优点就是:线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。
但其缺点也非常明显,那就是性能瓶颈问题,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在客户端使用这种线程模型

Reactor多线程模式

Linux的I/O 模式之多路复用
该流程与单 Reactor 单线程的模型基本一致,唯一的区别就是执行 Handler 逻辑的线程隶属于一个线程池。
很明显,单 Reactor 多线程的模型可以充分利用多核 CPU 的处理能力,提高整个系统的吞吐量,但引入多线程模型就要考虑线程并发、数据共享、线程调度等问题。在这个模型中,只有一个线程来处理 Reactor 监听到的所有 I/O 事件,其中就包括连接建立事件以及读写事件,当连接数不断增大的时候,这个唯一的 Reactor 线程也会遇到瓶颈。

Reactor主从模式

Linux的I/O 模式之多路复用
主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件,SubReactor只负责监听读写事件。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。

Reactor 线程模型运行机制

四个步骤,分别为连接注册事件轮询事件分发任务处理,如下图所示。

Linux的I/O 模式之多路复用

  • 连接注册:Channel 建立后,注册至 Reactor 线程中的 Selector 选择器。

  • 事件轮询:轮询 Selector 选择器中已注册的所有 Channel 的 I/O 事件。

  • 事件分发:为准备就绪的 I/O 事件分配相应的处理线程。

  • 任务处理:Reactor 线程还负责任务队列中的非 I/O 任务,每个 Worker 线程从各自维护的任务队列中取出任务异步执行。

Redis的多路复用

Redis基于Reactor模式(反应堆模式)开发了自己的网络模型,形成了一个完备的基于IO复用的事件驱动服务器,它内部使用文件事件处理器是单线程的,所以Redis才叫做单线程的模型。它采用IO多路复用机制同时监听多个Socket,根据Socket上的事件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:

  • 多个 Socket
  • IO 多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

多个 Socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 Socket,会将 Socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

资料来源

高并发基石|深入理解IO复用技术之epoll

多路复用IO与NIO

Redis线程模型

拉勾专栏《重学操作系统》

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/15207.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!