手写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

多线程100

100个请求同时发起每秒能处理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 servletMap;

    /**
     * 初始化
     *
     * @param servletMap
     */
    public NioWebServer(Map 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 selectionKeys = selector.selectedKeys();
            Iterator 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数据准备好后会更快的分到线程处理具体业务

你可能感兴趣的:(手写NIO版tomcat并Jmeter压测)