☰
Linux 线程同步:竞态条件与互斥锁
2026/10/3 12:47:17 网站建设 项目流程

Linux 线程同步:竞态条件与互斥锁

课程:尚硅谷《嵌入式 Linux 应用层开发》第 4 章线程处理
依据:2026-09-29 17:52 录音转写;课程 PDF 第 142—146 页。
本节边界:教材第 142—145 页讲竞态条件,第 146 页开始讲互斥锁;录音继续演示了互斥锁 API 和加锁后的累加程序,因此一并整理为完整学习闭环。

1. 本节要解决的问题

多个线程都能访问进程中的全局变量。如果它们同时修改同一份数据,程序结果可能随运行时序变化。

本节的完整思路是:

多个线程修改共享变量

num++ 操作互相穿插

结果不稳定

确定临界区

进入临界区前加锁

同一时刻只有一个线程修改

修改后立即解锁

结果稳定

学完本节,需要真正理解三件事:

  1. pthread_join()只能保证打印发生在线程结束之后,不能消除线程之间的竞争;
  2. num++不是不可分割的一步;
  3. 互斥锁保护的是一段访问共享状态的代码,也就是临界区。

2. 什么是竞态条件

竞态条件(race condition)指两个或更多执行单元并发访问共享状态,最终结果取决于它们执行的精确先后顺序。

典型条件是:

  • 至少两个线程并发执行;
  • 它们访问同一个共享对象;
  • 至少有一个线程执行写操作;
  • 访问之间没有正确的同步。

例如两个线程共同执行:

number++;

预期是加两次,但实际可能只增加一次。

2.1number++为什么会丢失更新

从 C 代码看,number++只有一行;从执行过程看,它通常包含三个逻辑步骤:

读取 number → 在寄存器中加 1 → 写回 number

假设初值是 10:

时刻线程 A线程 B内存中的number
1读取 1010
2读取 1010
3计算 1110
4计算 1110
5写回 1111
6写回 1111

两个线程都执行了一次加一,结果却从 10 变成 11。

2.2 更严格的 C 语言结论

如果多个线程在没有同步的情况下读写同一个普通变量,并且至少有一次是写操作,就产生了data race(数据竞争)。在 C 语言内存模型中,数据竞争会导致未定义行为。

因此不能只把它理解为“偶尔少加几次”。编译器优化后可能出现更多不可预测的结果。正确做法是使用互斥锁、原子操作或重新设计数据共享方式。

3. 教材中的 20000 线程累加实验

教材第 143—145 页让 20000 个线程分别执行一次(*p)++,最后由主线程等待全部线程并打印结果。

核心结构是:

#defineTHREAD_COUNT20000staticvoid*add_thread(void*arg){int*p=arg;(*p)++;returnNULL;}

主线程先创建所有线程,再逐个等待:

for(inti=0;i<THREAD_COUNT;++i){pthread_create(&threads[i],NULL,add_thread,&number);}for(inti=0;i<THREAD_COUNT;++i){pthread_join(threads[i],NULL);}

3.1 为什么已经join,结果仍可能出错

pthread_join()只表达:

主线程等待目标线程结束

它没有表达:

各工作线程必须按顺序修改 number

竞争发生在工作线程执行number++时。主线程最后再等待,不能倒回去修复已经丢失的更新。

3.2 这个实验还要注意什么

一次创建 20000 个线程只是为了放大现象,不是推荐的工程设计:

  • 线程创建和栈空间都有成本;
  • 系统可能因为资源不足而创建失败;
  • 原教材示例没有检查pthread_create()返回值;
  • 若创建失败后仍对相应数组元素调用pthread_join(),程序行为就不可靠。

实际程序通常创建少量长期运行的工作线程,让每个线程处理多个任务。

4. 避免竞态条件的两条路线

教材第 145 页给出两种思路:

4.1 减少或取消共享写入

每个线程只修改自己的数据,最后由一个线程统一合并。

例如让每个线程先计算局部计数:

线程 A → local_a 线程 B → local_b 主线程 → total = local_a + local_b

这是从程序结构上减少竞争,通常也有更好的并行性能。

4.2 给共享资源加锁

如果多个线程必须修改同一个共享状态,就在访问前取得锁,完成后释放锁。

pthread_mutex_lock(&mutex);number++;pthread_mutex_unlock(&mutex);

其中number++是临界区:执行它时,不能允许其他线程同时进入同一受保护区域。

5. 三种常见锁的定位

录音和教材第 145 页先做了概念比较:

类型等待方式与并发规则入门阶段怎样理解
互斥锁 Mutex同一时刻只有一个线程持有锁;其他线程通常阻塞等待本节重点,保护一般共享数据
读写锁 Reader Writer Lock多个读者可同时进入;写者必须独占读多写少时可能提高并发度
自旋锁 Spinlock获取不到时循环忙等,持续占用 CPU只适合持锁极短等特定场景,常见于内核

“读操作一定不需要锁”并不准确。多个线程只读且数据不会被任何线程修改时通常安全;只要存在并发写入,读者也必须参加同一套同步协议。

6. 什么是互斥锁

互斥锁(mutex)用于建立互斥访问:任意时刻最多只有一个线程持有某一把锁。

线程 B互斥锁线程 A线程 B互斥锁线程 A锁已被 A 持有,B 阻塞pthread_mutex_lock获取成功修改共享数据pthread_mutex_lockpthread_mutex_unlockB 获得锁修改共享数据pthread_mutex_unlock

互斥锁还建立了内存同步关系:一个线程解锁前完成的共享数据修改,在另一个线程随后成功获取同一把锁后可以被正确观察到。

6.1 锁与共享资源的关系

锁本身不会自动知道它保护哪个变量。这个对应关系由程序员建立并始终遵守:

/* 约定:访问 number 时必须持有 number_mutex */staticintnumber;staticpthread_mutex_tnumber_mutex=PTHREAD_MUTEX_INITIALIZER;

如果某些代码访问number时加锁,另一些代码绕过锁直接写,程序仍然存在数据竞争。

7.pthread_mutex_t应当怎样理解

声明互斥锁时使用:

#include<pthread.h>pthread_mutex_tmutex;

教材第 146 页展示了 glibc 中pthread_mutex_t的联合体实现,这是帮助观察实现方式的材料。编写应用程序时应把它当作不透明类型:

  • 只包含<pthread.h>;
  • 不直接访问内部字段;
  • 不假设它在所有系统上都是同一种结构;
  • 通过pthread_mutex_*系列函数操作。

8. 互斥锁的五个基础操作

intpthread_mutex_init(pthread_mutex_t*mutex,constpthread_mutexattr_t*attr);intpthread_mutex_lock(pthread_mutex_t*mutex);intpthread_mutex_trylock(pthread_mutex_t*mutex);intpthread_mutex_unlock(pthread_mutex_t*mutex);intpthread_mutex_destroy(pthread_mutex_t*mutex);
操作作用初学阶段重点
pthread_mutex_init运行时初始化互斥锁attr传NULL表示默认属性
pthread_mutex_lock获取锁锁被其他线程持有时阻塞等待
pthread_mutex_trylock尝试获取锁获取不到立即返回EBUSY
pthread_mutex_unlock释放当前线程持有的锁临界区结束后尽快释放
pthread_mutex_destroy销毁不再使用且已解锁的锁销毁后不能继续使用,除非重新初始化

这些函数成功返回0,失败直接返回错误号。检查方式是:

interror=pthread_mutex_lock(&mutex);if(error!=0){fprintf(stderr,"pthread_mutex_lock: %s\n",strerror(error));}

不要仅用perror()读取errno来判断大多数 Pthreads API 的错误。

9. 两种初始化方式

9.1 静态初始化

适合默认属性、生命周期清晰的互斥锁:

staticpthread_mutex_tmutex=PTHREAD_MUTEX_INITIALIZER;

使用这种方式后,不再对同一把锁调用pthread_mutex_init()。

9.2 动态初始化

适合运行时创建的对象,或需要自定义互斥锁属性的情况:

pthread_mutex_tmutex;interror=pthread_mutex_init(&mutex,NULL);if(error!=0){fprintf(stderr,"pthread_mutex_init: %s\n",strerror(error));}

使用结束且确认没有线程继续访问时:

pthread_mutex_destroy(&mutex);

静态初始化表示“不需要调用pthread_mutex_init()”,并不等于互斥锁永远不能或永远不必销毁。若锁与进程同寿命,进程退出时由操作系统回收资源,省略显式销毁通常没有实际影响;如果对象会被重复创建和释放,就应在安全时调用pthread_mutex_destroy()。

10. 基础完整练习:四个线程安全累加

下面用 4 个线程,每个线程累加 100000 次。它比一次创建 20000 个线程更适合反复练习。

#include<pthread.h>#include<stdio.h>#include<stdlib.h>#include<string.h>#defineTHREAD_COUNT4#defineADD_PER_THREAD100000staticlongcounter=0;staticpthread_mutex_tcounter_mutex=PTHREAD_MUTEX_INITIALIZER;staticvoidcheck_pthread(interror,constchar*operation){if(error!=0){fprintf(stderr,"%s: %s\n",operation,strerror(error));exit(EXIT_FAILURE);}}staticvoid*add_thread(void*arg){(void)arg;for(inti=0;i<ADD_PER_THREAD;++i){check_pthread(pthread_mutex_lock(&counter_mutex),"pthread_mutex_lock");++counter;/* 临界区:读、改、写共享变量 */check_pthread(pthread_mutex_unlock(&counter_mutex),"pthread_mutex_unlock");}returnNULL;}intmain(void){pthread_tthreads[THREAD_COUNT];for(inti=0;i<THREAD_COUNT;++i){check_pthread(pthread_create(&threads[i],NULL,add_thread,NULL),"pthread_create");}for(inti=0;i<THREAD_COUNT;++i){check_pthread(pthread_join(threads[i],NULL),"pthread_join");}printf("counter = %ld,期望值 = %d\n",counter,THREAD_COUNT*ADD_PER_THREAD);check_pthread(pthread_mutex_destroy(&counter_mutex),"pthread_mutex_destroy");returnEXIT_SUCCESS;}

编译运行:

gcc-Wall-Wextra-pthreadmutex_counter.c-omutex_counter ./mutex_counter

预期输出:

counter = 400000,期望值 = 400000

10.1 逐段理解

  1. counter是所有线程共享的数据;
  2. counter_mutex是所有线程共同使用的同一把锁;
  3. 每次修改前调用pthread_mutex_lock();
  4. 同一时刻只有一个线程能执行++counter;
  5. 修改完成马上调用pthread_mutex_unlock();
  6. 主线程join全部工作线程后,才读取最终结果;
  7. 所有线程都结束后,互斥锁一定无人使用,此时才销毁。

10.2 反向实验

暂时注释掉lock和unlock两行,多运行几次并观察结果。这个实验只能用于观察错误现象,不能据此认为某次结果正确就代表程序安全。

11.pthread_mutex_trylock()的使用方式

pthread_mutex_lock()获取不到锁时等待;pthread_mutex_trylock()获取不到时立即返回。

#include<errno.h>interror=pthread_mutex_trylock(&counter_mutex);if(error==0){++counter;pthread_mutex_unlock(&counter_mutex);}elseif(error==EBUSY){puts("锁正在使用,本次先处理其他任务");}else{fprintf(stderr,"pthread_mutex_trylock: %s\n",strerror(error));}

trylock适合“当前拿不到锁也可以做别的事情”的逻辑。不要用高频循环不停调用trylock,否则会退化成浪费 CPU 的忙等。

12. 临界区应当多大

加锁范围必须覆盖一次完整的共享状态操作,但应尽量短。

/* 推荐:只保护共享数据修改 */pthread_mutex_lock(&mutex);shared_value++;pthread_mutex_unlock(&mutex);/* 与共享状态无关的耗时工作放在锁外 */do_slow_work();

锁范围太小会漏掉需要保护的操作;范围太大则让其他线程长时间等待,降低并发效率。

不要在持锁期间随意执行长时间阻塞操作,例如等待用户输入、长时间网络访问或不受控的休眠。

13. 加锁后为什么看起来变慢

录音中加锁后,多个线程对计数器的修改被串行化,所以这部分不再并行。这个代价换来了正确、可预测的共享状态。

优化顺序应是:

  1. 先保证同步正确;
  2. 再测量真正的性能瓶颈;
  3. 缩短临界区或减少共享写入;
  4. 确有必要时,再考虑原子操作、读写锁或任务拆分。

“线程越多越快”并不成立。只做一次加一却创建一个线程,线程管理成本远大于计算本身。

14. 常见错误与后果

错误可能后果正确做法
只在部分访问位置加锁仍然存在数据竞争所有访问者遵守同一锁约定
加锁后提前return,没有解锁其他线程永久等待每条退出路径都释放锁
同一普通互斥锁重复加锁当前线程可能把自己锁死明确锁的持有状态
由未持锁线程调用unlock行为未定义或返回错误谁成功加锁,谁负责解锁
销毁仍被持有或等待中的锁行为未定义所有线程结束且锁已释放后销毁
复制pthread_mutex_t复制后的对象不可可靠使用初始化并传递原对象地址
用sleep猜测访问顺序时序仍不确定使用锁、条件变量或join
忽略 Pthreads 返回值资源失败时继续错误流程检查非零错误号并处理
一把锁包住所有耗时操作并发度下降只保护必要的共享状态

15. 死锁的最小认识

互斥锁使用错误可能造成死锁:线程永久等待无法再发生的解锁操作。

最简单的情况是同一线程对普通互斥锁连续加两次:

pthread_mutex_lock(&mutex);pthread_mutex_lock(&mutex);/* 可能永远等待自己释放 */

多个锁还可能形成循环等待:

线程 A 持有锁 1,等待锁 2 线程 B 持有锁 2,等待锁 1

入门阶段先遵守两条规则:

  • 固定多把锁的获取顺序;
  • 临界区结束后立即解锁。

16. 录音与教材表述校正

  1. 转写中的“静态条件”应为“竞态条件(race condition)”。
  2. number++在并发无同步时不是简单的“答案偶尔少一点”,而是 C 语言层面的数据竞争和未定义行为。
  3. pthread_join()负责等待与回收目标线程,不能让多个工作线程自动互斥。
  4. pthread_mutex_t的内部结构属于具体 C 库实现;应用代码只应包含<pthread.h>并调用公开 API。
  5. Pthreads 函数失败通常返回非零错误号,不应概括成“负数表示失败”。
  6. pthread_mutex_trylock()在普通的“锁已被占用”情况下返回EBUSY,不会阻塞等待。
  7. 普通互斥锁等待时通常阻塞,不像自旋锁那样持续忙等;具体调度由系统实现负责。
  8. 静态初始化解决的是初始化方式。锁是否显式销毁取决于对象生命周期和程序结构。
  9. 不能假定互斥锁释放后由哪一个等待线程先获得,调度次序不是普通互斥锁提供的业务顺序保证。

17. 本节最低掌握标准

学完后应能回答:

  1. 什么是共享资源、竞态条件和临界区?
  2. 为什么一行number++仍然可能被其他线程打断?
  3. pthread_join()为什么不能解决工作线程之间的数据竞争?
  4. pthread_mutex_lock()和pthread_mutex_trylock()有什么区别?
  5. 静态初始化与动态初始化分别怎样写?
  6. 为什么所有访问同一共享变量的代码都必须遵守同一把锁?
  7. 为什么不能销毁仍在使用的互斥锁?
  8. 为什么临界区既不能漏保护,也不宜过大?

最低实践要求:

  • 能独立写出“初始化 → 加锁 → 修改 → 解锁 → join → 销毁”的程序;
  • 能把示例中的锁去掉并解释错误原因;
  • 能用strerror(error)输出 Pthreads 错误;
  • 能识别忘记解锁、重复加锁和错误销毁三类问题。

18. PDF 页码与录音时间索引

主题PDF 页码录音时间
竞态条件定义14200:09—01:21
20000 线程累加示例143—14401:22—04:37
结果不稳定及原因14504:38—06:21
避免共享写入与加锁14506:00—06:21
互斥锁、读写锁、自旋锁概览14506:22—08:06
pthread_mutex_t与互斥锁用途14608:07—09:20
加锁、尝试加锁、解锁与销毁第 146 页主题的录音扩展09:21—12:22
使用互斥锁修正累加程序录音演示12:23—15:37

进一步核对资料:

  • POSIX Programmer’s Manual:pthread_mutex_init / destroy
  • POSIX Programmer’s Manual:pthread_mutex_lock / trylock / unlock

19. 下一步学习

掌握本节后,下一步通常学习条件变量或其他线程同步方法。继续学习前先确保自己能独立完成第 10 节的安全计数器,因为后续“等待某个条件成立”的写法通常仍要配合互斥锁使用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询