惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
I
InfoQ
The Register - Security
The Register - Security
L
LangChain Blog
H
Help Net Security
The GitHub Blog
The GitHub Blog
S
Schneier on Security
博客园 - 【当耐特】
W
WeLiveSecurity
Attack and Defense Labs
Attack and Defense Labs
IT之家
IT之家
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Google DeepMind News
Google DeepMind News
The Cloudflare Blog
H
Heimdal Security Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Y
Y Combinator Blog
雷峰网
雷峰网
N
Netflix TechBlog - Medium
Security Archives - TechRepublic
Security Archives - TechRepublic
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
L
Lohrmann on Cybersecurity
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
The Exploit Database - CXSecurity.com
P
Privacy & Cybersecurity Law Blog
G
GRAHAM CLULEY
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
V
Visual Studio Blog
博客园 - 聂微东
PCI Perspectives
PCI Perspectives
Last Week in AI
Last Week in AI
A
Arctic Wolf
宝玉的分享
宝玉的分享
T
The Blog of Author Tim Ferriss
S
Secure Thoughts
T
Threat Research - Cisco Blogs
GbyAI
GbyAI
云风的 BLOG
云风的 BLOG
D
Darknet – Hacking Tools, Hacker News & Cyber Security
S
SegmentFault 最新的问题
SecWiki News
SecWiki News
月光博客
月光博客
大猫的无限游戏
大猫的无限游戏
Schneier on Security
Schneier on Security
P
Proofpoint News Feed
博客园 - Franky
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
AI
AI
Engineering at Meta
Engineering at Meta

蛮荆

如何获取更多的免费服务器 Kubernetes 调度器队列 - 设计与实现 Kubernetes 调度器 - 核心流程 Kubernetes Networking Model & CNI Kubernetes 控制器管理总结 Kubernetes CronJob 设计与实现 Kubernetes Job 设计与实现 Kubernetes HPA 设计与实现 Kubernetes Deployment 滚动更新实现原理 Kubernetes GC 设计与实现 Kubernetes Pod 驱逐 - 设计与实现 Kubernetes Daemonset 设计与实现 Kubernetes ReplicaSet 设计与实现 Kubernetes EndPoint 设计与实现 Kubernetes Informer 设计与实现 降本增效之应用优化 (三) 日志存储与检索 Kubernetes Pod 设计与实现 - 创建流程 Kubernetes 探针设计与实现 Unix 编程艺术名句摘录 Kubernetes - CRI 概述 Golang 编译速度为什么这么快? Kubernetes Pod 设计与实现 - Pause 容器 Kubernetes - kube-proxy 代理模式工程优化 Kubernetes 应用最佳实践 - 优雅关闭长连接 Kubernetes Service 类型和会话亲和性 Kubernetes 为什么需要 Ingress Kubernetes 架构 - 控制平面和数据平面 降本增效之应用优化 (二) 大报表 Go 语言如何获取 CPU 利用率 降本增效之应用优化 (一) Redis 业务规则引擎演变过程简述 微服务中的熔断算法 漏桶算法和令牌桶算法 jsonparser 为什么比标准库的 encoding/json 快 10 倍 ? zap 高性能设计与实现 HTTP Router 算法演进 fastcache 高性能设计与实现 Web 常见的三个安全问题 ants Code Reading Go 线程安全 map 方案选型 布隆过滤器 死锁、活锁、饥饿、自旋锁 sync.Pool Code Reading Go 内存管理概述 Go netpoll Code Reading goroutine 泄漏与检测 time/Timer Code Reading GMP Scheduler Code Reading Go channel 的 15 条规则和底层实现 为什么 Linux “一切皆文件” context.Context Code Reading runtime/HACKING.md Goland 最佳实践 互联网开发与金庸武学 为什么 Redis 6.0 引入多线程模型? Kubernetes 应用最佳实践 - 金丝雀发布 容器中如何正确配置 GOMAXPROCS ? singleflight Code Reading sync.Map Code Reading sync.WaitGroup Code Reading sync.RWMutex Code Reading sync.Mutex Code Reading sync.Once Code Reading Go 无锁编程 sync/atomic Code Reading goroutine 交替打印奇偶数 GODEBUG Go 并发模式 Go 汇编 UUID 通用技术选型 Kubernetes 应用最佳实践 - 水平自动伸缩 Go 高性能 Tips fasthttp 为什么比标准库 net/http 快 10 倍 ? 技术文章配图指南 ChatGPT 初体验 Docker 网络原理概览 iptables 的五表五链 Kubernetes 应用最佳实践 - 亲和性和污点容忍度 Go 的反射与三大定律 Docker 官方提供的最佳实践 Go 语言内置的设计模式 HTTP1 到 HTTP3 的工程优化 Kubernetes 应用最佳实践 - Sidecar 模式 Kubernetes 应用最佳实践 - init 容器和钩子函数 为什么 recover 必须在 defer 中调用? 为什么 defer 的执行顺序和注册顺序不同? Go map 设计与实现 Go 切片扩容底层实现 Go 语言中的零拷贝 Go Delve 云原生和边缘计算简介 Kubernetes Pod 服务质量等级 Kubernetes 应用最佳实践 - 探针 Kubernetes 应用最佳实践 - 资源请求和限制 CDN 原理 Kubernetes 应用最佳实践 - 开篇 缓存策略和模式 Go 内存模型 Kubernetes 核心概念 软件版本语义化
sync.Cond Code Reading
2023-04-26 · via 蛮荆

2023-04-26 Golang 并发编程 Go 源码分析 读代码

概述

sync.Cond 实现了一种条件变量同步原语,可以让一个 goroutine 集合在满足特定条件时被唤醒。

sync.Cond 典型的使用场景是 生产-消费者模式,多个 goroutine 等待某个事件发生, 单个 goroutine 通知某个事件已发生。 比如电商中的用户下单事件发生时,会通知到订单、用户、积分、优惠券、仓储等服务,如果是单个生产者对单个消费者,直接使用 互斥锁channel 就可以。

为什么多个消费者模式不使用互斥锁或 channel 呢?

可以想象一个非常简单的场景: 有一个 goroutine 在异步接收数据,剩下的多个 goroutine 必须等待该 goroutine 接收完才能读取。 在这种情况下,如果单纯使用 互斥锁channel,就只能有一个 goroutine 可以等待并读取到数据,其他的 goroutine 没办法读取。

当然我们可以通过折衷的方案来解决,例如 可以创建一个全局变量,用来标志这个 goroutine 数据是否接收完成,剩下的 goroutine 反复检查该全局变量,直到满足条件。 或者 可以创建多个 channel,每个 goroutine 阻塞在一个 channel 上面,接收数据的 goroutine 在数据接收完毕后,逐个通知。 但是不论哪种方式,实现复杂度都大大增加了。

sync.Cond 提供了简洁优雅的方式来解决上述问题。

示例

通过一个小例子展示 sync.Cond 的使用方法。

package main

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

// 条件变量
var done = false

// 数据读取操作
func read(name string, c *sync.Cond) {
	c.L.Lock()
	for !done {
		// 等待生产者写入通知
		c.Wait()
	}
	fmt.Println(name, "starts reading")
	c.L.Unlock()
}

// 数据写入操作
func write(name string, c *sync.Cond) {
	fmt.Println(name, "starts writing")
	time.Sleep(100 * time.Millisecond)

	c.L.Lock()
	// 设置条件变量
	done = true
	c.L.Unlock()

	fmt.Println(name, "wakes all")
	// 通知所有消费者
	c.Broadcast() 
}

func main() {
	// 创建对象时传入一个互斥锁
	cond := sync.NewCond(&sync.Mutex{}) 

	// 3 个消费者
	go read("reader-1", cond)
	go read("reader-2", cond)
	go read("reader-3", cond)

	// 1 个生产者
	write("writer-1", cond)

	time.Sleep(time.Second)
}
$ go run main.go

# 输出如下
writer-1 starts writing
writer-1 wakes all
reader-2 starts reading
reader-1 starts reading
reader-3 starts reading

从输出结果中可以看到,消费者刚开始时调用 Wait 方法阻塞,直到生产者 (write) 写入完成后调用 Broadcast 方法通知所有消费者 (read),然后所有消费者依次输出。

调用关系图

内部实现

我们来探究一下 sync.Cond 的内部实现,文件路径为 $GOROOT/src/sync/cond.go,笔者的 Go 版本为 go1.19 linux/amd64

Cond 对象

Cond 对象表示同步条件变量,可以让 goroutins 等待或通知某个事件发生,Cond 对象一旦使用后,就不能再复制。

每一个 Cond 对象都持有一个对应的 Locker 接口 (通常是一个互斥锁或读写锁),当条件发生变化以及调用 Wait 方法时,必须持有对应的锁。

在简单的应用场景中,更好的选择是使用 channel 完成同步操作 (Go 的标准库设计理念是上层应用尽量使用 channel 作为同步原语),可以将两者的对应关系简单概况如下:

  • 关闭 channel 对应 Cond.Broadcast 方法
  • 向 channel 发送数据对应 Cond.Signal 方法

Signal 和 Broadcast

type Cond struct {
    // 保证编译期间不会发生复制
	noCopy noCopy
	// 当访问或者修改条件时,必须持有 L
	L Locker
	// goroutine 链表
	notify  notifyList
	// 保证运行期间不会发生复制
	checker copyChecker 
}

notifyList 对象

notifyList 对象表示一个 goroutine 链表数据结构。

// runtime/sema.go

type notifyList struct {
	// 等待的 goroutine 索引,可以在没有获取锁的情况下原子性递增
	wait uint32

	// 已经通知到的 goroutine 索引
	// 可以在没有获取锁的情况下进行读取操作,但是必须在获得锁的情况下进行写入操作
	notify uint32
	
	lock mutex
	// 等待索引和已通知索引可以是环形队列结构
	// 链表头指针
	head *sudog
    // 链表尾指针
	tail *sudog 
}

sync.Cond 对象结构

NewCond 方法

NewCond 方法创建一个 Cond 对象,参数为一个 Locker 接口。

func NewCond(l Locker) *Cond {
	return &Cond{L: l}
}

Wait 方法

Wait 方法 (阻塞调用) 会解锁 c.L 字段并且休眠当前 goroutine,等到当前 goroutine 被唤醒后,Wait 方法在返回之前再对 c.L 字段加锁。

func (c *Cond) Wait() {
	// 复制检测
	c.checker.check()
	// 等待索引 + 1
	t := runtime_notifyListAdd(&c.notify)
    // goroutine 休眠之前先解锁 (否则其他 goroutine 获取不到锁,会造成死锁问题)
	c.L.Unlock()
    // 等待唤醒,并传递等待索引
	runtime_notifyListWait(&c.notify, t)
	c.L.Lock()
}

runtime_notifyListAdd 方法

runtime_notifyListAdd 方法通过链接器链接到 notifyListAdd 方法,notifyListAdd 方法将当前调用方 goroutine 添加到通知链表中以便接收通知。

// runtime/sema.go

//go:linkname notifyListAdd sync.runtime_notifyListAdd
func notifyListAdd(l *notifyList) uint32 {
	// 等待索引计数 + 1
    return atomic.Xadd(&l.wait, 1) - 1
}

runtime_notifyListWait 方法

runtime_notifyListWait 方法通过链接器链接到 notifyListWait 方法,如果在调用 notifyListAdd 方法之后已经发送过通知,notifyListWait 就会立即返回,否则就陷入阻塞。

// runtime/sema.go

//go:linkname notifyListWait sync.runtime_notifyListWait
func notifyListWait(l *notifyList, t uint32) {
	// 如果之前已经发送过通知,直接返回即可
	if less(t, l.notify) {
		unlock(&l.lock)
		return
	}

	// 获取当前 goroutine 并追加到链表尾部
	s := acquireSudog()
	s.g = getg()
	// 获取等待索引
	s.ticket = t
	
	// 将 goroutine 加入到链表中
	if l.tail == nil {
		l.head = s
	} else {
		l.tail.next = s
	}
	l.tail = s
	
	// 休眠当前 goroutine
	goparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3)
	// 归还 goroutine
    releaseSudog(s)
}

Signal 方法

Signal 方法唤醒链表头部等待的 goroutine

func (c *Cond) Signal() {
    // 复制检测
	c.checker.check()
	runtime_notifyListNotifyOne(&c.notify)
}

runtime_notifyListNotifyOne 方法

runtime_notifyListNotifyOne 方法通过链接器链接到 notifyListNotifyOne 方法,唤醒链表头部的 goroutine

// runtime/sema.go

//go:linkname notifyListNotifyOne sync.runtime_notifyListNotifyOne
func notifyListNotifyOne(l *notifyList) {
	// 如果已通知索引和等待索引相同
	// 说明没有等待的 goroutine, 直接返回
	if atomic.Load(&l.wait) == atomic.Load(&l.notify) {
		return
	}

	// 获取已通知索引
    t := l.notify
	// 如果已通知索引和等待索引相同
	// 说明没有等待的 goroutine, 直接返回
	// 又是一个经典的双重检测
	if t == atomic.Load(&l.wait) {
		unlock(&l.lock)
		return
	}

	// 已通知索引 + 1
    atomic.Store(&l.notify, t+1)

	// 根据已通知索引,找到对应的 goroutine 唤醒
	for p, s := (*sudog)(nil), l.head; s != nil; p, s = s, s.next {
		if s.ticket == t {
			// 唤醒满足条件的 goroutine
			readyWithTime(s, 4)
			return
		}
	}
}

为什么不直接唤醒链表的头部元素呢

这就是 已通知索引 notify 字段存在的意义,因为获取等待索引和加入到链表两个步骤不是原子操作,这意味着在并发场景下,会出现顺序不一致的情况。

例如,goroutine 对应的等待索引为 2, 但是因为并发问题,加入到链表的时候,排到了第 3 个位置,如图所示:

乱序问题示例

不过不需要担心,我们可以根据 已通知索引 notify 字段,保证在发送单个通知时保证顺序的一致性,避免乱序可能带来的 先到的 goroutine 反而等待时间长 这类问题。

当前 notify 字段对应的 goroutine 通知后,会变更指针到下一个 goroutine,如图所示:

乱序问题解决

从算法时间复杂度来分析,直接唤醒链表头部元素是 O(1), 通过 notify 字段唤醒是 O(N), 但是官方的注释中写道:

This scan looks linear but essentially always stops quickly.

所以即便出现乱序,notify 索引字段对应的 goroutine 也不会太靠后,所以不会产生太多的性能问题。

Broadcast 方法

Broadcast 方法唤醒所有等待的 goroutine

func (c *Cond) Broadcast() {
	c.checker.check()
	runtime_notifyListNotifyAll(&c.notify)
}

runtime_notifyListNotifyAll 方法

runtime_notifyListNotifyAll 方法通过链接器链接到 notifyListNotifyAll 方法,唤醒所有等待的 goroutine

// runtime/sema.go

//go:linkname notifyListNotifyAll sync.runtime_notifyListNotifyAll
func notifyListNotifyAll(l *notifyList) {
    // 没有等待的 goroutine, 直接返回
	if atomic.Load(&l.wait) == atomic.Load(&l.notify) {
		return
	}
	
	// 既然是全部唤醒,也就不用担心上面提到的乱序问题了
	// 直接遍历 goroutine 链表,逐个唤醒 goroutine
	for s != nil {
		next := s.next
		s.next = nil
		readyWithTime(s, 4)
		s = next
	}
}

check 方法

Cond.copyChecker 字段持有指向自身的指针,用来检测是否被复制,当指针值和实际地址值不一致时,说明发生了复制。

type copyChecker uintptr

func (c *copyChecker) check() {
	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")
	}
}

check 方法的实现很有意思,里面有 3 个判断条件:

  1. uintptr(*c) != uintptr(unsafe.Pointer(c))

    比较 copyChecker 的指针值 (默认是 0)

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

    CAS 操作 copyChecker 的指针值

  3. uintptr(*c) != uintptr(unsafe.Pointer(c))`

    比较 copyChecker 的指针值

为什么 CAS 操作 之后又重复比较了一次呢?主要是针对临界区的检测,因为可能会出现一种极端情况: CAS 操作 之后的瞬间 copyChecker 被复制了

noCopy 对象

noCopy 对象可以添加到具体的结构体中,实现 “首次使用之后,无法被复制” 的功能 (由编译器实现)。

noCopy.Lock 方法是一个空操作,由 go vet 工具链中的 -copylocks checker 参数指令使用。

type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

小结

sync.Cond 不是一个常用的同步机制,在条件变量长时间无法满足时,sync.Cond 能够让出处理器的使用权,和单纯使用 for {} 进行无限等待相比, 可以提高 CPU 的利用率。但是使用时我们也需要注意以下问题:

  • Wait 方法在调用之前一定要完成加锁操作,否则程序会 panic (因为方法内部会释放互斥锁)
  • Signal 方法会唤醒链表 (队列) 最前面、等待最久的 goroutine (通过等待索引字段保证顺序)
  • Broadcast 方法会按照链表的顺序 (并不是先进先出,因为可能存在乱序问题) 唤醒所有等待的 goroutine

Reference

  1. Go sync.Cond
  2. Go 设计与实现
  3. Golang sync.Cond 条件变量源码分析