线程池有界队列和无界队列_线程池和工作队列

线程池是处理大量短期任务的有效方式,它减少了线程创建的开销并防止资源崩溃。工作队列是线程池的核心,允许固定线程数处理任务,避免调用者阻塞。然而,使用线程池也存在风险,如僵局、资源消耗和线程泄漏。正确调整线程池大小和使用成熟的并发库(如Doug Lea的并发实用程序)是确保高效并发的关键。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

为什么要使用线程池?

许多服务器应用程序,例如Web服务器,数据库服务器,文件服务器或邮件服务器,都是围绕处理大量来自某个远程源的简短任务而设计的。 请求以某种方式到达服务器,该方式可能是通过网络协议(例如HTTP,FTP或POP),通过JMS队列,或者可能是通过轮询数据库。 不管请求如何到达,在服务器应用程序中,通常每个任务的处理都是短暂的,并且请求的数量很大。

用于构建服务器应用程序的一种简化模型是,每次请求到达时创建一个新线程,并在新线程中处理该请求。 这种方法实际上可以很好地用于原型制作,但是有很多明显的缺点,如果您尝试部署以这种方式工作的服务器应用程序,这些缺点将变得显而易见。 每个请求线程方法的缺点之一是为每个请求创建一个新线程的开销很大。 如果服务器为每个请求创建一个新线程,则与处理实际用户请求相比,它会花费更多的时间并花费更多的系统资源来创建和销毁线程。

除了创建和销毁线程的开销外,活动线程还会消耗系统资源。 由于过多的内存消耗,在一个JVM中创建太多线程可能会导致系统内存不足或崩溃。 为了防止资源崩溃,服务器应用程序需要某种方式来限制在任何给定时间正在处理多少个请求。

线程池为线程生命周期开销问题和资源崩溃问题提供了解决方案。 通过将线程重用于多个任务,线程创建开销分散在许多任务上。 另外,由于在请求到达时线程已经存在,因此消除了线程创建带来的延迟。 因此,可以立即为请求提供服务,从而使应用程序更具响应性。 此外,通过适当地调整线程池中的线程数,可以通过强制超过某个阈值的任何请求等待直到有线程可以处理它来防止资源崩溃。

线程池的替代方法

线程池不是在服务器应用程序中使用多个线程的唯一方法。 如上所述,有时候为每个新任务生成一个新线程是完全明智的。 但是,如果任务创建的频率很高且平均任务持续时间很短,则为每个任务生成新线程将导致性能问题。

另一个常见的线程模型是具有单个后台线程和用于某种类型任务的任务队列。 AWT和Swing使用此模型,其中有一个GUI事件线程,并且导致用户界面更改的所有工作都必须在该线程中执行。 但是,由于只有一个AWT线程,因此不希望在AWT线程中执行可能要花费明显时间才能完成的任务。 结果,Swing应用程序通常需要其他工作线程来长时间运行与UI相关的任务。

每个任务线程和单后台线程方法在某些情况下都可以很好地工作。 “每任务线程”方法与少量长时间运行的任务配合得很好。 只要调度的可预测性不重要,那么单后台线程方法就可以很好地工作,低优先级后台任务就是这种情况。 但是,大多数服务器应用程序以处理大量短期任务或子任务为中心,因此,希望有一种机制能够以低开销有效地处理这些任务,以及某种程度的资源管理和时间可预测性。 线程池具有这些优点。

工作队列

就线程池的实际实现方式而言,术语“线程池”在某种程度上具有误导性,因为在大多数情况下,线程池的“显而易见”的实现不能完全产生我们想要的结果。 术语“线程池”早于Java平台,并且可能是来自不太面向对象的方法的产物。 尽管如此,该术语仍被广泛使用。

虽然我们可以轻松实现线程池类,其中客户端类将等待可用线程,将任务传递给该线程以执行,然后在完成时将线程返回到池中,但这种方法可能会产生一些不良影响。 例如,当池为空时会发生什么? 任何尝试将任务传递给池线程的调用方都将发现该池为空,并且其线程在等待可用池线程时将被阻塞。 通常,我们希望使用后台线程的原因之一是防止提交线程被阻塞。 像在线程池的“显而易见的”实现中那样,将阻塞一直推到调用者,最终可能会导致我们试图解决的相同问题。

我们通常想要的是一个工作队列,该队列与一组固定的工作线程组合在一起,该工作线程使用wait()notify()来向等待线程发出新工作到达的信号。 通常将工作队列实现为带有关联监视对象的某种链接列表。 清单1显示了一个简单的合并工作队列的示例。 这种模式使用Runnable对象队列,是调度程序和工作队列的通用约定,尽管Thread API并没有特别要求使用Runnable接口。

public class WorkQueue
{
    private final int nThreads;
    private final PoolWorker[] threads;
    private final LinkedList queue;

    public WorkQueue(int nThreads)
    {
        this.nThreads = nThreads;
        queue = new LinkedList();
        threads = new PoolWorker[nThreads];

        for (int i=0; i<nThreads; i++) {
            threads[i] = new PoolWorker();
            threads[i].start();
        }
    }

    public void execute(Runnable r) {
        synchronized(queue) {
            queue.addLast(r);
            queue.notify();
        }
    }

    private class PoolWorker extends Thread {
        public void run() {
            Runnable r;

            while (true) {
                synchronized(queue) {
                    while (queue.isEmpty()) {
                        try
                        {
                            queue.wait();
                        }
                        catch (InterruptedException ignored)
                        {
                        }
                    }

                    r = (Runnable) queue.removeFirst();
                }

                // If we don't catch RuntimeException, 
                // the pool could leak threads
                try {
                    r.run();
                }
                catch (RuntimeException e) {
                    // You might want to log something here
                }
            }
        }
    }
}

您可能已经注意到,清单1中的实现使用notify()而不是notifyAll() 。 大多数专家建议使用notifyAll()而不是notify() ,这有充分的理由:使用notify()存在一些细微的风险,并且仅在特定的特定条件下使用才是合适的。 另一方面,当正确使用时, notify()具有比notifyAll()更理想的性能特征; 特别是, notify()导致更少的上下文切换,这在服务器应用程序中很重要。

清单1中的示例工作队列满足安全使用notify()的要求。 因此,请继续在程序中使用它,但是在其他情况下使用notify()时要notify()小心。

使用线程池的风险

尽管线程池是用于构造多线程应用程序的强大机制,但它并非没有风险。 使用线程池构建的应用程序与任何其他多线程应用程序一样,都面临所有相同的并发风险,例如同步错误和死锁,以及线程池特有的其他一些风险,例如与池相关的死锁,资源颠簸和线程泄漏。

僵局

对于任何多线程应用程序,都有死锁的风险。 一组进程或线程在每个进程或线程正在等待事件中的死锁时,只有该组中的另一个进程可以引起该事件。 死锁最简单的情况是,线程A持有对象X的排他锁并等待对象Y的锁,而线程B持有对象Y的排他锁并等待对象X的锁。为了摆脱等待锁(Java锁不支持)的方式,死锁的线程将永远等待。

尽管死锁是任何多线程程序中的风险,但是线程池为死锁带来了另一种机会,其中所有池线程正在执行被阻塞的任务,等待队列中另一个任务的结果,但是另一个任务无法运行,因为没有空闲的空间。线程可用。 当使用线程池来实现涉及许多交互对象的模拟时,就会发生这种情况,并且模拟的对象可以向彼此发送查询,然后将它们作为排队的任务执行,并且查询对象同步等待响应。

资源th动

线程池的一个好处是,相对于替代的调度机制,线程池通常表现良好,我们已经讨论了其中的一些机制。 但这只有在正确调整线程池大小的情况下才是正确的。 线程消耗大量资源,包括内存和其他系统资源。 除了Thread对象所需的内存外,每个线程还需要两个执行调用堆栈,这可能会很大。 此外,JVM可能会为每个Java线程创建一个本机线程,这将消耗额外的系统资源。 最后,尽管线程之间切换的调度开销很小,但是对于许多线程而言,上下文切换可能会严重拖累程序的性能。

如果线程池太大,则这些线程消耗的资源可能会对系统性能产生重大影响。 在线程之间切换会浪费时间,并且线程数量超过您的需求可能会导致资源匮乏问题,因为池线程正在消耗资源,其他任务可以更有效地利用这些资源。 除了线程本身使用的资源之外,完成服务请求的工作可能还需要其他资源,例如JDBC连接,套接字或文件。 这些资源也是有限的,并且并发请求过多可能会导致失败,例如无法分配JDBC连接。

并发错误

线程池和其他排队机制依赖使用wait()notify()方法,这可能很棘手。 如果编码不正确,则可能会丢失通知,从而导致线程保持空闲状态,即使队列中有待处理的工作也是如此。 使用这些设施时必须格外小心; 甚至专家也会犯错。 更好的是,使用已知有效的现有实现,例如下面无需编写自己的中讨论的util.concurrent包。

螺纹泄漏

在所有类型的线程池中,重大的风险是线程泄漏,这种情况发生在从线程池中删除线程以执行任务时,但在任务完成时并未将其返回池中。 一种发生这种情况的方式是,当任务抛出RuntimeExceptionError 。 如果池类没有捕获到这些,则线程将简单退出,线程池的大小将永久减少一。 如果发生这种情况足够多的时间,则线程池最终将为空,并且由于没有线程可用于处理任务,系统将停止运行。

永久停止的任务(例如,可能永远等待无法保证可用的资源或可能已回家的用户的输入的任务)也可能导致线程泄漏。 如果线程被此类任务永久占用,则实际上已将其从池中删除。 应该为此类任务分配自己的线程,或者仅等待有限的时间。

请求超载

服务器有可能只是被请求所淹没。 在这种情况下,我们可能不想将每个传入请求都排队到我们的工作队列中,因为排队等待执行的任务可能会消耗过多的系统资源并导致资源匮乏。 在这种情况下,您应自行决定要做什么; 在某些情况下,您可以简单地将请求丢弃,依靠更高级别的协议在以后重试该请求,或者您可能希望以指示服务器暂时繁忙的响应来拒绝该请求。

有效使用线程池的准则

只要遵循一些简单的准则,线程池就可以成为构建服务器应用程序的一种非常有效的方法:

  • 不要将同步等待其他任务结果的任务排队。 这可能会导致上述形式的死锁,在该死锁中,所有线程都被任务占用,而这些任务又轮流等待由于所有线程都忙而无法执行的排队任务的结果。
  • 在将池化线程用于可能长期运行的操作时要小心。 如果程序必须等待资源(例如I / O完成),请指定最大等待时间,然后使任务失败或重新排队以供以后执行。 这样可以保证通过释放线程来完成可能成功完成的任务,最终将取得一些进展。
  • 了解您的任务。 为了有效地调整线程池的大小,您需要了解正在排队的任务以及它们在做什么。 它们是否受CPU限制? 它们是否受I / O约束? 您的答案将影响您调整应用程序的方式。 如果您具有特性完全不同的不同类别的任务,则可以为不同类型的任务使用多个工作队列,因此可以相应地调整每个池。

调整池大小

调整线程池的大小主要是要避免两个错误:线程过多或线程过多。 幸运的是,对于大多数应用程序,太少和太多之间的中间地带是相当宽的。

回想一下,在应用程序中使用线程有两个主要优点:允许处理在等待诸如I / O之类的缓慢操作的同时继续进行处理,以及利用多个处理器的可用性。 在运行于N处理器计算机上的受计算限制的应用程序中,随着线程数量接近N,添加其他线程可能会提高吞吐量,但是添加超过N的其他线程将无济于事。 实际上,由于额外的上下文切换开销,太多线程甚至会降低性能。

线程池的最佳大小取决于可用处理器的数量以及工作队列上任务的性质。 在一个N处理器系统上,该队列将完全容纳受计算限制的任务,通常使用N或N + 1线程的线程池可以达到最大的CPU利用率。

对于可能等待I / O完成的任务(例如,从套接字读取HTTP请求的任务),您将要增加池大小,使其超出可用处理器的数量,因为并非所有线程都在工作每时每刻。 使用概要分析,您可以估算典型请求的等待时间(WT)与服务时间(ST)的比率。 如果我们将此比率称为WT / ST,则对于N处理器系统,您将需要大约N *(1 + WT / ST)个线程来保持处理器的充分利用。

处理器利用率不是调整线程池大小的唯一考虑因素。 随着线程池的增长,您可能会遇到调度程序,可用内存或其他系统资源的限制,例如套接字数,打开的文件句柄或数据库连接。

不用自己写

Doug Lea编写了一个出色的并发实用程序开源库util.concurrent ,其中包括互斥体,信号量,队列和哈希表等收集类,这些类在并发访问下表现良好,并提供了几种工作队列实现。 此程序包中的PooledExecutor类是基于工作队列的高效,广泛使用的线程池的正确实现。 您可以考虑使用util.concurrent某些实用程序,而不是尝试自己编写,这很容易出错。

util.concurrent库也是JSR 166(一个Java社区流程(JCP)工作组)的灵感来源,该工作组将产生一组并发实用程序,以包含在java.util.concurrent包下的Java类库java.util.concurrent ,并且应该已经为Java Development Kit 1.5发行版做好了准备。

结论

线程池是用于组织服务器应用程序的有用工具。 它在概念上很简单,但是在实现和使用它时要注意几个问题,例如死锁,资源颠簸以及wait()notify()的复杂性。 如果发现自己的应用程序需要线程池,请考虑使用util.concurrent中的Executor类之一,例如PooledExecutor ,而不是从头开始编写。 如果您发现自己正在创建线程来处理短期任务,则绝对应该考虑使用线程池。


翻译自: https://www.ibm.com/developerworks/java/library/j-jtp0730/index.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值