天天看点

BIO、NIO编程与直接内存、零拷贝深入辨析

Socket

Socket是处于应用层和传输层中间的软件,是一个接口,我们应用层可以直接调用接口来实现网络连接,Socket帮我们实现了传输层和网络层复杂逻辑。每创建个连接就会创建一个Socket。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230215220048528

BIO

BIo即Blocking I/O,阻塞I/O。

在Socket中存在两种阻塞,一个accept,另外一个是Socket I/O的读,

accept是阻塞在应用层的,当有连接进来的时候,服务器接受信息(连接信息),并且发送中断信号唤醒accept线程.它不会影响传输层TCP的连接.

Socket I/O每创建socket时候都会创建两个socket缓存区.分别是读写,每当有数据过来时候会写入到Socket读取缓冲区内.发送中断信号唤醒客户端线程进行读取.

BIO相比较传输效率更快,但是服务器必须为每个客户端单开一个线程,客户端连接成本大,不能只能大量的连接.

服务器

public class Server {
    private static ExecutorService executorService
            = Executors.newFixedThreadPool(
            Runtime.getRuntime().availableProcessors());
    public static void main(String[] args) throws IOException {
        //服务端启动必备
        ServerSocket serverSocket = new ServerSocket();
        //表示服务端在哪个端口上监听
        serverSocket.bind(new InetSocketAddress(6666));
        System.out.println("服务端开启 ... ... ");
        try {
            while (true) {
                //阻塞,等待有新的连接过来
                //如果TCP,传输层有新的连接,才会进入到这里,这里阻塞是应用层的阻塞,不影响TCP连接
                Socket accept = serverSocket.accept();
                executorService.execute(new Thread(new ServerTask(accept)));
            }
        } finally {
            serverSocket.close();
        }
    }
    /**
     * 每有客户端建立连接,都会有对应的Task来处理
     */
    private static class ServerTask implements Runnable {
        private Socket socket = null;
        public ServerTask(Socket socket) {
            this.socket = socket;
        }
        @Override
        public void run() {
            try (
                    //实例化与客户端通信的输入输出流
                    ObjectInputStream inputStream = new ObjectInputStream(socket.getInputStream());
                    ObjectOutputStream outputStream = new ObjectOutputStream(socket.getOutputStream())
            ) {
                //如果webSocket输入流中没有数据,阻塞
                String text = inputStream.readUTF();
                System.out.println("服务器收到的消息:" + text);
                outputStream.writeUTF("服务器返回的消息:" + text);
                outputStream.flush();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
           

客户端

public class Client {
    public static void main(String[] args) throws IOException {
        //客户端启动必备
        Socket socket = null;
        //实例化与服务端通信的输入输出流
        ObjectOutputStream output = null;
        ObjectInputStream input = null;
        //服务器的通信地址
        InetSocketAddress addr
                = new InetSocketAddress("127.0.0.1", 6666);
        try {
            socket = new Socket();
            socket.connect(addr);
            output = new ObjectOutputStream(socket.getOutputStream());
            input = new ObjectInputStream(socket.getInputStream());
            /*向服务器输出请求*/
            output.writeUTF("你好服务器... ...");
            output.flush();
            //这里读取是阻塞的.
            System.out.println(input.readUTF());
        } finally {
            if (socket != null) socket.close();
            if (output != null) output.close();
            if (input != null) input.close();
        }
    }
}
           

阻塞IO和非阻塞IO

阻塞IO,指的是需要等待内核IO准备好后,才返回到用户空间执行用户的操作。阻塞是指用户空间的执行状态。

非阻塞IO,指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间执行用户操作,即处于非阻塞IO状态,内核空间会立即返回给用户一个状态值。调用线程拿到内核返回的状态值后,IO操作能干就干,不能就干别的事情。

JAVA I/O的各种流是阻塞的,意味着read()或write()时,线程阻塞,直到一些数据被读取,或者数据完全写入。再此期间线程不能干任何事情。

JAVA NIO的非阻塞模式,线程从通道读取数据,仅能得到可用的数据,如果没有数据,什么都不会获取,线程不会阻塞。

针对网络IO的操作,可以分成两个阶段,准备阶段和操作阶段。

1,准备阶段是判断是否能够操作(即等待数据是否可用),在内核进程完成的;

​ 2,操作阶段则执行实际的IO调用,数据从内核缓冲区拷贝到用户进程缓冲区。

一个read()操作发生时,它会经历下面两个阶段:

​ A, 等待数据准备,数据是否拷贝到内核缓冲区;

​ B, 将数据从内核拷贝到用户进程空间

那么在Socket.read()阻塞在当前操作系统的准备阶段,我还没准备好,稍等会。

Buffer如果里边有数据就返回,没有数据就不返回,不需要等待它有没有准备好。

Reactor模式

Reactor翻译过来是反应堆模式,大致是将要关注的事件扔到反应堆里边,当有关注的事件发生时候,反应堆就会告诉我们。

在内核中新开启个线程Selector,这个线程是阻塞状态,把socket放入到这个Selector线程里边,当有socket发生事件就会给Selector线程发送中断信号来唤醒select。

Reactor模式,是一种高效的异步IO模式特征是回调。

与观察者模式区别是,观察者模式是对单个事件感兴趣,Reactor是对多个事件感兴趣。

单线程Reactor模式

Reactor放入一个关注连接的事件,当有客户端发生连接时候,Reactor就会关注到,并且唤醒accept接受连接,产生一个Socket,这个socket关注读并放入到Reactor中,当我们socket读取的时候会阻塞主线程,当有新的客户端连接过来的时候,由于主线程阻塞在read那里,导致服务器没有办法响应新客户端的连接,但是这个时候TCP其实已经连接上,客户端也收到连接成功。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216123720734

多线程Reactor模式

多线程Reactor相比较于单线程,它会为每一个新连接的客户端Socket分配一个线程,这样socket读取不会影响主线程。如果客户连接相对较多就会分配更多的线程。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216124456447

主从Reactor模式

在相比较多线程模式下,当有大量的连接过来,那么在反应堆中就会存在大量的Socket线程关注着读写,然而关注连接的相比较会太少,所以将关注连接的和关注读写的反应堆分开

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216124923655

NIO三大核心组件

Selector:选择器,Channel:管道,buffer:缓冲区

Selector:也被叫做事件订阅器,NIO可以通过Selector来管理多个channel

channel:是程序和系统交换数据的渠道。

buffer:面向缓冲的,非阻塞。用于与channel交换数据。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216163501615

其中7,8是应用程序主动调用的。只有Socket创建的两个缓冲区是操作系统主动调用。

NIO代码

需要注意的是:

1:客户端再connect后需要判断是否连接上,因为connect是异步操作,可能没连接上需要注册连接事件

2:for循环keys去除key应该先从Iterator中删除,因为他不会自己删除,下次再来的话还会再此处理它。

keys是保存在sun.nio.ch.SelectorImpl#publicSelectedKeys,Set publicSelectedKeys;如果不手动剔除的话,不会自动剔除。

SelectKey

ServerSocketChannel ssc = (ServerSocketChannel) selectionKey.channel();
socketChannel.register(selector,SelectionKey.OP_CONNECT);
           

SelectKey表示channel和selector建立了关系,并维护了channel事件。

selectKey事件有四种:读,写,连接,就绪

OP_READ:读,当socket的读缓冲区有数据时候触发。

OP_WRITE:写,当socket的写缓冲区空闲时候触发,没必要注册该事件,啥时候写自己知道,不用操作系统通知。

OP_CONNECT:就绪,当请求连接成功后触发。该操作只给客户端使用。

OP_ACCEPT:当接受到一个客户端连接请求时触发,只给操作系统用。

直接内存

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216170228528

channel.write(writeBuffer);
           

当writeBuffer大于Data(Socket缓冲区的时候)write将不会返回(如果是同步的,将阻塞,直到所有数据发送到缓冲区)。write返回数据,仅仅表示buffer写入缓冲区成功,仅仅表示可以重新使用原来的缓冲区,TCP对端不一定收到消息。

I/O读写基本要求,一个地址通过JNI传递给C库的时候,这个地址不能失效。所以在JDK在写的时候必须先创建一个堆外内存DirectBuffer,并且把数据复制到堆外内存,一个GC管不了的地方。因为JDK堆内存有GC地址会变化。DirectBuffer只有当执行老年代Full GC的时候才会顺便回收直接内存。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216172743640

堆外内存的优缺点

​ 优点:

​ 1:避免重复复制。节省一次复制,节省一次用户态转内核态。

​ 2:减少了垃圾回收的工作。

缺点:

​ 1:内存泄漏,不好排查。

​ 2:不适合存储复杂对象。

​ 3:堆外内存创建比堆内内存创建复杂。

零拷贝

零拷贝是指CPU不需要将数据从某处复制到另一处。

减少不必要的中间拷贝,不是说的不拷贝,只是说减少冗余的拷贝。 例如:Netty,kafka,Rocketmq… …

我cpu一秒钟几亿上下,你却让我陪你吃杂碎面

DMA(Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依于CPU的大量中断负载。否则,CPU需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU对于其他的工作来说就无法使用。DMA传输方式无需CPU直接控制传输,也没有中断处理方式那样保留现场和恢复现场的过程,通过硬件为RAM与I/O设备开辟一条直接传送数据的通路,能使CPU 的效率大为提高。DMA内核中的操作。

程序IO读取数据:

1:DMA将数据准备好,从磁盘(硬件层)读取到内核空间。

2:用户程序,从内核拷贝到用户空间。

byffer = file.read();
Socket.send(buffer);
           
BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216203531487

传统发送文件需要四次上下文切换,五次拷贝。

MMAP内存映射

将应用程序上的数据和应用缓冲区进行映射。建立一一对应关系。减少一次DMA拷贝。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230218180656458

sendfile

DMA将磁盘数据复制到内核中的kernel buffer,然后将内核中的kernel buffer拷贝到socket buffer,将kernel buffer直接拷贝到Socket buffer中,并不是真正的拷贝,只是将内存地址和长度拷贝到缓冲区。DMA将数据直接从内核传递给协议引擎。需要硬件支持。

NIO利用的是sendfile().NIO提供FileChannel拥有transferTo和transferFrom两个方法。可以直接把FileChannel中的数据直接拷贝到另外一个Channel。或者Channel拷贝到FileChannel。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216203737075

splice

不需要硬件支持,从linux 2.6.17开始。数据从磁盘读取到内核OS,在内核直接可以与Socket buffer建立pipe管道。

BIO、NIO编程与直接内存、零拷贝深入辨析

image-20230216204121953

继续阅读