聊聊go中的锁机制(上)


聊聊go中的锁机制

本文内容主要来自对Let‘s talk locks

无处不在的锁

在多线程编程中,为了保证数据的一致性,锁几乎是无处不在的。每个人都在使用锁,但锁并不是一个没有缺陷可以随意使用的东西,经常可以听到由于锁导致的BUG或者性能问题。因此我们需要对锁足够了解,在这篇文章中,想和大家聊聊锁的内部原理,探讨锁的性能以及锁的策略问题。

建立一个锁

Go 语言作为一个原生支持用户态进程(Goroutine)的语言,当提到并发编程、多线程编程时,往往都离不开锁这一概念。锁是一种并发编程中的同步原语(Synchronization Primitives),它能保证多个 Goroutine 在访问同一片内存时不会出现竞争条件(Race condition)等问题。在文章中我们主要讨论sync.Mutex互斥锁的实现。

首先必须知道我们希望锁能使goroutine互斥。假设我们有一个简单的示例程序,我们有两个goroutine访问共享任务队列。T1是读取器,它将从任务队列中读取任务,T2是将项目放入队列的写入器。我们希望建立一个锁使得T1和T2永远无法同时访问该任务队列。

//shared ring buffer
var tasks Tasks
//T1 running on cpu1
func reader() {
   // Read a task
   t := tasks.get()
   // Do something with it.  
    ...
}
//T2 running on cpu2
func writer() {
    // Write to tasks
    tasks.put(t) 
}

如何做到这一点?我们只使用一个标志。原理是标志将跟踪任务队列是否已被访问,判断它是否空闲。如果空闲,标志为0;如果正在使用,标志为1。程序通过检查FLAG值判断任务队列是否空闲的,当FLAG=0时程序设置FLAG++并执行任务,任务结束后FLAG–将FLAG值置0。当FLAG=1时,则意味着另一个goroutine正在访问任务队列,程序会继续循环判断等待任务结束。

func reader() {  
    for {     
        /* If flag is 0,can access tasks. */
        if flag == 0 {   
            /* Set flag */       
            flag++       
            ...   
            /* Unset flag */       
            flag--       
            return     
        }     
    /* Else, keep looping. */ 
    }  
}

仅仅通过一个标志就能实现锁吗?当然不行。有两个原因:

  1. FLAG++操作,在X86中会将其编译为增量指令,该指令由处理器分三步运行。这是一条读-修改-写指令,将从内存中读取变量到本地CPU寄存器中,对其进行修改,在此进行递增,然后将其写回到内存中。一条指令导致了两次内存访问,这意味着线程的读取-修改-写入指令的读写可能与其他读取的线程交错进行。简单说T2的读取可能会在T1的FLAG++之后开始但此时T2看见的FLAG值任然是++操作之前的值(也就是0),这显然是有问题的。

    这是一个典型的非原子操作,意味着其他处理器可以看见其未完成的状态。操作可以是非原子的,因为它们使用许多CPU指令,可以在后台被编译为许多指令,例如在大型数据结构上加载或存储,或者因为它们可以编译为单个CPU指令,但是结果可以在众多内存访问中,例如上述FLAG++操作。与非原子操作相反的是原子操作。在x86中,存储中的负载自然与缓存行内对齐,保证是原子操作的。这些保证来自x86是缓存一致的事实。它可以确保如果您的数据位于单个缓存行中,那么所有CPU内核都具有一致的视图。这是缓存一致性,它们对于单个缓存行具有一致的视图。

  2. 第二个问题与这段代码有关,就是设置标志,读取任务并再次设置标志,对内存操作进行了重新排序。它们可以由编译器重新排序,因此在这里,我们看到等于0的标志在读取任务之前已重新排序。处理器还可以重新排序内存操作。在此示例中,这是存储负载重排序的示例,其中在写入之前将负载,任务的读取提升到发生,并且这样做的原因是隐藏写入延迟。由于高速缓存的一致性,必须进行的高速缓存验证,因此编写成本很高,因此x86可以做到这一点。它的作用是在写入过程中开始读取。这可能会隐藏写入的延迟。

    编译器和处理器会一直对内存操作进行重新排序,以加快处理速度。关于它们可以和不可以重新排序的规则有一些,唯一的基本规则是单线程程序的顺序一致性。只要不改变单线程程序可见的顺序,他们就可以重新排序。对于编译器,还有其他规则。这是由程序,语言的内存模型所捕获的,它们保证了如果您的多线程程序是无数据擦除的,那么如果您正确使用了同步结构,这样,就可以保证不会对内存操作进行重新排序。您的程序即使是多线程程序也保持顺序一致。

    这是编译器重新排序。至于处理器的重新排序,周围的规则由硬件的内存模型捕获。以x86为例,这是一种宽松的一致性形式,称为总商店排序。这就是说,不允许对内存进行大多数重新排序,它们是无效的,但是存储负载的重新排序(就像我们刚才看到的那样)是完全有效的。

我们的标志不起作用,因为它不是原子的,也不能给我们提供内存排序保证。好,没问题 为什么我们不仅仅构建一个让我们原子化并让我们进行内存排序的构造?不容易,但是,幸运的是,硬件可以提供。指令集公开了这些特殊的硬件指令,这些指令给我们原子性,并为我们提供了内存顺序保证。在x86中,交换指令是给我们原子性的一条内存指令示例。保留内存顺序的指令示例-这些指令称为“内存隔离栅”或“内存隔离栅”-例如内存隔离栅,存储隔离栅和装载隔离栅。

x86真正强大的工具使我们获得了锁定指令前缀。锁前缀和指令前缀。您可以将其应用于某些内存操作,例如加载,存储和增量。这既使我们保证原子性,又保留了内存操作,保留了顺序。实际上,这些锁定指令前缀是在大多数编程语言中为原子操作提供动力的事物。

听起来很有用。我们可以用它来解决我们的难题。锁定前缀的比较和交换指令或原子比较和交换指令正是我们需要的。该指令有条件地更新变量,因此,仅当它等于某个值时,它才会将其更新为另一个值,但是此读-修改-写操作是原子的,这正是我们想要的。

让我们进行这个原子的比较和交换,并尝试用它构建锁。回到上面的示例程序。在这里,我们所有的操作都使用我们对flag变量进行的所有操作,我们将使它们成为原子操作。我们将使用原子比较和交换来检查标志是否为零,以及是否将其设置为零。我们将在其他地方使用原子操作。如果该原子比较和交换失败,那么我们将循环并再次尝试。

实际上,它确实有效。这是自旋锁的简化实现,自旋锁在Linux内核中得到广泛使用。


未完待续


文章作者: Nczkevin
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Nczkevin !
评论
  目录