通常我们在做多线程编写WinForm程序的时候经常听到的一句话就是“窗体控件只能在主线程中创建”,刚开始的时候我也接受的是这样的教育,因此一直以来一直在编程中有意识的这样做了。平时也没有过多的去想为什么要这样做,就把这个当做是一条公理了,就像是数学中“两个不同的点确定一条直线,三个不在同一直线上的点确定一个平面”一样那么简单。
最近又一次在做一个WinForm程序,突发奇想想要试试在非主线程创建一个窗体控件会有什么结果,最坏不过是程序挂掉吧。思路定下来后代码就很快写出来了。为了突出重点只列出了相关的部分代码。
2 {
3
4 private Form1 frm;
5 private Thread thread;
6
7 private void MainForm_Load(object sender, EventArgs e)
8 {
9 LoadConfig();
10 }
11
12 private void LoadConfig()
13 {
14 Thread t = new Thread(DoLoadConfig);
15 t.Start();
16 }
17
18 private void DoLoadConfig()
19 {
20 // 加载配置
21
22 if(GlobalConfig.HasError)
23 {
24 frm = new Form1();
25 frm.ShowDialog(); // ----------- @1
26 }
27 }
结果运行这段程序的时候@1这个地方的两行代码也执行到了,可是却一点问题都没有,没有异常,没有特殊的提示,程序也能正常退出。这是怎么回事呢?不是说不能在非主线程上创建控件吗?可是我这个程序不是好好的运行着又怎么解释呢。带着这样的问题我又回头去看了一个自己写的代码,这次发现了一个问题:frm调用的是ShowDialog方法,这会使调用线程一直阻塞在这个地方。会不会是这个原因呢?于是我把ShowDialog替换成了Show方法,一试,果然就出错了。我又试了其它几种常见的控件,TextBox,Label,Button都是这样,唯一不同的是这些控件没有和ShowDialog类似的方法,一旦在不同的线程中创建好了并且加入到主线程控件的集合中之后,当线程t一结束就会出异常。如果线程t不结束的话就一直不出异常。看来不能在非主线程上创建控件还是对的。
然而这只是表面现象,真正导致这样的原因是什么呢。前面提到了如果线程t不退出的话那么线程上创建的控件永远不会出问题,看来关键还在于和线程的生命周期相关。我们知道Windows是消息驱动型的操作系统,Windows窗体控件也是由消息和用户进行交互的,一个比较精典的具有说服力的例子就是C++编写Windows程序中的while消息循环与消息泵。到C#中虽然大家好像看不到消息循环的代码或功能块了,这并不能说明C#的WinForm程序中没有这一块,只是被Framework隐藏了。而消息泵是和特定的线程相关联的,简单来说在哪个线程上创建的控件就会把该控件的消息泵与这个线程关联在一起,一旦相应的线程不存在了,那么消息循环就没有了对应的消息泵的来源,而一个没有消息泵的控件基本上可以算作是一个死控件,不会刷新界面,不会响应用户操作,甚至于不会销毁自身使用的资源,当然也就可能会出现一些异常呀什么的。
从这里这们可以看出控件并不是绝对不能从非主线程中创建,只要能保证创建控件的线程的生命周期能持续到整个进程线束就不会出什么问题。然而在平时的编程中很少有非主线程能持续到进程结束的,基本上是需要用到多线程的时候临时创建一个,多线程处理完了就释放了。另外一个为了程序结构上的统一,不至于让维护的人看不懂,还是建议尽量把控件的创建放到主线程上去吧。并且WinForm程序的主线程最重要的职责就是维护各个控件的创建,调用,销毁整个生命周期的。
弄明白了这一点后我们再来看看另一个问题:为什么对控件的操作需要放到主线程(创建控件的线程)上去做 ?我们知道,主线程时刻都在维护着控件的状态的改变和界面的更新。如果从另一个线程去访问或修改控件的信息的话就存在一个多线程同步的问题。常用的同步方式就是锁和信号量。如果想要加锁的话,必须在两个线程调用的地方都得加锁,一来我们不知道该加到什么地方,因为C#的控件对象本身只是保存了一个Windows内部真正对象的一个句柄,把锁加到这里吧,一来我们没法改变Framework的既有代码,另外也不可能一个方法一个方法的都去加锁,同样,采用信息量也存在同一个样的问题。而且为了不影响用户体验,我们一班不会让主线程阻塞的。所以采用加锁和信息量的方式是不太可行的,由此我们也得出不能从两个线程中去调用控件的方法。那么有的时候我们实在是要想从另外的线程去调用呢?比如一个异步传送数据的线程,需要实时的更新界面以显示传了多数数据。这种情况怎么处理呢?C#中可以采用在非主线程上调用控件的Invoke方法来实现,其它语言应该也有类似的实现。
2 {
3 using (new MultithreadSafeCallScope())
4 {
5 return this.FindMarshalingControl().MarshaledInvoke(this, method, args, true);
6 }
7 }
8
9 private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous)
10 {
11
12
13 ThreadMethodEntry entry = new ThreadMethodEntry(caller, method, args, synchronous, executionContext);
14
15
16 lock (this.threadCallbackList)
17 {
18
19 this.threadCallbackList.Enqueue(entry);
20 }
21
22 if (flag)
23 {
24 this.InvokeMarshaledCallbacks();
25 }
26 else
27 {
28 UnsafeNativeMethods.PostMessage(new HandleRef(this, this.Handle), threadCallbackMessage, IntPtr.Zero, IntPtr.Zero);
29 }
30 }
大家查看一个Invoke方法就可以发现,Invoke调用的时候传入的是一个真正需要执行的委托和相应的参数(如果有的话),通过向主线程发送一条自定义消息,这个消息指示了是一个回调某个方法的消息,同时把需要回调的方法和参数用一个结构存起来,再存到一个队列中,在消息处理函数中:
2 {
3
4 if ((m.Msg == threadCallbackMessage) && (m.Msg != 0))
5 {
6 this.InvokeMarshaledCallbacks();
7 }
8
9 }
10 private void InvokeMarshaledCallbacks()
11 {
12 ThreadMethodEntry tme = null;
13 lock (this.threadCallbackList)
14 {
15 if (this.threadCallbackList.Count > 0)
16 {
17 tme = (ThreadMethodEntry) this.threadCallbackList.Dequeue();
18 }
19 }
20 if (NativeWindow.WndProcShouldBeDebuggable && !tme.synchronous)
21 {
22 this.InvokeMarshaledCallback(tme);
23 }
24 }
从这里我们可以清楚的看到要调用的方法怎么通过消息的发送接收处理,被转到了主线程上执行了,因为消息处理是按队列一条一条处理的,而且最终在同一线程上执行,自然也就不存在了同步的问题。如果是同步调用的话,被阻塞的仅仅是非主线程调用,直到消息被处理并且调用的方法返回为止,对于主线程基本上没有什么影响,无非就是多处理了一条与控件状态存取相关的消息。从上面的if(flag){...}else{...}来看,通过调用Invoke会自动判断当前线程上的调用是否需要通过消息队列来完成,如果本身就是在主线程上执行的调用根本就不再需要进入到消息循环中了。
不过为了方法更好的封装和把影响缩小到最小范围,我建议使用下面的形式来调用:
2 {
3 private Label lblUserName;
4
5 public string GetUserName()
6 {
7 if(this.InvokeRequired)
8 {
9 GetUserNameHandler handler = new GetUserNameHandler(this.GetUserName);
10 return this.Invoke(handler);
11 }
12 else
13 {
14 return this.lblUserName.Text;
15 }
16 }
17
18 public void GetUserName(string userName)
19 {
20 if(this.InvokeRequired)
21 {
22 SetUserNameHandler handler = new SetUserNameHandler(this.GetUserName);
23 this.Invoke(handler, userName);
24 }
25 else
26 {
27 this.lblUserName.Text = userName;
28 }
29 }
30
31 private delegate string GetUserNameHandler();
32 private delegate void SetUserNameHandler(string name);
33 }
这样做的好处一是对外封装了两个delegate,不至于每一个需要调用Invoke的地方都去创建delegate,对旬公布的方法简单易用。另一个是如果以后调用控件的这部分代码被改到了在主线程上执行,或者从主线程上改到了非主线程上执行,都不会出问题。