脑抽研究生Go并发-5-总结-核心 sync 包、Channel 与 Context、高级并发模式与扩展库、分布式并发

16 阅读15分钟

总结

第一部分:核心 sync 包 —— 本地并发

sync 包里的这些工具,是处理单进程内、多个 goroutine 之间协同问题的基础

1. 锁 (Mutex & RWMutex)

  • 核心作用保护共享资源,保证数据一致性。 任何时候,只允许一个(或一类)goroutine 进入“临界区”。

  • 精华

    • Mutex (互斥锁) :最简单粗暴的锁,一次只能进一个,不管他是“读”还是“写”。

      • 决策口诀“情况不明,先上 Mutex。” 它是最安全、最通用的选择。
    • RWMutex (读写锁) :更智能的锁。

      • 读锁 (RLock) :允许多个“读者”同时进入不同的隔间,互不干扰。
      • 写锁 (Lock) :当有人要“写操作“时,会将读者清场,谁也不能进。
      • 决策口诀“读多写少,性能瓶颈,才用 RWMutex。” 不要滥用它,因为它的内部实现比 Mutex 复杂,在读写均衡的场景下,性能可能还不如 Mutex。
    • 共同的“坑”

      1. 忘记 Unlock:最常见的错误。defer mu.Unlock() 是你的肌肉记忆。
      2. 复制已锁定的锁:会导致程序行为异常。
      3. 重入: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 (接收): 从传送带的出口拿走一个包裹。
    • 两种模式,两种用途

      1. 无缓冲 Channel (make(chan int)) —— “同步接头”

        • 特点:传送带没有缓冲区。放包裹的人 (Writer) 和取包裹的人 (Reader) 必须同时到达传送带的两端,完成“一手交一手”的交接。任何一方先到,都必须原地阻塞等待。
        • 核心用途同步 (Synchronization) 。它不仅传递了数据,还保证了两个 goroutine 在某个时间点上“碰头”了。非常适合做任务编排信号通知
        • 决策口诀“需要等待、需要信号、需要同步,就用无缓冲 Channel。”
      2. 有缓冲 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 —— “请求的生命周期控制器”

  • 核心作用在一个请求的完整生命周期内,传递“取消信号”、“超时期限”和“请求范围的元数据”。

  • 精华

    • 比喻:它是一个 “任务监督员” ,跟着一个请求的整个处理流程。

    • 三大核心能力 (也是三大应用场景)

      1. 主动取消 (WithCancel) :监督员配了一个 “对讲机” 。你可以从外部通过 cancel() 函数对着对讲机喊:“任务取消!” 所有带着这个监督员的子任务(子 goroutine)都能听到,然后安全退出。
      2. 超时控制 (WithTimeout, WithDeadline) :监督员戴了一块 “倒计时手表” 。一旦时间到了,手表会自动响铃,效果等同于你喊了“任务取消!”。这是保护你的服务不被慢操作拖垮的生命线
      3. 传递元数据 (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 的两大痛点

      1. WaitGroup 不知道 goroutine 是否出错了。
      2. WaitGroup 无法在一个 goroutine 失败后,通知其他 goroutine 停止工作。
    • 工作机制:它内部巧妙地结合了 WaitGroup, sync.Once 和 Context。

      • 启动任务用 eg.Go()。

      • 只要任何一个 Go() 启动的函数返回了 error,errgroup 会:

        1. 用 sync.Once 只记录第一个出现的 error。
        2. 立即 cancel 它内部管理的那个 Context。
        3. 所有其他正在运行的、并且正确监听了 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 提供了最健壮、最地道的标准实现。