目录
出处:http://www.albahari.com/threading/part2.aspx
介绍和概念
C#支持通过多线程并行执行代码。线程是一个独立的执行路径,能够与其他线程同时运行。
C#客户端程序(控制台,WPF或Windows窗体)在CLR和操作系统自动创建的单个线程(“主”线程)中启动,并通过创建其他线程进行多线程。这是一个简单的例子及其输出:
所有示例都假定导入了以下命名空间:
using System;
using System.Threading;
class ThreadTest
{
static void Main()
{
Thread t = new Thread (WriteY); // 开始一个新的线程
t.Start(); // 运行WriteY()
// 同时,在主线程上做一些事情
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
,直到线程结束。当传递给Thread
构造函数的委托完成执行时,线程结束。一旦结束,线程就无法重启。
CLR为每个线程分配自己的内存堆栈,以便将局部变量保持独立。在下一个示例中,我们使用局部变量定义一个方法,然后在主线程和新创建的线程上同时调用该方法:
static void Main()
{
new Thread (Go).Start(); // 在新线程上调用Go()
Go(); // 在主线程上调用Go()
}
static void Go()
{
//声明并使用局部变量 - 'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}
??????????
在每个线程的内存堆栈上创建了一个单独的cycles变量副本,因此输出可预测为十个问号。
如果线程具有对同一对象实例的公共引用,则它们共享数据。例如:
class ThreadTest
{
bool done;
static void Main()
{
ThreadTest tt = new ThreadTest(); //创建一个共同的实例
new Thread (tt.Go).Start();
tt.Go();
}
//注意Go是目前一个实例方法
void Go()
{
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
因为两个线程都调用同一个ThreadTest
实例的Go()
,所以它们共享该done
字段。这导致“Done”被打印一次而不是两次:
Done
静态字段提供了另一种在线程之间共享数据的方法 这是与done
静态字段相同的示例:
class ThreadTest
{
static bool done; //静态字段在所有线程之间共享
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
这两个例子都说明了另一个关键概念:线程安全(或更确切地说缺少线程安全!)输出实际上是不确定的:可能(尽管不太可能)“Done”可以打印两次。但是,如果我们交换Go
方法中的语句顺序,那么“完成”两次打印的几率会急剧上升:
static void Go()
{
if (!done) { Console.WriteLine ("Done"); done = true; }
}
Done
Done (usually!)
问题是,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; }
}
}
}
当两个线程同时争用一个锁(在这种情况下locker
)时,一个线程等待或阻塞,直到锁变为可用。在这种情况下,它确保一次只有一个线程可以进入代码的关键部分,并且“Done”将只打印一次。以这种方式保护的代码 - 从多线程上下文中的不确定性 - 称为线程安全的。
共享数据是多线程中复杂性和模糊错误的主要原因。虽然通常是必不可少的,但要保持尽可能简单是值得的。
线程虽然被阻止,但不会占用CPU资源。
Join和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");
}
这打印“y”1000次,然后紧接着“Thread t has ended!”。 调用Join时可以包括超时,以毫秒为单位或作为一个TimeSpan。 如果线程结束则返回true,如果超时则返回false。
Thread.Sleep 暂停当前线程指定的时间段:
Thread.Sleep (TimeSpan.FromHours (1)); // 睡1小时
Thread.Sleep (500); // 睡500毫秒
在等待一个Sleep或Join时,一个线程被阻塞,因此不消耗CPU资源。
Thread.Sleep(0)
立即放弃线程的当前时间片,自愿将CPU交给其他线程。Framework 4.0的新Thread.Yield()
方法做了同样的事情 - 除了它只放弃在同一个处理器上运行的线程。
Sleep(0)
或Yield
偶尔在生产代码中用于高级性能调整。它也是一个很好的诊断工具,可以帮助发现线程安全问题:如果Thread.Yield()
在代码中的任何位置插入程序会破坏程序,那么几乎肯定会有错误。
线程如何工作
多线程由线程调度程序内部管理,线程调度程序是CLR通常委托给操作系统的函数。线程调度程序确保为所有活动线程分配适当的执行时间,并且等待或阻止的线程(例如,在独占锁或用户输入上)不消耗CPU时间。
在单处理器计算机上,线程调度程序执行时间分片 - 在每个活动线程之间快速切换执行。在Windows下,时间片通常在几十毫秒的区域内 - 远大于实际切换一个线程与另一个线程(通常在几微秒区域内)之间的上下文中的CPU开销。
在多处理器计算机上,多线程是通过时间切片和真正并发的混合实现的,其中不同的线程在不同的CPU上同时运行代码。几乎可以肯定,操作系统总会有一些时间切片,因为操作系统需要服务自己的线程 - 以及其他应用程序的线程。
当一个线程 由于诸如时间分片之类的外部因素而被执行中断时,该线程被认为是被抢占的。在大多数情况下,线程无法控制它被抢占的时间和地点。
线程与进程
线程类似于运行应用程序的操作系统进程。正如进程在计算机上并行运行一样,线程在单个进程中并行运行。流程彼此完全隔离; 线程只有有限的隔离程度。特别是,线程与在同一应用程序中运行的其他线程共享(堆)内存。这部分是为什么线程有用的原因:例如,一个线程可以在后台获取数据,而另一个线程可以在数据到达时显示数据。
线程的使用和误用
多线程有很多用途; 这里是最常见的:
维护响应式用户界面
通过在并行“工作”线程上运行耗时的任务,主UI线程可以自由地继续处理键盘和鼠标事件。
有效利用原本阻塞的CPU
当线程正在等待来自另一台计算机或硬件的响应时,多线程非常有用。虽然在执行任务时阻塞了一个线程,但其他线程可以利用其他无负载的计算机。
并行编程
如果工作负载在“分而治之”策略中在多个线程之间共享,则执行密集计算的代码可以在多核或多处理器计算机上更快地执行
投机执行
在多核计算机上,您有时可以通过预测可能需要完成的事情来提高性能,然后提前完成。LINQPad使用此技术来加速新查询的创建。一种变化是并行运行许多不同的算法,所有算法都能解决相同的任务。无论哪一个首先完成“胜利” - 当你无法提前知道哪种算法执行得最快时,这是有效的。
允许同时处理请求
在服务器上,客户端请求可以同时到达,因此需要并行处理(如果使用ASP.NET,WCF,Web服务或远程处理,.NET Framework会自动为此创建线程)。这在客户端上也很有用(例如,处理对等网络 - 甚至来自用户的多个请求)。
使用ASP.NET和WCF等技术,您可能不会意识到多线程甚至正在发生 - 除非您在没有适当锁定的情况下访问共享数据(可能通过静态字段),则会遇到线程安全问题。
线程也附带有字符串。最大的问题是多线程可能会增加复杂性。拥有大量线程并不会产生很多复杂性,复杂性通常来自线程之间的交互(通常通过共享数据)。这可能导致长的开发周期以及对间歇性和不可再现的错误的持续敏感性,适用于相互作用是否是有意的。出于这个原因,将交互保持在最低限度,并尽可能坚持简单且经过验证的设计是值得的。本文主要侧重于处理这些复杂性; 删除交互,并没有多少说!
一个好的策略是将多线程逻辑封装到可以独立检查和测试的可重用类中。框架本身提供了许多更高级别的线程构造,我们将在后面介绍。
线程在调度和切换线程时也会产生资源和CPU成本(当存在比CPU内核更多的活动线程时) - 并且还存在创建/拆除成本。多线程并不总能加速你的应用程序 - 如果使用过度或不恰当,它甚至可以减慢它的速度。例如,当涉及大量磁盘I / O时,让一些工作线程按顺序运行任务比一次执行10个线程更快。(在使用Wait和Pulse的信号中,我们描述了如何实现生产者/消费者队列,它提供了这个功能。)
创建和启动线程
正如我们在介绍中看到的那样,线程是使用Thread
类的构造函数创建的,传入一个ThreadStart
委托,指示执行应该从哪里开始。以下是ThreadStart
委托的定义方式:
public delegate void ThreadStart();
在该线程上调用Start
然后将其设置为运行。线程继续,直到其方法返回,此时线程结束。这是一个使用扩展的C#语法创建TheadStart
委托的示例,:
class ThreadTest
{
static void Main()
{
Thread t = new Thread (new ThreadStart (Go));
t.Start(); // Run Go() on the new thread.
Go(); // Simultaneously run Go() in the main thread.
}
static void Go()
{
Console.WriteLine ("hello!");
}
}
在这个例子中,线程t
执行Go()
- 在主线程调用Go()
的同时。结果是两个近乎即时的hellos。
通过仅指定方法组可以更方便地创建线程 - 并允许C#推断ThreadStart
委托:
Thread t = new Thread (Go); // 无需显式使用ThreadStart
另一个捷径是使用lambda表达式或匿名方法:
static void Main()
{
Thread t = new Thread ( () => Console.WriteLine ("Hello!") );
t.Start();
}
将数据传递给线程
将参数传递给线程的目标方法的最简单方法是执行一个lambda表达式,该表达式使用所需的参数调用该方法:
static void Main()
{
Thread t = new Thread ( () => Print ("Hello from t!") );
t.Start();
}
static void Print (string message)
{
Console.WriteLine (message);
}
使用此方法,您可以向该方法传递任意数量的参数。您甚至可以将整个实现包装在多语句lambda中:
new Thread (() =>
{
Console.WriteLine ("I'm running on another thread!");
Console.WriteLine ("This is so easy!");
}).Start();
您可以使用匿名方法在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; // 我们需要在这里转换类型
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#捕获变量的规则(在for
和foreach
循环的情况下有些不受欢迎)。
解决方案是使用临时变量,如下所示:
for (int i = 0; i < 10; i++)
{
int temp = i;
new Thread (() => Console.Write (temp)).Start();
}
变量temp
现在是每个循环迭代的本地变量。因此,每个线程捕获不同的内存位置,没有问题。我们可以通过以下示例更简单地说明早期代码中的问题:
string text = "t1";
Thread t1 = new Thread ( () => Console.WriteLine (text) );
text = "t2";
Thread t2 = new Thread ( () => Console.WriteLine (text) );
t1.Start();
t2.Start();
因为两个lambda表达式都捕获相同的text
变量,t2
所以打印两次:
t2
t2
命名线程
每个线程都有一个Name
属性,您可以设置该属性以进行调试。这在Visual Studio中特别有用,因为线程的名称显示在“线程窗口”和“调试位置”工具栏中。你只可以设置一次线程的名称, 稍后尝试更改它会引发异常。
static 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和操作系统版本而变化。
前景线程不需要这种处理,但您必须注意避免可能导致线程不能结束的错误。应用程序无法正常退出的常见原因是存在活动的前台线程。
线程优先级
线程的Priority
属性确定相对于操作系统中的其他活动线程获得的执行时间,具体如下:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
仅当多个线程同时处于活动状态时,这才变得相关
在提升线程的优先级之前要仔细考虑 - 它可能导致其他线程的资源不足等问题。
提升线程的优先级并不能使其能够执行实时工作,因为它仍然受到应用程序进程优先级的限制。要执行实时工作,您还必须使用Process
类提升进程优先级System.Diagnostics
(我们没有告诉您如何执行此操作):
using (Process p = Process.GetCurrentProcess())
p.PriorityClass = ProcessPriorityClass.High;
ProcessPriorityClass.High
实际上是最优先的一个缺口:Realtime
。设置进程优先级以Realtime
指示您从不希望进程将CPU时间用于另一个进程的操作系统。如果您的程序进入偶然的无限循环,您甚至可能会发现操作系统被锁定,只剩下电源按钮来拯救您!因此,High
通常是实时应用程序的最佳选择。
如果您的实时应用程序具有用户界面,则提升进程优先级会使屏幕更新过多的CPU时间,从而减慢整个计算机的速度(特别是在UI很复杂的情况下)。降低主线程的优先级同时提高进程的优先级可确保实时线程不会被屏幕重绘抢占,但无法解决其他应用程序占用CPU时间的问题,因为操作系统仍会分配整个过程不成比例的资源。理想的解决方案是让实时工作者和用户界面作为具有不同进程优先级的独立应用程序运行,通过远程处理或内存映射文件进行通信。内存映射文件非常适合此任务。
即使具有提升的流程优先级,托管环境在处理硬实时要求方面的适用性也是有限的。除了自动垃圾收集引入的延迟问题之外,操作系统还可能带来额外的挑战 - 即使对于非托管应用程序 - 也可以通过专用硬件或专用实时平台解决。
异常处理
创建线程时范围内的任何try
/ catch
/ finally
块在开始执行时与线程无关。考虑以下程序:
public static void Main()
{
try
{
new Thread (Go).Start();
}
catch (Exception ex)
{
// 我们永远不会到这儿!
Console.WriteLine ("Exception!");
}
}
static void Go() { throw null; } // 抛出NullReferenceException
此示例中的try
/ catch
语句无效,新创建的线程将受到未处理的阻碍NullReferenceException
。当您考虑每个线程都有一个独立的执行路径时,这种行为是有意义的。
解决方法是将异常处理程序移动到Go
方法中:
public static void Main()
{
new Thread (Go).Start();
}
static void Go()
{
try
{
// ...
throw null; // NullReferenceException将被捕获到
// ...
}
catch (Exception ex)
{
// 通常记录异常,和/或发出另一个线程的信号
// 我们已经解决
// ...
}
}
您需要在生产应用程序中的所有线程入口方法上使用异常处理程序 - 就像在主线程上执行(通常在更高级别,在执行堆栈中)一样。未处理的异常会导致整个应用程序关闭,带着烦人的对话!
在编写此类异常处理块时,您很少会忽略 该错误:通常,您将记录异常的详细信息,然后可能会显示一个对话框,允许用户自动将这些详细信息提交到Web服务器。然后,您可能会关闭应用程序 - 因为错误可能会破坏程序的状态。但是,这样做的代价是用户将丢失他最近的工作 - 例如打开文档。
WPF和Windows窗体应用程序(Application.DispatcherUnhandledException
和Application.ThreadException
)的“全局”异常处理事件仅针对主UI线程上引发的异常触发。您仍然必须手动处理工作线程上的异常。
AppDomain.CurrentDomain.UnhandledException
触发任何未处理的异常,但不提供阻止应用程序关闭的方法。
但是,在某些情况下,您不需要在工作线程上处理异常,因为.NET Framework会为您执行此操作。这些内容将在后面的章节中介绍,它们是:
- 异步代理
BackgroundWorker
- 该任务并行库(条件)
线程池
每当你启动一个线程时,花费几百微秒来组织一些新的私有局部变量栈。每个线程也消耗(默认情况下)大约1 MB的内存。该线程池削减这些费用通过共享和回收线程,允许在没有性能损失非常细致地被应用多线程。当利用多核处理器以“分而治之”的方式并行执行计算密集型代码时,这非常有用。
线程池还会限制它将同时运行的工作线程总数。过多的活动线程会对管理负担限制操作系统,并使CPU缓存无效。达到限制后,作业将排队并仅在另一个完成时启动。这使得任意并发应用程序成为可能,例如Web服务器。(异步方法模式是一种高级技术,它通过高效地使用池化线程来进一步实现这一点)。
有许多方法可以进入线程池:
- 通过任务并行库(来自Framework 4.0)
- 通过ThreadPool.QueueUserWorkItem
- 通过异步委托
- 通过BackgroundWorker
以下构造间接使用线程池:
- WCF,远程处理,ASP.NET和ASMX Web服务应用程序服务器
- System.Timers.Timer
- 以Async结尾的框架方法,例如
WebClient
(基于事件的异步模式)和大多数Begin
XXX方法(异步编程模型模式) - PLINQ
该任务并行库 (TPL)和PLINQ足够强大,你会想用它们来帮助多线程即使线程池是不重要的高水平。现在,我们将简要介绍如何使用Task该类作为在池化线程上运行委托的简单方法。
使用池化线程时需要注意一些事项:
- 您无法设置
Name
池化线程,使调试更加困难(尽管您可以在Visual Studio的“线程”窗口中进行调试时附加说明)。 - 池化线程始终是后台线程(这通常不是问题)。
- 阻止池化线程可能会在应用程序的早期生命周期中触发额外的延迟,除非您调用
ThreadPool.SetMinThreads
。
您可以自由更改 池化线程的优先级 - 在释放回池时它将恢复正常。
您可以通过属性查询当前是否正在池化线程上执行Thread.CurrentThread.IsThreadPoolThread
。
通过TPL进入线程池
您可以使用Task任务并行库中的类轻松地进入线程池。这些Task
类是在Framework 4.0中引入的:如果您熟悉旧的构造,请将非泛型Task
类视为替代ThreadPool.QueueUserWorkItem,并将泛型Task<TResult>
替换为异步委托。较新的构造比旧的更快,更方便,更灵活。
要使用非泛型Task
类,请调用Task.Factory.StartNew
,传入目标方法的委托:
static void Main() // Task类位于System.Threading.Tasks中
{
Task.Factory.StartNew (Go);
}
static void Go()
{
Console.WriteLine ("Hello from the thread pool!");
}
Task.Factory.StartNew
返回一个 Task
对象,然后可以使用它来监视任务 - 例如,您可以通过调用其Wait方法等待它完成。
当您调用任务时,任何未处理的异常都可以方便地重新抛出到主机线程上Wait method。(如果您不调用Wait
而是放弃该任务,则未处理的异常将像普通线程一样关闭该进程。)
泛型Task<TResult>
类是非泛型Task
的子类。它允许您在完成任务后从任务中获取返回值。在以下示例中,我们使用以下命令下载网页Task<TResult>
:
static void Main()
{
// 开始执行任务:
Task<string> task = Task.Factory.StartNew<string>
( () => DownloadString ("http://www.linqpad.net") );
// 我们可以在这里做其他工作,它将并行执行:
RunSomeOtherMethod();
// 当我们需要任务的返回值时,我们查询其Result属性:
// 如果它仍在执行,当前线程现在将阻塞(等待)
// 直到任务完成:
string result = task.Result;
}
static string DownloadString (string uri)
{
using (var wc = new System.Net.WebClient())
return wc.DownloadString (uri);
}
(<string>
是为了清楚突出显示参数的类型)
当您查询任务的Result
属性时,会自动重新抛出任何未处理的异常AggregateException。但是,如果您无法查询其Result
属性(并且不调用Wait
),则任何未处理的异常都会将该进程关闭。
任务并行库具有更多功能,特别适合利用多核处理器。
在没有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) // 第一次调用时,数据将为null
{
Console.WriteLine ("Hello from the thread pool! " + data);
}
Hello from the thread pool!
Hello from the thread pool! 123
我们的目标方法Go
必须接受一个object
参数(以满足WaitCallback
委托)。这提供了一种将数据传递给方法的便捷方式,就像使用ParameterizedThreadStart
。与之不同的是Task
,QueueUserWorkItem
不会返回一个对象来帮助您随后管理执行。此外,您必须明确处理目标代码中的异常 - 未处理的异常将删除该程序。
异步委托
ThreadPool.QueueUserWorkItem
没有提供一种简单的机制,可以在完成执行后从线程返回返回值。异步委托调用(简称异步委托)解决了这个问题,允许在两个方向上传递任意数量的类型参数。此外,异步委托上的未处理异常可以方便地在原始线程(或更准确地说,调用的线程EndInvoke
)上重新抛出,因此它们不需要显式处理。
不要将异步委托与异步方法混淆(以Begin或End开头的方法,例如File.BeginRead
/ File.EndRead
)。异步方法在外观上遵循类似的协议,但它们的存在是为了解决一个更难的问题。
以下是通过异步委托启动worker任务的方法:
- 实例化一个以您希望并行运行的方法为目标的委托(通常是一个预定义的
Func
委托)。 - 调用
BeginInvoke
委托,保存其IAsyncResult
返回值。BeginInvoke
立即返回给调用者。然后,您可以在池化线程工作时执行其他活动。 - 当您需要结果时,请调用
EndInvoke
委托,传入已保存的IAsyncResult
对象。
在下面的示例中,我们使用异步委托调用与主线程并发执行,这是一个返回字符串长度的简单方法:
static void Main()
{
Func<string, int> method = Work;
IAsyncResult cookie = method.BeginInvoke ("test", null, null);
//
// ... 这是我们可以并行完成其他工作的地方......
//
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);
}
最后一个参数BeginInvoke
是填充AsyncState
属性的用户状态对象IAsyncResult
。它可以包含你喜欢的任何东西; 在这种情况下,我们使用它将method
委托传递给完成回调,因此我们可以调用EndInvoke
它。
优化线程池
线程池从其池中的一个线程开始。在分配任务时,池管理器“注入”新线程以应对额外的并发工作负载,达到最大限制。经过足够的一段时间不活动后,池管理器可能会“退出”线程,如果它怀疑这样做会导致更好的吞吐量。
您可以通过调用设置池将创建的线程的上限ThreadPool.SetMaxThreads
; 默认值是:
- 在32位环境中的Framework 4.0中的1023
- 在64位环境中的Framework 4.0中的32768
- Framework 3.5中的每个核心250个
- Framework 2.0中每个核心25个
(这些数字可能因硬件和操作系统而异。)有很多原因是为了确保某些线程被阻塞(等待某些条件时空闲,例如来自远程计算机的响应)。
您也可以通过调用设置下限ThreadPool.SetMinThreads
。下限的作用更微妙:它是一种高级优化技术,它指示池管理器在达到下限之前不要延迟线程分配。当线程被阻塞时,提高最小线程数可以提高并发性。
默认下限是每个处理器核心一个线程 - 允许完全CPU利用率的最小值。但是在服务器环境中(例如IIS下的ASP.NET),下限通常要高得多 - 多达50或更多。
最小线程数如何工作
将线程池的最小线程数增加到x 实际上并不会立即强制创建x个线程 - 仅在需要时创建线程。相反,它指示池管理器在需要它们 的瞬间创建最多x个线程。那么,问题是为什么线程池在需要时会延迟创建线程?
答案是防止短暂的短暂活动导致完整的线程分配,突然增加应用程序的内存占用。为了说明,请考虑运行客户端应用程序的四核计算机,该应用程序一次排队40个任务。如果每个任务执行10毫秒计算,假设工作在四个核心之间分配,整个过程将在100毫秒内完成。理想情况下,我们希望40个任务在四个线程上运行:
- 更少,我们不会最大限度地利用所有四个核心。
- 更多,我们将浪费内存和CPU时间创建不必要的线程。
这正是线程池的工作原理。将线程计数与核心数匹配允许程序在不损害性能的情况下保留较小的内存占用 - 只要线程被有效使用(在这种情况下它们就是这样)。
但是现在假设每个任务不是工作10毫秒,而是查询因特网,在本地CPU空闲时等待半秒钟的响应。池管理者的线程经济策略崩溃了; 现在,创建更多线程会更好,因此所有Internet查询都可以同时进行。
幸运的是,池管理器有一个备份计划。如果其队列保持静止超过半秒,它会通过创建更多线程(每半秒一次)来响应,直到线程池的容量。
半秒钟的延误是一把双刃剑。一方面,这意味着一次性的短暂活动不会使程序突然消耗额外不必要的40 MB(或更多)内存。另一方面,当池化线程阻塞时,例如查询数据库或调用时,它可能会不必要地延迟WebClient.DownloadFile
。因此,您可以通过调用告诉池管理器不要延迟分配前x个线程,SetMinThreads
例如:
ThreadPool.SetMinThreads (50, 50);
(第二个值表示分配给I / O完成端口的线程数,这些端口由APM使用。)
默认值是每个核心一个线程。