多线程 - 线程同步锁(lock、Monitor)

时间:2024-01-11 09:39:56

1. 前言


多线程编程的时候,我们不光希望两个线程间能够实现逻辑上的先后顺序运行,还希望两个不相关的线程在访问同一个资源的时候,同时只能有一个线程对资源进行操作,否则就会出现无法预知的结果。

比如,有两个线程需要对同一个计数器加1,我们希望结果是计数器最终加2,但可能同时获取到了这个计数器,第一个线程对计数器加1,但第二个线程并不知道,于是重新对计数器加1,导致最终计数器损失了一个计数。为了解决这个问题,就必须在获取该计数器前锁定,防止其他线程再次获取,直到处理完成后再释放。

Monitor、lock就是引入用来处理这类问题。

2. 语法糖


在讨论Monitor、lock之前,我们先来了解一个简单的概念 - 什么是语法糖?其实语法糖是这样一类编程写法,用来简化编码语句,提高编码效率。

比如:我们声明属性

private string m_Name;
public string Name
{
get { return m_Name; }
set { m_Name = value; }
}

如果我们在一个类中定义了很多这种属性,那就要我们都要重复的写类似的代码,没有什么意义,于是C#给我们提供了另外一种简便的写法:

public string Name
{ get; set; }

很简单!仍然是提供了属性Name的读写操作,但把实际代码交给了C#编译器来帮我们完成,最终生成的代码是完全一样的。所以第二种形式就是第一种形式的语法糖。

其实,第一种写法也是一种语法糖,它实际上是对方法get_Name和set_Name(详细请参阅CSDN中关于属性访问器的解释)的简化。

lock也是一种语法糖,它是Monitor(Monitor.Enter和Monitor.Exit)的语法糖,以下代码等效:

lock (obj)
{...}
try
{
Monitor.Enter(obj);
....
}
finally
{
Monitor.Exit(obj);
}

3. lock是否可以完全取代Monitor


存在即是有意义的,当然不能完全取代,因为Monitor不仅提供了Monitor.Enter和Monitor.Exit方法,还提供了用于线程同步的类似于信号操作的方法Monitor.Wait和Monitor.Pulse。

大多数情况下,我们仅仅使用Monitor.Enter和Monitor.Exit用于资源同步时,是可以用lock取代,而且还会使代码更容易理解。但如果我们希望对锁定的代码有更精准的控制时,需要使用Monitor的方法,如下代码:

Queue m_TokenQueue = new Queue();

...

lock (obj)
{
if (m_TokenQueue.Count > 0)
{
var data = m_TokenQueue.Dequeue();
}
else
{
Thread.Sleep(60000);
}
}

当我们要操作队列m_TokenQueue时,需要先锁定资源,然后再判断当队列有数据时,获取数据;否则等待一分钟。这里就有个问题,当发现队列中没有数据的时候,我们希望的是立即释放这个锁资源,而不是直到一分钟以后才释放,这样的话,在这一分钟的时间里,即使其他线程想要访问这个队列也要等一分钟以后才行,而这个等待完全是无意义的!

这时候,就需要使用会Monitor的写法

Monitor.Enter(obj);
if (m_TokenQueue.Count > 0)
{
var data = m_TokenQueue.Dequeue();
Monitor.Exit(obj);
}
else
{
Monitor.Exit(obj);
Thread.Sleep(60000);
}

这样才判断完成后立即释放锁,其他的线程就不用再等待Sleep以后再获取资源锁了!不过代价就是在每个判断分支都要加上Monitor.Exit方法,确保资源锁被释放。

这个细节在我的项目中经常用到,至今还没有找到更好的替代方式。。

4. lock锁的到底是什么


既然是锁同步资源,我们想当然的认为参数应该就是要锁定的对象,如上面说的锁定队列,那代码应该是这样的:

Queue m_TokenQueue = new Queue();
lock (m_TokenQueue)
{...}

这样看起来就很容易理解了。这样写可以,但等下我们再回来讨论这样写的问题在哪。

其实,我们更应该把lock理解为锁定了一段代码,而这段代码用来操作边界资源!lock (Object obj)通过检查obj是不是同一个对象,来决定是否同步锁定,也就是说,不管这个obj是什么,只要是同一个对象(即检查是否是同一个地址)就可以了!

那么,再回来看刚刚说的,lock(m_TokenQueue)显然写法是对的,但是却不能保证m_TokenQueue对象地址不变,因为相关线程都是对这个资源操作,一旦有个线程对这个队列重新赋值,将造成其他同步失效!如下代码:

class Program
{
static Queue m_TokenQueue = new Queue();
static void Main(string[] args)
{
ThreadPool.QueueUserWorkItem(new WaitCallback(Method1), null);
ThreadPool.QueueUserWorkItem(new WaitCallback(Method2), null); Console.ReadKey();
} private static void Method1(object obj)
{
lock (m_TokenQueue)
{
Console.WriteLine(DateTime.Now.ToString("mm:ss") + ",enter 1");
m_TokenQueue = new Queue();
Thread.Sleep(10000);
}
Console.WriteLine(DateTime.Now.ToString("mm:ss") + ",exit 1");
} private static void Method2(object obj)
{
Thread.Sleep(1000); lock (m_TokenQueue)
{
Console.WriteLine(DateTime.Now.ToString("mm:ss") + ",enter 2");
}
Console.WriteLine(DateTime.Now.ToString("mm:ss") + ",exit 2");
}
}

先启动线程1,锁定m_TokenQueue,10秒钟后解锁;同时启动线程2,先休眠1秒钟,保证在线程锁定资源并保证执行到Thread.Sleep(10000)后再进入lock。理想情况下,希望线程1解锁后,线程2才能输出,但线程1中做了这样一个操作 m_TokenQueue = new Queue(); 这时候,m_TokenQueue地址已经变了,所以两个线程的lock (m_TokenQueue)不一样了,线程2不会再等待直接执行,结果如下:

多线程 - 线程同步锁(lock、Monitor)

综上,使用lock (m_TokenQueue)显然不是一个安全有效的方式,既然希望锁定对象地址不可变,那么就可以设置一个只读对象如

private readonly Object obj = new Object(); 做为lock 的锁定对象,就能保证代码安全地执行了。

5. SyncRoot


实际上,.Net在一些集合类(比如:Hashtable, Queue, ArrayList等),已经为我们提供了这样一个供lock使用的对象SyncRoot(定义在接口ICollection中),所以上面当我们对资源进行同步时,可以这样写:

Queue m_TokenQueue = new Queue();
lock (m_TokenQueue.SyncRoot)
{
...
}

如果是泛型队列,需要做一次强制转换:

Queue<int> m_TokenQueue = new Queue<int>();
lock (((ICollection)m_TokenQueue).SyncRoot)
{
...
}