动态调整线程池
无论您是否知道,您的Java Web应用程序很可能都使用线程池来处理传入的请求。 这是许多人忽略的实现细节,但是迟早您需要了解如何使用该池以及如何为您的应用程序正确调整池。 本文旨在说明线程模型,线程池是什么以及正确配置线程池所需执行的操作。
单螺纹
让我们从一些基础知识开始,并随着线程模型的发展而前进。 无论您使用哪种应用程序服务器或框架, Tomcat , Dropwizard , Jetty ,它们都使用相同的基本方法。 一个深埋在Web服务器内部的套接字。 该套接字正在侦听传入的TCP连接,并接受它们。 一旦接受,就可以从新建立的TCP连接中读取数据,进行解析并将其转换为HTTP请求。 然后将此请求移交给Web应用程序,以完成其所需的操作。
为了理解线程的作用,我们将不使用应用程序服务器,而是从头开始构建一个简单的服务器。 该服务器反映了大多数应用程序服务器的功能。 首先,单线程Web服务器可能如下所示:
ServerSocket listener = new ServerSocket(8080);
try {while (true) {Socket socket = listener.accept();try {handleRequest(socket);} catch (IOException e) {e.printStackTrace();}}
} finally {listener.close();
}
此代码在端口8080上创建一个ServerSocket ,然后在紧密循环中ServerSocket检查要接受的新连接。 接受后,套接字将传递给handleRequest方法。 该方法通常会读取HTTP请求,执行所需的任何过程并编写响应。 在此简单示例中,handleRequest读取一行,并返回简短的HTTP响应。 handleRequest做一些更复杂的事情是正常的,例如从数据库中读取或进行某种其他类型的IO。
final static String response =“HTTP/1.0 200 OK\r\n” +“Content-type: text/plain\r\n” +“\r\n” +“Hello World\r\n”;public static void handleRequest(Socket socket) throws IOException {// Read the input stream, and return “200 OK”try {BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));log.info(in.readLine());OutputStream out = socket.getOutputStream();out.write(response.getBytes(StandardCharsets.UTF_8));} finally {socket.close();}
}
由于只有一个线程处理所有接受的套接字,因此在接受下一个请求之前,必须完全处理每个请求。 在实际的应用程序中,等效的handleRequest方法返回大约100毫秒是正常的。 如果是这种情况,服务器将被限制为每秒仅处理10个请求,一个接一个。
多线程
即使handleRequest可能在IO上被阻止,CPU也可以自由处理更多请求。 使用单线程方法是不可能的。 因此,可以通过创建多个线程来改进此服务器以允许并发操作:
public static class HandleRequestRunnable implements Runnable {final Socket socket;public HandleRequestRunnable(Socket socket) {this.socket = socket;}public void run() {try {handleRequest(socket);} catch (IOException e) {e.printStackTrace();}}
}ServerSocket listener = new ServerSocket(8080);
try {while (true) {Socket socket = listener.accept();new Thread(new HandleRequestRunnable(socket)).start();}
} finally {listener.close();
}
在这里,仍然在单个线程内的紧密循环中调用accept(),但是一旦接受TCP连接并且有可用的套接字,就会产生一个新线程。 这个产生的线程执行一个HandleRequestRunnable,它从上面简单地调用相同的handleRequest方法。
创建新线程后,现在可以释放原始的accept()线程来处理更多的TCP连接,并允许应用程序同时处理请求。 该技术被称为“每个请求线程”,是最流行的方法。 值得注意的是,还有其他方法,例如事件驱动的异步模型NGINX和Node.js部署,但是它们不使用线程池,因此不在本文讨论范围之内。
在每个请求线程数方法中,创建新线程(然后销毁它)可能会很昂贵,因为JVM和OS都需要分配资源。 另外,在上述实现中,正在创建的线程数不受限制。 不受限制是很成问题的,因为它会很快导致资源枯竭。
资源枯竭
每个线程都需要一定数量的内存用于堆栈。 在最新的64位JVM上, 默认堆栈大小为1024KB。 如果服务器收到大量请求,或者handleRequest方法变慢,则服务器可能会出现大量并发线程。 因此,要管理1000个并发请求,仅1000个线程将消耗1GB的JVM RAM,仅用于线程的堆栈。 另外,在每个线程中执行的代码将在处理请求所需的堆上创建对象。 这很快就会加起来,并且可能超过分配给JVM的堆空间,从而对垃圾收集器施加压力,导致崩溃并最终导致OutOfMemoryErrors 。
线程不仅消耗RAM,而且可能使用其他有限资源,例如文件句柄或数据库连接。 超过这些可能导致其他类型的错误或崩溃。 因此,为了避免耗尽资源,重要的是避免无限制的数据结构。
不是万能的,但是可以通过使用-Xss标志调整堆栈大小来缓解堆栈大小问题。 较小的堆栈将减少每个线程的开销,但可能导致StackOverflowErrors 。 您的里程会有所不同,但是对于许多应用程序,默认的1024KB过多,而更小的256KB或512KB的值可能更合适。 Java允许的最小值是16KB。
线程池
为了避免连续创建新线程并限制最大数量,可以使用一个简单的线程池。 简而言之,该池跟踪所有线程,在需要达到上限时创建新线程,并在可能的情况下重用空闲线程。
ServerSocket listener = new ServerSocket(8080);
ExecutorService executor = Executors.newFixedThreadPool(4);
try {while (true) {Socket socket = listener.accept();executor.submit( new HandleRequestRunnable(socket) );}
} finally {listener.close();
}
现在,此代码不是直接创建线程,而是使用ExecutorService,它提交要在线程池中执行的工作(用Runnables术语)。 在此示例中,四个线程的固定线程池用于处理所有传入的请求。 这限制了“进行中”请求的数量,因此限制了资源的使用。
除了newFixedThreadPool之外 ,Executors实用程序类还提供了newCachedThreadPool方法。 这受到较早的无限线程数量的困扰,但是只要有可能,就利用先前创建但现在空闲的线程。 通常,这种类型的池对于不阻塞外部资源的短暂请求很有用。
ThreadPoolExecutors可以直接构造,从而可以自定义其行为。 例如,可以定义池中线程的最小和最大数量,以及何时创建和销毁线程的策略。 简短的例子。
工作队列
在固定线程池的情况下,细心的读者可能想知道如果所有线程都忙,并且有新的请求进入,该怎么办。那么ThreadPoolExecutor使用队列来保存线程可用之前的待处理请求。 默认情况下,Executors.newFixedThreadPool和Executors.newCachedThreadPool都使用无界LinkedList。 同样,这会导致资源耗尽问题,尽管速度要慢得多,因为每个排队的请求都小于一个完整的线程,并且通常不会使用那么多资源。 但是,在我们的示例中,每个排队的请求都持有一个套接字(取决于操作系统)将占用一个文件句柄。 这是操作系统将限制的资源,因此除非有必要,否则最好不要保留它。 因此,限制工作队列的大小也很有意义。
public static ExecutorService newBoundedFixedThreadPool(int nThreads, int capacity) {return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(capacity),new ThreadPoolExecutor.DiscardPolicy());
}public static void boundedThreadPoolServerSocket() throws IOException {ServerSocket listener = new ServerSocket(8080);ExecutorService executor = newBoundedFixedThreadPool(4, 16);try {while (true) {Socket socket = listener.accept();executor.submit( new HandleRequestRunnable(socket) );}} finally {listener.close();}
}
再次,我们创建了一个线程池,但是我们没有使用Executors.newFixedThreadPool帮助器方法,而是自己创建了ThreadPoolExecutor,并传递了一个限制为16个元素的有界LinkedBlockingQueue 。 或者,可以使用ArrayBlockingQueue ,它是有界缓冲区的实现。
如果所有线程都忙,并且队列已满,则下一步将由ThreadPoolExecutor的最后一个参数定义。 在此示例中,使用了DiscardPolicy ,它只是丢弃将使队列溢出的所有工作。 还有其他政策,如AbortPolicy它抛出一个异常,或CallerRunsPolicy执行该调用者的线程上的工作。 此CallerRunsPolicy提供了一种简单的方法来自我限制可以添加作业的速率,但是,这可能是有害的,阻塞了应保持不受阻塞的线程。
一个好的默认策略是“放弃”或“中止”,这两者都会放弃工作。 在这些情况下,很容易将简单错误返回给客户端,例如HTTP 503“服务不可用” 。 有人会争辩说只是增加队列大小,然后所有工作最终都会运行。 但是,用户不愿永远等待,如果从根本上说工作进入的速度超过了可以执行的速度,那么队列将无限期地增长。 相反,该队列仅应用于消除突发请求,或处理处理中的短暂停顿。 在正常操作中,队列应为空。
有多少个线程?
现在我们了解了如何创建线程池,困难的问题是应该有多少个线程可用? 我们确定最大数量应该限制为不导致资源耗尽。 这包括所有类型的资源,内存(堆栈和堆),打开的文件句柄,打开的TCP连接,远程数据库可以处理的连接数以及任何其他有限资源。 相反,如果线程是与CPU绑定而不是与IO绑定,则应将物理核的数量视为有限,并且每个核最多只能创建一个线程。
这一切都取决于应用程序正在执行的工作。 用户应使用各种池大小以及实际的请求混合来运行负载测试。 每次增加它们的线程池大小直到断点。 这样就可以在资源耗尽时找到上限。 在某些情况下,明智的做法是增加可用资源的数量,例如为JVM提供更多的RAM,或者调整OS以允许更多的文件句柄。 但是,在某个时候会达到理论上限,应该注意,但这还不是故事的结局。
利特尔定律
排队论,尤其是利特尔定律 ,可以用来帮助理解线程池的属性。 简单来说,利特尔定律描述了三个变量之间的关系。 L进行中的请求数,λ新请求到达的速率,W平均处理该请求的时间。 例如,如果每秒有10个请求到达,并且每个请求花费一秒钟的时间来处理,则在任何时间平均有10个正在进行的请求。 在我们的示例中,这映射为使用10个线程。 如果处理单个请求的时间增加了一倍,则运行中的平均请求数也将增加一倍,达到20,因此需要20个线程。
了解执行时间对进行中的请求的影响非常重要。 某些后端资源(例如数据库)停顿是很常见的,导致请求花费更长的时间来处理,从而很快耗尽了线程池。 因此,理论上限可能不是池大小的适当限制。 相反,应该对执行时间设置一个限制,并与理论上限结合使用。
例如,假设在JVM超出其内存分配之前,可以处理的最大传输中请求为1000。 如果我们预算每个请求的时间不超过30秒,那么我们应该期望在最坏的情况下每秒处理不超过33个请求。 但是,如果一切正常,并且请求仅用500毫秒即可处理,则应用程序每秒只能在1000个线程上处理2000个请求。 指定可以使用队列来消除短暂的延迟突发也可能是合理的。
为什么要麻烦?
如果线程池中的线程太少,则存在以下风险:资源利用不足,并不必要地将用户拒之门外。 但是,如果允许太多线程,则会发生资源耗尽,这可能会造成更大的破坏。
不仅会耗尽本地资源,还可能对其他资源产生不利影响。 例如,多个应用程序查询同一个后端数据库。 数据库通常对并发连接数有硬性限制。 如果一个行为异常的无限制应用程序消耗了所有这些连接,它将阻止其他应用程序访问数据库。 造成大范围的中断。
更糟糕的是,可能会发生级联故障。 想象一下一个环境,其中有一个应用程序的多个实例,位于一个公共负载平衡器的后面。 如果由于过多的正在进行中的请求而使其中一个实例的内存不足,则JVM将花费更多时间进行垃圾收集,并减少处理请求的时间。 这种减慢速度将降低该实例的容量,并迫使其他实例处理更高比例的传入请求。 随着他们现在使用无限制的线程池处理更多请求,会发生相同的问题。 它们耗尽了内存,然后再次开始积极地进行垃圾收集。 这个恶性循环在所有实例之间级联,直到出现系统性故障。
我经常观察到没有进行负载测试,并且允许任意数量的线程。 在通常情况下,应用程序可以使用少量线程以传入速率愉快地处理请求。 但是,如果处理请求取决于远程服务,并且该服务暂时变慢,则W的增加(平均处理时间)的影响会很快耗尽池。 由于从未对应用程序进行最大数量的负载测试,因此会出现之前概述的所有资源耗尽问题。
多少个线程池?
在微 服务或面向服务的体系结构 (SOA)中,访问多个远程后端服务是正常的。 此设置特别容易发生故障,因此应仔细解决这些问题。 如果远程服务的性能下降,则可能导致线程池Swift达到其极限,从而丢弃后续请求。 但是,并非所有请求都可能需要此不正常的后端,但是由于线程池已满,因此这些请求被不必要地删除了。
通过提供特定于后端的线程池,可以隔离每个后端的故障。 在这种模式下,仍然只有一个请求工作程序池,但是如果请求需要调用远程服务,则工作将转移到该后端的线程池。 这使主请求池不会受到单个缓慢后端的负担。 这样,只有需要特定后端池的请求才会在故障时受到影响。
多个线程池的最后一个好处是,它有助于避免某种形式的死锁。 如果由于尚未处理的请求而导致每个可用线程都被阻塞,则将发生死锁,并且没有线程能够前进。 当使用多个池并充分了解它们执行的工作时,可以在某种程度上缓解此问题。
截止日期和其他最佳做法
常见的最佳做法是确保所有远程呼叫都有最后期限。 也就是说,如果远程服务在合理时间内没有响应,则该请求将被放弃。 可以在线程池中使用相同的技术。 具体来说,如果线程正在处理一个请求的时间超过了定义的期限,则应终止该线程。 为新请求腾出空间,并在W上设置上限。这似乎是一种浪费,但是如果用户(通常可能是Web浏览器)正在等待响应,则30秒后,浏览器可能只会给出无论如何,还是用户可能变得不耐烦并导航离开。
快速失败是在为后端创建池时可以采用的另一种方法。 如果后端发生故障,则线程池将Swift填充等待连接到无响应后端的请求。 相反,可以将后端标记为不正常,所有后续请求都可能立即失败,而不是不必要地等待。 但是请注意,需要一种机制来确定后端何时再次恢复健康。
最后,如果一个请求需要独立地调用多个后端,则应该可以并行而不是顺序地调用它们。 这将减少等待时间,但以增加线程为代价。
幸运的是,有一个很棒的库hystrix ,它打包了许多这些最佳实践,并以简单安全的方式公开了它们。
结论
希望本文能增进您对线程池的了解。 通过了解应用程序的需求,并结合使用最大线程数和平均响应时间,可以确定适当的线程池。 这不仅可以避免级联故障,而且可以帮助计划和配置服务。
即使您的应用程序可能未显式使用线程池,但它们还是被应用程序服务器或更高级别的抽象隐式使用。 Tomcat , JBoss , Undertow , Dropwizard都为其线程池(执行servlet的池)提供了多个可调参数。
翻译自: https://www.javacodegeeks.com/2015/12/importance-tuning-thread-pools.html
动态调整线程池