C#跨线程修改控件——从MSIL和汇编看Invoke, 多线程, 事件与事件委托
C#跨线程修改控件——从MSIL和汇编看Invoke, 多线程, 事件与事件委托
许可协议:
CC BY-NC-SA
《C#跨线程修改控件——从MSIL和汇编看Invoke, 多线程, 事件与事件委托》 由 wenqiushi (cnblogs.com) 创作,采用 知识共享 署名-非商业性使用-相同方式共享 3.0 中国大陆 许可协议进行许可。请阅读原创性声明 。
正文:
相信大家刚开始写winform的时候都遇到过这样的问题,当跨线程修改控件属性时会遇到如下的异常:
线程间操作无效: 从不是创建控件"progressBar1"的线程访问它。
这是相应的产生上述异常的代码:
1 #region Auto-Generated Properties 2 3 // DelegateDemo - Director.cs 4 // by Wings 5 // Last Modified : 2013-05-28 11:43 6 7 #endregion 8 9 #region Using Block 10 11 using System.Globalization; 12 using System.Threading; 13 14 #endregion 15 16 namespace DelegateDemo 17 { 18 public delegate void PostEventHandler(string postStatus); 19 20 internal class Director 21 { 22 private static PostEventHandler _report; 23 24 public event PostEventHandler OnReport 25 { 26 add { _report += value; } 27 remove { _report -= value; } 28 } 29 30 public static void Test() 31 { 32 int counter = 0; 33 while (counter++ < 100) 34 { 35 _report(counter.ToString(CultureInfo.InvariantCulture)); 36 Thread.Sleep(100); 37 } 38 } 39 } 40 }
1 #region Auto-Generated Properties 2 3 // DelegateDemo - Form1.cs 4 // by Wings 5 // Last Modified : 2013-05-27 19:54 6 7 #endregion 8 9 #region Using Block 10 11 using System; 12 using System.Threading; 13 using System.Windows.Forms; 14 15 #endregion 16 17 namespace DelegateDemo 18 { 19 public partial class Form1 : Form 20 { 21 public Form1() 22 { 23 InitializeComponent(); 24 } 25 26 private void button1_Click(object sender, EventArgs e) 27 { 28 Director director = new Director(); 29 director.OnReport += director_OnReport; 30 Thread thread = new Thread(Director.Test) 31 { 32 Name = "thdDirector" 33 }; 34 thread.Start(); 35 } 36 37 private void director_OnReport(string postStatus) 38 { 39 int value = Convert.ToInt32(postStatus); 40 this.progressBar1.Value = value; //此处产生异常 41 } 42 } 43 }
我们知道当多个线程同时竞争资源的访问权并尝试修改资源状态时,资源可能出现同步异常。因此CLR才会禁止这种跨线程修改主窗体控件的行为。
一个简单粗暴(但十分有效)的方法是在主窗体构造函数中加入CheckForIllegalCrossThreadCalls = false;
像这样:
public Form1() { InitializeComponent(); CheckForIllegalCrossThreadCalls = false; }
附上msdn的解释:
获取或设置一个值,该值指示是否捕获对错误线程的调用,这些调用在调试应用程序时访问控件的 Handle 属性。
因此设为false后将不再检查非法跨线程调用。问题解决,本文也可以到此结束了(大误啊、、、)
毕竟跨线程调用是不安全的,可能导致同步失败。所以我们采用正统一点的方法来解决,那就是调用control的Invoke()或BeginInvoke()方法。
二者的差别在于BeginInvoke()是异步的,这里为了防止Director.Test()执行时主窗体关闭导致句柄失效进而产生异常,我们使用BeginInvoke()方法进行异步调用。
1 #region Auto-Generated Properties 2 3 // DelegateDemo - Form1.cs 4 // by Wings 5 // Last Modified : 2013-05-28 13:06 6 7 #endregion 8 9 #region Using Block 10 11 using System; 12 using System.Threading; 13 using System.Windows.Forms; 14 15 #endregion 16 17 namespace DelegateDemo 18 { 19 public partial class Form1 : Form 20 { 21 public Form1() 22 { 23 InitializeComponent(); 24 } 25 26 private void button1_Click(object sender, EventArgs e) 27 { 28 Director director = new Director(); 29 director.OnReport += director_OnReport; 30 Thread thread = new Thread(Director.Test) 31 { 32 Name = "thdDirector" 33 }; 34 thread.Start(); 35 } 36 37 private void director_OnReport(string postStatus) 38 { 39 int value = Convert.ToInt32(postStatus); 40 if (this.progressBar1.InvokeRequired) 41 { 42 SetValueCallback setValueCallback = delegate(int i) 43 { 44 this.progressBar1.Value = i; 45 }; 46 this.progressBar1.BeginInvoke(setValueCallback, value); 47 } 48 else 49 { 50 this.progressBar1.Value = value; 51 } 52 } 53 54 private delegate void SetValueCallback(int value); 55 } 56 }
至此,问题已经彻底解决,本文也可以真正地结束了。。。
但是!!!我们都知道一个不想当Geek的码农不是好程序猿~
于是乎我们应再次发扬Geek精神,剥去.NET粉饰的外衣,窥其真理的内核。
先从Invoke()入手,看到其源码:
public object Invoke(Delegate method, params object[] args) { using (new Control.MultithreadSafeCallScope()) return this.FindMarshalingControl().MarshaledInvoke(this, method, args, true); }
而BeginInvoke()差别仅仅在于MarshaledInvoke()的参数synchronous:
public IAsyncResult BeginInvoke(Delegate method, params object[] args) { using (new Control.MultithreadSafeCallScope()) return (IAsyncResult) this.FindMarshalingControl().MarshaledInvoke(this, method, args, false); }
实质都是调用了MarshaledInvoke方法。Marshaled这个词常写NativeMethods的同学一定很熟悉。中文翻译我还真不知道,这里给出维基百科的释义作为参考:
In computer science, marshalling (sometimes spelled marshaling with a single l) is the process of transforming the memory representation of an object to a data format suitable for storage or transmission, and it is typically used when data must be moved between different parts of a computer program or from one program to another. Marshalling is similar to serialization and is used to communicate to remote objects with an object, in this case a serialized object. It simplifies complex communication, using custom/complex objects to communicate instead of primitives. The opposite, or reverse, of marshalling is called unmarshalling (or demarshalling, similar to deserialization).
所以.NET的“暗箱操作”很有可能就在MarshaledInvoke里面。我们点进去看一下,当然主要关注NativeMethods
private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous) { if (!this.IsHandleCreated) throw new InvalidOperationException(System.Windows.Forms.SR.GetString("ErrorNoMarshalingThread")); if ((Control.ActiveXImpl) this.Properties.GetObject(Control.PropActiveXImpl) != null) System.Windows.Forms.IntSecurity.UnmanagedCode.Demand(); bool flag = false; int lpdwProcessId; if (System.Windows.Forms.SafeNativeMethods.GetWindowThreadProcessId(new HandleRef((object) this, this.Handle), out lpdwProcessId) == System.Windows.Forms.SafeNativeMethods.GetCurrentThreadId() && synchronous) flag = true; ExecutionContext executionContext = (ExecutionContext) null; if (!flag) executionContext = ExecutionContext.Capture(); Control.ThreadMethodEntry threadMethodEntry = new Control.ThreadMethodEntry(caller, this, method, args, synchronous, executionContext); lock (this) { if (this.threadCallbackList == null) this.threadCallbackList = new Queue(); } lock (this.threadCallbackList) { if (Control.threadCallbackMessage == 0) Control.threadCallbackMessage = System.Windows.Forms.SafeNativeMethods.RegisterWindowMessage(Application.WindowMessagesVersion + "_ThreadCallbackMessage"); this.threadCallbackList.Enqueue((object) threadMethodEntry); } if (flag) this.InvokeMarshaledCallbacks(); else //这里就是不添加任何防腐剂的纯天然原生态NativeMethod System.Windows.Forms.UnsafeNativeMethods.PostMessage(new HandleRef((object) this, this.Handle), Control.threadCallbackMessage, IntPtr.Zero, IntPtr.Zero); if (!synchronous) return (object) threadMethodEntry; if (!threadMethodEntry.IsCompleted) this.WaitForWaitHandle(threadMethodEntry.AsyncWaitHandle); if (threadMethodEntry.exception != null) throw threadMethodEntry.exception; else return threadMethodEntry.retVal; }
果然被我们找到了,这个System.Windows.Forms.UnsafeNativeMethods.PostMessage()就是WinAPI封装过后的NativeMethod了。当然它披上另一件衣服之后也是MFC里面的CWnd::PostMessage, 负责向窗体消息队列中放置一条消息,并且不等待消息被处理而直接返回(即异步,这也是与SendMessage的差别)。(Places a message in the window's message queue and then returns without waiting for the corresponding window to process the message.)
这也就解释了上述情况发生的原因,调用Invoke()而不是直接更改控件值使得主窗体能够将消息加入自身的消息队列中,从而在合适的时间处理消息,这样跨线程更改控件值就转变为窗体线程自己更改控件值,也就是从创建控件的线程(窗体主线程)访问控件,避免了之前的错误:“从不是创建控件"progressBar1"的线程访问它。”
不过还有一个问题,如果本来就是窗体线程对控件进行访问呢,毫无疑问直接设置值即可。在上面的代码中我使用InvokeRequired属性来判断控件更改者是否来自于其他线程,从而决定是调Invoke()还是直接表白(无误)。那么这个属性是否真的如我们所想,仅仅是判断调用者线程呢?看代码:
[SRDescription("ControlInvokeRequiredDescr")] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Advanced)] public bool InvokeRequired { get { using (new Control.MultithreadSafeCallScope()) { HandleRef hWnd; if (this.IsHandleCreated) { hWnd = new HandleRef((object) this, this.Handle); } else { Control marshalingControl = this.FindMarshalingControl(); if (!marshalingControl.IsHandleCreated) return false; hWnd = new HandleRef((object) marshalingControl, marshalingControl.Handle); } int lpdwProcessId; return System.Windows.Forms.SafeNativeMethods.GetWindowThreadProcessId(hWnd, out lpdwProcessId) != System.Windows.Forms.SafeNativeMethods.GetCurrentThreadId(); } } }
还真是这么直白,最后的return写的非常清楚。
至此,我们已经理解了Invoke的具体实现。下面来看事件委托,为什么Director.Test()能够触发Form1.cs的director_OnReport()回调函数。
我们在Form1.cs中的button1_Click()函数中添加了回调director.OnReport += director_OnReport;于是Director类OnReport事件执行了add{_report += value;}完成添加回调绑定过程。基于上面的现象我们知道progressBar1是在非窗体线程被更改的(见Invoke实现),既然是来自非窗体线程的更改,那么会不会是本来在窗体类中的director_OnReport(string postStatus)函数在回调绑定完成之后直接被替换到了Director.Test()中的_report(counter.ToString(CultureInfo.InvariantCulture));呢?
既然我们从表象上有理由怀疑这一点,那么就应当实际验证一下。只可惜C#封装的事件委托使得我们从.NET的源码中也难以知晓其底层实现。正所谓“不识庐山真面目,只缘身在此山中。”
为了理解其底层实现,我们必须先走出C#语言层面这座山。那就先看看Director类的MSIL吧(话说MSIL现已被微软正名为CIL,微软一匡天下之心昭然若揭。。。)
.method public hidebysig specialname instance void add_OnReport(class DelegateDemo.PostEventHandler 'value') cil managed { // 代码大小 23 (0x17) .maxstack 8 IL_0000: nop IL_0001: ldsfld class DelegateDemo.PostEventHandler DelegateDemo.Director::_report IL_0006: ldarg.1 IL_0007: call class [mscorlib]System.Delegate [mscorlib]System.Delegate::Combine(class [mscorlib]System.Delegate, class [mscorlib]System.Delegate) IL_000c: castclass DelegateDemo.PostEventHandler IL_0011: stsfld class DelegateDemo.PostEventHandler DelegateDemo.Director::_report IL_0016: ret } // end of method Director::add_OnReport .event DelegateDemo.PostEventHandler OnReport { .addon instance void DelegateDemo.Director::add_OnReport(class DelegateDemo.PostEventHandler) .removeon instance void DelegateDemo.Director::remove_OnReport(class DelegateDemo.PostEventHandler) } // end of event Director::OnReport
OnReport事件的内容被编译为两个函数。我们先只看add_OnReport这个函数,无非是与Property的Getter和Setter类似,对内绑定到_report()函数。那么再来看Form1中对OnReport事件的注册:
.method private hidebysig instance void button1_Click(object sender, class [mscorlib]System.EventArgs e) cil managed { // 代码大小 66 (0x42) .maxstack 3 .locals init ([0] class DelegateDemo.Director director, [1] class [mscorlib]System.Threading.Thread thread, [2] class [mscorlib]System.Threading.Thread '<>g__initLocal0') IL_0000: nop IL_0001: newobj instance void DelegateDemo.Director::.ctor() IL_0006: stloc.0 IL_0007: ldloc.0 IL_0008: ldarg.0 IL_0009: ldftn instance void DelegateDemo.Form1::director_OnReport(string) IL_000f: newobj instance void DelegateDemo.PostEventHandler::.ctor(object, native int) IL_0014: callvirt instance void DelegateDemo.Director::add_OnReport(class DelegateDemo.PostEventHandler) IL_0019: nop IL_001a: ldnull IL_001b: ldftn void DelegateDemo.Director::Test() IL_0021: newobj instance void [mscorlib]System.Threading.ThreadStart::.ctor(object, native int) IL_0026: newobj instance void [mscorlib]System.Threading.Thread::.ctor(class [mscorlib]System.Threading.ThreadStart) IL_002b: stloc.2 IL_002c: ldloc.2 IL_002d: ldstr "thdDirector" IL_0032: callvirt instance void [mscorlib]System.Threading.Thread::set_Name(string) IL_0037: nop IL_0038: ldloc.2 IL_0039: stloc.1 IL_003a: ldloc.1 IL_003b: callvirt instance void [mscorlib]System.Threading.Thread::Start() IL_0040: nop IL_0041: ret } // end of method Form1::button1_Click
其中IL_0009: ldftn位置到IL_000f: newobj位置声明并实例化了director_OnReport作为委托的target,而IL_0014: callvirt位置调用了add_OnReport()进行实际意义上的绑定。
然后从IL_001b: ldftn位置开始实例化新线程并进行相关赋值操作,直到IL_003b: callvirt位置调用Thead::Start()运行线程。
这样我们已经基本理清了绑定的实现过程,但是代码在执行时是否如我上面所说是“函数在回调绑定完成之后直接被替换”这样呢?想要验证就必须再看MSIL的底层实现,那是什么呢?对了,就是汇编。(//=_=老是自问自答有意思么。。。)
打开高端大气上档次的反汇编界面,在Director类中设定断点:
断点0:行26: add { _report += value; }
断点1:行35: _report(counter.ToString(CultureInfo.InvariantCulture));
开始调试,点击button1,第一次中断在断点0处:
--- Director.cs ---------------- 00000000 push ebp //各种压栈,为后面还原 00000001 mov ebp,esp 00000003 push edi 00000004 push esi 00000005 push ebx 00000006 sub esp,38h 00000009 xor eax,eax 0000000b mov dword ptr [ebp-10h],eax 0000000e xor eax,eax 00000010 mov dword ptr [ebp-1Ch],eax 00000013 mov dword ptr [ebp-3Ch],ecx 00000016 mov dword ptr [ebp-40h],edx 00000019 cmp dword ptr ds:[00289080h],0 00000020 je 00000027 00000022 call 78C0FD41 //这里开始对应 add { _report += value; } 00000027 nop //获得数据段地址寄存器偏移量02A184B8h(每次运行不同)处的值,赋给ecx寄存器,这个偏移量下面还会见到。 00000028 mov ecx,dword ptr ds:[02A184B8h] 0000002e mov edx,dword ptr [ebp-40h] 00000031 call 77EE1804 //调用Delegate Combine() 00000036 mov dword ptr [ebp-44h],eax 00000039 cmp dword ptr [ebp-44h],0 0000003d je 0000005E 0000003f mov eax,dword ptr [ebp-44h] 00000042 cmp dword ptr [eax],4430824h 00000048 jne 0000004F 0000004a mov eax,dword ptr [ebp-44h] 0000004d jmp 0000005C //直接跳到00000061 0000004f mov edx,dword ptr [ebp-44h] 00000052 mov ecx,4430824h 00000057 call 7899A73E 0000005c jmp 00000061 0000005e mov eax,dword ptr [ebp-44h] 00000061 lea edx,ds:[02A184B8h] 00000067 call 789911C8 //未跟踪 0000006c nop 0000006d lea esp,[ebp-0Ch] 00000070 pop ebx 00000071 pop esi 00000072 pop edi 00000073 pop ebp 00000074 ret
其中Combine代码(无所谓了):
public static Delegate Combine(Delegate a, Delegate b) { if (a == null) { return b; } return a.CombineImpl(b); }
来到了断点1:
--- Director.cs ---------------- //对应_report(counter.ToString(CultureInfo.InvariantCulture)); 00000038 nop //这个非常眼熟的偏移地址02A184B8h值又送给eax寄存器,这个偏移就是数据段中函数地址 00000039 mov eax,dword ptr ds:[02A184B8h] 0000003e mov dword ptr [ebp-48h],eax 00000041 lea eax,[ebp-3Ch] 00000044 mov dword ptr [ebp-4Ch],eax 00000047 call 77E72110 //这里构造了个CultureInfo 0000004c mov dword ptr [ebp-50h],eax 0000004f mov edx,dword ptr [ebp-50h] 00000052 mov ecx,dword ptr [ebp-4Ch] 00000055 call 7838EDA4 //调用NumberFormatInfo(), 还是Culture相关的 0000005a mov dword ptr [ebp-54h],eax 0000005d mov edx,dword ptr [ebp-54h] 00000060 mov ecx,dword ptr [ebp-48h] 00000063 mov eax,dword ptr [ecx+0Ch] 00000066 mov ecx,dword ptr [ecx+4] 00000069 call eax //此时eax中的值就是ds:[02A184B8h]的值,call后直接来到director_OnReport(),见下。 0000006b nop
直接跳转到了函数director_OnReport()
//直接跳到了函数director_OnReport() 00000052 nop //int value = Convert.ToInt32(postStatus); 00000053 mov ecx,dword ptr [ebp-40h] 00000056 call 03B4E948 0000005b mov dword ptr [ebp-58h],eax 0000005e mov eax,dword ptr [ebp-58h] 00000061 mov dword ptr [ebp-44h],eax //if (this.progressBar1.InvokeRequired) 00000064 mov eax,dword ptr [ebp-3Ch] 00000067 mov ecx,dword ptr [eax+00000144h] 0000006d mov eax,dword ptr [ecx] 0000006f call dword ptr [eax+00000128h] 00000075 mov dword ptr [ebp-5Ch],eax 00000078 cmp dword ptr [ebp-5Ch],0 0000007c sete al 0000007f movzx eax,al 00000082 mov dword ptr [ebp-50h],eax 00000085 cmp dword ptr [ebp-50h],0 00000089 jne 0000012D 0000008f nop
这就充分说明在C#代码层面上执行的_report()函数和director_OnReport()回调函数本质上是同一个函数(段地址相同),也恰好解释了为什么Form1类中的private函数为什么可以在另一个类中触发。因为C#也好,CIL也好,都是表层的封装。而在CLR虚拟机中实实在在运行的,是CLR Assembly. 我们说CLR是虚拟机,这个“虚拟”仅仅指CLR中的指令并非与物理硬件相关联,但是CLR以及其中的指令都是真实存在的,与真实机上的x86 CPU指令本质上是相同的。C#美轮美奂的亭台楼榭都建立在Assembly的一砖一瓦之上。而在CLR Assembly层面,只有内核级的概念,有内存管理,有线程调度。。。但是没有类级属性,没有成员函数,没有作用域可访问性控制,这也是我们能够看到其实质的原因。所以我们在使用C#封装好的模块和功能模型时,如果能够同时理解其底层实现,相信会对软件开发工作大有裨益。
忽然发现写了这么多。。而且好像逻辑很混乱的样子。。权当给小白入门看的吧~ 也欢迎各路大神不吝赐教。 另PS:这是本人的处女博(无误),以后要养成写博客的好习惯~
- END -