1.2 BIO编程与其局限性

2017-01-02 22:24:47 14,712 16

  所谓BIO编程,就是使用JDK1.4之前的api进行编程,在这里我们以ServerSocket和Socket为例进行讲解,编写一个时间服务的C/S架构应用。

 client可以发送请求指令"GET CURRENT TIME"给server端,每隔5秒钟发送一次,每次server端都返回当前时间。考虑到TCP编程中,不可避免的要处理粘包、解包的处理,这里为了简化,server在解包的时候,每次读取一行,认为一行就是一个请求。

 考虑到可能会有多个client同时请求server,我们针对每个client创建一个线程来进行处理,因此架构如下所示:

Image.png

这实际上就是最简化的reactor线程模型,实际上netty使用也是这种模型,只不过稍微复杂了一点点。accpetor thread只负责与clieng建立连接,worker thread用于处理每个thread真正要执行的操作。

下面是代码实现

Server端

public class TimeServer {
    public static void main(String[] args) {
        ServerSocket server=null;
        try {
            server=new ServerSocket(8080);
            System.out.println("TimeServer Started on 8080...");
            while (true){
                Socket client = server.accept();
                //每次接收到一个新的客户端连接,启动一个新的线程来处理
                new Thread(new TimeServerHandler(client)).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                server.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

public class TimeServerHandler implements Runnable {
    private Socket clientProxxy;

    public TimeServerHandler(Socket clientProxxy) {
        this.clientProxxy = clientProxxy;
    }

    @Override
    public void run() {
        BufferedReader reader = null;
        PrintWriter writer = null;
        try {
            reader = new BufferedReader(new InputStreamReader(clientProxxy.getInputStream()));
            writer =new PrintWriter(clientProxxy.getOutputStream()) ;
            while (true) {//因为一个client可以发送多次请求,这里的每一次循环,相当于接收处理一次请求
                String request = reader.readLine();
                if (!"GET CURRENT TIME".equals(request)) {
                    writer.println("BAD_REQUEST");
                } else {
                    writer.println(Calendar.getInstance().getTime().toLocaleString());
                }
                writer.flush();
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            try {
                writer.close();
                reader.close();
                clientProxxy.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

在这个案例中:

  我们把主(main)线程作为accpetor thread,因为我们main线程中执行了ServerSocket的accept方法,事实上,你可以认为,在哪个线程中执行了ServerSocket.accpet(),哪个线程就是accpetor thread

  针对每个client,我们都创建了一个新的Thread来处理这个client的请求,直到连接关闭,我们通过new 方法创建的线程就是worker Thread

Client端

public class TimeClient {
    public static void main(String[] args)  {
        BufferedReader reader = null;
        PrintWriter writer = null;
        Socket client=null;
        try {
            client=new Socket("127.0.0.1",8080);
            writer = new PrintWriter(client.getOutputStream());
            reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
           
            while (true){//每隔5秒发送一次请求
                writer.println("GET CURRENT TIME");
                writer.flush();
                String response = reader.readLine();
                System.out.println("Current Time:"+response);
                Thread.sleep(5000);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                writer.close();
                reader.close();
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

    }
}

首先运行服务端:

TimeServer Started on 8080...

接着启动客户端

Current Time:2016-12-17 22:24:51

Current Time:2016-12-17 22:24:56

Current Time:2016-12-17 22:25:01

Current Time:2016-12-17 22:25:06

...

可以看到,我们的程序已经正常工作。


BIO编程的局限性

 下面我们来分析上述代码的局限性,主要是server端。我们要将server端的终极目标:"server端应该使用尽可能少的线程,来处理尽可能多的client请求"牢记心中,这是server端优化的一个关键。

 上述代码中,针对每个client,都创建一个对应的线程来处理,如果client非常多,那么server端就要创建无数个线程来与之对应。而线程数量越多,线程上下文切换(context switch)造成的资源损耗就越大,因此我们需要使用尽可能少的线程。

  那么为什么要针对每个client都建立一个线程呢?因为BIO编程使用的是我们之前讲解的阻塞式(Blocking)I/O模型,在读取数据的时候,如果没有数据就一直等待。为了及时的响应每个client的请求,我们必须为每个client创建一个线程。例如,假设我们使用一个线程服务两个client:client A、client B,可能clientA当前没有发送请求,clientB发送了请求。如果此时线程正在读取clientA的数据,因为没有,导致线程一直处于阻塞状态,而clientB虽然有请求,但是线程因为被阻塞,也无法继续执行下去。

  因此BIO,无法满足server的终极目标,"server端应该使用尽可能少的线程,来处理尽可能多的client请求”

   可能会有人想到用线程池的方式优化,此时架构如下所示:

Image.png

  server不再针对每个client都创建一个新的线程,而是维护一个线程池,每次有client连接时,将其构造成一个task,交给ThreadPool处理。这样就可以最大化的复用线程。

想法是好的,现实很残酷,因为在阻塞式I/O模型下,使用线程池本身就是一个伪命题。  

  线程池的工作原理是,内部维护了一系列线程,接受到一个任务时,会找出一个当前空闲的线程来处理这个任务,这个任务处理完成之后,再将这个线程返回到池子中。

 而在阻塞式IO中,因为需要不断的检查一个client是否有新的请求,也就是调用其read方法,而这个方法是阻塞的,意味着,一旦调用了这个方法,如果没有读取到数据,那么这个线程就会一直block在那里,一直等到有数据,等到有了数据的时候,处理完成,立即由需要进行下一次判断,这个client有没有再次发送请求,如果没有,又block住了,因此可以认为,线程基本上是用一个少一个,因为对于一个client如果没有断开连接,就相当于这个任务没有处理完,任务没有处理完,线程永远不会返回到池子中,直到这个client断开连接。

  在BIO中,用了线程池,意味着线程池中维护的线程数,也就是server端支持最多有多少个client来连接。

 这里不得不提到<<netty权威指南>>的作者李林峰对读者的误导了,他的书中,2.2节,所谓的伪异步IO编程,用的就是上面这个方法。当然他对读者的误导,不止这一个地方,他在infoq发表的一篇文章,提到netty4内存池方面的问题,也是漏洞百出,我们将会之后分析到内存池的时候进行讲解。

现在我们来分析,BIO不支持server端的终极目标:"server端应该使用尽可能少的线程,来处理尽可能多的client请求" 的原因:

回顾我们在上一节讲解的UNIX五种IO模型中,读取数据都必须要经过的两个阶段:

阶段1、等待数据准备

阶段2、将准备好的数据从内核空间拷贝到用户空间


对于阶段1,其等待时间可能是无限长的,因为一个与server已经建立连接的client,可能很长时间内都没有发送新的请求

对于阶段2,只是将数据从内核空间拷贝到用户空间,这个时间实际上是很短的

  由于在Blocking IO模型中,进程是不区分这两个阶段的,把其当做一个整体来运行(这对应于Socket的getInputStream方法返回的InputStream 对象的read方法,这个方法不区分这两个阶段)。因此在没有数据准备好的情况下,是一直被阻塞的。而我们前面的代码, worker thread在不知道client有没有新的数据的情况下, 直接尝试去读取数据,因此线程被block住。

如果我们有一种机制,可以对这两个阶段进行区分。

  那么我们就可以用一个专门的线程去负责第一阶段:这个线程去检查有哪些client准备好了数据,然后将这些client过滤出来,交给worker线程去处理

  而worker线程只负责第二阶段:因为第一个阶段已经保证了当前处理的client肯定是有数据的,这样worker线程在读取的时候,阻塞时间是很短的,而不必经历第一阶段那样长时间的等待。

  这实际上就是我们之前提到的UNIX 五种IO模型中的多路复用模型,我们将在下一节中看到java的nio包是如何对此进行支持的。