C#多线程和异步

一、使用计时器

在某些情况下,可能不需要使用单独的线程。如果应用程序需要定期执行简单的与 UI 有关的操作,则应该考虑使用进程计时器。有时,在智能客户端应用程序中使用进程计时器,以达到下列目:

按计划定期执行操作。

在使用图形时保持一致的动画速度(而不管处理器的速度)。

监视服务器和其他的应用程序以确认它们在线并且正在运行。

.NET Framework 提供三种进程计时器:

System.Window.Forms.Timer

System.Timers.Timer

System.Threading.Timer

如果想要在 Windows 窗体应用程序中引发事件,System.Window.Forms.Timer 就非常有用。它经过了专门的优化以便与Windows 窗体一起使用,并且必须用在 Windows 窗体中。它设计成能用于单线程环境,并且可以在 UI 线程上同步操作。这就意味着该计时器从来不会抢占应用程序代码的执行(假定没有调用 Application.DoEvents),并且对与 UI 交互是安全的。

System.Timers.Timer 被设计并优化成能用于多线程环境。与 System.Window.Forms.Timer 不同,此计时器调用从 CLR 线程池中获得的辅助线程上的事件处理程序。在这种情况下,应该确保事件处理程序不与 UI 交互。System.Timers.Timer 公开了可以模拟 System.Windows.Forms.Timer 中的行为的 SynchronizingObject 属性,但是除非需要对事件的时间安排进行更精确的控制,否则还是应该改为使用 System.Windows.Forms.Timer。

System.Threading.Timer 是一个简单的轻量级服务器端计时器。它并不是内在线程安全的,并且使用起来比其他计时器更麻烦。此计时器通常不适合 Windows 窗体环境。表 1.1 列出了每个计时器的各种属性。

表 1.1 进程计时器属性

属性

System.Windows.Forms

System.Timers

System.Threading

计时器事件运行在什么线程中?

UI 线程

UI 线程或辅助线程

辅助线程

实例是线程安全的吗?

需要 Windows 窗体吗?

最初的计时器事件可以调度吗?

 二、使用Thread进行异步操作

使用Thread进行异步操作的方法如下所示,需要注意的就是IsBackground默认为false,也就是该线程对调用它的线程不产生依赖,当调用线程退出时该线程也不会结束。因此需要将IsBackground设置为true以指明该线程是后台线程,这样当主线程退出时该线程也会结束。另外跨线程操作UI还是要借助Dispatcher.BeginInvoke(),如果需要阻塞UI线程可以使用Dispatcher.Invoke()。

使用 Thread 类可以显式管理线程。这包括 CLR 创建的线程和进入托管环境执行代码的 CLR 以外创建的线程。CLR 监视其进程中曾经在 .NET Framework 内执行代码的所有线程,并且使用 Thread 类的实例来管理它们。

只要有可能,就应该使用 ThreadPool 类来创建线程。然而,在一些情况下,您还是需要创建并管理您自己的线程,而不是使用 ThreadPool 类。

在下面的情况下,使用 Thread 对象:

需要具有特定优先级的任务。

有可能运行很长时间的任务(这样可能阻塞其他任务)。

需要确保只有一个线程可以访问特定的程序集。

需要有与线程相关的稳定标识。

Thread 对象包括许多属性和方法,它们可以帮助控制线程。可以设置线程的优先级,查询当前的线程状态,中止线程,临时阻塞线程,并且执行许多其他的线程管理任务。

下面的代码示例演示了如何使用 Thread 对象创建并启动一个线程。

static void Main()

{System.Threading.Thread workerThread =new System.Threading.Thread( SomeDelegate);

workerThread.Start();

}

public static void SomeDelegate () {Console.WriteLine( "Do some work." ); }

在这个示例中,SomeDelegate 是一个 ThreadStart 委托 — 指向将要在新线程中执行的代码的引用。Thread.Start 向操作系统提交请求以启动线程。

如果采用这种方式实例化一个新线程,就不可能向 ThreadStart 委托传递任何参数。如果需要将一个参数传递给要在另一个线程中执行的方法,应该用所需的方法签名创建一个自定义委托并异步调用它。

如果需要从单独的线程中获得更新或结果,可以使用回调方法 — 一个委托,引用在线程完成工作之后将要调用的代码 — 这就使得线程可以与 UI 交互。有关详细信息,请参阅本章后面的“使用任务处理 UI 线程和其他线程之间的交互”部分。

 C#多线程和异步

三、使用ThreadPool进行异步操作

ThreadPool(线程池)的出现主要就是为了提高线程的复用(类似的还有访问数据库的连接池)。线程的创建是开销比较大的行为,为了达到较好的交互体验,开发中可能会大量使用异步操作,特别是需要频繁进行大量的短时间的异步操作时,频繁创建和销毁线程会在造成很多资源的浪费。而通过在线程池中存放一些线程,当需要新建线程执行操作时就从线程池中取出一个已经存在的空闲线程使用,如果此时没有空闲线程,且线程池中的线程数未达到线程池上限,则新建一个线程,使用完成后再放回到线程池中。这样可以极大程度上省去线程创建的开销。线程池中线程的最小和最大数都可以指定,不过多数情况下无需指定,CLR有一套管理线程池的策略。ThreadPool的使用非常简单,代码如下所示。跨线程操作UI仍需借助Dispatcher。

到现在为止,您可能会认识到许多应用程序都会从多线程处理中受益。然而,线程管理并不仅仅是每次想要执行一个不同的任务就创建一个新线程的问题。有太多的线程可能会使得应用程序耗费一些不必要的系统资源,特别是,如果有大量短期运行的操作,而所有这些操作都运行在单独线程上。另外,显式地管理大量的线程可能是非常复杂的。线程池化技术通过给应用程序提供由系统管理的辅助线程池解决了这些问题,从而使得您可以将注意力集中在应用程序任务上而不是线程管理上。

在需要时,可以由应用程序将线程添加到线程池中。当 CLR 最初启动时,线程池没有包含额外的线程。然而,当应用程序请求线程时,它们就会被动态创建并存储在该池中。如果线程在一段时间内没有使用,这些线程就可能会被处置,因此线程池是根据应用程序的要求缩小或扩大的。注每个进程都创建一个线程池,因此,如果您在同一个进程内运行几个应用程序域,则一个应用程序域中的错误可能会影响相同进程内的其他应用程序域,因为它们都使用相同的线程池。

线程池由两种类型的线程组成:

辅助线程。辅助线程是标准系统池的一部分。它们是由 .NET Framework 管理的标准线程,大多数功能都在它们上面执行。

完成端口线程.这种线程用于异步 I/O 操作(通过使用 IOCompletionPorts API)。

注,如果应用程序尝试在没有 IOCompletionPorts 功能的计算机上执行 I/O 操作,它就会还原到使用辅助线程。

对于每个计算机处理器,线程池都默认包含 25 个线程。如果所有的 25 个线程都在被使用,则附加的请求将排入队列,直到有一个线程变得可用为止。每个线程都使用默认堆栈大小,并按默认的优先级运行。

下面代码示例说明了线程池的使用。

private void ThreadPoolExample(){

WaitCallback callback = new WaitCallback(ThreadProc );

ThreadPool.QueueUserWorkItem( callback );}

在前面的代码中,首先创建一个委托来引用您想要在辅助线程中执行的代码。.NET Framework 定义了 WaitCallback 委托,该委托引用的方法接受一个对象参数并且没有返回值。下面的方法实现您想要执行的代码。

private void ThreadProc( Object stateInfo ){

// Do something on worker thread.}

可以将单个对象参数传递给 ThreadProc 方法,方法是将其指定为 QueueUserWorkItem 方法调用中的第二个参数。在前面的示例中,没有给 ThreadProc 方法传递参数,因此 stateInfo 参数为空。

在下面的情况下,使用 ThreadPool 类:

有大量小的独立任务要在后台执行。

不需要对用来执行任务的线程进行精细控制。

 C#多线程和异步

四、使用委托

委托是指向方法的引用(或指针)。在定义委托时,可以指定确切的方法签名,如果其他的方法想要代表该委托,就必须与该签名相匹配。所有委托都可以同步和异步调用。

下面的代码示例展示了如何声明委托。这个示例展示了如何将一个长期运行的计算实现为一个类中的方法。

delegate string LongCalculationDelegate(int count );

如果 .NET Framework 遇到像上面一样的委托声明,就隐式声明了一个从 MultiCastDelegate 类继承的隐藏类,正如下面的代码示例中所示。

Class LongCalculationDelegate :MutlicastDelegate{

public string Invoke( count );

public void BeginInvoke( int count,AsyncCallback callback,

object asyncState );

public string EndInvoke( IAsyncResultresult );}

委托类型 LongCalculationDelegate 用于引用接受单个整型参数并返回一个字符串的方法。下面的代码示例举例说明了一个这种类型的委托,它引用带有相关签名的特定方法。

LongCalculationDelegate longCalcDelegate =new LongCalculationDelegate(calculationMethod );

在本示例中,calculationMethod 是实现您想要在单独线程上执行的计算的方法的名称。

可以同步或异步调用委托实例所引用的方法。为了同步调用它,可以使用下面的代码。

string result = longCalcDelegate( 10000 );

该代码在内部使用上面的委托类型中定义的 Invoke 方法。因为 Invoke 方法是同步调用,所以此方法只在调用方法返回之后才返回。返回值是调用方法的结果。

更常见的情况是,为了防止调用线程阻塞,您将选择通过使用 BeginInvoke 和 EndInvoke 方法来异步调用委托。异步委托使用 .NET Framework 中的线程池化功能来进行线程管理。.NET Framework 实现的标准异步调用 模式提供 BeginInvoke 方法来启动线程上所需的操作,并且它提供 EndInvoke 方法来允许完成异步操作以及将任何得到的数据传送回调用线程。在后台处理完成之后,可以调用回调方法,其中,可以调用 EndInvoke 来获取异步操作的结果。

当调用 BeginInvoke 方法时,它不会等待调用完成;相反,它会立即返回一个 IAsyncResult 对象,该对象可以用来监视该调用的进度。可以使用 IAsyncResult 对象的 WaitHandle 成员来等待异步调用完成,或使用 IsComplete 成员轮询是否完成。如果在调用完成之前调用 EndInvoke 方法,它就会阻塞,并且只在调用完成之后才返回。然而,您应该慎重,不要使用这些技术来等待调用完成,因为它们可能阻塞 UI 线程。一般来说,回调机制是通知调用已经完成的最好方式。

异步执行委托引用的方法

1.

定义代表长期运行的异步操作的委托,如下面的示例所示:

delegate string LongCalculationDelegate( int count );

           

2.

定义一个与委托签名相匹配的方法。下面的示例中的方法模拟需要消耗较多时间的操作,方法是使线程返回之前睡眠 count 毫秒。

private string LongCalculation( int count )

            {

            Thread.Sleep( count );

            return count.ToString();

            }

           

3.

定义与 .NET Framework 定义的 AsyncCallback 委托相对应的回调方法,如下面的示例所示。

private void CallbackMethod( IAsyncResult ar )

            {

            // Retrieve the invoking delegate.

            LongCalculationDelegate dlgt = (LongCalculationDelegate)ar.AsyncState;

            // Call EndInvoke to retrieve the results.

            string results = dlgt.EndInvoke(ar);

            }

           

4.

创建一个委托实例,它引用您想要异步调用的方法,并且创建一个 AsyncCallback 委托来引用回调方法,如下面的代码示例所示。

    LongCalculationDelegate longCalcDelegate =

            new LongCalculationDelegate( calculationMethod );

            AsyncCallback callback = new AsyncCallback( CallbackMethod );

           

5.

从调用线程中开始异步调用,方法是调用引用您想要异步执行的代码的委托中的 BeginInvoke 方法。

    longCalcDelegate.BeginInvoke( count, callback, longCalcDelegate );

           

方法 LongCalculation 是在辅助线程上调用的。当它完成时,就调用 CallbackMethod 方法,并且获取计算的结果。

注回调方法是在非 UI 线程上执行的。要修改 UI,需要使用某些技术来从该线程切换到 UI 线程。有关详细信息,请参阅本章后面的“使用任务处理 UI 线程和其他线程之间的交互”部分。

可以使用自定义委托来将任意参数传送给要在单独的线程上执行的方法(有时当您直接使用 Thread 对象或线程池创建线程时,您无法这样做)。

当需要在应用程序 UI 中调用长期运行的操作时,异步调用委托非常有用。如果用户在 UI 中执行预期要花很长时间才能完成的操作,您肯定并不希望该 UI 冻结,也不希望它不能刷新自己。使用异步委托,可以将控制权返回给主 UI 线程以执行其他操作。

在以下情况中,您应该使用委托来异步调用方法:

需要将任意参数传递给您想要异步执行的方法。

您想要使用 .NET Framework 提供的异步调用 模式。


五、使用Task进行异步操作

Task进行异步操作时也是从线程池中获取线程进行操作,不过支持的操作更加丰富一些。而且Task<T>可以支持返回值,通过Task的ContinueWith()可以在Task执行结束后将返回值传入以进行操作,但在ContinueWith中跨线程操作UI仍需借助Dispatcher。另外Task也可以直接使用静态方法Task.Run<T>()执行异步操作。

使用Task处理 UI 线程和其他线程之间的交互:设计多线程应用程序最复杂的一个方面是处理 UI 线程和其他线程之间的关系。用于应用程序的后台线程并不直接与应用程序 UI 交互,这一点相当关键。如果后台线程试图修改应用程序的 UI 中的控件,该控件就可能会处于一种未知的状态。这可能会在应用程序中引起较大的问题,并难于诊断。例如,当另一个线程正在给动态生成的位图传送新数据时,它或许不能显示。或者,当数据集正在刷新时,绑定到数据集的组件可能会显示冲突信息。

为了避免这些问题,应该从不允许 UI 线程以外的线程更改 UI 控件或绑定到 UI 的数据对象。您应该始终尽力维护 UI 代码和后台处理代码之间的严格分离。将 UI 线程与其他线程分离是一个良好的做法,但是您仍然需要在这些线程之间来回传递信息。多线程应用程序通常需要具有下列功能:

从后台线程获得结果并更新 UI。

当后台线程执行它的处理时向 UI 报告进度。

从 UI 控制后台线程,例如让用户取消后台处理。

从处理后台线程的代码中分离 UI 代码的有效方法是,根据任务构造应用程序,并且使用封装所有任务细节的对象代表每个任务。任务是用户期望能够在应用程序内完成的一个工作单元。在多线程处理的环境中,Task 对象封装了所有的线程细节,这样它们就可以从 UI 中清晰地分离出来。通过使用 Task 模式,在使用多线程时可以简化代码。Task 模式将线程管理代码从 UI 代码中清晰地分离出来。UI 使用 Task 对象提供的属性和方法来执行行动,比如启动和停止任务、以及查询它们的状态。Task 对象也可以提供许多事件,从而允许将状态信息传送回 UI。这些事件都应该在 UI 线程内激发,这样,UI 就不需要了解后台线程。

使用 Task 对象可以充分简化线程交互,Task 对象虽然负责控制和管理后台线程,但是激发 UI 可以使用并且保证在 UI 线程上的事件。Task 对象可以在应用程序的各个部分中重用,甚至也可以在其他的应用程序中重用。

图 1.2 说明了使用 Task 模式时代码的整体结构。

C#多线程和异步

图 1.2 使用 Task 模式时的代码结构

注Task 模式可以用来在单独的线程上执行本地后台处理任务,或者与网络上的远程服务异步交互。在后者的情况下,Task 对象常常称为服务代理。服务代理可以使用与 Task 对象相同的模式,并且可以支持使其与 UI 交互更容易的属性和事件。

因为 Task 对象封装了任务的状态,所以可以用它来更新 UI。要这样做,无论何时发生更改,都可以让 Task 对象针对主 UI 线程激发 PropertyChanged 事件。这些事件提供一种标准而一致的方法来传递属性值更改。

可以使用任务来通知主 UI 线程进度或其他状态改变。例如,当任务变得可用时,可以将其设置为已启用的标志,该标志可用于启用相应的菜单项和工具栏按钮。相反,当任务变得不可用(例如,因为它还在进行中),可以将已启用标志设置为 false,这会导致主 UI 线程中的事件处理程序禁用适当的菜单项和工具栏按钮。

也可以使用任务来更新绑定到 UI 的数据对象。应该确保数据绑定到 UI 控件的任何数据对象在 UI 线程上更新。例如,如果将 DataSet 对象绑定到 UI 并从 Web 服务检索更新信息,就可以将新数据传递给 UI 代码。然后,UI 代码将新数据合并到 UI 线程上绑定的 DataSet 对象中。

可以使用 Task 对象实现后台处理和线程控制逻辑。因为 Task 对象封装了必要的状态和数据,所以它可以协调在一个或更多线程中完成任务所需的工作,并且在需要时传递更改和通知到应用程序的 UI。可以实现所有必需的锁定和同步并将其封装在 Task 对象中,这样 UI 线程就不必处理这些问题。

 C#多线程和异步

六、使用async/await进行异步操作

这个是C#5中的新特性,当遇到await时,会从线程池中取出一个线程异步执行await等待的操作,然后方法立即返回。等异步操作结束后回到await所在的地方接着往后执行。await需要等待async Task<T>类型的函数。详细的使用方法可参考相关资料,测试代码如下所示。异步结束后的会返回到调用线程,所以修改UI不需要Dispatcher。

await的是一个Task,Task是一个future模式的实现,然后会交给当前的TaskScheduler调度,比如 TheadPool;

await Task.Run(new Action(LongTask));

实现细节:该语法糖实现最基础关键的3部分:1、语言框架自动开、管理一个任务池及一个线程池(比如放N条线程)2、凡是任何时候遇到 await 关键字(表示程序员想在此处等待),便将该函数内,await关键字后面的所有代码打包成一个子函数X。(该步骤解决的问题:自动解决自己写回调函数的工作,由编译器自动生成该回调函数X。不难理解,你手动写的回调函数里的代码无非也就是await关键字后的代码,比如“将处理结果写入数据库之类的”)3、关键来了。当await那一行调用时,语言框架立马让本调用线程返回。并且在框架的任务池里添加 await 关键字这一行作为一个异步任务,并同时帮你管理好响应操作系统IO完成事件的工作。一旦IO完成,立马从线程池里拿出一条空闲线程,执行2中的子函数X。(该步骤解决的问题:自动帮你做系统IO事件监听、自动管理最小数量线程的线程池、自动调用你await关键字后面的处理代码X);

 C#多线程和异步

也可以把TestTask包装成async方法,这样就可以使用上图中注释掉的两行代码进行处理。包装后的异步方法如下所示:

 C#多线程和异步

async/await也是从线程池中取线程,可实现线程复用,而且代码简洁容易阅读,异步操作返回后会自动返回调用线程,是执行异步操作的首选方式。而且虽然是C#5的新特性,但C#4可以通过下载升级包来支持async/await。

关于效率

以上尝试的方法除了直接使用Thread之外,其他几种都是直接或间接使用线程池来获取线程的。从理论上来分析,创建线程时要给线程分配栈空间,线程销毁时需要回收内存,创建线程也会增加CPU的工作。因此可以连续创建线程并记录消耗的时间来测试性能。测试代码如下所示:

 C#多线程和异步

当测试Thread时每次测试在连续创建线程时内存和CPU都会有个小突起,不过在线程结束后很快就会降下去,在我的电脑上连续创建100个线程大概花费120-130毫秒。如图所示:

 C#多线程和异步

测试结果:

 C#多线程和异步

使用基于线程池的方法创建线程时,有时第一次会稍慢一些,应该是线程池内线程不足,时间开销在0-15毫秒,第一次创建内存也会上升。后面再测试时时间开销为0毫秒,内存表现也很平稳,CPU开销分布比较平均。测试结果如图所示:

 C#多线程和异步

结论

在执行异步操作时应使用基于线程池的操作,从代码的简洁程度和可读性上优先使用async/await方式。对于较老的.NET版本可以使用Task或ThreadPool。符合以下情况的可以使用Thread:

1、线程创建后需要持续工作到主线程退出的。这种情况下就算使用线程池线程也不会归还,实现不了复用,可以使用Thread。

2、线程在主线程退出后仍需要执行的,这种情况使用线程池线程无法满足需求,需要使用Thread并制定IsBackground为false(默认)。