通常,我们的应用程序不需要并行处理成千上万的用户,也不需要在一秒钟内处理成千上万的消息。我们只需要应付数十或数百个并发连接的用户,就可以在内部应用程序或某些微服务应用程序中承受如此大的负担。
在这种情况下,我们可以使用某些高级框架/库,这些框架/库在线程模型/使用的内存方面没有得到优化,并且仍然可以承受一些合理的资源和相当快的交付时间。
然而,有时我们会遇到这样的情况:我们的系统的一部分需要比其他应用程序更好地扩展。用传统的方法或框架编写系统的这一部分可能会导致巨大的资源消耗,并且需要启动同一服务的许多实例来处理负载。导致处理成千上万个连接的算法和方法也被称为C10K问题。
在本文中,我将主要关注在TCP连接/流量方面可以进行的优化,以优化(微型)服务实例以尽可能少地浪费资源,深入了解操作系统如何与TCP和Sockets一起工作,以及最后但并非最不重要的是,如何深入了解所有这些事情。我们开始吧。
I/O编程策略
让我们描述一下我们目前拥有什么类型的I/O编程模型,以及在设计应用程序时需要从哪些选项中进行选择。首先,没有好的或坏的方法,只有更适合我们当前用例的方法。选择错误的方法在将来会产生非常不方便的后果。它可能导致资源浪费,甚至从头开始重新编写应用程序。
带阻塞处理的阻塞I/O
每个连接服务器的线程数
这种方法背后的想法是,如果没有任何专用/空闲线程,就不接受套接字连接(稍后我们将展示它的含义)。在这种情况下,阻塞意味着特定的线程被绑定到连接,并且总是在读取或写入连接时阻塞。
- public static void main(String[] args) throws IOException {
- try (ServerSocket serverSocket = new ServerSocket(5050)) {
- while (true) {
- Socket clientSocket = serverSocket.accept();
- var dis = new DataInputStream(clientSocket.getInputStream());
- var dos = new DataOutputStream(clientSocket.getOutputStream());
- new Thread(new ClientHandler(dis, dos)).start();
- }
- }
- }
最简单的套接字服务器版本,从端口5050开始,以阻塞的方式从InputStream读取并写入OutputStream。当我们需要通过一个连接传输少量对象时很有用,然后在需要时关闭它并启动一个新的对象。
- 即使没有任何高级库,它也可以实现。
- 使用阻塞流进行读/写(等待阻塞InputStream读操作,该操作按当时TCP接收缓冲区中可用的字节填充提供的字节数组,并返回字节数或-1-流的结尾)和消耗字节,直到我们有足够的数据来构造请求。
- 当我们开始为无边界的传入连接创建线程时,会出现一个大问题和效率低下。我们将为非常昂贵的线程创建和内存影响付出代价,这与将一个Java线程映射到一个内核线程是密不可分的。
- 它不适合“真正的”生产,除非我们真的需要一个内存占用率低的应用程序,并且不想加载属于某些框架的很多类。
带阻塞处理的非阻塞I/O
基于线程池的服务器
这是大多数知名企业HTTP服务器所属的类别。一般来说,该模型使用多个线程池,使多cpu环境下的处理更高效,更适合企业应用程序。有几种方法可以配置线程池,但基本思想在所有HTTP服务器中是完全相同的。请参阅HTTP Grizzly I/O策略,了解通常可以根据基于线程池的非阻塞服务器配置的所有可能策略。
- 用于接受新连接的第一个线程池。如果一个线程能够管理传入连接的速度,它甚至可以是一个单线程池。通常有两个积压可以填补和下一个传入连接拒绝。如果可能,请检查是否正确使用了持久连接。
- 用于以非阻塞方式(选择器线程或IO线程)从/写入套接字的第二个线程池。每个选择器线程处理多个客户端(通道)。
- 第三个线程池,用于分离请求处理的非阻塞部分和阻塞部分(通常称为工作线程)。某些阻止操作无法阻止选择器线程,因为所有其他通道都无法取得任何进展(通道组只有一个线程,此线程将被阻止)。
- 非阻塞读/写是使用缓冲区实现的,只要处理请求的特定线程不满意(因为它们没有足够的数据来构造例如HTTP请求),选择器线程就会从套接字读取新字节并写入专用缓冲区(池缓冲区)。
我们需要澄清非阻塞术语:
- 我们在Socket服务器的上下文中对话,那么非阻塞意味着线程没有绑定到打开的连接,并且不等待传入的数据(甚至在TCP发送缓冲区已满的情况下写入数据),只要尝试读取,如果没有字节,那么就不会将任何字节添加到缓冲区中以进行进一步处理(构造请求),给定的选择器线程将继续从另一个打开的连接读取。
- 然而,在处理请求方面,代码在大多数情况下是阻塞的,这意味着我们执行一些代码来阻塞当前线程,这个线程等待I/O绑定处理(数据库查询、HTTP调用、从磁盘读取等)或一些长时间CPU绑定处理(计算哈希/阶乘,加密挖掘,…)。如果执行完成,则会唤醒线程,并在某些业务逻辑中继续执行。
业务逻辑的阻塞特性是工作池如此庞大的主要原因,我们只需要让大量线程发挥作用来提高吞吐量。否则,在负载较高的情况下(例如,更多的HTTP请求),我们可能会导致所有线程都处于阻塞状态,并且没有可用于请求处理的线程(没有处于可运行状态的线程可以在CPU上执行)。
优势
即使请求的数量相当高,并且我们的许多工作线程在某些阻塞操作上被阻塞,我们也能够接受新的连接,即使我们可能无法立即处理它们的请求,并且数据必须在TCP接收缓冲区中等待。
这种编程模型被许多框架/库(Spring Controllers,Jersey,…)和HTTP服务器(Jetty,Tomcat,Grizzly…)暗中使用,因为它非常容易编写业务代码,如果真的需要的话,让线程阻塞。
缺点
并行性通常不是由CPU的数量决定的,而是由阻塞操作的性质和工作线程的数量限制的。一般来说,这意味着如果阻塞操作(I/O)和进一步执行(在请求过程中)的时间比率过高,那么我们可以得到:
- 阻塞操作(数据库查询…)上的许多阻塞线程
- 等待处理工作线程的大量请求,以及
- 由于没有线程可以继续执行而非常未使用的CPU
较大的线程池导致上下文切换和CPU缓存的低效使用。
如何设置线程池
好的,我们有一个或多个线程池来处理阻塞的业务操作。但是,线程池的最佳大小是多少?我们可能会遇到两个问题:
- 线程池太小,我们没有足够的线程来覆盖所有线程被阻塞的时间,比如说等待I/O操作,而您的CPU没有得到有效使用。
- 线程池太大,我们要为很多实际空闲的线程付出代价(见下面运行很多线程的代价)。
我觉得可以参考Brian Goetz的一本书Java并发实践,书中说调整线程池的大小并不是一门精确的科学,它更多的是关于理解您的环境和任务的性质。
- 您的环境有多少CPU和多少内存?
- 任务主要执行计算、I/O或某种组合吗?
- 它们是否需要稀缺资源(JDBC连接)?线程池和连接池会相互影响,当我们充分利用连接池时,增加线程池以获得更好的吞吐量可能没有意义。
如果我们的程序包含I/O或其他阻塞操作,您需要一个更大的池,因为您的线程不允许一直放在CPU上。您需要使用一些分析器或基准来估计等待时间与计算任务时间的比率,并观察生产工作负载不同阶段(高峰时间与非高峰时间)的CPU利用率。
非阻塞处理的非阻塞I/O
基于与CPU核心相同的线程数的服务器
如果我们能够以非阻塞的方式管理大部分工作负载,那么这种策略是最有效的。这意味着处理套接字(接受连接、读、写)是使用非阻塞算法实现的,但即使是业务处理也不包含任何阻塞操作。
这个策略的典型代表是Netty框架,所以让我们深入了解一下如何实现这个框架的架构基础,以了解为什么它最适合解决C10K问题。如果您想详细了解它的工作原理,那么我可以推荐以下资源:
Netty in Action——作者是诺曼·莫尔。由Netty Framework Norman Mauer的作者撰写。这是了解如何使用具有各种协议的处理程序基于Netty实现客户端或服务器的宝贵资源。
具有异步编程模型的I/O库
Netty是一个I/O库和框架,它简化了非阻塞IO编程,并为服务器生命周期和传入连接期间发生的事件提供了异步编程模型。我们只需要用我们的lambdas连接回拨,我们就可以免费得到所有东西。
很多协议都可以在不依赖于某个大型库的情况下使用。
开始用纯JDK NIO构建应用程序是非常令人沮丧的,但Netty包含的特性使程序员保持在较低的级别,并提供了使许多事情更高效的可能性。Netty已经包含了大多数众所周知的协议,这意味着我们可以比在更高级别的库(例如Jersey/Spring MVC for HTTP/REST)中使用大量样板文件更有效地使用它们。
识别正确的非阻塞用例以充分利用Netty的能力
I/O处理、协议实现和所有其他处理程序都应该使用非阻塞操作来永不停止当前线程。我们总是可以使用额外的线程池来阻塞操作。但是,如果我们需要将每个请求的处理切换到专用的线程池来执行阻塞操作,那么我们几乎没有使用Netty的功能,因为我们很可能会遇到与非阻塞IO相同的情况,即阻塞处理-一个大的线程池正好位于应用程序的不同部分。
在上图中,我们可以看到Netty架构的主要组件。
EventLoopGroup-收集事件循环并提供要注册到其中一个事件循环的通道。
event loop-处理给定事件循环的已注册通道的所有I/O操作。EventLoop只在一个线程上运行。因此,对于一个EventLoopGroup,事件循环的最佳数量是cpu的数量(有些框架在出现页面错误时使用多个cpu+1来拥有额外的线程)。
管道-保持处理程序的执行顺序(当发生某个输入或输出事件时排序和执行的组件包含实际的业务逻辑)。管道和处理程序在属于EventLoop的线程上执行,因此,处理程序中的阻塞操作会阻塞给定EventLoop上的所有其他处理/通道。