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

.NET 的 ThreadPool 类 - 幕后探秘

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.72/5 (55投票s)

2003 年 3 月 19 日

CPOL

7分钟阅读

viewsIcon

231510

downloadIcon

1122

何时使用 .NET 的 ThreadPool 类,何时使用其他方法。

引言

我下一部分的应用程序自动化层需要一个线程池来管理工作线程。在研究 .NET 的 ThreadPool 类时,我发现它并不完全符合我的设想。引用 Manisha Mehta 的文章“多线程第四部分:线程池、Timer 类和异步编程探讨”的[此处][^]

当您开始创建自己的多线程应用程序时,您会发现大部分时间您的线程都在空闲等待事情发生……

这只适用于一部分线程,例如发生 I/O 完成事件时,线程会被释放。在许多情况下,我需要能够在后台执行某些工作的线程,同时仍然允许用户与应用程序进行交互。具体来说,我想探索所谓的“竞争线程”,即根据不同因素调整线程优先级。将其视为线程的“服务质量”。

该文章还提到

但是请记住,在任何特定时间点,每个进程只有一个线程池,并且每个线程池对象只有一个工作线程……[一个线程池]每个可用处理器默认限制为 25 个线程……

这些陈述似乎自相矛盾,暗示每个进程只能执行一个线程,但有 25 个可用线程,这非常令人困惑。在研究了 ThreadPool 后面的代码后,我发现这个说法“在某种程度上”是正确的,但也带来了一些其他发现。

本文的其余部分将讨论我的观察。为了理解讨论的某些数字,请记住我正在 1.6Ghz P4 单处理器系统上进行这些测试。另请注意,在所有这些测试中,我都使用了一个计时器事件,该事件设置为在 1 秒后超时。计时器事件设置了一个标志,线程会监控该标志。当线程看到标志被设置时,它会终止自己。计时器事件的代码是

static void OnTimerEvent(object src, ElapsedEventArgs e)
{
  done=true;
}

我能数到多少?

1 秒钟我能数到多少?大约 10,277,633。使用一个设置为 1 秒后触发的 `System.Timers.Timer` 对象,代码会愉快地计数直到计时器到期。

static decimal SingleThreadTest()
{
  done=false;
  decimal counter=0;
  timer.Start();
  while (!done)
  {
    ++counter;
  }
  return counter;
}

为什么使用线程池?

这是使用线程池的原因。这次,我将看看每增加一个计数器,通过创建和销毁一个线程,我能数到多少。代码如下

static void CreateAndDestroyTest()
{
  done=false;
  timer.Start();
  while (!done)
  {
    Thread counterThread=new Thread(new ThreadStart(Count1Thread));
    counterThread.Start();
    while (counterThread.IsAlive) {};
  }
}

static void Count1Thread()
{
  ++count2;
}
答案是:**11**。是。十一。在 1 秒内,我的机器能够创建和销毁十一个线程。显然,如果我有一个应用程序需要处理大量异步、非 I/O 完成类型的事件,创建和销毁线程是一种非常昂贵的方式。因此需要线程池。

首先,基准测试

在测试线程池的性能之前,进行基准测试很有用。我通过简单地实例化 10 个计数线程来创建一个基准测试。每个线程增加自己的计数器,并在 1 秒结束时退出。代码如下

// initialize counters
static void InitThreadPoolCounters()
{
  threadDone=0;
  for (int i=0; i<10; i++)
  {
    threadPoolCounters[i]=0;
  }
}

// initialize threads
static void InitThreads()
{
  for (int i=0; i<10; i++)
  {
    threads[i]=new Thread(new ThreadStart(Count2Thread));
    threads[i].Name=i.ToString();
  }
}

// start the threads
static void StartThreads()
{
  done=false;
  timer.Start();
  for (int i=0; i<10; i++)
  {
    threads[i].Start();
  }
}

// the thread itself
static void Count2Thread()
{
  int n=Convert.ToInt32(Thread.CurrentThread.Name);
  while (!done)
  { 
    ++threadPoolCounters[n];
  }
  Interlocked.Increment(ref threadDone);
}
……以及实际将所有内容整合在一起的代码
...
InitThreadPoolCounters();
InitThreads();
StartThreads();
while (threadDone != 10) {};
...
每个线程的结果计数是

T0 = 957393
T1 = 1003875
T2 = 934912
T3 = 1004638
T4 = 988772
T5 = 962442
T6 = 979893
T7 = 777888
T8 = 923105
T9 = 982427
总计 = 9515345

在合理的误差范围内,10 个单独的线程总和与之前由单个应用程序线程计数器确定的值相同。因此,这就是我们线程池性能的基准。

使用线程池

现在,让我们看看当我使用 .NET 的 ThreadPool 对象时会发生什么
static void QueueThreadPoolThreads()
{
  done=false;
  timer.Start();
  for (int i=0; i<10; i++)
  {
    ThreadPool.QueueUserWorkItem(new WaitCallback(Count3Thread), i);
  }
}

static void Count3Thread(object state)
{
  int n=(int)state;
  while (!done)
  {
    ++threadPoolCounters[n];
  }
  Interlocked.Increment(ref threadDone);
}
测试本应只运行 1 秒,但却花了大约 30 秒才完成!当它完成时,计数非常高,这表明计时器事件从未触发。要理解这一点,我们必须深入研究 sscli\clr\src\vm\win32threadpool.cpp 代码。让我们先看看 `ThreadPool.QueueUserWorkItem()`。

此函数将工作线程委托放入队列,并测试是否应创建新线程。如果应创建新线程,则调用 `CreateWorkerThread` 函数并退出。反之,如果此时不创建线程,则创建一个不同的线程“CreateThreadGate”,如果它尚不存在。线程门的目的是定期检查并查看是否可以稍后创建线程。

`ShouldGrowWorkerThread` 函数执行 3 个参数的测试,以确定是否应创建新线程。

请注意,此函数首先测试的是运行线程数是否小于可用 CPU 数。显然,在单 CPU 系统上有一个或多个运行线程时,此函数将返回 false。在这种情况下(根据流程图),将利用线程门稍后创建线程。我稍后会讨论这一点。

`CreateWorkerThread` 函数基本上只是一个实例化实际工作线程的存根

工作线程处于等待循环中,等待 `WorkRequestNotification` 事件,如果事件未被触发,则在 40 秒后超时。假设事件被触发,执行会继续通过从队列中移除委托(这将使事件处于未触发状态),测试是否确实获得了有效的委托,然后调用委托。当委托返回时,工作线程立即检查队列中是否还有其他请求。如果有,它会处理这些请求;如果没有,它会返回到等待状态。

仅凭这段代码,就能揭示 ThreadPool 管理线程的一个至关重要的事实:**尽快完成工作**。您的工作线程花费的任何时间都将导致处理队列中其他项目的延迟。

现在让我们通过查看 `GateThreadStart` 流程图来检查门控线程

首先要注意的是,此函数会休眠 0.5 秒。在此延迟之后,它会执行一个测试以确定队列中是否有任何线程请求。如果没有,它会再次休眠。如果有,它会调用一个函数来确定是否应启动一个线程。此函数会根据上一个线程创建的时间和当前运行线程的数量来延迟线程创建。通过检查此表

有趣的是,创建第 25 个线程可能需要 5.4 秒。此外,由于 `Sleep(500)` 调用,当线程快速连续创建时,这些时间会被量化为 500 毫秒的间隔。例如,如果连续创建两个线程,第二个线程将需要 1000 毫秒,因为requisite 550 毫秒尚未经过,并且函数返回到睡眠状态。

ThreadPool 的这一部分强调了尽快进出工作线程的必要性,以避免在有多个并发线程运行时出现的瓶颈。

计时器和可等待对象

计时器使用 ThreadPool 进行回调。因此,如果您想要相当可靠的计时器,它们和您的工作线程需要很短。同样,由 ThreadPool 管理的任何可等待对象(如 I/O 完成事件)也会受到您应用程序中其他线程实现的影响。

有哪些替代方案?

正如我在本文开头提到的,并非所有线程都是出于相同目的而创建的。虽然 ThreadPool 对于管理通常处于等待状态且工作时间很短的线程很有用,但对于不符合此标准的任务,.NET 的 ThreadPool 类是一个非常糟糕的选择。

幸运的是,Microsoft 的 Stephan Stoub 编写了一个 `ManagedThreadPool` 类,该类专为满足第二种线程需求而设计。使用它与 .NET 的 ThreadPool 完全相同

static void QueueManagedThreadPoolThreads()
{
    done=false;
    timer.Start();
    for (int i=0; i<10; i++)
    {
        Toub.Threading.ManagedThreadPool.QueueUserWorkItem(
                          new WaitCallback(Count3Thread), i);
    }
}
以下测试数字说明了这一点

T0 = 970806
T1 = 996123
T2 = 914349
T3 = 990998
T4 = 977450
T5 = 957585
T6 = 951259
T7 = 770934
T8 = 982279
T9 = 1135806
总计 = 9647589

它的表现非常好。Stoub 的 `ManagedThreadPool` 类的优点在于,所有线程最初都已创建并在需要时分配。没有复杂的线程创建延迟,这使得该线程池适用于两种类型的线程。

结论

我希望本文能阐明线程池的复杂性。附件代码包括 Stephan Stoub 的代码,未经修改。我将由读者自行进一步检查他的代码,他的代码非常出色。

特别感谢 CPian leppie 为我找到 Stoub 的代码!

© . All rights reserved.