BlockingIO +thread-per-connection的网络服务器设计方案
1、 前言
在 java1.4引入 NIO之前,网络服务器的典型实现方案是采用阻塞 IO+多线程模型,后来出现了非阻塞 IO( NIO),常用的实现方案则变成 NIO+Reactor模式,还有 NIO+proactor模式。本文主要是介绍阻塞 IO+多线程模型,虽然该方案有很多缺点,但此处还对此进行介绍的原因是为后面进一步介绍基于 NIO的方案做准备,毕竟研究一种新的方案时,只有知道它出现之前的老的方案的缺点后,才能对新的方案理解的更深。
2、 TCP通信过程
面向连接的 TCP 协议可以在多个通信端点之间可靠的进行数据传递,这个过程涉及到两个角色,一个是被动( passive )角色(服务器),一个是主动 (active) 角色(客户端)。服务器在一个端点被动的等待其他端点来连接,而客户端则主动的去连接服务端。以下是交互过程:
3、BlockingIO + thread-per-connection设计方案
3.1 通信体系示例
上面是一个典型的网络服务器通信体系。一个 web 服务器会同时被多个客户端并发的访问,每次访问中,客户端与服务器的通信过程分为以下几个步骤:
- 用户在客户端的浏览器中打开一个 URL,浏览器发送请求给服务器
- 服务器收到请求后,解析请求
- 服务器对请求进行处理
- 服务器将结果发给客户端
3.2 BlockingIO + thread-per-connection 架构方案
- Thread-per-connection:顾名思义,为每个连接分配一个线程。多个客户端并发的向服务端发起请求,同步的 Acceptor通过 TCP三次握手后,每接受一个客户端的 connection,就为该 connection分配一个线程,然后由该线程处理该客户端的请求。这样就实现了多个线程并发处理多个客户端请求的目的。
- BlockingIO:为什么是阻塞?如果客户端没有 connect请求,则服务端监听客户端连接的线程会一致等待连接,阻塞在 accept中;连接成功后,如果客户端一直没有发起请求,则负责处理该客户端请求的线程会一直阻塞在等待读取请求数据中;服务端向客户端回写数据的时候,也可能会被阻塞直到响应数据发送完毕。
使用该方案有以下缺点:
- 由于服务端需要为每个已连接的客户端分配一个线程,所以服务端线程的数量与已连接的客户端的数量是成线性正比关系的,譬如有 1000个客户端并发访问,而且是长连接,则服务端会启动 1000个线程。而每个线程是需要分配一定大小的堆栈空间的,所以在高并发的情况下,就会导致服务器资源耗尽。当然也可以采用线程池来处理,以控制线程的数量,但在高并发的情况下,当线程池的线程都分配完后,就无法再响应后面的客户端请求了,所以可伸缩性比较差。
- 多个线程之间的上下文切换也是浪费 CPU时间的,尤其是存在大量空闲连接的情况下,切换线程上下文完全是没有必要的
- 由于存在多线程,所以在共享资源的访问上就要求开发人员做好同步控制,增加了实现的复杂度。
4、代码示例
4.1 java Io的几个关键概念
- ServerSocket:服务端监听套接字,创建时需要指定端口号,譬如: new ServerSocket(9090),表示建立了一个监听 9090端口的套接字对象。该对象通过 accept方法监听客户端的连接请求,会阻塞直到建立一个连接,并返回已连接的套接字。
- Socket:客户端需要建立该套接字与服务端进行通信,创建时需要指定服务器的地址和端口,譬如: new Socket(InetAddress.getByName("localhost"), 9090)。利用该对象的方法 getOutputStream()和 getInputStream()可以分别获得向 Socket读写数据的输入/输出流
- 阻塞读 : 数据在不超过指定的长度的时候有多少读多少,没有数据就会一直等待
- 阻塞写:会一致阻塞,直到将所有数据写完
4.2 code
注:所有代码只用来作为原理的进一步阐述,不能用于生产环境
模拟以下场景:客户端向服务端发送 nice to meet you 信息,然后服务端响应客户端,发送 Nice to meet you too 。
服务端代码如下(采用线程池)
package iothreadpool;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 服务端
* @author jason
*
*/
public class IoServer implements Runnable{
ExecutorService threadPool = Executors.newCachedThreadPool();
@Override
public void run() {
try{
//建立服务端监听套接字,绑定的端口号是9090
ServerSocket serverSocket = new ServerSocket(9090);
while(true){
//监听客户端的的连接请求,会一直阻塞直到建立一个连接,并返回已连接的套接字
Socket socket = serverSocket.accept();
//为每一个连接从线程池分配一个线程
threadPool.execute(new Handler(socket));
}
}catch(Exception e){
e.printStackTrace();
}
}
//处理线程
class Handler implements Runnable{
private Socket socket;
Handler(Socket s){
this.socket = s;
}
@Override
public void run() {
try{
byte[] input = new byte[1024];
//读取客户端的请求数据,如果没有数据,会一直阻塞
socket.getInputStream().read(input);
//数据处理
System.out.println(new String(input));
//发送响应
socket.getOutputStream().write("nice to meet you too".getBytes());
}catch(Exception e){
e.printStackTrace();
}
}
}
//启动服务端
public static void main(String[] args){
new Thread(new IoServer()).start();
}
}
客户端代码如下:
package io;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
public class IoClient{
public static void main(String[] args) throws UnknownHostException, IOException{
byte[] input = new byte[1024];
//连接服务端
Socket socket = new Socket(InetAddress.getByName("localhost"), 9090);
//发送请求
socket.getOutputStream().write("nice to meet you".getBytes());
//读取响应数据
socket.getInputStream().read(input);
System.out.println(new String(input));
//关闭连接
socket.close();
}
}
5、总结
本文对 BlockingIO + thread-per-connection 的网路服务器设计方案进行了描述,并分析了该方案的缺点(注:如果客户端的连接数不是很大,则采用该方案是合适的),在下一篇 blog 中将分析 NIO+reactor 模式的网路服务器设计方案
本文为原创,转载请注明出处