一提到线程同步,就会提到锁,作为线程同步的手段之一,锁总是饱受质疑。一方面锁的使用很简单,只要在代码不想被重入的地方(多个线程同时执行的地方)加上锁,就可以保证无论何时,该段代码最多有一个线程在执行;另一方面,锁又不像它看起来那样简单,锁会造成很多问题:性能下降、死锁等。使用volatile关键字或者Interlocked中提供的方法能够避开锁的使用,但是这些原子操作的方法功能有限,很多操作实现起来很麻烦,如无序的线程安全集合。我在本系列的序中已经介绍了锁的总类,自旋锁、内核锁(内核构造)、混合锁,他们各有优缺点,下面就来一一介绍。

  • 自旋锁

  我在 C#多线程编程(6) 中已经介绍了简单的利用Interlocked实现的自旋锁,这种锁的优点是单线程时非常快,但是在竟态时会造成等待的线程“自旋”--无限while循环,在循环中只是在不断的尝试获得锁,其他什么也不做。如果获得锁的线程执行的非常快,那么等待的线程在那自旋一会是值得的,因为当锁被释放时,自旋的线程能够立马获得锁。但是当获得锁的线程执行的时间很长,等待线程的自旋就毫无意义了。设想这样一个场景,A线程获得了锁,B和C线程想要获得锁,发现锁已被其他线程获得,就会开始自旋,并且A线程迟迟不肯交出锁,这相当于一个锁占用了3个线程,这是多么大的浪费!

  • 内核锁(在C#多线程编程序中,我曾把内核锁叫成了信号量,是错误,现已更正。《CLR via C#》中叫内核构造)

  其实准确的说,内核锁是内核维护的变量,《CLR via C#》中叫内核构造,更准确一些,但是我感觉内核构造这个名字太奇怪了,还是内核锁好理解一些。内核锁是一组继承了WaitHandle基类的一组类

public abstract class WaitHandle : MarshalByRefObject, IDisposable{
public virtual IntPtr Handle { get; set; }
public SafeWaitHandle SafeWaitHandle { get; set; } public void Dispose();
public static bool SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn);
public static bool WaitAll(WaitHandle[] waitHandles);
public static int WaitAny(WaitHandle[] waitHandles);
public virtual bool WaitOne();
}

  我只列出了基本的方法,重载的版本没有列出。每构造一个内核锁,都是调用windows方法创建一个内核变量保存在SafeWaitHandle属性中。这是个抽象类,所有的内核锁都继承于此。简单介绍下上面列出的几个方法。

  1. WaitOne方法:等待内核对象收到信号,如果收到,就返回true,如果超时就返回false.
  2. WaitAll方法:等待waitHandles数组中任何一个内核变量的信号,返回的int值为waitHandles的元素索引,如果超时,则返回WaitHandle.WaitTimeout。
  3. WaitAny方法:等待waitHandles全部返回信号。WaitAll和WaitAny接受的WaitHandle数组的最大数量为64,超过64会抛出NotSupportedException。
  4. Dispose方法:释放底层内核对象。不建议使用,留给GC自己处理何时释放内核对象为好。

  下面介绍几个从WaitHandle派生的类:AutoResetEvent、Semaphore、Mutex。

  • AutoResetEvent

  AutoResetEvent是内核维护的一个bool型变量,当锁为自由状态时,值为true,调用WaitOne方法,值变为false(相当于获得锁)。在调用Set()方法后(相当于释放锁)值恢复为true,并唤醒一个等待的线程。,我们来看一个利用AutoResetEvent来重写 C#多线程编程(6) 中的SimpleSpinLock类,新类的名字为SimpleWaitLock。

class SimpleWaitLock : IDisposable{
private AutoResetEvent _lockState = new AutoResetEvent(true);
public void Enter() { _lockState.WaitOne(); }
public void Leave() { _lockState.Set(); }
public void Dispose() { _lockState.Dispose(); }
}

  可以看到锁和SimpleSpinLock非常像,但是这两个锁的性能截然不同。我前面说过,自旋锁在串行下效率非常高,但是内核锁在访问内核变量并判断是否可以获得锁时,要先从托管代码切换到内核代码,获得值后,在切换回托管代码,这些消耗无论是否竟态都要花费。但是在竟态时,自旋锁会白白浪费CPU,而内核锁会阻塞等待的线程,而不会自旋而浪费CPU。

  用一个例子来对比下在串行下两个锁的性能差距。

static void Main(string[] args){
int x = ;
int count = ;
Stopwatch sw = Stopwatch.StartNew();
for (int i = ; i < count; i++){
x++;
}
Console.WriteLine("NoMethod: {0} ms", sw.ElapsedMilliseconds);
sw = Stopwatch.StartNew();
for (int i = ; i < count; i++){
VoidMothed();
x++;
VoidMothed();
}
Console.WriteLine("VoidMethod:{0} ms", sw.ElapsedMilliseconds);
SpinLock spinLock = new SpinLock(false);
sw = Stopwatch.StartNew();
for (int i = ; i < count; i++){
bool taken = false;
spinLock.Enter(ref taken);
x++;
spinLock.Exit();
}
Console.WriteLine("SpinLock:{0} ms", sw.ElapsedMilliseconds);
using (SimpleWaitLock simpleWaitLock = new SimpleWaitLock()){
sw = Stopwatch.StartNew();
for (int i = ; i < count; i++){
simpleWaitLock.Enter();
x++;
simpleWaitLock.Leave();
}
Console.WriteLine("WaitLock: {0} ms", sw.ElapsedMilliseconds);
}
Console.ReadLine();
}

运行结果:  

NoMethod: 22 ms
VoidMethod:56 ms
SpinLock:216 ms
WaitLock: 11683 ms

执行一千万次的x++只需要22ms,带一个空方法VoidMethod的是56ms,spinlock是216ms,而内核锁执行了11683ms,是spinlock的54倍(11683/216),而比NoMethod慢了531倍(11683/22),确实直接访问内核锁要慢很多,因此能避免使用内核锁就要避免。

  • Semaphore

  信号量(Semaphore)就是内核维护的一个int型变量,其用法和AutoResetEvent类似。Release(int)方法支持同时释放多个阻塞的线程。与AutoResetEvent相比,AutoResetEvent.Set方法调用多次,只有一个线程接触阻塞,但是Release()会一直解除其他线程的阻塞,直到所有线程全部解除等待。若将所有等待的线程全部解除阻塞后,继续调用Release方法,会抛出一个SemaphoreFullException异常。

public sealed class Semaphore:WaitHandle{
public Semaphore(int initialCount, int maximumCount);
public int Release()//调用Release(1);释放1个线程的等待(阻塞)
public int Release(int releaseCount);//释放releaseCount个的线程的等待
}

  可以用信号量来重新实现SimpleWaitLock:

class SimpleWaitLock : IDisposable{
private readonly Semaphore _lockState;
public SimpleWaitLock(int maxCount) { _lockState = new Semaphore(maxCount, maxCount); }
public void Enter() { _lockState.WaitOne(); }
public void Leave() { _lockState.Release(); }
public void Dispose() { _lockState.Dispose(); }
  • Mutex

  Mutex是一个互斥锁,该锁的功能和AutoResetEvent还有SimpleWaitLock,

public class Mutex:WaitHandle{
public Mutex();
public void Release();
}

但是Mutex还有一些额外的功能:该锁支持递归调用。锁的递归调用就是单线程多次加锁,如下所示:

var smLock = new SmLock();

void M1(){
smLock.Enter();
M2();//M2中会再次加锁,即“递归”
smLock.Exit();
} void M2(){
smLock.Enter();
//一些操作
smLock.Exit();
}

为了支持这个功能,Mutex中会记录当前获得锁的线程Id,允许已经获得锁的线程再次加锁,并对加锁次数进行计数。当计数为0时,调用Release()方法重置锁。

前面介绍了自旋锁和内核锁,下面来介绍混合锁。前面介绍了自旋锁的优势在串行时的性能消耗较小,内核锁的优势在竟态时不占用CPU资源,而混合锁就是这两者的结合体,我们来实现一个简单的混合锁。

public class SimpleHybridLock : IDisposable
{
private readonly AutoResetEvent _coreLock = new AutoResetEvent(true);
private volatile int _outLock = ; public void Enter()
{
//尝试获得锁
if (++_outLock == 1)
//锁无人使用,直接返回
return;
//有其他线程已获得锁,则在此等待该锁释放。
_coreLock.WaitOne();
} public void Leave()
{
//尝试释放锁,如果没有其他线程在等待锁,则直接返回
if (--_outLock == )
return;
//唤醒一个等待的线程,比较浪费性能
_coreLock.Set();
} /// <summary>
/// 释放内核锁
/// </summary>
public void Dispose() { _coreLock.Dispose(); }
}

  在SimpleHybridLock中,我声明了一个int变量和一个AutoResetEvent变量,当调用Enter方法时,使_outLock++,并判断新值是否为1,如果是1,则表示该锁是自由的,没有被其他线程获得。因为当该锁被其他线程获得后,再次调用Enter方法会使_outLock的值变为2,。如果是1,则直接返回,表示该线程已经获得了该锁。如果大于1,表示该锁已经被其他线程获得,这时就会继续执行,调用_coreLock.WaitOne()方法,来等待该锁的释放。在调用Leave方法时,会先判断--_outLock 是否为0,若返回0,则该锁已经被释放。因为当返回值大于0时,代表有其他线程在等待该锁,这时就要调用_coreLock.Set来唤醒一个等待的线程。

  SimpleHybridLock锁的优势在:当单线程执行时,永远不会出现另外一个线程来尝试获得锁,那么该锁只有很小的开支,对_outLock++,并判断新值是否为1,然后直接返回,在释放锁的时候,对_outLock--,然后判断是否为0。_coreLock.WaitOne()永远不会被执行,因此该锁有自旋锁的优点,单线程快。当多线程时,会调用_coreLock.WaitOne()来等待该锁的释放,而不会“自旋”,从而浪费CPU。

  该锁的问题是,没有记录哪个线程获得了锁,如果想要获得锁的线程直接调用Leave方法,就会造成错误的锁释放。不光如此,该锁也没有实现锁的递归。实现这些功能就会造成资源的消耗,就看你是否有能力来保证不会出现上述情况。

  还可以在Enter方法中,if(++_outLock == 1){}处添加一个较短的自旋,该自旋是为了处理那些非常短的获得锁和释放所的程序。当判断锁已经被获取时,不会直接调用_coreLock.WaitOne(),而是自旋一段时间,如果获得锁的线程执行了一段很短的程序,那么在自旋的过程中,锁已经被释放了,这时就可以避免调用内核锁来浪费资源。这是一种有针对性的修改。使锁更适合那些较短的任务。

  • Monitor

  FCL提供了几个混合锁,其中最常用的是Monitor,它支持自旋、递归、锁的所有权,我们在调用lock(object)时,编译器会将其编译成Monitor.Enter和Exist块。那到底Monitor是怎么实现的?

  Monitor的基本原理是利用class在堆中初始化时,会初始化一个同步块。该同步块是用来记录有哪些指针引用了该对象,以及引用的个数。我们在调用Monitor的时候,会这样

Monitor.Enter(obj);
x++;
Monitor.Exist(obj);

  简单理解,在调用Monitor.Enter(obj)方法后,会造成obj无法再被其他线程调用,直到Monitor.Leave(obj)被调用。这样会造成Monitor的BUG,例子如下:

class MonitorExample
{
private int m;
public void Test()
{
Monitor.Enter(this);
m = ;
Monitor.Exit(this);
} public int M
{
get
{
Monitor.Enter(this);
//保证对m访问的安全性
int n = m;
Monitor.Exit(this);
return n;
}
}
} public static void TestMethod()
{
var t = new MonitorExample();
Monitor.Enter(t);
//注意,线程池会阻塞,直到TestMethod调用Monitor.Exist(t)!
//因为已经对t添加了Monitor.Enter,其他线程无法访问t。
ThreadPool.QueueUserWorkItem(o => {Console.WriteLine(t.M); });
   //执行其他代码
   Monitor.Exist(t);
}

TestMethod方法会造成线程池的阻塞,原因是已经对t添加了Monitor.Enter,其他线程无法访问t,究其原因,Monitor不该设计成静态类,而是应该和其他锁一样,设计成正常的类,然后初始化Monitor,就不会造成上述问题,因为锁是独有的。解决的办法是单独声明一个只读的字段用来锁定,如下:

object readonly obj = new object();
Monitor.Enter(obj);
//执行其他操作
Monitor.Exist(obj);

避免对访问对象调用Monitor.Enter,而是对单独的字段来进行锁定。

  以上,就是本文的全部内容,至此,我完成了C#多线程编程系列的全部内容。本文的知识点多是来自《CLR via C#》,说是该书最后一部分线程基础的总结和梳理也不为过。还有一部分是来自《果壳中的C#》,虽然该书内容较浅,很多知识都是浅尝辄止,但是我在开始看《CLR via C#》时,有点读不明白,因为知识点很多,而且太细了,反而有些没法掌握,偶然翻开《果壳》,简单的看了一下多线程部分,发现这本书讲的提纲挈领,我一下子就明白不少。

  如果您对本文有任何问题,欢迎在评论区与我互动。 

C#多线程编程(7)--锁的更多相关文章

  1. C# 多线程编程之锁的使用【互斥锁(lock)和读写锁(ReadWriteLock)】

    多线程编程之锁的使用[互斥锁(lock)和读写锁(ReadWriteLock)] http://blog.csdn.net/sqqyq/article/details/18651335 多线程程序写日 ...

  2. c++多线程编程互斥锁初步

    上一次讲述了多线程编程,但是由于线程是共享内存空间和资源的,这就导致:在使用多线程的时候,对于共享资源的控制要做的很好.先上程序: #include <iostream> #include ...

  3. 多线程编程-- part5 锁的种类以及辨析

    java中的锁,可以分为同步锁和JUC包中的锁. 同步锁 通过synchronized关键字进行同步,实现对竞争资源的互斥访问的锁,. 原理:对于每一个对象,有且只有一个同步锁,在同一时间点,所有的线 ...

  4. Java 多线程编程(锁优化)

    转:https://mp.weixin.qq.com/s/lDuguEhuWiLY8ofBRy3tZA 并发环境下进行编程时,需要使用锁机制来同步多线程间的操作,保证共享资源的互斥访问. 加锁会带来性 ...

  5. (转)Linux C 多线程编程----互斥锁与条件变量

    转:http://blog.csdn.net/xing_hao/article/details/6626223 一.互斥锁 互斥量从本质上说就是一把锁, 提供对共享资源的保护访问. 1. 初始化: 在 ...

  6. python多线程编程(3): 使用互斥锁同步线程

    问题的提出 上一节的例子中,每个线程互相独立,相互之间没有任何关系.现在假设这样一个例子:有一个全局的计数num,每个线程获取这个全局的计数,根据num进行一些处理,然后将num加1.很容易写出这样的 ...

  7. Python中的多线程编程,线程安全与锁(二)

    在我的上篇博文Python中的多线程编程,线程安全与锁(一)中,我们熟悉了多线程编程与线程安全相关重要概念, Threading.Lock实现互斥锁的简单示例,两种死锁(迭代死锁和互相等待死锁)情况及 ...

  8. Python中的多线程编程,线程安全与锁(一)

    1. 多线程编程与线程安全相关重要概念 在我的上篇博文 聊聊Python中的GIL 中,我们熟悉了几个特别重要的概念:GIL,线程,进程, 线程安全,原子操作. 以下是简单回顾,详细介绍请直接看聊聊P ...

  9. C#多线程编程中的锁系统

    C#多线程编程中的锁系统(二) 上章主要讲排他锁的直接使用方式.但实际当中全部都用锁又太浪费了,或者排他锁粒度太大了. 这一次我们说说升级锁和原子操作. 目录 1:volatile 2:  Inter ...

随机推荐

  1. java1环境与简介

    java1环境与简介   Ⅰ 个人简介 陈鹏 联系方式:15828682774 2012 年至今,从事软件开发 5 年. 1 年新加坡海外工作经历. 先后在民企.外企.创业公司做过开发. 熟悉 JAV ...

  2. Mysql引擎中MyISAM和InnoDB的区别有哪些?

    简单的概括一下 InnoDB:支持事务处理等不加锁读取支持外键支持行锁不支持FULLTEXT类型的索引不保存表的具体行数,扫描表来计算有多少行DELETE 表时,是一行一行的删除InnoDB 把数据和 ...

  3. android 如何画心

    先前写了一个Windows版的画心,现在想把windows版的画心变成安卓版的. xml布局: <?xml version="1.0" encoding="utf- ...

  4. HDU - 1430 魔板 (bfs预处理 + 康托)

    对于该题可以直接预处理初始状态[0, 1, 2, 3, 4, 5, 6, 7]所有可以到达的状态,保存到达的路径,直接打印答案即可. 关于此处的状态转换:假设有初始状态为2,3,4,5,0,6,7,1 ...

  5. kolla-ansible快速入门

    kolla-ansible快速入门 kolla-ansible是一个结构相对简单的项目,它通过一个shell脚本,根据用户的参数,选择不同的playbook和不同的参数调用ansible-playbo ...

  6. QRCode 扫描二维码、扫描条形码、相册获取图片后识别、生成带 Logo 二维码、支持微博微信 QQ 二维码扫描样式

    目录 功能介绍 常见问题 效果图与示例 apk Gradle 依赖 布局文件 自定义属性说明 接口说明 关于我 功能介绍 根据之前公司的产品需求,参考 barcodescanner 改的,希望能帮助到 ...

  7. Ubuntu 卸载cario-dock

    偶然间听说别人用dock 可以把ubuntu美化,结果就装了个cairo-dock .结果是苹果mac的风格.不是很喜欢.于是就卸载,卸载过程中.发行卸载不掉. 尝试了很多方法. sudo apt-g ...

  8. dojo实现省份地市级联报错(一)

  9. 双硬盘RAID 0全攻略

    . RAID53 RAID7即高效数据传送磁盘结构,是RAID3和带区结构的统一,因此它速度比较快,也有容错功能.但价格十分高,不易于实现. 为什么需要磁盘阵列        如何增加磁盘的存取(ac ...

  10. freemarker.template.TemplateException:Error executing macro:mainSelect

    1.错误描述 freemarker.template.TemplateException:Error executing macro:mainSelect require parameter:id i ...