在并发编程中,'锁'是解决资源竞争、保证数据一致性的核心工具 —— 无论是多线程共享进程内资源,还是多进程跨地址空间协作,都需要通过锁来规范临界区的访问逻辑。本文结合 C/C++ 开发场景(含 Linux 系统调用、C++11 + 标准库),系统梳理多线程 / 多进程中常用锁的种类、核心特点,并给出实战选型建议。
一、核心概念铺垫
在讲锁之前,先明确两个关键前提,避免混淆:
- 临界区:并发场景中多个执行流(线程 / 进程)共同访问的共享资源(如全局变量、文件、网络连接),是锁的保护对象。
- 线程 vs 进程:
- 多线程:共享同一进程的地址空间,锁可直接基于进程内内存实现(开销小);
- 多进程:地址空间独立,锁需借助'跨进程共享资源'(如文件、共享内存)实现(开销略大)。
二、多线程锁(线程间同步)
多线程锁用于保护进程内的共享资源(如全局变量、堆内存),核心是'线程间互斥 / 同步',常见类型如下:
1. 互斥锁(Mutex):并发编程的'基础款'
核心特点
- 本质:二进制锁(要么锁定,要么解锁),保证同一时刻只有一个线程进入临界区。
- 等待机制:线程获取不到锁时,会从'运行态'转为'阻塞态',放弃 CPU 资源(不占用 CPU 空转)。
- 适用场景:临界区执行时间较长(如 IO 操作、复杂计算),避免 CPU 浪费。
- 优缺点:
- 优点:开销低、使用简单,无 CPU 空转;
- 缺点:上下文切换开销(阻塞 / 唤醒线程时),适合临界区耗时 > 上下文切换耗时的场景。
C++ 实战示例(C++11 标准库)
#include <mutex>
#include <thread>
std::mutex mtx; // 全局互斥锁
int shared_data = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx); // RAII 风格,自动加锁/解锁,避免漏解锁
shared_data++; // 临界区:保护共享变量
}
int main() {
std::thread t1(increment), t2(increment);
t1.join();
t2.join();
return 0;
}
- 关键 API:
std::mutex(基础互斥锁)、std::lock_guard(RAII 自动管理)、std::unique_lock(更灵活,支持手动解锁 / 延迟加锁)。
2. 自旋锁(Spinlock):临界区极短场景的'性能款'
核心特点
- 本质:忙等锁,线程获取不到锁时,会循环检测锁状态(不放弃 CPU),直到获取成功。
- 适用场景:临界区执行时间极短(如简单的变量读写),上下文切换开销 > 临界区耗时。
- 优缺点:
- 优点:无上下文切换开销,响应速度快;
- 缺点:CPU 空转(忙等),高并发下会占用大量 CPU 资源,可能导致'优先级反转'。
实战注意(Linux 场景)
- C++ 标准库无自旋锁实现,需借助系统调用(如
pthread_spinlock_t)或第三方库(如 Boost); - 禁用场景:单核 CPU(忙等会阻塞所有线程)、临界区耗时较长(CPU 空转浪费严重)。
3. 读写锁(RWLock):读多写少场景的'优化款'
核心特点
- 本质:区分读 / 写操作,遵循'读共享、写独占'原则:
- 多个线程可同时获取读锁(读操作不冲突);
- 写锁会阻塞所有读锁和其他写锁(写操作独占资源)。
- 适用场景:读操作远多于写操作(如缓存查询、配置读取),大幅提升并发效率。
- 优缺点:
- 优点:读操作并发度高,比互斥锁更高效;
- 缺点:实现复杂,写操作可能出现'饥饿'(读锁一直占用,写锁长期等待),需设置公平策略。
C++ 实战示例(C++17+)
#include <shared_mutex>
#include <thread>
#include <vector>
std::shared_mutex rw_mtx;
int cache_data = 0; // 读操作:获取共享锁(读锁)
void read_cache() {
std::shared_lock<std::shared_mutex> lock(rw_mtx); // 读取 cache_data,无写操作
}
// 写操作:获取独占锁(写锁)
void update_cache() {
std::unique_lock<std::shared_mutex> lock(rw_mtx);
cache_data++; // 写操作,独占资源
}
- 注意:C++11/14 无
std::shared_mutex,需使用boost::shared_mutex。
4. 条件变量(Condition Variable):线程同步的'通信款'
核心特点
- 本质:配合互斥锁使用,实现线程间'等待 - 通知'机制,解决'忙等同步'问题(如生产者 - 消费者模型)。
- 核心逻辑:线程 A 获取互斥锁后,若条件不满足,会释放锁并阻塞等待;线程 B 满足条件后,通知线程 A 唤醒并重新获取锁。
- 适用场景:线程间需要基于某个条件协作(如生产者生产数据后通知消费者消费)。
- 优缺点:
- 优点:避免忙等,减少 CPU 占用,同步逻辑清晰;
- 缺点:必须与互斥锁搭配使用,易出现'虚假唤醒'(需用 while 循环判断条件)。
C++ 实战示例(生产者 - 消费者)
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
// 生产者
void producer() {
for (int i = 0; i < 10; i++) {
std::unique_lock<std::mutex> lock(mtx);
q.push(i);
cv.notify_one(); // 通知一个等待的消费者
}
}
// 消费者
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
// 避免虚假唤醒,用 while 循环判断条件
cv.wait(lock, []() { return !q.empty(); });
int data = q.front();
q.pop();
lock.unlock(); // 提前解锁,提高并发
}
}
5. 递归锁(Recursive Mutex):递归调用的'安全款'
核心特点
- 本质:允许同一线程多次获取锁(自动计数,解锁次数需与加锁次数一致),避免递归调用时的死锁。
- 适用场景:函数递归调用时需要访问临界区,或同一线程在多个嵌套函数中需要加锁。
- 优缺点:
- 优点:解决递归场景的死锁问题;
- 缺点:可能隐藏逻辑漏洞(如误判临界区范围),开销略高于普通互斥锁,不建议滥用。
C++ 实战示例
#include <mutex>
std::recursive_mutex rec_mtx;
int count = 0;
void recursive_func(int depth) {
if (depth == 0) return;
std::lock_guard<std::recursive_mutex> lock(rec_mtx);
count++;
recursive_func(depth - 1); // 同一线程再次加锁,不会死锁
}
三、多进程锁(进程间同步)
多进程的地址空间相互独立,锁需借助'跨进程共享资源'实现,常见类型如下:
1. 文件锁(File Lock):最易实现的跨进程锁
核心特点
- 本质:以文件作为共享资源,通过文件的'锁定状态'实现进程间互斥,分为两种:
flock:建议性锁(进程可忽略),支持整个文件锁定,接口简单;fcntl:强制性锁(需系统配置),支持'记录锁'(锁定文件部分内容),更灵活。
- 适用场景:进程间共享文件(如日志写入、配置文件修改),实现简单,无需额外依赖。
- 优缺点:
- 优点:实现简单,无需手动管理共享资源,进程崩溃后锁自动释放;
- 缺点:依赖文件系统,性能略低,不适合高频通信场景。
Linux 系统调用示例(fcntl 记录锁)
#include <fcntl.h>
#include <unistd.h>
// 加锁:fd 为文件描述符,offset 和 len 为锁定范围
int lock_file(int fd) {
struct flock fl;
fl.l_type = F_WRLCK; // 写锁(独占),读锁为 F_RDLCK
fl.l_whence = SEEK_SET;
fl.l_start = 0;
fl.l_len = 0; // 0 表示锁定整个文件
return fcntl(fd, F_SETLK, &fl); // 非阻塞,失败返回 -1
}
// 解锁
int unlock_file(int fd) {
struct flock fl;
fl.l_type = F_UNLCK;
fl.l_whence = SEEK_SET;
fl.l_start = 0;
fl.l_len = 0;
return fcntl(fd, F_SETLK, &fl);
}
2. 共享内存锁:高性能跨进程锁
核心特点
- 本质:通过共享内存存储锁状态(如自旋锁、互斥锁),进程间直接访问共享内存获取锁,无文件 IO 开销。
- 适用场景:高频通信的多进程场景(如进程池、分布式计算),追求极致性能。
- 优缺点:
- 优点:性能极高(内存级访问);
- 缺点:需手动管理共享内存的创建 / 销毁,进程崩溃后可能导致锁残留(需处理异常)。
实现思路(Linux)
- 用
shmget创建共享内存,shmat将其映射到进程地址空间; - 在共享内存中定义锁结构(如自旋锁);
- 进程通过访问共享内存中的锁状态实现互斥。
3. 信号量(Semaphore):通用跨进程 / 线程同步工具
核心特点
- 本质:计数器锁,支持'计数'(非二进制),核心逻辑:
- 申请资源(P 操作):计数器 - 1,若计数器 < 0 则阻塞;
- 释放资源(V 操作):计数器 + 1,若计数器 <= 0 则唤醒一个阻塞进程 / 线程。
- 适用场景:
- 线程 / 进程间互斥(计数器 = 1,等价于互斥锁);
- 限制资源访问数量(如线程池大小、连接池数量)。
- 分类:
- System V 信号量:适合多进程同步,需手动管理(
semget创建、semctl销毁); - Posix 信号量:支持线程 / 进程同步,接口更简洁(
sem_init创建、sem_destroy销毁)。
- System V 信号量:适合多进程同步,需手动管理(
C++ 实战示例(Posix 信号量,多进程)
#include <semaphore.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
// 创建命名信号量(跨进程可见),初始值 1(互斥锁)
sem_t* sem = sem_open("/my_sem", O_CREAT | O_EXCL, 0644, 1);
if (fork() == 0) {
sem_wait(sem); // 子进程 P 操作(加锁)
// 临界区:访问共享资源
sem_post(sem); // 子进程 V 操作(解锁)
sem_close(sem);
return 0;
}
sem_wait(sem); // 父进程 P 操作(加锁)
// 临界区:访问共享资源
sem_post(sem); // 父进程 V 操作(解锁)
wait(nullptr);
sem_close(sem);
sem_unlink("/my_sem"); // 删除信号量
return 0;
}
四、锁的核心对比与选型决策树
1. 关键维度对比表
| 锁类型 | 适用场景 | 核心优点 | 核心缺点 | 实现方式 |
|---|---|---|---|---|
| 互斥锁 | 多线程,临界区较长 | 无 CPU 空转,使用简单 | 上下文切换开销 | C++11 std::mutex |
| 自旋锁 | 多线程,临界区极短 | 无上下文切换,响应快 | CPU 空转,易优先级反转 | Linux pthread_spinlock_t |
| 读写锁 | 多线程,读多写少 | 读操作并发度高 | 写操作可能饥饿 | C++17 std::shared_mutex |
| 条件变量 | 多线程,等待 - 通知同步 | 避免忙等,同步逻辑清晰 | 需配合互斥锁,有虚假唤醒 | C++11 std::condition_variable |
| 递归锁 | 多线程,递归调用 | 避免递归死锁 | 可能隐藏逻辑漏洞 | C++11 std::recursive_mutex |
| 文件锁 | 多进程,共享文件 | 实现简单,锁自动释放 | 性能低,依赖文件系统 | Linux flock/fcntl |
| 共享内存锁 | 多进程,高频通信 | 性能极高 | 需手动管理共享内存 | Linux shmget + 自定义锁 |
| 信号量 | 线程 / 进程,资源计数 | 通用灵活,支持计数 | 需手动管理,易出错 | Posix/System V 信号量 |
2. 选型决策树(实战优先)
- 先判断:是多线程还是多进程?
- 多线程 → 优先选'线程锁'(开销低);
- 多进程 → 优先选'进程锁'(依赖共享资源)。
- 多线程场景再细化:
- 读多写少 → 读写锁;
- 临界区 < 1ms → 自旋锁;
- 临界区 > 1ms → 互斥锁;
- 递归调用 → 递归锁;
- 线程间需等待通知 → 条件变量。
- 多进程场景再细化:
- 共享文件 → 文件锁;
- 高频通信 → 共享内存锁;
- 资源计数 / 通用同步 → 信号量。
五、实战避坑指南(面试高频)
- 死锁预防:
- 加锁顺序一致(避免 A 拿锁 1 等锁 2,B 拿锁 2 等锁 1);
- 避免锁嵌套(如需嵌套,控制层数并明确顺序);
- 使用超时锁(如
std::unique_lock::try_lock_for),避免无限等待。
- 性能优化:
- 减少临界区范围(只锁必要代码,不锁整个函数);
- 读多写少用读写锁,避免用互斥锁浪费并发;
- 多进程高频通信避免文件锁,选共享内存锁。
- 常见误区:
- 条件变量用
if判断条件(易触发虚假唤醒,需用while); - 自旋锁用在单核 CPU(忙等会阻塞所有线程);
- 递归锁滥用(可能掩盖临界区设计问题)。
- 条件变量用
总结
锁的核心是'平衡并发与安全'—— 没有最好的锁,只有最适合的场景。实际开发中,需先明确'线程 / 进程''临界区特性''资源类型'三个关键维度,再结合锁的优缺点选型。本文梳理的锁类型、特点及选型逻辑,既适用于日常开发沉淀,也能应对 C/C++ 并发编程的面试提问。
