探索 Go sync.Cond 的实现与应用

38 阅读1分钟

基本概念

sync.Cond 也被称作条件变量,虽然在实际的工作中应用较少,但是sync.Cond在 Go 语言中也是一种比较重要的同步机制,在实际应用中我们比较容易混淆条件变量、互斥锁、信号量等同步机制,下面我们展开详细讨论一下。

什么是条件变量

条件变量(Condition Variable)是一种用于多线程或多进程同步的机制。它通常与互斥锁(Mutex)结合使用,用于解决线程或进程之间的等待和通知问题。

条件变量允许一个或多个线程等待某个条件的发生,而其他线程可以在条件满足时通知等待的线程。当一个线程需要等待某个条件时,它会先获取互斥锁,然后检查条件是否满足。如果条件不满足,线程会调用条件变量的等待函数,释放互斥锁并进入等待状态。当其他线程改变了条件,使其满足时,会调用条件变量的通知函数,唤醒一个或多个等待的线程。被唤醒的线程会重新获取互斥锁,再次检查条件是否满足,如果满足则继续执行,否则再次进入等待状态。

条件变量的主要作用是在多线程或多进程环境中,实现线程或进程之间的协作和同步,避免不必要的忙碌等待和资源浪费。它可以用于解决生产者 - 消费者问题、线程间的任务分配等多种同步场景。

条件变量 VS 互斥锁

条件变量和互斥锁都是用于多线程或多进程同步的机制,但它们的作用和使用方式有所不同。

互斥锁(Mutex)主要用于保护共享资源,确保在同一时刻只有一个线程或进程能够访问该资源。互斥锁通过加锁和解锁来实现对共享资源的独占访问,防止多个线程或进程同时修改共享资源导致的数据不一致或竞态条件。

条件变量(Condition Variable)则用于线程或进程之间的等待和通知。当一个线程或进程需要等待某个条件满足时,它可以使用条件变量进行等待。其他线程或进程可以在条件满足时通过条件变量通知等待的线程或进程。条件变量通常与互斥锁结合使用,在等待条件变量之前需要先获取互斥锁,以保护条件的判断和修改。

具体来说,互斥锁用于解决资源竞争问题,而条件变量用于解决线程或进程之间的协作问题。互斥锁关注的是对资源的访问控制,而条件变量关注的是线程或进程之间的状态变化和通知机制。

例如,在生产者 - 消费者问题中,互斥锁用于保护共享的缓冲区,确保生产者和消费者在访问缓冲区时不会发生冲突。而条件变量用于实现生产者和消费者之间的同步,当缓冲区为空时,消费者等待生产者生产数据并通知;当缓冲区已满时,生产者等待消费者取出数据并通知。

总的来说,互斥锁和条件变量相互配合,共同实现多线程或多进程的同步和协作。

条件变量 VS 信号量

条件变量和信号量都是用于多线程或多进程同步和通信的机制,但它们有一些区别:

条件变量主要用于线程或进程之间的等待和通知。当一个线程或进程需要等待某个条件满足时,它可以使用条件变量进行等待,其他线程或进程可以在条件满足时通过条件变量通知等待的线程或进程。条件变量通常与互斥锁结合使用,以保护条件的判断和修改。

信号量主要用于控制对共享资源的访问数量。它是一个整数计数器,表示可用资源的数量。线程或进程可以通过对信号量进行 P 操作(减 1)来申请资源,如果信号量的值小于等于 0,则线程或进程会被阻塞;通过 V 操作(加 1)来释放资源,唤醒一个或多个等待的线程或进程。

条件变量适用于需要线程或进程之间进行等待和通知的场景,例如生产者 - 消费者问题、线程间的任务分配等。

信号量适用于控制同时访问某个共享资源的线程或进程数量,例如限制同时访问数据库连接的数量、控制并发任务的执行数量等。

总的来说,条件变量更侧重于线程或进程之间的协作和状态同步,而信号量更侧重于资源的访问控制和并发数量的限制。在实际应用中,根据具体的需求选择合适的同步机制。

实现原理

Cond 结构体

type Cond struct {
    noCopy noCopy

    // L is held while observing or changing the condition
    L Locker

    notify  notifyList
    checker copyChecker
}
  • noCopy 是一个空结构体,用来检查 sync.Cond 是否被复制。(在编译前通过 go vet 命令来检查);

  • L 是一个 Locker 接口,用来保护条件变量,保存调用 NewCond 时的互斥锁;

  • notify 是一个 notifyList 类型,用来记录所有阻塞的 goroutine,具体实现的核心。

  • checker 是一个 copyChecker 类型,用来检查 sync.Cond 是否被复制。(如果在运行时被复制,会导致 panic)

notifyList 结构体

notifyList 是 sync.Cond 中维护的一个链表,这个链表记录了所有因为共享资源还没准备好而阻塞的 goroutine。它的定义如下所示:

// src/runtime/sema.go 
type notifyList struct {
    wait   uint32
    notify uint32
    lock   uintptr // key field of the mutex
    head   unsafe.Pointer
    tail   unsafe.Pointer
}

属性说明:

  • wait 是下一个 waiter 的编号,从 0 开始计数,。它在锁外自动递增。

  • notify 是下一个要通知的 waiter 的编号。它可以在锁外读取,但只能在持有锁的情况下写入。

  • lock 底层是一个 mutex 类型的指针,用来保护 notifyList。

  • head 底层是一个 sudog 类型的指针,用来记录阻塞的 goroutine 链表的头节点。

  • tail 底层是一个 sudog 类型的指针,用来记录阻塞的 goroutine 链表的尾节点。

notifyList 提供了一系列函数,来实现notifyList 读写操作,sync.Cond 也是调用这些方法来实现goroutine的阻塞与唤醒:

  • notifyListAdd 方法将 waiter 的编号加 1。

  • notifyListWait 方法将当前的 goroutine 加入到 notifyList 中。(也就是将当前协程挂起)

  • notifyListNotifyOne 方法将 notifyList 中的第一个 goroutine 唤醒。

  • notifyListNotifyAll 方法将 notifyList 中的所有 goroutine 唤醒。

  • notifyListCheck 方法检查 notifyList 的大小是否正确。

Wait 方法

func (c *Cond) Wait() {
    // 检查是否被复制
    c.checker.check()
    // 更新 notifyList 中需要等待的 waiter 的数量
    // 返回当前需要插入 notifyList 的编号
    t := runtime_notifyListAdd(&c.notify)
    // 解锁
    c.L.Unlock()
    // 挂起当前 g,直到被唤醒
    runtime_notifyListWait(&c.notify, t)
    // 唤醒之后,重新加锁。
    // 因为阻塞之前解锁了。
    c.L.Lock()
}
  1. runtime_notifyListAdd 底层是 src/runtime/sema.go 中的 notifyListAdd 函数,返回需要插入 notifyList 的编号*。*

  2. runtime_notifyListWait 底层是 src/runtime/sema.go 中的 notifyListWait 函数,挂起当前 goroutine 并加入notifyList 中*。*

  3. Wait 方法中会先解锁然后再阻塞当前 goroutine,因此调用 Wait 方法前先加锁。

Signal 方法

func (c *Cond) Signal() {
    // 检查 sync.Cond 是否被复制了
    c.checker.check()
    // 唤醒 notifyList 中的第一个 goroutine
    runtime_notifyListNotifyOne(&c.notify)
}

runtime_notifyListNotifyOne 底层是 src/runtime/sema.go 中的 notifyListNotifyOne 函数,唤醒 notifyList 中的第一个 goroutine*。*

Broadcast 方法

func (c *Cond) Broadcast() {
    // 检查 sync.Cond 是否被复制了
    c.checker.check()
    // 唤醒 notifyList 中的所有 goroutine
    runtime_notifyListNotifyAll(&c.notify)
}

runtime_notifyListNotifyAll 底层是 src/runtime/sema.go 中的 notifyListNotifyAll 函数,唤醒 notifyList 中的全部 goroutine*。*

copyChecker

在调用 Wait、Signal、Broadcast 方法是都会先调用 copyChecker.check 方法,目的是检查 Cond 实例有没有被复制。

type copyChecker uintptr

func (c *copyChecker) check() {
    // Check if c has been copied in three steps:
    // 1. The first comparison is the fast-path. If c has been initialized and not copied, this will return immediately. Otherwise, c is either not initialized, or has been copied.
    // 2. Ensure c is initialized. If the CAS succeeds, we're done. If it fails, c was either initialized concurrently and we simply lost the race, or c has been copied.
    // 3. Do step 1 again. Now that c is definitely initialized, if this fails, c was copied.
    if uintptr(*c) != uintptr(unsafe.Pointer(c)) &&
       !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) &&
       uintptr(*c) != uintptr(unsafe.Pointer(c)) {
       panic("sync.Cond is copied")
    }
}
  1. uintptr(*c) != uintptr(unsafe.Pointer(c)) 时有两种情况:

    1. check 第一次被调用,c 还没被初始化,也就是 c == 0 ,所以 uintptr(*c) != uintptr(unsafe.Pointer(c))

    2. c 初始化后(完成第一次check调用)c 被复制了,c 被复制之后 c 的地址会发生变化,所以 uintptr(*c) != uintptr(unsafe.Pointer(c))

  2. !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c)))

    1. 如果 c 还没被初始化,也就是 c == 0 时,!atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) 为 false,c 的值会被初始化成 c 的地址,然后直接返回。

    2. 如果 c 已经初始化,c != 0 时,!atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) 为 true,继续执行。

  3. uintptr(*c) != uintptr(unsafe.Pointer(c)) ,执行到这块逻辑说明 c 已经完成初始化,如果此时 uintptr(*c) != uintptr(unsafe.Pointer(c)) 说明 c 被复制了。

基本用法

  1. 创建条件变量

    使用sync.NewCond函数创建一个新的条件变量,通常传入一个互斥锁。

  2. 等待条件满足

    在一个协程中,首先获取互斥锁,然后判断条件是否满足。如果不满足,则调用cond.Wait方法等待,这会释放互斥锁并阻塞当前协程,直到被其他协程唤醒。

  3. 发出信号唤醒等待的协程

    在另一个协程中,当条件满足时,获取互斥锁,修改条件变量,然后调用cond.Signal方法发出信号,唤醒一个等待的协程,或者调用cond.Broadcast方法唤醒所有等待的协程。

应用示例

生产者与消费者

假设有一个生产者协程不断生产产品并放入缓冲区,有多个消费者协程从缓冲区中取出产品进行消费。可以使用sync.Cond来协调生产者和消费者,当缓冲区为空时,消费者协程等待;当缓冲区已满时,生产者协程等待。

package main

import (
    "fmt"
    "sync"
)

type Buffer struct {
    data []int
    size int
    mutex *sync.Mutex
    notEmpty sync.Cond
    notFull  sync.Cond
}

func NewBuffer(size int) *Buffer {
    mutex := sync.Mutex{}
    return &Buffer{
        size: size,
        mutex: &mutex,
        notEmpty: *sync.NewCond(&mutex),
        notFull:  *sync.NewCond(&mutex),
    }
}

func (b *Buffer) Put(item int) {
    b.mutex.Lock()
    for len(b.data) == b.size {
        b.notFull.Wait()
    }
    b.data = append(b.data, item)
    b.notEmpty.Signal()
    b.mutex.Unlock()
}

func (b *Buffer) Get() int {
    b.mutex.Lock()
    for len(b.data) == 0 {
        b.notEmpty.Wait()
    }
    item := b.data[0]
    b.data = b.data[1:]
    b.notFull.Signal()
    b.mutex.Unlock()
    return item
}

func producer(buffer *Buffer) {
    for i := 0; ; i++ {
        buffer.Put(i)
        fmt.Println("Produced:", i)
    }
}

func consumer(buffer *Buffer) {
    for {
        item := buffer.Get()
        fmt.Println("Consumed:", item)
    }
}

func main() {
    buffer := NewBuffer(5)
    go producer(buffer)
    go consumer(buffer)
    go consumer(buffer)
    select {}
}

任务队列管理

有一个任务队列,多个工作协程从队列中获取任务进行处理,当队列为空时等待新任务的加入。管理者协程负责向队列中添加任务,并在添加任务后通知等待的工作协程。

package main

import (
    "fmt"
    "sync"
    "time"
)

type TaskQueue struct {
    queue    []interface{}
    mutex    *sync.Mutex
    notEmpty sync.Cond
}

func NewTaskQueue() *TaskQueue {
    mutex := &sync.Mutex{}
    return &TaskQueue{
       mutex:    mutex,
       notEmpty: *sync.NewCond(mutex),
    }
}

func (tq *TaskQueue) AddTask(task interface{}) {
    tq.mutex.Lock()
    tq.queue = append(tq.queue, task)
    tq.notEmpty.Signal()
    tq.mutex.Unlock()
}

func (tq *TaskQueue) GetTask() interface{} {
    tq.mutex.Lock()
    for len(tq.queue) == 0 {
       tq.notEmpty.Wait()
    }
    task := tq.queue[0]
    tq.queue = tq.queue[1:]
    tq.mutex.Unlock()
    return task
}

func worker(tq *TaskQueue) {
    for {
       task := tq.GetTask()
       fmt.Println("Processing task:", task)
    }
}

func main() {
    taskQueue := NewTaskQueue()
    for i := 0; i < 3; i++ {
       go worker(taskQueue)
    }
    for j := 0; j < 10; j++ {
       taskQueue.AddTask(j)
    }

    time.Sleep(3 * time.Second)
}

注意事项

正确使用互斥锁

sync.Cond必须与互斥锁一起使用,以确保对条件变量的访问是线程安全的。

  • 从源码的角度看,在 Wait 方法内部会先解锁然后再进行阻塞,如果调用 Wait 方法前没有进行加锁会出现panic。

  • 从应用场景来说,通常 Wait 等待的共享资源是并发访问的,加锁的目的是为了保证共享资源的并发安全。

// 官方文档里的一个示例
c.L.Lock()
for !condition() {
    c.Wait()
}
... make use of condition ...
c.L.Unlock()

避免虚假唤醒

在使用cond.Wait时,可能会出现虚假唤醒的情况,即协程在没有收到信号的情况下被唤醒。因此,在等待条件满足的循环中,应该始终检查条件是否真正满足。

// bad case
c.L.Lock()
if !condition() {
    c.Wait()
}
... make use of condition ...
c.L.Unlock()

// good case 
c.L.Lock()
for !condition() {
    c.Wait()
}
... make use of condition ...
c.L.Unlock()

信号的精确性

cond.Signal只唤醒一个等待的协程,如果有多个协程等待,不能保证唤醒的是特定的协程。如果需要唤醒所有等待的协程,应该使用cond.Broadcast。

总结

sync.Cond虽然在实际工作中应用较少,但在某些特定场景下,它是一种非常有效的同步机制。通过与互斥锁的结合使用,sync.Cond可以实现线程或进程之间的等待和通知,从而解决多线程或多进程环境中的协作和同步问题。

在使用sync.Cond时,需要注意正确使用互斥锁、避免虚假唤醒以及根据实际需求选择合适的信号发送方式。同时,还需要根据具体的应用场景选择合适的同步机制,如在资源竞争问题中使用互斥锁,在线程或进程之间的协作问题中使用条件变量,在控制资源访问数量时使用信号量等。

总之,了解和掌握sync.Cond的基本概念、实现原理、应用场景和注意事项,对于编写高效、可靠的多线程或多进程程序具有重要意义。