所谓BIO编程,就是使用JDK1.4之前的api进行编程,在这里我们以ServerSocket和Socket为例进行讲解,编写一个时间服务的C/S架构应用。
client可以发送请求指令"GET CURRENT TIME"
给server端,每隔5秒钟发送一次,每次server端都返回当前时间。考虑到TCP编程中,不可避免的要处理粘包、解包的处理,这里为了简化,server在解包的时候,每次读取一行,认为一行就是一个请求。
考虑到可能会有多个client同时请求server,我们针对每个client创建一个线程来进行处理,因此架构如下所示:
这实际上就是最简化的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请求”
。
可能会有人想到用线程池的方式优化,此时架构如下所示:
server不再针对每个client都创建一个新的线程,而是维护一个线程池,每次有client连接时,将其构造成一个task,交给ThreadPool处理。这样就可以最大化的复用线程。
想法是好的,现实很残酷,因为在阻塞式I/O模型下,使用线程池本身就是一个伪命题。
线程池的工作原理是,内部维护了一系列线程,接受到一个任务时,会找出一个当前空闲的线程来处理这个任务,这个任务处理完成之后,再将这个线程返回到池子中。
而在阻塞式IO中,因为需要不断的检查一个client是否有新的请求,也就是调用其read方法,而这个方法是阻塞的,意味着,一旦调用了这个方法,如果没有读取到数据,那么这个线程就会一直block在那里,一直等到有数据,等到有了数据的时候,处理完成,立即由需要进行下一次判断,这个client有没有再次发送请求,如果没有,又block住了,因此可以认为,线程基本上是用一个少一个,因为对于一个client如果没有断开连接,就相当于这个任务没有处理完,任务没有处理完,线程永远不会返回到池子中,直到这个client断开连接。
在BIO中,用了线程池,意味着线程池中维护的线程数,也就是server端支持最多有多少个client来连接。
这里不得不提到<
现在我们来分析,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包是如何对此进行支持的。