mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4882 字
27 分钟
Linux 服务器高阶编程(15):线程安全、互斥锁、读写锁与原子操作

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读取 00
3写回 1在自己的计算中得到 11
4已完成写回 11

两个线程各做了一次加一,但最终只增加了一次。

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 次 → 两线程预期 1000000

3. 代码的适用范围#

上面的主函数是为了快速观察现象:同一个 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. 怎么选择#

“一个函数有很多行,真正访问共享状态的只有几行”说明:没有必要不加分析地把整个函数都锁起来。

可按下面顺序思考:

  1. 哪些状态需要共同保护?
  2. 哪些步骤不能被其他线程插入?
  3. 哪些计算、等待、打印可以移到锁外?
  4. 调用频率和竞争程度是否使锁开销成为瓶颈?

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 万快不等于正确,数据竞争导致结果不可靠
互斥锁达到预期结果,时间开销较大保护了每次更新,但存在获取/释放及竞争成本
原子操作达到预期结果,明显快于互斥版本简单计数适合评估原子更新
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Linux 服务器高阶编程(15):线程安全、互斥锁、读写锁与原子操作
https://joyanblog.com/posts/linux-server-advanced-programming-15-thread-safety-locks-atomics/
作者
Joyan
发布于
2026-11-08
许可协议
CC BY-NC-SA 4.0

目录