网络I/O与磁盘I/O

目录

  • 一、同步&异步 阻塞&非阻塞
  • 二、网络I/O
    • 1. BIO
    • 2. NIO
    • 3. 多路复用器
      • 3.1 select & poll
      • 3.2 epoll
      • 3.3 Redis的IO多路复用
    • 4. Reactor模型
    • 5. Netty
  • 三、磁盘I/O
    • 1. 缓存I/O
    • 2. Direct I/O
    • 3. mmap
    • 4. write、flush、fsync

一、同步&异步 阻塞&非阻塞

参考:同步、异步、阻塞、非阻塞

同步和异步指的是:当前线程是否需要等待方法调用执行完毕

  • 同步:指的是调用这个方法,你的线程需要等待这个方法执行完成才返回结果,接着再继续执行剩下的代码逻辑
  • 异步:调用这个方法,立马就直接返回,没有返回结果,可以立马执行后面的代码逻辑,然后利用回调或者事件通知的方式得到结果。

阻塞和非阻塞指的是:当前接口数据还未准备就绪时,当前线程是否被阻塞挂起

  • 阻塞挂起:当前线程还处于 CPU 时间片当中,调用了阻塞的方法,由于数据未准备就绪,则时间片还未到就让出 CPU。所以阻塞和同步看起来都是等,但是本质上它们不一样,同步的时候可没有让出 CPU。
  • 非阻塞:当前接口数据还未准备就绪时(例如接口会返回-1),当前线程不会被阻塞挂起,后续可以不断轮询请求接口,看看数据是否已经准备就绪(是否大于0)

关于 I/O 的阻塞、非阻塞、同步、异步:

  • 阻塞和非阻塞指的是发起 I/O 请求后,用户线程状态的不同,阻塞I/O在数据未准备就绪的时候会阻塞当前用户线程,而非阻塞 I/O 会立马返回一个错误或-1,不会阻塞当前用户线程。
  • 同步和异步是指,内核的 I/O 拷贝实现,当数据准备就绪后,需要将内核空间的数据拷贝至用户空间,如果是同步 I/O 那么用户线程会等待拷贝的完成,而异步 I/O则这个拷贝过程用户线程该干嘛可以去干吗,当内核拷贝完毕之后会“通知”用户线程。

关于异步的实现方式之一就是多线程,关于线程的创建可以参考 线程创建的过程
所以在使用多线程时通常需要使用线程池,好处:
降低资源的消耗,通过重复利用已经创建的线程降低线程创建和销毁造成的消耗。
提高相应速度,当任务到达的时候,任务可以不需要等到线程创建就能立刻执行,减少了用户空间栈的创建、减少系统调用(clone等)、用户态内核态切换的过程等
提高线程的可管理性,线程是稀缺资源,使用线程池可以统一的分配、调优和监控。

二、网络I/O

1. BIO

同步阻塞式IO,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理后续的读。即一个线程只能连接一个Socket连接, 处理一个Socket的请求,accept时阻塞等待有客户端连接, read时阻塞等待有数据输入。如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。

相关代码

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketBIO {


    public static void main(String[] args) throws Exception {
        ServerSocket server = new ServerSocket(8090,20);  //netstat -natp   listen socket

        System.out.println("step1: new ServerSocket(8090) ");

        while (true) {
            Socket client = server.accept();  //阻塞1
            System.out.println("step2:client\t" + client.getPort());

            new Thread(new Runnable(){

                public void run() {
                    InputStream in = null;
                    try {
                        in = client.getInputStream();
                        BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                        while(true){
                            String dataline = reader.readLine(); //阻塞2

                            if(null != dataline){
                                System.out.println(dataline);
                            }else{
                                client.close();
                                break;
                            }
                        }
                        System.out.println("客户端断开");

                    } catch (IOException e) {
                        e.printStackTrace();
                    }

                }



            }).start();

        }
    }


}

网络I/O与磁盘I/O_第1张图片
优势:可以接收很多连接

缺点:

  • 线程内存浪费
  • cpu在多个线程之间的切换调度消耗资源

2. NIO

同步非阻塞式IO,一个线程可以连接多个Socket连接,处理多个Socket的请求; 更好的利用了线程, 避免因一个文件阻塞而导致整个线程啥也不干了。
accept时得不到客户端连接 和 read时得不到数据会直接返回默认值负数, 不会在那一直阻塞着等待。

相关代码

import java.net.InetSocketAddress;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.LinkedList;

public class SocketNIO {
    public static void main(String[] args) throws Exception {

        LinkedList<SocketChannel> clients = new LinkedList<>();

        ServerSocketChannel ss = ServerSocketChannel.open();  //服务端开启监听:接受客户端
        ss.bind(new InetSocketAddress(8090),2);
        ss.configureBlocking(false); //重点  OS  NONBLOCKING!!!  //只让接受客户端  不阻塞
        //  2729 fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK)    = 0

//        ss.setOption(StandardSocketOptions.TCP_NODELAY, false);
//        StandardSocketOptions.TCP_NODELAY
//        StandardSocketOptions.SO_KEEPALIVE
//        StandardSocketOptions.SO_LINGER
//        StandardSocketOptions.SO_RCVBUF
//        StandardSocketOptions.SO_SNDBUF
//        StandardSocketOptions.SO_REUSEADDR




        while (true) {
            //接受客户端的连接
//            Thread.sleep(1000);
            SocketChannel client = ss.accept(); //不会阻塞?  -1 NULL
            //accept  调用内核了:1,没有客户端连接进来,返回值?在BIO 的时候一直卡着,但是在NIO ,不卡着,返回-1,NULL
            //如果来客户端的连接,accept 返回的是这个客户端的fd  5,client  object
            //NONBLOCKING 就是代码能往下走了,只不过有不同的情况

            if (client == null) {
             //   System.out.println("null.....");
            } else {
                client.configureBlocking(false); //重点  socket(服务端的listen socket<连接请求三次握手后,往我这里扔,我去通过accept 得到  连接的socket>,连接socket<连接后的数据读写使用的> )
                int port = client.socket().getPort();
                System.out.println("client..port: " + port);
                clients.add(client);
            }

            ByteBuffer buffer = ByteBuffer.allocateDirect(4096);  //可以在堆里   堆外

            //遍历已经链接进来的客户端能不能读写数据
            for (SocketChannel c : clients) {   //串行化!!!!  在这里可以使用多线程!!
                int num = c.read(buffer);  // >0  -1  0   //不会阻塞
                if (num > 0) {
                    buffer.flip();
                    byte[] aaa = new byte[buffer.limit()];
                    buffer.get(aaa);

                    String b = new String(aaa);
                    System.out.println(c.socket().getPort() + " : " + b);
                    buffer.clear();
                }


            }
        }
    }

}

网络I/O与磁盘I/O_第2张图片
优势:规避了多线程带来的内存&cpu问题
弊端:连接数一多,假如只有一个连接有数据,则在用户空间每遍历一次都要对每个连接发起一次系统调用recv,从用户态切换到内核态,那么其他连接的遍历是无意义的,时间复杂度是O(N)。

3. 多路复用器

同步非阻塞式IO,是对NIO的优化。"多路"是指多个Socket连接, “复用” 指的是复用同一个线程

3.1 select & poll

select其实就是把NIO中用户态要遍历的fd数组(List集合)拷贝到了内核态,并且select能感知到有事件发生, 让内核态来遍历,返回fds的状态,再在用户空间去遍历这些fds,进行相应的操作

select监视的socket默认是1024个,poll没有这个限制而已
网络I/O与磁盘I/O_第3张图片
优势:通过一次系统调用,把fds传递给内核,内核进行遍历,减少了系统调用的次数,这里的调用是O(1)的时间复杂度,然后多路复用器返回的是各连接的状态
弊端:

  • 每一次调用select( ), fd数组都会被从用户态拷贝到内核态, 仍具有很大开销
  • select只是感知到了有事件发生, 并没有通知用户态具体是哪一个socket有数据, 返回状态后仍需要O(n)的遍历

3.2 epoll

epoll可以解决上述的两个问题
流程

int s = socket(AF_INET, SOCK_STREAM, 0);   
bind(s, ...)
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1){
	//检查一下rdllist有没有数据
    int n = epoll_wait(...)
    for(接收到数据的socket){ //这里不是所有socket
        //接收数据处理
    }
}

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,如下所示:

struct eventpoll {
  ...
  /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
  也就是这个epoll监控的事件*/
  struct rb_root rbr;
  /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
  struct list_head rdllist;
  ...
};

调用epoll_create时,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。若是多核cpu,这个将对应的fs放入到双向链表中的操作用另一个cpu操作,这样与epoll_ctl或者epoll_wait是并行的。

当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_wait效率非常高。epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。

总结:

  1. 执行epoll_create()时,创建了红黑树和就绪链表
  2. 执行epoll_ctl()时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
  3. 执行epoll_wait()时立刻返回准备就绪链表里的数据即可。

优势:

  • 只需要把fd数组从用户态拷贝到内核态一次, 而不用每次调用epoll都进行拷贝fd数组
  • 使用事件通知机制, 每次socket中有数据会主动通知内核, 并加入到就绪链表中, 便不需要遍历所有的socket, 而是只遍历就绪列表, 复杂度为O(1)
    网络I/O与磁盘I/O_第4张图片

注意无论是BIO还是NIO还是多路复用器,都是需要程序自己读取数据的,那么这些I/O模型都是同步I/O模型,只不过有阻塞和非阻塞之分,不过像Windows 的 IOCP,内核有线程将数据拷贝到程序的内存空间这就是异步I/O模型。

相关代码

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;

public class SocketMultiplexingSingleThreadv2 {

    private ServerSocketChannel server = null;
    private Selector selector = null;   //linux 多路复用器(select poll epoll) nginx  event{}
    int port = 9090;

    public void initServer() {
        try {
            server = ServerSocketChannel.open();
            server.configureBlocking(false);
            server.bind(new InetSocketAddress(port));
            selector = Selector.open();  //  select  poll  *epoll
            server.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start() {
        initServer();
        System.out.println("服务器启动了。。。。。");
        try {
            while (true) {
//                Set keys = selector.keys();
//                System.out.println(keys.size()+"   size");
                while (selector.select(50) > 0) {
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();
                    Iterator<SelectionKey> iter = selectionKeys.iterator();
                    while (iter.hasNext()) {
                        SelectionKey key = iter.next();
                        iter.remove();
                        if (key.isAcceptable()) {
                            acceptHandler(key);
                        } else if (key.isReadable()) {
//                            key.cancel();  //现在多路复用器里把key  cancel了
                            System.out.println("in.....");
                            key.interestOps(key.interestOps() | ~SelectionKey.OP_READ);

                            readHandler(key);//还是阻塞的嘛? 即便以抛出了线程去读取,但是在时差里,这个key的read事件会被重复触发

                        } else if(key.isWritable()){  //我之前没讲过写的事件!!!!!
                            //写事件<--  send-queue  只要是空的,就一定会给你返回可以写的事件,就会回调我们的写方法
                            //你真的要明白:什么时候写?不是依赖send-queue是不是有空间
                            //1,你准备好要写什么了,这是第一步
                            //2,第二步你才关心send-queue是否有空间
                            //3,so,读 read 一开始就要注册,但是write依赖以上关系,什么时候用什么时候注册
                            //4,如果一开始就注册了write的事件,进入死循环,一直调起!!!
//                            key.cancel();
                            key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);



                            writeHandler(key);
                        }
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void writeHandler(SelectionKey key) {
        new Thread(()->{
            System.out.println("write handler...");
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = (ByteBuffer) key.attachment();
            buffer.flip();
            while (buffer.hasRemaining()) {
                try {

                    client.write(buffer);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            buffer.clear();
//            key.cancel();

//            try {
                client.shutdownOutput();
//
                client.close();
//
//            } catch (IOException e) {
//                e.printStackTrace();
//            }
        }).start();

    }

    public void acceptHandler(SelectionKey key) {
        try {
            ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
            SocketChannel client = ssc.accept();
            client.configureBlocking(false);
            ByteBuffer buffer = ByteBuffer.allocate(8192);
            client.register(selector, SelectionKey.OP_READ, buffer);
            System.out.println("-------------------------------------------");
            System.out.println("新客户端:" + client.getRemoteAddress());
            System.out.println("-------------------------------------------");

        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void readHandler(SelectionKey key) {
        new Thread(()->{
            System.out.println("read handler.....");
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = (ByteBuffer) key.attachment();
            buffer.clear();
            int read = 0;
            try {
                while (true) {
                    read = client.read(buffer);
                    System.out.println(Thread.currentThread().getName()+ " " + read);
                    if (read > 0) {
                        key.interestOps(  SelectionKey.OP_READ);

                        client.register(key.selector(),SelectionKey.OP_WRITE,buffer);
                    } else if (read == 0) {

                        break;
                    } else {
                        client.close();
                        break;
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();

    }

    public static void main(String[] args) {
        SocketMultiplexingSingleThreadv2 service = new SocketMultiplexingSingleThreadv2();
        service.start();
    }
}

3.3 Redis的IO多路复用

网络I/O与磁盘I/O_第5张图片
用epoll函数来实现IO多路复用, 将连接信息和事件放到队列中, 事件分派器将队列中的事件分发给事件处理器(这一步是单线程的, 所以Redis核心是单线程模型)

Redis基于Reactor模式开发了网络(文件)事件处理器:

  1. 多个Socket套接字连接
  2. IO多路复用器(epoll)
  3. 事件分派器
  4. 事件处理器Handler

具体的redis流程可以看 Redis 究竟是单线程还是多线程呢?

4. Reactor模型

Reactor 模式也叫做反应器设计模式,是一种为处理服务请求并发提交到一个或者多个服务处理器的事件设计模式。当请求抵达后,通过服务处理器将这些请求采用多路分离的方式分发给相应的请求处理器。
网络I/O与磁盘I/O_第6张图片
主要有以下三种模型:

  • 单 Reactor 单线程模型
  • 单 Reactor 多线程模型
  • 主从 Reactor 多线程模型
    网络I/O与磁盘I/O_第7张图片

具体参考以下文章:
Java IO篇:什么是 Reactor 网络模型?
Reactor 模式

5. Netty

参考:
万字长文带你深入理解netty
Netty实战入门详解

Netty是一款基于NIO(Nonblocking I/O,非阻塞IO)开发的网络通信框架, 对 JDK 自带的 NIO 的 API 进行了封装,像ElasticSearch就是使用了Netty框架。

Netty的线程模型就是类似于主从 Reactor 多线程模型
网络I/O与磁盘I/O_第8张图片
1、如何理解NioEventLoop和NioEventLoopGroup
NioEventLoop实际上就是工作线程,可以直接理解为一个线程。NioEventLoopGroup是一个线程池,线程池中的线程就是NioEventLoop。
实际上bossGroup中有多个NioEventLoop线程,每个NioEventLoop绑定一个端口,也就是说,如果程序只需要监听1个端口的话,bossGroup里面只需要有一个NioEventLoop线程就行了。

2、每个NioEventLoop都绑定了一个Selector,所以在Netty的线程模型中,是由多个Selecotr在监听IO就绪事件。而Channel注册到Selector。

3、一个Channel绑定一个NioEventLoop,相当于一个连接绑定一个线程,这个连接所有的ChannelHandler都是在一个线程中执行的,避免了多线程干扰。更重要的是ChannelPipline链表必须严格按照顺序执行的。单线程的设计能够保证ChannelHandler的顺序执行。

4、一个NioEventLoop的selector可以被多个Channel注册,也就是说多个Channel共享一个EventLoop。EventLoop的Selecctor对这些Channel进行检查。

优点:
1、高性能

  • 采用异步非阻塞的IO类库,基于Reactor模式实现,解决了传统同步阻塞IO模式
  • TCP接收和发送缓冲区使用直接内存代替堆内存,避免了内存复制,提升了IO读取和写入的性能,在netty里面通过ByteBuf可以直接对这些数据进行直接操作,从而加快了传输速度
  • 采用环形数组缓冲区实现无锁化并发编程,代替传统的线程安全或锁

2、可靠性

  • 链路有效监测(心跳和空闲检测)
  • 内存保护机制

三、磁盘I/O

参考:Linux 缓存IO、直接IO、内存映射
网络I/O与磁盘I/O_第9张图片

磁盘IO延时:

  • 寻道时间:把磁头移动到指定磁道上所经历的时间
  • 旋转延时间:指定扇区移动到磁头下面所经历的时间
  • 传输时间:数据的传输时间(数据读出或写入的时间)

1. 缓存I/O

读操作:
当应用程序调用read()方法时,操作系统检查内核缓冲区是否存在需要的数据,如果存在,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用;如果内核缓冲区没有需要的数据,则通过DMA方式从磁盘中读取数据到内核缓冲区(DMA Copy),然后把内核空间的数据copy到用户空间(Cpu Copy)(上图绿色实线部分)

写操作:
当应用程序调用write()方法时,应用程序将数据从用户空间copy到内核空间的缓冲区(如果用户空间没有相应的数据,则需要从磁盘–>内核缓冲区–>用户缓冲区依次读取),这时对用户程序来说写操作就已经完成,至于什么时候把数据再写到磁盘,由操作系统决定。操作系统将要写入磁盘的数据先保存于系统为写缓存分配的内存空间中,当保存到内存池中的数据达到一个程度时,便将数据保存在硬盘中。这样可以减少实际的磁盘操作,有效的保护磁盘免于重复的读写操作而导致的损坏,也能减少写入所需的时间。除非应用程序显式的调用了sync命令,立即把数据写入磁盘。
如果应用程序没准备好写的数据,则必须先从磁盘读取数据才能执行写操作,这时会涉及到四次缓冲区的copy:
a、第一次从磁盘的缓冲区读取数据到内核缓冲区(DMA Copy);
b、第二次从内核缓冲区复制到用户缓冲区(Cpu Copy);
c、第三次从用户缓冲区写到内核缓冲区(Cpu Copy);
d、第四次从内核缓冲区写到磁盘(DMA Copy);(上图绿色实线部分双向箭头)

缓存I/O的缺点:在缓存I/O机制中,DMA方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输。这样的话,数据 在传输过程中需要在应用程序地址空间和页缓存之间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。对于某些特殊的应用程序来说,避开操作系统内核缓冲区,而直接在应用程序地址空间和磁盘之间传输数据,会比使用操作系统内核缓冲区获取更好的性能,因此引入"Direct I/O"。

2. Direct I/O

凡是通过直接 I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要页缓存的支持。Direct I/O 本质是 DMA 设备把数据从用户空间拷贝到设备,或是从设备拷贝到用户空间。

进程在打开文件的时候设置对文件的访问模式为 O_DIRECT ,这样就等于告诉操作系统进程在接下来使用 read() 或者 write() 系统调用去读写文件的时候使用的是直接 I/O 方式,所传输的数据均不经过操作系统内核缓存空间。

直接I/O优点:

  • 数据直接缓存在应用层,应用能够更加灵活的操作数据
  • 减少操作系统缓冲区和用户地址空间的拷贝次数
  • 降低CPU开销和内存带宽

直接I/O缺点:

  • 直接 I/O 的开销也很大,如果访问的数据不在应用程序缓存中,因为系统基本不缓存数据,那么每次数据都会直接从磁盘加载
  • 磁盘的读写是通过磁头的切换到不同的磁道上读取和写入数据,如果需要写入数据在磁盘位置相隔比较远,就会导致寻道的时间大大增加,写入读取的效率大大降低。
  • O_DIRECT也不能确保数据每次写入的时候同步写入磁盘,因此如果需要数据同步写入磁盘还需要手工设置O_SYNC标识或者手工调用fsync方法

通常直接IO与异步IO结合使用,会得到比较好的性能。

3. mmap

mmap 本质是内存共享机制,它把 page cache 地址空间映射到用户空间,换句话说,mmap 是一种特殊的 Buffered I/O,这种方式的目的同样是减少数据在用户空间和内核空间之间的拷贝操作,当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。

网络I/O与磁盘I/O_第10张图片
mmap 内存映射过程:

  • 进程在虚拟地址空间中为映射创建虚拟映射区域
  • 内核把文件物理地址和进程虚拟地址进行映射
  • 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
    换句话说,在调用 mmap 后,只是在进程的虚拟空间中分配了一段空间,真实的物理地址还不会分配的。
  • 当进程第一次访问这段空间(当作内存一样),CPU 陷入 OS 内核执行异常处理。然后异常处理会在这个时间分配物理内存,并用文件的内容填充这片内存,然后才返回进程的上下文,这时进程才会感知到这片内存里有数据

网络I/O与磁盘I/O_第11张图片

4. write、flush、fsync

参考:Linux I/O操作fsync后数据就安全了么(fsync、fwrite、fflush、mmap、write barriers详解)
网络I/O与磁盘I/O_第12张图片

你可能感兴趣的:(java,网络,I/O)