C#多线程实践——锁和线程安全

8/3/2015来源:C#应用人气:1858

C#多线程实践——锁和线程安全

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

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 <string> list = new List <string>();         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总是被相同的排他锁访问。

性能考量

  锁本身是非常快的,一个锁在没有堵塞的情况下一般只需几十纳秒(十亿分之一秒)。如果发生堵塞,任务切换带来的开销接近于数微秒(百万分之一秒)的范围内,尽管在线程重组实际的安排时间之前它可能花费数毫秒(千分之一秒)。相反,该使用锁而没使用的会带来更长的时间开销。如果发生了死锁和竞争锁,锁就会带来反作用,由于太多的代码被放置到锁语句中了,引起其它线程不必要的被阻止。死锁是两线程彼此等待被锁定的内容,导致两者都无法继续下去。争用锁是两个线程任一个都可以锁定某个内容,如果“错误”的线程获取了锁,则导致程序错误。

对于同步对象非常容易出现死锁的情况,比较好的处理方式是设计较少的锁。在一个可信的情况下涉及比较多阻止的话,可以考虑增加锁的粒度。

线程安全

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

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

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

为了处理一个特定的多线程情况,线程安全经常只在需要实现的地方来实现。不过也有特殊情况,通过牺牲锁的粒度包含大段的代码甚至在排他锁中访问全局对象来迫使在更高的级别上实现串行化访问,实现庞大复杂的类安全地运行在多线程环境中。这种用法让非线程安全的对象用于线程安全代码中,避免了相同的互斥锁被用于在保护对非线程安全对象的所有的属性、方法和字段的访问上。或者通过最小化共享数据来最小化线程交互,多用于“弱状态”的中间层程序和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对象本身,也许是一个不错的方式。枚举.NET的集合也不是线程安全的,在枚举的时候另一个线程改动list的话,会抛出异常。为了不直接锁定枚举过程,我们首先将项目复制到数组中,避免因为在枚举过程中有潜在的耗时而固定住锁。

一个有趣的假设:如果List实际上为线程安全的,要增加一个项目到我们假象的线程安全的list里,如下:

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

  无论list是否为线程安全的,这个语句显然不是,也就是说完全线程安全的通用集合类是基本不存在的。.net4.0中,微软提供了一组线程安全的并行集合类,但他们都经过特殊处理,在访问方式做了限定。上面的语句要实现线程安全,整个if语句必须放到一个锁中,用来保护在判断有无和增加新的之间的抢占。类似的锁需要用于任何我们需要修改list的地方,比如下面的语句:

myList.Clear();

  换言之,我们必须锁定差不多所有非线程安全的集合类们。内置线程安全,显而易见是浪费时间!由于这些理由,.NET framework中静态成员是线程安全的,而一个实例成员则不是。从而在写自定义类型时,也不要尝试去创建一个线程安全的自定义组件!当写公用组件的时候,单独小心处理静态成员是一个好的编码习惯。