手写NIO版tomcat并Jmeter压测
前言
上文不使用第三方工具, 纯java搭建web服务完成了一个web服务,并封装实现了一个内嵌的tomcat,今天在上文基础上对性能做优化和jmeter压测
阻塞
上文中最终实现的非多线程版本tomcat代码如下:
public void run() throws IOException {
// 开启一个socket服务,绑定端口号8888
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("===server start listen 8888===");
while (true) {
Socket clientSocket = serverSocket.accept();
try {
// 解析请求信息为HttpRequest对象
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
// 根据path获取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
continue;
}
// 执行业务
String data = servlet.service(request);
// 响应
HttpResponse response = new HttpResponse(data);
// 返回数据
clientSocket.getOutputStream().write(response.getBytes());
clientSocket.getOutputStream().flush();
clientSocket.getOutputStream().close();
clientSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
逻辑就是一个死循环,调用serverSocket.accept
方法阻塞等待连接,连接成功之后根据/path调用对应的servlet执行对应的服务,最后返回结果
这种写法显然有个致命问题:一次只能处理一个请求
下面使用jmeter工具进行压测试一下,为了效果明显,我们把Order服务的执行时间加长500ms:
public class UserController implements Servlet {
public String service(HttpRequest request) {
try {
Thread.sleep(500); // 模拟处理时间
} catch (InterruptedException e) {
e.printStackTrace();
}
return "{\"message\": \"user done\"}";
}
}
使用jmeter建立10个线程访问/user接口,最终结果如下
单线程结果吞吐量是2.0/sec,一个请求0.5秒,一个接一个的做,一秒钟确实只能处理2个请求,相当于每个请求排着队一个个执行,服务器也只有一个线程在工作,效率肯定是级低的
BIO
BIO模型就是一个请求一个线程,相当于本来一个人干的活分给多个人干,效率必然大大提升
修改tomcat代码如下:
public void run() throws IOException {
// 开启一个socket服务,绑定端口号8888
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("===server start listen 8888===");
while (true) {
Socket clientSocket = serverSocket.accept();
// 开启新线程处理请求
new Thread(()->{
try {
// 解析请求信息为HttpRequest对象
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
// 根据path获取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
return;
}
// 执行业务
String data = servlet.service(request);
// 响应
HttpResponse response = new HttpResponse(data);
// 返回数据
try {
clientSocket.getOutputStream().write(response.getBytes());
clientSocket.getOutputStream().flush();
} finally {
clientSocket.getOutputStream().close();
}
clientSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
相当于每次accpet一个请求,就新开一个线程处理业务,jmeter测试一下
多线程优化效果极其显著,原来每秒处理2个,现在每秒能处理7个请求,线程数调整至100
多线程100100个请求同时发起每秒能处理67个请求,并发量一下就上来了
继续调整请求数测试结果如下
并发请求数 | 吞吐量 |
---|---|
10 | 7.1/sec |
100 | 67.0/sec |
1000 | 666.7/sec |
10000 | 1948.6/sec |
100000 | 2028.8/sec |
可以看到线程1000以下吞吞吐量基本都是几何倍增长,但线程过万后明显增长不上去了,100000和10000的吞吐量已经差不多了
所以并不是请求越多,吞吐量越高,如果线程过多,服务器线程切换开销就会很大,这就是著名的c10k问题
线程池
所以BIO这种模式还是要使用线程池进行优化,不能肆无忌惮的创建线程,比如说实际场景一般同时并发请求最多也就100个左右,那就设个100大小的线程池(真实tomcat默认好像是200),代码修改如下
public void run() throws IOException {
// 开启一个socket服务,绑定端口号8888
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("===server start listen 8888===");
// 创建一个线程池
ExecutorService pool = Executors.newFixedThreadPool(100);
while (true) {
Socket clientSocket = serverSocket.accept();
// 线程池处理请求
pool.execute(()->{
try {
// 解析请求信息为HttpRequest对象
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
// 根据path获取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
return;
}
// 执行业务
String data = servlet.service(request);
// 响应
HttpResponse response = new HttpResponse(data);
// 返回数据
try {
clientSocket.getOutputStream().write(response.getBytes());
clientSocket.getOutputStream().flush();
} finally {
clientSocket.getOutputStream().close();
}
clientSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
jmeter测试结果如下
并发请求数 | 吞吐量 |
---|---|
10 | 7.1/sec |
100 | 67.0/sec |
1000 | 196.1/sec |
100以内和之前完全一样,100以上明显降低(等待线程池释放空闲线程),但线程池可以有效避免c10k,而且可以结合实际场景设置线程池大小
NIO
上文介绍过BIO模型的缺点,主要在inputStream的read上(读取客户端发来的数据),这个过程服务端线程是阻塞的,换句话说这段时间线程占用的cpu就干等着,是一种资源浪费(本来线程池干活的线程固定的,还有几个线程傻等着,效率能高吗),而NIO模型就是为了解决这个问题
NIO的最大特点是当客户端连接建立好后,可以注册可读取事件,当客户端数据发送过来后再去执行读操作,而整个过程是不阻塞的
我们继续用线程池处理请求,原来是一个连接建立就分一个线程等待数据并处理,现在是某个连接数据准备好了,才分线程去处理,可预见在某些情况下这种分配是更合理且高效的
public class NioWebServer {
/**
* 存储path到服务的映射
*/
private Map<String, Servlet> servletMap;
/**
* 初始化
*
* @param servletMap
*/
public NioWebServer(Map<String, Servlet> servletMap) {
this.servletMap = servletMap;
}
/**
* 运行tomcat
*
* @throws IOException
*/
public void run() throws IOException {
// 开启一个socket服务,绑定端口号8888
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(8888));
// 设置ServerSocketChannel为非阻塞
serverSocket.configureBlocking(false);
// 打开Selector处理Channel,即创建epoll
Selector selector = Selector.open();
// 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("===server start listen 8888===");
// 创建一个业务处理线程池
ExecutorService pool = Executors.newFixedThreadPool(100);
while (true) {
// 阻塞等待需要处理的事件发生
selector.select();
// 获取selector中注册的全部事件的 SelectionKey 实例
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
// 遍历SelectionKey对事件进行处理
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 如果是OP_ACCEPT事件,则进行连接获取和事件注册
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(false);
// 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(10*1024));
} else if (key.isReadable()) { // 如果是OP_READ事件,则进行读取和处理
key.cancel();
pool.execute(() -> {
try {
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
socketChannel.read(buffer);
// 解析请求信息为HttpRequest对象
HttpRequest request = new HttpRequest(new StringReader(new String(buffer.array())));
// 根据path获取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
return;
}
// 执行业务
String data = servlet.service(request);
// 响应
HttpResponse response = new HttpResponse(data);
// 返回
socketChannel.write(ByteBuffer.wrap(response.getBytes()));
// 关闭连接
socketChannel.close();
} catch (Exception e) {
e.printStackTrace();
}
});
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}
jmeter测试结果如下
并发请求数 | 吞吐量 |
---|---|
10 | 7.1/sec |
100 | 67.0/sec |
1000 | 196.1/sec |
可以发现和我们的BIO模型基本上性能一样,没啥太大区别,这结果一度让我非常费解,完全感受不到NIO的优势在哪里
NIO的优势
仔细的想了一下,结合代码,发现NIO的优势也就在于读取网络IO请求时不阻塞,而我本地测试,一个http请求过来数据基本上立刻就到,所以即使阻塞阻塞的时间也微乎其微,基本上就可以忽略了
为了证明,写了个代码计时,计算从InputStream转换为Request对象所执行的时间
// BIO中
long startTime = System.currentTimeMillis(); //获取开始时间
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
long endTime = System.currentTimeMillis(); //获取结束时间
System.out.println("IO:" + (endTime - startTime) + "ms"); // 输出
// NIO中
long startTime = System.currentTimeMillis(); //获取开始时间
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
socketChannel.read(buffer);
// 解析请求信息为HttpRequest对象
String receive = new String(buffer.array());
HttpRequest request = new HttpRequest(new StringReader(receive));
long endTime = System.currentTimeMillis(); //获取结束时间
System.out.println("IO:" + (endTime - startTime) + "ms"); //输出
一个测试请求过来,输出IO:0ms
,基本上证明了几乎没有IO阻塞的猜想
为了让IO阻塞时间明显起来,首先增加请求体的数据
请求体但还是没啥效果~本地就是快,所以只能放大招,修改jmeter的测试带宽:
在jmeter.properties
中增加
httpclient.socket.http.cps=5472
httpclient.socket.https.cps=5472
限制了带宽,再次测试
BIO中输出:IO: 462ms
,而NIO中输出IO:0ms
,对比一下就明显起来了,也就是说BIO处理线程接收到请求需要阻塞将近半秒的时间才能接受数据,而NIO是等内核准备好数据后才接受数据几乎不花费线程的时间
再次测试,这次参数准备如下:
- 测试线程数修改为1000,Ramp-up时间改为10(如果用1的话后续BIO的IO时间会减少,这个具体原因暂时还不太清除,可能是jmeter本身的一些优化)
- UserController sleep时间设为1,代表业务代码执行1ms结束
- worker线程数改小至10,这样线程容易占满,方便呈现差异
结果又失败了~测试数据BIO和NIO依然是没啥大差异
又仔细想一下,发现问题~NIO虽然不阻塞线程,但这段数据IO的时间一点没省,只不过内核准备好数据才通知线程去读,比如当前带宽读取数据时间是500ms,BIO是直接分配线程去等,NIO是内核等完之后再交给线程去做,所以当前的场景,NIO虽然不需要线程去阻塞,但网络IO时间线程也无事可做,等不等效果都一样
比如现在有个大众浴池,BIO是来了一个客人就分配一个搓澡工,等着客人洗完澡他就开始给搓,而NIO是等客人洗完过来搓澡的时候再分配搓澡工
所以以上的测试案例就好比一次来了1000个人洗澡,此时NIO是没有优势的,因为就算刚开始不分配搓澡工,也得等人洗完澡才能开始搓澡
而NIO的优势也明了了,在客人洗澡的时候,搓澡工可以干点别的活,比如扫扫地,而BIO由于提前分配了搓澡工,搓澡工只能干等着客户洗完澡,这个过程干不了别的活
为了测试这个场景,我开启两个jmeter客户端,一个带宽限制(IO时间长),一个不限制(IO时间短),两个客户端同时发出1000个请求测试结果如下(限制带宽Ramp-up为10,不限制带宽Ramp-up为1)
- BIO:
| 带宽 | 吞吐量 | 最大时间 |
| ---- | ---- | ---- |
| 限制 | 74.4/sec | 3486 |
| 不限制 | 732.1/sec | 389 | - NIO:
| 带宽 | 吞吐量 | 最大时间 |
| ---- | ---- | ---- |
| 限制 | 74.4/sec | 3460 |
| 不限制 | 1002.0/sec | 4 |
可以发现明显的差异,NIO的不限制带宽请求吞吐量1002.0/sec,基本上1秒就全处理完了,最大响应时间是4,而BIO吞吐量732.1/sec,最大的请求需要389ms才响应
这也证明了以上猜想,NIO由于线程不阻塞,网路IO数据准备时可以去处理其他快请求,而BIO由于阻塞及时来了不限制带宽的请求也不能分出线程去处理,在以上场景下NIO的优势就体现出来了
总结
NIO对线程的分配相较于BIO肯定更加合理,充分的压榨了CPU(但这种优势的测试真的很费劲)
就像浴池的例子,生活中一定是有客人洗完澡才分配搓澡工,而不是客人来了就分配一个搓澡工等着他洗完再搓澡,后者客户量一上来搓澡效率就会很低,所以NIO显然更接近现实的工作流程,所以也更加合理。NIO这种方式就相当于老板对搓澡工的压榨,保证大部分时间搓澡工时可用状态,就好比我们对CPU的压榨,而正因为这种压榨,才能在高并发下处理更多的请求
总结一下,NIO不会提升单个请求的速度,也不会提高IO效率,只是在读取IO数据时不占用线程,使更多线程可用,在某个请求IO数据准备好后会更快的分到线程处理具体业务