并发安全之锁机制一
锁机制一
锁机制是计算机系统中解决并发冲突的核心工具,其存在和应用场景源于一个根本问题:当多个执行单元(线程、进程、分布式节点)同时访问或修改同一份共享资源时,如何保证数据的正确性、一致性和系统可靠性?
一、为什么需要锁?
想象以下场景,如果没有锁会发生什么:
- 银行存款取款(数据竞争):
- 线程A读取账户余额:100元。
- 线程B读取账户余额:100元。
- 线程A存入50元,计算新余额 100 + 50 = 150,写入150。
- 线程B取出30元,计算新余额 100 - 30 = 70,写入70。
- 结果: 最终余额是70元,而不是正确的120元 (100+50-30)。线程B的操作覆盖了线程A的操作,因为两者都基于旧的余额100元进行计算。这破坏了数据一致性。
- 订票系统(超卖问题):
- 剩余票数:1张。
- 用户A和用户B同时点击购买。
- 服务器进程A读取票数:1。
- 服务器进程B读取票数:1。
- 进程A判断有票,执行扣减:1-1=0,更新为0,出票成功。
- 进程B判断有票,执行扣减:1-1=0,更新为0,出票成功。
- 结果: 1张票卖给了两个人。这破坏了业务规则。
- 链表插入操作(数据结构损坏):
- 链表: Node1 -> Node2。
- 线程A要在Node1和Node2之间插入NodeX。
- 线程B要删除Node2。
- 如果没有锁控制时序,可能导致:
- 线程A刚让Node1指向NodeX(但NodeX还没指向Node2)。
- 线程B删除Node2,让Node1(或前一个节点)指向Node2的下一个节点(可能是NULL)。
- 结果:NodeX悬空或链表断裂。这破坏了数据结构完整性。
这些问题的根源在于:
- 非原子操作: 像“读取-修改-写入”这样的复合操作,如果中间被其他操作打断,就会导致结果错误。
- 操作交错的不可预测性: 多个操作以不同的顺序和时机交织执行(交错),会产生各种预期之外的结果。
- 内存/缓存可见性问题: 一个线程对共享变量的修改可能不会立即被其他线程看到(由于CPU缓存的存在)。
锁就是解决这些问题的“协调员”:
- 实现互斥: 锁确保在同一时刻,只有一个执行单元能进入受保护的代码区域(临界区)访问或修改特定的共享资源。其他执行单元必须等待锁释放。
- 保证原子性: 对于复合操作(如余额增减、票数扣减、链表节点指针修改),锁可以将它们包装成一个不可分割的操作单元,在执行过程中不会被其他操作打断。
- 保障可见性: 在释放锁时,通常会强制将修改刷新到主内存;在获取锁时,通常会强制从主内存重新加载最新值。这确保了临界区内修改的结果对后续获得锁的执行单元是可见的。
- 维持顺序: 锁隐式地建立了操作的先后顺序,避免了破坏性交错的产生。
二、锁有哪些应用场景?
锁的应用极其广泛,存在于计算机系统的各个层面:
- 操作系统内核:
- 保护内核数据结构: 进程表、文件描述符表、内存管理结构(如页表、空闲列表)、设备驱动状态等。多个CPU核心上的线程或中断处理程序都需要安全地访问这些全局结构。
- 同步原语实现: 信号量、条件变量、屏障等同步机制本身就需要锁(通常是自旋锁)来保护其内部状态。
- 设备访问: 确保同一时间只有一个进程/线程能向特定硬件设备(如打印机、特定端口)发送命令或数据。
- 多线程应用程序:
- 保护共享内存变量: 计数器、标志位、配置参数等。
- 保护复杂数据结构: 链表、哈希表、树、队列等。插入、删除、查找(如果查找可能触发修改)都需要锁来防止结构损坏。
- 单例模式实现: 确保在多线程环境下,一个类只有一个实例被创建(通常使用互斥锁+双重检查锁定)。
- 线程池任务队列: 多个工作线程从任务队列取任务,生产者线程向队列添加任务。队列本身需要锁保护。
- 资源池管理: 如数据库连接池、线程池。分配和回收资源需要互斥操作。
- 缓存同步: 更新和读取共享缓存数据时。
- 数据库管理系统:
- 事务并发控制: 这是数据库锁最核心的应用。数据库使用各种粒度的锁(行锁、页锁、表锁、意向锁)和不同模式的锁(共享锁/S锁、排他锁/X锁)来实现不同的事务隔离级别,保证ACID特性(特别是隔离性I和一致性C)。
- 行锁/记录锁: 防止两个事务同时修改同一条记录。
- 间隙锁: 防止幻读(在范围查询中插入新记录)。
- 表锁: 在特定操作(如ALTER TABLE)或行锁冲突升级时使用。
- 死锁检测与处理: 数据库有专门的机制来处理事务间因循环等待锁而导致的死锁。
- 索引维护: 对B+树等索引结构进行分裂、合并等操作时需要锁保护。
- 缓存管理: 数据库缓冲池(Buffer Pool)的管理也需要锁机制。
- 事务并发控制: 这是数据库锁最核心的应用。数据库使用各种粒度的锁(行锁、页锁、表锁、意向锁)和不同模式的锁(共享锁/S锁、排他锁/X锁)来实现不同的事务隔离级别,保证ACID特性(特别是隔离性I和一致性C)。
- 文件系统:
- 文件写入: 防止多个进程同时写入同一个文件导致内容混乱。通常使用文件锁(如
flock
,fcntl
)。 - 元数据更新: 修改文件的属性(如大小、权限、时间戳)、目录结构(创建、删除、重命名文件/目录)时需要锁保护,避免元数据不一致。
- 文件写入: 防止多个进程同时写入同一个文件导致内容混乱。通常使用文件锁(如
- 分布式系统:
- 分布式锁: 在多个独立的服务器或进程之间协调对共享资源的访问。例如:
- 防止多个节点同时执行同一个定时任务。
- 确保在分布式环境中对某个全局配置项的修改是互斥的。
- 实现分布式环境下的选主(Leader Election)。
- 控制对共享存储(如分布式文件系统中的一个文件)的并发写入。
- 常用实现: ZooKeeper, Redis (RedLock), etcd, Consul 等提供的分布式锁服务。这比单机锁复杂得多,需要处理网络延迟、节点故障、时钟漂移等问题。
- 分布式锁: 在多个独立的服务器或进程之间协调对共享资源的访问。例如:
三、常见的锁类型
-
互斥锁:
- 特点: 最基本的锁类型。严格互斥,一次只允许一个持有者。
- 行为: 如果一个线程试图获取已被持有的互斥锁,它将被阻塞(进入睡眠状态),直到锁被释放。操作系统会进行线程调度。
- 用途: 保护需要绝对互斥访问的临界区。
- 例子:
pthread_mutex_t
(POSIX),std::mutex
(C++),synchronized
关键字修饰的方法或代码块 (Java 内部使用互斥锁),Lock
(数据库中的排他锁)。
-
自旋锁:
- 特点: 当尝试获取锁失败时,线程不会立即阻塞,而是在一个循环中不断检查锁的状态(“自旋”)。
- 优点: 避免上下文切换的开销,对于预期等待时间非常短的场景效率高。
- 缺点: 浪费CPU周期(忙等待),如果等待时间长,效率极低。
- 用途: 多处理器系统,临界区非常小且执行时间极短,且持有锁的线程不太可能被抢占的场景(如内核中断处理)。
- 例子:
pthread_spinlock_t
(POSIX),std::atomic_flag
(C++ 可用于实现自旋锁), 底层硬件指令如test-and-set
,compare-and-swap
。
-
读写锁:
- 特点: 区分读操作和写操作。
- 读锁: 允许多个读者同时持有。只要没有写者,读者就可以并发访问。
- 写锁: 是排他的。一个写锁被持有时,不能有其他读者或写者。获取写锁通常需要等待所有现有的读者释放读锁。
- 优点: 大幅提高读多写少场景的并发性能。
- 缺点: 实现比互斥锁复杂;如果写操作频繁,可能导致读者或写者“饿死”(需要公平策略)。
- 用途: 保护经常被读取但较少被修改的数据结构(如配置信息、缓存)。
- 例子:
pthread_rwlock_t
(POSIX),std::shared_mutex
(C++17),ReentrantReadWriteLock
(Java),LOCK IN SHARE MODE
/FOR SHARE
(数据库共享锁),FOR UPDATE
(数据库排他锁)。
- 特点: 区分读操作和写操作。
-
悲观锁 vs 乐观锁
类型 机制 适用场景 悲观锁 默认资源会被修改,访问前强制加锁(如行锁、表锁) 写操作频繁的高冲突场景 乐观锁 通过版本号或CAS算法检测冲突,提交时校验 读多写少的低冲突场景(如电商库存)
CAS(Compare-And-Swap) 是一种关键的无锁(Lock-Free)编程原子操作,也是实现乐观并发控制的核心。它直接由大多数现代CPU提供硬件支持(通过特定的机器指令),用于在多线程/多处理器环境下安全地更新共享变量,而无需使用传统的互斥锁。
工作流程(从线程视角)
- 读取: 线程读取共享变量
V
的当前值,记为current_value
。 - 计算: 线程基于
current_value
计算出希望更新的新值new_value
。 - CAS尝试: 线程执行
CAS(V, current_value, new_value)
。- 成功: 如果
V
的当前值仍然等于current_value
(意味着在此期间没有其他线程修改过V
),则V
被原子地设置为new_value
,返回true
。线程的更新操作完成。 - 失败: 如果
V
的当前值不等于current_value
(意味着在此期间有其他线程抢先修改了V
),则CAS
什么也不做(不更新V
),返回false
。
- 成功: 如果
- 失败处理: 如果
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 intfunc increment() {mu.Lock() // 加锁defer mu.Unlock() // 确保解锁(推荐用defer避免忘记解锁)counter++
}
2. 读写锁(sync.RWMutex)
- 适用场景:读多写少(如缓存系统)。
- 方法:
RLock()
:获取读锁(允许多个读并发)RUnlock()
:释放读锁Lock()
:获取写锁(独占,与读/写互斥)Unlock()
:释放写锁
- 规则:
- 写锁优先级高于读锁(防止读锁饿死写锁)
- 持有读锁时无法升级为写锁
var rwMu sync.RWMutex
var data map[string]stringfunc 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锁),实现独占读写
自动应用于写操作:UPDATE
、DELETE
、INSERT
语句默认加X锁
6.悲观锁(Pessimistic Lock)
- 实现机制
- 基于数据库原生锁机制(S锁/X锁),操作前先加锁,假设高并发冲突概率。
- 典型语句:
SELECT ... FOR UPDATE
(X锁)、SELECT ... LOCK IN SHARE MODE
(S锁)。
- 适用场景
- 写密集型操作(如库存扣减、支付交易)。
- 需强一致性的金融系统,容忍锁开销换取安全性。
7.乐观锁(Optimistic Lock)
-
实现机制
-
无锁设计:通过业务层逻辑(版本号/时间戳)检测冲突,提交时校验数据一致性89。
-
伪代码逻辑:
sql CodeUPDATE products SET stock = new_stock, version = version + 1 WHERE id = 10 AND version = current_version; -- 校验版本号
-
-
适用场景
- 读多写少场景(如评论计数更新)。
- 分布式系统,减少数据库锁竞争开销。