并发安全之锁机制一

发布于:2025-07-29 ⋅ 阅读:(13) ⋅ 点赞:(0)

锁机制一

锁机制是计算机系统中解决并发冲突的核心工具,其存在和应用场景源于一个根本问题:当多个执行单元(线程、进程、分布式节点)同时访问或修改同一份共享资源时,如何保证数据的正确性、一致性和系统可靠性?

一、为什么需要锁?

想象以下场景,如果没有锁会发生什么:

  1. 银行存款取款(数据竞争):
    • 线程A读取账户余额:100元。
    • 线程B读取账户余额:100元。
    • 线程A存入50元,计算新余额 100 + 50 = 150,写入150。
    • 线程B取出30元,计算新余额 100 - 30 = 70,写入70。
    • 结果: 最终余额是70元,而不是正确的120元 (100+50-30)。线程B的操作覆盖了线程A的操作,因为两者都基于旧的余额100元进行计算。这破坏了数据一致性。
  2. 订票系统(超卖问题):
    • 剩余票数:1张。
    • 用户A和用户B同时点击购买。
    • 服务器进程A读取票数:1。
    • 服务器进程B读取票数:1。
    • 进程A判断有票,执行扣减:1-1=0,更新为0,出票成功。
    • 进程B判断有票,执行扣减:1-1=0,更新为0,出票成功。
    • 结果: 1张票卖给了两个人。这破坏了业务规则。
  3. 链表插入操作(数据结构损坏):
    • 链表: Node1 -> Node2。
    • 线程A要在Node1和Node2之间插入NodeX。
    • 线程B要删除Node2。
    • 如果没有锁控制时序,可能导致:
      • 线程A刚让Node1指向NodeX(但NodeX还没指向Node2)。
      • 线程B删除Node2,让Node1(或前一个节点)指向Node2的下一个节点(可能是NULL)。
      • 结果:NodeX悬空或链表断裂。这破坏了数据结构完整性。

这些问题的根源在于:

  • 非原子操作: 像“读取-修改-写入”这样的复合操作,如果中间被其他操作打断,就会导致结果错误。
  • 操作交错的不可预测性: 多个操作以不同的顺序和时机交织执行(交错),会产生各种预期之外的结果。
  • 内存/缓存可见性问题: 一个线程对共享变量的修改可能不会立即被其他线程看到(由于CPU缓存的存在)。

锁就是解决这些问题的“协调员”:

  1. 实现互斥: 锁确保在同一时刻,只有一个执行单元能进入受保护的代码区域(临界区)访问或修改特定的共享资源。其他执行单元必须等待锁释放。
  2. 保证原子性: 对于复合操作(如余额增减、票数扣减、链表节点指针修改),锁可以将它们包装成一个不可分割的操作单元,在执行过程中不会被其他操作打断。
  3. 保障可见性: 在释放锁时,通常会强制将修改刷新到主内存;在获取锁时,通常会强制从主内存重新加载最新值。这确保了临界区内修改的结果对后续获得锁的执行单元是可见的。
  4. 维持顺序: 锁隐式地建立了操作的先后顺序,避免了破坏性交错的产生。

二、锁有哪些应用场景?

锁的应用极其广泛,存在于计算机系统的各个层面:

  1. 操作系统内核:
    • 保护内核数据结构: 进程表、文件描述符表、内存管理结构(如页表、空闲列表)、设备驱动状态等。多个CPU核心上的线程或中断处理程序都需要安全地访问这些全局结构。
    • 同步原语实现: 信号量、条件变量、屏障等同步机制本身就需要锁(通常是自旋锁)来保护其内部状态。
    • 设备访问: 确保同一时间只有一个进程/线程能向特定硬件设备(如打印机、特定端口)发送命令或数据。
  2. 多线程应用程序:
    • 保护共享内存变量: 计数器、标志位、配置参数等。
    • 保护复杂数据结构: 链表、哈希表、树、队列等。插入、删除、查找(如果查找可能触发修改)都需要锁来防止结构损坏。
    • 单例模式实现: 确保在多线程环境下,一个类只有一个实例被创建(通常使用互斥锁+双重检查锁定)。
    • 线程池任务队列: 多个工作线程从任务队列取任务,生产者线程向队列添加任务。队列本身需要锁保护。
    • 资源池管理: 如数据库连接池、线程池。分配和回收资源需要互斥操作。
    • 缓存同步: 更新和读取共享缓存数据时。
  3. 数据库管理系统:
    • 事务并发控制: 这是数据库锁最核心的应用。数据库使用各种粒度的锁(行锁、页锁、表锁、意向锁)和不同模式的锁(共享锁/S锁、排他锁/X锁)来实现不同的事务隔离级别,保证ACID特性(特别是隔离性I和一致性C)。
      • 行锁/记录锁: 防止两个事务同时修改同一条记录。
      • 间隙锁: 防止幻读(在范围查询中插入新记录)。
      • 表锁: 在特定操作(如ALTER TABLE)或行锁冲突升级时使用。
      • 死锁检测与处理: 数据库有专门的机制来处理事务间因循环等待锁而导致的死锁。
    • 索引维护: 对B+树等索引结构进行分裂、合并等操作时需要锁保护。
    • 缓存管理: 数据库缓冲池(Buffer Pool)的管理也需要锁机制。
  4. 文件系统:
    • 文件写入: 防止多个进程同时写入同一个文件导致内容混乱。通常使用文件锁(如flock, fcntl)。
    • 元数据更新: 修改文件的属性(如大小、权限、时间戳)、目录结构(创建、删除、重命名文件/目录)时需要锁保护,避免元数据不一致。
  5. 分布式系统:
    • 分布式锁: 在多个独立的服务器或进程之间协调对共享资源的访问。例如:
      • 防止多个节点同时执行同一个定时任务。
      • 确保在分布式环境中对某个全局配置项的修改是互斥的。
      • 实现分布式环境下的选主(Leader Election)。
      • 控制对共享存储(如分布式文件系统中的一个文件)的并发写入。
      • 常用实现: ZooKeeper, Redis (RedLock), etcd, Consul 等提供的分布式锁服务。这比单机锁复杂得多,需要处理网络延迟、节点故障、时钟漂移等问题。

三、常见的锁类型

  1. 互斥锁:

    • 特点: 最基本的锁类型。严格互斥,一次只允许一个持有者。
    • 行为: 如果一个线程试图获取已被持有的互斥锁,它将被阻塞(进入睡眠状态),直到锁被释放。操作系统会进行线程调度。
    • 用途: 保护需要绝对互斥访问的临界区。
    • 例子: pthread_mutex_t (POSIX), std::mutex (C++), synchronized 关键字修饰的方法或代码块 (Java 内部使用互斥锁), Lock (数据库中的排他锁)。
  2. 自旋锁:

    • 特点: 当尝试获取锁失败时,线程不会立即阻塞,而是在一个循环中不断检查锁的状态(“自旋”)。
    • 优点: 避免上下文切换的开销,对于预期等待时间非常短的场景效率高。
    • 缺点: 浪费CPU周期(忙等待),如果等待时间长,效率极低。
    • 用途: 多处理器系统,临界区非常小且执行时间极短,且持有锁的线程不太可能被抢占的场景(如内核中断处理)。
    • 例子: pthread_spinlock_t (POSIX), std::atomic_flag (C++ 可用于实现自旋锁), 底层硬件指令如 test-and-set, compare-and-swap
  3. 读写锁:

    • 特点: 区分读操作和写操作。
      • 读锁: 允许多个读者同时持有。只要没有写者,读者就可以并发访问。
      • 写锁: 是排他的。一个写锁被持有时,不能有其他读者或写者。获取写锁通常需要等待所有现有的读者释放读锁。
    • 优点: 大幅提高读多写少场景的并发性能。
    • 缺点: 实现比互斥锁复杂;如果写操作频繁,可能导致读者或写者“饿死”(需要公平策略)。
    • 用途: 保护经常被读取但较少被修改的数据结构(如配置信息、缓存)。
    • 例子: pthread_rwlock_t (POSIX), std::shared_mutex (C++17), ReentrantReadWriteLock (Java), LOCK IN SHARE MODE / FOR SHARE (数据库共享锁), FOR UPDATE (数据库排他锁)。
  4. 悲观锁 vs 乐观锁

    类型 机制 适用场景
    悲观锁 默认资源会被修改,访问前强制加锁(如行锁、表锁) 写操作频繁的高冲突场景‌
    乐观锁 通过版本号或CAS算法检测冲突,提交时校验 读多写少的低冲突场景(如电商库存)‌

CAS(Compare-And-Swap) 是一种关键的无锁(Lock-Free)编程原子操作,也是实现乐观并发控制的核心。它直接由大多数现代CPU提供硬件支持(通过特定的机器指令),用于在多线程/多处理器环境下安全地更新共享变量,而无需使用传统的互斥锁。

工作流程(从线程视角)

  1. 读取: 线程读取共享变量 V 的当前值,记为 current_value
  2. 计算: 线程基于 current_value 计算出希望更新的新值 new_value
  3. CAS尝试: 线程执行 CAS(V, current_value, new_value)
    • 成功: 如果 V 的当前值仍然等于 current_value(意味着在此期间没有其他线程修改过 V),则 V 被原子地设置为 new_value,返回 true。线程的更新操作完成。
    • 失败: 如果 V 的当前值不等于 current_value(意味着在此期间有其他线程抢先修改了 V),则 CAS 什么也不做(不更新 V),返回 false
  4. 失败处理: 如果 CAS 失败,线程通常不会阻塞,而是选择:
    • 放弃: 如果操作允许失败。
    • 重试(自旋): 最常见的方式。线程回到步骤1,重新读取 V最新值作为新的 current_value,重新计算 new_value,然后再次尝试 CAS。这个循环(读取 -> 计算 -> CAS)会一直持续,直到 CAS 成功或达到某种退出条件(如重试次数上限)。

四、golang中的锁机制

在Go语言中,处理并发问题时通常优先考虑使用信道(channel),但在某些情况下,当信道无法解决问题时,就需要使用锁机制来处理共享内存的并发访问。Go语言提供了两种主要的锁类型:互斥锁(Mutex)和读写锁(RWMutex)。

1. 互斥锁(sync.Mutex)
  • 作用:确保同一时间只有一个goroutine访问共享资源。
  • 方法
    • Lock():获取锁(若锁被占用,则阻塞当前goroutine)
    • Unlock():释放锁
  • 特点
    • 不可重入:同一goroutine重复加锁会导致死锁。
    • 零值可用:未初始化的Mutex可直接使用。
var mu sync.Mutex
var counter int

func increment() {
    mu.Lock()          // 加锁
    defer mu.Unlock()  // 确保解锁(推荐用defer避免忘记解锁)
    counter++
}
2. 读写锁(sync.RWMutex)
  • 适用场景:读多写少(如缓存系统)。
  • 方法
    • RLock():获取读锁(允许多个读并发)
    • RUnlock():释放读锁
    • Lock():获取写锁(独占,与读/写互斥)
    • Unlock():释放写锁
  • 规则
    • 写锁优先级高于读锁(防止读锁饿死写锁)
    • 持有读锁时无法升级为写锁
var rwMu sync.RWMutex
var data map[string]string

func read(key string) string {
    rwMu.RLock()         // 读锁
    defer rwMu.RUnlock()
    return data[key]
}

func write(key, value string) {
    rwMu.Lock()          // 写锁
    defer rwMu.Unlock()
    data[key] = value
}
3. Mutex的优化机制
  • 自旋尝试
    当锁被短期持有时,等待的goroutine会在用户态自旋尝试(约4次),避免立即进入休眠(减少上下文切换开销)。
  • 饥饿模式
    若某个goroutine等待超过1ms,锁会进入饥饿模式——新来的goroutine直接排队(不抢锁),确保公平性。
4. RWMutex的设计
  • 读锁计数
    通过readerCount跟踪当前读锁数量(正数表示读锁,负数表示有写锁等待)。
  • 写锁优先
    当有写锁等待时,新来的读锁会被阻塞,防止写锁被饿死。

五、mysql的锁机制

1. 全局锁(Global Lock)
  • 作用:锁定整个数据库实例,使所有表处于只读状态。
  • 命令FLUSH TABLES WITH READ LOCK(FTWRL)25。
  • 场景:全库逻辑备份(如mysqldump)时确保数据一致性。
  • 风险:阻塞所有写操作,长时间锁定会导致业务瘫痪。推荐事务引擎使用–single-transaction参数(基于MVCC备份,不阻塞写)24。
2. 表级锁(Table Lock)
  • 类型
    • 普通表锁:通过LOCK TABLES ... READ/WRITE手动加锁,读锁允许多会话并发读但阻塞写,写锁独占36。
    • 元数据锁(MDL):自动加锁,保护表结构。DML操作(如SELECT)加MDL读锁,DDL操作(如ALTER TABLE)加MDL写锁,读写互斥。长事务会阻塞DDL,导致雪崩24。
    • 意向锁(Intention Lock):表级锁,分为意向共享锁(IS)和意向排他锁(IX),用于快速判断表中是否有行锁冲突48。
  • 适用引擎:MyISAM默认表锁;InnoDB显式支持。
  • 特点:开销小、加锁快、无死锁,但并发度低39。
3. 行级锁(Row Lock)
  • 适用引擎:仅InnoDB支持,细粒度锁定单行数据。
  • 特点:开销大、加锁慢、可能出现死锁,但并发度高16。
  • 加锁条件:必须通过索引检索数据,否则退化为表锁310。
4.共享锁(S锁)

共享锁(S锁)SELECT ... LOCK IN SHARE MODE,允许多事务读,阻塞写。

允许多事务‌并发读取同一数据‌,但阻止任何事务获取排他锁进行修改‌

锁兼容性:多个S锁可共存,但S锁与X锁互斥‌

5.排他锁(X锁)

排他锁(X锁)SELECT ... FOR UPDATE或自动加锁(如UPDATE),独占数据

事务持有X锁时,‌禁止其他事务加任何锁‌(包括S锁和X锁),实现独占读写‌

自动应用于写操作:UPDATEDELETEINSERT语句默认加X锁‌

6.悲观锁(Pessimistic Lock)
  1. 实现机制
    • 基于‌数据库原生锁机制‌(S锁/X锁),操作前先加锁,假设高并发冲突概率‌。
    • 典型语句:SELECT ... FOR UPDATE(X锁)、SELECT ... LOCK IN SHARE MODE(S锁)‌。
  2. 适用场景
    • 写密集型操作(如库存扣减、支付交易)‌。
    • 需强一致性的金融系统,容忍锁开销换取安全性‌。
7.乐观锁(Optimistic Lock)
  1. 实现机制

    • 无锁设计‌:通过业务层逻辑(版本号/时间戳)检测冲突,提交时校验数据一致性‌89。

    • 伪代码逻辑:

      sql Code
      
      UPDATE products 
      SET stock = new_stock, version = version + 1 
      WHERE id = 10 AND version = current_version; -- 校验版本号
      
  2. 适用场景

    • 读多写少场景(如评论计数更新)‌。
    • 分布式系统,减少数据库锁竞争开销‌。

网站公告

今日签到

点亮在社区的每一天
去签到