最原始的网络编程思路就是服务器用一个while循环,不断监听端口是否有新的套接字连接,如果有,那么就调用一个处理函数处理。
while(true){
socket = accept();
handle(socket)
}
这种方法的最大问题是无法并发,效率太低。如果当前的请求没有处理完,那么后面的请求只能被阻塞,服务器的吞吐量太低。
针对上面的问题,很自然想到了使用多线程处理IO,也就是很经典的connection per thread,每一个连接用一个线程处理。
while(true){
socket = accept();
new thread(socket);
}
tomcat服务器的早期版本确实是这样实现的。多线程的方式确实一定程度上极大地提高了服务器的吞吐量,因为之前的请求在read阻塞以后,不会影响到后续的请求,因为他们在不同的线程中。这也是为什么通常会讲“一个线程只能对应一个socket”的原因。
那么,线程中创建多个socket不行吗?语法上确实可以,但是实际上没有用,每一个socket都是阻塞的,这就遇到同单线程IO一样的问题。所以在一个线程里只能处理一个socket,就算accept了多个也没用,前一个socket被阻塞了,后面的是无法被执行到的。
下面基于同步阻塞式IO创建一个时间服务TimeServer。
public class TimeServer {
private static int port = 8080;
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
System.out.println("server starts in port: " + port);
Socket socket = null;
while (true) {
// 监听来自客户端的连接,主线程阻塞在accept操作上
socket = serverSocket.accept();
// 创建一个新的线程处理socket链路
new Thread(new TimeServerHandler(socket)).start();
}
} finally {
if (serverSocket != null) {
serverSocket.close();
}
}
}
}
public class TimeServerHandler implements Runnable {
private Socket socket;
public TimeServerHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
BufferedReader in = null;
PrintWriter out = null;
try {
in = new BufferedReader(new InputStreamReader(this.socket.getInputStream()));
out = new PrintWriter(this.socket.getOutputStream(), true);
String currTime = null;
String body = null;
while (true) {
body = in.readLine();
if (body == null) {
break;
}
System.out.println("time server receive: " + body);
currTime = new Date(System.currentTimeMillis()).toString();
out.println(currTime);
}
} catch (Exception e) {
// ignore
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
if (out != null) {
out.close();
}
if (this.socket != null) {
try {
this.socket.close();
} catch (IOException e2) {
e2.printStackTrace();
}
}
this.socket = null;
}
}
}
public class TimeClient {
public static void main(String[] args) {
int port = 8080;
Socket socket = null;
BufferedReader in = null;
PrintWriter out = null;
try {
socket = new Socket("127.0.0.1", port);
in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
out = new PrintWriter(socket.getOutputStream(), true);
out.println("query time");
System.out.println("send query time request");
String rep = in.readLine();
System.out.println("curr time is " + rep);
} catch (Exception e) {
// ignore
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
if (out != null) {
out.close();
}
if (socket != null) {
try {
socket.close();
} catch (IOException e2) {
e2.printStackTrace();
}
}
}
}
}
# client
send query time request
curr time is Sat Aug 11 13:55:47 CST 2018
# server
server starts in port: 8080
time server receive: query time
从上面的Demo我们可以发现,多线程BIO主要问题在于每一个新的Client请求时,Server必须创建一个新的线程来处理。一个线程只能处理一个客户端连接。系统中创建线程是需要比较多的系统资源的。如果同时有成千上万个Client并发连接,连接数太高,系统无法承受;而且,线程的反复创建-销毁也需要代价。
线程池本身可以缓解线程创建-销毁的代价。下面对Server端代码进行简单改造,用线程池来处理连接。
public class TimeServer {
private static int port = 8080;
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
System.out.println("server starts in port: " + port);
Socket socket = null;
// 创建一个线程池处理socket链路
TimeServerHandlerPool pool = new TimeServerHandlerPool(10, 1000);
while (true) {
// 监听来自客户端的连接,主线程阻塞在accept操作上
socket = serverSocket.accept();
pool.execute(new TimeServerHandler(socket));
}
} finally {
if (serverSocket != null) {
serverSocket.close();
}
}
}
}
public class TimeServerHandlerPool {
private ExecutorService executorService;
public TimeServerHandlerPool(int poolSize, int queueSize) {
executorService = new ThreadPoolExecutor(
8,
poolSize, 120,
TimeUnit.SECONDS,
new ArrayBlockingQueue(queueSize)
);
}
public void execute(Runnable task) {
executorService.execute(task);
}
}
当收到客户端连接时,Server把请求的Socket封装成一个Task,交给线程池去处理,从而避免了每个请求都创建一个新的线程。
不过底层通信机制依然还是BIO,根本的问题就是线程的粒度太大。每一个线程把一次交互的事情全部做了,包括读取和返回,甚至连接,表面上似乎连接不在线程里,但是如果线程和队列不够,有了新的连接,也无法得到处理。
多线程BIO模型无法满足高性能、高并发的接入场景。因为其底层通信机制依然采用同步阻塞模型,无法从根本上解决问题。那么Java NIO是如何从根本上解决这类问题的呢?
上面的方案,线程Task里可以看成要做三件事,连接,读取和写入。线程同步的粒度太大了,限制了吞吐量。应该把一次连接的操作分为更细的粒度或者过程,这些更细的粒度是更小的线程。整个线程池的数目会翻倍,但是线程更简单,任务更加单一。Reactor模式则体现了这一改进思路。
在Reactor中,这些被拆分的小线程或者子过程对应的是handler,每一种handler会出处理一种event。有一个全局的管理者selector,我们需要把channel注册感兴趣的事件,那么这个selector就会不断在channel上检测是否有该类型的事件发生。如果没有,那么主线程就会被阻塞,否则就会调用相应的事件处理函数即handler来处理。
典型的事件有连接,读取和写入,当然我们就需要为这些事件分别提供处理器,每一个处理器可以采用线程的方式实现。一个连接来了,显示被读取线程或者handler处理了,然后再执行写入,那么之前的读取就可以被后面的请求复用,吞吐量就提高了。
Reactor模式是javaNIO非堵塞技术的实现原理,与Socket
类和ServerSocket
类相对应,NIO也提供了SocketChannel
和ServerSocketChannel
两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用非常简单,但是性能和可靠性都不好,非阻塞模式则正好相反。一般来说,低负载、低并发的应用程序可以选择同步阻塞I/O以降低编程复杂度;对于高负载、高并发的网络应用,需要使用NIO的非阻塞模式进行开发。
在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
缓冲区实质上是一个数组。通常它是一个字节数组(ByteBuffer),也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数据的结构化访问以及维护读写位置(limit)等信息。最常用的是ByteBuffer
。
Channel
是一个通道,它就像自来水管一样,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是InputStream或者Output-Stream的子类),而通道可以用于读、写或者二者同时进行。因为Channel是全双工的,所以它可以比流更好地映射底层操作系统的API。
Selector
是Java NIO的基础,它会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过Selection-Key可以获取就绪Channel的集合,进行后续的I/O操作。一个多路复用器Selector可以同时轮询多个Channel,由于JDK使用了epoll()代替传统的select实现。
public class NIOTimeServer implements Runnable {
private Selector selector;
private ServerSocketChannel serverChannel;
private volatile boolean stop;
public NIOTimeServer(int port) {
try {
// 初始化资源
selector = Selector.open();
serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new InetSocketAddress(port), 1024);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("nio time server starts in port: " + port);
} catch (IOException e) {
// ignore
}
}
public void stop() {
this.stop = true;
}
@Override
public void run() {
while (!stop) {
try {
selector.select(1000);
Set selectionKeySet = selector.selectedKeys();
Iterator it = selectionKeySet.iterator();
SelectionKey key = null;
while (it.hasNext()) {
key = it.next();
it.remove();
try {
handleInput(key);
} catch (Exception e) {
if (key != null) {
key.cancel();
if (key.channel() != null) {
key.channel().close();
}
}
}
}
} catch (Exception e) {
// ignore
}
}
if (selector != null) {
try {
selector.close();
} catch (Exception e) {
// ignore
}
}
}
private void handleInput(SelectionKey key) throws IOException {
if (key.isValid()) {
// 处理新接入的请求
if (key.isAcceptable()) {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = ssc.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
int readBytes = socketChannel.read(readBuffer);
if (readBytes > 0) {
readBuffer.flip();
byte[] bytes = new byte[readBuffer.remaining()];
readBuffer.get(bytes);
String body = new String(bytes, "UTF-8");
System.out.println("nio time server receive: " + body);
String currTime = new Date(System.currentTimeMillis()).toString();
doWrite(socketChannel, currTime);
} else if (readBytes < 0) {
// 关闭对端链路
key.cancel();
socketChannel.close();
} else {
// 读到0字节 忽略
}
}
}
}
public void doWrite(SocketChannel socketChannel, String response) throws IOException {
if (response != null && response.trim().length() > 0) {
byte[] bytes = response.getBytes();
ByteBuffer byteBuffer = ByteBuffer.allocate(bytes.length);
byteBuffer.put(bytes);
byteBuffer.flip();
socketChannel.write(byteBuffer);
}
}
public static void main(String[] args) throws IOException {
int port = 8080;
System.out.println("server starts in port: " + port);
// 创建一个新的NIO time server 处理socket链路
NIOTimeServer timeServer = new NIOTimeServer(port);
new Thread(timeServer, "NIO-NIOTimeServer").start();
}
}
public class NIOTimeClient implements Runnable {
private String host;
private int port;
private Selector selector;
private SocketChannel socketChannel;
private volatile boolean stop;
public NIOTimeClient(String host, int port) {
this.host = (host == null) ? "127.0.0.1" : host;
this.port = port;
try {
selector = Selector.open();
socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
} catch (IOException e) {
// ignore
}
}
@Override
public void run() {
// do connect
try {
doConnect();
} catch (IOException e) {
// ignore
}
while (!stop) {
try {
selector.select(1000);
Set selectionKeys = selector.selectedKeys();
Iterator iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
try {
handleKey(key);
} catch (Exception e) {
// ignore
}
}
} catch (Exception e) {
// ignore
}
}
if (selector != null) {
try {
selector.close();
} catch (IOException e) {
// ignore
}
}
}
private void handleKey(SelectionKey key) throws IOException {
if (key.isValid()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
if (key.isConnectable()) {
if (socketChannel.finishConnect()) {
System.out.println("client finish connect");
socketChannel.register(selector, SelectionKey.OP_READ);
doWrite(socketChannel, "query time");
} else {
System.out.println("client connect failed, exit");
System.exit(1);
}
}
if (key.isReadable()) {
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int readBytes = socketChannel.read(byteBuffer);
if (readBytes > 0) {
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
String currTime = new String(bytes, "UTF-8");
System.out.println("curr time is: " + currTime);
} else if (readBytes < 0) {
key.cancel();
socketChannel.close();
} else {
// ignore
}
}
}
}
public void doWrite(SocketChannel socketChannel, String request) throws IOException {
if (request != null && request.trim().length() > 0) {
byte[] bytes = request.getBytes();
ByteBuffer byteBuffer = ByteBuffer.allocate(bytes.length);
byteBuffer.put(bytes);
byteBuffer.flip();
socketChannel.write(byteBuffer);
if (!byteBuffer.hasRemaining()) {
System.out.println("client send req succeed!");
}
}
}
private void doConnect() throws IOException {
if (socketChannel.connect(new InetSocketAddress(host, port))) {
socketChannel.register(selector, SelectionKey.OP_READ);
doWrite(socketChannel, "query time");
} else {
socketChannel.register(selector, SelectionKey.OP_CONNECT);
}
}
public static void main(String[] args) throws Exception {
new Thread(new NIOTimeClient("127.0.0.01", 8080), "NIO-NIOTimeServer").start();
}
}
epoll
实现,它没有连接句柄数的限制(只受限于操作系统的最大句柄数或者对单个进程的句柄限制)。一个Selector线程可以处理成千上万个连接。