龙空技术网

Golang 中的互斥锁和锁内部结构

启辰8 130

前言:

目前大家对“golang锁实现”大体比较关切,大家都需要知道一些“golang锁实现”的相关文章。那么小编同时在网摘上汇集了一些对于“golang锁实现””的相关内容,希望姐妹们能喜欢,大家快快来学习一下吧!

在现代软件开发中,并发的概念涉及程序同时执行多个任务或操作的能力。 这可以有效利用系统资源并增强性能。 Go 编程语言 (Golang) 的一个突出特性是其对并发的出色支持。 然而,并发系统带来了需要仔细处理的挑战。 当多个资源尝试同时访问和修改共享资源时,就会出现这样的挑战之一,从而导致不可预测的错误行为。

如何保护关键部分:

为了解决这个问题,锁的概念被引入。 锁用于保护访问共享资源的关键代码部分。 它们确保在任何给定时间只有一个线程可以访问受保护的资源。

在 Golang 中,我们利用 goroutine,它是轻量级线程。 这些 goroutine 的调度由 Go 运行时调度程序管理。 虽然深入研究 Go 运行时调度程序的复杂性超出了本文的范围,但您可以参考本文以更深入地了解 Go 运行时调度程序。

在本文中,我们将从探索锁的基础知识开始。 我们将讨论如何在需要时创建锁。 之后,我们将深入研究各种锁定策略。 最后,我们将深入研究 Go 互斥体。 我恳请大家陪我直到文章结束。

Golang中的锁:

在 Go 中,并发的单位是 goroutine,而 goroutine 在概念上与常规线程非常相似。 使用它们就像在 Java 或 C++ 等语言中使用线程一样,但最大的区别在于它们是用户空间。 它们完全由 Go 运行时运行和管理,而不是由操作系统运行和管理。 它们是由运行时创建和调度的。与常规线程一样,当有两个 goroutine 访问共享内存位置时,该访问需要同步。 您可以使用 Go 的锁实现(我们今天要讨论的实现)sync.Mutex 来同步它。 互斥锁是阻塞非递归锁,因此不存在尝试获取语义。 如果 goroutine 尝试获取锁但无法获取,它将阻塞。

如果我们必须从头开始构建一把锁,那么讨论就足够了,让我们探讨一下构建有效锁定机制所涉及的基本步骤。

锁内部结构和锁定机构

假设我们有两个进程:T1 是从队列中检索任务的读取器,T2 是向队列添加任务的写入器。 为了防止同时访问,锁定机制是必不可少的。 下面是这两个过程的代码:

// Thread 1 which is reading the tasksfunc reader() { //read a task t := tasks.get() //Do something with the tasks}// Thread 2 which is writing the tasksfunc writer() { //write to tasks tasks.put(t)}

让我们构建一个锁定机制,确保 T1 和 T2 永远不会同时访问任务队列。

初步尝试:使用标志

最初的方法涉及使用标志来指示任务队列是正在使用还是空闲。 当线程访问队列时,它会设置标志并继续。 这种方法的代码如下所示:

var flag intvar tasks Tasksfunc reader() { for {  if flag == 0 {   //Set flag which indicates flag is being set so some thread already processing.   flag++   t := tasks.get()   //Unset flag which means tasks is not accessed by any thread   flag --   return  }  //else keep looping }}func writer() { for {  if flag == 0 {   //Set flag which indicates flag is being set so some thread already processing.   flag++   tasks.put(t)   //Unset flag which means tasks is not accessed by any thread   flag --   return  }  //else keep looping }}

我们如何做到这一点?

我们使用一个标志来跟踪任务队列是否可用(空闲)或正在使用。 如果可用,则该标志设置为 0。如果正在使用,则该标志设置为 1。

读取器和写入器代码是什么样子的:

检查标志是否为0(任务队列空闲),如果不是则继续循环。

如果为0,则将标志设置为1以指示队列正在被使用。

做必要的任务。

完成后将标志设置回 0。

这个方法有效吗? 我们可以结束并离开吗?

不,这行不通。 第一个问题是这一行,这个flag++。 这是一条读-修改-写指令,因此,变量从内存读入本地 CPU 寄存器,对其进行修改,在此处递增,然后将其写回内存。

我们的一条指令实际上导致两次内存访问。 为什么这是一个问题? 问题是来自不同线程的这些内存访问可能会混淆。 即使线程 1 的“flag++”在线程 2 读取之前启动,线程 2 仍然可能看到该标志的旧值。 因此,线程 2 可能会将标志视为 0,这是一个问题。

这种情况称为“非原子操作”。 这意味着其他处理器可以在完成之前看到它所做的更改。 当操作涉及幕后的多个小步骤时(例如处理大数据或使用多个内存访问),操作就变成非原子的。 当它们作为一个步骤出现但实际上涉及多个内存操作时,它们也可以是非原子的,正如我们在前面的示例中看到的那样。

我们的标志不会起作用,因为它不是原子的,为什么我们不能构建原子的东西? 这听起来很有用。 我们可以用它来解决我们的难题吗? 我们可以。

第二次尝试:使用原子操作

我们找到了解决方案:原子比较和交换指令。 它做我们需要的事情。 该指令在某些条件下更新变量 - 仅当它已经是特定值时才更改它。 重要的是,这个读取-修改-写入过程同时发生,这正是我们所需要的。

让我们应用这个原子比较和交换来构建我们的锁。 这种方法的代码如下所示:

var flag intvar tasks Tasksfunc reader() { for {  if atomic.CompareAndSwap(&flag, 0, 1) { //Set flag to 1 succeeded   t := tasks.get()   atomic.Store(&flag, 0) //Unset flag to 0   return  }  //else keep looping }}func writer() { for {  if atomic.CompareAndSwap(&flag, 0, 1) { //Set flag to 1 succeeded   tasks.put(t)   atomic.Store(&flag, 0) //Unset flag to 0   return  }  //else keep looping }}

我们的读取器和写入器代码是什么样子的:

尝试将 CAS 标志从 0 更改为 1。因此,如果该标志确实为零,它将在一个不间断的步骤中将该标志设置为 1。

如果 CAS 指令无法设置标志(可能是因为另一个进程在我们之前更改了它),我们不会放弃。 我们会再试一次。 我们将继续循环,直到成功将标志设置为 1

做必要的任务。

以原子方式将标志值恢复为 0。

很有道理,这个有用吗? 事实上,它确实有效。

这是自旋锁的简化实现,自旋锁在整个 Linux 内核中广泛使用。

我们应该运送它吗?

不。有一个大问题,最大的问题是我们让这个线程一直在旋转,只是忙于等待。 这是一种浪费,它会消耗 CPU 周期,并且会占用其他可能正在运行的线程的宝贵 CPU 时间。

如果当我们的线程进行比较和交换并且失败时,我们可以将其收起,而不是旋转,那就太好了。 如果我们可以将其置于睡眠状态,并在值标志发生更改时恢复它,并且可以重试,这听起来不错。 幸运的是,操作系统为我们提供了一种方法来做到这一点。

如何做到这一点以使我们的线程进入睡眠状态并在需要时唤醒:

在 Linux 中,有这些 futex,futex 为用户空间提供了接口和机制,供程序请求内核睡眠和唤醒线程。 它的接口部分是 futex 系统调用,其机制是内核管理的等待队列。

Futex 的工作原理:

现在我们将扩展我们的标志变量,而不是只有两个值。 它将开始存储三个值:

“flag”变量可以是 0(空闲)、1(锁定)或 2(有服务员)。

那么我们的读写器处理器功能是如何扩展的:

func reader() { for {  if atomic.CompareAndSwap(&flag, 0, 1) { //Set flag to 1 succeeded   t := tasks.get()   atomic.Store(&flag, 0) //Unset flag to 0   return  }  // CAS failed, set flag to sleeping set flag to 2 (there’s a waiter)   v := atomic.Xchg(&flag, 2)  // and go to sleep.  futex(&flag, FUTEX_WAIT, ...) }}

上面的代码执行以下操作:

它尝试使用原子操作将标志从 0 切换到 1。

如果成功则执行该操作并将backs标志存储为0,然后检查是否有任何等待者触发该等待者。

如果不成功(另一个线程将其设置为1),它将自己标记为等待者(flag = 2)。

然后,它使用 futex 系统调用告诉内核将其置于睡眠状态,直到标志发生变化。

当被唤醒时,它会重试原子操作。

切换到内核空间,让我们看看内核做了什么。 内核需要做两件事:

内核存储有关等待线程的信息 - 为了存储此信息,内核维护用户空间地址。 它存储在存储线程和密钥的等待队列条目中。

线程进入睡眠状态。

当线程完成并将 flag 设置为 0 时,它使用 futex 调用来唤醒等待线程。

内核识别正确的等待线程并安排它们运行。

这真是太好了,这真的很方便。 我们看到的这个实现是一个极其简化的 Futex 实现。 他们给我们提供的是这个漂亮的、轻量级的原语来构建同步结构,比如锁。 事实上,你在C和Java中使用的pthread Mutex,它们都使用了这个futex的变体。

在这一点上,值得一问的是,睡觉而不是旋转是否有意义。 实际上,这归结为摊余成本论点。 如果锁定持续时间(我们要持有锁定的时间)很短,那么旋转是有意义的,但权衡是,在旋转时,我们没有做任何有用的工作,我们 燃烧 CPU 周期。 在某些时候,支付线程接触切换的成本是有意义的,让线程进入睡眠状态并运行其他线程,这样我们就可以做有用的工作。

因此,我们需要构建一些在进入睡眠状态之前旋转一些迭代的东西,这与混合 futex 提供的实现相同。

混合 futex 引入了一种巧妙的线程同步方法。 当线程尝试使用比较和交换操作锁定并失败时,它不会立即进入睡眠状态。 相反,它首先旋转少量次数,看看情况是否发生变化。 只有当自旋不会导致获取锁时,线程才会使用类似 futex 的机制来挂起自身。 这种方法将动感单车的效率与睡眠的有效性结合起来。

至此,我们已经从自旋锁发展到 futexes,再到混合 futexes。 我们现在完成了吗?

好吧,我们可以。 如果我们谈论的是像 Java 或 C++ 这样具有本机线程模型的语言,那么我们就会这样做。

如果像 Go 这样使用用户空间线程及其 goroutine 的语言又如何呢? 我们还能做得更好吗? 让我们看看 golang 中如何实现锁/互斥锁。

Golang 互斥体实现

正如上一节所讨论的,我们阻塞了内核线程并将它们放入等待队列,因此操作系统必须付出额外的线程切换成本。

但是 Golang 有 goroutine,它为我们提供了做一些聪明事情的机会。 这使我们有机会阻塞 goroutine 而不是底层操作系统线程。 如果一个 Goroutine 试图获取 Mutex 但它不能,我们完全可以让 Goroutine 进入睡眠状态,而无需让底层线程进入睡眠状态,这样我们就不必支付昂贵的线程切换成本。 这很聪明,这正是 Go 运行时所做的。

让我们看看这是如何工作的。 在我们的程序中,当比较和交换失败时,Go 运行时会将 goroutine 添加到等待队列,但在这种情况下,等待队列是在用户空间中管理的。 等待队列看起来与 futex 等待队列非常相似。 我们有一个哈希表,每个哈希桶都有一个等待队列。 一旦 goroutine 被添加到等待队列中,Go 运行时就会通过调用 Go 调度程序来取消调度 goroutine。 Goroutine 被挂起,但底层线程继续运行,底层线程只是拿起其他 Goroutine 来运行。 当编写者出现时,它完成了它的事情,Go 运行时遍历等待队列,找到第一个正在等待标志变量的要运行的 goroutine,然后将其重新安排到操作系统线程上,以便 goroutine 可以运行。

这很聪明,我们找到了一种方法来避免繁重的线程上下文切换成本。 至此,我们几乎可以完成了,但我们没有,而且我们没有这样做有几个原因。 这个实现有几个问题,它们都归结为这样一个事实:一旦 goroutine 被唤醒,那么 goroutine 尝试获取锁,但失败了,它被置于等待状态 队列。 一旦恢复,它必须竞争,它必须进行比较和交换,它必须与其他也试图进行比较和交换的传入 goroutine 竞争。 完全有可能它会再次失败,并且再次陷入沉睡。 事实上,不仅有可能,而且很可能会发生这种情况,因为其他 goroutine,传入的 goroutine,它们已经被调度到线程上,它们已经在 CPU 上运行,而不是我们刚刚醒来的 goroutine, 在将其放入线程并运行之前存在调度延迟。 很有可能会输。

为了避免上述问题,goroutine 以两种模式运行:

正常模式

饥饿模式

让我们考虑一个场景,其中两个 goroutine 尝试获取锁:

func main() { var mutex sync.Mutex var data int //Writer goroutines -- G1 go func() {  mutex.Lock()  data = 42  mutex.Unlock() }() // Reader goroutines -- G2 for i := 0; i < 3; i++ {  go func() {   mutex.Lock()   fmt.Println("Reader: Data read:", data)   mutex.Unlock()  }() }  // Allow time for the goroutines to complete time.Sleep(time.Second)}

正常模式:

在这种模式下,等待的 goroutine 排成一行,就像队列一样。 然而,当等待的 goroutine 收到继续的信号时,它不会立即拥有锁。 相反,它与试图获得锁的新 goroutine 竞争。 但是新的 goroutine 拥有领先优势,因为它们已经在 CPU 上,所以等待的 goroutine 经常会失败。 为了给等待的 goroutine 有机会,当它们醒来时,它们会被移到队列的前面。 如果等待的 goroutine 多次未能获得锁并且花费的时间超过 1 毫秒,系统将切换到不同的模式。

普通模式下的锁获取:

正常模式下锁释放:

一旦它释放了锁,那么此时可能会有一些等待的 goroutine,并且一些 goroutine 已经在尝试获取锁。 因此,一旦锁被释放,所有 goroutine 就会相互竞争获取锁。

现在正常模式的问题是,等待 goroutine 和运行 goroutine 之间总是存在获取锁的竞争,等待 goroutine 很有可能会失败。 所以我们的锁在 goroutine 旋转 1ms 后从正常模式切换到饥饿模式。

为此,使用 mutexLocked 标志,我们还维护 mutexStarving 模式,它指定互斥体运行的模式

饥饿模式:

在这种模式下,锁直接从释放锁的 Goroutine 给到队列前面等待的 Goroutine。 新的 goroutine 不会旋转或尝试获取锁。 相反,他们耐心地加入队伍的末尾。

饥饿模式下获取的锁:

饥饿模式下锁释放:

当释放锁并且我们在饥饿模式下运行时,我们会看到处理 goroutine 如果它是队列中的最后一个或者等待时间少于 1 毫秒,系统将返回到正常模式。

为什么有两种模式?

普通模式通常速度更快,允许 goroutine 快速多次获取锁。 饥饿模式有助于避免 goroutine 永远陷入等待的极端情况。 因此,这些模式的结合确保了事情保持高效和公平。

我们已经讨论了锁的工作原理以及其他语言如何处理它们。 有了 Go,一切都变得更加智能。 Go 使用自己的方式来管理线程,称为 goroutine。 这使得锁更加高效。 不是阻塞整个线程,而是只等待需要它的部分,以及系统如何切换不同的模式以使其更加高效和有效。

有关互斥锁的更多信息可以在 mutex.go 文件中的同步包中探索。

我们还没有介绍 Go 中互斥体的类型以及何时使用它们。 如果您想深入研究该主题,请在下面发表评论,我很乐意解释 Go 中不同类型的互斥体以及每种类型最适合使用的场景。

标签: #golang锁实现 #golang中的锁 #golang 锁 #golang 锁不起作用 #golang 锁 饥饿模式