Linux 多线程互斥与同步机制解析
前面的文章,我们讲解了线程的基础知识和如何控制线程。但是线程中最重要的互斥和同步机制还没有涉及,那么本篇文章将会带领大家理解线程的互斥与同步。 在此之前,先让我们来看一段经典的多线程抢票程序吧。
Linux 多线程编程中常面临资源共享冲突问题,如抢票示例导致的票数负数现象。本文通过实例分析临界资源与临界区概念,阐述互斥锁(mutex)的作用及 pthread 库相关函数(init、destroy、lock、unlock)。同时探讨死锁产生的四个必要条件及避免策略,并引入条件变量解决线程饥饿问题,实现线程间的等待 - 通知同步机制,确保数据一致性与执行顺序。

前面的文章,我们讲解了线程的基础知识和如何控制线程。但是线程中最重要的互斥和同步机制还没有涉及,那么本篇文章将会带领大家理解线程的互斥与同步。 在此之前,先让我们来看一段经典的多线程抢票程序吧。
思路很简单,假设有 1000 张票,让 5 个线程去抢,抢到为 0 为止。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#define N 5
using namespace std;
int ticket = 1000;
void* pthreadRun(void* arg) {
char* name = static_cast<char*>(arg);
int sum = 0;
while (true) {
if (ticket > 0) {
usleep(2000);
--ticket;
cout << "线程:" << name << "抢票成功!" << "剩余:" << ticket << "票" << endl;
sum++;
} else {
break;
}
usleep(2000);
}
cout << name << "抢了" << sum << "张票" << endl;
delete[] name;
return nullptr;
}
int main() {
pthread_t pths[N];
for (int i = 1; i <= N; ++i) {
char* name = new char[64];
snprintf(name, 64, "pthread-%d", i);
pthread_create(&pths[i - 1], nullptr, pthreadRun, name);
}
for (int i = 1; i <= N; ++i) {
int ret = pthread_join(pths[i - 1], nullptr);
if (ret != 0) {
perror("线程等待失败!");
return 1;
}
}
cout << "线程全部退出完毕" << "剩余票数:" << ticket << endl;
return 0;
}
我们可以通过这个程序看到,最后居然抢到了负数的票。但是我们的程序好像没有错误啊。怎么回事呢?
在上面的抢票程序中,全局变量是 ticket,所以它也是线程的共享资源。
如果我们想要对 ticket 进行修改,需要几步?
答案是 3 步:将 ticket 从内存拷贝到寄存器中;在 CPU 内完成计算;从寄存器中转移到内存。
这是单线程情况下,改变一个值需要 3 步,其实好像也没什么吧,比较计算机的速度非常快,用户根本也感受不到。
但是如果我们变成多线程呢?
虽然改变一个值的步骤仍然不变,但是多线程对共享资源的处理是存在竞争的现象的。
我们假设,有两个线程分别为:thread1 和 thread2。
在某个时刻,thread1 准备对 ticket 进行修改,当 ticket 通过内存拷贝到寄存器时,thread2 出现把 ticket 直接切走,导致原来的操作滞后。
大部分情况下这种事件都不会发生,因为 CPU 的计算速度超级快,会执行完全部操作的。但是在上面的抢票程序中这种还是发生了。
这是由于休眠操作导致了,这里我们仅仅分析当 ticket=1 时的这瞬间。
当 ticket=1 时,满足循环中的 if 条件(ticket>0)。假设此时是线程 thread-1 在执行该操作,进入 if 语句后,执行休眠。但是 CPU 可以不会开始休眠,它会马上运行下一个线程,假设此时 CPU 选择的是 thread-2,那么又因为 ticket 的值还没有修改,导致 ticket 还是等于 1,那么 thread-2 满足了 if 条件,其他线程同理,过了一段时间 thread-1 醒了,开始 ticket-- 操作,其他线程后续醒了也会执行 ticket-- 操作,导致最后的票被抢成负数了。
那是不是只要把 usleep 给去掉就不会出现负数情况了,也不是,只是概率会很低。
正确的做法是加锁。
在多线程的场景中,对于像前文中的 ticket 这种可以被多线程看到的同一份资源称为临界资源,涉及对临界资源进行操作的上下文代码区域称为临界区。
int ticket = 1000; // 临界资源
void* pthreadRun(void* arg) {
char* name = static_cast<char*>(arg);
int sum = 0;
while (true) {
// 临界区开始
if (ticket > 0) {
usleep(2000);
--ticket;
cout << "线程:" << name << "抢票成功!" << "剩余:" << ticket << "票" << endl;
sum++;
} else {
break;
}
// 临界区结束
usleep(2000);
}
cout << name << "抢了" << sum << "张票" << endl;
delete[] name;
return nullptr;
}
临界资源的本质就是多线程共享资源,而临界区为涉及共享资源操作的代码区间。
如果我们想要安全的访问临界资源,就必须确保临界资源在使用时的安全性,也就是有 锁。
用生活中的例子来说就是:公共厕所,众所周知公共厕所是公共资源,所有人都可以使用,但是你也不想你在使用的时候被人打扰吧,所以公共厕所的卫生间都是有门的而且都有锁。
对于临界资源也是如此,为了访问时的安全,可以通过加锁来实现。实现多线程间的互斥访问、互斥锁是解决多线程并发访问问题的手段之一。
具体操作就是:在进入临界区之前加锁,出临界区之后解锁。
还是以前面的抢票程序为例。
假设此时正在执行的线程为 thread-1,当它在访问 ticket 时如果进行了加锁,在 thread-1 被切走了后,假设此时进入的线程为 thread-2,thread-2 无法对 ticket 进行操作,因为此时锁被 thread-1 持有,thread-2 只能堵塞式等待锁,直到 thread-1 解锁。
因此,对于 thread-1 来说,在加锁环境中,只要接手了访问临界资源 ticket 的任务,要么完成,要么不完成,不会出现中间状态,像这种不会出现了中间状态】结果可预期的特性称为 原子性。
也就是说,加锁的本质是为了实现 原子性。
在加锁的同时,我们还需要注意以下几点:
加锁、解锁是比较耗费系统资源的,会在一定程序上降低程序的运行速度。加锁后的代码是串行执行的,势必会影响多线程场景中的运行速度。为了尽可能降低影响,加锁粒度要尽可能地细。
上面的内容都是为了引出下面线程互斥与同步的操作。
线程互斥(Thread Mutual Exclusion)是多线程编程中为避免多个线程同时访问共享资源而导致数据不一致的问题。通过线程互斥机制,可以确保在任意时刻,只有一个线程可以访问临界区(Critical Section),从而保证共享数据的完整性和一致性。 正如我们上面讲的那样,总结下来就是两个原因使得我们需要线程互斥。
x = x + 1 实际上是三步操作:读取、加一、写回。如果没有互斥,多个线程同时执行会导致结果不正确。那么在 Linux,我们要怎么做到线程互斥呢?
加锁。
提供一个锁机制,在临界区时上锁,离开时解锁。
Linux 下的原生线程库,提供了类型为 pthread_mutex_t 的互斥锁,互斥锁需要进行初始化和销毁。
pthread_mutex_init 函数在 Linux 多线程编程中,互斥锁是用来保护共享资源,防止多个线程同时访问同一个资源而导致数据竞争的问题。pthread_mutex_init 函数用于初始化一个互斥锁(mutex)。
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数说明
pthread_mutex_t *mutexconst pthread_mutexattr_t *attrNULL,则使用默认属性。返回值
EAGAIN:系统资源不足,无法初始化互斥锁。ENOMEM:内存不足。EINVAL:传递了无效参数。pthread_mutex_destroy 函数pthread_mutex_destroy 用于销毁已经初始化的互斥锁对象。它释放互斥锁占用的系统资源。在多线程程序中,互斥锁在使用结束后必须销毁,否则可能导致资源泄漏。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明
pthread_mutex_t *mutex返回值
EBUSY:互斥锁当前被其他线程锁定,无法销毁。EINVAL:传递的互斥锁无效或未初始化。下面来看这两个函数在程序中的生态位。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int main() {
pthread_mutex_t mtx; // 定义互斥锁
pthread_mutex_init(&mtx, nullptr); // 初始化互斥锁
// ...
pthread_mutex_destroy(&mtx); // 销毁互斥锁
return 0;
}
注意:
互斥锁是一种资源,因此 [初始化互斥锁] 操作应该在线程之前完成,[销毁互斥锁] 操作应该在线程运行结束后执行;总结就是使用前创建,使用后销毁。对于多线程来说,应该让他们看到同一把锁,否则无意义。不能重复销毁互斥锁。已经销毁的互斥锁无法使用。
知识补充
我们在使用 pthread_mutex_init 初始化互斥锁的方式称为动态分配,需要手动初始化与销毁,除此之外还存在静态分配,也就是在定义互斥锁时初始化为 PTHREAD_MUTEX_INITIALIZER
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
静态分配的优点在于无需手动初始化和销毁,锁的生命周期伴随程序,缺点就是定义的互斥锁一定是全局互斥锁。
当我定义完锁以及初始化后就可以开始加锁操作了。
互斥锁的加锁和解锁操作主要由 pthread_mutex_lock 和 pthread_mutex_unlock 来完成。
pthread_mutex_lock 函数加锁一个互斥锁对象。如果互斥锁已经被其他线程锁住,调用的线程会阻塞,直到该锁可用。
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数
pthread_mutex_t *mutex:指向需要加锁的互斥锁对象。返回值
EINVAL:传递的锁无效或未初始化。EDEADLK:发生死锁(调用线程已经持有该锁,或者死锁检测机制发现潜在问题)。pthread_mutex_unlock 函数解锁一个互斥锁对象。如果有其他线程因等待该锁而阻塞,解锁后会唤醒一个阻塞的线程。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数
pthread_mutex_t *mutex:指向需要解锁的互斥锁对象。返回值
EINVAL:传递的锁无效或未初始化。EPERM:当前线程不是锁的拥有者。理解锁后,我们就可以重新书写原来的抢票代码了。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#define N 5
using namespace std;
int ticket = 10000;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void* pthreadRun(void* arg) {
char* name = static_cast<char*>(arg);
int sum = 0;
while (true) {
pthread_mutex_lock(&mtx);
if (ticket > 0) {
usleep(2000);
--ticket;
cout << "线程:" << name << "抢票成功!" << "剩余:" << ticket << "票" << endl;
sum++;
pthread_mutex_unlock(&mtx);
} else {
pthread_mutex_unlock(&mtx);
break;
}
}
cout << name << "抢了" << sum << "张票" << endl;
delete[] name;
return nullptr;
}
int main() {
pthread_t pths[N];
for (int i = 1; i <= N; ++i) {
char* name = new char[64];
snprintf(name, 64, "pthread-%d", i);
pthread_create(&pths[i - 1], nullptr, pthreadRun, name);
}
for (int i = 1; i <= N; ++i) {
int ret = pthread_join(pths[i - 1], nullptr);
if (ret != 0) {
perror("线程等待失败!");
return 1;
}
}
cout << "线程全部退出完毕" << "剩余票数:" << ticket << endl;
return 0;
}
上面是静态版本,下面我们优化下代码,写一个动态版本。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
const int n = 5;
int ticket = 1000;
class ThreadData {
public:
ThreadData(string name, pthread_mutex_t& mtx): _name(name), _mtx(mtx) {}
~ThreadData() {}
public:
string _name;
pthread_mutex_t& _mtx;
};
void* threadRun(void* arg) {
ThreadData* td = static_cast<ThreadData*>(arg);
int sum = 0;
while (true) {
pthread_mutex_lock(&td->_mtx);
if (ticket > 0) {
usleep(1000);
ticket--;
cout << "线程:" << td->_name << "抢票成功" << "剩余:" << ticket << "张票" << endl;
sum++;
pthread_mutex_unlock(&td->_mtx);
} else {
pthread_mutex_unlock(&td->_mtx);
break;
}
usleep(1000);
}
cout << "线程:" << td->_name << "抢票数量:" << sum << endl;
delete td;
return nullptr;
}
int main() {
pthread_t pts[n];
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);
for (int i = 0; i < n; ++i) {
char* name = new char[64];
snprintf(name, 64, "Thread-%d", i);
ThreadData* td = new ThreadData(name, mtx);
pthread_create(pts + i, nullptr, threadRun, td);
}
for (int i = 0; i < n; ++i) {
int ret = pthread_join(pts[i], nullptr);
if (ret != 0) {
perror("线程回收失败");
return 1;
}
}
pthread_mutex_destroy(&mtx);
cout << "所有线程回收完毕,剩余票数为:" << ticket << endl;
return 0;
}
无论运行多少次,最终的剩余票数都是 0,并且所有线程抢到的票数之和为 1000.
注意:
锁是临界资源
那岂不是还要给锁也搞一个锁?那就无限递归下去了。
虽然锁是临界资源,但是锁是原子的。
锁的设计者在设计锁时就已经考虑到这个问题了,对于锁这个临界资源进行了特殊化的处理:加锁和解锁的操作都是原子的,不存在中间状态,也就不需要保护了。
死锁是指在多线程或多进程环境下,多个线程或进程因争夺资源而相互等待,导致它们都无法继续执行的一种状态。 造成死锁的 4 个必要条件。
演示:
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
pthread_mutex_t lock1;
pthread_mutex_t lock2;
void* thread1(void* arg) {
pthread_mutex_lock(&lock1);
printf("Thread 1: locked lock1\n");
sleep(1); // 模拟处理时间
pthread_mutex_lock(&lock2);
printf("Thread 1: locked lock2\n");
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
return NULL;
}
void* thread2(void* arg) {
pthread_mutex_lock(&lock2);
printf("Thread 2: locked lock2\n");
sleep(1); // 模拟处理时间
pthread_mutex_lock(&lock1);
printf("Thread 2: locked lock1\n");
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_mutex_init(&lock1, NULL);
pthread_mutex_init(&lock2, NULL);
pthread_create(&t1, NULL, thread1, NULL);
pthread_create(&t2, NULL, thread2, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&lock1);
pthread_mutex_destroy(&lock2);
return 0;
}
lock1,然后尝试锁定 lock2。lock2,然后尝试锁定 lock1。lock1 和 lock2 后同时等待对方释放锁,就会发生死锁。避免死锁的方法
lock1,再锁定 lock2。void* thread1(void* arg) {
pthread_mutex_lock(&lock1);
printf("Thread 1: locked lock1\n");
pthread_mutex_lock(&lock2);
printf("Thread 1: locked lock2\n");
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
return NULL;
}
void* thread2(void* arg) {
pthread_mutex_lock(&lock1);
printf("Thread 2: locked lock1\n");
pthread_mutex_lock(&lock2);
printf("Thread 2: locked lock2\n");
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
return NULL;
}
线程同步是多线程编程中用于协调线程间访问共享资源的技术,目的是避免因竞争条件(Race Conditions)导致的数据不一致或程序异常。通过线程同步,能够保证多线程在正确的顺序下安全地访问共享数据。
饥饿(Starvation)问题是计算机系统中资源管理的常见问题之一,发生在某些线程或进程长期无法获得所需的资源,导致其无法执行或严重延迟。 如果我们仅仅只是加锁,假设此时锁由线程 1 占有,一段时间后,线程 1 打算解锁,解锁后线程 1 仍然是最容易拿到锁的一个线程,因为距离锁是最近的,那么它就可以一直拿,拿完放,放完拿。那么其他线程不就拿不到了吗,这就导致了饥饿问题。为了避免这种问题,便引入了同步机制,当一个线程解锁后不能马上再拿锁,必须到后面排队。
这是我前面写的代码,这段代码在运行过程中可能其他线程根本抢不到票。
条件变量(Condition Variable)是一种线程同步机制,通常与互斥锁(Mutex)一起使用,提供了一种线程间的等待 - 通知机制。通过条件变量,线程可以在等待某个条件满足时释放锁,并进入等待状态;条件满足后,另一个线程可以通知它继续执行。
条件变量的本质就是 衡量访问资源的状态
作为出自 原生线程库 的 条件变量,使用接口与 互斥锁 风格差不多,比如 条件变量 的类型为 pthread_cond_t,同样在创建后需要初始化
pthread_cond_init 函数pthread_cond_init 函数用于初始化一个条件变量,使其可以在多线程同步中被使用。
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
参数说明
cond
pthread_cond_t 类型的变量。attr
NULL,表示使用默认属性。pthread_condattr_t 设置。返回值
pthread_cond_destroy 函数pthread_cond_destroy 函数用于销毁条件变量,释放与其相关的资源。
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明
cond
返回值
注意事项
pthread_cond_wait 函数pthread_cond_wait 用于阻塞当前线程,直到接收到其他线程发送的信号。此函数需要与互斥锁一起使用,确保在等待条件时线程的操作是线程安全的。
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
参数说明
cond
mutex
返回值
关键点
pthread_cond_wait 时,线程会自动释放与之关联的互斥锁。pthread_cond_wait 通常与循环配合使用以检查实际条件是否满足。pthread_cond_signal 函数pthread_cond_signal 用于唤醒一个等待在条件变量上的线程。如果有多个线程在等待,唤醒其中一个线程。
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
参数说明
cond
返回值
关键点
注意:同步机制也支持全局条件变量,允许自动初始化、自动销毁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
using namespace std;
const int N = 5;
// 定义全局的,自动初始化和释放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* pthreadRun(void* arg) {
char* name = static_cast<char*>(arg);
int sum = 0;
while (true) {
pthread_mutex_lock(&mtx);
if (ticket > 0) {
// 等待条件满足
pthread_cond_wait(&cond, &mtx);
// usleep(2000);
// --ticket;
// cout << "线程:" << name << "抢票成功!" << "剩余:" << ticket << "票" << endl;
cout << "线程:" << name << "正在抢票" << endl;
sum++;
pthread_mutex_unlock(&mtx);
} else {
pthread_mutex_unlock(&mtx);
break;
}
}
cout << name << "抢了" << sum << "张票" << endl;
delete[] name;
return nullptr;
}
int main() {
pthread_t pths[N];
for (int i = 1; i <= N; ++i) {
char* name = new char[64];
snprintf(name, 64, "pthread-%d", i);
pthread_create(&pths[i - 1], nullptr, pthreadRun, name);
}
// 等待所有次线程就位
sleep(1);
// 主线程唤醒子线程
while (true) {
cout << "主线程正在唤起子线程!" << endl;
pthread_cond_signal(&cond); // 单个唤醒
sleep(1);
}
for (int i = 1; i <= N; ++i) {
int ret = pthread_join(pths[i - 1], nullptr);
if (ret != 0) {
perror("线程等待失败!");
return 1;
}
}
cout << "线程全部退出完毕" << "剩余票数:" << ticket << endl;
return 0;
}
可以看到,子线程正在以一种既定的顺序执行,这就是同步的作用。
在多线程编程中,线程同步与异步是两个核心概念,它们在保障程序稳定性和提升性能方面各司其职。同步通过协调线程间的执行顺序,避免了资源竞争和数据不一致的问题;而异步则通过允许线程独立执行任务,提升了系统的响应效率和并行能力。两者在不同场景下有着独特的应用价值,但也可能引发死锁或线程饥饿等问题。通过合理运用锁机制、条件变量等工具,我们可以在同步和异步之间找到平衡,为程序的稳定性和效率提供保障。掌握这些技巧,不仅是提升编程能力的关键,也是构建高效、健壮系统的基础。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online