深究IO模型

0.前言

本文讲的IO模型主要是讲的是网络IO模型,而且编程语言使用的是Java语言。对于网络IO,个人觉得最重要一点是要弄清是从哪个方面来划分的。主要分成两个角度:一个是操作系统的角度,一个是Java语言的角度

且这篇博客主要讲的Java语言角度的三个IO,操作系统角度的IO简单介绍而已,Java语言角度的三个IO会细讲,且给出实际的代码例子。

网上很多博客没有说清楚划分角度,只是从某一个角度出发,初学时吃了不少苦头,因为不同角度的IO模型,有些知识是冲突的。大概划分入下图:

在这里插入图片描述
操作系统层面的IO是底层IO,Java语言层面的是对它进行了一个封装,BIO用的是阻塞IO,NIO底层用的是多路复用,AIO是异步IO。其实这样看也还可以接受,但NIO也叫同步非阻塞IO,这个可能就会引起歧义,但它的底层使用的是多路复用IO,下面将会细讲到。


1.知识补充

1.1 IO流程

在学习IO模型之前,一定一定要对IO流程有一定的了解,不然就会完成不懂在说什么,如果最好,是要有计算机网络和操作系统的知识。如下图,这就是一次IO流程,下面讲一下:
在这里插入图片描述
先讲一下IO流程涉及的各个部分:

  • socket:可以看成就是客户端发给服务端的一个连接,服务器接受之后,客户端就从这里发送IO请求

  • 网卡:在IO流程里,它是用来接受发送过来的socket的,也就是接受IO请求

  • 应用程序:应用程序指的是服务器上的程序,而不是客户端的程序

    • 线程:同理,线程也是服务器上的程序的线程。网上有一些说法把它叫成用户线程,个人觉得很容易引起歧义(被误导了很久,一直以为是客户端),其实这个用户是与操作系统的内核相对应的。
  • 系统内核:IO都是要通过系统内核的操作的,为什么呢?因为应用程序是无法直接操作硬件的,它无法直接从网卡里获取socket,而是必须通过操作系统来操作,所以这里也就有了操作系统这一层。

  • 内存:应用程序要socket,但它自己拿不了所以它叫操作系统帮它拿,拿到之后呢操纵系统要怎么给应用程序呢?通过内存呀。

    所以首先操作系统会去网卡里面拿socket,然后存在属于操作系统的内存,这个就是内核空间,这一步也叫做数据准备;然后再把它给应用程序,也就是把数据从内核空间拷贝到应用程序的内存,也叫用户空间,这一步叫数据拷贝;直到数据拷贝完成,应用程序才算真正获取了一次IO请求。

再来讲一下一些API:

  • accept():这个是socket的API,用来连接socket,socket的连接也相当于是一次IO
  • read():这个也是socket的API,应用程序从socket里读取数据,也就是IO请求了
  • recvForm():这个是操作系统的函数了,作用就是让操作系统从网卡里读取socket,然后把数据放到内核空间,再从内核空间拷贝到用户空间,accept()read()都会调用recvForm()

最后来个总结,IO的流程就是:首先应用程序的线程调用accept()方法,accept()调用操作系统的recvForm(),然后操作系统从网卡里拿socket,然后放到内核空间,然后再把数据考核到用户空间,此时就算socket连接完成。

然后应用程序调用read(),也就是再走以上的步骤,它的作用是读取socket里的数据。


1.2 阻塞和非阻塞

下面来讲讲这两对概念:阻塞和非阻塞、同步和异步,这个算是IO模型的难点和重点了,很多人会把他们混淆。你只要懂了IO流程,这个其实是十分简单的。

先简单讲一下,这两对概念的差别和作用:

  • 区别:
    对于上面的流程图,我们可以分出两个对象:应用程序和系统内核。阻塞和非阻塞是针对应用程序的线程,同步和异步是针对系统内核的,它们的针对对象不同,这个就是它们之间的区别。
  • 作用:
    我们知道IO是有很多模型的,那这些模型有什么区别呢?其实就是使用的是阻塞IO还是非阻塞IO,是同步IO还是异步IO。而这些不同的模型适用于不同的场景需要。这两对概念就是这些IO模型的原理。

废话不说啦,来细讲阻塞和非阻塞。根据上面的流程图,应用程序通过accept()连接socket,但是假如此时网卡没有socket呢?应该程序通过read()去socket里面读取数据,但是假如此时socket里没有数据呢?发生这种状况时该咋办?无非就是下面两种结果:

  • 读不到?不允许!我一定要数据,我就硬等。
  • 读不到?行吧,我溜了。

以上两种结果就对应了阻塞和非阻塞,阻塞就是应用程序调用操作系统去recvForm()的时候发现没有数据,所以此时应用程序的线程就会被阻塞(硬等),直到接收到数据。

非阻塞就是发现没有数据的时候,应用程序不头铁,直接不等直接返回。但是没有数据咋办呢,下面的操作都是要有数据的,没数据会报错(空指针),那咋办呢?

其实非阻塞情况下,得到的结果就不一定是数据了,也有可能是一个error,这个表示数据没来没拿到。利用这个标志进行while循环,一直去请求,直到拿到数据为止。这个操作也叫做轮询。

注意一下现在讲的已经不是非阻塞的概念了,非阻塞的概念到获取不到数据就返回就结束了,这些讲的是对非阻塞的补充,因为非阻塞都是和轮询都是绑定在一起的。

还有一点需要注意 :就是非阻塞只会在数据准备那个阶段读取不到数据会返回,在拷贝数据那个阶段是会阻塞的。

个人理解,非阻塞其实也是一种阻塞,对于下面的操作来说,假如数据没好都是不会执行到它们那里去的,非阻塞和阻塞结果都是一样的。区别就是阻塞就是完全傻傻的站那里等,非阻塞就是不停地去问好了没,陷入循环。

非阻塞有好处也有坏处,好处就是它不会傻等,它能直接返回然后做一些操作,然后再去发送请求,比起阻塞它可以做一些别的操作;缺点就是不停发送请求是十分消耗资源的。

这个就是阻塞和非阻塞了,注意注意再注意,这里讲的都是针对应用程序的线程,下面将会讲到另一组概念,同步和异步。

1.3 同步和异步

同步和异步针对的就是系统内核了,看回上面的流程图,应用程序让操作系统去拿数据,操作系统拿到后,改怎么给应用程序呢?也有以下两种处理方法:

  • 操作系统拿到数据了,应用程序自己过来拿
  • 操纵系统拿到数据了,操作系统自己把数据拿去给应用程序

这个就是同步和异步,也就是说强调的是数据的返回形式,同步是应用程序自己去拷贝数据,异步是内核帮忙拷贝数据到用户空间。很多人混淆两对概念,举例子来说明一下:应用程序的线程到操作系统家里,找操作系统然他帮忙拿数据。

  • 同步:操作系统拿到了,应用程序亲手把这个数据拿回去自己家里。很多人混淆是认为在操作系统去拿数据的时候,应用程序会一直在操作系统家里等,其实不是的,应用程序想干嘛就干嘛,他要硬等就是阻塞(同步阻塞),他不硬等就是非阻塞(同步非阻塞)。

  • 异步:操作系统拿到了,然后他顺便把数据拿到应用程序家里。这里人们误解为非阻塞原因就是,因为数据操作系统已经帮应用程序拿回家里了,那应用程序还在操作系统家里等有意义吗?没意义(所以不存在异步阻塞IO),所以应用程序都是会离开操作系统而回到自己家里的(异步非阻塞IO)。虽然线程是非阻塞的,但异步强调的主角是操作系统。

两对概念大概就是如此,再来强调一次:阻塞非阻塞说的是应用程序的线程,异步同步说的是操作系统。它们之间也可以互相搭配,所以有了同步阻塞、同步非阻塞、异步非阻塞,但不存在异步阻塞,因为没意义。


2.操作系统层面的IO模型

2.1 阻塞IO

终于来到了IO模型了,先从操作系统层面的IO模型讲起。首先是阻塞IO,阻塞IO模型我个人认为是最容易理解的。它的工作流程很简单,如下图:
在这里插入图片描述
应用程序的线程调用accept()或者read()之类会让操作系统调用recvfrom()的方法,此时发现没有数据,或者说操作系统在进行的数据准备还没准备好,那么此时线程就会被卡住,直至数据准备就绪,然后应用程序线程将其数据拷贝到自己的用户空间。

public static void main(String[] args) {
    //服务端
    ServerSocket serverSocket = null;
    //客户端
    Socket socket = null;

    InputStream inputStream = null;
    OutputStream outputStream = null;

	
    try{
        serverSocket = new ServerSocket(8080);
        while(true){
            socket = serverSocket.accept();
            //处理socket数据
            inputStream = socket.getInputStream();
            int length = 0;
            byte[] req = new byte[1024];
            while ((length= inputStream.read(req)) != -1){
                System.out.println(new String(req,0,length));
                outputStream = socket.getOutputStream();
                outputStream.write("res".getBytes());
            }
        }
    }catch (Exception e){
        e.printStackTrace();
    }
    finally {
        try {
            serverSocket.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

很简单对吧,其实就是阻塞的概念而已。但有一个问题,现在是单线程情况,也就一个IO请求,你想卡多久就卡多久。但假如是高并发呢?有多个IO请求怎么办?总不能让一个卡着,其他全部在等他吧。

那怎么解决多个IO请求的问题呢?新建一个线程咯,把处理数据的任务丢给新建的线程,卡也是卡新建的线程,原本的线程继续处理下一个请求,也就是一个请求对应一个线程。如下面伪代码:

public class Server {
    public static void main(String[] args) {
        //服务端
        ServerSocket serverSocket = null;
        //客户端
        Socket socket = null;
        try{
            serverSocket = new ServerSocket(8080);
            while(true){
                socket = serverSocket.accept();
                //新建线程,把处理数据的任务丢给别的线程
                new Thread(new handler(socket)).start();
            }
        }catch (Exception e){
            e.printStackTrace();
        } finally {
            try {
                serverSocket.close();
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

public class handler implements Runnable{
    private Socket socket;

    public handler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        InputStream inputStream = null;
        OutputStream outputStream = null;
        try{
            inputStream = socket.getInputStream();
            int length = 0;
            byte[] req = new byte[1024];
            while ((length= inputStream.read(req)) != -1){
                System.out.println(new String(req,0,length));
                outputStream = socket.getOutputStream();
                outputStream.write("res".getBytes());
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

但这样其实也是存在问题的,假如有1000个IO请求发送过来,难不成你还新建1000个线程去处理?这样显然是有问题的,当然我们也可以用线程池去处理,但线程池能支持的并发数也是有限的。

所以这个也就是阻塞IO的缺点所在,每处理一个IO请求都要新建一个线程,但高并发的时候这个模型就处理不过来了,所以阻塞IO适合低并发的场景。


2.2 非阻塞IO

阻塞IO的缺点很明显,在处理多个IO请求的时候要新建多个线程。所以阻塞IO并不适合高并发情况下。所以还有没有其他方式来处理多个IO请求呢?

有的,非阻塞IO就是其中一种,但要注意:非阻塞IO模型只适用于特定场景,在实际运用里,它几乎不会被使用,我们也是使用下面会讲到的IO多路复用来解决高并发问题。

下面来讲一下非阻塞IO,先回顾下非阻塞的概念,应用程序调用recvform()会第一时间返回,但返回的不一定是数据,也有可能是一个标志error。我们也还提到过一个概念——轮询,非阻塞IO模型正是利用这一点,来完成处理多个IO请求。

在这里插入图片描述
在这里插入图片描述

如图,线程一次性接收多个socket,并把它们维护在一个链表里面。然后使用一个死循环,在死循环里面不停地遍历每个socket,调用recvform(notBlock)函数请求数据,有数据就处理,没有就直接去往下个socket。伪代码如下:

//死循环
while(true){
	//遍历socket链表
	for(socket : sockets){
		//判断socket是否有数据
		if(socket.read() != error){
			//处理数据
			process();
		}
	}
}

或许你会觉得不对呀,这样非阻塞IO模型不是挺好的嘛,解决了并发问题,为啥还不用它呢?你仔细看清楚,它是使用了一个死循环,这样的话他就会一直不停地发送请求,询问是否有数据。

假如一个socket一直没有数据传过来,非阻塞IO模型像个憨憨一样,一直问一直问,这样无疑是十分消耗资源的,所以这个也就是非阻塞IO模型的弊端,因为这个缺点太过致命,所以非阻塞IO一直存在于理论之中。


2.3 多用复路IO

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值