GO语⾔并发编程之互斥锁、读写锁详解
在本节,我们对Go语⾔所提供的与锁有关的API进⾏说明。这包括了互斥锁和读写锁。我们在第6章描述过互斥锁,但却没有
提到过读写锁。这两种锁对于传统的并发程序来说都是⾮常常⽤和重要的。
⼀、互斥锁
互斥锁是传统的并发程序对共享资源进⾏访问控制的主要⼿段。它由标准库代码包sync中的Mutex结构体类型代表。
类型(确切地说,是*类型)只有两个公开⽅法——Lock和Unlock。顾名思义,前者被⽤于锁定当前的
互斥量,⽽后者则被⽤来对当前的互斥量进⾏解锁。
类型的零值表⽰了未被锁定的互斥量。也就是说,它是⼀个开箱即⽤的⼯具。我们只需对它进⾏简单声明就可以正
常使⽤了,就像这样:
复制代码代码如下:
()
在我们使⽤其他编程语⾔(⽐如C或Java)的锁类⼯具的时候,可能会犯的⼀个低级错误就是忘记及时解开已被锁住的锁,从
⽽导致诸如流程执⾏异常、线程执⾏停滞甚⾄程序死锁等等⼀系列问题的发⽣。然⽽,在Go语⾔中,这个低级错误的发⽣⼏
率极低。其主要原因是有defer语句的存在。
我们⼀般会在锁定互斥锁之后紧接着就⽤defer语句来保证该互斥锁的及时解锁。请看下⾯这个函数:
复制代码代码如下:
funcwrite(){
()
()
//省略若⼲条语句
}
函数write中的这条defer语句保证了在该函数被执⾏结束之前互斥锁mutex⼀定会被解锁。这省去了我们在所有return语句之前
以及异常发⽣之时重复的附加解锁操作的⼯作。在函数的内部执⾏流程相对复杂的情况下,这个⼯作量是不容忽视的,并且极
易出现遗漏和导致错误。所以,这⾥的defer语句总是必要的。在Go语⾔中,这是很重要的⼀个惯⽤法。我们应该养成这种良
好的习惯。
对于同⼀个互斥锁的锁定操作和解锁操作总是应该成对的出现。如果我们锁定了⼀个已被锁定的互斥锁,那么进⾏重复锁定操
作的Goroutine将会被阻塞,直到该互斥锁回到解锁状态。请看下⾯的⽰例:
复制代码代码如下:
funcrepeatedlyLock(){
n("Lockthelock.(G0)")
()
n("Thelockislocked.(G0)")
fori:=1;i<=3;i++{
gofunc(iint){
("Lockthelock.(G%d)n",i)
()
("Thelockislocked.(G%d)n",i)
}(i)
}
()
n("Unlockthelock.(G0)")
()
n("Thelockisunlocked.(G0)")
()
}
我们把执⾏repeatedlyLock函数的Goroutine称为G0。⽽在repeatedlyLock函数中,我们⼜启⽤了3个Goroutine,并分别把它
们命名为G1、G2和G3。可以看到,我们在启⽤这3个Goroutine之前就已经对互斥锁mutex进⾏了锁定,并且在这3个
Goroutine将要执⾏的go函数的开始处也加⼊了对mutex的锁定操作。这样做的意义是模拟并发地对同⼀个互斥锁进⾏锁定的
情形。当for语句被执⾏完毕之后,我们先让G0⼩睡1秒钟,以使运⾏时系统有充⾜的时间开始运⾏G1、G2和G3。在这之后,
解锁mutex。为了能够让读者更加清晰地了解到repeatedlyLock函数被执⾏的情况,我们在这些锁定和解锁操作的前后加⼊了
若⼲条打印语句,并在打印内容中添加了我们为这⼏个Goroutine起的名字。也由于这个原因,我们在repeatedlyLock函数的
最后再次编写了⼀条“睡眠”语句,以此为可能出现的其他打印内容再等待⼀⼩会⼉。
经过短暂的执⾏,标准输出上会出现如下内容:
复制代码代码如下:
Lockthelock.(G0)
Thelockislocked.(G0)
Lockthelock.(G1)
Lockthelock.(G2)
Lockthelock.(G3)
Unlockthelock.(G0)
Thelockisunlocked.(G0)
Thelockislocked.(G1)
从这⼋⾏打印内容中,我们可以清楚的看出上述四个Goroutine的执⾏情况。⾸先,在repeatedlyLock函数被执⾏伊始,对互
斥锁的第⼀次锁定操作便被进⾏并顺利地完成。这由第⼀⾏和第⼆⾏打印内容可以看出。⽽后,在repeatedlyLock函数中被启
⽤的那三个Goroutine在G0的第⼀次“睡眠”期间开始被运⾏。当相应的go函数中的对互斥锁的锁定操作被进⾏的时候,它们都
被阻塞住了。原因是该互斥锁已处于锁定状态了。这就是我们在这⾥只看到了三个连续的Lockthelock.(G)⽽没有⽴即看
到Thelockislocked.(G)的原因。随后,G0“睡醒”并解锁互斥锁。这使得正在被阻塞的G1、G2和G3都会有机会重新锁定
该互斥锁。但是,只有⼀个Goroutine会成功。成功完成锁定操作的某⼀个Goroutine会继续执⾏在该操作之后的语句。⽽其他
Goroutine将继续被阻塞,直到有新的机会到来。这也就是上述打印内容中的最后三⾏所表达的含义。显然,G1抢到了这次机
会并成功锁定了那个互斥锁。
实际上,我们之所以能够通过使⽤互斥锁对共享资源的唯⼀性访问进⾏控制正是因为它的这⼀特性。这有效的对竞态条件进⾏
了消除。
互斥锁的锁定操作的逆操作并不会引起任何Goroutine的阻塞。但是,它的进⾏有可能引发运⾏时恐慌。更确切的讲,当我们
对⼀个已处于解锁状态的互斥锁进⾏解锁操作的时候,就会已发⼀个运⾏时恐慌。这种情况很可能会出现在相对复杂的流程之
中——我们可能会在某个或多个分⽀中重复的加⼊针对同⼀个互斥锁的解锁操作。避免这种情况发⽣的最简单、有效的⽅式依
然是使⽤defer语句。这样更容易保证解锁操作的唯⼀性。
虽然互斥锁可以被直接的在多个Goroutine之间共享,但是我们还是强烈建议把对同⼀个互斥锁的成对的锁定和解锁操作放在
同⼀个层次的代码块中。例如,在同⼀个函数或⽅法中对某个互斥锁的进⾏锁定和解锁。⼜例如,把互斥锁作为某⼀个结构体
类型中的字段,以便在该类型的多个⽅法中使⽤它。此外,我们还应该使代表互斥锁的变量的访问权限尽量的低。这样才能尽
量避免它在不相关的流程中被误⽤,从⽽导致程序不正确的⾏为。
互斥锁是我们见到过的众多同步⼯具中最简单的⼀个。只要遵循前⾯提及的⼏个⼩技巧,我们就可以以正确、⾼效的⽅式使⽤
互斥锁,并⽤它来确保对共享资源的访问的唯⼀性。下⾯我们来看看稍微复杂⼀些的锁实现——读写锁。
⼆、读写锁
读写锁即是针对于读写操作的互斥锁。它与普通的互斥锁最⼤的不同就是,它可以分别针对读操作和写操作进⾏锁定和解锁操
作。读写锁遵循的访问控制规则与互斥锁有所不同。在读写锁管辖的范围内,它允许任意个读操作的同时进⾏。但是,在同⼀
时刻,它只允许有⼀个写操作在进⾏。并且,在某⼀个写操作被进⾏的过程中,读操作的进⾏也是不被允许的。也就是说,读
写锁控制下的多个写操作之间都是互斥的,并且写操作与读操作之间也都是互斥的。但是,多个读操作之间却不存在互斥关
系。
这样的规则对于针对同⼀块数据的并发读写来讲是⾮常贴切的。因为,⽆论读操作的并发量有多少,这些操作都不会对数据本
⾝造成变更。⽽写操作不但会对同时进⾏的其他写操作进⾏⼲扰,还有可能造成同时进⾏的读操作的结果的不正确。例如,在
32位的操作系统中,针对int64类型值的读操作和写操作都不可能只由⼀个CPU指令完成。在⼀个写操作被进⾏的过程当中,
针对同⼀个只的读操作可能会读取到未被修改完成的值。该值既不与旧的值相等,也不等于新的值。这种错误往往不易被发
现,且很难被修正。因此,在这样的场景下,读写锁可以在⼤⼤降低因使⽤锁⽽对程序性能造成的损耗的情况下完成对共享资
源的访问控制。
在Go语⾔中,读写锁由结构体类型x代表。与互斥锁类似,x类型的零值就已经是⽴即可⽤的读写
锁了。在此类型的⽅法集合中包含了两对⽅法,即:
复制代码代码如下:
func(*RWMutex)Lock
func(*RWMutex)Unlock
和
复制代码代码如下:
func(*RWMutex)RLock
func(*RWMutex)RUnlock
前⼀对⽅法的名称和签名与互斥锁的那两个⽅法完全⼀致。它们分别代表了对写操作的锁定和解锁。以下简称它们为写锁定和
写解锁。⽽后⼀对⽅法则分别表⽰了对读操作的锁定和解锁。以下简称它们为读锁定和读解锁。
对已被写锁定的读写锁进⾏写锁定,会造成当前Goroutine的阻塞,直到该读写锁被写解锁。当然,如果有多个Goroutine因此
⽽被阻塞,那么当对应的写解锁被进⾏之时只会使其中⼀个Goroutine的运⾏被恢复。类似的,对⼀个已被写锁定的读写锁进
⾏读锁定,也会阻塞相应的Goroutine。但不同的是,⼀旦该读写锁被写解锁,那么所有因欲进⾏读锁定⽽被阻塞的Goroutine
的运⾏都会被恢复。另⼀⽅⾯,如果在进⾏过程中发现当前的读写锁已被读锁定,那么这个写锁定操作将会等待直⾄所有施加
于该读写锁之上的读锁定都被清除。同样的,在有多个写锁定操作为此⽽等待的情况下,相应的读锁定的全部清除只能让其中
的某⼀个写锁定操作获得进⾏的机会。
现在来关注写解锁和读解锁。如果对⼀个未被写锁定的读写锁进⾏写解锁,那么会引发⼀个运⾏时恐慌。类似的,当对⼀个未
被读锁定的读写锁进⾏读解锁的时候也会引发⼀个运⾏时恐慌。写解锁在进⾏的同时会试图唤醒所有因进⾏读锁定⽽被阻塞的
Goroutine。⽽读解锁在进⾏的时候则会试图唤醒⼀个因进⾏写锁定⽽被阻塞的Goroutine。
⽆论锁定针对的是写操作还是读操作,我们都应该尽量及时的对相应的锁进⾏解锁。对于写解锁,我们⾃不必多说。⽽读解锁
的及时进⾏往往更容易被我们忽视。虽说读解锁的进⾏并不会对其他正在进⾏中的读操作产⽣任何影响,但它却与相应的写锁
定的进⾏关系紧密。注意,对于同⼀个读写锁来说,施加在它之上的读锁定可以有多个。因此,只有我们对互斥锁进⾏相同数
量的读解锁,才能够让某⼀个相应的写锁定获得进⾏的机会。否则,后者会继续使进⾏它的Goroutine处于阻塞状态。由于
x和*x类型都没有相应的⽅法让我们获得已进⾏的读锁定的数量,所以这⾥是很容易出现问题的。
还好我们可以使⽤defer语句来尽量避免此类问题的发⽣。请记住,针对同⼀个读写锁的写锁定和读锁定是互斥的。⽆论是写
解锁还是读解锁,操作的不及时都会对使⽤该读写锁的流程的正常执⾏产⽣负⾯影响。
除了我们在前⾯详细讲解的那两对⽅法之外,*x类型还拥有另外⼀个⽅法——RLocker。这个RLocker⽅法会返
回⼀个实现了接⼝的值。接⼝类型包含了两个⽅法,即:Lock和Unlock。细⼼的读者可能会发
现,*类型和*x类型都是该接⼝类型的实现类型。实际上,我们在调⽤*x类型值的
RLocker⽅法之后所得到的结果值就是这个值本⾝。只不过,这个结果值的Lock⽅法和Unlock⽅法分别对应了针对该读写锁的
读锁定操作和读解锁操作。换句话说,我们在对⼀个读写锁的RLocker⽅法的结果值的Lock⽅法或Unlock⽅法进⾏调⽤的时候
实际上是在调⽤该读写锁的RLock⽅法或RUnlock⽅法。这样的操作适配在实现上并不困难。我们⾃⼰也可以很容易的编写出
这些⽅法的实现。通过读写锁的RLocker⽅法获得这样⼀个结果值的实际意义在于,我们可以在之后以相同的⽅式对该读写锁
中的“写锁”和“读锁”进⾏操作。这为相关操作的灵活适配和替换提供了⽅便。
三、锁的完整⽰例
我们下⾯来看⼀个与上述锁实现有关的⽰例。在Go语⾔的标准库代码包os中有⼀个名为File的结构体类型。类型的值
可以被⽤来代表⽂件系统中的某⼀个⽂件或⽬录。它的⽅法集合中包含了很多⽅法,其中的⼀些⽅法被⽤来对相应的⽂件进⾏
写操作和读操作。
假设,我们需要创建⼀个⽂件来存放数据。在同⼀个时刻,可能会有多个Goroutine分别进⾏对此⽂件的进⾏写操作和读操
作。每⼀次写操作都应该向这个⽂件写⼊若⼲个字节的数据。这若⼲字节的数据应该作为⼀个独⽴的数据块存在。这就意味
着,写操作之间不能彼此⼲扰,写⼊的内容之间也不能出现穿插和混淆的情况。另⼀⽅⾯,每⼀次读操作都应该从这个⽂件中
读取⼀个独⽴、完整的数据块。它们读取的数据块不能重复,且需要按顺序读取。例如,第⼀个读操作读取了数据块1,那么
第⼆个读操作就应该去读取数据块2,⽽第三个读操作则应该读取数据块3,以此类推。对于这些读操作是否可以被同时进
⾏,这⾥并不做要求。即使它们被同时进⾏,程序也应该分辨出它们的先后顺序。
为了突出重点,我们规定每个数据块的长度都是相同的。该长度应该在初始化的时候被给定。若写操作实际欲写⼊数据的长度
超过了该值,则超出部分将会被截掉。
当我们拿到这样⼀个需求的时候,⾸先应该想到使⽤类型。它为我们操作⽂件系统中的⽂件提供了底层的⽀持。但是,
该类型的相关⽅法并没有对并发操作的安全性进⾏保证。换句话说,这些⽅法不是并发安全的。我只能通过额外的同步⼿段来
保证这⼀点。鉴于这⾥需要分别对两类操作(即写操作和读操作)进⾏访问控制,所以读写锁在这⾥会⽐普通的互斥锁更加适
⽤。不过,关于多个读操作要按顺序且不能重复读取的这个问题,我们需还要使⽤其他辅助⼿段来解决。
为了实现上述需求,我们需要创建⼀个类型。作为该类型的⾏为定义,我们先编写了⼀个这样的接⼝:
复制代码代码如下:
//数据⽂件的接⼝类型。
typeDataFileinterface{
//读取⼀个数据块。
Read()(rsnint64,dData,errerror)
//写⼊⼀个数据块。
Write(dData)(wsnint64,errerror)
//获取最后读取的数据块的序列号。
Rsn()int64
//获取最后写⼊的数据块的序列号。
Wsn()int64
//获取数据块的长度
DataLen()uint32
}
其中,类型Data被声明为⼀个[]byte的别名类型:
复制代码代码如下:
//数据的类型
typeData[]byte
⽽名称wsn和rsn分别是WritingSerialNumber和ReadingSerialNumber的缩写形式。它们分别代表了最后被写⼊的数据块的
序列号和最后被读取的数据块的序列号。这⾥所说的序列号相当于⼀个计数值,它会从1开始。因此,我们可以通过调⽤Rsn
⽅法和Wsn⽅法得到当前已被读取和写⼊的数据块的数量。
根据上⾯对需求的简单分析和这个DataFile接⼝类型声明,我们就可以来编写真正的实现了。我们将这个实现类型命名为
myDataFile。它的基本结构如下:
复制代码代码如下:
//数据⽂件的实现类型。
typemyDataFilestruct{
f*//⽂件。
x//被⽤于⽂件的读写锁。
wofftint64//写操作需要⽤到的偏移量。
rofftint64//读操作需要⽤到的偏移量。
//写操作需要⽤到的互斥锁。
//读操作需要⽤到的互斥锁。
dataLenuint32//数据块长度。
}
类型myDataFile共有七个字段。我们已经在前⾯说明过前两个字段存在的意义。由于对数据⽂件的写操作和读操作是各⾃独
⽴的,所以我们需要两个字段来存储两类操作的进⾏进度。在这⾥,这个进度由偏移量代表。此后,我们把wofft字段称为
写偏移量,⽽把rofft字段称为读偏移量。注意,我们在进⾏写操作和读操作的时候会分别增加这两个字段的值。当有多个写
操作同时要增加wofft字段的值的时候就会产⽣竞态条件。因此,我们需要互斥锁wmutex来对其加以保护。类似的,rmutex
互斥锁被⽤来消除多个读操作同时增加rofft字段的值时产⽣的竞态条件。最后,由上述的需求可知,数据块的长度应该是在
初始化myDataFile类型值的时候被给定的。这个长度会被存储在该值的dataLen字段中。它与DataFile接⼝中声明的DataLen
⽅法是对应的。下⾯我们就来看看被⽤来创建和初始化DataFile类型值的函数NewDataFile。
关于这类函数的编写,读者应该已经驾轻就熟了。NewDataFile函数会返回⼀个DataFile类型值,但是实际上它会创建并初始
化⼀个*myDataFile类型的值并把它作为它的结果值。这样可以通过编译的原因是,后者会是前者的⼀个实现类型。
NewDataFile函数的完整声明如下:
复制代码代码如下:
funcNewDataFile(pathstring,dataLenuint32)(DataFile,error){
f,err:=(path)
iferr!=nil{
returnnil,err
}
ifdataLen==0{
returnnil,("Invaliddatalength!")
}
df:=&myDataFile{f:f,dataLen:dataLen}
returndf,nil
}
可以看到,我们在创建*myDataFile类型值的时候只需要对其中的字段f和dataLen进⾏初始化。这是因为wofft字段和rofft
字段的零值都是0,⽽在未进⾏过写操作和读操作的时候它们的值理应如此。对于字段fmutex、wmutex和rmutex来说,它们的
零值即为可⽤的锁。所以我们也不必对它们进⾏显式的初始化。
把变量df的值作为NewDataFile函数的第⼀个结果值体现了我们的设计意图。但要想使*myDataFile类型真正成为DataFile类型
的⼀个实现类型,我们还需要为*myDataFile类型编写出已在DataFile接⼝类型中声明的所有⽅法。其中最重要的当属Read⽅
法和Write⽅法。
我们先来编写*myDataFile类型的Read⽅法。该⽅法应该按照如下步骤实现。
(1)获取并更新读偏移量。
(2)根据读偏移量从⽂件中读取⼀块数据。
(3)把该数据块封装成⼀个Data类型值并将其作为结果值返回。
其中,前⼀个步骤在被执⾏的时候应该由互斥锁rmutex保护起来。因为,我们要求多个读操作不能读取同⼀个数据块,并且它
们应该按顺序的读取⽂件中的数据块。⽽第⼆个步骤,我们也会⽤读写锁fmutex加以保护。下⾯是这个Read⽅法的第⼀个版
本:
复制代码代码如下:
func(df*myDataFile)Read()(rsnint64,dData,errerror){
//读取并更新读偏移量
varofftint64
()
offt=t
t+=int64(n)
()
//读取⼀个数据块
rsn=offt/int64(n)
()
k()
bytes:=make([]byte,n)
_,err=(bytes,offt)
iferr!=nil{
return
}
d=bytes
return
}
可以看到,在读取并更新读偏移量的时候,我们⽤到了rmutex字段。这保证了可能同时运⾏在多个Goroutine中的这两⾏代
码:
复制代码代码如下:
offt=t
t+=int64(n)
的执⾏是互斥的。这是我们为了获取到不重复且正确的读偏移量所必需采取的措施。
另⼀⽅⾯,在读取⼀个数据块的时候,我们适时的进⾏了fmutex字段的读锁定和读解锁操作。fmutex字段的这两个操作可以保
证我们在这⾥读取到的是完整的数据块。不过,这个完整的数据块却并不⼀定是正确的。为什么会这样说呢?
请想象这样⼀个场景。在我们的程序中,有3个Goroutine来并发的执⾏某个*myDataFile类型值的Read⽅法,并有2个
Goroutine来并发的执⾏该值的Write⽅法。通过前3个Goroutine的运⾏,数据⽂件中的数据块被依次的读取了出来。但是,由
于进⾏写操作的Goroutine⽐进⾏读操作的Goroutine少,所以过不了多久读偏移量rofft的值就会等于甚⾄⼤于写偏移量
wofft的值。也就是说,读操作很快就会没有数据可读了。这种情况会使上⾯的⽅法返回的第⼆个结果值为代表
错误的⾮nil且会与相等的值。实际上,我们不应该把这样的值看成错误的代表,⽽应该把它看成⼀种边界情况。但不
幸的是,我们在这个版本的Read⽅法中并没有对这种边界情况做出正确的处理。该⽅法在遇到这种情况时会直接把错误值返
回给它的调⽤⽅。该调⽤⽅会得到读取出错的数据块的序列号,但却⽆法再次尝试读取这个数据块。由于其他正在或后续执⾏
的Read⽅法会继续增加读偏移量rofft的值,所以当该调⽤⽅再次调⽤这个Read⽅法的时候只可能读取到在此数据块后⾯的
其他数据块。注意,执⾏Read⽅法时遇到上述情况的次数越多,被漏读的数据块也就会越多。为了解决这个问题,我们编写
了Read⽅法的第⼆个版本:
复制代码代码如下:
func(df*myDataFile)Read()(rsnint64,dData,errerror){
//读取并更新读偏移量
//省略若⼲条语句
//读取⼀个数据块
rsn=offt/int64(n)
bytes:=make([]byte,n)
for{
()
_,err=(bytes,offt)
iferr!=nil{
iferr=={
k()
continue
}
k()
return
}
d=bytes
k()
return
}
}
在上⾯的Read⽅法展⽰中,我们省略了若⼲条语句。原因在这个位置上的那些语句并没有任何变化。为了进⼀步节省篇幅,
我们在后⾯也会遵循这样的省略原则。
第⼆个版本的Read⽅法使⽤for语句是为了达到这样⼀个⽬的:在其中的⽅法返回错误的时候继续尝试获取
同⼀个数据块,直到获取成功为⽌。注意,如果在该for代码块被执⾏期间⼀直让读写锁fmutex处于读锁定状态,那么针对它
的写锁定操作将永远不会成功,且相应的Goroutine也会被⼀直阻塞。因为它们是互斥的。所以,我们不得不在该for语句块中
的每条return语句和continue语句的前⾯都加⼊⼀个针对该读写锁的读解锁操作,并在每次迭代开始时都对fmutex进⾏⼀次读
锁定。显然,这样的代码看起来很丑陋。冗余的代码会使代码的维护成本和出错⼏率⼤⼤增加。并且,当for代码块中的代码
引发了运⾏时恐慌的时候,我们是很难及时的对读写锁fmutex进⾏读解锁的。即便可以这样做,那也会使Read⽅法的实现更
加丑陋。我们因为要处理⼀种边界情况⽽去掉了k()语句。这种做法利弊参半。
其实,我们可以做得更好。但是这涉及到了其他同步⼯具。因此,我们以后再来对Read⽅法进⾏进⼀步的改造。顺便提⼀
句,当⽅法返回⼀个⾮nil且不等于的错误值的时候,我们总是应该放弃再次获取⽬标数据块的尝试⽽⽴即
将该错误值返回给Read⽅法的调⽤⽅。因为这样的错误很可能是严重的(⽐如,f字段代表的⽂件被删除了),需要交由上层
程序去处理。
现在,我们来考虑*myDataFile类型的Write⽅法。与Read⽅法相⽐,Write⽅法的实现会简单⼀些。因为后者不会涉及到边界
情况。在该⽅法中,我们需要进⾏两个步骤,即:获取并更新写偏移量和向⽂件写⼊⼀个数据块。我们直接给出Write⽅法的
实现:
复制代码代码如下:
func(df*myDataFile)Write(dData)(wsnint64,errerror){
//读取并更新写偏移量
varofftint64
()
offt=t
t+=int64(n)
()
//写⼊⼀个数据块
wsn=offt/int64(n)
varbytes[]byte
iflen(d)>int(n){
bytes=d[0:n]
}el{
bytes=d
}
()
()
_,err=(bytes)
return
}
这⾥需要注意的是,当参数d的值的长度⼤于数据块的最⼤长度的时候,我们会先进⾏截短处理再将数据写⼊⽂件。如果没有
这个截短处理,我们在后⾯计算的已读数据块的序列号和已写数据块的序列号就会不正确。
有了编写前⾯两个⽅法的经验,我们可以很容易的编写出*myDataFile类型的Rsn⽅法和Wsn⽅法:
复制代码代码如下:
func(df*myDataFile)Rsn()int64{
()
()
t/int64(n)
}
func(df*myDataFile)Wsn()int64{
()
()
t/int64(n)
}
这两个⽅法的实现分别涉及到了对互斥锁rmutex和wmutex的锁定操作。同时,我们也通过使⽤defer语句保证了对它们的及时
解锁。在这⾥,我们对已读数据块的序列号rsn和已写数据块的序列号wsn的计算⽅法与前⾯⽰例中的⽅法是相同的。它们都
是⽤相关的偏移量除以数据块长度后得到的商来作为相应的序列号(或者说计数)的值。
⾄于*myDataFile类型的DataLen⽅法的实现,我们⽆需呈现。它只是简单地将dataLen字段的值作为其结果值返回⽽已。
编写上⾯这个完整⽰例的主要⽬的是展⽰互斥锁和读写锁在实际场景中的应⽤。由于还没有讲到Go语⾔提供的其他同步⼯
具,所以我们在相关⽅法中所有需要同步的地⽅都是⽤锁来实现的。然⽽,其中的⼀些问题⽤锁来解决是不⾜够或不合适的。
我们会在本节的后续部分中逐步的对它们进⾏改进。
从这两种锁的源码中可以看出,它们是同源的。读写锁的内部是⽤互斥锁来实现写锁定操作之间的互斥的。我们可以把读写锁
看做是互斥锁的⼀种扩展。除此之外,这两种锁实现在内部都⽤到了操作系统提供的同步⼯具——信号灯。互斥锁内部使⽤⼀
个⼆值信号灯(只有两个可能的值的信号灯)来实现锁定操作之间的互斥,⽽读写锁内部则使⽤⼀个⼆值信号灯和⼀个多值信
号灯(可以有多个可能的值的信号灯)来实现写锁定操作与读锁定操作之间的互斥。当然,为了进⾏精确的协调,它们还使⽤
到了其他⼀些字段和变量。由于篇幅原因,我们就不在这⾥赘述了。如果读者对此感兴趣的话,可以去阅读sync代码包中的
相关源码⽂件。
本文发布于:2022-12-10 03:25:46,感谢您对本站的认可!
本文链接:http://www.wtabcd.cn/fanwen/fan/88/76677.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
留言与评论(共有 0 条评论) |