跨线程委托执行
前面写过两篇文章Async和Await异步编程的原理和.NET中STAThread和MTAThread。一个关于.NET异步编程,一个关于COM公寓模型,在这两篇文章中都涉及到了一个线程让另一个线程执行指定代码的问题,而这个问题其实是.NET异步编程和COM运行中非常核心的问题,因此有必要进一步讨论。对于一个线程让另一个线程执行指定代码这个过程是否有标准的名称或叫法,我确实不知道(请大家多多指教),为了方便,我将其称为跨线程委托执行。
1. 什么是跨线程委托执行
为了给出一个更一般的定义,我们假设有一个程序,该程序有一个函数F,该程序运行时有两个处于运行状态的线程T1和T2。T1执行到某一位置,需要T2执行函数F并将结果返回,T1才能继续执行。也就是线程T1委托线程T2执行函数F,T1称为委托线程,T2称为被委托线程,整个过程我们称其为跨线程委托执行。
那么我们更通常的多线程编程的场景是什么呢?通常情况下,线程t1执行到某一位置,发现有一部分任务可以由另一个线程同时来执行,这时t1就通过直接创建线程或调用线程池的方式主动启动一个线程t2,在t1执行完不需要t2的运行结果就能执行的代码后t1.Join(t2),等t2执行完毕后,Join方法返回,t1这时可以获取到t2的运行结果,t1继续执行。这个过程我们称为普通线程执行。
这两种场景的区别就是:在跨线程委托执行中第二个线程一直处于运行状态;而在普通线程执行中,第一个线程根据需要启动第二个线程并等待其执行完毕。
2. 跨线程委托执行的实现
2.1 实现描述
线程的性质告诉我们:一个线程,一旦开始运行,任何其它线程都不能直接改变该线程的执行流程。
要改变被委托线程的执行流程,实现跨线程委托执行,必须通过以下方式:
- 被委托线程T2明确知道自己要执行别的线程委托执行的代码
- 被委托线程T2和委托线程T1共享一个状态信息
- 被委托线程T2进入一种轮询状态,根据状态信息,决定要执行的代码
- 委托线程T1修改状态信息,以指示T2要执行的代码。
2.2 伪代码
我们用伪代码定义两个能够实现跨线程委托执行的线程。每次跨线程委托执行被定义为一个任务。
在被委托线程T2中,循环检查任务列表中是否有需要执行的任务,如果有,取出任务,执行,通知委托线程执行完毕,进入下一次循环;如果没有,线程挂起。
在委托线程中,将要委托T1执行的内容作为任务添加到任务列表中,通知被委托线程任务已添加,然后等待任务执行完毕。注意,返回值事件与委托线程一一对应,因为一个委托线程同时只能进行一个跨线程委托执行,同时每个跨线程委托执行必须有自己的返回值事件。
被委托线程的伪代码:
定义 任务列表信号量(0)
定义 任务列表互斥事件(true)
while(true){
Wait(任务列表信号量)
Wait(任务列表互斥事件)
取出任务
Signal(任务列表互斥事件)
执行任务
Signal(返回值事件)
}
委托线程的伪代码:
定义 返回值事件(false)
Wait(任务列表互斥事件)
添加任务
Signal(任务列表互斥事件)
Signal(任务列表信号量)
Wait(返回值事件)
获取任务返回值
实际上,这个过程大体上也就是Widows的消息泵的实现方式。 对应于windows中,UI线程就是一个被委托线程,工作线程通常就是委托线程。一个被委托线程可服务多个委托线程。
3. 实际应用
3.1 UI线程
最常用到的跨线程委托执行就是WinForm中的Control.Invoke和WPF中的Dispatcher.Invoke,所有与UI相关的代码必须在UI线程中运行。相信很多人都经历过在工作线程中直接修改UI然后程序崩溃的情况。
在Silverlight中,大量运用异步方法调用,甚至WebClient这样的类的回调事件干脆自动在主线程中执行的,也就是说,内部就调用了Dispatcher.Invoke。
一个题外话:为什么工作线程中执行UI相关的代码可能导致程序崩溃?
可以猜想到的一个可能的原因就是:用户界面的应用场景决定了它需要响应大量的并发的不可预知事件,如鼠标键盘输入、其它线程的消息 、其它进程的消息,这些事件都可能导致用户界面重绘(比如你将挡在当前用户界面上的另一个应用程序的窗口移开,操作系统就会通知当前窗口重绘),在用户界面重绘的过程中会用到你用UI代码修改的相关的属性,执行绘制的主线程和修改属性值的工作线程在读取或修改这些属性值的时候会发生竞争,可能出现非法的属性值或触发异常,导致错误。
3.2 STA线程
在进程内COM中,如果当前STA线程没有窗体,COM运行时会使用隐藏窗口为该STA线程创建一个消息泵,其它线程对当前STA中COM对象的方法的调用过程都是跨线程委托执行的过程。
我们将上篇文章中的STA的示例稍做修改:在主线程中增加对Run的调用,可以进一步看到跨线程委托执行的效果:
1 namespace ConsoleApplication1 2 { 3 class Program 4 { 5 [STAThread()] 6 static void Main(string[] args) 7 { 8 var v = new ATLTestLib.SimpleCom(); 9 Thread t = new Thread(x => 10 { 11 Run((ATLTestLib.ISimpleCom)x); 12 }); 13 t.SetApartmentState(ApartmentState.STA); 14 t.Start(v); 15 Thread.Sleep(300); 16 Thread t2 = new Thread(x => 17 { 18 Run((ATLTestLib.ISimpleCom)x); 19 }); 20 t2.SetApartmentState(ApartmentState.STA); 21 t2.Start(v); 22 23 Run(v); 24 } 25 26 static public void Run(ATLTestLib.ISimpleCom sc) 27 { 28 try 29 { 30 //Console.WriteLine(Thread.CurrentThread.ApartmentState); 31 for (var i = 0; i < 5; i++) 32 { 33 Console.WriteLine(string.Format("[{0}] {1}", 34 Thread.CurrentThread.ManagedThreadId, 35 sc.Hello())); 36 } 37 } 38 catch (Exception ex) 39 { 40 Console.WriteLine(ex); 41 } 42 } 43 } 44 }
运行结果
[1] [19792] 0>你好!1>m_iMember = 0; 2>再见~ [1] [19792] 0>你好!1>m_iMember = 1; 2>再见~ [1] [19792] 0>你好!1>m_iMember = 2; 2>再见~ [1] [19792] 0>你好!1>m_iMember = 3; 2>再见~ [1] [19792] 0>你好!1>m_iMember = 4; 2>再见~ [4] [19792] 0>你好!1>m_iMember = 5; 2>再见~ [3] [19792] 0>你好!1>m_iMember = 6; 2>再见~ [4] [19792] 0>你好!1>m_iMember = 7; 2>再见~ [3] [19792] 0>你好!1>m_iMember = 8; 2>再见~ [4] [19792] 0>你好!1>m_iMember = 9; 2>再见~ [3] [19792] 0>你好!1>m_iMember = 10; 2>再见~ [4] [19792] 0>你好!1>m_iMember = 11; 2>再见~ [3] [19792] 0>你好!1>m_iMember = 12; 2>再见~ [4] [19792] 0>你好!1>m_iMember = 13; 2>再见~ [3] [19792] 0>你好!1>m_iMember = 14; 2>再见~ 请按任意键继续. . .
运行结果的第一列是调用COM对象中的方法的调用线程的托管ID,第二列是执行运行COM对象的线程的本机ID,后面是Hello方法返回的值。
上篇文章中的代码中有两个工作线程同时访问在主线程中创建的COM对象,本文中在主线程中同时调用COM对象。但是,从运行结果可以看到,主线程中的调用并没有和其他两个线程中的调用同步发生,而是在主线程中的调用完成后,两个工作线程中的调用才同步开始进行。
原理是这样的:因为主线程是STA线程且没有窗口,因此COM运行时为该线程创建了一个隐藏的窗口,但是在main函数中的代码执行完毕之前,这个隐藏窗口的消息泵没有机会被启动,(牢记一个线程只能有一条执行流程),也就是不符合前面描述的实现跨线程委托执行的第三个条件:被委托线程T2进入一种轮询状态。只有main函数中的代码都被执行完成后,隐藏窗口的消息泵被启动,COM对象开始处理来自其他线程的方法调用。
3.3 异步编程
在异步编程中,当我们在线程tMain中await一个异步函数时,先会发生普通线程执行,启动线程tNew来执行那个最终真正需要异步执行的异步函数,在线程tNew执行到最后阶段,也就是await的返回位置,就会发生跨线程委托执行,线程tNew将await之后的代码委托给调用线程tMain执行,很明显调用线程tMain必须拥有消息泵;否则跨线程委托执行不会发生,await之后的代码将直接在线程tNew中运行。(在Console应用中,默认不存在消息泵,await后面的代码不在主线程中执行,对于Console应用,这通常也没什么影响)
4. 用Dispatcher实现跨线程委托执行
实际上Dispatcher类不光是在WPF的UI线程中能够使用,在任何线程中都可以使用,包括Conosle应用程序中的线程。Dispatcher为我们提供了一个现成的、非常易用的跨线程委托执行的实现方法。
1 using System; 2 using System.Threading; 3 using System.Windows.Threading; 4 5 namespace ConsoleApplication2 6 { 7 public class Program 8 { 9 [STAThread] 10 static void Main(string[] args) 11 { 12 Thread.CurrentThread.Name = "MainThread"; 13 new Thread((data) => 14 { 15 Thread.CurrentThread.Name = "WorkerThread"; 16 var d = data as Dispatcher; 17 Console.WriteLine("Invoked by [{0}].", 18 Thread.CurrentThread.Name); 19 d.Invoke(new Action(()=> 20 { 21 Console.WriteLine("Executed by [{0}].", 22 Thread.CurrentThread.Name); 23 })); 24 d.InvokeShutdown(); 25 }).Start(Dispatcher.CurrentDispatcher); 26 Dispatcher.Run(); 27 } 28 } 29 }
运行结果
Invoked by [WorkerThread].
Executed by [MainThread].
请按任意键继续. . .
5. 总结
轮询是跨线程间调用实现的本质,无论上层如何封装,最底层的实现都一样。
在.NET应用现跨线程调研非常容易。