下面进入线程同步的基本内容。线程同步不是三两句能说完的话题,所以可能还会有比较多的内容,希望你做好准备,特别是心理上的准备(滑稽
我们试着给大家解释这个线程同步,那么就需要一个例子。我们使用一个最普通的对抗加减法的例子给大家介绍线程同步。
假设一个数字从 0 开始。主线程执行减法让它持续减少一个单位;而我们使用线程池调取一个线程让这个数不断增大一个单位。那么因为多线程的不可再现性,我们无法知道结果是多少。
我们开始运行程序。程序运行结果基本上可以说不可能是 0:
而且这个是其中一种答案。对吧。因为不可再现性你根本控制不了顺序计算的结果是多少,所以结果可能天马行空。
真正的原因是因为线程的不可再现性导致的加法和减法不可能一对一地执行,正常的数学知识告诉我们,正常的逻辑下肯定是一加一减最后一定是 0,但在多线程里会有这样的情况, 所以结果是根本不确定的。
还有一个问题是这里的 ++
和 --
操作。因为我之前就说过, 它被翻译成增大一个单位和减小一个单位后重新赋值给变量本身的过程,因此这个操作本身也不是原子性的。所以很有可能在底层执行的时候,时间片一到,线程切换执行(即术语的上下文切换),这个变量数值还没有发生变化就留给别的线程执行去了,所以数值也会奇奇怪怪的。
甚至……多线程里还有一个神奇的赋值过程。如果一个对象比较大的话(占据内存空间比较多的时候),在多线程下赋值可能还没赋值完成就切换上下文了,线程就会执行别的操作,但这个变量的数据都还没拷贝完。这种错误线程在术语词里称为 torn read。其中 torn 是 tear 的过去分词,除了“眼泪”的意思外还可以作为动词,表示“撕裂”的意思,所以这个 torn read 大概就是“读取撕裂”的意思,暗示数据都被撕裂断开了。
正常情况下,一个非 torn read 的大小是不超过你电脑位数这么大的内存空间的数据可以是原子读取的。比如说你的电脑是 64 位的,那么就意味着你的电脑可以保证在多线程里,一个 64 位(一个
long
大小)的变量可以正确地、原子性地读取成功。但是 C# 里有一个叫decimal
的数据类型,它占 128 位的空间。如果多线程里拷贝一个decimal
的变量的话,就可能出现 torn read 现象。很有可能多线程还没有拷贝完这个数据,上下文切换了,数据就只保留到了其中一部分拷贝成功的这部分的大小。
所以,这个程序看起来好像只是一个演示程序,但漏洞百出(起码在多线程里是漏洞百出)。归结到本质上,就是因为读写数据都是发生在同一个变量(这个静态的字段 Result
)上的。因为两个线程同时都在更改同一个变量的数值,所以我们无从知晓多线程的真实读写情况。为了解决这个问题,我们不得不需要线程同步。
那么,现在就来告诉大家,什么是线程同步。线程同步(Thread Synchronization)就是让多个线程在本身不期望这样执行的情况下,以书写代码的方式控制这些线程在某个时刻下能够按照我们期望的方式继续执行下去的行为。换句话说,就是不可控的情况转为可控的情况;而从这个角度出发,既然我们可以控制线程同步了,那么自然而然地,线程就具备了执行正确性、有效性的条件,所以在一些教材书籍上也利用线程同步定义了线程安全的概念:如果能同步多个线程对代码或者数据的并发访问,就可以说这些代码和数据是线程安全的。
注意这个“并发”的概念。并发(Concurrency)指的是多个线程是一起开始的,就好比比赛的时候,多个选手一起从起跑线出发跑出去,那么比如说 8 位选手要去终点,那么我们可以用多线程的角度来解释这个现象,就是“8 个并发的线程”;而并行,指的是执行期间线程和线程之间互不干扰,它们都在执行。所以并发是说明开始的情况,而并行则说明的是期间的过程的情况。这里的概念定义是说,如果多线程是一并开始的,那么它们自然就有了关联(只是说互不影响、互不干扰地执行着,但是由我们写的代码全部挨个“发射出去”的线程,所以受我们控制,因此它们也算是有了关系)。
Monitor
类型提供的方法锁定和解除锁定要让多个线程同步起来,办法其实有很多。我们来看第一个方法:Monitor
类型。Monitor
有两个意思,“监视器”和“班长”的意思。显然这里肯定不能翻译成“班长”所以就只能取第一个意思了。所谓的“监视器”,就是充当一个监控摄像头的角色,监控执行过程。一旦发现别的线程在访问这个变量但你这个变量此时还尚未完成一些行为(比如赋值和自增自减等本不是原子行为但我期望它是原子行为的行为),我可以直接封锁现场,让这个线程先给我卡住,你先不要动,我这边先把数据处理了你再来。
翻译成代码的话,就是两个方法配套起来用:Monitor.Enter
和 Monitor.Exit
。这两个都是静态方法。第一个方法 Enter
作用是开启监控过程,而 Exit
方法则是退出监控过程。所以代码初步应该长这样:
不过,这里只能说是初步情况。这个代码存在两个问题。第一是,为什么需要这个 Exit
方法,第二是抛异常了咋办。
第一,如果这个监控对象如果长期不释放的话,它就会起到监控的作用导致别的线程无法进入这段代码继续执行。如果我们不配合 Exit
方法使用的话,只有 Enter
启用代码段就会造成监控对象长期占据执行过程,使得任何别的线程都无法继续访问下面的代码。这就很奇怪了,是吧。所以必须要配合使用。
第二,如果执行同步代码的时候抛异常咋办?抛异常不要紧,关键是这个监控对象本身。这个监控对象自己会牺牲拿来监控执行过程,可它自己在多线程执行期间是不可变动的。就好比一个比赛,上了一个裁判参与比赛的结果裁决,结果比赛期间出现异常情况了,裁判要是不摆脱异常情况这个困境,他自己就会卡死在现场。(好吧实际上是这样的:选手比如说贫血进医院了,这个时候选手就该医生管了,这不属于裁判的事情,但裁判一直没有被要求退出比赛过程,那么它就会在这个比赛中一直判决已经中断了的比赛过程,可这显然没有意义。比赛都中断了还判个什么劲儿呢……)所以当然裁判先得脱身然后让一些别的人来看具体选手或者现场都发生啥异常了,怎么解决,对吧。所以,我们还需要一个 finally
块来解除这个监控对象的监控行为:我需要在 Enter
和同步代码的外侧加上一个 try
块,然后 Exit
方法外侧加一个 finally
块。
只有 try
和 finally
就可以。catch
可以“吃掉”异常让这个程序继续运行也不会闪退,但有些时候我们更期望它抛出来,反正异常又不是特别严重的问题,不会造成内存溢出的这种复杂问题,所以抛出异常有时候还可以帮助我们找一些 bug。不要什么异常都“吃掉”。
这个“一个监控行为的临时对象”和“刚才的那个监控对象”我们称为同步锁(Synchronization Lock)。换句话说就是,使用同步锁来解决线程不同步的问题。
可……finally
里的 if
条件,为什么是线程还在执行中,才 Exit
退出监控啊?不应该是没有执行的时候退出监控吗?这个问题你得反过来想:正是因为线程正在进行之中,才需要我们退出监控。如果线程都没有执行,那么这个 bool
变量会保持 false
的数值。这个时候我们退出个什么劲儿呢?
还记得吧,
ref
参数表示它会同时影响调用方和方法内执行的同名的变量。这里如果我们没有bool
的话,这个外部传入的bool
变量就无法得到记录。我在某个时候变更了这个变量的数值,自然我更希望调用方(这个try
块的代码)立刻知晓这个变更。所以我当然是需要用ref
修饰参数了。
那么,回到原来的例子上去,我们需要把 Result++
和 Result--
包裹起来,用这个 try
-finally
包裹起来。
比如这样。我们重新运行程序,在程序运行了一会儿之后,这次我们就可以看到正确的结果显示了:
因为这个时候,我们同步了代码之后,多线程虽然看起来是两个线程并行执行的,但有了同步锁之后,我们保证了线程必须在执行自增自减完成后才能继续执行别的代码,这样就不会出现多线程的不可再现,就是数据没有完成自增自减就切换上下文的问题。
虽然多线程仍会出现主线程和线程池后台线程不知道谁更先执行谁更后执行的问题,但是我们不妨思考一个问题。就这个例子来说,谁先谁后是不是都不影响?我从 0 开始计算的话,我试着先 +1 后再 -1,和我先 -1 后 +1 计算出来的结果是不是都应该是 0?所以我们压根无关线程本身的谁先谁后,我只需要交替执行就可以了。
代码里的这个
SyncRoot
就不多解释了吧。我讲了同步锁的概念之后你应该就能明白为什么这里叫做SyncRoot
了吧:sync 是 synchronized 的缩写,root 则是指代一个对象。C# 里把所有受到 GC 管控范围的对象都称为根。因为每一个元素实际上都被当成了一棵树的顶级元素,它们会牵连别的东西,而它自己的销毁和使用都会影响别的元素。所以,刚好作为树的根出现,所以称为根。而 sync root 其实就是暗示这个对象用于这里的线程同步。所以才会这么取名。另外,你以后会经常看到这样的命名存在。
lock
关键字实际上,lock
关键字在之前就说过了。不过这里再说一次是因为它就是用来同步线程的。而它刚好的底层实现原理就是用的 Monitor
,所以这里可以再说一下。
比如上面的代码,我们使用的是 Monitor
类型的 Exit
和 Enter
方法。但开发人员很有可能会在写代码的时候忘记写 Exit
方法了啊、忘记 try
-finally
了啊之类的。所以 C# 为了避免这样的现象,用了一个 lock
关键字来避免用户错误使用 Monitor
类型的这两个方法的调用。
这样的代码效果和之前的写法没有差别。这样写简单了,而且帮助了开发人员避免了一些书写代码上的错误:lock
后跟的变量就是 Monitor.Enter
和 Monitor.Exit
的同步锁对象,bool lockTaken
被这个语句隐藏了不写出来;lock
语句的开大括号就意味着 Monitor.Enter
语句的发出,大括号里的语句就是 try
块的后面那部分同步代码(也叫关键代码);最后出了这个 lock
语句的大括号也就等于是调用了 Monitor.Exit
语句。
同步锁的概念不只是在 Monitor.Enter
和 Monitor.Exit
方法里使用,它也在 lock
语句里使用。不过,同步锁不是所有东西都可以传入进去。这个监控对象要想起到监控的效果和作用,很多东西其实都不允许。我们来看有哪些是不行的。
首先有一个点要说。我们仔细观察 Monitor.Enter
和 Monitor.Exit
方法可以发现,它们传入的第一个参数,接收是用的 object
类型接收的。假设我传入了一个值类型的对象进去,那么值类型遇到 object
这个引用类型就必然会发生装箱过程。装箱的最终结果是什么呢?在堆内存里创建一块内存存储这个数值。但问题就在这里。由于我调用 Monitor.Exit
的时候也是 object
类型接收的第一个参数。就算是传入的是相同的数值过去,但因为 object
类型接收的关系,必然会导致装箱,装箱又必然导致创建新内存,那么 Enter
和 Exit
传入而产生的装箱,会不会是同一块内存?当然不是。
地址都不一样了,那我监控什么?我 Enter
和 Exit
的对象都不是一个东西,而我现在传参进去的变量本身是一个数值,Enter
接收的对象是另外一个数值,Exit
接收的又是一个新的数值,这三个结果全部各自都没关系。所以我使用值类型就必然导致我无法释放同步锁的监控状态。因此,我们不建议使用值类型作为同步锁的对象的实例。
private
修饰的这个其实很好理解,至少比刚才不让用值类型的约束好理解多了。同步锁要求我临时监控数据信息,它主要体现的作用在“临时”上。如果我 public
化或者 protected
化,或者 internal
化这个同步锁,就势必可能在任何一处别的位置调用到它。而此时 private
才是你写代码的可控范畴。只有 private
的修饰符才完全受你写代码的掌控。如果你写的是比如 protected
的修饰符的话,这个同步锁必然就可以在派生类里看到它。不论派生类是你自己写的,还是别人用的你这套 API 写的,这个同步锁就必然会被看到,于是对方就可能会拿来干坏事,或者不知情的情况下用来做别的事情。同步锁只用来同步线程,而且是你在控制范畴下同步线程,所以不能容许任何其它情况使用它。当然,你写了 private
结果你自己又在乱使用这个同步锁对象,那么……当我没说。
static
修饰的同步锁应该确保我任何必需的时候都可以即刻拿到对象。所以我必须加上 static
修饰符,让它在程序开始运行的时候就创建好它。这样我才不至于我还得实例化对象了之后才能使用它。要知道,实例成员和静态成员调用都可以使用静态对象,但这个静态对象只能在静态成员里进行调用,它们不是对称的关系。
readonly
修饰的同步锁必须确保对象只读。不然你随便篡改修改对象的数值,就起不到同步锁该起到的作用。它是一个锁,用来同步线程,你篡改它的数值也没有任何意义。
既然有 private
、static
和 readonly
修饰,那么能产生这样的情况的成员类别只有字段和属性两种了。但是属性的话,每一次都会产生一个新的对象(用 get
方法):
这么写是没错, 但……每次我用 SyncRoot
我都创建新对象,是不是有点欠妥?属性本质还是两个方法构成的(get
和 set
),所以还是不建议使用。因此能用的就只剩下字段了。
等会儿。this
和 typeof
表达式这些东西其实也都可以。所以先别急。先继续看看后面的内容吧。
lock (this)
众所周知,小括号里的东西是直接传入到 Monitor
的那两个方法里当参数使用的。那么既然是参数就必然是什么可能情况都会有,比如传入一个字符串字面量进去,比如我传入两个字符串拼接的表达式结果过去(比如 a + b
什么的),我甚至传入一个 typeof
表达式也行的。对吧。因为 object
对象接收和支持任何非指针类型的对象赋值过去。
我们来说一下,这些东西到底可以不可以。首先来说 this
对象。this
表示当前实例成员的环境下可以使用的特殊对象,它表示当前对象自己。具体在调用和计算的时候,是什么对象,就把这个对象当成 this
替换替代过去就可以了。可是,我如果把同步锁用 this
的话,且不说它是不是值类型,我们就假设它是引用类型,思考一下可不可以。
显然不好。因为我直接把对象本身拿来当监控对象的话,对象自己就当裁判上场了(刚才那个举例)。那么问题来了,你在这个比赛上当裁判,可不可能别的比赛也需要你?完全是有可能的嘛。你这个 this
只是一个代号,它可以替换为我们具体执行的一个对象。而问题就在于,this
关键字代替的这个对象是从外界代码里传入的对象的一个抽象概念,而外界代码你是无法控制的,你完全可能会在别处产生两个对象同时被 lock
锁掉。
这么说有点抽象,我来举个例子。假设我一个方法 P
包含了 lock
语句锁的是 this
对象。
而我完全不知情。我在外面调用代码我是看不到 P
里面的代码的。现在我试着调用代码的时候:
这么做可以,对吧。它会把我们传过来的 i
锁住。这个 i
是实例,它就替换到底层代码里的 this
,做一个替换。
可问题是,我在后续的代码里还在用 i
,咋办?同步锁是只用于同步线程的,它自己是实例本身就是用作计算和调用实例应该做的事情,而现在它又在做同步线程的事情,至少从良构类型里就算是违背了单一职责原则了吧。这足够我寄刀片过了吧。
再说了,你这个 i
进入 P
后就被锁住了,我还拿来做别的事情,显然是不行的。你当裁判可以看两个选手在干什么,可以建立线程和线程的关系(通过你自己),但你自己是一个单独的对象啊,你在这个时候只能做一件事情,就是当裁判。这个时候你裁判只能做裁判的事,别的事情都做不了。你想想,是不是这个道理。
lock (typeof(T))
this
都不允许了,那么 typeof
应该也不合适了。typeof
是什么?typeof
是一个表达式,它最后会得到一个固有实例,这个实例可用来参与和计算你这个类型的基本反射信息。但是,难不成我两次调用 typeof(int)
,会产生两个不同的对象吗?肯定不可能。因为我程序集的 int
的反射信息我只需要一份就行了。因为,表示这个 int
类型的字段、属性有多少个,分别是哪些。这些信息在程序集跑起来、运行起来的时候是不可变的,因为它们都在元数据里。
既然都不可变了,那么我两次调用 typeof(int)
,你想想会不会可能是两个不同的对象?是的,C# 的反射机制会使得两次调用获取的是同一个反射信息提供对象。
那么,既然能产生同样的实例,就必然会出现刚才 this
的错误使用情况。我拿一个当裁判,结果又让它去做别的事情,显然不可能。所以 typeof(T)
也不适合用于同步锁。
最后。字符串也是一个引用类型,看起来它可以实例化在字段里:
好像前面的条件我都满足,那么字符串是否适合用于同步锁呢?
答案是否定的。原因在于字符串的底层存储机制。在 C# 的底层,字符串会按照引用类型的操作进行实例化、赋值、取值和计算。但是它和普通对象不同的地方在于,字符串拥有一个和普通数据类型不同的存储机制:拘留池(Intern Pool)。
为了优化使用字符串,C# 允许相同的字符串实例会缓存到拘留池里,使得它们的地址也相同。举个例子。
你猜猜这个计算结果是什么?我们知道即使数值相同,但地址不一致,是引用类型的基本操作了已经。但是字符串因为字符串内容一致的关系,较短的字符串会被丢进拘留池里,因此 s
和 t
会保持一致的地址数值。因此不论比较 ReferenceEquals
还是比较字符串内容,s
和 t
都应该是一样的,因为它们就是同一个对象。
既然如此,字符串进入了拘留池,那么如果你实例化了一个相同字面量的字符串,也很有可能因为拘留池存有这个字符串,因而导致两个字符串是同一个变量。所以,这又回到了刚才 this
、typeof
表达式里那个问题了。
因此,字符串也不建议作为同步锁对象。
MethodImplAttribute
特性C# 里有一个神奇的特性,可以在标记了方法之后改变方法在运行时的执行效果。
其实,也不算神奇。因为特性就是拿来做这个事的。特性就是高配版的注释文字,给成员啊、参数打上标签,这样以后我可以通过反射获取它们,改变执行意义,这确实是特性本来的目的。
MethodImplAttribute
特性可以改变方法执行的时候的行为。它需要传入一个参数,是 MethodImplOptions
类型的枚举,其中有一个数值是 MethodImplOptions.Synchronized
,一旦标记上去后,方法整体就是方法级的线程同步形式执行了。
用法是这样的:
这样的话,整个方法基本等价于这样:
是的。当然,这是实例方法。如果方法是静态方法的话,标记这个特性上去,就不是等价于 lock (this)
了,而是等价于包裹的 lock (typeof(类型))
了。但是前文说过,我们不建议使用 lock (this)
和 lock (typeof(类型))
的东西,所以,这个特性我们也不建议使用。
Interlocked
类型提供的方法是的。如果单纯只是解决自增和自减方法的话,你可以使用 Interlocked
静态类型里提供的 Increment
和 Decrement
方法,来达到即使没有使用 lock
语句也可以享受 lock
环境下的锁定行为。
你甚至不必写出 lock
语句,直接替换掉原本的 ++
和 --
,改成 Interlocked.Increment(ref Result)
和 Interlocked.Decrement(ref Result)
就可以了:
SyncRoot
因为它是自带的锁定行为,因此我们不需要 SyncRoot
lock
语句等同的行为。
当然,除了使用 Decrement
和 Increment
方法外,Interlocked
还提供了一个等价的加减法运算方法:Add
。这个方法可以允许我们对一个数值指定增大或减小多少,等价于一般代码的 +=
和 -=
运算符。
Interlocked.Decrement(ref Result)
「艾尔登法环」梅琳娜手办开订 立体手办▪
万代「艾尔登法环」白狼战鬼手办开订 立体手办▪
「夏目友人帐」猫咪老师粘土人开订 立体手办▪
「五等分的新娘∬」中野三玖·白无垢版手办开订 立体手办▪
「海贼王」乌索普Q版手办开订 立体手办▪
良笑社「初音未来」新手办开订 立体手办▪
「黑岩射手DAWN FALL」死亡主宰手办开订 立体手办▪
「盾之勇者成名录」菲洛手办登场 立体手办▪
「魔法少女小圆」美树沙耶香手办开订 立体手办▪
「咒术回战」七海建人粘土人登场 立体手办▪
「五等分的新娘」中野二乃白无垢手办开订 立体手办▪
「为美好的世界献上祝福!」芸芸粘土人开订 立体手办▪
「公主连结 与你重逢」六星可可萝手办开订 立体手办▪
「女神异闻录5」Joker雨宫莲手办开订 立体手办▪
「间谍过家家」约尔・福杰粘土人登场 立体手办▪
「街角魔族 2丁目」吉田优子手办开订 立体手办▪
「火影忍者 疾风传」旗木卡卡西·暗部版粘土人登场 立体手办▪
「佐佐木与宫野」宫野由美粘土人开订 立体手办▪
「盾之勇者成名录」第2季拉芙塔莉雅手办开订 立体手办▪
「咒术回战」两面宿傩Q版坐姿手办开订 立体手办▪
「DATE·A·BULLET」时崎狂三手办开订 立体手办▪
「狂赌之渊××」早乙女芽亚里粘土人开订 立体手办▪
「魔道祖师」魏无羨粘土人开订 立体手办▪
「新·奥特曼」奥特曼手办现已开订 立体手办▪