go语言使用互斥锁进行同步
我们可以利用互斥锁来保护代码中的关键部分从而确保每次只能有一个goroutine访问共享资源。这样一来就能避免竞争条件的问题。几乎所有支持并发编程的语言中都使用了类似互斥锁的机制。在本章中我们首先会了解互斥锁的功能。之后还会探讨一种名为“读写互斥锁”的特殊类型的互斥锁。读写互斥锁能在我们只需要在修改共享资源时才阻止并发操作的情况下帮助我们提升性能。利用这种机制我们可以允许多个进程同时读取共享资源而与此同时又能确保只有某个进程能够进行写入操作。我们将通过一个示例来了解读写互斥锁的用法还会学习其内部原理并亲手实现一个类似的互斥锁。使用互斥量保护临界区如果我们能确保每次只有一条执行线程能够访问那些关键代码段那该多好啊。这就是互斥锁的作用所在。可以把互斥锁看作是一种“物理锁”它能防止多个协程同时访问代码中的特定部分。只要每次只有一个协程在访问关键代码段就能避免竞争条件的问题。因为竞争条件只有在那两个或多个协程同时尝试访问同一资源时才会发生。我们可以利用互斥锁来标记代码中临界区的起始和结束位置。当某个协程进入由互斥锁保护的临界区时它首先会通过程序代码中的指令明确地锁定该互斥锁。之后协程开始执行临界区内的代码。当执行完成后它会释放互斥锁这样其他协程就可以进入该临界区了。如果另一个协程试图锁定一个已经被占用的互斥锁那么该协程将被挂起直到互斥锁被释放为止。如果有多个协程都在等待锁的释放那么只会有一个协程能够继续执行而那个协程就是下一个成功获得互斥锁的协程。所谓“互斥锁”其实就是一种用于防止竞争条件的并发控制机制。它确保同一时间只能有一个执行单元比如一个协程或内核级线程进入临界区域。如果有两个执行单元同时试图获取对该互斥锁的访问权那么互斥锁的规则会保证只有一个协程能够成功获取访问权而另一个执行单元则必须等待直到互斥锁再次变为可用状态。在 Go 语言中互斥锁的功能由sync包中的Mutex类提供。通过Mutex类我们可以使用Lock()和Unlock()这两个方法来实现对资源的锁定与解锁操作。packagemainimport(fmtsynctime)funcstingy(money*int,mutex*sync.Mutex){fori:0;i1000000;i{// 进入临界区之前先锁定互斥变量mutex.Lock()*money10// 离开临界区之后解锁互斥变量mutex.Unlock()}fmt.Println(Stingy Done)}funcspendy(money*int,mutex*sync.Mutex){fori:0;i1000000;i{mutex.Lock()*money-10mutex.Unlock()}fmt.Println(Spendy Done)}funcmain(){money:100mutex:sync.Mutex{}gostingy(money,mutex)gospendy(money,mutex)time.Sleep(2*time.Second)mutex.Lock()fmt.Println(Money in bank account: ,money)mutex.Unlock()}在我们的主函数中当各个协程执行完成后我们在读取“money”变量时也会使用互斥锁来确保同步。虽然由于我们设置了等待时间来确保所有协程都已完成执行因此出现竞争条件的概率很低。但即便如此保护共享资源始终是良好的编程习惯。使用互斥锁以及后面章节中介绍的其他同步机制可以确保协程能够读取到该变量的最新值。请注意我们必须保护所有关键代码段包括那些goroutine仅用于读取共享资源的区域。编译器的优化措施可能会重新调整指令的执行顺序从而导致指令以不同的方式被执行。通过使用适当的同步机制比如互斥锁我们可以确保自己能够读取到共享资源的最新版本。互斥锁是如何实现的呢互斥锁的实现通常需要操作系统和硬件的支持。如果系统只有一个处理器那么我们可以通过在某个线程持有锁时禁止中断的方式来实现互斥锁。这样一来其他线程就不会干扰当前线程的执行从而避免冲突。不过这种做法并不理想因为编写不当的代码可能会导致整个系统被阻塞让其他进程和线程都无法正常运行。此外如果系统有多个处理器这种做法也是行不通的因为其他线程可以在其他CPU上并行执行。互斥量的实现需要硬件的支持以便能够进行原子的测试和设置操作。通过这种操作某个执行单元可以检查内存中的值是否与预期相符如果相符它就会将内存中的值设置为“已锁定”状态。硬件确保了这一测试和设置操作的原子性——也就是说在操作完成之前其他执行单元无法访问该内存位置。早期的硬件实现方式是通过封锁整个总线来确保这种原子性这样其他处理器就无法同时使用该内存。如果另一个执行单元尝试进行同样的操作却发现内存已经被设置为“已锁定”状态那么操作系统会阻止该线程的继续执行直到内存状态再次变为“可用”为止。互斥锁与顺序处理当然当协程的数量超过两个时我们也可以使用互斥锁来解决同步问题。请注意使用互斥锁会限制程序的并发程度。在互斥锁被锁定和解锁之间的代码段任何时候都只能由一个协程来执行这样一来这段代码就变成了顺序执行的代码。正如我们在第一章中所看到的根据阿姆达尔定律顺序执行与并行执行的比例会限制代码的性能可扩展性。因此我们必须尽量减少互斥锁被占用的时间。如果在countLetters()函数开始时锁定互斥锁而在函数执行完毕后释放它会怎么样呢从下面的代码可以看出我们在调用该函数后立即锁定互斥锁而在输出完成后的消息后释放互斥锁。packagemainimport(fmtionet/httpstringssynctime)constAllLettersabcdefghijklmnopqrstuvwxyzfuncmain(){mutex:sync.Mutex{}varfrequencymake([]int,26)fori:1000;i1030;i{url:fmt.Sprintf(https://rfc-editor.org/rfc/rfc%d.txt,i)goCountLetters(url,frequency,mutex)}time.Sleep(60*time.Second)mutex.Lock()fori,c:rangeAllLetters{fmt.Printf(%c-%d ,c,frequency[i])}mutex.Unlock()}// CountLetters// Note: this program us locking the entire goroutine with mutex on purpose to demonstrate// bad placement of the lock and unlock. We fix this in the next listingfuncCountLetters(urlstring,frequency[]int,mutex*sync.Mutex){mutex.Lock()resp,_:http.Get(url)deferresp.Body.Close()ifresp.StatusCode!200{panic(Server returning error status code: resp.Status)}body,_:io.ReadAll(resp.Body)for_,b:rangebody{c:strings.ToLower(string(b))cIndex:strings.Index(AllLetters,c)ifcIndex0{frequency[cIndex]1}}fmt.Println(Completed:,url,time.Now().Format(15:04:05))mutex.Unlock()}通过这种方式使用互斥锁我们将原本的并发程序变成了顺序执行的程序。这样一来我们每次只能下载并处理一个网页因为整个程序的执行过程被无谓地阻塞了。如果继续运行这个程序其耗时将与非并发版本的程序相同只不过各网页的执行顺序会是随机的而已。下图展示了一种使用三个协程来实现这种锁定机制的简化调度示意图。从图中可以看出这些协程大部分时间都用于下载文档而用于处理文档的时间则相对较短。在图中我们刻意缩小了下载与处理之间所需时间的比例以便于说明问题。实际上两者之间的时间差距要大得多。总的来说协程们几乎把所有时间都用在了下载文档上而用于处理文档的时间则微乎其微。从性能角度来看没有必要让整个执行过程都被阻塞。因为文档下载过程与其他协程无关所以也就不存在发生竞争条件的风险。提示在决定何时以及如何使用互斥锁时我们应先明确需要保护哪些资源并确定各个临界区的起始点和结束点。之后我们需要想办法尽量减少“Lock()”和“Unlock()”调用的次数。packagelisting4_5import(fmtionet/httpstringssynctime)constAllLettersabcdefghijklmnopqrstuvwxyzfuncCountLetters(urlstring,frequency[]int,mutex*sync.Mutex){resp,_:http.Get(url)deferresp.Body.Close()ifresp.StatusCode!200{panic(Server returning error code: resp.Status)}body,_:io.ReadAll(resp.Body)mutex.Lock()for_,b:rangebody{c:strings.ToLower(string(b))cIndex:strings.Index(AllLetters,c)ifcIndex0{frequency[cIndex]1}}mutex.Unlock()fmt.Println(Completed:,url,time.Now().Format(15:04:05))}在这个代码版本中下载过程——也就是程序中耗时最长的部分——会以并行方式执行。而快速处理字母计数的任务则会在顺序模式下进行。通过这种方式我们能够最大限度地提升程序的可扩展性因为我们只对那些执行速度远远快于其他部分的代码段使用锁机制。程序的执行过程如图所示。同样为了便于观察下载和处理所需的时间比例被夸大了。实际上处理所需的时间远远少于下载网页所需的时间因此加速效果更为显著。实际上在我们的main()函数中可以将等待时间缩短到几秒钟左右之前是60秒。第二种解决方案比我们的第一次尝试要快得多。当锁定需要处理的代码量较小时我们完成任务的效率会更高。这里的经验法则是尽量减少持有互斥锁的时间同时也要减少互斥锁的调用次数。回想一下阿姆达尔定律就不难理解这一点如果代码中并行处理部分所占的时间更长那么整体处理速度就会更快系统的扩展性也会更好。非阻塞式互斥锁当一个协程调用 Lock() 函数时如果互斥锁已被其他协程占用那么该协程将会被阻塞。这就是所谓的“阻塞函数”该协程的执行会停止直到有另一个协程调用 Unlock() 函数为止。在某些应用中我们会……或许不必阻塞该协程而是可以先执行其他任务然后再尝试锁定互斥量并访问临界区。正因如此Go语言中的互斥锁提供了另一个名为TryLock()的函数。调用这个函数时我们可能会遇到两种结果之一• 锁是可用的这种情况下我们就能获取到它此时函数会返回布尔值“true”。• 该锁当前无法使用因为另一个协程正在使用这个互斥锁。因此该函数会立即返回返回值为布尔值“false”。非阻塞机制的用途在Go语言的1.18版本中新增了用于处理互斥锁的TryLock()函数。不过关于这种非阻塞调用方式的实用示例其实很少见。这是因为在Go中创建一个goroutine的成本远远低于在其他语言中创建内核级线程的成本。如果互斥锁不可用让goroutine做其他事情并没有太大意义。因为在这种情况下我们完全可以另开一个goroutine来执行任务而无需等待互斥锁被释放。实际上Go的互斥锁文档中也提到了这一点来源pkg.go.dev/sync#Mutex.TryLock。需要注意的是虽然确实存在正确使用TryLock的情况但这种情况相当罕见。通常来说使用TryLock往往意味着在多线程编程中存在更严重的问题。使用TryLock()的一个例子是当需要监控某个任务的执行进度时又不希望干扰该任务的正常进行。如果使用普通的Lock()机制由于系统中有很多其他goroutine也在试图获取锁那么就会给互斥锁带来不必要的负担而这一切都是为了实现监控功能而已。而使用TryLock()的话如果此时有别的goroutine正在占用互斥锁那么监控用的goroutine就可以选择稍后再试。这就好比去邮局处理一件不紧急的事务时看到门口排着长队就决定改天再来办理吧。我们可以修改我们的字母频率统计程序让主goroutine在下载数据和处理文档的过程中定期检查频率表的内容。packagemainimport(fmtionet/httpstringssynctime)funcmain(){mutex:sync.Mutex{}varfrequencymake([]int,26)fori:2000;i2200;i{url:fmt.Sprintf(https://rfc-editor.org/rfc/rfc%d.txt,i)goCountLetters(url,frequency,mutex)}fori:0;i100;i{time.Sleep(100*time.Millisecond)ifmutex.TryLock(){fori,c:rangeAllLetters{fmt.Printf(%c-%d ,c,frequency[i])}fmt.Println()mutex.Unlock()}else{fmt.Println(Mutex already being used)}}}constAllLettersabcdefghijklmnopqrstuvwxyzfuncCountLetters(urlstring,frequency[]int,mutex*sync.Mutex){resp,_:http.Get(url)deferresp.Body.Close()ifresp.StatusCode!200{panic(Server returning error code: resp.Status)}body,_:io.ReadAll(resp.Body)mutex.Lock()for_,b:rangebody{c:strings.ToLower(string(b))cIndex:strings.Index(AllLetters,c)ifcIndex0{frequency[cIndex]1}}mutex.Unlock()fmt.Println(Completed:,url,time.Now().Format(15:04:05))}当我们运行代码时可以从输出结果中看到main()协程试图获取锁以打印出频率表。有时它能成功获取锁并完成打印而有时则无法获取锁此时它需要等待100毫秒后再尝试一次。利用读写互斥锁提升性能有时候互斥锁的约束过于严格。可以把互斥锁看作是一种“粗暴”的工具它通过阻止多个线程同时执行来解决问题。在任何时刻都只能有一个goroutine能够进入受互斥锁保护的代码段。这种方式确实能有效避免竞争条件带来的问题但对于某些应用来说它可能会不必要地限制性能和可扩展性。读写互斥锁则是对标准互斥锁的一种改进只有在我们需要更新共享资源时它才会阻止其他线程的访问。使用读写互斥锁后那些以读取操作为主的程序的性能可以得到提升因为在这种情况下对共享数据的读取操作远远多于更新操作。Go语言中的读写互斥锁如果所有处理客户请求的协程都能以非独占的方式访问该数据结构那么它们就可以在需要时同时读取数据。这样可以提高性能因为这样多个仅负责读取共享数据的协程就能同时访问该数据。只有当需要更新数据时才会对共享数据的访问进行限制。在这个例子中数据的更新频率很低每秒几次而读取数据的频率则非常高每秒数千次。因此采用一种允许多个协程同时读取数据、但只能有单个协程进行写入的机制会更有利。这就是读写锁的作用。当我们只需读取共享资源而不对其进行修改时读写锁允许多个协程同时执行只读操作。而当我们需要修改共享资源时负责写入操作的协程会请求获得写锁从而实现对该资源的独占访问。这一原理在上图中有所体现图的左侧显示读锁允许多个协程同时进行读取操作但会阻止任何写入操作而在图的右侧一旦获得了写锁那么所有的读取和写入操作都会被阻止这与普通互斥锁的机制类似。总结• 互斥锁可以用来保护代码中的关键部分防止它们被同时执行。• 我们可以通过在关键代码段的开始处调用Lock()函数在结束处调用unLock()函数来使用互斥锁保护这些关键部分。• 如果锁定的时间过长那么原本可以并行执行的代码就会变成顺序执行从而降低性能。• 我们可以通过调用 TryLock() 函数来检测某个互斥锁是否已被锁定。• 读写互斥锁能够提升那些以读取操作为主的应用程序的性能。• 读写互斥锁允许多个读取者协程同时执行临界代码段同时确保只有单个写入者协程能够访问该资源。• 我们可以使用一个计数器以及两个普通的互斥锁来构建一种“以读取操作为主的读写互斥机制”。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2579416.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!