总结
第一部分:核心 sync 包 —— 本地并发
sync 包里的这些工具,是处理单进程内、多个 goroutine 之间协同问题的基础
1. 锁 (Mutex & RWMutex)
-
核心作用:保护共享资源,保证数据一致性。 任何时候,只允许一个(或一类)goroutine 进入“临界区”。
-
精华:
-
Mutex (互斥锁) :最简单粗暴的锁,一次只能进一个,不管他是“读”还是“写”。
- 决策口诀: “情况不明,先上 Mutex。” 它是最安全、最通用的选择。
-
RWMutex (读写锁) :更智能的锁。
- 读锁 (RLock) :允许多个“读者”同时进入不同的隔间,互不干扰。
- 写锁 (Lock) :当有人要“写操作“时,会将读者清场,谁也不能进。
- 决策口诀: “读多写少,性能瓶颈,才用 RWMutex。” 不要滥用它,因为它的内部实现比 Mutex 复杂,在读写均衡的场景下,性能可能还不如 Mutex。
-
共同的“坑” :
- 忘记 Unlock:最常见的错误。defer mu.Unlock() 是你的肌肉记忆。
- 复制已锁定的锁:会导致程序行为异常。
- 重入:Go 的锁是不可重入的,自己锁了自己,就会死锁。这是设计哲学,不是 bug。
-
2. 并发编排 (WaitGroup)
-
核心作用:等待一组并发任务全部完成。
-
精华:
-
比喻:就像田径比赛。
- wg.Add(N):裁判(主 goroutine)在发令枪响前,确认有 N 位选手就位。
- go worker():发令枪响,N 位选手同时开跑。
- wg.Done():每个选手跑过终点线时,举手示意一下。
- wg.Wait():裁判在终点线等着,直到所有 N 位选手都举过手,他才宣布比赛结束。
-
决策口诀: “并发启动,等待所有,就用 WaitGroup。”
-
3. 单次执行 (Once) —— “一生一次的初始化”
-
核心作用:保证某个函数在整个程序生命周期内,无论被多少个 goroutine 调用,都只会被真正执行一次。
-
你要记住的精华:
- 最经典用途:实现线程安全的单例模式 (Singleton) 。比如,全局的数据库连接池、配置加载器,这些东西只需要初始化一次。
- 比喻:就像一个“一次性封印”。第一个调用 once.Do(f) 的 goroutine 会执行 f,然后把封印贴上。所有后来者,无论多少,都会看到封印而直接跳过。
- 注意:如果 f 函数执行失败或 panic,这个封印依然被贴上了,once 会认为它“已经执行过”,不会再给你第二次机会。
4. 条件变量 (Cond) —— “等通知再开工”
-
核心作用:让一组 goroutine 在满足某个特定条件之前,一直挂起等待,直到被唤醒。
-
你要记住的精华:
- 极少使用! 这是 sync 包里最不常用的工具。因为它的功能,99% 的情况下都可以用 Channel 更优雅、更清晰地实现。
- 比喻:一群工人在工位上睡觉 (cond.Wait())。一个工头(另一个goroutine)在检查条件(比如“原料到了吗?”)。一旦条件满足,工头可以用 cond.Signal() 叫醒一个工人,或者用 cond.Broadcast() 叫醒所有工人起来干活。
- 决策口诀: “想用 Cond 时,先想想能不能用 Channel。”
5. 临时对象池 (Pool) —— “公用餐具回收站”
-
核心作用:复用内存中的临时对象,以减轻 GC 压力。
-
你要记住的精华:
- 池化的是“工具”,不是“数据” 。比如可被重置的 bytes.Buffer,而不是包含业务数据的用户信息。
- 不保证存活:Pool 里的对象随时可能被 GC 回收,不能用它来做需要保证数据存在的缓存。
- 最佳场景:在高并发场景下,需要频繁创建和销毁的、小而多的临时对象。
- 决策口诀: “GC 压力大?看看是不是忘了用 sync.Pool。”
第二部分:Channel 与 Context —— Go 并发哲学的“灵魂”
掌握了它们,才算真正开始用“Go 的方式”去思考并发。
1. Channel —— “带排队功能的、线程安全的传送带”
-
核心作用:在 goroutine 之间安全地传递数据,并进行同步。 它完美地诠释了 Go 的核心信条:“不要通过共享内存来通信,而要通过通信来共享内存。”
-
精华:
-
比喻:把它想象成一条传送带。
- ch := make(chan int): 建造一条只能传送 int 类型包裹的传送带。
- ch <- data (发送): 把一个包裹放到传送带的入口。
- data := <- ch (接收): 从传送带的出口拿走一个包裹。
-
两种模式,两种用途:
-
无缓冲 Channel (make(chan int)) —— “同步接头”
- 特点:传送带没有缓冲区。放包裹的人 (Writer) 和取包裹的人 (Reader) 必须同时到达传送带的两端,完成“一手交一手”的交接。任何一方先到,都必须原地阻塞等待。
- 核心用途:同步 (Synchronization) 。它不仅传递了数据,还保证了两个 goroutine 在某个时间点上“碰头”了。非常适合做任务编排和信号通知。
- 决策口诀: “需要等待、需要信号、需要同步,就用无缓冲 Channel。”
-
有缓冲 Channel (make(chan int, N)) —— “异步队列”
- 特点:传送带有N个槽位的缓冲区。只要缓冲区没满,放包裹的人 (Writer) 把包裹一放就可以立刻走人,去做别的事。只要缓冲区不空,取包裹的人 (Reader) 随时来都能取到。
- 核心用途:解耦 (Decoupling) 和 流量削峰 (Buffering) 。它允许生产者和消费者的处理速度不一致。比如生产者瞬间生产了100个任务扔进 Channel,消费者可以慢慢地、一个一个地取出来处理。
- 决策口诀: “生产者消费者,异步处理,流量缓冲,就用带缓冲 Channel。”
-
-
关闭 (close(ch)) :
- 就像传送带的管理员按下了“停机”按钮,并挂上牌子:“今天的包裹都送完了!”
- 关闭后不能再发送(会 panic)。
- 但可以继续接收,直到缓冲区里的包裹被取完。之后再接收,会立刻得到该类型的零值。
- 这个特性常与 for range 结合,优雅地处理消费者退出。
-
2. select —— “并发任务的调度中心”
-
核心作用:同时监听多个 Channel 的状态,哪个先就绪就处理哪个。
-
精华:
-
比喻:你是一个接线总机的话务员,面前有多个电话机 (Channel) 。
- case <-ch1:: 1号电话响了。
- case data := <-ch2:: 2号电话响了,并且有人说话了。
- case ch3 <- data:: 你有一条消息要通过3号电话线发出去,并且对方正好接了。
-
工作机制:
- 只选一个:select 会一直等着,直到其中至少一个电话机“就绪”(可以收或可以发)。
- 随机公平:如果多个电话同时响了,它会随机接起其中一个,避免你总是先理睬1号线而饿死了2号线。
- 永不阻塞 (带default) :如果加了 default 分支,select 就变成了一个 “巡检员” 。它会立刻扫视一遍所有电话线,如果都没响,它就不等了,直接去执行 default 的任务(比如喝口水)。这可以用来实现非阻塞的 channel 操作。
-
决策口诀: “多路监听,超时控制,非阻塞收发,就用 select。”
-
3. Context —— “请求的生命周期控制器”
-
核心作用:在一个请求的完整生命周期内,传递“取消信号”、“超时期限”和“请求范围的元数据”。
-
精华:
-
比喻:它是一个 “任务监督员” ,跟着一个请求的整个处理流程。
-
三大核心能力 (也是三大应用场景) :
- 主动取消 (WithCancel) :监督员配了一个 “对讲机” 。你可以从外部通过 cancel() 函数对着对讲机喊:“任务取消!” 所有带着这个监督员的子任务(子 goroutine)都能听到,然后安全退出。
- 超时控制 (WithTimeout, WithDeadline) :监督员戴了一块 “倒计时手表” 。一旦时间到了,手表会自动响铃,效果等同于你喊了“任务取消!”。这是保护你的服务不被慢操作拖垮的生命线。
- 传递元数据 (WithValue) :监督员有一个 “公文包” ,可以携带只跟本次请求相关的数据(如 trace_id)。切记:不要用它来传递业务参数!
-
黄金法则 (口诀) :
首参传 Context, 空值 Background。 切莫长久存, Key 用自定义。
-
级联取消:Context 像一棵树。砍掉树根(取消父 Context),所有的树枝和树叶(子 Context)都会立即枯萎(被取消)。
-
sync vs. Channel 的终极选择
任务编排用 Channel 共享资源保护用传统并发原语 (sync 包)
- 当问题是“一个数据如何被多个 goroutine 安全地读写”时 -> 想到 Mutex, RWMutex。
- 当问题是“多个 goroutine 如何按照一定顺序协作,或者一个 goroutine 如何把工作成果交给另一个”时 -> 想到 Channel, select。
第三部分总结:高级并发模式与扩展库
这部分的核心是:知道在什么场景下,标准库的工具“不够用”,以及此时应该拿出哪个更专业的工具来解决问题。
1. 原子操作 (sync/atomic) —— “无锁编程的基石”
-
核心作用:提供硬件级别的、不可中断的原子性操作,比使用 Mutex 轻量得多。
-
精华:
-
比喻:它就像银行柜台的 “点钞机” 。
- Mutex:柜员需要把整个柜台(临界区)锁起来,然后慢慢地手动数钱(读-修改-写),数完再解锁。这个过程有上下文切换的开销。
- 原子操作:你直接把钱放进点钞机 (atomic.AddInt64),机器 “唰” 地一下就完成了计数,这个过程是不可分割的,速度极快。
-
什么时候用?
- 只针对“单个变量” 进行简单的、并发安全的读、写、增、减操作。
- 最经典的场景:实现高性能的并发计数器、设置状态标志位。
-
核心函数:
- Add: 原子加/减。
- Load: 原子读。
- Store: 原子写。
- Swap: 原子交换(写入新值并返回旧值)。
- CompareAndSwap (CAS): “看一眼再改” 。先比较变量的当前值和期望值是否相等,如果相等,才写入新值。这是实现各种无锁数据结构(Lock-Free)的基石。
-
决策口诀: “保护单个值,简单加减载,就用 atomic 代。”
-
2. errgroup —— “带保险丝的 WaitGroup”
-
核心作用:在 WaitGroup 的基础上,增加了错误传递和级联取消的功能。
-
精华:
-
解决了 WaitGroup 的两大痛点:
- WaitGroup 不知道 goroutine 是否出错了。
- WaitGroup 无法在一个 goroutine 失败后,通知其他 goroutine 停止工作。
-
工作机制:它内部巧妙地结合了 WaitGroup, sync.Once 和 Context。
-
启动任务用 eg.Go()。
-
只要任何一个 Go() 启动的函数返回了 error,errgroup 会:
- 用 sync.Once 只记录第一个出现的 error。
- 立即 cancel 它内部管理的那个 Context。
- 所有其他正在运行的、并且正确监听了 ctx.Done() 的 goroutine,都会收到取消信号并退出。
-
-
决策口诀: “并发任务要连坐,一错全停,就用 errgroup。”
-
3. singleflight —— “防击穿的请求合并器”
-
核心作用:对于同一个资源(由一个 key 标识)的并发读请求,确保在同一时间内,只有一个请求会真正执行,其他请求都会原地等待,并共享这唯一一次执行的结果。
-
精华:
-
完美解决“缓存击穿”问题。这是它最最经典的应用场景。
-
比喻:食堂打饭。
- 没有 singleflight:某个热门菜(热点Key)刚好没了。100 个同学同时发现菜没了,于是所有 100 个人都跑去后厨找厨师(查数据库)。后厨瞬间被挤爆。
- 有了 singleflight:第一个发现菜没了的同学,会获得一个“催菜”的牌子,他代表大家去后厨。其他 99 个同学,则在原地排队等待。等到那个同学把一大份新菜端回来,大家再一起分享。
-
重要警告:只能用于幂等的“读”操作,绝对不能用于“写”操作! 否则会导致严重的数据不一致问题。
-
决策口诀: “并发读,防击穿,合并请求,singleflight 担。”
-
4. Worker Pool (协程池) —— “控制并发的阀门”
-
核心作用:复用有限数量的 goroutine,去处理海量的任务,以防止因无限创建 goroutine 而耗尽系统资源。
-
精华:
-
解决了什么问题? Go 的 goroutine 虽然轻量,但也不是完全没有成本。如果一瞬间来了 100 万个任务,你直接 for 循环 go task(),你的程序会因为内存耗尽或调度压力过大而崩溃。
-
工作机制:
- 预先创建 N 个“工人” (worker goroutine)。
- 所有任务都扔到一个“任务传送带” (通常是一个 channel) 上。
- 这 N 个工人不断地从传送带上取任务来执行。
- 传送带满了,提交任务的人就会被阻塞,形成自然的背压 (Backpressure) ,保护了系统。
-
决策口诀: “任务海量,并发要控,Worker Pool 来疏通。”
-
选型:在大多数场景下,使用一个成熟的第三方库(如 gammazero/workerpool)比自己手写更健壮。
-
第四部分总结:分布式并发 —— 从“单机”到“集群”
etcd 的核心是作为一个强一致的、高可用的“协调服务” 。你可以把它想象成所有分布式节点共同信任的、唯一的 “公证处” 或 “仲裁委员会” 。
1. etcd 的核心能力 (为什么是它?)
- Raft 共识算法:这是 etcd 的“灵魂”。它保证了所有对 etcd 的写入操作,在集群中的大多数节点都确认之前,是不会成功的。这确保了数据的强一致性。
- Key-Value 存储:提供了一个简单的、类似文件系统的 API (PUT, GET, DELETE)。
- Watch 机制:允许客户端“订阅”某个 Key 或目录的变化。一旦数据发生改变,etcd 会立即主动通知所有订阅者。
- Lease (租约) 机制:允许一个 Key 和一个“租约”绑定。如果客户端在租约到期前没有“续租”(发送心跳),etcd 会自动删除这个 Key。这是实现分布式锁和节点健康检查的基石。
etcd 在 Go 并发中的三大核心应用
1. 分布式锁 (Distributed Lock) —— “跨机器的 Mutex”
-
核心作用:确保在整个分布式集群中,某个任务或对某个资源的访问,在同一时间只被一个节点(进程)执行。
-
精华:
-
比喻:就像多个部门(分布式节点)抢一个唯一的会议室(锁) 。
- 获取锁 (Lock) :每个部门都去 etcd 这个“行政中心”,尝试在 /locks/meeting_room 这个路径下原子性地创建一个带租约的 Key。etcd 保证了只有一个部门能创建成功。
- 持有锁:创建成功的部门,就获得了会议室的使用权。它必须不断地给自己的租约续期,证明会议还在开。
- 释放锁 (Unlock) :开完会,主动去 etcd 删除那个 Key。
- 节点宕机 (自动释放) :如果某个部门的服务器突然断电了,它就无法再为租约续期。租约到期后,etcd 会自动删除那个 Key,把会议室让出来给别人用,完美地避免了死锁。
-
读写锁:etcd 的 concurrency 包也提供了读写锁,其原理更复杂,但同样实现了 “写优先” 策略,防止写操作被饿死。
-
决策口诀: “跨机抢资源,任务要独占,就用分布式锁干。”
-
2. Leader 选举 (Leader Election) —— “集群不可一日无主”
-
核心作用:在一组对等的服务节点中,选举出唯一一个“主”节点 (Leader) 来负责某些特殊任务(比如处理写请求、调度任务),其他节点则作为“从”节点 (Follower) 备用。并在主节点宕机后,能自动进行下一轮选举。
-
精华:
-
实现方式:Leader 选举本质上是对一个“分布式锁”的持续性争抢。
- 所有节点都去 Campaign(竞选)一个代表“王位”的 Key。
- 第一个成功获取到锁(创建了带租约的 Key)的节点,就成为了 Leader。
- 其他节点则进入“等待”状态,并用 Observe 机制监听“王位”Key 的变化。
- Leader 节点负责干活,并不断为自己的“王位”租约续期。
- 如果 Leader 宕机,租约过期,Key 被自动删除。
- 其他等待的节点会立刻收到 Key 被删除的通知,然后立刻开始下一轮的 Campaign,争抢新的王位。
-
决策口诀: “主备要分明,宕机要切换,选举 Leader 不出错。”
-
3. 分布式队列 & 事务 (Queue & STM) —— “可靠的任务分发与原子操作”
-
核心作用:提供跨机器的任务队列和多 Key 操作的原子性保证。
-
精华:
-
分布式队列 (Queue) :
- 特点:可靠但低吞吐。每一次入队和出队,都是一次经过 Raft 共识的写操作。
- 适用场景:低并发、但对任务分发的一致性和可靠性要求极高的场景。比如 Kubernetes 中,kube-controller-manager 把一个“创建 Pod”的任务放入队列,必须保证这个任务不丢失,并且最终被某个 kubelet 消费。
- 不适用:绝对不能用它来做高吞吐量的业务消息队列(比如订单系统),那应该用 Kafka/RabbitMQ。
-
分布式事务 (STM - Software Transactional Memory) :
- 解决了什么? 原子性地修改多个 Key。比如,在 etcd 中实现的银行转账,你需要原子性地“给A账户的Key减100”并且“给B账户的Key加100”。
- 工作机制:采用乐观锁。事务提交时,etcd 会检查你在事务期间读取过的所有 Key 是否被其他人修改过。如果没有,就成功提交;如果有,就整个事务失败并自动重试。
- 决策口诀: “跨 Key 改数据,要么全成功,要么全回滚,STM 来保证。”
-
最终总结:何时选择分布式并发原语?
这个决策非常简单:
当你的业务逻辑,需要跨越多个独立的进程或机器,来保证数据的一致性、操作的唯一性或角色的确定性时,你就必须使用分布式并发原语。而在 Go 生态中,etcd 提供了最健壮、最地道的标准实现。