C#多线程实践-锁和线程安全
阅读原文时间:2023年07月08日阅读:2

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

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

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

  下面用c#中的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方法的逻辑,以及val1 和val2字段的逻辑。一个等候竞争锁的线程被阻止将在ThreadState上为WaitSleepJoin状态。稍后将讨论一个线程通过另一个线程调用Interrupt或Abort方法来强制地被释放。这是用于结束工作线程一个相当高效率的技术。C#的lock 语句实际上是调用Monitor.Enter和Monitor.Exit,中间夹杂try-finally语句的简略版,下面是实际发生在之前例子中的Go方法:

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

  在同一个对象上,在调用第一个Monitor.Ente之前却先调用了Monitor.Exit将引发异常。Monitor 也提供了TryEnter方法来实现一个超时功能——也用毫秒或TimeSpan,如果获得了锁返回true,反之没有获得返回false。TryEnter也可以没有超时参数,“测试”一下锁,如果锁不能被获取的话就立刻超时。

选择同步对象

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

class ThreadSafe
{
List list = new List ();

    void Test() {

    lock (list) {

    list.Add ("Item 1");

    ...

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

lock (this) { … }

或:

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

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

 锁并没有以任何方式阻止对同步对象本身的访问,换言之,x.ToString()不会由于另一个线程调用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 < 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是否为线程安全的,这个语句显然不是!(因此,可以说完全线程安全的通用集合类是基本不存在的。.net4.0中,微软提供了一组线程安全的并行集合类,但是都是特殊的经过处理过的,访问方式都经过了限定。),上面的语句要实现线程安全,整个if语句必须放到一个锁中,用来保护抢占在判断有无和增加新的之间。上述的锁需要用于任何我们需要修改list的地方,比如下面的语句需要被同样的锁包括住:

myList.Clear();

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

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

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

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