C# 线程同步 之 Lock

在具体说 lock 之前,先来认识一下临界区:

临界区(Critical Section,又称关键段),就是每个线程中访问临界资源的那段程序称为临界区(临界资源是一次仅允许一个线程使用的共享资源)。每次只准许一个线程进入临界区,进入后不允许其他线程进入。    

lock 关键字就可以用来定义临界区。它其实是一个C#语法糖,它会在代码块开始的地方使用 Moniter.Enter,在代码块结束地方插入finally块,并在其中调用 Moniter.Exit。使用 lock 关键字非常简单:

 lock(lockobj)
 {
     //临界区代码
 }


上面的代码是不是很 EASY,只需要把代码用lock关键字包围起来,就算实现了临界区的能力。

那么,lockobj 是什么东东?来看一下微软的说明:

通常,应避免锁定 public 类型,否则实例将超出代码的控制范围。 常见的结构 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 违反此准则:

如果实例可以被公共访问,将出现 lock (this) 问题。

如果 MyType 可以被公共访问,将出现 lock (typeof (MyType)) 问题。

由于进程中使用同一字符串的任何其他代码都将共享同一个锁,所以出现 lock("myLock") 问题。

最佳做法是定义 private 对象来锁定, 或 private static 对象变量来保护所有实例所共有的数据。

在 lock 语句的正文不能使用 await 关键字。

-- “lock”语句(C# 参考)


上面的说明,不知道你有没有看明白。我先来简单说下背景知识。

每个引用类型对象在内存中除了包含对象本身的成员外,还会额外包含一个同步块索引,这个索引值直接指向 CLR 在内存中维护的一个特殊数据结构(同步块数组)。如果当前这个同步块索引没有指向数组中的任何一个位置(值小于0),意味着当前这个对象允许被加锁。否则,表示当前对象已经被锁。当执行 Moniter.Enter(lockobj)的时候,就会去检查这个lockobj的同步块索引,如果小于0,则在那个特殊的数据结构中为它分配一个同步块,同时把索引号赋值给lockobj。如果发现lockobj中的同步块索引大于0,则调用Moniter.Enter的线程将被阻塞,直到lockobj的索引重新变成小于0的状态。

图片摘自网络


有了这个背景知识,我们可以知道这么几点信息:

1、lockobj 必须是一个引用类型对象,因为只有引用类型对象才会有同步块索引。

2、不同的线程只有当都尝试图获取同一个 lockobj 的时候,才会真正起到作用。


那为什么不建议使用 this、typeof(MyType)、“mylock” 来作为 lockobj 呢?原因很简单,因为这些对象谁都可以访问,意味着谁都可以执行进行加锁,那你就无法预料当你试图进行加锁的时候,是否有其它程序(比如恶意程序)早就占用了这把锁。这会导致真正要执行的操作无法执行。所以微软建议使用私有的对象来进行加锁操作,推荐作法:

private readonly object lockobj = new object(); //希望一个实例方法线程安全时使用

private readonly static object lockobj = new object(); //如果希望所有实例共用一把锁, 或者希望一个静态方法线程安全时


我写了一个DEMO,来演示使用不同的 lockobj 可能会出现的问题:

//Program.cs 先来看不使用多线程,同步执行的结果
MyTask t = new MyTask(); 
t.DoSync();
t.DoSync(); 

//MyTask.cs 中 DoSync的定义
public void DoSync()
{
    //如果认为该方法永远不会有多个线程并发执行的情况,则不需要同步机制    
    for (int i = 0; i < 1000; i++)
    {
        MyList.Add(i);
        Thread.Sleep(new Random().Next(10));
    }
    Console.WriteLine("List count:" + MyList.Count);
}

//最后输出
List count: 1000
List count: 2000


接着看下,使用了多线程,但不回锁的情况:

//Program.cs
Task t1 = Task.Factory.StartNew(() => t.DoParallelWithoutLock());
Task t2 = Task.Factory.StartNew(() => t.DoParallelWithoutLock());


//和 DoSync 一样
public void DoParallelWithoutLock()
{
    for (int i = 0; i < 1000; i++)
    {
        MyList.Add(i);
        Thread.Sleep(new Random().Next(10)); //随机睡个几毫秒,以便引发两个线程争用的情况
    }
 
    Console.WriteLine("List count:" + MyList.Count);
}

//最后输出
List count: 1987
List count: 1994

可以看到,没有加任何锁的时候,结果是不对的。总数并非是2000,说明中途至少有6次两个线程出现了重叠。


下面是一个使用 this 的例子

//Program.cs
Task t5 = Task.Factory.StartNew(() => t.DoParallelByLockThis());
Task t6 = Task.Factory.StartNew(() => t.DoParallelByLockThis());
lock (t) 
{
    Console.WriteLine("模拟死锁开始,在其它地方调用了lock(同一个实例)");
    Thread.Sleep(3000);
    Console.WriteLine("死锁结束");
}

//MyTask.cs
public void DoParallelByLockThis()
{
    lock (this) // this == t
    {
        for (int i = 0; i < 1000; i++)
        {
            MyList.Add(i);
            Thread.Sleep(new Random().Next(10));
        }
        Console.WriteLine("List count:" + MyList.Count);
    }
}

//最后输出
模拟死锁开始,在其它地方调用了lock(同一个实例)
死锁结束
List count: 1000
List count: 2000

可以看到,当使用this的时候,你(编写MyTask的开发人员)无法预期外界会如何去使用它,就会发生类似死锁的情况。在模拟死锁的时候,真正的任务无法执行,如果把 Thread.Sleep(3000) 换成一个死循环呢?


正确的作法

//Program.cs
Task t11 = Task.Factory.StartNew(() => t.DoParallelByLockCorrectly());
Task t12 = Task.Factory.StartNew(() => t.DoParallelByLockCorrectly());
lock (t)
{
    Console.WriteLine("模拟死锁开始,在其它地方调用了lock(同一个实例)");
    Thread.Sleep(3000);
    Console.WriteLine("死锁结束");
}

//MyTask.cs
private readonly object obj = new object(); 
public void DoParallelByLockCorrectly()
{
    lock (obj) 
    {
        for (int i = 0; i < 1000; i++)
        {
            MyList.Add(i);
            Thread.Sleep(new Random().Next(10));
        }
        Console.WriteLine("List count:" + MyList.Count);
    }
}

//最后输出
模拟死锁开始,在其它地方调用了lock(同一个实例)
List count: 1000
List count: 2000
死锁结束

可以看出,由于外界无法获取obj这个对象,所以加锁的行为在我们的控制之内,也就不会造成上一示例的情况。


关于更多示例代码,请猛击《lock Keywords Demo

文章索引

[隐 藏]

本站采用知识共享署名 3.0 中国大陆许可协议进行许可。 ©2014 Charley Box | 关于本站 | 浙ICP备13014059号