IO系列2-深入理解五种IO模型

1,665 阅读17分钟

欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

本篇文章主要讲解下面问题:

  1. IO模型重要概念,比如同步/异步,阻塞/非阻塞等
  2. 五种IO模型详解,基于java语言给出代码理解
  3. 总结对比五种IO模型

1. 理解重要概念

从网上找来一个段子,大家先看看:

上午开会,错过了公司食堂的饭点, 中午就和公司的首席架构师一起去楼下的米线店去吃米线。我们到了一看,果然很多人在排队。
架构师马上发话了:嚯, 请求排队 啊!你看这位收银点菜的, 像不像nginx的反向代理? 只收请求,不处理,把请求都发给后厨去处理。
我们交了钱,拿着号离开了点餐收银台,找了个座位坐下等餐。
架构师:你看,这就是 异步处理 ,我们下了单就可以离开等待,米线做好了会通过小喇叭 回调 我们去取餐;
如果 同步 处理,我们就得在收银台站着等餐,后面的请求无法处理,客户等不及肯定会离开了。 
接下里架构师盯着手中的纸质号牌。 
架构师:你看,这个纸质号牌在后厨“服务器”那里也有,这不就是表示 会话的ID 吗? 
有了它就可以把大家给区分开,就不会把我的排骨米线送给别人了。过了一会, 排队的人越来越多,已经有人表示不满了,可是收银员已经满头大汗,忙到极致了。 
架构师:你看他这个系统缺乏 弹性扩容 , 现在这么多人,应该增加收银台,可以没有其他收银设备,老板再着急也没用。 
老板看到在收银这里帮不了忙,后厨的订单也累积得越来越多, 赶紧跑到后厨亲自去做米线去了。 
架构师又发话了:幸亏这个系统的后台 有并行处理能力 ,可以随意地增加资源来处理请求(做米线)。 
我说:他就这点儿资源了,除了老板没人再会做米线了。 
不知不觉,我们等了20分钟, 但是米线还没上来。 
架构师:你看,系统的处理能力达到极限, 超时了 吧。 
这时候收银台前排队的人已经不多了,但是还有很多人在等米线。 
老板跑过来让这个打扫卫生的去收银,让收银小妹也到后厨帮忙。打扫卫生的做收银也磕磕绊绊的,没有原来的小妹灵活。 
架构师:这就叫 服务降级 ,为了保证米线的服务,把别的服务都给关闭了。 
又过了20分钟,后厨的厨师叫道:237号, 您点的排骨米线没有排骨了,能换成番茄的吗? 
架构师低声对我说:瞧瞧, 人太多,  系统异常 了。然后他站了起来:不行,系统得进行 补偿操作:退费。 
说完,他拉着我,饿着肚子,头也不回地走了。

1.1 同步/异步

  • 同步:调用者要一直等待调用结果的通知后才能进行后续的执行,现在就要,我可以等,等出结果为止
  • 异步:指被调用方先返回应答让调用者先回去,然后再计算调用结果,计算完最终结果后再回调通知并返回给调用方
  • 同步和异步的理解:同步、异步的讨论对象是被调用者(服务提供者),重点在于获得调用结果的消息通知方式

1.2 阻塞/非阻塞

  • 阻塞:调用方一直在等待而且别的事情什么都不做,当前进/线程会被挂起,啥都不干
  • 非阻塞:调用在发出去后,调用方先去忙别的事情,不会阻塞当前进/线程,而会立即返回
  • 阻塞非阻塞理解:阻塞、非阻塞的讨论对象是调用者(服务请求者),重点在于等消息时候的行为,调用者是否能干其它事

1.3 四种方式组合

  • 同步阻塞:服务员说快到你了,先别离开我后台看一眼马上通知你。客户在海底捞火锅前台干等着,啥都不干。
  • 同步非阻塞:服务员说快到你了,先别离开。客户在海底捞火锅前台边刷抖音边等着叫号
  • 异步阻塞:服务员说还要再等等,你先去逛逛,一会儿通知你。客户怕过号在海底捞火锅前台拿着排号小票啥都不干,一直等着店员通知
  • 异步非阻塞:服务员说还要再等等,你先去逛逛,一会儿通知你。拿着排号小票+刷着短视频,等着店员通知

1.4 Socket

Socket 中文翻译为套接字,是计算机网络中进程间进行双向通信的端点的抽象。一个 Socket 代表了网络通信的一端,是由操作系统提供的进程间通信机制。

  • 在操作系统中,通常会为应用程序提供一组应用程序接口,称为 Socket 接口(Socket API)。应用程序可以通过 Socket 接口,来使用网络 Socket,以进行数据的传输。
  • 一个 Socket 由IP地址和端口组成,即:Socket 地址 = IP地址 : 端口号。
  • 在同一台计算机上,TCP 协议与 UDP 协议可以同时使用相同的端口(Port),而互不干扰。
  • 要想实现网络通信,至少需要一对 Socket,其中一个运行在客户端,称之为 Client Socket;另一个运行在服务器端,称之为 Server Socket。
  • Socket 之间的连接过程可以分为三个步骤:(1)服务器监听;(2)客户端连接;(3)连接确认。 image.png

1.5 Socket缓冲区

每个 Socket 被创建后,都会在内核中分配两个缓冲区:输入缓冲区和输出缓冲区。

  • 通过 Socket 发送数据并不会立即向网络中传输数据,而是先将数据写入到输出缓冲区中,再由 TCP 协议将数据从输出缓冲区发送到目标主机。
  • 通过 Socket 接收数据也是如此,也是从输入缓冲区中读取数据,而不是直接从网络中读取。 image.png

1.6 用户空间、内核空间、系统调用

操作系统的进程空间可以分为用户空间(User Space)和内核空间(Kernel Space),它们需要不同的执行权限。

  • 大多数系统交互式操作需要在内核空间中运行,比如设备 IO 操作。
  • 我们的应用程序运行在用户空间,是不具备系统级的直接操作权限的。如果应用程序想要访问系统核心功能,必须通过系统调用(System Call)来完成。比如调用recv()函数,会将输入缓冲区中的内容拷贝到用户缓冲区。
  • 系统调用运行在内核空间,是操作系统为应用程序提供的接口。 image.png
    下面列举了一些 Linux 操作系统中的系统调用接口(部分函数后面章节会用到):
  • socketcall socket系统调用
  • socket 建立socket
  • bind 绑定socket到端口
  • connect 连接远程主机
  • accept 响应socket连接请求
  • send 通过socket发送信息
  • sendto 发送UDP信息
  • recv 通过socket接收信息
  • recvfrom 接收UDP信息
  • listen 监听socket端口
  • select 对多路同步IO进行轮询
  • shutdown 关闭socket上的连接
  • sigaction 设置对指定信号的处理方法

2. 五种IO模型

IO 模型是指:用什么样的通道或者说是通信模式进行数据的传输,这很大程序上决定了程序通信的性能。Linux 系统为我们提供五种可用的 IO 模型:

  • Blocking IO - 阻塞式 IO 模型
  • NoneBlocking IO - 非阻塞式 IO 模型
  • IO multiplexing - IO 多路复用模型
  • signal driven IO - 信号驱动 IO 模型
  • asynchronous IO - 异步 IO 模型

2.1 阻塞式 IO 模型(BIO)

阻塞式 IO (Blocking IO):应用进程从发起 IO 系统调用,至内核返回成功标识,这整个期间是处于阻塞状态的。

  • 使用系统调用,并一直阻塞直到内核将数据准备好,之后再由内核缓冲区复制到用户态,在等待内核准备的这段时间什么也干不了
  • 下图函数调用期间,一直被阻塞,直到数据准备好且从内核复制到用户程序才返回,这种IO模型为阻塞式IO image.png

2.1.1 java代码实现BIO

从代码角度理解BIO:我们启动一个BioServer,两个BioClient01

2.1.1.1 accept()方法

public class BioServer {

    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket(6380);

        while(true)
        {
            System.out.println("模拟RedisServer启动-----111 等待连接");
            Socket socket = serverSocket.accept();
            System.out.println("-----222 成功连接");
            System.out.println();
        }
    }

}

public class BioClient01 {

    public static void main(String[] args) throws IOException {
        System.out.println("------RedisClient01 start");
        Socket socket = new Socket("127.0.0.1", 6380);
    }

}
BioClient02  跟BioClient01 代码一样

我们发现BioServer一直阻塞在accept()方法中,说明accept()是阻塞方法

2.1.1.2 read()方法

/**
 * @author hsfxuebao
 * Created on 2021-12-28
 */
public class BioServerBIO {
    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket(6379);

        while(true) {
            System.out.println("-----111 等待连接");
            Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
            System.out.println("-----222 成功连接");

            InputStream inputStream = socket.getInputStream();
            int length = -1;
            byte[] bytes = new byte[1024];
            System.out.println("-----333 等待读取");
            while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
            {
                System.out.println("-----444 成功读取"+new String(bytes,0,length));
                System.out.println("====================");
                System.out.println();
            }
            inputStream.close();
            socket.close();
        }
    }
}

public class BioClient01 {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1",6379);
        OutputStream outputStream = socket.getOutputStream();

        //socket.getOutputStream().write("RedisClient01".getBytes());

        while(true) {
            Scanner scanner = new Scanner(System.in);
            String string = scanner.next();
            if (string.equalsIgnoreCase("quit")) {
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("------input quit keyword to finish......");
        }
        outputStream.close();
        socket.close();
    }
}
BioClient02  跟BioClient01 代码一样

我们发现read()也是阻塞方法,这个模型存在很大的问题,如果客户端与服务端建立了连接,如果这个连接的客户端迟迟不发数据程序就会一直堵塞在read()方法上,这样其他客户端也不能进行连接,也就是一次只能处理一个客户端,对客户很不友好。我们首先想到为每一个客户端连接建立都开启一个线程,是否就可以解决了?

2.1.2 BIO多线程模式

利用多线程,只要连接了一个socket,操作系统分配一个线程来处理, 这样read()方法堵塞在每个具体线程上而不堵塞主线程,就能操作多个socket了,哪个线程中的socket有数据,就读哪个socket,各取所需,灵活统一。程序服务端只负责监听是否有客户端连接,使用 accept() 阻塞。如此而来,任何一个线程上的socket有数据发送过来,read()就能立马读到,cpu就能进行处理(tomcat7之前就是用BIO多线程来解决多连接)。

/**
 * @author hsfxuebao
 * Created on 2021-12-28
 */
public class BIOMultiThreadServer {

    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket(6379);

        while(true) {
            //System.out.println("-----111 等待连接");
            Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
            //System.out.println("-----222 成功连接");

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    int length = -1;
                    byte[] bytes = new byte[1024];
                    System.out.println("-----333 等待读取");
                    while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
                    {
                        System.out.println("-----444 成功读取"+new String(bytes,0,length));
                        System.out.println("====================");
                        System.out.println();
                    }
                    inputStream.close();
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            },Thread.currentThread().getName()).start();

            System.out.println(Thread.currentThread().getName());

        }
    }

}
public class BioClient01 {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1",6379);
        OutputStream outputStream = socket.getOutputStream();

        //socket.getOutputStream().write("RedisClient01".getBytes());

        while(true) {
            Scanner scanner = new Scanner(System.in);
            String string = scanner.next();
            if (string.equalsIgnoreCase("quit")) {
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("------input quit keyword to finish......");
        }
        outputStream.close();
        socket.close();
    }

}
BioClient02  跟BioClient01 代码一样

大家认为这就解决问题了?NO,我们一起来看一下这个模型存在的问题:

  • 利用多线程模型,每来一个客户端,就要开辟一个线程,如果来1万个客户端,那就要开辟1万个线程。 
  • 在操作系统中用户态不能直接开辟线程,需要调用内核来创建的一个线程, 这其中还涉及到用户状态的切换(上下文的切换),十分耗资源。 那么,有同学就说利用线程池解决呀?当然线程池可以解决客户端连接少的情况下,但是客户端很大的情况下,没法预估线程池要多大,内存是否够,也不可行。
    那么引出重量级嘉宾-NIO(非阻塞IO)方式。因为read()方法堵塞了,所有要开辟多个线程,如果什么方法能使read()方法不堵塞,这样就不用开辟多个线程了,这就用到了另一个IO模型,NIO(非阻塞式IO)。

2.1.3 C10K问题

C10K 就是 Client 10000 问题,即「在同时连接到服务器的客户端数量超过 10000 个的环境中,即便硬件性能足够, 依然无法正常提供服务」,简而言之,就是单机1万个并发连接问题。这个概念最早由 Dan Kegel 提出并发布于其个人站点( www.kegel.com/c10k.html

2.1.4 BIO典型应用和特点

典型应用:阻塞socket、Java BIO
特点:

  • 进程阻塞挂起不消耗CPU资源,及时响应每个操作;
  • 实现难度低、开发应用较容易;
  • 适用并发量小的网络应用开发;

2.2 非阻塞式 IO 模型(NIO)

非阻塞式IO(Non-Blocking IO):应用进程可以将 Socket 设置为非阻塞,这样应用进程在发起 IO 系统调用后,会立刻返回。应用进程可以轮询的发起 IO 系统调用,直到内核返回成功标识。

  • 内核在没有准备好数据的时候会返回错误码,而调用程序不会休眠,而是不断轮询询问内核数据是否准备好
  • 下图函数调用时,如果数据没有准备好,不像阻塞式IO那样一直被阻塞,而是返回一个错误码。数据准备好时,函数成功返回。
  • 应用程序对这样一个非阻塞描述符循环调用成为轮询。
  • 非阻塞式IO的轮询会耗费大量cpu,通常在专门提供某一功能的系统中才会使用。通过为套接字的描述符属性设置非阻塞式,可使用该功能 image.png

2.2.1 BIO存在的问题

问题点:

  • accept()、read()方法是阻塞
  • 在阻塞式 I/O 模型中,应用程序在从调用 recvfrom 开始到它返回有数据报准备好这段时间是阻塞的,recvfrom 返回成功后,应用进程开始处理数据报。 对问题的思考:
  • 每个线程分配一个连接
  • 每个线程分配一个连接,必然会产生多个,既然是多个socket链接必然需要放入进容器,纳入统一管理

2.2.2 java代码实现NIO

定义socketList管理所有的连接。

public class NioServer {

    static ArrayList<SocketChannel> socketList = new ArrayList<>();
    static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    public static void main(String[] args) throws IOException {
        System.out.println("---------RedisServerNIO 启动等待中......");
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        // 绑定ip和端口
        serverSocket.bind(new InetSocketAddress("127.0.0.1",6379));
        serverSocket.configureBlocking(false);//设置为非阻塞模式

        while (true) {
            for (SocketChannel element : socketList)
            {
                int read = element.read(byteBuffer);
                if(read > 0)
                {
                    System.out.println("-----读取数据: "+read);
                    byteBuffer.flip();
                    byte[] bytes = new byte[read];
                    byteBuffer.get(bytes);
                    System.out.println(new String(bytes));
                    byteBuffer.clear();
                }
            }

            SocketChannel socketChannel = serverSocket.accept();
            if(socketChannel != null)
            {
                System.out.println("-----成功连接: ");
                socketChannel.configureBlocking(false);//设置为非阻塞模式
                socketList.add(socketChannel);
                System.out.println("-----socketList size: "+socketList.size());
            }
        }
    }
}
public class NioClient01 {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1",6379);
        OutputStream outputStream = socket.getOutputStream();

        //socket.getOutputStream().write("RedisClient01".getBytes());

        while(true) {
            Scanner scanner = new Scanner(System.in);
            String string = scanner.next();
            if (string.equalsIgnoreCase("quit")) {
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("------input quit keyword to finish......");
        }
        outputStream.close();
        socket.close();
    }

}
NioClient02 同NioClient01 代码一样

NIO存在的问题:

  • 非阻塞式IO的轮询会耗费大量cpu 如果想解决这个问题,需要引入第三种IO模型:IO多路复用模型。

2.2.2 典型应用和特点

典型应用:socket是非阻塞的方式(设置为NONBLOCK) 特点:

  • 进程轮询(重复)调用,消耗CPU的资源;
  • 实现难度低、开发应用相对阻塞IO模式较难;
  • 适用并发量较小、且不需要及时响应的网络应用开发;

2.3 IO多路复用

IO 多路复用(IO Multiplexin):可以将多个应用进程的 Socket 注册到一个 Select(多路复用器)上,然后使用一个进程来监听该 Select(该操作会阻塞),Select 会监听所有注册进来的 Socket。只要有一个 Socket 的数据准备好,就会返回该Socket。再由应用进程发起 IO 系统调用,来完成数据读取。

  • 类似与非阻塞,只不过轮询不是由用户线程去执行,而是由内核去轮询,内核监听程序监听到数据准备好后,调用内核函数复制数据到用户态
  • 下图中select这个系统调用,充当代理类的角色,不断轮询注册到它这里的所有需要IO的文件描述符,有结果时,把结果告诉被代理的recvfrom函数,它本尊再亲自出马去拿数据
  • IO多路复用至少有两次系统调用,如果只有一个代理对象,性能上是不如前面的IO模型的,但是由于它可以同时监听很多套接字,所以性能比前两者高

image.png IO多路复用涉及Linux中select、poll、epoll函数,这块我们下一篇文章详细讲解。敬请期待

2.3.1 典型应用和特点

典型应用:select、poll、epoll三种方案,nginx都选择使用这三个方案;Java NIO; 特点:

  • 专一进程解决多个进程IO的阻塞问题,性能好;Reactor模式(netty,nginx,redis都使用Reactor模式);
  • 实现、开发应用难度较大;
  • 适用高并发服务应用开发:一个进程(线程)响应多个请求;

2.4 信号驱动 IO 模型

信号驱动 IO(Signal Driven IO):可以为 Socket 开启信号驱动 IO 功能,应用进程需向内核注册一个信号处理程序,该操作并立即返回。当内核中有数据准备好,会发送一个信号给应用进程,应用进程便可以在信号处理程序中发起 IO 系统调用,来完成数据读取了。

  • 使用信号,内核在数据准备就绪时通过信号来进行通知
  • 首先开启信号驱动io套接字,并使用sigaction系统调用来安装信号处理程序,内核直接返回,不会阻塞用户态
  • 数据准备好时,内核会发送SIGIO信号,收到信号后开始进行io操作

image.png

2.4.1 特点

特点:回调机制,实现、开发应用难度大;

2.5 异步IO模型

异步 IO(Asynchronous IO): 应用进程发起 IO 系统调用后,会立即返回。当内核中数据完全准备后,并且也复制到了用户空间,会产生一个信号来通知应用进程。

  • 异步IO依赖信号处理程序来进行通知
  • 不过异步IO与前面IO模型不同的是:前面的都是数据准备阶段的阻塞与非阻塞,异步IO模型通知的是IO操作已经完成,而不是数据准备完成
  • 异步IO才是真正的非阻塞,主进程只负责做自己的事情,等IO操作完成(数据成功从内核缓存区复制到应用程序缓冲区)时通过回调函数对数据进行处理
  • unix中异步io函数以aio_或lio_打头 image.png

2.5.1 典型应用和特点

典型应用:JAVA7 AIO、高性能服务器应用 特点:

  • 不阻塞,数据一步到位;Proactor模式;
  • 需要操作系统的底层支持,LINUX 2.5 版本内核首现,2.6 版本产品的内核标准特性;
  • 实现、开发应用难度大;
  • 非常适合高性能高并发应用;

3. 五种IO模型总结

  • 前面四种IO模型的主要区别在第一阶段,他们第二阶段是一样的:数据从内核缓冲区复制到调用者缓冲区期间都被阻塞住!
  • 前面四种IO都是同步IO:IO操作导致请求进程阻塞,直到IO操作完成
  • 异步IO:IO操作不导致请求进程阻塞 image.png

参考文章:
五种IO模型介绍和对比
浅聊Linux的五种IO模型