C#线程 入门

Threading in C#

 

第一部分: 入门

介绍和概念

C#支持通过多线程并行执行代码。线程是一个独立的执行路径,能够与其他线程同时运行。C#客户端程序(控制台,WPF或Windows窗体)在CLR和操作系统自动创建的单个线程(“主”线程)中启动,并通过创建其他线程而成为多线程。这是一个简单的示例及其输出:

所有示例均假定导入了以下名称空间:

using System;
using System.Threading;
class ThreadTest

{
  static void Main()
  {
    Thread t = new Thread (WriteY);          // Kick off a new thread
    t.Start();                               // running WriteY()
 
    // Simultaneously, do something on the main thread.
    for (int i = 0; i < 1000; i++) Console.Write ("x");
  }
 
  static void WriteY()
  {
    for (int i = 0; i < 1000; i++) Console.Write ("y");
  }
}

 

xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...

主线程创建一个新线程t,在该线程上运行一种方法,该方法反复打印字符“ y”。同时,主线程重复打印字符“ x”:

 

 一旦启动,线程的IsAlive属性将返回true,直到线程结束为止。当传递给线程构造函数的委托完成执行时,线程结束。一旦结束,线程将无法重新启动。

 1 static void Main() 
 2 {
 3   new Thread (Go).Start();      // Call Go() on a new thread
 4   Go();                         // Call Go() on the main thread
 5 }
 6  
 7 static void Go()
 8 {
 9   // Declare and use a local variable - 'cycles'
10   for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
11 }

 

??????????

在每个线程的内存堆栈上创建一个单独的cycles变量副本,因此,可以预见的是,输出为十个问号。

 

如果线程具有对同一对象实例的公共引用,则它们共享数据。例如:

class ThreadTest
{
  bool done;
 
  static void Main()
  {
    ThreadTest tt = new ThreadTest();   // Create a common instance
    new Thread (tt.Go).Start();
    tt.Go();
  }
 
  // Note that Go is now an instance method
  void Go() 
  {
     if (!done) { done = true; Console.WriteLine ("Done"); }
  }
}

 

由于两个线程在同一个ThreadTest实例上调用Go(),因此它们共享done字段。这导致“完成”打印一次而不是两次:

完成

静态字段提供了另一种在线程之间共享数据的方法。这是同一示例,其作为静态字段完成了:

class ThreadTest 
{
  static bool done;    // Static fields are shared between all threads
 
  static void Main()
  {
    new Thread (Go).Start();
    Go();
  }
 
  static void Go()
  {
    if (!done) { done = true; Console.WriteLine ("Done"); }
  }
}
View Code

 

这两个示例都说明了另一个关键概念:线程安全的概念(或更确切地说,缺乏安全性)。输出实际上是不确定的:“完成”有可能(尽管不太可能)打印两次。但是,如果我们在Go方法中交换语句的顺序,则两次打印完成的机率会大大提高:

static void Go()
{
  if (!done) { Console.WriteLine ("Done"); done = true; }
}
View Code

完成

完成(通常!)

问题在于,一个线程可以评估if语句是否正确,而另一个线程正在执行WriteLine语句-在有机会将done设置为true之前。

 

补救措施是在读写公共字段时获得排他锁。 C#为此提供了lock语句:

class ThreadSafe 
{
  static bool done;
  static readonly object locker = new object();
 
  static void Main()
  {
    new Thread (Go).Start();
    Go();
  }
 
  static void Go()
  {
    lock (locker)
    {
      if (!done) { Console.WriteLine ("Done"); done = true; }
    }
  }
}
View Code

 

当两个线程同时争用一个锁(在这种情况下为锁柜)时,一个线程将等待或阻塞,直到锁可用为止。在这种情况下,可以确保一次只有一个线程可以输入代码的关键部分,并且“完成”将仅打印一次。以这种方式受到保护的代码(在多线程上下文中不受不确定性的影响)被称为线程安全的。共享数据是造成多线程复杂性和模糊错误的主要原因。尽管通常是必不可少的,但保持尽可能简单是值得的。线程虽然被阻止,但不会消耗CPU资源。

Join and Sleep

您可以通过调用其Join()来等待另一个线程结束。例如:

static void Main()
{
  Thread t = new Thread (Go);
  t.Start();
  t.Join();
  Console.WriteLine ("Thread t has ended!");
}
 
static void Go()
{
  for (int i = 0; i < 1000; i++) Console.Write ("y");
}
View Code

这将打印“ y” 1,000次,然后显示“线程t已结束!”。紧接着。您可以在调用Join时包含一个超时(以毫秒为单位)或作为TimeSpan。然后,如果线程结束,则返回true;如果超时,则返回false。

 

Thread.Sleep将当前线程暂停指定的时间:

Thread.Sleep (TimeSpan.FromHours (1));  // sleep for 1 hour
Thread.Sleep (500);                     // sleep for 500 milliseconds

  

在等待睡眠或加入时,线程被阻塞,因此不消耗CPU资源。

 

Thread.Sleep(0)立即放弃线程的当前时间片,自动将CPU移交给其他线程。 Framework 4.0的新Thread.Yield()方法具有相同的作用-只是它只放弃运行在同一处理器上的线程。

 

Sleep(0)或Yield在生产代码中偶尔用于进行高级性能调整。它也是帮助发现线程安全问题的出色诊断工具:如果在代码中的任意位置插入Thread.Yield()会破坏程序,则几乎肯定会出现错误。

 线程如何工作 

 

多线程由线程调度程序在内部进行管理,这是CLR通常委托给操作系统的功能。线程调度程序确保为所有活动线程分配适当的执行时间,并且正在等待或阻塞的线程(例如,排他锁或用户输入)不会浪费CPU时间。

在单处理器计算机上,线程调度程序执行时间切片-在每个活动线程之间快速切换执行。在Windows下,时间片通常在数十毫秒的区域中-远大于在一个线程与另一个线程之间实际切换上下文时的CPU开销(通常在几微秒的区域)。

在多处理器计算机上,多线程是通过时间片和真正的并发实现的,其中不同的线程在不同的CPU上同时运行代码。几乎可以肯定,由于操作系统需要服务自己的线程以及其他应用程序的线程,因此还会有一些时间片。

当线程的执行由于外部因素(例如时间分段)而中断时,可以说该线程被抢占。在大多数情况下,线程无法控制其抢占的时间和地点。

 线程与进程

线程类似于您的应用程序在其中运行的操作系统进程。正如进程在计算机上并行运行一样,线程在单个进程中并行运行。流程彼此完全隔离;线程的隔离度有限。特别是,线程与在同一应用程序中运行的其他线程共享(堆)内存。这部分是为什么线程有用的原因:例如,一个线程可以在后台获取数据,而另一个线程可以在数据到达时显示数据

线程的使用和滥用

多线程有很多用途。这是最常见的:

 

维护响应式用户界面

 

通过在并行的“工作者”线程上运行耗时的任务,主UI线程可以自由继续处理键盘和鼠标事件。

有效利用原本被阻塞的CPU

 

当线程正在等待另一台计算机或硬件的响应时,多线程很有用。当一个线程在执行任务时被阻塞时,其他线程可以利用原本没有负担的计算机。

 

并行编程

 

如果以“分而治之”策略在多个线程之间共享工作负载,则执行密集计算的代码可以在多核或多处理器计算机上更快地执行(请参阅第5部分)。

 

投机执行

 

在多核计算机上,有时可以通过预测可能需要完成的事情然后提前进行来提高性能。 LINQPad使用此技术来加快新查询的创建。一种变化是并行运行许多不同的算法,这些算法都可以解决同一任务。不论哪一个先获得“胜利”,当您不知道哪种算法执行最快时,这才有效。

 

允许同时处理请求

 

在服务器上,客户端请求可以同时到达,因此需要并行处理(如果使用ASP.NET,WCF,Web服务或远程处理,.NET Framework会为此自动创建线程)。这在客户端上也很有用(例如,处理对等网络-甚至来自用户的多个请求)。

 

使用ASP.NET和WCF之类的技术,您可能甚至不知道多线程正在发生-除非您在没有适当锁定的情况下访问共享数据(也许通过静态字段),否则会破坏线程安全性。

 

线程还附带有字符串。最大的问题是多线程会增加复杂性。有很多线程本身并不会带来很多复杂性。确实是线程之间的交互(通常是通过共享数据)。无论交互是否是有意的,这都适用,并且可能导致较长的开发周期以及对间歇性和不可复制错误的持续敏感性。因此,必须尽量减少交互,并尽可能地坚持简单且经过验证的设计。本文主要侧重于处理这些复杂性。删除互动,无需多说!

 

好的策略是将多线程逻辑封装到可重用的类中,这些类可以独立检查和测试。框架本身提供了许多更高级别的线程结构,我们将在后面介绍。

 

线程化还会在调度和切换线程时(如果活动线程多于CPU内核)会导致资源和CPU成本的增加,并且还会产生创建/拆除的成本。多线程并不总是可以加快您的应用程序的速度-如果使用过多或使用不当,它甚至可能减慢其速度。例如,当涉及大量磁盘I / O时,让几个工作线程按顺序运行任务比一次执行10个线程快得多。 (在“使用等待和脉冲发送信号”中,我们描述了如何实现仅提供此功能的生产者/消费者队列。)

创建和启动线程

正如我们在简介中所看到的,线程是使用Thread类的构造函数创建的,并传入ThreadStart委托,该委托指示应从何处开始执行。定义ThreadStart委托的方法如下:

public delegate void ThreadStart();

在线程上调用Start,然后将其设置为运行。线程继续执行,直到其方法返回为止,此时线程结束。这是使用扩展的C#语法创建TheadStart委托的示例:

 1 class ThreadTest
 2 {
 3   static void Main() 
 4   {
 5     Thread t = new Thread (new ThreadStart (Go));
 6  
 7     t.Start();   // Run Go() on the new thread.
 8     Go();        // Simultaneously run Go() in the main thread.
 9   }
10  
11   static void Go()
12   {
13     Console.WriteLine ("hello!");
14   }
15 }
View Code

 

在此示例中,线程t在主线程调用Go()的同一时间执行Go()。结果是两个接近即时的问候。

 

通过仅指定一个方法组,并允许C#推断ThreadStart委托,可以更方便地创建线程:

 Thread t = new Thread (Go);  //无需显式使用ThreadStart

另一个快捷方式是使用lambda表达式或匿名方法:

 
static void Main()
{
  Thread t = new Thread ( () => Console.WriteLine ("Hello!") );
  t.Start();
}
View Code

 

将数据传递给线程

将参数传递给线程的target方法的最简单方法是执行一个lambda表达式,该表达式使用所需的参数调用该方法:

 1 static void Main()
 2 {
 3   Thread t = new Thread ( () => Print ("Hello from t!") );
 4   t.Start();
 5 }
 6  
 7 static void Print (string message) 
 8 {
 9   Console.WriteLine (message);
10 }

 

使用这种方法,您可以将任意数量的参数传递给该方法。您甚至可以将整个实现包装在多语句lambda中:

new Thread (() =>
{
  Console.WriteLine ("I'm running on another thread!");
  Console.WriteLine ("This is so easy!");
}).Start();
View Code

 

您可以使用匿名方法在C#2.0中几乎轻松地执行相同的操作:

new Thread (delegate()
{
  ...
}).Start();

另一种技术是将参数传递给Thread的Start方法:

static void Main()
{
  Thread t = new Thread (Print);
  t.Start ("Hello from t!");
}
 
static void Print (object messageObj)
{
  string message = (string) messageObj;   // We need to cast here
  Console.WriteLine (message);
}

 

之所以可行,是因为Thread的构造函数被重载为接受两个委托之一:

public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);

  

ParameterizedThreadStart的局限性在于它仅接受一个参数。而且由于它是object类型的,因此通常需要强制转换。

Lambda表达式和捕获的变量

如我们所见,lambda表达式是将数据传递到线程的最强大的方法。但是,您必须小心在启动线程后意外修改捕获的变量,因为这些变量是共享的。例如,考虑以下内容:
for (int i = 0; i < 10; i++)
  new Thread (() => Console.Write (i)).Start();

  输出是不确定的!这是一个典型的结果:

0223557799

问题在于,i变量在循环的整个生命周期中都指向相同的内存位置。因此,每个线程都会在变量上调用Console.Write,该变量的值可能会随着运行而改变!

 

这类似于我们在C#4.0的第八章“捕获变量”中描述的问题。问题不在于多线程,而是与C#捕获变量的规则有关(在for和foreach循环的情况下这是不希望的)。

 

 解决方案是使用如下临时变量:

for (int i = 0; i < 10; i++)
{
  int temp = i;
  new Thread (() => Console.Write (temp)).Start();
}

  

现在,可变温度是每个循环迭代的局部变量。因此,每个线程捕获一个不同的内存位置,这没有问题。我们可以通过以下示例更简单地说明早期代码中的问题:

string text = "t1";
Thread t1 = new Thread ( () => Console.WriteLine (text) );
 
text = "t2";
Thread t2 = new Thread ( () => Console.WriteLine (text) );
 
t1.Start();
t2.Start();

  

因为两个lambda表达式都捕获相同的文本变量,所以t2被打印两次

t2
t2

命名线程

每个线程都有一个Name属性,可以设置该属性以利于调试。这在Visual Studio中特别有用,因为线程的名称显示在“线程窗口”和“调试位置”工具栏中。您只需设置一个线程名称即可;稍后尝试更改它会引发异常。

 

静态Thread.CurrentThread属性为您提供当前正在执行的线程。在以下示例中,我们设置主线程的名称:

class ThreadNaming
{
  static void Main()
  {
    Thread.CurrentThread.Name = "main";
    Thread worker = new Thread (Go);
    worker.Name = "worker";
    worker.Start();
    Go();
  } static void Go()
  {
    Console.WriteLine ("Hello from " + Thread.CurrentThread.Name);
  }
}

 

  

 
 

前台线程和后台线程

默认情况下,您显式创建的线程是前台线程。只要前台线程中的任何一个正在运行,它就可以使应用程序保持活动状态,而后台线程则不会。一旦所有前台线程完成,应用程序结束,所有仍在运行的后台线程终止。

 

线程的前台/后台状态与其优先级或执行时间的分配无关。

 

您可以使用其IsBackground属性查询或更改线程的背景状态。这是一个例子:

class PriorityTest
{
  static void Main (string[] args)
  {
    Thread worker = new Thread ( () => Console.ReadLine() );
    if (args.Length > 0) worker.IsBackground = true;
    worker.Start();
  }
}

如果不带任何参数调用此程序,则工作线程将处于前台状态,并将在ReadLine语句上等待用户按Enter。同时,主线程退出,但是应用程序继续运行,因为前台线程仍然处于活动状态。

 

另一方面,如果将参数传递给Main(),则会为工作程序分配背景状态,并且在主线程结束(终止ReadLine)时,程序几乎立即退出。

 

当进程以这种方式终止时,将规避后台线程执行堆栈中的所有finally块。如果您的程序最终使用(或使用)块来执行清理工作(例如释放资源或删除临时文件),则会出现问题。为了避免这种情况,您可以在退出应用程序后显式等待此类后台线程。

有两种方法可以实现此目的:

 

  • 如果您自己创建了线程,请在该线程上调用Join。
  • 如果您使用的是共享线程,请使用事件等待句柄。

在这两种情况下,您都应指定一个超时时间,以便在由于某种原因而拒绝完成的叛逆线程时可以放弃它。这是您的备份退出策略:最后,您希望您的应用程序关闭-无需用户从任务管理器中寻求帮助!

 

如果用户使用任务管理器强制结束.NET进程,则所有线程都“掉线”,就好像它们是后台线程一样。这是观察到的,而不是记录的行为,并且它可能因CLR和操作系统版本而异。

 

前景线程不需要这种处理,但是您必须注意避免可能导致线程无法结束的错误。应用程序无法正常退出的常见原因是活动的前台线程的存在。

线程优先级

线程的“优先级”属性确定相对于操作系统中其他活动线程而言,执行时间的长短如下:

 

枚举ThreadPriority {最低,低于正常,正常,高于正常,最高}

仅在同时激活多个线程时,这才有意义。

 

在提高线程的优先级之前,请仔细考虑-这可能导致诸如其他线程的资源匮乏之类的问题。

 

提升线程的优先级并使其无法执行实时工作,因为它仍然受到应用程序进程优先级的限制。要执行实时工作,您还必须使用System.Diagnostics中的Process类提高流程优先级(我们没有告诉您如何执行此操作):

using (Process p = Process.GetCurrentProcess())
  p.PriorityClass = ProcessPriorityClass.High;

  

实际上,ProcessPriorityClass.High比最高优先级低了一个等级:实时。将进程优先级设置为“实时”会指示OS,您从不希望该进程将CPU时间浪费给另一个进程。如果您的程序进入意外的无限循环,您甚至可能会发现操作系统已锁定,只剩下电源按钮可以拯救您!因此,“高”通常是实时应用程序的最佳选择。

 

如果您的实时应用程序具有用户界面,则提高进程优先级将给屏幕更新带来过多的CPU时间,从而减慢整个计算机的速度(尤其是在UI复杂的情况下)。降低主线程的优先级并提高进程的优先级可确保实时线程不会因屏幕重绘而被抢占,但不会解决使其他应用程序耗尽CPU时间的问题,因为操作系统仍会分配整个过程的资源不成比例。理想的解决方案是使实时工作程序和用户界面作为具有不同进程优先级的单独应用程序运行,并通过远程处理或内存映射文件进行通信。内存映射文件非常适合此任务。简而言之,我们将在C#4.0的第14和25章中解释它们的工作原理。

 

即使提高了流程优先级,托管环境在处理严格的实时需求方面的适用性也受到限制。除了由自动垃圾收集引起的延迟问题外,操作系统(甚至对于非托管应用程序)可能还会带来其他挑战,而这些挑战最好通过专用硬件或专用实时平台来解决。

异常处理

创建线程时,作用域中的任何try / catch / finally块都与线程开始执行时无关。考虑以下程序:

public static void Main()
{
  try
  {
    new Thread (Go).Start();
  }
  catch (Exception ex)
  {
    // We'll never get here!
    Console.WriteLine ("Exception!");
  }
}
 
static void Go() { throw null; }   // Throws a NullReferenceException

此示例中的try / catch语句无效,并且新创建的线程将受到未处理的NullReferenceException的阻碍。当您认为每个线程都有一个独立的执行路径时,此行为很有意义。

补救措施是将异常处理程序移至Go方法中:

public static void Main()
{
   new Thread (Go).Start();
}
 
static void Go()
{
  try
  {
    // ...
    throw null;    // The NullReferenceException will get caught below
    // ...
  }
  catch (Exception ex)
  {
    // Typically log the exception, and/or signal another thread
    // that we've come unstuck
    // ...
  }
}

  

在生产应用程序中的所有线程进入方法上都需要一个异常处理程序,就像在主线程上一样(通常在执行堆栈中处于更高级别)。未处理的异常会导致整个应用程序关闭。与一个丑陋的对话!

在编写此类异常处理块时,很少会忽略该错误:通常,您会记录异常的详细信息,然后显示一个对话框,允许用户自动将这些详细信息提交到您的Web服务器。然后,您可能会关闭该应用程序-因为该错误有可能破坏了程序的状态。但是,这样做的代价是用户将丢失其最近的工作-例如打开的文档。

WPF和Windows Forms应用程序的“全局”异常处理事件(Application.DispatcherUnhandledException和Application.ThreadException)仅针对在主UI线程上引发的异常触发。您仍然必须手动处理工作线程上的异常。

 AppDomain.CurrentDomain.UnhandledException在任何未处理的异常上触发,但没有提供防止应用程序随后关闭的方法。但是,在某些情况下,您不需要处理工作线程上的异常,因为.NET Framework会为您处理异常。这些将在接下来的部分中介绍,分别是:

  • 异步委托
  • 后台工作者
  • 任务并行库(适用条件)

 

线程池

每当启动线程时,都会花费数百微秒来组织诸如新鲜的私有局部变量堆栈之类的事情。每个线程(默认情况下)也消耗大约1 MB的内存。线程池通过共享和回收线程来减少这些开销,从而允许在非常细粒度的级别上应用多线程,而不会影响性能。当利用多核处理器以“分而治之”的方式并行执行计算密集型代码时,这很有用。

线程池还限制了将同时运行的工作线程总数。过多的活动线程限制了操作系统的管理负担,并使CPU缓存无效。一旦达到限制,作业将排队并仅在另一个作业完成时才开始。这使任意并发的应用程序(例如Web服务器)成为可能。 (异步方法模式是一种高级技术,通过高效利用池化线程来进一步实现这一点;我们在C#4.0的第23章“ Nutshell”中对此进行了描述)。

有多种进入线程池的方法:

  1. 通过任务并行库(来自Framework 4.0)
  2. 通过调用ThreadPool.QueueUserWorkItem
  3. 通过异步委托
  4. 通过BackgroundWorker

以下构造间接使用线程池:

  • WCF,远程,ASP.NET和ASMX Web服务应用程序服务器
  • System.Timers.Timer和System.Threading.Timer
  • 以Async结尾的框架方法,例如WebClient上的框架方法(基于事件的异步模式),以及大多数BeginXXX方法(异步编程模型模式)
  • PLINQ

任务并行库(TPL)和PLINQ具有足够的功能和高级功能,即使在线程池不重要的情况下,您也希望使用它们来协助多线程。我们将在第5部分中详细讨论这些内容。现在,我们将简要介绍如何使用Task类作为在池线程上运行委托的简单方法。

使用池线程时需要注意以下几点:

  • 您无法设置池线程的名称,从而使调试更加困难(尽管您可以在Visual Studio的“线程”窗口中进行调试时附加说明)。
  • 池线程始终是后台线程(这通常不是问题)。
  • 除非您调用ThreadPool.SetMinThreads(请参阅优化线程池),否则阻塞池中的线程可能会在应用程序的早期阶段触发额外的延迟。
  • 您可以自由更改池线程的优先级-在释放回池时,它将恢复为正常。

 

您可以通过Thread.CurrentThread.IsThreadPoolThread属性查询当前是否在池化线程上执行。

通过TPL进入线程池

您可以使用“任务并行库”中的“任务”类轻松地输入线程池。 Task类是在Framework 4.0中引入的:如果您熟悉较早的构造,请考虑将非通用Task类替换为ThreadPool.QueueUserWorkItem,而将通用Task <TResult>替换为异步委托。与旧版本相比,新版本的结构更快,更方便且更灵活。

 

要使用非泛型Task类,请调用Task.Factory.StartNew,并传入目标方法的委托:

static void Main()    // The Task class is in System.Threading.Tasks
{
  Task.Factory.StartNew (Go);
}
 
static void Go()
{
  Console.WriteLine ("Hello from the thread pool!");
}

  

Task.Factory.StartNew返回一个Task对象,您可以使用该对象来监视任务-例如,您可以通过调用其Wait方法来等待它完成。

 

调用任务的Wait方法时,所有未处理的异常都可以方便地重新抛出到主机线程中。 (如果您不调用Wait而是放弃任务,则未处理的异常将像普通线程一样关闭进程。)

 

通用Task <TResult>类是非通用Task的子类。它使您可以在完成执行后从任务中获取返回值。在下面的示例中,我们使用Task <TResult>下载网页:

static void Main()
{
  // Start the task executing:
  Task<string> task = Task.Factory.StartNew<string>
    ( () => DownloadString ("http://www.linqpad.net") );
 
  // We can do other work here and it will execute in parallel:
  RunSomeOtherMethod();
 
  // When we need the task's return value, we query its Result property:
  // If it's still executing, the current thread will now block (wait)
  // until the task finishes:
  string result = task.Result;
}
 
static string DownloadString (string uri)
{
  using (var wc = new System.Net.WebClient())
    return wc.DownloadString (uri);
}

 

(突出显示<string>类型的参数是为了清楚:如果我们省略它,则可以推断出它。)

查询包含在AggregateException中的任务的Result属性时,所有未处理的异常都会自动重新抛出。但是,如果您无法查询其Result属性(并且不调用Wait),则任何未处理的异常都会使该过程失败。

任务并行库具有更多功能,特别适合利用多核处理器。我们将在第5部分中继续讨论TPL。

不通过TPL进入线程池

如果目标是.NET Framework的早期版本(4.0之前),则不能使用任务并行库。相反,您必须使用一种较旧的结构来输入线程池:ThreadPool.QueueUserWorkItem和异步委托。两者之间的区别在于异步委托使您可以从线程返回数据。异步委托也将任何异常封送回调用方。

QueueUserWorkItem

要使用QueueUserWorkItem,只需使用要在池线程上运行的委托调用此方法:

static void Main()
{
  ThreadPool.QueueUserWorkItem (Go);
  ThreadPool.QueueUserWorkItem (Go, 123);
  Console.ReadLine();
}
 
static void Go (object data)   // data will be null with the first call.
{
  Console.WriteLine ("Hello from the thread pool! " + data);
}
Hello from the thread pool!
Hello from the thread pool! 123

我们的目标方法Go必须接受单个对象参数(以满足WaitCallback委托)。就像使用ParameterizedThreadStart一样,这提供了一种将数据传递给方法的便捷方法。与Task不同,QueueUserWorkItem不会返回对象来帮助您随后管理执行。另外,您必须在目标代码中显式处理异常-未处理的异常将使程序瘫痪。

异步委托

ThreadPool.QueueUserWorkItem没有提供一种简单的机制来在线程执行完毕后从线程取回返回值。异步委托调用(简称异步委托)解决了这一问题,允许在两个方向上传递任意数量的类型化参数。此外,异步委托上未处理的异常可以方便地在原始线程(或更准确地说是调用EndInvoke的线程)上重新抛出,因此不需要显式处理。

不要将异步委托与异步方法(以Begin或End开头的方法,例如File.BeginRead / File.EndRead)混淆。异步方法在外部遵循类似的协议,但是它们存在是为了解决更难的问题,我们将在C#4.0的第23章“简而言之”中进行描述。

通过异步委托启动工作任务的方法如下:

  1. 实例化一个以您要并行运行的方法为目标的委托(通常是预定义的Func委托之一)。
  2. 在委托上调用BeginInvoke,保存其IAsyncResult返回值。     BeginInvoke立即返回给调用者。然后,您可以在池线程正在工作时执行其他活动。
  3. 当需要结果时,在委托上调用EndInvoke,传入保存的IAsyncResult对象。

在下面的示例中,我们使用异步委托调用与主线程并发执行,主线程是一种返回字符串长度的简单方法:

 

static void Main()
{
  Func<string, int> method = Work;
  IAsyncResult cookie = method.BeginInvoke ("test", null, null);
  //
  // ... here's where we can do other work in parallel...
  //
  int result = method.EndInvoke (cookie);
  Console.WriteLine ("String length is: " + result);
}
static int Work (string s) { return s.Length; }

  

EndInvoke做三件事。首先,它会等待异步委托完成执行(如果尚未执行)。其次,它接收返回值(以及任何ref或out参数)。第三,它将所有未处理的工作程序异常抛出回调用线程。

如果您使用异步委托调用的方法没有返回值,则仍然(在技术上)有义务调用EndInvoke。实际上,这是有争议的。没有EndInvoke警察对违规者进行处罚!但是,如果您选择不调用EndInvoke,则需要考虑worker方法上的异常处理,以避免无提示的失败。

您还可以在调用BeginInvoke时指定一个回调委托-一种接受IAsyncResult对象的方法,该方法在完成后会自动调用。这允许煽动线程“忘记”异步委托,但是在回调端需要一些额外的工作:

static void Main()
{
  Func<string, int> method = Work;
  method.BeginInvoke ("test", Done, method);
  // ...
  //
}
 
static int Work (string s) { return s.Length; }
 
static void Done (IAsyncResult cookie)
{
  var target = (Func<string, int>) cookie.AsyncState;
  int result = target.EndInvoke (cookie);
  Console.WriteLine ("String length is: " + result);
}
View Code

BeginInvoke的最后一个参数是填充IAsyncResult的AsyncState属性的用户状态对象。它可以包含您喜欢的任何内容;在这种情况下,我们使用它将方法委托传递给完成回调,因此我们可以在其上调用EndInvoke。

优化线程池

线程池从其池中的一个线程开始。分配任务后,池管理器会“注入”新线程以应对额外的并发工作负载(最大限制)。在足够长时间的不活动之后,如果池管理器怀疑这样做会导致更好的吞吐量,则可以“退出”线程。

您可以通过调用ThreadPool.SetMaxThreads;来设置池将创建的线程的上限。默认值为:

  • 32位环境中Framework 4.0中的1023
  • 在64位环境中的Framework 4.0中为32768
  • 框架3.5中的每个核心250个
  • Framework 2.0中每个内核25个

(这些数字可能会因硬件和操作系统而异。)之所以有很多原因,是为了确保某些线程被阻塞(在等待某种条件(例如,来自远程计算机的响应)时处于空闲状态)的进度。

您还可以通过调用ThreadPool.SetMinThreads设置下限。下限的作用是微妙的:这是一种高级优化技术,它指示池管理器在达到下限之前不要延迟线程的分配。当线程被阻塞时,提高最小线程数可提高并发性(请参见侧栏)。

默认的下限是每个处理器内核一个线程-允许全部CPU利用率的最小值。但是,在服务器环境(例如IIS下的ASP.NET)上,下限通常要高得多-多达50个或更多。

 

最小线程数如何工作?

 

实际上,将线程池的最小线程数增加到x并不会实际上强制立即创建x个线程-线程仅根据需要创建。相反,它指示池管理器在需要它们时立即最多创建x个线程。那么,问题是,为什么在需要时线程池会延迟创建线程的时间呢?

答案是防止短暂的短暂活动导致线程的完全分配,从而突然膨胀应用程序的内存空间。为了说明这一点,请考虑运行一个客户端应用程序的四核计算机,该应用程序一次可处理40个任务。如果每个任务执行10毫秒的计算,则假设工作在四个核心之间分配,整个任务将在100毫秒内结束。理想情况下,我们希望40个任务恰好在四个线程上运行:

  • 减少一点,我们就不会充分利用这四个核心。
  • 再有,我们将浪费内存和CPU时间来创建不必要的线程。

这正是线程池的工作方式。只要将线程数与内核数进行匹配,只要有效地使用了线程(在这种情况下就是这样),程序就可以在不影响性能的情况下保留较小的内存占用。

但是现在假设,每个任务而不是工作10毫秒,而是查询Internet,在本地CPU空闲时等待半秒以响应。池管理器的线程经济策略崩溃了;现在创建更多线程会更好,因此所有Internet查询都可以同时发生。

幸运的是,池管理器有一个备份计划。如果其队列保持静止状态超过半秒,它将通过创建更多线程(每半秒一个)来响应,直至达到线程池的容量。

延迟的半秒是一把两刃剑。一方面,这意味着一次短暂的短暂活动不会使程序突然消耗掉不必要的40 MB(或更多)内存。另一方面,当池中的线程阻塞时,例如查询数据库或调用WebClient.DownloadFile时,它可能不必要地延迟事情。因此,可以通过调用SetMinThreads来告诉池管理器不要延迟前x个线程的分配:

ThreadPool.SetMinThreads(5050);
View Code

(第二个值指示要分配给I / O完成端口的线程数,由APM使用,具体请参见C#4.0第23章的内容。)

默认值为每个内核一个线程。

 

 

  

posted @ 2020-03-22 04:45  十年磨一磨霎时快如箭  阅读(1045)  评论(0编辑  收藏  举报