第二部分:线程同步基础1

2008-05-11 01:07

同步要领

下面的表格列展了.NET对协调或同步线程动作的可用的工具:

简易阻止方法

构成

目的

Sleep

阻止给定的时间周期

Join

等待另一个线程完成

锁系统

构成

目的

跨进程?

速度

lock

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

Mutex

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

中等

Semaphore

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

中等

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

信号系统

构成

目的

跨进程?

速度

EventWaitHandle

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

中等

Wait 和 Pulse*

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

中等

非阻止同步系统*

构成

目的

跨进程?

速度

Interlocked*

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

是(内存共享情况下)

非常快

volatile*

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

非常快

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

阻止 (Blocking)

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

当线程通过(不建议)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 < nextStartTime);

这是非常浪费CPU时间的:对于CLR和操作系统而言,线程进行了一个重要的计算,所以分配了相应的资源!在这种状态下的轮询线程不算是阻止,不像一个线程等待一个EventWaitHandle(一般使用这样的信号任务来构建)。

阻止和轮询组合使用可以产生一些变换:

while (!proceed) Thread.Sleep (x);    // "轮询休眠!"

x越大,CPU效率越高,折中方案是增大潜伏时间,任何20ms的花费是微不足道的,除非循环中的条件是极其复杂的。

除了稍有延迟,这种轮询和休眠的方式可以结合的非常好。(但有并发问题,在第四部分讨论)可能它最大的用处在于程序员可以放弃使用复杂的信号结构 来工作了。

使用Join等待一个线程完成

你可以通过Join方法阻止线程直到另一个线程结束:

class JoinDemo {
static void Main() {
Thread t = new Thread (delegate() { Console.ReadLine(); });
t.Start();
t.Join();    // 等待直到线程完成
Console.WriteLine ("Thread t's ReadLine complete!");
}
}

Join方法也接收一个使用毫秒或用TimeSpan类的超时参数,当Join超时是返回false,如果线程已终止,则返回true 。Join所带的超时参数非常像Sleep方法,实际上下面两行代码几乎差不多:

Thread.Sleep (1000);
Thread.CurrentThread.Join (1000);

(他们的区别明显在于单线程的应用程序域与COM互操作性,源于先前描述Windows信息汲取部分:在阻止时,Join保持信息汲取,Sleep暂停信息汲取。)

锁和线程安全

锁实现互斥的访问,被用于确保在同一时刻只有一个线程可以进入特殊的代码片段,考虑下面的类:

class ThreadUnsafe {
  static int val1, val2;
 
  static void Go() {
     if (val2 != 0) Console.WriteLine (val1 / val2);
     val2 = 0;
  }
}

这不是线程安全的:如果Go方法被两个线程同时调用,可能会得到在某个线程中除数为零的错误,因为val2可能被一个线程设置为零,而另一个线程刚好执行到ifConsole.WriteLine语句。

下面用lock来修正这个问题:

class ThreadSafe {
  static object locker = new object();
  static int val1, val2;
 
  static void Go() {
     lock (locker) {
       if (val2 != 0) Console.WriteLine (val1 / val2);
       val2 = 0;
     }
  }
}

在同一时刻只有一个线程可以锁定同步对象(在这里是locker),任何竞争的的其它线程都将被阻止,直到这个锁被释放。如果有大于一个的线程竞争这个锁,那么他们将形成称为“就绪队列”的队列,以先到先得的方式授权锁。互斥锁有时被称之对由锁所保护的内容强迫串行化访问,因为一个线程的访问不能与另一个重叠。在这个例子中,我们保护了Go方法的逻辑,以及val1val2字段的逻辑。

一个等候竞争锁的线程被阻止将在ThreadState上为WaitSleepJoin状态。稍后我们将讨论一个线程通过另一个线程调用InterruptAbort方法来强制地被释放。这是一个相当高效率的技术可以被用于结束工作线程。

C#的lock 语句实际上是调用Monitor.EnterMonitor.Exit,中间夹杂try-finally语句的简略版,下面是实际发生在之前例子中的Go方法:

Monitor.Enter (locker);
try {
  if (val2 != 0) Console.WriteLine (val1 / val2);
  val2 = 0;
}
finally { Monitor.Exit (locker); } 
 

在同一个对象上,在调用第一个之前Monitor.Enter而先调用了Monitor.Exit将引发异常。

Monitor 也提供了TryEnter方法来实现一个超时功能——也用毫秒或TimeSpan,如果获得了锁返回true,反之没有获得返回false,因为超时了。TryEnter也可以没有超时参数,“测试”一下锁,如果锁不能被获取的话就立刻超时。

选择同步对象

任何对所有有关系的线程都可见的对象都可以作为同步对象,但要服从一个硬性规定:它必须是引用类型。也强烈建议同步对象最好私有在类里面(比如一个私有实例字段)防止无意间从外部锁定相同的对象。服从这些规则,同步对象可以兼对象和保护两种作用。比如下面List

class ThreadSafe {
  List <string> list = new List <string>();
 
  void Test() {
     lock (list) {
list.Add ("Item 1");
...

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

lock (this) { ... }

或:

lock (typeof (Widget)) { ... }     // 保护访问静态

是不好的,因为这潜在的可以在公共范围访问这些对象。

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

嵌套锁定

线程可以重复锁定相同的对象,可以通过多次调用Monitor.Enterlock语句来实现。当对应编号的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) {
     ...
   }
  释放了锁?没有完全释放!
}

线程只能在最开始的锁或最外面的锁时被阻止。

何时进行锁定

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

class ThreadUnsafe {
  static int x;
  static void Increment() { x++; }
  static void Assign()    { x = 123; }
}

下面是IncrementAssign 线程安全的版本:

class ThreadUnsafe {
static object locker = new object();
  static int x;
 
  static void Increment() { lock (locker) x++; }
  static void Assign()    { lock (locker) x = 123; }
}

作为锁定另一个选择,在一些简单的情况下,你可以使用非阻止同步,在第四部分讨论(即使像这样的语句需要同步的原因)。

锁和原子操作

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

lock (locker) { if (x != 0) y /= x; }

你可以认为xy 通过原子的方式访问,因为代码段没有被其它的线程分开抢占,别的线程改变xy无效的输出,你永远不会得到除数为零的错误,保证了xy总是被相同的排他锁访问。

性能考量

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

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

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

线程安全

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

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

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

因此线程安全经常只在需要实现的地方来实现,为了处理一个特定的多线程情况。

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

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

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

线程安全与.NET Framework类型

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

class ThreadSafe {
  static List <string> list = new List <string>();
 
  static void Main() {
     new Thread (AddItems).Start();
     new Thread (AddItems).Start();
  }
 
  static void AddItems() {
     for (int i = 0; i < 100; i++)
       lock (list)
         list.Add ("Item " + list.Count);
 
     string[] items;
     lock (list) items = list.ToArray();
     foreach (string s in items) Console.WriteLine (s);
   }
}

在这种情况下,我们锁定了list对象本身,这个简单的方案是很好的。如果我们有两个相关的list,也许我们就要锁定一个共同的目标——可能是单独的一个字段,如果没有其它的list出现,显然锁定它自己是明智的选择。

枚举.NET的集合也不是线程安全的,在枚举的时候另一个线程改动list的话,会抛出异常。胜于直接锁定枚举过程,在这个例子中,我们首先将项目复制到数组当中,这就避免了固定住锁因为我们在枚举过程中有潜在的耗时。

这里的一个有趣的假设:想象如果List实际上为线程安全的,如何解决呢?代码会很少!举例说明,我们说我们要增加一个项目到我们假象的线程安全的list里,如下:

if (!myList.Contains (newItem)) myList.Add (newItem);

无论与否list是否为线程安全的,这个语句显然不是!整个if语句必须放到一个锁中,用来保护抢占在判断有无和增加新的之间。上述的锁需要用于任何我们需要修改list的地方,比如下面的语句需要被同样的锁包括住:

myList.Clear();

来保证它没有抢占之前的语句,换言之,我们必须锁定差不多所有非线程安全的集合类们。内置的线程安全,显而易见是浪费时间!

在写自定义组件的时候,你可能会反对这个观点——为什么建造线程安全让它容易的结果会变的多余呢 ?

有一个争论:在一个对象包上自定义的锁仅在所有并行的线程知道、并且使用这个锁的时候才能工作,而如果锁对象在更大的范围内的时候,这个锁对象可能不在这个锁范围内。最糟糕的情况是静态成员在公共类型中出现了,比如,想象静态结构在DateTime上,DateTime.Now不是线程安全的,当有2个并发的调用可带来错乱的输出或异常,补救方式是在其外进行锁定,可能锁定它的类型本身—— lock(typeof(DateTime))来圈住调用DateTime.Now,这会工作的,但只有所有的程序员同意这样做的时候。然而这并靠不住,锁定一个类型被认为是一件非常不好的事情。

由于这些理由,DateTime上的静态成员是保证线程安全的,这是一个遍及.NET framework一个普遍模式——静态成员是线程安全的,而一个实例成员则不是。从这个模式也能在写自定义类型时得到一些体会,不要创建一个不能线程安全的难题!

当写公用组件的时候,好的习惯是不要忘记了线程安全,这意味着要单独小心处理那些在其中或公共的静态成员。

Interrupt 和 Abort

一个被阻止的线程可以通过两种方式被提前释放:

这必须通过另外活动的线程实现,等待的线程是没有能力对它的被阻止状态做任何事情的。

Interrupt方法

在一个被阻止的线程上调用Interrupt 方法,将强迫释放它,抛出ThreadInterruptedException异常,如下:

class Program {
  static void Main() {
     Thread t = new Thread (delegate() {
       try {
         Thread.Sleep (Timeout.Infinite);
       }
       catch (ThreadInterruptedException) {
         Console.Write ("Forcibly ");
       }
       Console.WriteLine ("Woken!");
     });
 
     t.Start();
     t.Interrupt();
  }
}

Forcibly Woken!

中断一个线程仅仅释放它的当前的(或下一个)等待状态:它并不结束这个线程(当然,除非未处理ThreadInterruptedException异常)。

如果Interrupt被一个未阻止的线程调用,那么线程将继续执行直到下一次被阻止时,它抛出ThreadInterruptedException异常。用下面的测试避免这个问题:

if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)
  worker.Interrupt();

这不是一个线程安全的方式,因为可能被抢占了在if语句和worker.Interrupt间。

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

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

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值