65.9K
CodeProject 正在变化。 阅读更多。
Home

《.NET 线程入门指南:第 4 部分》

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.82/5 (123投票s)

2008 年 7 月 19 日

CPOL

11分钟阅读

viewsIcon

186646

downloadIcon

2153

本文将重点介绍如何控制不同线程的同步。

引言

我恐怕得说,我就是那种如果没有事做就会感到无聊的人。所以现在我终于觉得我掌握了 WPF 的基础知识,是时候转向其他事情了。

我有许多事情需要处理(例如《WCF/WF/CLR Via C# 第 2 版》一书),但我最近换了一份工作(并且接受了,但最终还是拒绝了),这份工作要求我对多线程有深入的了解。虽然我认为自己在多线程方面做得不错,但我还是觉得,嗯,我多线程还行,但总能做得更好。因此,我决定专门写一系列关于 .NET 多线程的文章。

这个系列的文章无疑要归功于我购买的一本出色的 Visual Basic .NET 线程手册,它很好地填补了 MSDN 的空白,也为你们提供了帮助。

我猜测这个主题将涵盖从简单到中等到高级的内容,并且会涉及很多 MSDN 上的内容,但我希望也能给出我自己的见解。所以,如果它看起来有点像 MSDN,请原谅我。

我不知道确切的时间表,但它最终可能会是这样的:

我想最好的方式就是直接开始。不过在开始之前有一点需要注意,我将使用 C# 和 Visual Studio 2008。

我将在本文中尝试涵盖的内容是:

本文将重点介绍如何控制不同线程的同步。

线程池的必要性

线程池确实是必要的;如果我们回顾之前的文章,会发现创建新线程是有一定开销的。`ThreadPool` 类正是为了解决这个问题而存在的。以下是线程池之所以重要的几个原因:

  • 线程池提高了应用程序的响应时间,因为线程已经存在于线程池中,无需创建。
  • 线程池节省了创建新线程以及在线程完成后回收其资源的开销。
  • 线程池根据当前正在运行的进程,优化了当前的时间片分配。
  • 线程池允许我们创建多个任务,而无需为每个任务指定优先级(尽管这可能是我们希望做的;稍后会详细介绍)。
  • 线程池使我们能够将状态信息作为对象传递给正在执行的任务的过程参数。
  • 线程池可用于限制客户端应用程序中的线程最大数量。

线程池概念

影响多线程应用程序响应能力的主要问题之一是为新任务生成线程所花费的时间。

例如,假设一个 Web 服务器是一个多线程应用程序,可以同时处理多个客户端请求。为了方便讨论,我们假设同时有 10 个客户端访问该 Web 服务器。

如果服务器采用“每客户端一个线程”的策略,它将创建 10 个新线程来服务这 10 个客户端。这涉及到创建线程、管理线程及其整个生命周期中的相关资源的开销。此外,机器资源可能在某个时候耗尽也是一个需要考虑的问题。

作为替代方案,如果 Web 服务器使用线程池来满足客户端请求,那么它就可以节省每次看到客户端请求时生成新线程所花费的时间。

这正是线程池背后的原理。

Windows 操作系统维护一个线程池来处理请求。如果我们的应用程序请求一个新线程,Windows 会尝试从池中获取一个。如果池为空,它会生成一个新线程并将其提供给我们。Windows 会动态调整线程池的大小,以提高我们应用程序的响应速度。

一旦线程完成其分配的任务,它就会返回到池中等待下一个分配。

线程池的大小

.NET Framework 在 `System.Threading` 命名空间中提供了 `ThreadPool` 类,用于在我们的应用程序中使用线程池。如果池中的某个线程空闲,线程池将促使一个工作线程保持所有处理器忙碌。如果池中的所有线程都已繁忙且有待处理的工作,`ThreadPool` 将生成新线程来完成待处理的工作。但是,创建的线程数不能超过指定的最大值。您可以使用 `ThreadPool.SetMaxThreads()` 和 `ThreadPool.SetMinThreads()` 方法来更改池的大小。

对于额外的线程需求,请求将被排队,直到某个线程完成其分配的任务并返回到池中。

线程池的陷阱

毫无疑问,`ThreadPool` 是一个非常有用的类,在开发多线程应用程序时肯定会派上用场。但是,在使用 `ThreadPool` 之前,有一些事情需要考虑,它们如下:

  • CLR 将 `ThreadPool` 中的线程分配给任务,并在任务完成后将它们释放回 `ThreadPool`。一旦任务开始,就没有办法取消它。
  • 线程池对于短暂的任务非常有效。`ThreadPool` 实际上不应用于长时间运行的任务,因为您将长时间占用一个可用的 `ThreadPool` 线程,这恰恰是使 `ThreadPool` 如此吸引人的原因之一。
  • `ThreadPool` 中的所有线程都是多线程单元。如果我们想将线程放在单线程单元中,`ThreadPool` 就不是合适的选择。
  • 如果我们想标识线程并执行诸如启动/挂起/中止等任务,那么 `ThreadPool` 就不是合适的选择。
  • 对于给定的进程,只能有一个 `ThreadPool`。
  • 如果 `ThreadPool` 中的某个任务被锁定,该线程将永远不会被释放回 `ThreadPool`。

探索 ThreadPool

下面列出了 `ThreadPool` 类的主要方法:

名称 描述
BindHandle 重载。将操作系统句柄绑定到 `ThreadPool`。
GetAvailableThreads 检索 `GetMaxThreads` 方法返回的最大线程池线程数与当前活动线程数之间的差值。
GetMaxThreads 检索可以同时活动的线程池请求数。超过该数量的所有请求都将保持排队状态,直到线程池线程可用为止。
GetMinThreads 检索线程池为了响应新请求而维护的空闲线程数。
QueueUserWorkItem 重载。将方法加入队列以执行。当线程池线程可用时,该方法将执行。
RegisterWaitForSingleObject 重载。注册一个等待 `WaitHandle` 的委托。
SetMaxThreads 设置可以同时活动的线程池请求数。超过该数量的所有请求都将保持排队状态,直到线程池线程可用为止。
SetMinThreads 设置线程池为了响应新请求而维护的空闲线程数。
UnsafeQueueNativeOverlapped 将重叠 I/O 操作加入队列以执行。
UnsafeQueueUserWorkItem 注册一个委托以等待 `WaitHandle`。
UnsafeRegisterWaitForSingleObject 重载。将指定的委托加入线程池队列。

-- 来自 MSDN

我认为您会看到的最常用的方法是以下这些:

  • QueueUserWorkItem
  • RegisterWaitForSingleObject

因此,演示代码将重点介绍这两个方法。

演示代码

正如我所说,`ThreadPool` 的两个最常用的方法是:

  • QueueUserWorkItem
  • RegisterWaitForSingleObject

让我们逐一来考虑它们。

QueueUserWorkItem(WaitCallback)

将方法加入队列以执行。当线程池线程可用时,该方法将执行。这是一个重载方法,允许您创建一个新的 `ThreadPool` 任务,可以使用状态对象,也可以不使用状态对象。第一个示例将一个新任务放入 `ThreadPool`,但未使用任何状态对象。

using System;
using System.Threading;

namespace QueueUserWorkItemNoState
{
    /// <summary>
    /// Queues a method for execution. 
    /// The method executes when a thread pool thread becomes available
    /// 
    /// This example was taken directly from MSDN
    /// </summary>
    class Program
    {
        static void Main(string[] args)
        {
            // Queue the task.
            ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadProc));

            Console.WriteLine("Main thread does some work, then sleeps.");
            // If you comment out the Sleep, the main thread exits before
            // the thread pool task runs. The thread pool uses background
            // threads, which do not keep the application running. (This
            // is a simple example of a race condition.)
            Thread.Sleep(1000);

            Console.WriteLine("Main thread exits.");
            Console.ReadLine();

        }

        // This thread procedure performs the task.
        static void ThreadProc(Object stateInfo)
        {
            // No state object was passed to QueueUserWorkItem, so 
            // stateInfo is null.
            Console.WriteLine("Hello from the thread pool.");
        }
    }
}

这基本上是最简单的形式了。结果如下:

`QueueUserWorkItem` 的第二个重载允许我们向任务传递状态。我们现在来看看,好吗?

QueueUserWorkItem(WaitCallback, Object)

将方法加入队列以执行,并指定一个包含要由方法使用的数据的对象。当线程池线程可用时,该方法将执行。

此示例使用简单的 `ReportData` 状态对象传递给 `ThreadPool` 工作项(任务)。

using System;
using System.Threading;

namespace QueueUserWorkItemWithState
{
    // ReportData holds state information for a task that will be
    // executed by a ThreadPool thread.
    public class ReportData
    {
        public int reportID;

        public ReportData(int reportID)
        {
            this.reportID = reportID;
        }
    }

    /// <summary>
    /// Queues a method for execution with a ReportData state object
    /// </summary>
    class Program
    {
        static void Main(string[] args)
        {
            // Queue the task with a state object
            ReportData rd = new ReportData(15);
            ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadProc), rd);

            Console.WriteLine("Main thread does some work, then sleeps.");
            // If you comment out the Sleep, the main thread exits before
            // the thread pool task runs. The thread pool uses background
            // threads, which do not keep the application running. (This
            // is a simple example of a race condition.)
            Thread.Sleep(1000);

            Console.WriteLine("Main thread exits.");
            Console.ReadLine();
        }

        // This thread procedure performs the task.
        static void ThreadProc(Object stateInfo)
        {
            //get the state object
            ReportData rd = (ReportData)stateInfo;
            Console.WriteLine(string.Format("report {0} is running", rd.reportID));
        }
    }
}

结果如下:

这演示了如何创建可由 `ThreadPool` 管理的简单任务。但正如我们在本系列的 第 3 部分 中所知,我们有时需要同步线程以确保它们行为良好并按正确的顺序运行。这对于我们手动创建的线程来说相对容易;我们可以使用 `WaitHandle` 来确保我们的线程按预期运行。但是,在使用 `ThreadPool` 时,这一切是如何工作的呢?

幸运的是,`ThreadPool` 还有一个方法(`ThreadPool.RegisterWaitForSingleObject()`),它允许我们提供一个 `WaitHandle`,以便我们可以确保 `ThreadPool` 工作项(任务)行为良好并按特定顺序运行。

`RegisterWaitForSingleObject` 方法有以下重载:

方法 描述
RegisterWaitForSingleObject(WaitHandle, WaitOrTimerCallback, Object, Int32, Boolean) 注册一个等待 `WaitHandle` 的委托,并指定一个 32 位有符号整数作为超时(以毫秒为单位)。
RegisterWaitForSingleObject(WaitHandle, WaitOrTimerCallback, Object, Int64, Boolean) 注册一个等待 `WaitHandle` 的委托,并指定一个 64 位有符号整数作为超时(以毫秒为单位)。
RegisterWaitForSingleObject(WaitHandle, WaitOrTimerCallback, Object, TimeSpan, Boolean) 注册一个等待 `WaitHandle` 的委托,并指定一个 `TimeSpan` 值作为超时。
RegisterWaitForSingleObject(WaitHandle, WaitOrTimerCallback, Object, UInt32, Boolean) 注册一个等待 `WaitHandle` 的委托,并指定一个 32 位无符号整数作为超时(以毫秒为单位)。

我不会详细介绍所有这些,但会介绍其中一个来概述原理。鼓励读者阅读这些重载以及其他 `ThreadPool` 方法。

随附的演示项目涵盖了以下重载的用法。

RegisterWaitForSingleObject(WaitHandle, WaitOrTimerCallback, Object, Int32, Boolean)

在此示例中,我将在 `ThreadPool` 中创建两个任务,其中一个任务是使用我们之前看到的 `ThreadPool.QueueUserWorkItem()` 加入队列的简单任务(带状态对象),第二个任务是 `RegisterWaitForSingleObject(WaitHandle, WaitOrTimerCallback, Object, Int32, Boolean)`,它将被加入 `ThreadPool` 队列,但直到它等待的 `WaitHandle` 被信号量化才能执行。

using System;
using System.Threading;

namespace RegisterWaitForSingleObject
{
    // ThreadIdentity holds state information for a task that will be
    // executed by a ThreadPool thread.
    public class ThreadIdentity
    {
        public string threadName;

        public ThreadIdentity(string threadName)
        {
            this.threadName = threadName;
        }
    }

    /// <summary>
    /// Queues both a normal task using the ThreadPool.QueueUserWorkItem
    /// and a task for execution but waits for a signal from a WaitHandle
    /// before proceeding to execute
    /// 
    /// The later is done using the ThreadPool.RegisterWaitForSingleObject
    /// ThreadPool method
    /// </summary>
    class Program
    {

        static AutoResetEvent ar = new AutoResetEvent(false);

        static void Main(string[] args)
        {
            //Registers a task that will wait for
            //a WaitHandle and will wait forever (-1 means
            //never expire) and has a state object,
            //and should execute only once
            ThreadIdentity ident1 = 
              new ThreadIdentity("RegisterWaitForSingleObject");
            ThreadPool.RegisterWaitForSingleObject(ar, 
                       new WaitOrTimerCallback(ThreadProc), 
                       ident1, -1, true);

            // Queue the task as normal with a state object
            ThreadIdentity ident2 = 
              new ThreadIdentity("QueueUserWorkItem");
            ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadProc), ident2);

            Console.WriteLine("Main thread does some work, then sleeps.");
            // If you comment out the Sleep, the main thread exits before
            // the thread pool task runs. The thread pool uses background
            // threads, which do not keep the application running. (This
            // is a simple example of a race condition.)
            Thread.Sleep(5000);

            Console.WriteLine("Main thread exits." + 
                              DateTime.Now.ToLongTimeString());
            Console.ReadLine();

        }

        // This thread procedure performs the task specified by the 
        // ThreadPool.QueueUserWorkItem
        static void ThreadProc(Object stateInfo)
        {
            ThreadIdentity ident1 = (ThreadIdentity)stateInfo;
            Console.WriteLine(string.Format(
                "Hello from thread {0} at time {1}",
                ident1.threadName,DateTime.Now.ToLongTimeString()));

            Thread.Sleep(2000);
            //set the AutoResetEvent to allow the waiting ThreadPool task
            //which we added using the RegisterWaitForSingleObject to proceed
            ar.Set();
        }


        // This thread procedure performs the task specified by the 
        // ThreadPool.RegisterWaitForSingleObject
        static void ThreadProc(Object stateInfo, bool timedOut)
        {
            ThreadIdentity ident1 = (ThreadIdentity)stateInfo;
            Console.WriteLine(string.Format("Hello from thread {0} at time {1}",
                ident1.threadName, DateTime.Now.ToLongTimeString()));
        }
    }
}

结果如下:可以看到,第一个加入队列的工作项(任务)是由 `RegisterWaitForSingleObject()` 方法完成的。由于它以传递到 `ThreadPool` 方法的 `WaitHandle` 开始,因此在 `WaitHandle` 被信号量化之前无法开始,而这只有在另一个使用 `QueueUserWorkItem()` 方法加入队列的工作项完成时才会发生。

使用此代码,应该很容易理解 `RegisterWaitForSingleObject()` 方法的其余重载是如何工作的。

进一步阅读

CodeProject 上有一篇非常出色的文章:“The Smart Thread Pool”,作者是 Ami Bar,它具有以下特点:

  • 线程数量根据线程池中的线程负载动态变化。
  • 工作项可以返回值。
  • 如果工作项尚未执行,则可以将其取消。
  • 执行工作项时会使用调用线程的上下文(有限)。
  • 使用最少数量的 Win32 事件句柄,这样应用程序的句柄数就不会爆炸。
  • 调用者可以等待多个或所有工作项完成。
  • 工作项有一个 `PostExecute` 回调,该回调在工作项完成后立即调用。
  • 与工作项关联的状态对象可以自动释放。
  • 工作项异常会被发送回调用者。
  • 工作项具有优先级。
  • 工作项分组。
  • 调用者可以暂停线程池和工作项组的启动。
  • 线程具有优先级。

这实际上做得非常好。

Ami Bar 的“The Smart Thread Pool”项目可以在以下 URL 找到:Smart Thread Pool

我们完成了

好了,这次就说到这里。我只想说,我**完全**意识到本文借鉴了许多不同的来源;然而,我认为它可以提醒潜在的多线程新手,让他们注意到他们不知道去查找的类/对象。因此,我仍然认为本文会有所帮助。好吧,这至少是我的初衷。我只希望您同意;如果同意,请告诉我,并投个赞成票。

下次

下次,我们将研究 UI 中的多线程。

如果喜欢这篇文章,能否请您投个赞成票?非常感谢。

参考文献

  1. C# 中的多线程,Joseph Albahari
  2. System.Threading MSDN 页面
  3. 线程对象和功能 MSDN 页面
  4. Visual Basic .NET 多线程,Wrox
© . All rights reserved.