关于多线程委托的控件操作
介绍
这篇文章将介绍异步调用的实现机制及如何调用异步方法。大多数.NET开发者在经过delegate、Thread、AsynchronousInvocation之后,通常都会对以上概念产生混淆及误用。实际上,以上概念是.NET2.0版本中对并行编程的核心支持,基于概念上的错误认识有可能导致在实际的编程中,无法利用异步调用的特性优化我们的程序,例如大数据量加载引起的窗体”假死”。事实上这并不是一个困难的问题,该文将以一种逐层深入、抽丝剥茧的方式逐渐深入到异步编程的学习中。
同步与异步
大多数人并不喜欢阅读大量的文字说明,而喜欢直接阅读代码,因此,我们在下文中将主要以代码的形式阐述同步与异步的调用。
同步方法调用
假设我们有一个函数,它的功能是将当前线程挂起3秒钟。
static void Sleep()
{
Thread.Sleep(3000);
}
通常,当你的程序在调用Sleep后,它将等待3秒钟的时间,在这3秒钟时间内,你不能做任何其他操作。3秒之后,控制权被交回给调用线程(通常也就是你的主线程,即WinForm程序的UI线程)。这种类型的调用称为同步,本次调用顺序如下:
● 调用Sleep();
● Sleep()执行中;
● Sleep()执行完毕,控制权归还调用线程。
我们再次调用Sleep()函数,不同的是,我们要基于委托来完成这次调用。一般为了将函数绑定在委托中,我们要定义与函数返回类型、参数值完全一致的委托,这稍有点麻烦。但.NET内部已经为我们定义好了一些委托,例如MethodInvoker,这是一种无返回值、无参数的委托签名,这相当于你自定义了一种委托:
public delegate void SimpleHandler();
执行以下代码:
MethodInvoker
invoker = new
MethodInvoker(Sleep);
invoker.Invoke();
我们使用了委托,但依然是同步的方式。主线程仍然要等待3秒的挂起,然后得到响应。
注意:Delegate.Invoke是同步方式的。
异步方法调用
如何在调用Sleep()方法的同时,使主线程可以不必等待Sleep()的完成,一直能够得到相应呢?这很重要,它意味着在函数执行的同时,主线程依然是非阻塞状态。在后台服务类型的程序中,非阻塞的状态意味着该应用服务可以在等待一项任务的同时去接受另一项任务;在传统的WinForm程序中,意味着主线程(即UI线程)依然可以对用户的操作得到响应,避免了”假死”。我们继续调用Sleep()函数,但这次要引入BeginInvoke。
MethodInvoker
invoker = new
MethodInvoker(Sleep);
invoker.BeginInvoke(null, null);
● 注意BeginInvoke这行代码,它会执行委托所调用的函数体。同时,调用BeginInvoke方法的线程(以下简称为调用线程)会立即得到响应,而不必等待Sleep()函数 的完成。
● 以上代码是异步的,调用线程完全可以在调用函数的同时处理其他工作,但是不足的是我们仍然不知道对于Sleep()函数的调用何时会结束,这是下文将要解决的问 题。
● BeginInvoke可以以异步的方式完全取代Invoke,我们也不必担心函数包含参数的情况,下文介绍传值问题。
注意:Delegate.BeginInvoke是异步方式的。如果你要执行一项任务,但并不关心它何时完成,我们就可以使用BeginInvoke,它不会带来调用线程的阻塞。
对于异步调用,.NET内部究竟做了什么?
一旦你使用.NET完成了一次异步调用,它都需要一个线程来处理异步工作内容(以下简称异步线程),异步线程不可能是当前的调用线程,因为那样仍然会造成调用线程的阻塞,与同步无异。事实上,.NET会将所有的异步请求队列加入线程池,以线程池内的线程处理所有的异步请求。对于线程池似乎不必了解的过于深入,但我们仍需要关注以下几点内容:
● Sleep()的异步调用会在一个单独的线程内执行,这个线程来自于.NET线程池。
● .NET线程池默认包含25个线程,你可以改变这个值的上限,每次异步调用都会使用其中某个线程执行,但我们并不能控制具体使用哪一个线程。
● 线程池具备最大线程数目上限,一旦所有的线程都处于忙碌状态,那么新的异步调用将会被置于等待队列,直到线程池产生了新的可用线程,因此对于大量异步请 求,我们有必要关注请求数量,否则可能造成性能上的影响。
简单了解线程池
为了暴露线程池的上限,我们修改Sleep()函数,将线程挂起的时间延长至30s。在代码的运行输出结果中,我们需要关注以下内容:
● 线程池内的可用线程数量。
● 异步线程是否来自于线程池。
● 线程托管ID值。
上文已经提到,.NET线程池默认包含25个线程,因此我们连续调用30次异步方法,这样可以在第25次调用后,看看线程池内部究竟发生了什么。
private void Sleep()
{
int intAvailableThreads,
intAvailableIoAsynThreds;
// 取得线程池内的可用线程数目,我们只关心第一个参数即可
ThreadPool.GetAvailableThreads(out intAvailableThreads,
out intAvailableIoAsynThreds);
// 线程信息
string strMessage =
String.Format("是否是线程池线程:{0},线程托管ID:{1},可用线程数:{2}",
Thread.CurrentThread.IsThreadPoolThread.ToString(),
Thread.CurrentThread.GetHashCode(),
intAvailableThreads);
Console.WriteLine(strMessage);
Thread.Sleep(30000);
}
private void
CallAsyncSleep30Times()
{
// 创建包含Sleep函数的委托对象
MethodInvoker invoker = new MethodInvoker(Sleep);
for (int i = 0; i < 30; i++)
{
// 以异步的形式,调用Sleep函数30次
invoker.BeginInvoke(null, null);
}
}
输出结果:
对于输出结果,我们可以总结为以下内容:
● 所有的异步线程都来自于.NET线程池。
● 每次执行一次异步调用,便产生一个新的线程;同时可用线程数目减少。
● 在执行异步调用25次后,线程池中不再有空闲线程。此时,应用程序会等待空闲线程的产生。
● 一旦线程池内产生了空闲线程,它会立即被分配给异步任务等待队列,之后线程池中仍然不具备空闲线程,应用程序主线程进入挂起状态继续等待空闲线程,这样 的调用一直持续到异步调用被执行完30次。
针对以上结果,我们对于异步调用可以总结为以下内容:
● 每次异步调用都在新的线程中执行,这个线程来自于.NET线程池。
● 线程池有自己的执行上限,如果你想要执行多次耗费时间较长的异步调用,那么线程池有可能进入一种”线程饥饿”状态,去等待可用线程的产生。
BeginInvoke和EndInvoke
我们已经知道,如何在不阻塞调用线程的情况下执行一个异步调用,但我们无法得知异步调用的执行结果,及它何时执行完毕。为了解决以上问题,我们可以使用EndInvoke。EndInvoke在异步方法执行完成前,都会造成线程的阻塞。因此,在调用BeginInvoke之后调用EndInvoke,效果几乎完全等同于以阻塞模式执行你的函数(EndInvoke会使调用线程挂起,一直到异步函数执行完毕)。但是,.NET是如何将BeginInvoke和EndInvoke进行绑定呢?答案就是IAsyncResult。每次我们使用BeginInvoke,返回值都是IAsyncResult类型,它是.NET追踪异步调用的关键值。每次异步调用之后的结果如何?如果要了解具体执行结果,IAsyncResult便可视为一个标签。通过这个标签,你可以了解异步调用何时执行完毕,更重要的是,它可以保存异步调用的参数传值,解决异步函数上下文问题。
我们现在通过几个例子来了解IAsyncResult。如果之前对它了解不多,那么就需要耐心的将它领悟,因为这种类型的调用是.NET异步调用的关键内容。
private void SleepOneSecond()
{
// 当前线程挂起1秒
Thread.Sleep(1000);
}
private void
UsingEndInvoke()
{
// 创建一个指向SleepOneSecond的委托
MethodInvoker invoker = new MethodInvoker(SleepOneSecond);
// 开始执行SleepOneSecond,但这次异步调用我们传递一些参数
//
观察Delegate.BeginInvoke()的第二个参数
IAsyncResult tag = invoker.BeginInvoke(null, "passing
some state");
// 应用程序在此处会造成阻塞,直到SleepOneSecond执行完成
invoker.EndInvoke(tag);
// EndInvoke执行完毕,取得之前传递的参数内容
string strState = (string)tag.AsyncState;
Console.WriteLine("EndInvoke的传递参数" +
tag.AsyncState.ToString());
}
输出结果:
回到文章初始提到的”窗体动态更新”问题,如果你将上述代码运行在一个WinForm程序中,会发现窗体依然陷入”假死”。对于这种情况,你可能会陷入疑惑:之前说异步函数都执行在线程池中,因此可以肯定异步函数的执行不会引起UI线程的忙碌,但为什么窗体依然陷入了”假死”?问题就在于EndInvoke。EndInvoke此时扮演的角色就是”线程锁”,它充当了一个调用线程与异步线程之间的调度器,有时调用线程需要使用异步函数的执行结果,那么调度线程就需要在异步执行完之前一直等待,直到得到结果方可继续运行。EndInvoke一方面负责监听异步函数的执行状况,一方面将调用线程挂起。
因此在Win Form环境下,UI线程的”假死”并不是因为线程忙碌造成,而是被EndInvoke”善意的”暂时封锁,它只是为了等待异步函数的完成。
我们可以对EndInvoke总结如下:
● 在执行EndInvoke时,调用线程会进入挂起状态,一直到异步函数执行完成。
● 使用EndInvoke可以使应用程序得知异步函数何时执行完毕。
● 如果将上述写法称为”异步”,你一定觉得这种”异步”徒具其名,虽然知道异步函数何时执行完毕,也得到了异步函数的传值,但我们的调用线程仍然会等待函数执行完毕,在等待过程中线程阻塞,实际上与同步调用无异。
如何捕捉异常?
现在我们把问题稍微复杂化,考虑异步函数抛出异常的一种情形。我们需要了解在何处捕捉到异常,是BeginInvoke,还是EndInvoke?甚至是有没有可能无法捕捉异常?答案是EndInvoke。BeginInvoke的工作只是开始线程池对于异步函数的执行工作,EndInvoke则需要处理函数执行完成的所有信息,包括其中产生的异常。
private void SleepOneSecond()
{
Thread.Sleep(3000);
throw new Exception("Here Is An Async
Function Exception");
}
private void
UsingEndInvoke()
{
// 创建一个指向SleepOneSecond的委托
MethodInvoker invoker = new MethodInvoker(SleepOneSecond);
// 开始执行SleepOneSecond,但这次异步调用我们传递一些参数
//
观察Delegate.BeginInvoke()的第二个参数
IAsyncResult tag = invoker.BeginInvoke(null, "passing
some state");
try
{
// 应用程序在此处会造成阻塞,直到SleepOneSecond执行完成
invoker.EndInvoke(tag);
}
catch (Exception ex)
{
// 此处可以捕捉异常
MessageBox.Show(ex.Message);
}
// EndInvoke执行完毕,取得之前传递的参数内容
string strState = (string)tag.AsyncState;
Console.WriteLine("EndInvoke的传递参数" +
tag.AsyncState.ToString());
}
执行以上代码后,你将发现只有在使用EndInvoke时,才会捕捉到异常,否则异常将丢失。需要注意的是,直接在编译器中运行程序是无法产生捕获异常的,只有在Debug、Release环境下运行,异常才会以对话框的形式直接弹出。
向函数中传递参数
现在我们来改变一下异步函数,让它接收一些参数。
private string FuncWithParameters(int param1, string param2, ArrayList param3)
{
// 我们在这里改变参数值
param1 = 100;
param2 = "hello";
param3 = new ArrayList();
return "thank
you for reading me";
}
下面我们使用BeginInvoke与EndInvoke来调用这个函数,首先,我们创建一个匹配该函数的委托签名。
public delegate string DelegateWithParameters(int param1, string param2, ArrayList param3);
我们可以将BeginInvoke和EndInvoke视为将异步函数分割为两部分的特殊函数。BeginInvoke通过自己的两个参数值(一个AsyncCallBack委托,一个object对象)来接收传入参数,EndInvoke用于计算传出参数(标记了out或者ref的参数)和函数返回值。
现在我们回到自己的函数FuncWithParameters,param1、param2、param3是传入值,同时,它们也作为BeginInvoke的参数来处理;函数的返回值是string类型,它将作为EndInvoke的返回类型。比较酷的是,编译器可以通过委托类型,来自动为BeginInvoke和EndInvoke生成正确的参数与返回值类型。
注意我们在异步函数中为参数分配了新的值,这样可以检验这些参数在调用异步函数后,究竟会传出什么样的值……
private void CallFuncWithParameters()
{
// 创建几个参数
string strParam = "Param1";
int intValue = 100;
ArrayList list = new ArrayList();
list.Add("Item1");
// 创建委托对象
DelegateWithParameters delFoo =
new DelegateWithParameters(FuncWithParameters);
// 调用异步函数
IAsyncResult tag =
delFoo.BeginInvoke(intValue,
strParam, list, null, null);
// 通常调用线程会立即得到响应
// 因此你可以在这里进行一些其他处理
// 执行EndInvoke来取得返回值
string strResult =
delFoo.EndInvoke(tag);
Trace.WriteLine("param1: " + intValue);
Trace.WriteLine("param2: " + strParam);
Trace.WriteLine("ArrayList count: " + list.Count);
}
我们的异步函数对参数的改变并没有影响其传出值,现在我们把ArrayList变为ref参数,看看会给EndInvoke带来什么变化。
public delegate string DelegateWithParameters(out int param1, string param2, ref ArrayList param3);
private string FuncWithParameters(out int param1, string param2, ref ArrayList param3)
{
// 我们在这里改变参数值
param1 = 300;
param2 = "hello";
param3 = new ArrayList();
return "thank
you for reading me";
}
private void
CallFuncWithParameters()
{
// 创建几个参数
string strParam = "Param1";
int intValue = 100;
ArrayList list = new ArrayList();
list.Add("Item1");
// 创建委托对象
DelegateWithParameters delFoo =
new DelegateWithParameters(FuncWithParameters);
// 调用异步函数
IAsyncResult tag =
delFoo.BeginInvoke(out intValue,
strParam, ref list, null, null);
// 通常调用线程会立即得到响应
// 因此你可以在这里进行一些其他处理
// 调用EndInvoke,发现intValue和list可以作为参数被传出,
// 是因为他们可以被异步函数更新
string strResult =
delFoo.EndInvoke(out intValue, ref list, null);
Trace.WriteLine("param1: " + intValue);
Trace.WriteLine("param2: " + strParam);
Trace.WriteLine("ArrayList count: " + list.Count);
}
param2没有变化,因为它是输入参数;param1作为输出参数,被更新为300;ArrayList的值已被重新分配,我们可以发现它的引用被指向了一个空元素的ArrayList对象(初始引用已丢失)。通过以上实例,我们应该能理解参数是如何在BeginInvoke与EndInvoke之间传递的。现在我们来尝试完成一个非阻塞模式下的异步调用,这是个重头戏!
二、了解IAsyncResult
现在我们已经了解,EndInvoke可以给我们提供传出参数与更新后的ref参数;也可以向我们导出异步函数中的异常信息。例如,我们使用BeginInvoke调用了异步函数Sleep,它开始执行。之后调用EndInvoke,可以获取Sleep何时执行完成。但如果我们在Sleep执行完成20分钟后,才去调用EndInvoke呢?EndInvoke仍然会给我们提供传出值及异步中的异常(假如产生了异常),那么这些信息到底存储在哪里?EndInvoke如何在函数执行如此久之后仍然能够调用这些返回值?答案就在于IAsyncResult对象。EndInvoke每次在执行后,都会调用一个该对象作为参数,它包括以下信息:
● 异步函数是否已经完成
● 对调用了BeginInvoke方法的委托的引用
● 所有的传出参数及它们的值
● 所有的ref参数及它们的更新值
● 函数的返回值
● 异步函数产生的异常
IAsyncResult看起来空无一物,这是因为它仅仅是一个包含了若干属性的接口;而实际上,它是一个System.Runtime.Remoting.Messaging.AsyncResult对象。
如果我们在编译器运行期间监视tag的状态,就会发现,AsyncResult对象下包含类型为System.Runtime.Remoting.Messaging.ReturnMessage的对象。点开它,就会发现这个标签中包含的所有的异步函数的执行信息!
使用Callback委托:好莱坞原则”不要联系我,我会联系你”
目前为止,我们需要了解如何传递参数、如何捕捉异常;了解我们的异步方法其实是执行在线程池中的某个具体线程对象中。唯一未涉及到的就是如何在异步函数执行完成后得到通知。毕竟,阻塞调用线程等待函数结束的做法始终差强人意。为了实现这个目的,我们必须为BeginInvoke函数提供一个Callback委托。观察一下两个函数:
private void CallSleepWithoutOutAndRefParameterWithCallback()
{
// 创建几个参数
string strParam = "Param1";
int intValue = 100;
ArrayList list = new ArrayList();
list.Add("Item1");
// 创建委托对象
DelegateWithParameters delSleep =
new DelegateWithParameters(FuncWithParameters);
delSleep.BeginInvoke(out intValue,
strParam, ref list, new
AsyncCallback(CallBack), null);
}
private void
CallBack(IAsyncResult tag)
{
// 我们的int参数标记了out,因此此处不能定义初始值
int intOutputValue;
ArrayList list = null;
// IAsyncResult实际上就是AsyncResult对象,
//
取得它也就可以从中取得用于调用函数的委托对象
AsyncResult result = (AsyncResult)tag;
// 取得委托
DelegateWithParameters del =
(DelegateWithParameters)result.AsyncDelegate;
// 取得委托后,我们需要在其上执行EndInvoke。
//
这样就可以取得函数中的执行结果。
string strReturnValue =
del.EndInvoke(out
intOutputValue, ref list, tag);
Trace.WriteLine(strReturnValue);
}
在这里,我们向BeginInvoke传递了Callback回调函数。这样.NET就可以在FuncWithParameters()执行完后调用Callback函数。在之前,我们已经了解到,必须使用EndInvoke来取得函数的执行结果,注意上面为了使用EndInvoke,我们使用了一些特殊操作来取得delegate对象。
// IAsyncResult实际上就是AsyncResult对象,
// 取得它也就可以从中取得用于调用函数的委托对象
AsyncResult result = (AsyncResult)tag;
// 取得委托
DelegateWithParameters del =
(DelegateWithParameters)result.AsyncDelegate;
最后一个问题:回调函数执行在什么线程?
总而言之,Callback函数(回调函数)是.NET通过我们的委托对象来实现调用的。我们可能会希望得到一个更清晰的画面:回调函数究竟执行在那个线程?为了达到这个目的:我们在函数中加入线程日志。
private string FuncWithParameters(out int param1, string param2, ref ArrayList param3)
{
// 记录线程信息
Trace.WriteLine("In
FuncWithParameters: Thread Pool? "
+
Thread.CurrentThread.IsThreadPoolThread.ToString() +
" Thread Id: " +
Thread.CurrentThread.GetHashCode());
// 挂起秒以模拟线程在这里执行了耗时较长的任务
Thread.Sleep(4000);
// 我们在这里改变参数值
param1 = 300;
param2 = "hello";
param3 = new ArrayList();
// 这里执行一些耗时较长的工作
Thread.Sleep(3000);
return "thank
you for reading me";
}
private void
CallBack(IAsyncResult tag)
{
// 回调函数在什么线程执行?
Trace.WriteLine("In Callback:
Thread Pool? "
+
Thread.CurrentThread.IsThreadPoolThread.ToString() +
" Thread Id: " +
Thread.CurrentThread.GetHashCode());
// 我们的int参数标记了out,因此此处不能定义初始值
int intOutputValue;
ArrayList list = null;
// IAsyncResult实际上就是AsyncResult对象,
//
取得它也就可以从中取得用于调用函数的委托对象
AsyncResult result = (AsyncResult)tag;
// 取得委托
DelegateWithParameters del =
(DelegateWithParameters)result.AsyncDelegate;
// 取得委托后,我们需要在其上执行EndInvoke。
//
这样就可以取得函数中的执行结果。
string strReturnValue =
del.EndInvoke(out
intOutputValue, ref list, tag);
Trace.WriteLine(strReturnValue);
}
我将CallSleepWithoutOutAndRefParameterWithCallback()函数放在某个窗体按钮的单击事件中,并且连续点击三次,将得到这样的执行结果:
注意FuncWithParameter函数被连续执行了3次,它们依次被执行在相互独立的线程上,并且这些线程来自于线程池。而他们各自的回调函数也执行在与FuncWithParameter相同的线程中。线程11执行了FuncWithParameter,3秒后,它的回调函数也执行在线程11中,线程12、13也是同样。这样,我们可以认为回调函数实际上是异步函数的一种延续。
为什么要这样做?也许是因为这样我们就不必过多的耗费线程池中的线程,达到线程复用的效果;通过执行在相同的线程,也可以避免不同的线程间传递上下文环境带来的损耗问题。
到此为止,我们在Form中执行异步函数,将会得到一个完全不堵塞主线程的异步调用,这就是我们所希望的效果!
应用场景模拟
现在我们了解了BeginInvoke、EndInvoke、Callback的使用及特点,如何将他们运用到我们的Win Form程序中,使数据的获取不再阻塞UI线程,实现异步加载数据的效果?我们现在通过一个具体实例来加以说明。
场景描述:将系统的操作日志从数据库中查询出来,并且加载到前端的ListBox控件中。
要求:查询数据库的过程是个时间复杂度较高的作业,但我们的窗体在执行查询时,不允许出现”假死”的情况。
private void button1_Click(object sender, EventArgs e)
{
GetLogDelegate getLogDel = new
GetLogDelegate(GetLogs);
getLogDel.BeginInvoke(new
AsyncCallback(LogTableCallBack), null);
}
public delegate DataTable GetLogDelegate();
/// <summary>
/// 从数据库中获取操作日志,该操作耗费时间较长,
/// 且返回数据量较大,日志记录可能超过万条。
/// </summary>
/// <returns></returns>
private DataTable GetLogs()
{
string sql = "select * from ***";
DataSet ds = new DataSet();
using (OracleConnection cn = new
OracleConnection(connectionString))
{
cn.Open();
OracleCommand cmd = new OracleCommand(sql,
cn);
OracleDataAdapter adapter = new
OracleDataAdapter(cmd);
adapter.Fill(ds);
}
return ds.Tables[0];
}
/// <summary>
/// 绑定日志到ListBox控件。
/// </summary>
/// <param name="tag"></param>
private void
LogTableCallBack(IAsyncResult tag)
{
AsyncResult result =
(AsyncResult)tag;
GetLogDelegate del =
(GetLogDelegate)result.AsyncDelegate;
DataTable logTable =
del.EndInvoke(tag);
if (this.listBox1.InvokeRequired)
{
this.listBox1.Invoke(new MethodInvoker(delegate()
{
BindLog(logTable);
}));
}
else
{
BindLog(logTable);
}
}
private void
BindLog(DataTable logTable)
{
this.listBox1.DataSource = logTable;
}
以上代码在获取数据时,将不会带来任何UI线程的阻塞。
总结:
写下本文的主要目的在于总结以非阻塞模式调用函数的方法,我们应当了解以下结论;
● Delegate会对BeginInvoke与EndInvoke的调用生成正确的参数,所有的传出参数、返回值与异常都可以在EndInvoke中取得。
● 不要忘记BeginInvoke是取自线程池中的线程,要注意防止异步任务的数量超过了线程池的线程上限值。
● CallBack委托表示对与异步任务的回调,它将使我们从阻塞的困扰中彻底解脱。
● 截止到目前为止,UI线程在处理异步工作时将不再阻塞,而只有在更新UI具体内容时才会发生阻塞。
问题
我们将发现,一旦数据量较大,我们的UI线程在装载这些数据到控件的时候,依然会发生”假死”的情况。这是正常的,因为我们只保证了获取数据与UI线程的独立性,并没有保证更新UI带来的线程忙碌问题,”假死”正是UI线程忙碌带来的一个用户感受,如何避免这种情况,下文继续介绍。
三、解决窗口假死
在之前的《创建无阻塞的异步调用》中,已经介绍过异步调用的编写步骤和实施原理。异步调用是CLR为开发者提供的一种重要的编程手段,它也是构建高性能、可伸缩应用程序的关键。在多核CPU越来越普及的今天,异步编程允许使用非常少的线程执行很多操作。我们通常使用异步完成许多计算型、IO型的复杂、耗时操作,去取得我们的应用程序运行所需要的一部分数据。在取得这些数据后,我们需要将它们绑定在UI中呈现。当数据量偏大时,我们会发现窗体变成了空白面板。此时如果用鼠标点击,窗体标题将会出现”失去响应”的字样,而实际上UI线程仍在工作着,这对用户来说是一种极度糟糕的体验。如果你希望了解其中的原因(并不复杂:)),并彻底解决该问题,那么花时间读完此文也许是个不错的选择。
一般来说,窗体阻塞分为两种情况。一种是在UI线程上调用耗时较长的操作,例如访问数据库,这种阻塞是UI线程被占用所导致,可以通过delegate.BeginInvoke的异步编程解决;另一种是窗体加载大批量数据,例如向ListView、DataGridView等控件中添加大量的数据。本文主要探讨后一种阻塞。
基础理论
这部分简单介绍CLR对跨线程UI访问的处理。作为基础内容,相信大部分.NET开发者对它并不陌生,读者可根据实际情况略过此处。
控件的线程安全检测
在传统的窗体编程中,UI中的控件元素与其他工作线程互相隔离,每次我们访问一个UI控件,实际上都是在UI线程中进行。如果尝试在其他线程中访问控件,CLR针对不同的.NET Framework版本,会有不同的处理。在Framework1.x中,CLR允许应用程序以跨线程的方式运行,而在Framework2.0及以后版本中,System.Windows.Form.Control新增了CheckForIllegalCrossThreadCalls属性,它是一个可读写的bool常量,标记我们是否需要对非UI线程对控件的调用做出检测。如果指定true,当以其他线程访问UI,CLR会跑出一个”InvalidOperationException:线程间操作无效,从不是创建控件***的线程访问它”;如果为false,则不对该错误线程的调用进行捕获,应用程序依然运行。
在Framework1.x版本中,这个值默认是false。问什么之后的版本会加入这个属性来约束我们的UI呢?实际上官方对此的解释是当有多个并发线程尝试对UI进行读写时,容易造成线程争用资源带来的死锁。所以,CLR默认不允许以非UI线程访问控件。
然而,我们常常需要在窗体中使用异步线程来处理一些操作,例如IO和Socket通讯等。这时跨线程的UI访问又是必须的,对此,.NET给我们的补充方案就是Control的Invoke和BeginInvoke。
Control的Invoke和BeginInvoke
对于这两个方法,首先我们要有以下的认识:
- Control.Invoke,Control.BeginInvoke和delegate.Invoke,delegate.BeginInvoke是不同的。
- Control.Invoke中的委托方法,执行在主线程,也就是我们的UI线程。而Control.BeginInvoke从命名上来看虽然具有异步调用的特征(Begin),但也仍然执行在UI线程。
- 如果在UI线程中直接调用Invoke和BeginInvoke,数据量偏大时,依然会造成UI的假死。
有很多开发者在初次接触这两个函数时,很容易就将它们同异步联系起来、有些人会认为他们是独立于UI线程之外的工作线程,实际上,他们都被这两个函数的命名所蒙蔽了。如果以传统调用异步的方式,直接调用Control.BeginInvoke,与同步函数的执行无异,UI线程还是会处理所有辛苦的操作,造成我们的应用程序阻塞。
Control.Invoke的调用模型很明确:在UI线程中以代码顺序同步执行,因此,抛开工作线程调用UI元素的干扰,我们可以将Control.Invoke视为同步,本文不做过多介绍。
很多开发者在接触异步后,再来处理窗体假死的问题,很容易想当然的将Control.BeginInvoke视为WinForm封装的异步。所以我们重点关注这个方法。
体验BeginInvoke
前面说过,BeginInvoke除了命名上来看像异步,其实很多时候我们调用起来根本没有异步的”非阻塞”特性,我用下面这个例子简单的尝试一次对BeginInvoke的调用。
如你所见,我现在创建了一个简陋的Form,其中放置了一个Lable控件lable1,一个Button控件btn_Start,下面,开始code:
private void btn_Start_Click(object sender, EventArgs e)
{
// 储存UI线程的标识符
int curThreadID =
Thread.CurrentThread.ManagedThreadId;
new Thread((ThreadStart)delegate()
{
PrintThreadLog(curThreadID);
})
.Start();
}
private void
PrintThreadLog(int
mainThreadID)
{
// 当前线程的标识符
//
A代码块
int asyncThreadID =
Thread.CurrentThread.ManagedThreadId;
// 输出当前线程的扼要信息,及与UI线程的引用比对结果
//
B代码块
label1.BeginInvoke((MethodInvoker)delegate()
{
// 执行BeginInvoke内的方法的线程标识符
int curThreadID
= Thread.CurrentThread.ManagedThreadId;
label1.Text = string.Format("Async Thread ID:{0},Current Thread ID:{1},Is UI
Thread:{2}",
asyncThreadID, curThreadID,
curThreadID.Equals(mainThreadID));
});
// 挂起当前线程3秒,模拟耗时操作
//
C代码块
Thread.Sleep(3000);
}
这段代码在新的线程中访问了UI,所以我们使用了label1.BeginInvoke函数。新的线程中,我们取得了当前工作线程的线程标识符,也取得了BeginInvoke函数内的线程。然后,将它与UI线程的标志符作比对,将结果输出于Label1控件上。最后,我们挂起当前工作线程3秒,用于模拟一些常见的耗时操作。
为了便于区分,我们将这段代码分为A、B、C三个代码块。
运行结果:
我们能得到以下结论:
● PrintThreadLog函数主体(A、C代码块)执行在新的线程,它执行了不被BeginInvoke所包含的其他代码。
● 当我们调用了Control.BeginInvoke之后,线程调度权回归到了UI线程。也就是说,BeginInvoke内部的代码(B代码块)均执行在UI线程。
● 在UI线程执行BeginInvok中封装的代码时,工作线程内的剩余代码(C代码块)同时进行。它与BeginInvoke中的UI线程并行执行,互不干扰。
● 由于Thread.Sleep(3000)是隔离在UI线程外的工作线程,因此这行代码带来的线程阻塞实际上阻塞了工作线程,不会给UI带来任何影响。
Control.BeginInvoke的真正含义
既然Control.BeginInvoke其中的委托函数仍执行在UI线程内,那这个”异步”到底指的是什么?话题回到本文最初:我们在上文已经提到了”控件的线程安全检测”概念,相信大家对这种工作线程内调用Control.BeginInvoke的做法已经太熟悉了。我们也提到了”CLR不喜欢工作线程调用UI元素”。微软的决心如此之大,以至于CLR团队在.NET Framework2.0中添加了CheckForIllegalCrossThreadCalls和Control.Invoke、Control.BeginInvoke方法。这是一次相当重大的改革,CLR团队希望达到这样的效果:
如果不申明CheckForIllegalCrossThreadCalls = false;这样的”不安全”代码,你就只能使用Control.Invoke和Control.BeginInvoke;而只要使用后两者,不论它们的上下文运行环境是其它工作线程还是UI线程,它们封装的代码都会执行在UI线程内。
所以,msdn对Control.BeginInvoke给出了这样的解释:在创建控件的基础句柄所在线程上异步执行指定委托。
它的真正含义是:BeginInvoke所谓的异步,是相对于调用线程的异步,而不是相对于UI线程的异步。
CLR把Control.BeginInvoke(delegate method)中的异步函数执行在UI内,如果你像我上文那样用新线程调用BeginInvoke,那么method相对于这个新线程内的其他函数是异步的。毕竟method执行在了UI线程,新线程立即回调,不必等待Control.BeginInvoke的完成。所以,这个后台线程充分享受了”异步”的好处,不再阻塞,只是我们看不到而已;当然,如果你在BeginInvoke内执行一段耗时的代码,无论是从远程服务器获取数据库资料、IO读取,还是在控件内加载一大批数据,UI线程还是阻塞的。
正如传统的Delegate.BeginInvoke的异步工作线程取自于.NET线程池,Control.BeginInvoke的异步工作线程就是UI线程。
现在您明白两种BeginInvoke的区别了吗?
Control.Invoke、BeginInvoke与Windows消息
实际上,Invoke和BeginInvoke的原理是将调用的方法Marshal成消息,然后调用Win32Api的RegisterWindowMessage()向UI发送消息。我们使用Reflector,可以看到以下代码:
Control.Invoke:
public object Invoke(Delegate method, params object[] args)
{
using (new MultithreadSafeCallScope())
{
return this.FindMarshalingControl().MarshaledInvoke(this, method, args, true);
}
}
Control.BeginInvoke:
[EditorBrowsable(EditorBrowsableState.Advanced)]
public IAsyncResult BeginInvoke(Delegate method, params object[] args)
{
using (new MultithreadSafeCallScope())
{
return (IAsyncResult)this.FindMarshalingControl().MarshaledInvoke(this, method, args, false);
}
}
在以上代码中我们看到Control.Invoke和BeginInvoke的不同之处,在于调用MarshaledInvoke时,Invoke向最后一个参数传递了false,而BeginInvoke则是true。
MarshaledInvoke的结构是这样的:
private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous)
很明显,最后一个参数synchronous表示是否按照同步处理。MarshaledInvoke内部这样处理这个参数:
if (!synchronous)
{
return entry;
}
if
(!entry.IsCompleted)
{
this.WaitForWaitHandle(entry.AsyncWaitHandle);
}
所以,BeginInvoke的处理就是直接回调,Invoke却在等待异步函数执行完后,才继续执行。
到此为止,Invoke和BeginInvoke的工作就结束了,其余的工作就是UI对消息的处理,它由Control的WndProc(ref Message m)来执行。消息处理到底会给我们的UI带来什么样的影响?接着来看Application.DoEvents()函数。
Application.DoEvents
Application.DoEvents()函数是WinForm编程中极为重要的函数,但实际编程中,大多数开发者极少调用它。如果您对这个函数缺乏了解,那很可能会在以后长期的编程中对“窗体假死”这样的现象陷入迷惑。
当运行 Windows 窗体时,它将创建新窗体,然后该窗体等待处理事件。该窗体在每次处理事件时,均将处理与该事件关联的所有代码。所有其他事件在队列中等待。当代码处理事件时,应用程序不会响应。例如,如果将甲窗口拖到乙窗口之上,则乙窗口不会重新绘制。
如果在代码中调用 DoEvents,则您的应用程序可以处理其他事件。 例如,如果您有向ListBox添加数据的窗体,并将 DoEvents 添加到代码中,那么当将另一窗口拖到您的窗体上时,该窗体将重新绘制。如果从代码中移除 DoEvents,那么在按钮的单击事件处理程序执行结束以前,您的窗体不会重新绘制。
因此,如果我们在窗体执行事件时,不处理消息队列中的windows消息,窗体必然会失去响应。而上文已经介绍过,Control.Invoke和BeginInvoke都会向UI发送消息,造成UI对消息的处理,因此,这为我们解决窗体加载大量数据时的假死提供了思路。
解决方案
尝试”无假死”
这次我们使用开发中出现频率极高的ListView控件,体验一次理想的”异步刷新”,窗体中有一个ListView控件命名为listView1,并将View设置为Detail,添加两个ColumnHeader;一个Button命名为btn_Start,设计视图如下:
开始code:
private readonly int Max_Item_Count = 10000;
private void
button1_Click(object sender, EventArgs e)
{
new Thread((ThreadStart)(delegate()
{
for (int i = 0; i < Max_Item_Count; i++)
{
// 此处警惕值类型装箱造成的"性能陷阱"
listView1.Invoke((MethodInvoker)delegate()
{
listView1.Items.Add(new ListViewItem(new string[]
{ i.ToString(), string.Format("This is No.{0} item", i.ToString()) }));
});
};
}))
.Start();
}
代码运行后,你将会看到一个飞速滚动的ListView列表,在加载的过程中,列表以令人眼花缭乱的速度添加数据,此时你尝试拉动滚动条,或者移动窗体,都会发现这次的效果与以往的”白板”、”假死”截然不同!这是一个令人欣喜的变化。
运行过程:
从我的截图中可以看出,窗体在加载数据的过程中,依然绘制界面,并没有出现”假死”。
如果上述代码调用的是Control.BeginInvoke,程序会发生些奇怪的现象,想想是为什么?
好吧,到了现在,我们终于可以松了一口气了,界面响应的问题已经被解决,一切美好。但是,这样的窗体还是暴漏出两个大问题:
1. 比起传统加载,”无假死窗体”加载速度明显减慢。
2. 加载数据过程中,窗体发生剧烈闪烁现象。
问题分析
我们在调用Control.Invoke时,强迫窗体处理消息,从而使界面得到了响应,同时也产生了一些副作用。其中之一就是消息处理使得窗体发生了在循环中发生了重绘,”闪烁”现象就是窗体重绘引发的,有过GDI+开发经验的开发者应该比较熟悉。同时,每次调用Invoke都会使UI处理消息,也直接增加了控件对数据处理的时间成本,导致了性能问题。
对于”性能问题”,我并没有什么解决方案(有自己见解的朋友欢迎提出)。有些控件(ListView、ListBox)具有BeginUpdate和EndUpdate函数,可以临时挂起刷新,加快性能。但毕竟我们这里创建了一个会滚动的界面,这种数据的”动态加载”方式是前者无法比拟的。
对于”闪烁”,我先来解释问题的原因。通常,控件的绘制包括两个环节:擦出原对象与绘制新对象。首先windows发送一个消息,通知控件擦除原图像,然后进行绘制。如果要在控件面板上以SolidBrush绘制,控件就会在其面板上直接绘制内容。当用户改变了控件尺寸,Windows将会调用很多绘制回收操作,当每次回收和绘制发生时,由于”绘制”较”擦除”更为延后,才会给用户带来”闪烁”的感觉。以往我们为解决此类问题,往往需要在Control.WndProc中作出复杂的处理。而.NET Framework为我们提供了更为优雅的一种方案,那就是双缓冲,我们直接调用它即可。
最终方案
- 新建Windows组件DBListView.cs,让它继承自ListView。
- 在控件中添加如下代码:
public DBListView()
{
// 打开控件的双缓冲
SetStyle(ControlStyles.OptimizedDoubleBuffer |
ControlStyles.AllPaintingInWmPaint, true);
}
将项目重新生成,然后从工具箱中拖出新增的组建DBListView到窗体上,命名为dbListView1,执行以下代码:
private void button1_Click(object sender, EventArgs e)
{
new Thread((ThreadStart)(delegate()
{
for (int i = 0; i < Max_Item_Count; i++)
{
// 此处警惕值类型装箱造成的"性能陷阱"
dbListView1.Invoke((MethodInvoker)delegate()
{
dbListView1.Items.Add(new ListViewItem(new string[]
{ i.ToString(), string.Format("This is No.{0} item", i.ToString()) }));
});
};
}))
.Start();
}
现在”闪烁”的问题是不是已经得到了解决?
在我们的实际应用中,这种加载数据引起的阻塞是很常见的,在用户对界面性能关注度不高的情况下,使用本文介绍的方式处理这种阻塞是一种不错的选择,如果以类似IE8、迅雷等软件的载入动画配合,效果会更理想。