通常,我们的应用程序不需要并行处理成千上万的用户,也不需要在一秒钟内处理成千上万的消息。我们只需要应付数十或数百个并发连接的用户,就可以在内部应用程序或某些微服务应用程序中承受如此大的负担。
在这种情况下,我们可以使用某些高级框架/库,这些框架/库在线程模型/使用的内存方面没有得到优化,并且仍然可以承受一些合理的资源和相当快的交付时间。
然而,有时我们会遇到这样的情况:我们的系统的一部分需要比其他应用程序更好地扩展。用传统的方法或框架编写系统的这一部分可能会导致巨大的资源消耗,并且需要启动同一服务的许多实例来处理负载。导致处理成千上万个连接的算法和方法也被称为C10K问题。
在本文中,我将主要关注在TCP连接/流量方面可以进行的优化,以优化(微型)服务实例以尽可能少地浪费资源,深入了解操作系统如何与TCP和Sockets一起工作,以及最后但并非最不重要的是,如何深入了解所有这些事情。我们开始吧。
让我们描述一下我们目前拥有什么类型的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。当我们需要通过一个连接传输少量对象时很有用,然后在需要时关闭它并启动一个新的对象。
基于线程池的服务器
这是大多数知名企业HTTP服务器所属的类别。一般来说,该模型使用多个线程池,使多cpu环境下的处理更高效,更适合企业应用程序。有几种方法可以配置线程池,但基本思想在所有HTTP服务器中是完全相同的。请参阅HTTP Grizzly I/O策略,了解通常可以根据基于线程池的非阻塞服务器配置的所有可能策略。
我们需要澄清非阻塞术语:
业务逻辑的阻塞特性是工作池如此庞大的主要原因,我们只需要让大量线程发挥作用来提高吞吐量。否则,在负载较高的情况下(例如,更多的HTTP请求),我们可能会导致所有线程都处于阻塞状态,并且没有可用于请求处理的线程(没有处于可运行状态的线程可以在CPU上执行)。
即使请求的数量相当高,并且我们的许多工作线程在某些阻塞操作上被阻塞,我们也能够接受新的连接,即使我们可能无法立即处理它们的请求,并且数据必须在TCP接收缓冲区中等待。
这种编程模型被许多框架/库(Spring Controllers,Jersey,…)和HTTP服务器(Jetty,Tomcat,Grizzly…)暗中使用,因为它非常容易编写业务代码,如果真的需要的话,让线程阻塞。
并行性通常不是由CPU的数量决定的,而是由阻塞操作的性质和工作线程的数量限制的。一般来说,这意味着如果阻塞操作(I/O)和进一步执行(在请求过程中)的时间比率过高,那么我们可以得到:
较大的线程池导致上下文切换和CPU缓存的低效使用。
好的,我们有一个或多个线程池来处理阻塞的业务操作。但是,线程池的最佳大小是多少?我们可能会遇到两个问题:
我觉得可以参考Brian Goetz的一本书Java并发实践,书中说调整线程池的大小并不是一门精确的科学,它更多的是关于理解您的环境和任务的性质。
如果我们的程序包含I/O或其他阻塞操作,您需要一个更大的池,因为您的线程不允许一直放在CPU上。您需要使用一些分析器或基准来估计等待时间与计算任务时间的比率,并观察生产工作负载不同阶段(高峰时间与非高峰时间)的CPU利用率。
基于与CPU核心相同的线程数的服务器
如果我们能够以非阻塞的方式管理大部分工作负载,那么这种策略是最有效的。这意味着处理套接字(接受连接、读、写)是使用非阻塞算法实现的,但即使是业务处理也不包含任何阻塞操作。
这个策略的典型代表是Netty框架,所以让我们深入了解一下如何实现这个框架的架构基础,以了解为什么它最适合解决C10K问题。如果您想详细了解它的工作原理,那么我可以推荐以下资源:
Netty in Action——作者是诺曼·莫尔。由Netty Framework Norman Mauer的作者撰写。这是了解如何使用具有各种协议的处理程序基于Netty实现客户端或服务器的宝贵资源。
Netty是一个I/O库和框架,它简化了非阻塞IO编程,并为服务器生命周期和传入连接期间发生的事件提供了异步编程模型。我们只需要用我们的lambdas连接回拨,我们就可以免费得到所有东西。
很多协议都可以在不依赖于某个大型库的情况下使用。
开始用纯JDK NIO构建应用程序是非常令人沮丧的,但Netty包含的特性使程序员保持在较低的级别,并提供了使许多事情更高效的可能性。Netty已经包含了大多数众所周知的协议,这意味着我们可以比在更高级别的库(例如Jersey/Spring MVC for HTTP/REST)中使用大量样板文件更有效地使用它们。
I/O处理、协议实现和所有其他处理程序都应该使用非阻塞操作来永不停止当前线程。我们总是可以使用额外的线程池来阻塞操作。但是,如果我们需要将每个请求的处理切换到专用的线程池来执行阻塞操作,那么我们几乎没有使用Netty的功能,因为我们很可能会遇到与非阻塞IO相同的情况,即阻塞处理-一个大的线程池正好位于应用程序的不同部分。
在上图中,我们可以看到Netty架构的主要组件。
EventLoopGroup-收集事件循环并提供要注册到其中一个事件循环的通道。
event loop-处理给定事件循环的已注册通道的所有I/O操作。EventLoop只在一个线程上运行。因此,对于一个EventLoopGroup,事件循环的最佳数量是cpu的数量(有些框架在出现页面错误时使用多个cpu+1来拥有额外的线程)。
管道-保持处理程序的执行顺序(当发生某个输入或输出事件时排序和执行的组件包含实际的业务逻辑)。管道和处理程序在属于EventLoop的线程上执行,因此,处理程序中的阻塞操作会阻塞给定EventLoop上的所有其他处理/通道。