.net 线程和同步
在应用程序中进行网络调用需要一定的时间。用户不希望在安装用户界面时只是等待,直到服务器返回一个响应为止。用户可以在这个过程中执行其他一些操作,甚至取消发送给服务器的请求。这些都可以使用线程来实现。
使用线程有几个原因。不让用户等待是其中一个原因。对于所有需要等待的操作,例如文件、数据库或网络访问的启动都需要一定的时间,此时就可以启动一个新线程,完成其他任务。即使是处理密集型的任务,线程也是有帮助的。一个进程的多个线程可以同时运行在不同的CPU上,或多个核心CPU的不同核心上。
还必须注意运行多个线程的一些问题。它们可以同时运行,但如果线程访问相同的数据,就很容易出问题。必须实现同步机制。
本章介绍用多个线程编写应用程序所需了解的知识,包括:
● 线程概述
● 使用委托的轻型线程
● 线程类
● 线程池
● 线程问题
● 同步技术
● COM空间
● BackgroundWorker
18.1 概述
线程是程序中独立的指令流。使用C#编写任何程序时,都有一个入口:Main()方法。程序从Main()方法的第一条语句开始执行,直到这个方法返回为止。
这个程序结构非常适合于有一个可识别的任务序列的程序,但程序常常需要同时完成多个任务。线程对客户端和服务器端应用程序都非常重要。在Visual Studio编辑器中输入C#代码时,Dynamic Help窗口会立即显示与所输入代码相关的主题。后台线程会搜索帮助。Microsoft Word的拼写检查器也会做相同的事。一个线程等待用户输入,另一个线程进行后台搜索。第三个线程将写入的数据存储在临时文件中,第四个线程从Internet上下载其他数据。
运行在服务器上的应用程序中,一个线程等待客户的请求,称为监听器线程。只要接收到请求,就把它传送给另一个工作线程,之后继续与客户通信。监听器线程会立即返回,接收下一个客户发送的下一个请求。
使用Windows任务管理器,可以从菜单View | Select Columns中打开Threads列,查看进程和每个进程的线程号。在图18-1中,只有cmd.exe运行在一个线程中,其他应用程序都使用多个线程。Internet Explorer运行了51个线程。
图 18-1
操作系统会调度线程。线程有一个优先级、正在处理的程序的位置计数器、一个存储其本地变量的堆栈。每个线程都有自己的堆栈,但程序代码的内存和堆由一个进程的所有线程共享。这使一个进程中的所有线程之间的通信非常快――该进程的所有线程都寻址相同的虚拟内存。但是,这也使处理比较困难,因为多个线程可以修改同一个内存位置。
进程管理的资源包括虚拟内存和Windows句柄,其中至少包含一个线程。线程是运行程序所必需的。
在.NET中,托管的线程由Thread类定义。托管的线程不一定映射为一个操作系统线程。尽管这种情况可能出现,但应由.NET运行库负责将托管的线程映射到操作系统的物理线程上。在这方面,SQL Server 2005的运行主机与Windows应用程序的运行主机完全不同。使用ProcessThread类可以获得内部线程的信息,但在托管的应用程序中,通常最好使用托管的线程。
18.2 异步委托
创建线程的一种简单方式是定义一个委托,异步调用它。第7章提到,委托是方法的类型安全的引用。Delegate类还支持异步调用方法。在后台,Delegate类会创建一个执行任务的线程。
提示:
委托使用线程池来完成异步任务。线程池详见本章后面的内容。
为了演示委托的异步特性,启动一个方法,它需要一定的时间才能执行完毕。方法TakesAWhile至少需要作为变元传送过来的毫秒数才能执行完,因为它调用了Thread.Sleep()方法:
static int TakesAWhile(int data, int ms)
{
Console.WriteLine("TakesAWhile started");
Thread.Sleep(ms);
Console.WriteLine("TakesAWhile completed");
return ++data;
}
要在委托中调用这个方法,必须定义一个有相同参数和返回类型的委托,如下面的TakesAWhileDelegate所示:
public delegate int TakesAWhileDelegate(int data, int ms);
现在可以使用不同的技术异步调用委托,返回结果。
18.2.1 投票
一种技术是投票,检查委托是否完成了任务。所创建的Delegate类提供了方法BeginInvoke(),在该方法中,可以传送用委托类型定义的输入参数。BeginInvoke()方法总是有两个AsyncCallback和Object类型的额外参数(稍后讨论)。现在重要的是BeginInvoke()方法的返回类型:IAsyncResult。在IAsyncResult中,可以获得委托的信息,并验证委托是否完成了任务,这是IsCompleted属性的功劳。只要委托没有完成其任务,程序的主线程就继续执行while循环。
static void Main()
{
// synchronous
// TakesAWhile(1, 3000);
// asynchronous
TakesAWhileDelegate d1 = TakesAWhile;
IAsyncResult ar = d1.BeginInvoke(1, 3000, null, null);
while (!ar.IsCompleted)
{
// doing something else in the main thread
Console.Write(".");
Thread.Sleep(50);
}
int result = d1.EndInvoke(ar);
Console.WriteLine("result: {0}", result);
}
运行应用程序,可以看到主线程和委托线程同时运行,在委托线程执行完毕后,主线程就停止循环。
.TakesAWhile started
...................................................TakesAWhile completed
result: 2
除了检查委托是否完成之外,还可以在完成了由主线程执行的工作后,调用委托类型的EndInvoke()方法。EndInvoke()方法会一直等待,直到委托完成其任务为止。
警告:
如果不等待委托完成其任务就结束主线程,委托线程就会停止。
18.2.2 等待句柄
等待异步委托的结果的另一种方式是使用与IAsyncResult相关的等待句柄。使用AsyncWaitHandle属性可以访问等待句柄。这个属性返回一个WaitHandle类型的对象,它可以等待委托线程完成其任务。方法WaitOne()将一个超时时间作为可选的第一个参数,在其中可以定义要等待的最大时间。这里设置为50毫秒。如果发生超时,WaitOne()就返回false,while循环会继续执行。如果等待操作成功,就用一个中断退出while循环,用委托的EndInvoke()方法接收结果。
static void Main()
{
TakesAWhileDelegate d1 = TakesAWhile;
IAsyncResult ar = d1.BeginInvoke(1, 3000, null, null);
while (true)
{
Console.Write(".");
if (ar.AsyncWaitHandle.WaitOne(50, false))
{
Console.WriteLine("Can get the result now");
break;
}
}
int result = d1.EndInvoke(ar);
Console.WriteLine("result: {0}", result);
}
提示:
等待句柄的内容详见本章后面的“同步”一节。
18.2.3 异步回调
等待委托的结果的第三种方式是使用异步回调。在BeginInvoke()方法的第三个参数中,可以传送一个满足AsyncCallback委托的需求的方法。AsyncCallback委托定义了一个IAsyncResult类型的参数,其返回类型是void。这里,把方法TakesAWhileCompleted的地址赋予第三个参数,以满足AsyncCallback委托的需求。对于最后一个参数,可以传送任意对象,以便从回调方法中访问它。传送委托实例是可行的,这样回调方法就可以使用它获得异步方法的结果。
现在,只要委托TakesAWhileDelegate完成了其任务,就调用TakesAWhileCompleted()方法。不需要在主线程中等待结果。但是在委托线程的任务未完成之前,不能停止主线程,除非刚刚停止的委托线程没有出问题。
static void Main()
{
TakesAWhileDelegate d1 = TakesAWhile;
d1.BeginInvoke(1, 3000, TakesAWhileCompleted, d1);
for (int i = 0; i < 100; i++)
{
Console.Write(".");
Thread.Sleep(50);
}
}
方法TakesAWhileCompleted()用AsyncCallback委托指定的参数和返回类型来定义。用BeginInvoke()方法传送的最后一个参数可以使用ar. AsyncState读取。在TakesAWhileDelegate委托中,可以调用EndInvoke()方法获得结果。
static void TakesAWhileCompleted(IAsyncResult ar)
{
if (ar == null) throw new ArgumentNullException("ar");
TakesAWhileDelegate d1 = ar.AsyncState as TakesAWhileDelegate;
Trace.Assert(d1 != null, "Invalid object type");
int result = d1.EndInvoke(ar);
Console.WriteLine("result: {0}", result);
}
警告:
使用回调方法,必须注意这个方法在委托线程中调用,而不是在主线程中调用。
除了定义一个单独的方法,给它传送BeginInvoke()方法之外,匿名方法也非常适合这种情况。使用委托关键字,可以定义一个匿名方法,其参数是IAsyncResult类型。现在不需要把一个值赋予BeginInvoke()方法的最后一个参数,因为匿名方法可以直接访问该方法外部的变量d1。但是,匿名方法仍是在委托线程中调用,以这种方式定义方法时,这不是很明显。
static void Main()
{
TakesAWhileDelegate d1 = TakesAWhile;
d1.BeginInvoke(1, 3000,
delegate(IAsyncResult ar)
{
int result = d1.EndInvoke(ar);
Console.WriteLine("result: {0}", result);
},
null);
for (int i = 0; i < 100; i++)
{
Console.Write(".");
Thread.Sleep(50);
}
}
提示:
只有代码不太多,且实现代码不需要用于不同的地方时,才应使用匿名方法。在这种情况下,定义一个单独的方法比较好。匿名方法详见第7章。
编程模型和所有这些利用异步委托的选项—— 投票、等待句柄和异步调用—— 不仅能用于委托,编程模型在.NET Framework的各个地方都能见到。例如,可以用HttpWebRequest类的BeginGetResponse()方法异步发送HTTP Web请求,使用SqlCommand类的BeginExecute- Reader()方法给数据库发送异步请求。其参数类似于委托的BeginInvoke()方法,也可以使用相同的方式获得结果。
提示:
HttpWebRequest参见第35章,SqlCommand参见第25章。
18.3 Thread类
使用Thread类可以创建和控制线程。下面的代码是创建和启动一个新线程的简单例子。Thread类的构造函数接受ThreadStart和ParameterizedThreadStart类型的委托参数。ThreadStart委托定义了一个返回类型为void的无参数方法。在创建了Thread对象后,就可以用Start()方法启动线程了:
using System;
using System.Threading;
namespace Wrox.ProCSharp.Threading
{
class Program
{
static void Main()
{
Thread t1 = new Thread(ThreadMain);
t1.Start();
Console.WriteLine("This is the main thread.");
}
static void ThreadMain()
{
Console.WriteLine("Running in a thread.");
}
}
}
运行这个程序,得到两个线程的输出:
This is the main thread.
Running in a thread.
不能保证哪个结果先输出。线程由操作系统调度,每次哪个线程在前面都是不同的。
前面探讨了匿名方法如何与异步委托一起使用。异步委托还可以与Thread类一起使用,将线程方法的实现代码传送给Thread构造函数的变元:
using System;
using System.Threading;
namespace Wrox.ProCSharp.Threading
{
class Program
{
static void Main()
{
Thread t1 = new Thread(
delegate()
{
Console.WriteLine("running in a thread");
});
t1.Start();
Console.WriteLine("This is the main thread.");
}
}
}
在创建好线程后,如果不需要用引用线程的变量来控制线程,还可以用更简洁的方式编写代码。用构造函数创建一个新的Thread对象,将匿名方法传送给构造函数,用返回的Thread对象直接调用Start()方法:
using System.Threading;
namespace Wrox.ProCSharp.Threading
{
class Program
{
static void Main()
{
new Thread(
delegate()
{
Console.WriteLine("running in a thread");
}).Start();
Console.WriteLine("This is the main thread.");
}
}
}
但是,采用一个引用Thread对象的变量是有原因的。例如,为了更好地控制线程,可以在启动线程前,设置Name属性,给线程指定名称。为了获得当前线程的名称,可以使用静态属性Thread.CurrentThread,获取当前线程的Thread实例,访问Name属性,进行读取访问。线程也有一个托管的线程ID,可以用ManagedThreadId属性读取它。
static void Main()
{
Thread t1 = new Thread(ThreadMain);
t1.Name = "MyNewThread1";
t1.Start();
Console.WriteLine("This is the main thread.");
}
static void ThreadMain()
{
Console.WriteLine("Running in the thread {0}, id: {1}.",
Thread.CurrentThread.Name, Thread.CurrentThread.ManagedThreadId);
}
在应用程序的输出中,现在还可以看到线程名和ID:
This is the main thread.
Running in the thread MyNewThread1, id: 3.
警告:
给线程指定名称,非常有助于调试线程。在Visual Studio的调试会话中,可以打开Debug Location工具栏,查看线程的名称。
18.3.1 给线程传送数据
如果需要给线程传送一些数据,可以采用两种方式。一种方式是使用带ParameterizedThreadStart委托参数的Thread构造函数,另一种方式是创建一个定制类,把线程的方法定义为实例方法,这样就可以初始化实例的数据,之后启动线程。
要给线程传送数据,需要某个存储数据的类或结构。这里定义了包含字符串的结构Data,也可以传送任意对象。
public struct Data
{
public string Message;
}
如果使用了ParameterizedThreadStart委托,线程的入口点必须有一个object类型的参数,返回类型为void。对象可以转换为数据,这里是把信息写入控制台。
static void ThreadMainWithParameters(object o)
{
Data d = (Data)o;
Console.WriteLine("Running in a thread, received {0}", d.Message);
}
在Thread类的构造函数中,可以将新的入口点赋予ThreadMainWithParameters,传送变量d,调用Start()方法。
static void Main()
{
Data d = new Data();
d.Message = "Info";
Thread t2 = new Thread(ThreadMainWithParameters);
t2.Start(d);
}
给新线程传送数据的另一种方式是定义一个类(参见类MyThread),在其中定义需要的字段,将线程的主方法定义为类的一个实例方法:
public class MyThread
{
private string data;
public MyThread(string data)
{
this.data = data;
}
public void ThreadMain()
{
Console.WriteLine("Running in a thread, data: {0}", data);
}
}
这样,就可以创建MyThread的一个对象,给Thread类的构造函数传送对象和Thread Main()方法。线程可以访问数据。
MyThread obj = new MyThread("info");
Thread t3 = new Thread(obj.ThreadMain);
t3.Start();
18.3.2 后台线程
只要有一个前台线程在运行,应用程序的进程就在运行。如果多个前台线程在运行,而Main方法结束了,应用程序的进程就是激活的,直到所有前台线程完成其任务为止。
在默认情况下,用Thread类创建的线程是前台线程。线程池中的线程总是后台线程。
在用Thread类创建线程时,可以设置属性IsBackground,以确定该线程是前台线程还是后台线程。Main()方法将线程t1的IsBackground属性设置为false(默认值)。在启动新线程后,主线程就把结束信息写入控制台。新线程会写入启动和结束信息,在这个过程中它要睡眠3秒。在这3秒中,新线程会完成其工作,主线程才结束。
class Program
{
static void Main()
{
Thread t1 = new Thread(ThreadMain);
t1.Name = "MyNewThread1";
t1.IsBackground = false;
t1.Start();
Console.WriteLine("Main thread ending now...");
}
static void ThreadMain()
{
Console.WriteLine("Thread {0} started", Thread.CurrentThread.Name);
Thread.Sleep(3000);
Console.WriteLine("Thread {0} completed", Thread.CurrentThread.Name);
}
}
在启动应用程序时,会看到写入控制台的完成信息,尽管主线程会早一步完成其工作。原因是新线程也是一个前台线程。
Main thread ending now...
Thread MyNewThread1 started
Thread MyNewThread1 completed
如果将启动新线程的IsBackground属性改为true,显示在控制台上的结果就会不同。在一个系统上,可以看到新线程的启动信息,但没有结束信息。如果线程没有正常结束,还有可能看不到启动信息。
Main thread ending now...
Thread MyNewThread1 started
后台线程非常适合于完成后台任务。例如,如果关闭Word应用程序,拼写检查器继续运行其进程就没有意义了。在应用程序结束时,拼写检查器线程就可以关闭了。但是,组织Outlook信息库的线程应一直是激活的,直到Outlook结束,它才结束。
18.3.3 线程的优先级
前面提到,操作系统是在调度线程。给线程指定优先级,就可以影响这个调度。
在改变优先级之前,必须理解线程调度器。操作系统根据优先级来调度线程。优先级最高的线程在CPU上运行。线程如果在等待资源,就会停止运行,释放CPU。线程必须等待有几个原因,例如响应睡眠指令、等待磁盘I/O的完成,等待网络包的到达等。如果线程不是主动释放CPU,线程调度器就会抢先安排该线程。如果线程有一个时间量,就可以继续使用CPU。如果优先级相同的多个线程等待使用CPU,线程调度器就会使用一个循环调度规则,将CPU逐个交给线程使用。如果线程是被其他线程抢先了,它就会排在队列的最后。
只有优先级相同的多个线程在运行,才用得上时间量和循环规则。优先级是动态的。如果线程是CPU密集型的(一直需要CPU,且不等待资源),其优先级就低于用该线程定义的基本优先级。如果线程在等待资源,就会推动优先级向上移动,它的优先级就会增加。由于有这个推动,线程才有可能在下次等待结束时获得CPU。
在Thread类中,可以设置Priority属性,以影响线程的基本优先级。Priority属性需要一个ThreadPriority枚举定义的值。该值定义的级别有Highest、AboveNormal、BelowNormal和Lowest。在给线程指定较高的优先级时要小心,因为这可能降低其他线程的运行几率。如果需要,可以改变优先级一段较短的时间。
18.3.4 控制线程
调用Thread对象的Start()方法,可以创建线程。但是,在调用Start()方法后,新线程仍不在Running状态,而是在Unstarted状态。操作系统的线程调度器选择了要运行的线程后,线程就会改为Running状态。读取Thread.ThreadState属性,就可以获得线程的当前状态。
使用Thread.Sleep()方法,会使线程处于WaitSleepJoin状态,在用Sleep()方法定义的时间过后,线程就会再次被调用。
要停止另一个线程,可以调用Thread.Abort()方法。调用这个方法时,会在接到中止命令的线程中抛出ThreadAbortException类型的异常。用一个处理程序捕获这个异常,线程可以在结束前完成一些清理工作。线程还可以在接收到调用Thread.ResetAbort()方法的结果ThreadAbortException后继续运行。如果线程没有重置中止,接收到中止请求的线程状态将从AbortRequested改为Aborted。
如果需要等待线程的结束,就可以调用Thread.Join()方法。Thread.Join()方法会停止当前线程,把它设置为WaitSleepJoin状态,直到加入的线程完成为止。
.NET 1.0也支持Thread.Suspend()和Thread.Resume()方法,它们分别用于暂停和继续一个线程。但是,线程在得到Suspend请求时,我们并不知道线程在做什么,它可能处于有锁的同步段中。这很容易导致死锁。这就是这些方法现在被废弃的原因。另外,还可以使用同步对象给线程发信号,这样线程就可以挂起了。于是,线程就知道进入等待状态的最佳时机。
18.4 线程池
创建线程是需要时间的。如果有不同的小任务要完成,就可以事先创建许多线程,在应完成这些任务时发出请求。这个线程数应在需要更多的线程时增加,在需要释放资源时减少。
不需要自己创建这样一个列表。该列表由ThreadPool类管理。这个类会在需要时增减池中线程的个数,直到最大的线程数。池中的最大线程数是可以配置的。在双核CPU中,默认设置为50个工作线程和1000个I/O线程。也可以指定在创建线程池时应立即启动的最小线程数,以及线程池中可用的最大线程数。如果有更多的工作要处理,线程池中线程的使用也到了极限,最新的工作就要排队,必须等待线程完成其任务。
下面的示例程序首先要读取工作线程和I/O线程的最大线程数,把这个信息写入控制台。接着在for循环中,调用ThreadPool.QueueUserWorkItem()方法,传送一个WaitCallback类型的委托,把方法JobForAThread()赋予线程池中的线程。线程池收到这个请求后,就会从池中选择一个线程,来调用该方法。如果线程池还没有运行,就会创建一个线程池,启动第一个线程。如果线程池已经在运行,且有一个自由线程,就把工作传送给这个线程。
using System;
using System.Threading;
namespace Wrox.ProCSharp.Threading
{
class Program
{
static void Main()
{
int nWorkerThreads;
int nCompletionPortThreads;
ThreadPool.GetMaxThreads(out nWorkerThreads, out nCompletion
PortThreads);
Console.WriteLine("Max worker threads: {0}, I/O completion
threads: {1}",
nWorkerThreads, nCompletionPortThreads);
for (int i = 0; i < 5; i++)
{
ThreadPool.QueueUserWorkItem(JobForAThread);
}
Thread.Sleep(3000);
}
static void JobForAThread(object state)
{
for (int i = 0; i < 3; i++)
{
Console.WriteLine("loop {0}, running inside pooled thread {1}", i,
Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(50);
}
}
}
}
运行应用程序,可以看到50个工作线程的当前设置。5个任务只由两个线程池中的线程处理,读者运行该程序的结果可能与此不同,也可以改变任务的睡眠时间和要处理的任务数,得到完全不同的结果。
Max worker threads: 50, I/O completion threads: 1000
loop 0, running inside pooled thread 4
loop 0, running inside pooled thread 3
loop 1, running inside pooled thread 4
loop 1, running inside pooled thread 3
loop 2, running inside pooled thread 4
loop 2, running inside pooled thread 3
loop 0, running inside pooled thread 4
loop 0, running inside pooled thread 3
loop 1, running inside pooled thread 4
loop 1, running inside pooled thread 3
loop 2, running inside pooled thread 4
loop 2, running inside pooled thread 3
loop 0, running inside pooled thread 4
loop 1, running inside pooled thread 4
loop 2, running inside pooled thread 4
线程池使用起来很简单,但它有一些限制:
● 线程池中的所有线程都是后台线程。如果进程中的所有前台线程都结束了,所有的后台线程就会停止。不能把线程池中的线程改为前台线程。
● 不能给线程池中的线程设置优先级或名称。
● 对于COM对象,线程池中的所有线程都是多线程单元(multithreaded apartment,MTA)线程。许多COM对象都需要单线程单元(single-threaded apartment,MTA) 线程。
● 线程池中的线程只能用于时间较短的任务。如果线程要一直运行(如Word的拼写检查器线程),就应使用Thread类创建一个线程。
18.5 线程问题
用多个线程编程并不容易。在启动访问相同数据的多个线程时,会遇到难以发现的问题。为了避免这些问题,必须特别注意同步问题和多个线程可能发生的其他问题。下面探讨与线程相关的问题,如竞态条件和死锁。
18.5.1 竞态条件
如果两个或多个线程访问相同的对象,或者访问不同步的共享状态,就会出现竞态条件。
为了演示竞态条件,定义一个StateObject类,它包含一个int字段和一个方法Change State。在ChangeState方法的实现代码中,验证state变量是否包含5。如果是,就递增其值。下一个语句是Trace.Assert,它验证state现在是否包含6。在给包含5的变量递增了1后,该变量的值就应是6。但事实不一定是这样。例如,如果一个线程刚刚执行完if(state ==5)语句,它就被其他线程抢先,调度器去运行另一个线程了。第二个线程现在进入if体,由于state的值仍是5,所以将它递增为6。第一个线程现在再次被安排执行,在下一个语句中,state被递增为7。这时就发生了竞态条件,显示断言信息。
public class StateObject
{
private int state = 5;
public void ChangeState(int loop)
{
if (state == 5)
{
state++;
Trace.Assert(state == 6, "Race condition occurred after " +
loop + " loops");
}
state = 5;
}
}
下面定义一个线程方法来验证这一点。SampleThread类的方法RaceCondition()将一个StateObject对象作为其参数。在一个无限while循环中,调用方法ChangeState()。变量i仅用于显示断言信息中的循环数。
public class SampleThread
{
public void RaceCondition(object o)
{
Trace.Assert(o is StateObject, "o must be of type StateObject");
StateObject state = o as StateObject;
int i = 0;
while (true)
{
state.ChangeState(i++);
}
}
}
在程序的Main方法中,创建了一个新的StateObject对象,它由所有的线程共享。在Thread类的构造函数中,给RaceCondition的地址传送一个SampleThread类型的对象,以创建Thread对象。接着传送state对象,使用Start()方法启动这个线程。
static void Main()
{
StateObject state = new StateObject();
for (int i = 0; i < 20; i++)
{
new Thread(new SampleThread().RaceCondition).Start(state);
}
}
启动程序,就会出现竞态条件。在竞态条件第一次出现后,还需要多长时间才能第二次出现竞态条件,取决于系统以及将程序建立为发布版本还是调试版本。如果建立为发布版本,该问题的出现次数会比较多,因为代码被优化了。如果系统中有多个CPU或使用双核CPU,其中多个线程可以同时运行,该问题也会比单核CPU的出现次数多。在单核CPU中,若线程调度是抢先式的,也会出现该问题,只是没有那么频繁。
图18-2显示在3816个循环后,发生竞态条件的程序断言。多启动应用程序几次,总是会得到不同的结果。
图 18-2
要避免该问题,可以锁定共享的对象。这可以在线程中完成:用下面的lock语句锁定在线程中共享的变量state。只有一个线程能在锁定块中处理共享的state对象。由于这个对象由所有的线程共享,因此如果一个线程锁定了state,另一个线程就必须等待该锁定的解除。一旦进行了锁定,线程就拥有该锁定,直到该锁定块的末尾才解除锁定。如果每个改变state变量引用的对象的线程都使用一个锁定,竞态条件就不会出现。
public class SampleThread
{
public void RaceCondition(object o)
{
Trace.Assert(o is StateObject, "o must be of type StateObject");
StateObject state = o as StateObject;
int i = 0;
while (true)
{
lock (state) // no race condition with this lock
{
state.ChangeState(i++);
}
}
}
}
在使用共享对象时,除了进行锁定之外,还可以将共享对象设置为线程安全的对象。其中ChangeState()方法包含一个lock语句。由于不能锁定state变量本身(只有引用类型才能用于锁定),因此定义一个object类型的变量sync,将它用于lock语句。如果每次state值都使用同一个同步对象来修改锁定,竞态条件就不会出现。
public class StateObject
{
private int state = 5;
private object sync = new object();
public void ChangeState(int loop)
{
lock (sync)
{
if (state == 5)
{
state++;
Trace.Assert(state == 6, "Race condition occurred after " +
loop + " loops");
}
state = 5;
}
}
}
18.5.2 死锁
过多的锁定也会有麻烦。在死锁中,至少有两个线程被挂起,等待对方解除锁定。由于两个线程都在等待对方,就出现了死锁,线程将无限等待下去。
为了演示死锁,下面实例化两个StateObject类型的对象,并传送给SampleThread类的构造函数。创建两个线程,其中一个线程运行方法Deadlock1(),另一个线程运行方法Deadlock2():
StateObject state1 = new StateObject();
StateObject state2 = new StateObject();
new Thread(new SampleThread(state1, state2).Deadlock1).Start();
new Thread(new SampleThread(state1, state2).Deadlock2).Start();
方法Deadlock1()和Deadlock2()现在改变两个对象s1和s2的状态。这就进行了两个锁定。方法Deadlock1()先锁定s1,接着锁定s2。方法Deadlock2()先锁定s2,再锁定s1。现在,有可能方法Deadlock1()中s1的锁定会被解除。接着出现一次线程切换,Deadlock2()开始运行,并锁定s2。第二个线程现在等待s1锁定的解除。因为它需要等待,所以线程调度器再次调度第一个线程,但第一个线程在等待s2锁定的解除。这两个线程现在都在等待,只要锁定块没有结束,就不会解除锁定。这是一个典型的死锁。
public class SampleThread
{
public SampleThread(StateObject s1, StateObject s2)
{
this.s1 = s1;
this.s2 = s2;
}
private StateObject s1;
private StateObject s2;
public void Deadlock1()
{
int i = 0;
while (true)
{
lock (s1)
{
lock (s2)
{
s1.ChangeState(i);
s2.ChangeState(i++);
Console.WriteLine("still running, {0}", i);
}
}
}
}
public void Deadlock2()
{
int i = 0;
while (true)
{
lock (s2)
{
lock (s1)
{
s1.ChangeState(i);
s2.ChangeState(i++);
Console.WriteLine("still running, {0}", i);
}
}
}
}
}
结果是,程序运行了许多循环,不久就没有响应了。“仍在运行”的信息仅在控制台上写入几次。死锁问题的发生频率也取决于系统配置,每次运行的结果都不同。
死锁问题并不总是很明显。一个线程锁定了s1,接着锁定s2,另一个线程锁定了s2,接着锁定s1。只需改变锁定顺序,这两个线程就会以相同的顺序进行锁定。但是,锁定可能隐藏在方法的深处。为了避免这个问题,可以在应用程序的体系架构中,从一开始就设计好锁定顺序,也可以为锁定定义超时时间。如何定义超时时间详见下一节的内容。
18.6 同步
要避免同步问题,最好不要在线程之间共享数据。当然,这并不总是可行的。如果需要共享数据,就必须使用同步技术,确保一次只有一个线程访问和改变共享状态。注意,同步问题与竞态条件和死锁有关。如果不注意这些问题,就很难在应用程序中找到问题的原因,因为线程问题是不定期发生的。
本节讨论可以用于多个线程的同步技术:
● lock语句
● Interlocked类
● Monitor类
● 等待句柄
● Mutex类
● Semaphore类
● Event类
lock语句、Interlocked类和Monitor类可用于进程内部的同步。Mutex类、Semaphore类和Event类提供了多个进程中的线程同步。
18.6.1 lock语句和线程安全
C#为多个线程的同步提供了自己的关键字:lock语句。lock语句是设置锁定和解除锁定的一种简单方式。
在添加lock语句之前,先进入另一个竞态条件。类SharedState演示了如何使用线程共享的状态,并保存一个整数值。
public class SharedState
{
private int state = 0;
public int State
{
get { return state; }
set { state = value; }
}
}
类Task包含方法DoTheTask(),该方法是新线程的入口点。在其实现代码中,将SharedState的State递增50000次。变量sharedState在这个类的构造函数中初始化:
public class Task
{
SharedState sharedState;
public Task(SharedState sharedState)
{
this.sharedState = sharedState;
}
public void DoTheTask()
{
for (int i = 0; i < 50000; i++)
{
sharedState.State += 1;
}
}
}
在Main()方法中,创建一个SharedState对象,并传送给20个Thread对象的构造函数。在启动所有的线程后,Main()方法进入另一个循环,使20个线程处于等待状态,直到所有的线程都执行完毕为止。线程执行完毕后,把共享状态的合计值写入控制台。因为执行了50000个循环,有20个线程,所以写入控制台的值应是1000000。但是,事实常常并非如此。
class Program
{
static void Main()
{
int numThreads = 20;
SharedState state = new SharedState();
Thread[] threads = new Thread[numThreads];
for (int i = 0; i < numThreads; i++)
{
threads[i] = new Thread(new Task(state).DoTheTask);
threads[i].Start();
}
for (int i = 0; i < numThreads; i++)
{
threads[i].Join();
}
Console.WriteLine("summarized {0}", state.State);
}
}
}
多次运行应用程序的结果如下所示:
summarized 939270
summarized 993799
summarized 998304
summarized 937630
每次运行的结果都不同,但没有一个结果是正确的。调试版本和发布版本的区别很大。所使用的CPU类型不同,结果也不一样。如果将循环次数改为比较小的值,就会多次得到正确的值,但不是每次。这个应用程序非常小,很容易看出问题,但该问题的原因在大型应用程序中就很难确定。
必须在这个程序中添加同步功能,这可以用lock关键字实现。
用lock语句定义的对象表示,要等待指定对象的锁定解除。只能传送引用类型。锁定值类型只是锁定了一个副本,这是没有什么意义的。编译器会提供一个锁定值类型的错误。进行了锁定后—— 只有一个线程得到了锁定块,就可以运行lock语句块。在lock语句块的最后,对象的锁定被解除,另一个等待锁定的线程就可以获得该锁定块了。
lock (obj)
{
// synchronized region
}
要锁定静态成员,可以把锁定放在object类型上:
lock (typeof(StaticClass))
{
}
使用lock关键字可以将类的实例成员设置为线程安全。这样,一次只有一个线程能访问该实例的DoThis()和DoThat()方法。
public class Demo
{
public void DoThis()
{
lock (this)
{
// only one thread a time can access the DoThis and DoThat methods
}
}
public void DoThat()
{
lock (this)
{
}
}
}
但是,因为实例的对象也可以用于外部的同步访问,我们不能在类中控制这种访问,所以应采用SyncRoot模式。在SyncRoot模式中,创建了一个私有对象syncRoot,将这个对象用于lock语句。
public class Demo
{
private object syncRoot = new object();
public void DoThis()
{
lock (syncRoot)
{
// only one thread a time can access the DoThis and DoThat methods
}
}
public void DoThat()
{
lock (syncRoot)
{
}
}
}
使用锁定是需要时间的,且并不总是必需的。可以创建类的两个版本,一个同步版本,一个异步版本。这里用修改类Demo来演示。类Demo本身并不是同步的,这可以在DoThis()和DoThat()方法中看出。该类还定义了IsSynchronized属性,客户可以从该属性中获得类的同步选项信息。为了获得该类的同步版本,可以使用静态方法Synchronized()传送一个非同步对象,这个方法会返回SynchronizedDemo类型的对象。SynchronizedDemo实现为派生自基类Demo的一个内部类,并重写了基类中的虚成员。重写的成员使用了SyncRoot模式。
public class Demo
{
private class SynchronizedDemo : Demo
{
private object syncRoot = new object();
private Demo d;
public SynchronizedDemo(Demo d)
{
this.d = d;
}
public override bool IsSynchronized
{
get { return true; }
}
public override void DoThis()
{
lock (syncRoot)
{
d.DoThis();
}
}
public override void DoThat()
{
lock (syncRoot)
{
d.DoThat();
}
}
}
public virtual bool IsSynchronized
{
get { return false; }
}
public static Demo Synchronized(Demo d)
{
if (!d.IsSynchronized)
{
return new SynchronizedDemo(d);
}
return d;
}
public virtual void DoThis()
{
}
public virtual void DoThat()
{
}
}
必须注意,在使用SynchronizedDemo类时,只有方法是同步的,对这个类的两个成员的调用并没有同步。
警告:
SyncRoot模式可能使线程安全产生负面影响。.NET 1.0集合类实现了SyncRoot模式;.NET 2.0的泛型集合类不再实现这个模式。
下面研究一下前面的例子。如果试图用SyncRoot模式锁定对属性的访问,使SharedState类变成线程安全的,仍会出现前面描述的竞态条件。
public class SharedState
{
private int state = 0;
private object syncRoot = new object();
public int State // there’s still a race condition, don’t do this!
{
get { lock (syncRoot) {return state; }}
set { lock (syncRoot) {state = value; }}
}
}
调用方法DoTheTask()的线程访问SharedState类的get存取器,以获得state的当前值,接着get存取器给state设置新值。在调用对象的get和set存取器期间,对象没有锁定,另一个线程可以获得临时值。
public void DoTheTask()
{
for (int i = 0; i < 50000; i++)
{
sharedState.State += 1;
}
}
所以,最好不改变SharedState类,让它没有线程安全性。
public class SharedState
{
private int state = 0;
public int State
{
get { return state; }
set { state = value; }
}
}
然后在方法DoTheTask()中,将lock语句添加到合适的地方:
public void DoTheTask()
{
for (int i = 0; i < 50000; i++)
{
lock (sharedState)
{
sharedState.State += 1;
}
}
}
这样,应用程序的结果就总是正确的:
summarized 1000000
警告:
在一个地方使用lock语句并不意味着,访问对象的其他线程都在等待。必须对每个访问共享状态的线程显式使用同步功能。
当然,还必须修改SharedState类的设计,将递增提供为一个原子操作。这是一个设计问题—— 什么是类的原子功能?
public class SharedState
{
private int state = 0;
private object syncRoot = new object();
public int State
{
get { return state; }
}
public int IncrementState()
{
lock (syncRoot)
{
return ++state;
}
}
}
提示:
上面锁定状态递增的最后一个例子,有一个使用Interlocked类的版本更快,如下所示。
18.6.2 Interlocked
Interlocked类用于使变量的简单语句原子化。i++不是线程安全的,它的操作包括从内存中获取一个值,给该值递增1,再将它存储回内存。这些操作都可能会被线程调度器打断。Interlocked类提供了以线程安全的方式递增、递减和交换值的方法。
Interlocked类提供的方法如表18-1所示。
表 18-1
Interlocked类的成员 |
说 明 |
Increment() |
Increment()方法递增一个变量,把结果存储到一个原子操作中 |
Decrement() |
Decrement()递减一个变量,并存储结果 |
Exchange() |
Exchange()将一个变量设置为指定的值,并返回变量的初始值 |
CompareExchange() |
CompareExchange()对两个变量进行相等比较,如果它们相等,就设置指定的值,返回初始值 |
Add() |
Add()对两个值执行相加操作,用结果替代第一个变量 |
Read() |
Read()方法用于在一个原子操作中从内存中读取64位值。在32位系统中,读取64位不是原子化的,而需要从两个内存地址中读取 在64位系统中,不需要Read()方法,因为访问64位是一个原子操作 |
与其他同步技术相比,使用Interlocked类会快得多。但是,它只能用于简单的同步问题。
例如,这里不使用lock语句锁定对someState变量的访问,把它设置为一个新值,以防它是空的,而可以使用Interlocked类,它比较快:
lock (this)
{
if (someState == null)
{
someState = newState;
}
}
这个功能相同、但比较快的版本使用了Interlocked.CompareExchange方法:
Interlocked.CompareExchange<SomeState>(ref someState, newState, null);
不在lock语句中执行递增操作:
public int State
{
get
{
lock (this)
{
return ++state;
}
}
}
而使用较快的Interlocked.Increment():
public int State
{
get
{
return Interlocked.Increment(ref state);
}
}
18.6.3 Monitor类
C#的lock语句由编译器解析为使用Monitor类。下面的lock语句:
lock (obj)
{
// synchronized region for obj
}
解析为调用Enter()方法,该方法会一直等待,直到线程获得对象的锁定为止。一次只有一个线程能成为对象锁定的拥有者。只要解除了锁定,线程就可以进入同步段。Monitor类的Exit()方法解除了锁定。无论在什么情况下解除该锁定(包括抛出异常的情况),Exit()方法都放在try块的finally处理程序中。
提示:
try/finally详见第13章。
Monitor.Enter(obj);
try
{
// synchronized region for obj
}
finally
{
Monitor.Exit(obj);
}
与C#的lock语句相比,Monitor类的主要优点是:可以添加一个等待获得锁定的超时值。这样就不会无限期地等待获得锁定,而可以使用TryEnter方法,给它传送一个超时值,确定等待获得锁定的最长时间。如果得到了obj的锁定,TryEnter方法就返回true,访问由对象obj锁定的状态。如果另一个线程锁定obj的时间超过了500毫秒,TryEnter方法就返回false,线程不再等待,而是执行其他操作。也许在以后,该线程会尝试再次获得该锁定。
if (Monitor.TryEnter(obj, 500))
{
try
{
// acquired the lock
// synchronized region for obj
}
finally
{
Monitor.Exit(obj);
}
}
else
{
// didn’t get the lock, do something else
}
18.6.4 等待句柄
WaitHandle是一个抽象基类,用于等待一个信号的设置。可以等待不同的信号,因为WaitHandle是一个基类,可以从中派生一些类。
在本章前面使用异步委托时,已经使用了WaitHandle。异步委托的方法BeginInvoke()返回一个实现了IAsycResult接口的对象。使用IAsycResult接口,可以用属性AsycWaitHandle访问WaitHandle。在调用WaitOne()方法时,线程会等待接收一个与等待句柄相关的信号。
static void Main()
{
TakesAWhileDelegate d1 = TakesAWhile;
IAsyncResult ar = d1.BeginInvoke(1, 3000, null, null);
while (true)
{
Console.Write(".");
if (ar.AsyncWaitHandle.WaitOne(50, false))
{
Console.WriteLine("Can get the result now");
break;
}
}
int result = d1.EndInvoke(ar);
Console.WriteLine("result: {0}", result);
}
WaitHandle类定义的、执行等待的方法如表18-2所示。
表 18-2
WaitHandle类的成员 |
说 明 |
WaitOne() |
WaitOne()是一个实例方法,利用它可以等待一个信号的发生。也可以为最大等待时间指定一个超时值 |
WaitAll() |
WaitAll()是一个静态方法,用于传送WaitHandle对象的数组,并等待所有的句柄发出信号 |
WaitAny() |
WaitAny()是一个静态方法,用于传送WaitHandle对象的数组,并等待其中一个句柄发出信号。这个方法返回发出信号的等待句柄对象的索引,以便确定可以在程序中继续执行什么功能。如果在句柄发出信号之前超时,WaitAny()就返回WaitTimeout |
使用SafeWaitHandle属性,还可以将一个内置句柄赋予一个操作系统资源,并等待该句柄。例如,可以指定一个SafeWaitHandle等待文件I/O操作的完成,或者指定定制的SafeTransactionHandle,参见第21章。
类Mutex、Event和Semaphore派生自基类WaitHandle,所以可以在等待时使用它们。
18.6.5 Mutex类
Mutex(mutual exclusion,互斥)是.NET Framework中提供同步访问多个进程的一个类。它非常类似于Monitor类,因为它们都只有一个线程能拥有锁定。只有一个线程能获得互斥锁定,访问受互斥锁定保护的同步代码区域。
在Mutex类的构造函数中,可以指定互斥锁定是否最初应由调用线程拥有,定义互斥锁定的名称,获得互斥锁定是否已存在的信息。在下面的示例代码中,第三个参数定义为输出参数,接收一个表示互斥锁定是否为新创建的布尔值。如果返回的值是false,就表示互斥锁定已经定义。互斥锁定可以在另一个进程中定义,因为操作系统知道有名称的互斥锁定,它由不同的进程共享。如果没有给互斥锁定指定名称,互斥锁定就是未命名的,不在不同的进程之间共享。
bool createdNew;
Mutex mutex = new Mutex(false, "ProCSharpMutex", out createdNew);
要打开已有的互斥锁定,还可以使用方法Mutex.OpenExisting(),它不需要用构造函数创建互斥锁定时需要的.NET权限。
Mutex类派生自基类WaitHandle,因此可以利用WaitOne()方法获得互斥锁定,在该过程中成为该互斥锁定的拥有者。调用ReleaseMutex()方法,即可释放互斥锁定。
if (mutex.WaitOne())
{
try
{
// synchronized region
}
finally
{
mutex.ReleaseMutex();
}
}
else
{
// some problem happened while waiting
}
由于系统知道有名称的互斥锁定,因此可以使用它禁止应用程序启动两次。在下面的Windows窗体应用程序中,调用了Mutex对象的构造函数。接着验证名称为SingletonWinAppMutex的互斥锁定是否存在。如果存在,应用程序就退出。
static class Program
{
[STAThread]
static void Main()
{
bool createdNew;
Mutex mutex = new Mutex(false, "SingletonWinAppMutex", out createdNew);
if (!createdNew)
{
MessageBox.Show("You can only start one instance of the application");
Application.Exit();
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
18.6.6 Semaphore类
旗语(Semaphore)锁定非常类似于互斥锁定,其区别是,旗语锁定可以同时由多个线程使用。旗语锁定是一种计数的互斥锁定。使用旗语锁定,可以定义允许同时访问受旗语锁定保护的资源的线程个数。如果有许多资源,且只允许一定数量的线程访问该资源,就可以使用旗语锁定。例如,要访问系统上的物理I/O端口,且有三个端口可用,就允许三个线程同时访问I/O端口,但第四个线程需要等待前三个线程中的一个释放资源。
在下面的示例程序中,Main()方法创建了6个线程和一个计数为4的旗语锁定。在Semaphore类的构造函数中,定义了锁定数的计数,它可以用旗语锁定(第二个参数)来获得,还定义了最初自由的锁定数(第一个参数)。如果第一个参数的值小于第二个参数,它们的差就是已经赋予线程的旗语锁定数。与互斥锁定一样,也可以给旗语锁定指定名称,使之在不同的进程之间共享。这里定义旗语锁定时没有指定名称,所以它只能在这个进程中使用。在创建了Semaphore对象之后,启动六个线程,它们都获得了相同的旗语锁定。
using System;
using System.Threading;
using System.Diagnostics;
namespace Wrox.ProCSharp.Threading
{
class Program
{
static void Main()
{
int threadCount = 6;
int semaphoreCount = 4;
Semaphore semaphore = new Semaphore(semaphoreCount, semaphoreCount);
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++)
{
threads[i] = new Thread(ThreadMain);
threads[i].Start(semaphore);
}
for (int i = 0; i < threadCount; i++)
{
threads[i].Join();
}
Console.WriteLine("All threads finished");
}
在线程的主方法ThreadMain()中,线程利用WaitOne()锁定了旗语。旗语锁定的计数是4,所以有四个线程可以获得锁定。线程5必须等待,这里还定义了最大等待时间为500毫秒。如果在该等待时间过后未能获得锁定,线程就把一个信息写入控制台,在循环中继续等待。只要获得了锁定,线程就把一个信息写入控制台,睡眠一段时间,然后解除锁定。在解除锁定时,一定要解除资源的锁定。这就是在finally处理程序中调用Semaphore类的Release()方法的原因。
static void ThreadMain(object o)
{
Semaphore semaphore = o as Semaphore;
Trace.Assert(semaphore != null, "o must be a Semaphore type");
bool isCompleted = false;
while (!isCompleted)
{
if (semaphore.WaitOne(600, false))
{
try
{
Console.WriteLine("Thread {0} locks the sempahore",
Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(2000);
}
finally
{
semaphore.Release();
Console.WriteLine("Thread {0} releases the semaphore",
Thread.CurrentThread.ManagedThreadId);
isCompleted = true;
}
}
else
{
Console.WriteLine("Timeout for thread {0}; wait again",
Thread.CurrentThread.ManagedThreadId);
}
}
}
}
}
运行应用程序,可以看到有四个线程获得了锁定。ID为7和8的线程需要等待。该等待会重复进行,直到四个获得锁定的线程之一解除了旗语锁定。
Thread 3 locks the sempahore
Thread 4 locks the sempahore
Thread 5 locks the sempahore
Thread 6 locks the sempahore
Timeout for thread 8; wait again
Timeout for thread 7; wait again
Timeout for thread 8; wait again
Timeout for thread 7; wait again
Timeout for thread 7; wait again
Timeout for thread 8; wait again
Thread 3 releases the semaphore
Thread 8 locks the sempahore
Thread 4 releases the semaphore
Thread 7 locks the sempahore
Thread 5 releases the semaphore
Thread 6 releases the semaphore
Thread 8 releases the semaphore
Thread 7 releases the semaphore
All threads finished
18.6.7 Events类
事件是另一个系统级的资源同步方法。为了在托管代码中使用系统事件,.NET Framework在System.Threading命名空间中提供了ManualResetEvent和AutoResetEvent类。
提示:
第7章介绍了C#中的event关键字,它与System.Threading命名空间中的Event类没有关系。event关键字基于委托,而上述两个Event类是.NET封装器,用于系统级的内置事件资源的同步。
可以使用事件通知其他线程:这里有一些数据,完成了一些操作等。事件可以发信号,也可以不发信号。使用前面介绍的WaitHandle类,线程可以等待处于发信号状态的事件。
调用Set()方法,即可使ManualResetEvent发信号。调用Reset()方法,可以使之返回不发信号的状态。如果多个线程等待一个事件发出信号,并调用了Set()方法,就释放所有等待的线程。另外,如果一个线程刚刚调用了WaitOne()方法,但事件已经发出了信号,等待的线程就可以继续等待。
AutoResetEvent也是通过Set()方法发信号。也可以使用Reset()方法使之返回不发信号的状态。但是,如果一个线程在等待自动重置的事件发信号,当第一个线程的等待结束时,该事件会自动变为不发信号的状态。这样,如果多个线程在等待事件发信号,就只有一个线程结束其等待状态,它不是等待时间最长的线程,而是优先级最高的线程。
为了演示AutoResetEvent类的事件,下面的ThreadTask类定义了Calculation()方法,这是线程的入口点。在这个方法中,线程接收用于计算的输入数据(由结构InputData定义),将结果写入变量result,它可以通过Result属性来访问。只要完成了计算(在随机的一段时间过后),就调用AutoResetEvent的Set()方法,向事件发信号。
public struct InputData
{
public int X;
public int Y;
public InputData(int x, int y)
{
this.X = x;
this.Y = y;
}
}
public class ThreadTask
{
private AutoResetEvent autoEvent;
private int result;
public int Result
{
get { return result; }
}
public ThreadTask(AutoResetEvent ev)
{
this.autoEvent = ev;
}
public void Calculation(object obj)
{
InputData data = (InputData)obj;
Console.WriteLine("Thread {0} starts calculation",
Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(new Random().Next(3000));
result = data.X + data.Y;
// signal the event - completed!
Console.WriteLine("Thread {0} is ready",
Thread.CurrentThread.ManagedThreadId);
autoEvent.Set();
}
}
程序的Main()方法定义了包含四个AutoResetEvent对象的数组和包含四个ThreadTask对象的数组。每个ThreadTask在构造函数中用一个AutoResetEvent对象初始化,这样每个线程在完成时都有自己的事件对象来发信号。现在使用ThreadPool类调用QueueUserWorkItem()方法,让后台线程执行计算任务。
class Program
{
static void Main()
{
int taskCount = 4;
AutoResetEvent[] autoEvents = new AutoResetEvent[taskCount];
ThreadTask[] tasks = new ThreadTask[taskCount];
for (int i = 0; i < taskCount; i++)
{
autoEvents[i] = new AutoResetEvent(false);
tasks[i] = new ThreadTask(mevents[i]);
ThreadPool.QueueUserWorkItem(tasks[i].Calculation,
new InputData(i + 1, i + 3));
}
//...
WaitHandle类现在用于等待数组中的任意一个事件。WaitAny()等待向任意一个事件发信号。从WaitAny()返回的index匹配数组中传送给WaitAny()的事件,以提供向哪个事件发信号的信息,并从这个事件中读取结果。
for (int i = 0; i < taskCount; i++)
{
int index = WaitHandle.WaitAny(autoEvents);
if (index == WaitHandle.WaitTimeout)
{
Console.WriteLine("Timeout!!");
}
else
{
Console.WriteLine("finished task for {0}, result: {1}", index,
tasks[index].Result);
}
}
}
}
启动应用程序,可以看到线程在进行计算,设置事件,通知主线程,它可以读取结果了。由于随机时间、是调试版本还是发布版本、以及硬件的不同,会看到不同的顺序,线程池中有不同数量的线程在执行任务。这里重用了线程池中的线程4,完成了两个任务,因为它比较快,能第一个完成计算。
Thread 3 starts calculation
Thread 4 starts calculation
Thread 5 starts calculation
Thread 4 is ready
finished task for 1, result: 6
Thread 4 starts calculation
Thread 3 is ready
finished task for 0, result: 4
Thread 4 is ready
finished task for 3, result: 10
Thread 5 is ready
finished task for 2, result: 8
18.7 COM单元
线程总是一个与COM对象相关的重要主题。COM定义了单元模型。在单线程单元(STA)中,COM运行库会执行同步。多线程单元(MTA)的性能比较好,但没有COM运行库的同步功能。
COM组件在注册表中设置了一个配置值,从而定义了它需要的单元模型。以线程安全的方式开发的COM组件支持MTA。多个线程可以同时访问这个组件,该组件必须自己实现同步。不能处理多个线程的COM组件需要使用STA。在STA中,只有一个线程(总是这样)访问组件。另一个线程只有使用代理,给连接到COM对象上的线程发送一个Windows信息,才能访问组件。STA使用该Windows信息进行同步。
VB6组件仅支持STA模型。用both选项配置的COM组件支持STA和MTA。
COM组件定义了对单元的要求,而实例化COM对象的线程定义了运行它的单元。这个单元应该就是COM需要的单元。
.NET线程默认运行在MTA上。在Windows应用程序的Main()方法中,有时可以看到特性[STAThread]。这个特性指定,主线程加入STA。Windows窗体应用程序需要一个STA线程。
[STAThread]
static void Main()
{
//...
在创建新线程时,可以将[STAThread]或[MTAThread]特性应用于线程的入口点方法,或调用Thread类的SetApartmentTherad()方法,来定义单元模型,之后启动线程:
Thread t1 = new Thread(DoSomeWork);
t1.SetApartmentState(ApartmentState.STA);
t1.Start();
使用GetApartmentThread()方法可以获得线程的单元。
提示:
第23章介绍了.NET与COM组件的交互操作和COM单元模型的详细内容。
18.8 BackgroundWorker组件
如果没有编写过Windows应用程序,就可以跳过这一节,继续阅读后面的内容。注意,在Windows应用程序中使用线程会增加复杂性,应在阅读了Windows窗体的章节(第28~30章)或WPF(第31章)后,再阅读本节。无论如何,从Windows窗体的角度来看,这里演示的Windows窗体应用程序都是非常简单的。
Windows窗体和WPF控件绑定到一个线程上。对于每个控件,都只能从创建该控件的线程中调用方法。也就是说,如果有一个后台线程,就不能直接在这个线程中访问UI控件。
在Windows窗体控件中,唯一可以从非创建线程中调用的是方法Invoke()、BeginInvoke()、EndInvoke()和属性InvokeRequired。BeginInvoke()和EndInvoke()是Invoke()的异步版本。这些方法会切换到创建控件的线程上,调用赋予一个委托参数的方法,该委托参数可以传送给这些方法。这些方法的使用并不简单,这就是.NET 2.0新组件BackgroundWorker和新异步模式一起开发的原因。
类BackgroundWorker定义了如表18-3所示的方法、属性和事件。
表 18-3
BackgroundWorker类的成员 |
说明 |
IsBusy |
在激活异步任务时,属性IsBusy返回true |
CancellationPending |
在调用CancelAsync()方法后,属性CancellationPending返回true。如果这个属性设置为true,异步任务就应停止其工作 |
RunWorkerAsync() DoWork |
方法RunWorkerAsync()引发DoWork事件,在一个单独的线程中启动异步任务 |
CancelAsync() WorkerSupportCancellation |
如果启用了取消功能(将WorkerSupportCancellation属性设置为true),就可以用CancelAsync()方法取消异步任务 |
ReportProgress() ProgressChanged WorkerReportsProgress |
如果WorkerReportsProgress属性设置为true,BackgroundWorker就可以给出异步任务进度的临时反馈信息。调用ReportProgress()方法,异步任务可以提供了已完成的工作百分数反馈信息,之后这个方法会引发ProgressChanged事件 |
RunWorkerCompleted |
无论取消与否,只要完成了异步任务,就引发RunWorkerCompleted事件 |
下面的示例程序演示了BackgroundWorker控件在Windows窗体应用程序中的用法,它执行一个需要一定时间的任务。创建一个新的Windows窗体应用程序,在窗体上添加三个标签控件、三个文本框控件、两个按钮控件、一个进度条控件和一个BackgroundWorker控件,如图18-3所示。
图 18-3
按表18-4配置控件的属性。
表 18-4
控 件 |
属性和事件 |
值 |
标签 |
Text |
X: |
文本框 |
Name |
textbox |
(续表)
控 件 |
属性和事件 |
值 |
标签 |
Text |
Y: |
文本框 |
Name |
textBoxY |
标签 |
Text |
Result: |
文本框 |
Name |
textBoxResult |
按钮 |
Name |
buttonCalculate |
|
Text |
Calculate |
|
Click |
OnCalculate |
按钮 |
Name |
buttonCancel |
|
Text |
Cancel |
|
Enabled |
False |
|
Click |
OnCancel |
进度条 |
Name |
ProgressBar |
BackgroundWorker |
Name |
backgroundWorker |
|
DoWork |
OnDoWork |
|
RunWorkerCompleted |
OnWorkCompleted |
在项目中添加结构CalcInput。这个结构用于包含文本框控件中的输入数据。
public struct CalcInput
{
public CalcInput(int x, int y)
{
this.x = x;
this.y = y;
}
public int x;
public int y;
}
方法OnCalculate()是按钮控件buttonCalculate的Click事件处理程序。在执行过程中,按钮buttonCalculate被禁用,所以在计算完成之前,用户不能再次单击该按钮。要启动BackgroundWorker,可调用方法RunWorkerAsync()。BackgroundWorker使用线程池中的一个线程来计算。RunWorkerAsync()需要将输入参数传送给DoWork事件的处理程序。
private void OnCalculate(object sender, EventArgs e)
{
this.buttonCalculate.Enabled = false;
this.textBoxResult.Text = String.Empty;
this.buttonCancel.Enabled = true;
this.progressBar.Value = 0;
backgroundWorker.RunWorkerAsync(new CalcInput(
int.Parse(this.textBoxX.Text), int.Parse(this.textBoxY.Text)));
}
方法OnDoWork()连接到BackgroundWorker控件的DoWork事件上。在DoWorkEventArgs中,通过属性Argument接收输入参数。其执行代码模拟的功能需要一定的执行时间和5秒的睡眠时间。在睡眠时间过后,将计算的结果写入DoEventArgs的Result属性。如果将计算和睡眠操作添加到OnCalculate()方法中,在用户输入时,Windows应用程序就不能获得用户输入。但是,这里使用一个单独的线程,用户界面仍是激活的。
private void OnDoWork(object sender, DoWorkEventArgs e)
{
CalcInput input = (CalcInput)e.Argument;
Thread.Sleep(5000);
e.Result = input.x + input.y;
}
方法OnDoWork()完成后,BackgroundWorker控件就引发RunWorkerCompleted事件。方法OnWorkCompleted()与这个事件相关。这里从RunWorkerCompletedEventArgs参数的Result属性中接收结果,该结果写入文本框控件result中。在引发该事件时,BackgroundWorker控件会把控制权交给创建它的线程,所以不需要使用Windows窗体控件的Invoke方法,而可以直接调用Windows窗体控件的属性和方法。
private void OnWorkCompleted(object sender, RunWorkerCompletedEventArgs e)
{
this.textBoxResult.Text = e.Result.ToString();
this.buttonCalculate.Enabled = true;
this.buttonCancel.Enabled = false;
this.progressBar.Value = 100;
}
现在可以测试应用程序,看看计算过程是否独立于UI线程,UI仍是激活的,窗体可以四处移动。但是,取消和进度条功能仍需要实现。
18.8.1 激活取消功能
要激活取消功能,以停止线程的运行,必须把BackgroundWorker控件的属性WorkerSupportsCancellation设置为true。接着,实现与按钮buttonCancel的Click事件相关的OnCancel处理程序。BackgroundWorker控件的CancelAsync()方法可以取消正在进行的异步任务。
private void OnCancel(object sender, EventArgs e)
{
backgroundWorker.CancelAsync();
}
异步任务不会自动取消。在执行异步任务的OnDoWork()处理程序中,必须修改其实现代码,检查BackgroundWorker控件的属性CancellationPending。这个属性在调用CancelAsync()方法时设置。如果要执行取消操作,就把DoWorkEventArgs的Cancel属性设置为true,退出处理程序。
private void OnDoWork(object sender, DoWorkEventArgs e)
{
CalcInput input = (CalcInput)e.Argument;
for (int i = 0; i < 10; i++)
{
Thread.Sleep(500);
if (backgroundWorker.CancellationPending)
{
e.Cancel = true;
return;
}
}
e.Result = input.x + input.y;
}
如果异步方法成功完成或被取消,就调用完成处理程序OnWorkCompleted()。如果取消了该方法,就不能访问Result属性,因为这会抛出一个InvalidOperationException异常,并显示操作被取消的信息。所以必须检查RunWorkerCompletedEventArgs的Cancelled属性,并根据不同的情况执行不同的操作:
private void OnWorkCompleted(object sender, RunWorkerCompletedEventArgs e)
{
if (e.Cancelled)
{
this.textBoxResult.Text = "Cancelled";
}
else
{
this.textBoxResult.Text = e.Result.ToString();
}
this.buttonCalculate.Enabled = true;
this.buttonCancel.Enabled = false;
}
再次运行应用程序,就可以在用户界面上取消异步进程了。
18.8.2 激活进度功能
为了获得用户界面的进度信息,必须将BackgroundWorker控件的WorkerReports- Progress属性设置为true。
在OnDoWork方法中,可以用ReportProgress()方法报告BackgroundWorker控件的进度。
private void OnDoWork(object sender, DoWorkEventArgs e)
{
CalcInput input = (CalcInput)e.Argument;
for (int i = 0; i < 10; i++)
{
Thread.Sleep(500);
backgroundWorker.ReportProgress(i * 10);
if (backgroundWorker.CancellationPending)
{
e.Cancel = true;
return;
}
}
e.Result = input.x + input.y;
}
方法ReportProgress()引发BackgroundWorker控件的ProgressChanged事件,这个事件会将控件改为UI线程。
在ProgressChanged事件中添加方法OnProgressChanged(),在其实现代码中,给进度条控件设置一个从ProgressChangedEventArgs的ProgressPercentage属性中接收的新值。
private void OnProgressChanged(object sender, ProgressChangedEventArgs e)
{
this.progressBar.Value = e.ProgressPercentage;
}
在OnWorkCompleted()事件处理程序中,进度条最终设置为100%。
private void OnWorkCompleted(object sender, RunWorkerCompletedEventArgs e)
{
if (e.Cancelled)
{
this.textBoxResult.Text = "Cancelled";
}
else
{
this.textBoxResult.Text = e.Result.ToString();
}
this.buttonCalculate.Enabled = true;
this.buttonCancel.Enabled = false;
this.progressBar.Value = 100;
}
图18-4是正在计算的应用程序。
图 18-4
18.9 小结
本章介绍了如何通过System.Threading命名空间编写多线程应用程序。在应用程序中使用多线程要仔细规划。太多的线程会导致资源问题,线程不足又会使应用程序执行缓慢,执行效果也不好。
.NET Framework中的System.Threading命名空间允许处理线程,但.NET Framework并没有完成多线程中所有困难的任务。我们必须考虑线程的优先级和同步问题。本章讨论了这些问题,介绍了如何在C#应用程序中为它们编码。还论述了与死锁和竞态条件相关的问题。
如果要在C#应用程序中使用多线程功能,就必须仔细规划。
下面是关于线程的一些规则:
● 尝试使同步要求降到最少。同步是非常复杂的,会阻碍线程的运行。如果尝试避免共享状态,就可以避免同步。当然,这并不总是可行。
● 类的静态成员应是线程安全的。.NET Framework中的类通常是这样。
● 实例状态不需要是线程安全的。要获得最佳的性能,最好根据需要在类的外部使用同步功能,且不对类的每个成员使用该功能。.NET Framework类的实例成员通常不是线程安全的。Framework中每个类的这方面信息可以在Thread Safety部分找到。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步