今天在看Nettty的权威指南一书的时候,站在服务端的角度,再次审视异步、多线程的问题。一个很实际的场景,服务处理来自客户端的请求,一个server和一个client。学过socket的人都知道,最简单的方式,server端监听,client端发起请求然后等待处理结果。但这个流程有很值得深究的地方,下面仔细说说这个过程:
首先贴上服务端的代码(BIO)
public static void main(String[] args) throws IOException {
int port = 8080;
if (args != null && args.length > 0) {
try {
port = Integer.valueOf(args[0]);
} catch (NumberFormatException e) {
// 采用默认值
}
}
ServerSocket server = null;
try {
server = new ServerSocket(port);
System.out.println("The time server is start in port : " + port);
Socket socket = null;
while (true) {
socket = server.accept();
new Thread(new TimeServerHandler(socket)).start();
}
} finally {
if (server != null) {
System.out.println("The time server close");
server.close();
server = null;
}
}
}
其余的不多说,能扯很远,首先server.accept() 这个方法是一个阻塞方法,外部的死循环就是针对它的,用来应对下一次请求。而这句代码下面,是新起线程来对请求进行处理,那么问题是这里为什么要新启线程?还不是因为处理的过程有可能是耗时操作,一旦有耗时操作,这个监听所在的线程都卡住了那么根本就没有办法接收到任何请求了。所以得出,处理请求得在工作线程进行处理。
那么,如果有一百万个请求在很短的时间内蜂拥而来,这个server能处理多少请求?回答是基本没有,因为尽管来一个请求启动一个线程,但光启动线程这个操作瞬间上百万,服务器就无法承受,更何况还要处理百万个请求,结果就是这段代码会直接报错或者系统卡死。那么这个地方,似曾相识的共同点出现了——线程池为何会出现?为什么要做线程池这件事?它的出现其中一个重要的原因就是这个,换句话说我们可以使用线程池来避免系统卡死等问题,其实利用这个思路来解决问题是完全OK的。
线程池优化版本(BIO + 线程池)
public static void main(String[] args) throws IOException {
int port = 8080;
if (args != null && args.length > 0) {
try {
port = Integer.valueOf(args[0]);
} catch (NumberFormatException e) {
// 采用默认值
}
}
ServerSocket server = null;
try {
server = new ServerSocket(port);
System.out.println("The time server is start in port : " + port);
Socket socket = null;
FakeTimeServerHandlerExecutePool singleExecutor = new FakeTimeServerHandlerExecutePool(50, 10000);
while (true) {
socket = server.accept();
singleExecutor.execute(new TimeServerHandler(socket));
}
} finally {
if (server != null) {
System.out.println("The time server close");
server.close();
server = null;
}
}
}
其余代码就不贴了,理解意思就可以。那么到这里,如果是其他问题其实是可以解决了,但这里的耗时操作是对输入输出流进行操作,核心代码如下:
in = new BufferedReader(new InputStreamReader(this.socket.getInputStream()));
out = new PrintWriter(this.socket.getOutputStream(), true);
String currentTime = null;
String body = null;
while (true) {
body = in.readLine();
if (body == null)
break;
System.out.println("The time server receive order : " + body);
currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body)
? new java.util.Date(System.currentTimeMillis()).toString() : "BAD ORDER";
out.println(currentTime);
}
- 首先,不管读写操作阻塞或不阻塞并不是这个问题的根本,问题的根本在于耗时操作上,为什么线程池无法解决根本问题?原因就是有可能出现(实际工作中其实蛮多出现的,如环境、网络异常等等问题)耗时操作非常耗时。怎么理解呢?比如,一百万个请求都是抢票的,而抢票的实际处理是放在另外一台服务器的,那么当前服务器需要在每次拿到请求的之后新启线程去向另一台服务器请求处理,一旦处理抢票的服务器出现异常、网络不好等等导致请求超时,那么根据上面的代码设置的线程池大小包括队列是无法满足一百万个请求的,因为大家都知道当线程池中线程数量超出队列大小时,后来的线程将会被丢弃,那也就意味着这一百万个客户端请求中有一小部分会处于阻塞状态(前端页面会是在转圈),而绝大部分请求会被直接拒绝(页面直接显示无法连接),这跟宕机没有区别。
- 其次,CPU在执行的过程中会有一个切换的问题,CPU的执行是按照轮转的方式执行任务的,当一个线程任务执行超过CPU指定的时间,它就会切换到另一个线程执行任务,这时就需要记录前一个任务执行的整体情况和环境以方便下一次切回来时继续执行,这个过程就叫做CPU上下文切换,如果线程数量过多,上下文切换的负担就会很重,哪怕线程池有线程的复用,但任务并没有复用,照样需要对上下文进行切换,这也是为什么线程很宝贵的另一个原因。
NIO与AIO
实际上NIO并不能解决上面百万请求的问题,只能起到提高效率,优化的作用,当然作用比起单独使用线程池肯定大很多,不过换个角度讲,它倒是解决了上面提到的瞬间启动百万线程的问题,但是就不展开了,因为网上太多NIO的介绍,写得很好的也不少。因为NIO不光是异步的问题,更多的是IO结合异步的问题,因此就不展开,AIO也是在NIO的基础上加上异步回调的优化,所以下面只贴一些写得很好的文章,可以在学习NIO的时候配合理解。
多路复用
聊聊BIO,NIO和AIO
Java NIO浅析
重点看这篇
其余的多数文章都是拷贝粘贴没有讲解,上面的几篇重点看最后一篇,因为它从字面意思开始层层剥离,非常值得玩味。