Linux 服务器高阶编程(15):线程安全、互斥锁、读写锁与原子操作
一、为什么会发生共享资源访问冲突
1. 哪些东西可能是共享资源
包括:
- 同一进程内的全局变量。
- 多个执行者共同访问的文件。
- 共享内存或共享映射中的数据。
- 数据库中的同一条或同一组业务数据。
关键不是“它叫什么名字”,而是:是否存在多个执行者访问同一份状态,且至少有一方修改它。
例如,两个线程共同修改全局计数器,一个线程写共享数据而另一个线程读取,或者两个执行者更新同一份业务记录,都需要考虑访问之间的约束。
只读且没有并发写入的数据,在对象生命周期正确的前提下,不必仅仅因为“被多个线程读取”就一律加互斥锁。
2. ++a 不是天然的线程安全操作
设定:
int a = 0;两个线程分别执行 5000 次 ++a。从业务期望看:
初值 0 + 线程一的 5000 次 + 线程二的 5000 次 = 10000问题在于,普通 C 表达式 ++a 不能直接等同于一个不可被打断的原子动作。
“取值—加一—写回”的模型帮助理解:
从共享内存读取 a在寄存器中加 1把结果写回共享内存下面是一种丢失更新的交错:
| 顺序 | 线程 T1 | 线程 T2 | 共享变量 a |
|---|---|---|---|
| 1 | 读取 0 | 尚未操作 | 0 |
| 2 | 在自己的计算中得到 1 | 读取 0 | 0 |
| 3 | 写回 1 | 在自己的计算中得到 1 | 1 |
| 4 | 已完成 | 写回 1 | 1 |
两个线程各做了一次加一,但最终只增加了一次。
3. 两方面的损失
第一,计算结果错误。即使只少一次,也不能认为程序正确。
第二,资源已经消耗,但部分结果被覆盖。线程仍然占用了 CPU 等资源,却没有得到预期的有效累计结果。
引出:要使用互斥手段,让同一份共享状态的冲突访问不能任意交错。
二、thread++.c:先复现,再解决
1. 无保护版本的核心结构
int a = 0;
void *business(void *arg){ for (int i = 0; i < 5000; i++) { printf("0x%x ++a = %d\n", (unsigned int)pthread_self(), ++a); } return NULL;}主函数创建两个执行相同函数的线程,并通过循环休眠让进程继续存活。
pthread_t tid;pthread_create(&tid, NULL, business, NULL);pthread_create(&tid, NULL, business, NULL);
while (1) sleep(1);这里两个线程都干同一件事,因此可以使用同一个线程入口函数,不需要专门写两个相同的函数。
2. 为什么第一次运行可能看不出错误
前几次运行结果看起来正常,之后出现少一次的情况;再增加循环次数,错误更容易观察。
需要建立的判断习惯:
- 没有复现,不代表不存在竞争。
- 多运行几次都正确,也不能证明无保护访问安全。
- 增大循环次数只是增加观察到问题的机会,不是解决问题。
- 给程序加
sleep、增加打印或更换机器,都会影响调度,从而改变表面现象。
循环次数中有调整:最初每个线程 5000 次,随后增加到 500000 次,互斥锁示例中又使用每个线程 50000 次。计算预期结果时要与当前代码保持一致。
每线程 5000 次 → 两线程预期 10000每线程 50000 次 → 两线程预期 100000每线程 500000 次 → 两线程预期 10000003. 代码的适用范围
上面的主函数是为了快速观察现象:同一个 tid 变量被写入两次,主线程没有逐一 join。
三、互斥锁的基本思想
1. 卫生间门锁的类比
一个人进入、反锁、使用、离开并开锁;其他人发现门锁着,就必须等待。
对应到程序:
申请锁成功 → 访问受保护的数据或流程 → 释放锁申请锁失败/暂时不可用 → 等待能够获得锁这段保护的目标是:同一时刻,最多只有一个遵守该协议的执行者进入对应临界区。
2. 锁并不会自动找到变量
声明一把锁,并不等于编译器会自动禁止所有其他代码访问 a。
真正有效的协议是:
- 所有会冲突的访问,都使用同一把锁。
- 访问发生在成功加锁之后、解锁之前。
- 不能某个线程加锁,另一个线程绕开锁直接修改。
- 不同线程各自创建一把互不相关的锁,也不能保护同一份共享数据。
3. 什么是临界区
临界区是需要互斥执行的代码区间,不是新分配的一块内存。
pthread_mutex_lock(&lock);
/* 临界区:需要共同保护的操作 */
pthread_mutex_unlock(&lock);锁不仅可以保护一个变量,也可以保护一段需要保持整体一致性的流程。
例如,对两项相关状态的检查与修改需要作为一个整体完成时,只保护其中某一次赋值通常不够。
四、互斥锁接口与生命周期
1. 头文件与类型
#include <pthread.h>
pthread_mutex_t lock;lock 是锁对象本身,调用函数时通常传它的地址 &lock。
2. 动态初始化
pthread_mutex_init(&lock, NULL);第一个参数是待初始化的锁;第二个参数为属性对象地址,传 NULL 表示采用默认属性。
需要定制属性时,必须先准备好属性,再用它初始化锁。
3. 静态初始化
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;在加锁版本中使用了这一写法,主函数里的动态初始化被注释掉。
两者是初始化方案的选择,不是要求连续执行两次。不要对已经初始化、甚至正在被使用的锁再随意初始化。
4. 加锁、解锁与销毁
pthread_mutex_lock(&lock);pthread_mutex_unlock(&lock);pthread_mutex_destroy(&lock);可以按生命周期记忆:
初始化一次 → 使用期间多次加锁/解锁 → 所有使用者结束后销毁锁应在线程开始使用它之前初始化。销毁则应放在没有线程继续持有或使用它之后,不能在线程仍在运行时为了“回收资源”直接销毁。
5. 返回值与等待行为
普通用法中,成功返回 0,失败返回错误号。锁被其他线程持有时,pthread_mutex_lock 可能阻塞,直到能够获得锁。
必须真正获得锁后才能进入受保护流程。
五、给累加程序加锁
1. 循环内加锁版本
#include <stdio.h>#include <unistd.h>#include <pthread.h>
int a = 0;pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *business(void *arg){ for (int i = 0; i < 50000; i++) { pthread_mutex_lock(&lock); // 临界区代码,线程互斥访问 printf("0x%x ++a = %d\n", (unsigned int)pthread_self(), ++a); pthread_mutex_unlock(&lock); } return NULL;}
int main(void){ pthread_t tid; // pthread_mutex_init(&lock, NULL); pthread_create(&tid, NULL, business, NULL); pthread_create(&tid, NULL, business, NULL);
while (1) sleep(1);}这里把对 a 的自增与相关打印放进同一个临界区,两个线程必须依次执行这一段。两个工作线程都完成 50000 次循环后,累计结果应达到 100000。
2. 编译与观察
可用的编译方式:
gcc thread++.c -o app -pthread./app-pthread 用于线程程序的编译与链接
3. 加锁后仍可能出现连续一大段同一线程输出
互斥只保证冲突操作不同时进入,并不承诺:
T1 加一次 → T2 加一次 → T1 加一次 → T2 加一次一个线程解锁后,如果再次很快获得锁,可能连续执行多次。这不是“锁失效”。
想要求严格交替或等待某个条件,需要后续的同步机制;本节不能仅靠将锁挪到循环内来保证交替。
六、加锁范围与性能:锁放循环内还是循环外
1. 循环内加锁
for (int i = 0; i < 50000; i++) { pthread_mutex_lock(&lock); ++a; pthread_mutex_unlock(&lock);}每次只保护一小段操作,临界区短,但获取与释放锁的次数多。
两个线程各 50000 次:
加锁调用:100000 次解锁调用:100000 次二者合计:200000 次2. 循环外加锁
pthread_mutex_lock(&lock);for (int i = 0; i < 50000; i++) { ++a;}pthread_mutex_unlock(&lock);每个线程只加锁一次、解锁一次。两个线程合计 4 次锁相关调用。
代价是整段循环都被串行化:一个线程执行整批操作时,另一个线程无法进入这段循环。
3. 怎么选择
“一个函数有很多行,真正访问共享状态的只有几行”说明:没有必要不加分析地把整个函数都锁起来。
可按下面顺序思考:
- 哪些状态需要共同保护?
- 哪些步骤不能被其他线程插入?
- 哪些计算、等待、打印可以移到锁外?
- 调用频率和竞争程度是否使锁开销成为瓶颈?
4. 关于开销的补充辨析
用户态/内核态切换说明锁的潜在成本,但不能把上面的 200000 次函数调用直接乘以固定次数,认定必然发生同等数量的系统调用或线程切换。
Linux 常见线程库基于 futex 等机制提供用户态快速路径:无竞争时可以在用户态完成,竞争等待时才可能需要内核参与。加锁/解锁也不是每次创建/销毁一块“临界区内存”。
七、等待、唤醒与惊群
1. 等待的直观过程
假设 T1 持有锁,T2、T3、T4 也要访问同一临界区,它们不能直接闯进去,需要等锁可用。
T1 解锁后,其他线程才有机会获得这把锁。
2. 惊群的含义
“很多线程等待同一个资源,资源一可用就把很多等待者一起叫醒”的情况解释惊群。
如果最终只能有一个线程使用资源,其余被唤醒者又重新等待,就可能产生不必要的唤醒、竞争和调度成本。
这里要记住的是减少无效竞争的思想,而不是认为每种互斥锁每次解锁都一定唤醒所有等待者。
八、保护手段要与资源范围匹配
不同环境中有不同层次的保护机制。
同一进程中的线程共享状态,可以使用进程内互斥锁;不同进程共同访问共享内存,则需要后文的进程共享锁。
数据库中的一致性问题还涉及数据库事务和数据库自己的锁。如果多个独立程序都能修改数据库,仅在其中一个程序里加一把线程锁,无法约束其他程序。
进程内锁不能替代数据库面向所有相关客户端的事务一致性机制。
九、读写锁:让读者可以并行
1. 为什么还需要另一种锁
互斥锁不区分读和写,只要进入临界区就排他。
如果共享数据读多写少,多名读者彼此没有修改冲突,却也全部串行执行,就可能限制并发。
读写锁针对这种访问模式进行区分:
| 已持有的模式 | 另一个读者申请读锁 | 写者申请写锁 |
|---|---|---|
| 无持有者 | 可取得,具体还受等待者策略影响 | 可取得 |
| 已有读者 | 可以共享,具体受实现策略影响 | 等待读者退出 |
| 已有写者 | 等待 | 等待 |
2. 类型与接口
pthread_rwlock_t lock;
pthread_rwlock_init(&lock, NULL);pthread_rwlock_rdlock(&lock);pthread_rwlock_wrlock(&lock);pthread_rwlock_unlock(&lock);pthread_rwlock_destroy(&lock);读操作申请读锁,写操作申请写锁,最后统一使用 pthread_rwlock_unlock 释放。
3. 读锁也可能等待
有写者持锁时,读者不能进入;有写者排队时,新读者能否进入还受到具体策略影响。
“允许多名读者”也不等于承诺无限个读锁。实现可以限制同时持有的读锁数量,超出限制可报 EAGAIN,不能只理解成“超过某个固定人数就排队”。
十、rwlock.c:3 个写线程、5 个读线程
1. 共享对象
全局声明:
pthread_rwlock_t lock;int a;a 为全局变量,初值为 0。
2. 写线程
核心循环:
void *wr(void *arg){ while (1) { pthread_rwlock_wrlock(&lock); printf("wr thread 0x%x ++a %d\n", (unsigned int)pthread_self(), ++a); pthread_rwlock_unlock(&lock); usleep(300000); }}顺序是申请写锁、修改并打印、解锁、休眠。休眠放在锁外,避免休眠期间继续占着写锁。
3. 读线程
void *rd(void *arg){ while (1) { pthread_rwlock_rdlock(&lock); printf("rd thread 0x%x print a %d\n", (unsigned int)pthread_self(), a); pthread_rwlock_unlock(&lock); usleep(300000); }}读线程不修改 a,但仍然需要与写线程协调,因此使用读锁,而不是直接无保护读取。
4. 创建线程的结构
使用 pthread_t tids[8],先创建 3 个写线程,再创建 5 个读线程。
pthread_t tids[8];int i;
for (i = 0; i < 3; i++) pthread_create(&tids[i], NULL, wr, NULL);
for (; i < 8; i++) pthread_create(&tids[i], NULL, rd, NULL);后面通过 pthread_join 等待线程。
十一、从线程间互斥转向进程间互斥
1. 普通全局变量不能直接作为父子进程共同更新的那一份数据
线程共享同一进程地址空间;fork 产生的父子进程则有各自的普通内存状态。
因此,想让父子进程共同累加同一个计数器,需要先建立真正共享的存储。
2. 数据共享和锁共享是两个条件
只让数据共享,却让父子进程各用一把私有锁,不能保护彼此的访问。
将数据和锁放进一个结构体:
typedef struct{ int cont; pthread_mutex_t lock;} shared_t;字段中写作 cont
接下来把整个 shared_t 映射到共享区域,确保父子进程访问的计数器和锁都是同一份共享对象。
3. 为什么必须设置进程共享属性
互斥锁默认属性是 PTHREAD_PROCESS_PRIVATE,用于同一进程中的线程。
跨进程使用时,需要 PTHREAD_PROCESS_SHARED,并且锁必须确实位于各方可访问的共享存储中。仅设置属性不会自动把普通内存变成共享内存。
十二、process_lock.c 的实现步骤
1. 创建后备文件并调整大小
int fd;fd = open("MAPFILE", O_RDWR | O_CREAT, 0664);ftruncate(fd, sizeof(shared_t));2. 建立共享映射
shared_t *ptr = NULL;
if ((ptr = mmap(NULL, sizeof(shared_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) == MAP_FAILED) { perror("mmap call failed"); exit(0);}
close(fd);几个参数的含义:
NULL:映射起始地址由系统选择。sizeof(shared_t):映射长度覆盖整个共享结构体。PROT_READ | PROT_WRITE:允许读写。MAP_SHARED:修改通过共享映射反映到共享对象。fd, 0:从后备文件偏移 0 的位置开始映射。
3. 设置计数初值,初始化属性与锁
ptr->cont = 0;
pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);pthread_mutex_init(&ptr->lock, &attr);顺序不能颠倒:
共享存储就绪 → 初始化属性对象 → 设置进程共享属性 → 使用该属性初始化共享区中的锁 → fork注意传给 pthread_mutex_init 的是 &attr,不是 NULL;否则就没有使用刚刚设置的定制属性。
4. 再创建子进程
pid_t pid;pid = fork();映射在 fork 之前建立,子进程继承该映射,因此本例不需要在子进程分支中再执行一套相同的映射代码。
5. 父子进程使用同一把锁
父进程核心循环:
for (int i = 0; i < 5000; i++) { pthread_mutex_lock(&ptr->lock); printf("parent pid %d, ++cont = %d\n", getpid(), ++(ptr->cont)); pthread_mutex_unlock(&ptr->lock);}子进程核心循环:
for (int i = 0; i < 5000; i++) { pthread_mutex_lock(&ptr->lock); printf("child pid %d, ++cont = %d\n", getpid(), ++(ptr->cont)); pthread_mutex_unlock(&ptr->lock);}两个分支保护的都是 ptr->cont,使用的都是共享区里的 ptr->lock。完成后累计预期为 10000。
6. 父进程等待并清理
wait(NULL);pthread_mutexattr_destroy(&attr);pthread_mutex_destroy(&ptr->lock);munmap(ptr, sizeof(shared_t));子进程分支中使用 exit(0) 结束。
在父进程完成自身工作、等待子进程结束后,再销毁共享锁,避免子进程还在使用它时就撤销资源。
属性对象和互斥锁对象不是同一个东西:销毁属性不会代替销毁锁,销毁锁也不会自动取消映射。
十三、原子操作与 CAS 的思想
1. 为什么在讲完锁以后还要讲原子操作
给简单计数加锁可以保证正确,但有时只是对一个基础数值做一次更新,整个互斥锁协议显得比较重。
因此引入原子操作:把受支持的单次更新作为不可分割的读—改—写操作完成。
2. CAS:比较并交换
CAS 是 Compare And Swap,即比较并交换。
可以用三个量帮助理解:
V:共享位置当前保存的值。A:调用者预期看到的旧值。B:如果旧值符合预期,准备写入的新值。
原子地完成以下判断和修改:
若 V == A,则将 V 改为 B;否则不按这个旧预期覆盖 V。如果其他线程已经改变了值,旧的更新条件就不再成立。基于 CAS 实现自增时,通常需要重新读取、计算并重试,而不是比较失败以后仍然强行写入。
十四、GCC __sync_* 原子加减接口
1. 实际使用的四个名字
| 函数 | 动作 | 返回值含义 | 类比 |
|---|---|---|---|
__sync_fetch_and_add(&a, 1) | 原子加 1 | 更新之前的值 | 后置 a++ |
__sync_add_and_fetch(&a, 1) | 原子加 1 | 更新之后的值 | 前置 ++a |
__sync_fetch_and_sub(&a, 1) | 原子减 1 | 更新之前的值 | 后置 a-- |
__sync_sub_and_fetch(&a, 1) | 原子减 1 | 更新之后的值 | 前置 --a |
两个关键参数:第一个是要修改对象的地址,第二个是增加或减少的数值。
__sync_add_and_fetch(&a, 1);表示对 a 原子加 1,并返回新值;如果想加 5,第二个参数就改为 5。
2. “先取值”与“后取值”的差别
假设没有其他并发更新,原来 a = 30:
int old = __sync_fetch_and_add(&a, 1);完成后 a == 31,old == 30。
若从同样的初值重新开始:
int now = __sync_add_and_fetch(&a, 1);完成后 a == 31,now == 31。
函数返回值和另一个时刻重新读取到的全局值不是同一回事。并发情况下,函数返回以后可能已经有其他线程继续修改 a。
3. 改造最初的线程累加程序
去掉了原来的互斥锁保护,直接把 ++a 换成原子加法:
printf("0x%x ++a = %d\n", (unsigned int)pthread_self(), __sync_add_and_fetch(&a, 1));两个线程仍然各执行规定次数,依靠原子自增保证计数不被其他同样的原子自增覆盖。
本节强调函数名前面是 两个下划线:
正确:__sync_add_and_fetch错误:_sync_add_and_fetch这组函数是 GCC 内建函数,不是要求另外引入一个名叫 sync.h 的头文件。
十五、原子操作不能直接替代所有互斥锁
1. 一个数值更新,与一段业务流程不同
原子加一适合本例这种简单计数,但不能仅凭某个变量的更新是原子的,就认定整段业务流程已经安全。
例如,“先检查条件,再修改多个相关字段”可能要求几步作为一个整体完成。把其中一次加法换成原子操作,不会自动保护其余步骤。
互斥锁能保护一个临界区;单个原子加减接口只保证其自身那一次操作。
2. 选择的基本原则
- 只有一个简单计数或受支持的单次基础更新:优先考虑合适的原子操作。
- 多步操作必须共同保持一致:先明确需要保护的整体,再选择互斥或专门设计的并发算法。
- 读多写少:可以评估读写锁,但不保证在所有负载下都比互斥锁快。
- 跨进程共享内存:还要保证共享存储和进程共享属性都正确。
十六、原子操作、互斥锁、无保护更新的性能对比
1. 程序
测试设定:
- 创建 4 个线程。
- 每个线程进行 1000000 次更新。
- 涉及线程绑定 CPU。
- 预期总计数为 4000000。
程序用参数选择不同方式:
./app 1 # 原子操作版本./app 2 # 互斥锁版本./app 3 # 无保护的普通更新2. 观察
| 版本 | 观察结果 | 应理解的结论 |
|---|---|---|
| 普通无保护更新 | 该次结果只有约 180 多万,未达到 400 万 | 快不等于正确,数据竞争导致结果不可靠 |
| 互斥锁 | 达到预期结果,时间开销较大 | 保护了每次更新,但存在获取/释放及竞争成本 |
| 原子操作 | 达到预期结果,明显快于互斥版本 | 简单计数适合评估原子更新 |
如果这篇文章对你有帮助,欢迎分享给更多人!





