现在的位置: 首页 > 综合 > 正文

C#中的多线程-线程同步基础

2013年10月17日 ⁄ 综合 ⁄ 共 10857字 ⁄ 字号 评论关闭
第二部分:线程同步基础
同步要领
下面的表格列展了.NET对协调或同步线程动作的可用的工具:

简易阻止方法

构成

目的

Sleep

阻止给定的时间周期

Join

等待另一个线程完成

锁系统

构成

目的

跨进程?

速度

lock

确保只有一个线程访问某个资源或某段代码。

Mutex

确保只有一个线程访问某个资源或某段代码。
可被用于防止一个程序的多个实例同时运行。

中等

Semaphore

确保不超过指定数目的线程访问某个资源或某段代码。

中等

(同步的情况下也提够自动锁。)

信号系统

构成

目的

跨进程?

速度

EventWaitHandle

允许线程等待直到它受到了另一个线程发出信号。

中等

Wait 和 Pulse*

允许一个线程等待直到自定义阻止条件得到满足。

中等

非阻止同步系统*

构成

目的

跨进程?

速度

Interlocked*

完成简单的非阻止原子操作。

是(内存共享情况下)

非常快

volatile*

允许安全的非阻止在锁之外使用个别字段。

非常快

* 代表页面将转到第四部分

阻止 (Blocking)
当一个线程通过上面所列的方式处于等待或暂停的状态,被称为被阻止。一旦被阻止,线程立刻放弃它被分配的CPU时间,将它的ThreadState属性添加为WaitSleepJoin状态,不在安排时间直到停止阻止。停止阻止在任意四种情况下发生(关掉电脑的电源可不算!):

阻止的条件已得到满足
操作超时(如果timeout被指定了)
通过Thread.Interrupt中断了
通过Thread.Abort放弃了
当线程通过(不建议)Suspend 方法暂停,不认为是被阻止了。

休眠 和 轮询
调用Thread.Sleep阻止当前的线程指定的时间(或者直到中断):

static void Main() {
Thread.Sleep (0); // 释放CPU时间片
Thread.Sleep (1000); // 休眠1000毫秒
Thread.Sleep (TimeSpan.FromHours (1)); // 休眠1小时
Thread.Sleep (Timeout.Infinite); // 休眠直到中断
}
更确切地说,Thread.Sleep放弃了占用CPU,请求不在被分配时间直到给定的时间经过。Thread.Sleep(0)放弃CPU的时间刚刚够其它在时间片队列里的活动线程(如果有的话)被执行。

Thread.Sleep在阻止方法中是唯一的暂停汲取Windows Forms程序的Windows消息的方法,或COM环境中用于单元模式。这在Windows Forms程序中是一个很大的问题,任何对主UI线程的阻止都将使程序失去相应。因此一般避免这样使用,无论信息汲取是否被“技术地”暂定与否。由COM遗留下来的宿主环境更为复杂,在一些时候它决定停止,而却保持信息的汲取存活。微软的 Chris Brumm 在他的博客中讨论这个问题。(搜索: 'COM "Chris Brumme"')

线程类同时也提供了一个SpinWait方法,它使用轮询CPU而非放弃CPU时间的方式,保持给定的迭代次数进行“无用地繁忙”。50迭代可能等同于停顿大约一微秒,虽然这将取决于CPU的速度和负载。从技术上讲,SpinWait并不是一个阻止的方法:一个处于spin-waiting的线程的ThreadState不是WaitSleepJoin状态,并且也不会被其它的线程过早的中断(Interrupt)。SpinWait很少被使用,它的作用是等待一个在极短时间(可能小于一微秒)内可准备好的可预期的资源,而不用调用Sleep方法阻止线程而浪费CPU时间。不过,这种技术的优势只有在多处理器计算机:对单一处理器的电脑,直到轮询的线程结束了它的时间片之前,一个资源没有机会改变状态,这有违它的初衷。并且调用SpinWait经常会花费较长的时间这本身就浪费了CPU时间。

阻止 vs. 轮询
线程可以等待某个确定的条件来明确轮询使用一个轮询的方式,比如:

while (!proceed);
或者:

while (DateTime.Now list = new List ();

void Test() {
lock (list) {
list.Add ("Item 1");
...
一个专门字段是常用的(如在先前的例子中的locker) , 因为它可以精确控制锁的范围和粒度。用对象或类本身的类型作为一个同步对象,即:

lock (this) { ... }
或:

lock (typeof (Widget)) { ... } // 保护访问静态
是不好的,因为这潜在的可以在公共范围访问这些对象。

锁并没有以任何方式阻止对同步对象本身的访问,换言之,x.ToString()不会由于另一个线程调用lock(x) 而被阻止,两者都要调用lock(x) 来完成阻止工作。

嵌套锁定
线程可以重复锁定相同的对象,可以通过多次调用Monitor.Enter或lock语句来实现。当对应编号的Monitor.Exit被调用或最外面的lock语句完成后,对象那一刻被解锁。这就允许最简单的语法实现一个方法的锁调用另一个锁:

static object x = new object();

static void Main() {
lock (x) {
Console.WriteLine ("I have the lock");
Nest();
Console.WriteLine ("I still have the lock");
}
在这锁被释放
}

static void Nest() {
lock (x) {
...
}
释放了锁?没有完全释放!
}
线程只能在最开始的锁或最外面的锁时被阻止。

何时进行锁定
作为一项基本规则,任何和多线程有关的会进行读和写的字段应当加锁。甚至是极平常的事情——单一字段的赋值操作,都必须考虑到同步问题。在下面的例子中Increment和Assign 都不是线程安全的:

class ThreadUnsafe {
static int x;
static void Increment() { x++; }
static void Assign() { x = 123; }
}
下面是Increment 和 Assign 线程安全的版本:

class ThreadUnsafe {
static object locker = new object();
static int x;

static void Increment() { lock (locker) x++; }
static void Assign() { lock (locker) x = 123; }
}
作为锁定另一个选择,在一些简单的情况下,你可以使用非阻止同步,在第四部分讨论(即使像这样的语句需要同步的原因)。

锁和原子操作
如果有很多变量在一些锁中总是进行读和写的操作,那么你可以称之为原子操作。我们假设x 和 y不停地读和赋值,他们在锁内通过locker锁定:

lock (locker) { if (x != 0) y /= x; }
你可以认为x 和 y 通过原子的方式访问,因为代码段没有被其它的线程分开 或 抢占,别的线程改变x 和 y是无效的输出,你永远不会得到除数为零的错误,保证了x 和 y总是被相同的排他锁访问。

性能考量
锁定本身是非常快的,一个锁在没有堵塞的情况下一般只需几十纳秒(十亿分之一秒)。如果发生堵塞,任务切换带来的开销接近于数微秒(百万分之一秒)的范围内,尽管在线程重组实际的安排时间之前它可能花费数毫秒(千分之一秒)。而相反,与此相形见绌的是该使用锁而没使用的结果就是带来数小时的时间,甚至超时。

如果耗尽并发,锁定会带来反作用,死锁和争用锁,耗尽并发由于太多的代码被放置到锁语句中了,引起其它线程不必要的被阻止。死锁是两线程彼此等待被锁定的内容,导致两者都无法继续下去。争用锁是两个线程任一个都可以锁定某个内容,如果“错误”的线程获取了锁,则导致程序错误。

对于太多的同步对象死锁是非常容易出现的症状,一个好的规则是开始于较少的锁,在一个可信的情况下涉及过多的阻止出现时,增加锁的粒度。

线程安全
线程安全的代码是指在面对任何多线程情况下,这代码都没有不确定的因素。线程安全首先完成锁,然后减少在线程间交互的可能性。

一个线程安全的方法,在任何情况下可以可重入式调用。通用类型在它们中很少是线程安全的,原因如下:

完全线程安全的开发是重要的,尤其是一个类型有很多字段(在任意多线程上下文中每个字段都有潜在的交互作用)的情况下。
线程安全带来性能损失(要付出的,在某种程度上无论与否类型是否被用于多线程)。
一个线程安全类型不一定能使程序使用线程安全,有时参与工作后者可使前者变得冗余。
因此线程安全经常只在需要实现的地方来实现,为了处理一个特定的多线程情况。

不过,有一些方法来“欺骗”,有庞大和复杂的类安全地运行在多线程环境中。一种是牺牲粒度包含大段的代码——甚至在排他锁中访问全局对象,迫使在更高的级别上实现串行化访问。这一策略也很关键,让非线程安全的对象用于线程安全代码中,避免了相同的互斥锁被用于保护对在非线程安全对象的所有的属性、方法和字段的访问。

原始类型除外,很少的.NET framework类型实例相比于并发的只读访问,是线程安全的。责任在开放人员实现线程安全代表性地使用互斥锁。

另一个方式欺骗是通过最小化共享数据来最小化线程交互。这是一个很好的途径,被暗中地用于“弱状态”的中间层程序和web服务器。自多个客户端请求同时到达,每个请求来自它自己的线程(效力于ASP.NET,Web服务器或者远程体系结构),这意味着它们调用的方法一定是线程安全的。弱状态设计(因伸缩性好而流行)本质上限制了交互的能力,因此类不能够在每个请求间持久保留数据。线程交互仅限于可以被选择创建的静态字段,多半是在内存里缓存常用数据和提供基础设施服务,例如认证和审核。

线程安全与.NET Framework类型
锁定可被用于将非线程安全的代码转换成线程安全的代码。好的例子是在.NET framework方面,几乎所有非初始类型的实例都不是线程安全的,而如果所有的访问给定的对象都通过锁进行了保护的话,他们可以被用于多线程代码中。看这个例子,两个线程同时为相同的List增加条目,然后枚举它:

class ThreadSafe {
static List list = new List ();

static void Main() {
new Thread (AddItems).Start();
new Thread (AddItems).Start();
}

static void AddItems() {
for (int i = 0; i 0)
worker.Interrupt();
这不是一个线程安全的方式,因为可能被抢占了在if语句和worker.Interrupt间。

随意中断线程是危险的,因为任何框架或第三方方法在调用堆栈时可能会意外地在已订阅的代码上收到中断。这一切将被认为是线程被暂时阻止在一个锁中或同步资源中,并且所有挂起的中断将被踢开。如果这个方法没有被设计成可以被中断(没有适当处理finally块)的对象可能剩下无用的状态,或资源不完全地被释放。

中断一个线程是安全的,当你知道它确切的在哪的时候。稍后我们讨论 信号系统,它提供这样的一种方式。

Abort方法
被阻止的线程也可以通过Abort方法被强制释放,这与调用Interrupt相似,除了用ThreadAbortException异常代替了ThreadInterruptedException异常,此外,异常将被重新抛出在catch里(在试图以有好方式处理异常的时候),直到Thread.ResetAbort在catch中被调用;在这期间线程的ThreadState为AbortRequested。

在Interrupt 与 Abort 之间最大不同在于它们调用一个非阻止线程所发生的事情。Interrupt继续工作直到下一次阻止发生,Abort在线程当前所执行的位置(可能甚至不在你的代码中)抛出异常。终止一个非阻止的线程会带来严重的后果,这在后面的 “终止线程”章节中将详细讨论。

线程状态

图1: 线程状态关系图

你可以通过ThreadState属性获取线程的执行状态。图1将ThreadState列举为“层”。ThreadState被设计的很恐怖,它以按位计算的方式组合三种状态“层”,每种状态层的成员它们间都是互斥的,下面是所有的三种状态“层”:

运行 (running) / 阻止 (blocking) / 终止 (aborting) 状态(图1显示)
后台 (background) / 前台 (foreground) 状态 (ThreadState.Background)
不建议使用的Suspend 方法(ThreadState.SuspendRequested 和 ThreadState.Suspended)挂起的过程
总的来说,ThreadState是按位组合零或每个状态层的成员!一个简单的ThreadState例子:

Unstarted
Running
WaitSleepJoin
Background, Unstarted
SuspendRequested, Background, WaitSleepJoin
(所枚举的成员有两个从来没被用过,至少是当前CLR实现上:StopRequested 和 Aborted。)

还有更加复杂的,ThreadState.Running潜在的值为0 ,因此下面的测试不工作:

if ((t.ThreadState & ThreadState.Running) > 0) ...
你必须用按位与非操作符来代替,或者使用线程的IsAlive属性。但是IsAlive可能不是你想要的,它在被阻止或挂起的时候返回true(只有在线程未开始或已结束时它才为true)。

假设你避开不推荐使用的Suspend 和 Resume方法,你可以写一个helper方法除去所有除了第一种状态层的成员,允许简单测试计算完成。线程的后台状态可以通过IsBackground 独立地获得,所以实际上只有第一种状态层拥有有用的信息。

public static ThreadState SimpleThreadState (ThreadState ts)
{
return ts & (ThreadState.Aborted | ThreadState.AbortRequested |
ThreadState.Stopped | ThreadState.Unstarted |
ThreadState.WaitSleepJoin);
}
ThreadState对调试或程序概要分析是无价之宝,与之不相称的是多线程的协同工作,因为没有一个机制存在:通过判断ThreadState来执行信息,而不考虑ThreadState期间的变化。

等待句柄
lock语句(也称为Monitor.Enter / Monitor.Exit)是线程同步结构的一个例子。当lock对一段代码或资源实施排他访问时, 有些同步任务是笨拙的或难以实现的,比如说传输信号给等待的工作线程开始任务。

Win32 API拥有丰富的同步系统,这在.NET framework以EventWaitHandle, Mutex 和 Semaphore类展露出来。而一些比有些更有用:例如Mutex类,在EventWaitHandle提供唯一的信号功能时,大多会成倍提高lock的效率。

这三个类都依赖于WaitHandle类,尽管从功能上讲, 它们相当的不同。但它们做的事情都有一个共同点,那就是,被“点名”,这允许它们绕过操作系统进程工作,而不是只能在当前进程里绕过线程。

EventWaitHandle有两个子类:AutoResetEvent 和 ManualResetEvent(不涉及到C#中的事件或委托)。这两个类都派生自它们的基类:它们仅有的不同是它们用不同的参数调用基类的构造函数。

性能方面,使用Wait Handles系统开销会花费在较小微秒间,不会在它们使用的上下文中产生什么后果。

AutoResetEvent在WaitHandle中是最有用的的类,它连同lock 语句是一个主要的同步结构。

AutoResetEvent
AutoResetEvent就像一个用票通过的旋转门:插入一张票,让正确的人通过。类名字里的“auto”实际上就是旋转门自动关闭或“重新安排”后来的人让其通过。一个线程等待或阻止通过在门上调用WaitOne方法(直到等到这个“one”,门才开) ,票的插入则由调用Set方法。如果由许多线程调用WaitOne,在门前便形成了队列,一张票可能来自任意某个线程——换言之,任何(非阻止)线程要通过AutoResetEvent对象调用Set方法来释放一个被阻止的的线程。

如果Set调用时没有任何线程处于等待状态,那么句柄保持打开直到某个线程调用了WaitOne 。这个行为避免了在线程起身去旋转门和线程插入票(哦,插入票是非常短的微秒间的事,真倒霉,你将必须不确定地等下去了!)间的竞争。但是在没人等的时候重复地在门上调用Set方法不会允许在一队人都通过,在他们到达的时候:仅有下一个人可以通过,多余的票都被“浪费了"。

WaitOne 接受一个可选的超时参数——当等待以超时结束时这个方法将返回false,WaitOne在等待整段时间里也通知离开当前的同步内容,为了避免过多的阻止发生。

Reset方法提供在没有任何等待或阻止的时候关闭旋转门——它应该是开着的。

AutoResetEvent可以通过2种方式创建,第一种是通过构造函数:

EventWaitHandle wh = new AutoResetEvent (false);
如果布尔参数为真,Set方法在构造后立刻被自动的调用,另一个方法是通过它的基类EventWaitHandle:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto);
EventWaitHandle的构造器也允许创建ManualResetEvent(用EventResetMode.Manual定义).

在Wait Handle不在需要时候,你应当调用Close方法来释放操作系统资源。但是,如果一个Wait Handle将被用于程序(就像这一节的大多例子一样)的生命周期中,你可以发点懒省略这个步骤,它将在程序域销毁时自动的被销毁。

接下来这个例子,一个线程开始等待直到另一个线程发出信号。

class BasicWaitHandle {
static EventWaitHandle wh = new AutoResetEvent (false);

static void Main() {
new Thread (Waiter).Start();
Thread.Sleep (1000); // 等一会...
wh.Set(); // OK ——唤醒它
}

static void Waiter() {
Console.WriteLine ("Waiting...");
wh.WaitOne(); // 等待通知
Console.WriteLine ("Notified");
}
}
Waiting... (pause) Notified.

创建跨进程的EventWaitHandle
EventWaitHandle的构造器允许以“命名”的方式进行创建,它有能力跨多个进程。名称是个简单的字符串,可能会无意地与别的冲突!如果名字使用了,你将引用相同潜在的EventWaitHandle,除非操作系统创建一个新的,看这个例子:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto,
"MyCompany.MyApp.SomeName");
如果有两个程序都运行这段代码,他们将彼此可以发送信号,等待句柄可以跨这两个进程中的所有线程。

任务确认
设想我们希望在后台完成任务,不在每次我们得到任务时再创建一个新的线程。我们可以通过一个轮询的线程来完成:等待一个任务,执行它,然后等待下一个任务。这是一个普遍的多线程方案。也就是在创建线程上切分内务操作,任务执行被序列化,在多个工作线程和过多的资源消耗间排除潜在的不想要的操作。

我们必须决定要做什么,但是,如果当新的任务来到的时候,工作线程已经在忙之前的任务了,设想这种情形下我们需选择阻止调用者直到之前的任务被完成。像这样的系统可以用两个AutoResetEvent对象实现:一个“ready”AutoResetEvent,当准备好的时候,它被工作线程调用Set方法;和“go”AutoResetEvent,当有新任务的时候,它被调用线程调用Set方法。在下面的例子中,一个简单的string字段被用于决定任务(使用了volatile 关键字声明,来确保两个线程都可以看到相同版本):

class AcknowledgedWaitHandle {
static EventWaitHandle ready = new AutoResetEvent (false);
static EventWaitHandle go = new AutoResetEvent (false);
static volatile string task;

static void Main() {
new Thread (Work).Start();

// 给工作线程发5次信号
for (int i = 1; i tasks = new Queue();

public ProducerConsumerQueue() {
worker = new Thread (Work);
worker.Start();
}

public void EnqueueTask (string task) {
lock (locker) tasks.Enqueue (task);
wh.Set();
}

public void Dispose() {
EnqueueTask (null); // 告诉消费者退出
worker.Join(); // 等待消费者线程完成
wh.Close(); // 释放任何OS资源
}

void Work() {
while (true) {
string task = null;
lock (locker)
if (tasks.Count > 0) {
task = tasks.Dequeue();
if (task == null) return;
}
if (task != null) {
Console.WriteLine ("Performing task: " + task);
Thread.Sleep (1000); // 模拟工作...
}
else
wh.WaitOne(); // 没有任务了——等待信号
}
}
}
下面是一个主方法测试这个队列:

class Test {
static void Main() {
using (ProducerConsumerQueue q = new ProducerConsumerQueue()) {
q.EnqueueTask ("Hello");
for (int i = 0; i 0) safeInstance.RemoveAt (0);
除非使用这代码的类本身是一个同步的ContextBoundObject!

同步环境可以扩展到超过一个单独对象的区域。默认地,如果一个同步对象被实例化从在另一段代码之内,它们拥有共享相同的同步环境(换言之,一个大锁!)。这个行为可以由改变Synchronization特性的构造器的参数来指定。使用SynchronizationAttribute类定义的常量之一:

常量

含义

NOT_SUPPORTED

相当于不使用同步特性

SUPPORTED

如果从另一个同步对象被实例化,则合并已存在的同步环境,否则只剩下非同步。

REQUIRED
(默认)

如果从另一个同步对象被实例化,则合并已存在的同步环境,否则创建一个新的同步环境。

REQUIRES_NEW

总是创建新的同步环境

所以如果SynchronizedA的实例被实例化于SynchronizedB的对象中,如果SynchronizedB像下面这样声明的话,它们将有分离的同步环境:

[Synchronization (SynchronizationAttribute.REQUIRES_NEW)]
public class SynchronizedB : ContextBoundObject { ...
越大的同步环境越容易管理,但是减少机会对有用的并发。换个有限的角度,分离的同步环境会造成死锁,看这个例子:

[Synchronization]
public class Deadlock : ContextBoundObject {
public DeadLock Other;
public void Demo() { Thread.Sleep (1000); Other.Hello(); }
void Hello() { Console.WriteLine ("hello"); }
}

public class Test {
static void Main() {
Deadlock dead1 = new Deadlock();
Deadlock dead2 = new Deadlock();
dead1.Other = dead2;
dead2.Other = dead1;
new Thread (dead1.Demo).Start();
dead2.Demo();
}
}
因为每个Deadlock的实例在Test内创建——一个非同步类,每个实例将有它自己的同步环境,因此,有它自己的锁。当它们彼此调用的时候,不会花太多时间就会死锁(确切的说是一秒!)。如果Deadlock 和 Test是由不同开发团队来写的,这个问题特别容易发生。别指望Test知道如何产生的错误,更别指望他们来解决它了。在死锁显而易见的情况下,这与使用明确的锁的方式形成鲜明的对比。

可重入性问题
线程安全方法有时候也被称为可重入式的,因为在它执行的时候可以被抢占部分线路,在另外的线程调用也不会带来坏效果。从某个意义上讲,术语线程安全 和 可重入式的是同义的或者是贴义的。

不过在自动锁方式上,如果Synchronization的参数可重入式的 为true的话,可重入性会有潜在的问题:

[Synchronization(true)]
同步环境的锁在执行离开上下文时被临时地释放。在之前的例子里,这将能预防死锁的发生;很明显很需要这样的功能。然而一个副作用是,在这期间,任何线程都可以自由的调用在目标对象(“重进入”的同步上下文)的上任何方法,而非常复杂的多线程中试图避免不释放资源是排在首位的。这就是可重入性的问题。

因为[Synchronization(true)]作用于类级别,这特性打开了对于非上下文的方法访问,由于可重入性问题使它们混入类的调用。

虽然可重入性是危险的,但有些时候它是不错的选择。比如:设想一个在其内部实现多线程同步的类,将逻辑工作线程运行在不同的语境中。在没有可重入性问题的情况下,工作线程在它们彼此之间或目标对象之间可能被无理地阻碍。

这凸显了自动同步的一个基本弱点:超过适用的大范围的锁定带来了其它情况没有带来的巨大麻烦。这些困难:死锁,可重入性问题和被阉割的并发,使另一个更简单的方案——手动的锁定变得更为合适。

【上篇】
【下篇】

抱歉!评论已关闭.