C#.NET Thread多线程并发编程学习与常见面试题解析-3、lock深度解析

前言:上一期讲了互斥和同步的基本概念,而且用金矿和苦工的例子讲了信号量。

一、lock使用

我们继续用金矿和苦工的例子举例,但是这一次我们不再是用控制台来简单的输出是否正在挖矿了,而是我们开一个变量用来事实的去模拟金钱的增加。
我们就让5个矿工每个矿工挖矿100次

using System;
using System.Threading;
namespace LeeCarry
{
    public class Test
    {
        private static int gold=0;
        public static void Main(string[] args)
        {
            for(int i=1;i<=5;i++)
            {
                Thread t = new Thread(DoMining);
                t.Start(i);
            }
        }
        private static void DoMining(object obj)
        {
            int id=Convert.ToInt32(obj);
            for(int i=0;i<100;i++)
            {
                gold++;        
            }
            Console.WriteLine("我是{0}号挖矿完成,现在一共有{1}黄金",id,gold);
        }
    }
}

输出结果:
C#.NET Thread多线程并发编程学习与常见面试题解析-3、lock深度解析_第1张图片
为什么会是这个样子呢,这实际上就是上一期提到的互斥问题,因为他们都想去读写同一个变量,例如A和B都想执行一次x=x+1,x假如当前是3的话,A读取到了x是3,然后可以解析为x=4。B如果是并行和A执行到x=x+1的话(此时A还没写进去),也是解析为x=4,那么最终x的结果就为4了,但我们都知道如果执行两次x=x+1应该是5才对。
那怎么解决这个问题呢?在C#当中很方便,直接用一个lock就可以了,lock参数锁住的对象必须得是一个引用类型,推荐使用object,具体的分析我们先等看了代码后再说。之后在lock语句块里面放我们要执行的内容即可。

using System;
using System.Threading;
namespace LeeCarry
{
    public class Test
    {
        private static readonly object flag=new object();
        private static int gold=0;
        public static void Main(string[] args)
        {
            for(int i=1;i<=5;i++)
            {
                Thread t = new Thread(DoMining);
                t.Start(i);
            }
        }
        private static void DoMining(object obj)
        {
            int id=Convert.ToInt32(obj);
            for(int i=0;i<100;i++)
            {
                lock(flag)
                {
                    gold++;
                }
                      
            }
            Console.WriteLine("我是{0}号挖矿完成,现在一共有{1}黄金",id,gold);
        }
    }
}

结果:
C#.NET Thread多线程并发编程学习与常见面试题解析-3、lock深度解析_第2张图片
当然这里因为几乎是同时完成的所以控制台输出顺序不一定,但是至少我们的lock语句块里面要执行的东西是不会冲突了。
使用lock锁对象的时候还要注意以下几点,原则是要锁的这个对象最好除了lock以外没有任何地方使用到

  1. 这个对象肯定要是引用类型,因为值类型多次装箱后的对象是不同的,会导致无法锁定。
  2. 不要锁定this
  3. 不要锁定一个类型对象(type)
  4. 不要锁定一个字符串,因为字符串是一个特殊的引用类型(此处是一个不小的知识点),字符串可能被驻留,不同字符对象可能指向同一个字符串。

所以还是更推荐用private readonly object(这里的readonly一个引用也是一个考点,包括readonly和cosnt区别等)。

二、lock解析

要知道lock只是C#的一个语法糖(syntactic sugar),糖虽然好吃,但是不注意的话会蛀牙的噢。

让我们先来第一步分解lock,假设x是一个引用类型,那么

lock(x)
{
    //要执行的代码
}

可以看作是(前提是使用System.Threading命名空间)

Monitor.Enter(flag);
//要执行的代码
Monitor.Exit(flag);

当然这样子是不严谨的,为什么?因为即使lock语句的正文中引发异常,也会释放 lock,所以要改成

try
{
    Monitor.Enter(flag);
    //要执行的代码
}
finally
{
    Monitor.Exit(flag);
}

当然可以写的更严谨一点,即完全等同于(来自MSDN官方解释)

object __lockObj = x;
bool __lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(__lockObj, ref __lockWasTaken);
    // Your code...
}
finally
{
    if (__lockWasTaken) System.Threading.Monitor.Exit(__lockObj);
}

这里就可以看到我们的lock其实就是Monitor的一个使用方式,那么Monitor又是一个什么呢?Monitor其实是一个混合锁,混合了什么呢?我很想在这个地方就直接展开来说,但是这样这一章篇幅就太长了,留到下一章再展开来看吧,本章还是着重看一下lock和Monitor

首先昨天讲了四种解决互斥与同步问题的方法,如果要把lock套上其中一种方法的话那就是临界区。
这里再重新介绍一下临界区(critical section),就是标识一段代码为临界区,每次只准许一个进程进入临界区,进入后不允许其他进程进入。

的确它的设计思路就是来源于临界区的,又或者说临界区本身就是一种设计思路,MSDN官方上也有说可以使用lock标识临界区,这当然是完全正确的,但C#中lock封装的Monitor能做的比临界区要多。

三、同步块和同步块索引

现在我们即使看到了lock剥去一层外衣后知道是使用Monitor后应该还是会有一个疑问,为什么锁住的是一个引用类型就可以标识他们能同步?这里就要先讲讲同步块和同步块索引了


同步块是.NET中解决对象同步问题的基本机制,该机制为每个堆内的对象(即引用类型对象实例)分配一个同步索引,该索引中只保存一个表明数组内索引的整数。具体过程是:.NET在加载时就会新建一个同步块数组,当某个对象需要被同步时,.NET会为其分配一个同步块,并且把该同步块在同步块数组中的索引加入该对象的同步块索引中。
C#.NET Thread多线程并发编程学习与常见面试题解析-3、lock深度解析_第3张图片
同步块机制包含以下几点:

  1. 在.NET被加载时初始化同步块数组;
  2. 每一个被分配在堆上的对象都会包含两个额外的字段,其中一个存储类型指针,而另外一个就是同步块索引,初始时被赋值为-1;
  3. 当一个线程试图使用该对象进入同步时,会检查该对象的同步索引:
    如果同步索引为负数,则会在同步块数组中新建一个同步块,并且将该同步块的索引值写入该对象的同步索引中;
    如果同步索引不为负数,则找到该对象的同步块并检查是否有其他线程在使用该同步块,如果有则进入等待状态,如果没有则申明使用该同步块;
  4. 当一个对象退出同步时,该对象的同步索引被修改为-1,并且相应的同步块数组中的同步块被视为不再使用。

引用:
lock 语句-MSDN
lock 语句2-MSDN
C++ 临界区 多线程同步互斥-lzg13541043726
C#中lock死锁实例教程-刘奇云
揭示同步块索引(上):从lock开始-横刀天笑的碎碎念
.NET基础拾遗(5)多线程开发基础-Edison Zhou
.NET面试题解析(07)-多线程编程与线程同步-/梦里花落知多少/

你可能感兴趣的:(并发,异步,并行,C#)