天天动画片 > 八卦谈 > 第 63 讲:多线程(五):线程同步的基本概念

第 63 讲:多线程(五):线程同步的基本概念

八卦谈 佚名 2024-03-16 00:36:53

下面进入线程同步的基本内容。线程同步不是三两句能说完的话题,所以可能还会有比较多的内容,希望你做好准备,特别是心理上的准备(滑稽

Part 1 什么是线程同步?为什么要线程同步?

我们试着给大家解释这个线程同步,那么就需要一个例子。我们使用一个最普通的对抗加减法的例子给大家介绍线程同步。

假设一个数字从 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 个并发的线程”;而并行,指的是执行期间线程和线程之间互不干扰,它们都在执行。所以并发是说明开始的情况,而并行则说明的是期间的过程的情况。这里的概念定义是说,如果多线程是一并开始的,那么它们自然就有了关联(只是说互不影响、互不干扰地执行着,但是由我们写的代码全部挨个“发射出去”的线程,所以受我们控制,因此它们也算是有了关系)。

Part 2 同步线程的办法

2-1 使用 Monitor 类型提供的方法锁定和解除锁定

要让多个线程同步起来,办法其实有很多。我们来看第一个方法:Monitor 类型。Monitor 有两个意思,“监视器”和“班长”的意思。显然这里肯定不能翻译成“班长”所以就只能取第一个意思了。所谓的“监视器”,就是充当一个监控摄像头的角色,监控执行过程。一旦发现别的线程在访问这个变量但你这个变量此时还尚未完成一些行为(比如赋值和自增自减等本不是原子行为但我期望它是原子行为的行为),我可以直接封锁现场,让这个线程先给我卡住,你先不要动,我这边先把数据处理了你再来。

翻译成代码的话,就是两个方法配套起来用:Monitor.EnterMonitor.Exit。这两个都是静态方法。第一个方法 Enter 作用是开启监控过程,而 Exit 方法则是退出监控过程。所以代码初步应该长这样:

不过,这里只能说是初步情况。这个代码存在两个问题。第一是,为什么需要这个 Exit 方法,第二是抛异常了咋办。

第一,如果这个监控对象如果长期不释放的话,它就会起到监控的作用导致别的线程无法进入这段代码继续执行。如果我们不配合 Exit 方法使用的话,只有 Enter 启用代码段就会造成监控对象长期占据执行过程,使得任何别的线程都无法继续访问下面的代码。这就很奇怪了,是吧。所以必须要配合使用。

第二,如果执行同步代码的时候抛异常咋办?抛异常不要紧,关键是这个监控对象本身。这个监控对象自己会牺牲拿来监控执行过程,可它自己在多线程执行期间是不可变动的。就好比一个比赛,上了一个裁判参与比赛的结果裁决,结果比赛期间出现异常情况了,裁判要是不摆脱异常情况这个困境,他自己就会卡死在现场。(好吧实际上是这样的:选手比如说贫血进医院了,这个时候选手就该医生管了,这不属于裁判的事情,但裁判一直没有被要求退出比赛过程,那么它就会在这个比赛中一直判决已经中断了的比赛过程,可这显然没有意义。比赛都中断了还判个什么劲儿呢……)所以当然裁判先得脱身然后让一些别的人来看具体选手或者现场都发生啥异常了,怎么解决,对吧。所以,我们还需要一个 finally 块来解除这个监控对象的监控行为:我需要在 Enter 和同步代码的外侧加上一个 try 块,然后 Exit 方法外侧加一个 finally 块。

只有 tryfinally 就可以。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 其实就是暗示这个对象用于这里的线程同步。所以才会这么取名。另外,你以后会经常看到这样的命名存在。

2-2 使用 lock 关键字

实际上,lock 关键字在之前就说过了。不过这里再说一次是因为它就是用来同步线程的。而它刚好的底层实现原理就是用的 Monitor,所以这里可以再说一下。

比如上面的代码,我们使用的是 Monitor 类型的 ExitEnter 方法。但开发人员很有可能会在写代码的时候忘记写 Exit 方法了啊、忘记 try-finally 了啊之类的。所以 C# 为了避免这样的现象,用了一个 lock 关键字来避免用户错误使用 Monitor 类型的这两个方法的调用。

这样的代码效果和之前的写法没有差别。这样写简单了,而且帮助了开发人员避免了一些书写代码上的错误:lock 后跟的变量就是 Monitor.EnterMonitor.Exit 的同步锁对象,bool lockTaken 被这个语句隐藏了不写出来;lock 语句的开大括号就意味着 Monitor.Enter 语句的发出,大括号里的语句就是 try 块的后面那部分同步代码(也叫关键代码);最后出了这个 lock 语句的大括号也就等于是调用了 Monitor.Exit 语句。

2-3 同步锁的选取

同步锁的概念不只是在 Monitor.EnterMonitor.Exit 方法里使用,它也在 lock 语句里使用。不过,同步锁不是所有东西都可以传入进去。这个监控对象要想起到监控的效果和作用,很多东西其实都不允许。我们来看有哪些是不行的。

2-3-1 必须是引用类型,不能是值类型

首先有一个点要说。我们仔细观察 Monitor.EnterMonitor.Exit 方法可以发现,它们传入的第一个参数,接收是用的 object 类型接收的。假设我传入了一个值类型的对象进去,那么值类型遇到 object 这个引用类型就必然会发生装箱过程。装箱的最终结果是什么呢?在堆内存里创建一块内存存储这个数值。但问题就在这里。由于我调用 Monitor.Exit 的时候也是 object 类型接收的第一个参数。就算是传入的是相同的数值过去,但因为 object 类型接收的关系,必然会导致装箱,装箱又必然导致创建新内存,那么 EnterExit 传入而产生的装箱,会不会是同一块内存?当然不是。

地址都不一样了,那我监控什么?我 EnterExit 的对象都不是一个东西,而我现在传参进去的变量本身是一个数值,Enter 接收的对象是另外一个数值,Exit 接收的又是一个新的数值,这三个结果全部各自都没关系。所以我使用值类型就必然导致我无法释放同步锁的监控状态。因此,我们不建议使用值类型作为同步锁的对象的实例。

2-3-2 必须是 private 修饰的

这个其实很好理解,至少比刚才不让用值类型的约束好理解多了。同步锁要求我临时监控数据信息,它主要体现的作用在“临时”上。如果我 public 化或者 protected 化,或者 internal 化这个同步锁,就势必可能在任何一处别的位置调用到它。而此时 private 才是你写代码的可控范畴。只有 private 的修饰符才完全受你写代码的掌控。如果你写的是比如 protected 的修饰符的话,这个同步锁必然就可以在派生类里看到它。不论派生类是你自己写的,还是别人用的你这套 API 写的,这个同步锁就必然会被看到,于是对方就可能会拿来干坏事,或者不知情的情况下用来做别的事情。同步锁只用来同步线程,而且是你在控制范畴下同步线程,所以不能容许任何其它情况使用它。当然,你写了 private 结果你自己又在乱使用这个同步锁对象,那么……当我没说。

2-3-3 必须是 static 修饰的

同步锁应该确保我任何必需的时候都可以即刻拿到对象。所以我必须加上 static 修饰符,让它在程序开始运行的时候就创建好它。这样我才不至于我还得实例化对象了之后才能使用它。要知道,实例成员和静态成员调用都可以使用静态对象,但这个静态对象只能在静态成员里进行调用,它们不是对称的关系。

2-3-4 必须是 readonly 修饰的

同步锁必须确保对象只读。不然你随便篡改修改对象的数值,就起不到同步锁该起到的作用。它是一个锁,用来同步线程,你篡改它的数值也没有任何意义。

2-3-5 最好是一个字段

既然有 privatestaticreadonly 修饰,那么能产生这样的情况的成员类别只有字段和属性两种了。但是属性的话,每一次都会产生一个新的对象(用 get 方法):

这么写是没错, 但……每次我用 SyncRoot 我都创建新对象,是不是有点欠妥?属性本质还是两个方法构成的(getset),所以还是不建议使用。因此能用的就只剩下字段了。

等会儿。thistypeof 表达式这些东西其实也都可以。所以先别急。先继续看看后面的内容吧。

2-3-6 避免 lock (this)

众所周知,小括号里的东西是直接传入到 Monitor 的那两个方法里当参数使用的。那么既然是参数就必然是什么可能情况都会有,比如传入一个字符串字面量进去,比如我传入两个字符串拼接的表达式结果过去(比如 a + b 什么的),我甚至传入一个 typeof 表达式也行的。对吧。因为 object 对象接收和支持任何非指针类型的对象赋值过去。

我们来说一下,这些东西到底可以不可以。首先来说 this 对象。this 表示当前实例成员的环境下可以使用的特殊对象,它表示当前对象自己。具体在调用和计算的时候,是什么对象,就把这个对象当成 this 替换替代过去就可以了。可是,我如果把同步锁用 this 的话,且不说它是不是值类型,我们就假设它是引用类型,思考一下可不可以。

显然不好。因为我直接把对象本身拿来当监控对象的话,对象自己就当裁判上场了(刚才那个举例)。那么问题来了,你在这个比赛上当裁判,可不可能别的比赛也需要你?完全是有可能的嘛。你这个 this 只是一个代号,它可以替换为我们具体执行的一个对象。而问题就在于,this 关键字代替的这个对象是从外界代码里传入的对象的一个抽象概念,而外界代码你是无法控制的,你完全可能会在别处产生两个对象同时被 lock 锁掉。

这么说有点抽象,我来举个例子。假设我一个方法 P 包含了 lock 语句锁的是 this 对象。

而我完全不知情。我在外面调用代码我是看不到 P 里面的代码的。现在我试着调用代码的时候:

这么做可以,对吧。它会把我们传过来的 i 锁住。这个 i 是实例,它就替换到底层代码里的 this,做一个替换。

可问题是,我在后续的代码里还在用 i,咋办?同步锁是只用于同步线程的,它自己是实例本身就是用作计算和调用实例应该做的事情,而现在它又在做同步线程的事情,至少从良构类型里就算是违背了单一职责原则了吧。这足够我寄刀片过了吧。

再说了,你这个 i 进入 P 后就被锁住了,我还拿来做别的事情,显然是不行的。你当裁判可以看两个选手在干什么,可以建立线程和线程的关系(通过你自己),但你自己是一个单独的对象啊,你在这个时候只能做一件事情,就是当裁判。这个时候你裁判只能做裁判的事,别的事情都做不了。你想想,是不是这个道理。

2-3-7 避免 lock (typeof(T))

this 都不允许了,那么 typeof 应该也不合适了。typeof 是什么?typeof 是一个表达式,它最后会得到一个固有实例,这个实例可用来参与和计算你这个类型的基本反射信息。但是,难不成我两次调用 typeof(int),会产生两个不同的对象吗?肯定不可能。因为我程序集的 int 的反射信息我只需要一份就行了。因为,表示这个 int 类型的字段、属性有多少个,分别是哪些。这些信息在程序集跑起来、运行起来的时候是不可变的,因为它们都在元数据里。

既然都不可变了,那么我两次调用 typeof(int),你想想会不会可能是两个不同的对象?是的,C# 的反射机制会使得两次调用获取的是同一个反射信息提供对象。

那么,既然能产生同样的实例,就必然会出现刚才 this 的错误使用情况。我拿一个当裁判,结果又让它去做别的事情,显然不可能。所以 typeof(T) 也不适合用于同步锁。

2-3-8 避免字符串类型的变量作同步锁

最后。字符串也是一个引用类型,看起来它可以实例化在字段里:

好像前面的条件我都满足,那么字符串是否适合用于同步锁呢?

答案是否定的。原因在于字符串的底层存储机制。在 C# 的底层,字符串会按照引用类型的操作进行实例化、赋值、取值和计算。但是它和普通对象不同的地方在于,字符串拥有一个和普通数据类型不同的存储机制:拘留池(Intern Pool)。

为了优化使用字符串,C# 允许相同的字符串实例会缓存到拘留池里,使得它们的地址也相同。举个例子。

你猜猜这个计算结果是什么?我们知道即使数值相同,但地址不一致,是引用类型的基本操作了已经。但是字符串因为字符串内容一致的关系,较短的字符串会被丢进拘留池里,因此 st 会保持一致的地址数值。因此不论比较 ReferenceEquals 还是比较字符串内容,st 都应该是一样的,因为它们就是同一个对象。

既然如此,字符串进入了拘留池,那么如果你实例化了一个相同字面量的字符串,也很有可能因为拘留池存有这个字符串,因而导致两个字符串是同一个变量。所以,这又回到了刚才 thistypeof 表达式里那个问题了。

因此,字符串也不建议作为同步锁对象。

2-4 用 MethodImplAttribute 特性

C# 里有一个神奇的特性,可以在标记了方法之后改变方法在运行时的执行效果。

其实,也不算神奇。因为特性就是拿来做这个事的。特性就是高配版的注释文字,给成员啊、参数打上标签,这样以后我可以通过反射获取它们,改变执行意义,这确实是特性本来的目的。

MethodImplAttribute 特性可以改变方法执行的时候的行为。它需要传入一个参数,是 MethodImplOptions 类型的枚举,其中有一个数值是 MethodImplOptions.Synchronized,一旦标记上去后,方法整体就是方法级的线程同步形式执行了。

用法是这样的:

这样的话,整个方法基本等价于这样:

是的。当然,这是实例方法。如果方法是静态方法的话,标记这个特性上去,就不是等价于 lock (this) 了,而是等价于包裹的 lock (typeof(类型)) 了。但是前文说过,我们不建议使用 lock (this)lock (typeof(类型)) 的东西,所以,这个特性我们也不建议使用。

2-5 使用 Interlocked 类型提供的方法

是的。如果单纯只是解决自增和自减方法的话,你可以使用 Interlocked 静态类型里提供的 IncrementDecrement 方法,来达到即使没有使用 lock 语句也可以享受 lock 环境下的锁定行为。

你甚至不必写出 lock 语句,直接替换掉原本的 ++--,改成 Interlocked.Increment(ref Result)Interlocked.Decrement(ref Result) 就可以了:

你甚至不需要写 SyncRoot。这样运行的结果照样是 0:

因为它是自带的锁定行为,因此我们不需要 SyncRoot 也可以达到锁定效果;另外,这个方法不是 C# 语法能实现的,因此你即使查看源代码也无法找到它的源代码;虽然看不到代码,但我们仍然推荐你使用这个方法来达到自增自减和 lock 语句等同的行为。

当然,除了使用 DecrementIncrement 方法外,Interlocked 还提供了一个等价的加减法运算方法:Add。这个方法可以允许我们对一个数值指定增大或减小多少,等价于一般代码的 +=-= 运算符。

比如这样就跟 Interlocked.Decrement(ref Result) 是一个效果。

本文标题:第 63 讲:多线程(五):线程同步的基本概念 - 八卦谈
本文地址:www.ttdhp.com/article/51109.html

天天动画片声明:登载此文出于传递更多信息之目的,并不意味着赞同其观点或证实其描述。
扫码关注我们