工作中常用的工程思维🚀

1. 「减小临界区粒度」

核心原则:锁只保护共享数据的读写,除此之外的任何操作(计算、I/O、转换)都应该扔到锁外面去。

场景 1:先准备,后提交 (Prepare then Commit)

这是最常见的场景。很多时候,我们需要根据旧数据计算出一个新值,然后更新回去。

  • 反模式:在锁里做复杂的计算(序列化、加密、大对象构造)。
  • 优化:在锁外把能算的都算好,进锁只做“赋值”这一动作。
// ❌ 粒度过大:加密是 CPU 密集型操作,不需要锁保护
func (u *UserManager) UpdateDataBad(data []byte) {
    u.mu.Lock()
    defer u.mu.Unlock()
    
    // 假设 Encrypt 很耗时 (5ms)
    encrypted := Encrypt(data) 
    u.storage = encrypted
}

// ✅ 优化后:Prepare Outside, Commit Inside
func (u *UserManager) UpdateDataGood(data []byte) {
    // 1. [Prepare] 在锁外做耗时计算
    // 这里的 encrypted 只是局部变量,不需要锁
    encrypted := Encrypt(data) 

    // 2. [Commit] 快速加锁赋值
    u.mu.Lock()
    u.storage = encrypted
    u.mu.Unlock()
}

场景 2:先提交,后处理 (Commit then Process)

当状态更新触发了“副作用”(Side Effects),如发消息、写日志、回调通知时,必须把副作用移出锁外。

  • 反模式:持有锁的时候去通知别人。

  • 优化:改完状态立刻解锁,然后去通知。

// ❌ 粒度过大:Notify 需要网络请求,会阻塞其他查询 Task 的人
func (t *Task) FinishBad() {
    t.mu.Lock()
    defer t.mu.Unlock()

    t.status = "DONE"
    // 网络 I/O,阻塞锁
    NotifyUser(t.id, "Task Done") 
}

// ✅ 优化后:状态变更与通知分离
func (t *Task) FinishGood() {
    t.mu.Lock()
    t.status = "DONE"
    t.mu.Unlock() // 🔥 立刻解锁!

    // 锁释放后再慢慢通知
    NotifyUser(t.id, "Task Done")
}

注意:如果 NotifyUser 必须保证在状态是 DONE 时才发,且不能重复发,通常需要配合原子标记或者在锁内返回一个 needNotify 的布尔值标志,在锁外判断该标志执行。

场景 3:锁分段 (Lock Sharding / Striping)

启示:当竞争无法避免时,分散竞争. 当一个锁保护的数据量太大、访问频率太高时,哪怕临界区很小,竞争也会很激烈。

  • 反模式:一把全局大锁(Global Lock)保护一个巨大的 Map。

  • 优化:把 Map 切分成 N 个小 Map(分片),每个分片有一把锁。根据 Key 的 Hash 值决定去抢哪把锁。

// ❌ 粒度过大:所有 key 都争抢一把锁
type BigCache struct {
    mu    sync.Mutex
    items map[string]interface{}
}

// ✅ 优化后:锁分段
type ShardedCache struct {
    shards []*cacheShard // 比如分 64 个片
}

type cacheShard struct {
    mu    sync.Mutex
    items map[string]interface{}
}

func (c *ShardedCache) Set(key string, value interface{}) {
    // 1. 根据 key 计算 hash,找到对应的分片
    shard := c.getShard(key) 
    
    // 2. 只锁这一个分片,其他 63 个分片依然可以并发读写!
    shard.mu.Lock()
    shard.items[key] = value
    shard.mu.Unlock()
}

场景 4:读写分离 (Reader-Writer Separation)

“读多写少”读要用读锁,写要用写锁

  • 反模式:读和写都用互斥锁 sync.Mutex。这会导致读操作之间互斥,浪费性能。
  • 优化:使用读写锁 sync.RWMutex。允许多个读者同时进入临界区,只有写者需要独占
// ❌ 读请求也会互相阻塞
func (c *Config) GetBad() string {
    c.mu.Lock() 
    defer c.mu.Unlock()
    return c.val
}

// ✅ 多个读请求可以并行执行
func (c *Config) GetGood() string {
    c.rwMu.RLock() // RLock: Read Lock
    defer c.rwMu.RUnlock()
    return c.val
}

如何识别“坏味道”?

写 Lock() 和 Unlock() 之间的代码时,如果出现以下情况,警钟就应该响起来:

  • 有循环 (Loop):如果是遍历大列表,考虑 Copy-On-Write。

  • 有 I/O (Network/Disk):必须移出去。

  • 有复杂对象构造/计算:移到 Lock 之前。

  • 有 time.Sleep 或阻塞调用:绝对禁止。

2. 「Copy-On-Write」

场景 1:全系统热配置更新 (Hot Configuration Reload)

启示:对于那些“一旦生成就不可变,只能整体替换”的数据,COW 是性能天花板。

  • 场景:配置(Config)通常在启动时加载,运行时偶尔修改(比如动态开关),但每一个 HTTP 请求都要读取配置。

  • 传统做法:使用 RWMutex。每次读取配置都要 RLock()。但在超高并发下(如网关层,QPS 10万+),大量的 RLock 也会导致 CPU 缓存争用(Cache Line Bouncing),影响性能。

  • COW 做法:使用 atomic.Value 存储配置指针。读取时直接原子加载指针,完全没有锁开销

// 场景:全局配置管理
type ConfigManager struct {
    // atomic.Value 可以原子地存储和加载任意对象
    config atomic.Value 
}

// 1. 初始化
func NewConfigManager() *ConfigManager {
    cm := &ConfigManager{}
    cm.config.Store(&AppConfig{LogLB: "INFO", Timeout: 10}) // 存入初始值
    return cm
}

// 2. [Reader] 高频读取:完全无锁,速度极快
func (c *ConfigManager) GetConfig() *AppConfig {
    // 仅仅是一个原子指针加载,纳秒级耗时
    return c.config.Load().(*AppConfig)
}

// 3. [Writer] 低频更新:复制 -> 修改 -> 替换
func (c *ConfigManager) UpdateConfig(newTimeout int) {
    // A. 取出旧配置
    oldConfig := c.GetConfig()
    
    // B. 【Copy】在内存中创建副本 (Deep Copy)
    newConfig := *oldConfig 
    
    // C. 【Write】在副本上修改,不影响正在读旧配置的人
    newConfig.Timeout = newTimeout 
    
    // D. 【Swap】原子替换指针
    c.config.Store(&newConfig)
}

场景 2:黑白名单/路由表 (Routing Table / AllowList)

当读操作不仅需要性能,还需要快照隔离(Snapshot Isolation)时(即在一次查询中看到的数据必须是一致的),COW 也是完美方案。

  • 场景:假设在做一个防火墙或网关,内存里有一个包含 10 万个 IP 的白名单 map。每个请求进来都要查一下这个 map

  • 痛点:如果为了每分钟一次的“添加IP”操作,让每秒 10 万次的“查询IP”操作都去抢锁,这极其不划算。

  • COW 做法:写操作时,把整个 map 复制一份,添加新 IP,然后把全局 map 的引用指向新的

type Firewall struct {
    mu       sync.Mutex
    allowList unsafe.Pointer // 指向 map[string]bool
}

// [Reader] 极其高效,无锁
func (f *Firewall) Allow(ip string) bool {
    // 原子获取当前的 map 指针
    ptr := atomic.LoadPointer(&f.allowList)
    m := *(*map[string]bool)(ptr)
    
    // 直接读,不需要锁,因为这个 map 是只读的,不会有人并发修改它
    return m[ip]
}

// [Writer] 较重,但发生频率低
func (f *Firewall) AddIP(ip string) {
    f.mu.Lock() // 写锁,防止多个写者同时 Copy
    defer f.mu.Unlock()

    // 1. 获取旧 map
    oldPtr := atomic.LoadPointer(&f.allowList)
    oldMap := *(*map[string]bool)(oldPtr)

    // 2. 【Copy】创建新 map
    newMap := make(map[string]bool, len(oldMap)+1)
    for k, v := range oldMap {
        newMap[k] = v
    }

    // 3. 【Write】写入新 map
    newMap[ip] = true

    // 4. 【Swap】原子替换
    atomic.StorePointer(&f.allowList, unsafe.Pointer(&newMap))
}

场景 3:观察者模式/事件监听 (Observer Pattern)

解决“边遍历边修改”导致的并发安全问题,COW 是最优雅的解法之一

COW 做法:Subscribe/Unsubscribe 时,不直接改原切片,而是生成一个新切片。遍历通知时,遍历的是旧切片(快照)。

type EventBus struct {
    mu        sync.Mutex
    listeners []func() // 监听器列表
}

// 添加监听器 (Copy-On-Write)
func (e *EventBus) Subscribe(fn func()) {
    e.mu.Lock()
    defer e.mu.Unlock()
    
    // 创建一个新切片,长度+1
    newSlice := make([]func(), len(e.listeners)+1)
    copy(newSlice, e.listeners)
    newSlice[len(e.listeners)] = fn
    
    // 替换
    e.listeners = newSlice
}

// 触发事件
func (e *EventBus) Notify() {
    // 1. 获取当前的列表快照
    // 这里甚至可能不需要加锁,或者加锁只为了读取切片头指针
    e.mu.Lock()
    snapshot := e.listeners
    e.mu.Unlock()

    // 2. 遍历快照
    // 即使在回调执行期间,有人调用了 Subscribe 修改了 e.listeners,
    // 也不会影响这里的 snapshot 循环,非常安全!
    for _, fn := range snapshot {
        fn() 
    }
}

场景 4:游戏/仿真中的世界快照 (World Snapshot)

在游戏服务器或自动驾驶仿真中,我们需要定期将当前的“世界状态”保存下来存盘(Persistence),或者发送给前端渲染。

  • 痛点:保存数据需要序列化,很耗时。如果保存期间暂停游戏逻辑,玩家会卡顿。
  • COW 做法: 利用操作系统的 fork() 机制(Redis 的 BGSAVE 就是利用这个),或者在应用层利用不可变数据结构。当主逻辑要修改某个对象时,先克隆它再修改,未修改的对象依然和“快照线程”共享内存。

(注:这是 COW 在操作系统层面的经典应用,Linux 创建进程时,父子进程共享内存,只有当一方尝试写入时,OS 才会触发缺页中断去复制物理内存页)

总结 COW 适用性判断表

  • 读多写少:非常适合✅ 读操作无锁,性能最大化。
  • 数据量小:非常适合✅ 复制成本低(如配置对象、IP列表)。
  • 数据量巨大:不适合❌ 复制几 GB 的数据太慢,且造成内存抖动。
  • 写操作频繁:不适合❌ 频繁复制会消耗大量 CPU 和 GC 压力。
  • 一致性要求:最终一致性✅读者可能在短时间内读到旧数据,但最终会读到新的。
Tags: 工作
Share: X (Twitter) Facebook LinkedIn