也谈多线程同步
并发模式主要是为了处理以下两种类型的问题:
1) 共享资源:每次只能由一个操作访问共享资源,从而不至于产生死锁。
2) 操作顺序。在访问共享资源时,有时要保证多个访问操作按照特定的顺序进行。
以下为11种并发模式:
1. 单线程执行模式:最简单的解决方案,确保了每次最多只有一个线程访问一个资源。
2. 静态锁定顺序:死锁的解决方案。
3. 锁对象:通过锁定唯一对象,使一个操作可以独占访问多个资源。
4. 受保护的挂起:当线程已经独占访问一个资源,却发现因为某个原因而不能完成对该资源的操作。
5. 阻行:操作必须完成,或者根本不需要完成时。
6. 调度器:专门用于处理操作顺序有影响的情形。
7. 读/写锁:处理了某些、操作可以共享同一资源而另一些不可以的情形。
8. 生产/消费者:协调了生产资源的对象和消费资源的对象。
9. 双缓冲:在需要资源之前生产出资源。
10. 异步处理:callback回调技术
11. Future:使调用操作的类避免必须知道该操作是同步的还是异步的。
1.单线程执行模式
又名临界区(Critical Section)。确保每次最多只有一个线程访问一个资源。
场景所需的一些要求
一个类,拥有更新或者设置实例或类变量的方法和属性。
这个方法,操作的外部资源每次只支持一个操作。
这个方法可以被不同的线程并发调用
不要求这个方法一经调用便立即执行,也就是说,可以有短暂的延时。
实现
将受保护方法的主体嵌入到lock语句块中,如:
{
lock (this)
{
n += 1;
}
}
目前,我们先锁住this这个对象。我们还可以锁定其它对象,下文会进行分析。
这里有一种锁分解(Lock Factoring)的技术,如果在lock块中调用另外的一个受保护的方法DoIt,而且这个DoIt方法仅在这一个地方被调用,那么可以把DoIt方法改写为不受保护的方法,这是可以的,而且也能保证同步——这是一种不安全的优化方法,因为DoIt以后会被修改为在其它地方也可以调用。
lock语句块,与下面语句块是等效的:
{
try
{
Monitor.Enter(this);
n += 1;
}
finally
{
Monitor.Exit(this);
}
}
Monitor这个静态类,可以提供比lock语句块更细致的同步操作。在它的Enter和Exit方法之间,执行这些受保护的方法。Exit方法要放在finally块中,以表示无论操作是否成功,都要释放这个线程的资源。
当然这里也可以不使用try语句块,直接使用Monitor.TryEnter静态方法:
{
if (!Monitor.TryEnter(this))
return false;
n += 1;
Monitor.Exit(this);
return true;
}
Monitor.TryEnter还有另外两种重载方法,多了一个时间参数,表示为了尝试获取这个锁需要等待多长时间:
public static bool TryEnter(object obj, int millisecondsTimeout);
public static bool TryEnter(object obj, TimeSpan timeout);
此外,Monitor静态类还有Wait、Pulse、PulseAll三个静态方法,会在下面进行分析。
2. 静态锁定顺序
这个模式是为了解决死锁的。
死锁:某个操作在继续执行之前,必须等待另一个操作完成完成自己的任务。因为每个操作都在等待其他操作完成自己的任务,所以它们将永远等待下去,什么都不做。
写一个最简单的死锁模拟程序——直接拿来1-2-3写过一篇文章里面的代码实例:
可以看到,foo1方法,在锁定mk1的代码块中,又锁定了mk2;而foo2方法,则在锁定mk2的代码块中,又锁定了mk1。这种产生了互相等待,从而死锁。
死锁,是人为造成的。为此,我们要避免写出上面的代码。之前介绍过“锁分解的技术”,不失为一种办法,从而减少了锁的数量;但是,如果不能减少锁,也就是说,要直接面对多重锁带来的死锁危机,那么就要使用静态锁定顺序这个同步技术。
静态锁定顺序,可以认为是对Monitor的Enter和Exit这两个代码块的封装。为此,建立泛型类MultiMonitor<T>:
{
public static void Enter(ICollection objs)
{
T[] myArray = new T[objs.Count];
objs.CopyTo(myArray, 0);
Array.Sort(myArray);
for (int i = 0; i < myArray.Length; i++)
{
Monitor.Enter(myArray[i]);
}
}
public static void MyExit(ICollection objs)
{
foreach (Object obj in objs)
{
Monitor.Exit(obj);
}
}
}
新的Enter方法,按照一个固定的顺序,依次锁定objs中的对象,如ABCD,从而永远不会发生死锁(不会有BA的锁定顺序)。
当然可以重载Enter方法,允许自定义这个排序规则IComparer:
{
T[] myArray = new T[objs.Count];
objs.CopyTo(myArray, 0);
Array.Sort(myArray, comparer);
for (int i = 0; i < myArray.Length; i++)
{
Monitor.Enter(myArray[i]);
}
}
当然,要注意尽量不要在不同的线程中使用不同的排序规则IComparer,因为如果T1线程使用了ABCD的顺序,此时T2线程使用了AC的顺序,这是没有问题的;但是T3线程使用了CA的顺序,就会与T1线程发生死锁了。
注:这个MultiMonitor<T>是一个很常用的小工具,大家可以将其嵌入到自己的程序中,进行同步操作控制。
3. 锁对象
这个同步方式,简单的说,就是创建并锁定一个新的无关对象,将受保护的方法放入这个锁定区域中。
public void Foo()
{
lock (s_lock)
{
n += 1;
}
}
注:如果受保护的方法是是静态,那么锁对象就也该是静态的。
示例1:双检锁技术,double-check locking,也就是为Singleton模式的实例创建添加锁,在lock语句块的前后要判断两次对象的存在与否:
{
static Signleton mySignleton;
static Object s_lock = new Object();
private Signleton() { }
public static Signleton Instance()
{
//判断单实例对象是否已经被创建
if (mySignleton == null)
{
lock (s_lock)
{
//锁定后再次判断——有没有另一个线程在创建它
if (mySignleton == null)
mySignleton = new Signleton();
}
}
return mySignleton;
}
}
示例2:事件与线程安全
同步指导方针指出:方法永远不要在类型对象上加锁,否则这个锁将对所有代码公开(从而任何人都可以写代码锁住这个对象,导致死锁)。
对于方法同步,我们可以对方法其应用[MethodImpl(MethodImplOptions.Synchronized)]特性。
public void Method1()
但是,对于事件中的add和remove,却无法加上这个特性,所以要使用锁对象的技术。
在事件的add和remove上加锁,从而确保每次只有一个add或remove可以执行,以免委托对象的链表被破坏:
private EventHandler<NewEventArgs> m_NewEvent;
public event EventHandler<NewEventArgs> NewEvent
{
add
{
lock (m_lock)
{
m_NewEvent += value;
}
}
remove
{
lock (m_lock)
{
m_NewEvent -= value;
}
}
}
4.受保护的挂起
如果存在某个条件,它阻止方法完成它应该执行的事情。在这一条件消失之前,将一直挂起该方法。
这种同步方式的实现,需要使用到Monitor类的Wait和Pulse方法。
{
private SomeDataClass myData;
public void Foo()
{
lock (myData)
{
while (!myData.IsOK)
{
Monitor.Wait(myData);
}
//do something
}
}
public void Bar(int x)
{
lock (myData)
{
//这里有一些代码,使myData的IsOK属性改为true
Monitor.Pulse(myData);
}
}
}
Foo方法的逻辑是:只要myData的IsOK属性不为true,就一直挂起而不会跳出while循环,线程会一直处于等待状态:
Monitor.Wait(myData);
那么接下来的代码段就不会执行。
Bar方法的逻辑是:将myData的IsOK属性改为true,也就是条件不再满足,这时不再阻止,也就是释放这个锁:
Monitor.Pulse(myData);
下面介绍一个最典型的例子:Queue。
Queue这个数据结构,是先进先出的。
(未完待续)