前面的文章,我们讲解了线程的基础知识和如何控制线程。但是线程中最重要的互斥和同步机制还没有涉及,那么本篇文章将会带领大家理解线程的互斥与同步。 在此之前,先让我们来看一段经典的多线程抢票程序吧。
思路很简单,假设有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;
}
/*
截取最后的打印结果:
......
线程:pthread-3抢票成功!剩余:0票
线程:pthread-2抢票成功!剩余:-1票
线程:pthread-4抢票成功!剩余:-2票
pthread-1抢了201张票
pthread-5抢了201张票
pthread-3抢了201张票
pthread-2抢了201张票
pthread-4抢了201张票
线程全部退出完毕剩余票数:-2
*/
我们可以通过这个程序看到,最后居然抢到了负数的票。但是我们的程序好像没有错误啊。怎么回事呢?
在上面的抢票程序中,全局变量是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
只能堵塞式等待锁,直到thraed-1
解锁。
因此,对于thread-1
来说,在加锁环境中,只要接手了访问临界资源ticket
的任务,要么完成,要么不完成,不会出现中间状态,像这种不会出现了中间状态】结果可预期的特性称为原子性
。
也就是说,加锁的本质是为了实现原子性
。
在加锁的同时,我们还需要注意以下几点:
上面的内容都是为了引出下面线程互斥与同步的操作。
线程互斥(Thread Mutual Exclusion)是多线程编程中为避免多个线程同时访问共享资源而导致数据不一致的问题。通过线程互斥机制,可以确保在任意时刻,只有一个线程可以访问临界区(Critical Section),从而保证共享数据的完整性和一致性。 正如我们上面讲的那样,总结下来就是两个原因使得我们需要线程互斥。
x = x + 1
实际上是三步操作:读取、加一、写回。如果没有互斥,多个线程同时执行会导致结果不正确。
那么在Linux,我们要怎么做到线程互斥呢?加锁。
提供一个锁机制,在临界区时上锁,离开时解锁。
Linux下的原生线程库,提供了类型为pthread_mutex_t
的互斥锁,互斥锁需要进行初始化和销毁。
pthread_mute_init
函数在 Linux 多线程编程中,互斥锁是用来保护共享资源,防止多个线程同时访问同一个资源而导致数据竞争的问题。pthread_mutex_init
函数用于初始化一个互斥锁(mutex)。
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数说明
pthread_mutex_t *mutex
指向一个互斥锁对象的指针。需要在使用前分配内存空间,通常定义为全局或堆内存变量。const pthread_mutexattr_t *attr
指向一个互斥锁属性对象的指针,用于设置互斥锁的行为。如果为 NULL
,则使用默认属性。
返回值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_ulock
来完成。
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 ThraedData{
public:
ThraedData(string name,pthread_mutex_t& mtx)
:_name(name),_mtx(mtx)
{}
~ThraedData()
{}
public:
string _name;
pthread_mutex_t& _mtx;
};
void* threadRun(void* arg){
ThraedData* td = static_cast<ThraedData*>(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);
ThraedData* td = new ThraedData(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
后同时等待对方释放锁,就会发生死锁。
避免死锁的方法
1.避免循环等待
通过为资源编号,线程按固定顺序请求资源。例如,始终先锁定 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;
}
2. 避免长时间持有锁
线程同步是多线程编程中用于协调线程间访问共享资源的技术,目的是避免因竞争条件(Race Conditions)导致的数据不一致或程序异常。通过线程同步,能够保证多线程在正确的顺序下安全地访问共享数据。
饥饿(Starvation)问题是计算机系统中资源管理的常见问题之一,发生在某些线程或进程长期无法获得所需的资源,导致其无法执行或严重延迟。 如果我们仅仅只是加锁,假设此时锁由线程1占有,一段时间后,线程1打算解锁,解锁后线程1仍然是最容易拿到锁的一个线程,因为距离锁是最近的,那么它就可以一直拿,拿完放,放完拿。那么其他线程不就拿不到了吗,这就导致了饥饿问题。为了避免这种问题,便引入了同步机制,当一个线程解锁后不能马上再拿锁,必须到后面排队。
#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;
}
这是我前面写的代码,这段代码在运行过程中可能其他线程根本抢不到票。
条件变量(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;
}
可以看到,子线程正在以一种既定的顺序执行,这就是同步的作用。
在多线程编程中,线程同步与异步是两个核心概念,它们在保障程序稳定性和提升性能方面各司其职。同步通过协调线程间的执行顺序,避免了资源竞争和数据不一致的问题;而异步则通过允许线程独立执行任务,提升了系统的响应效率和并行能力。两者在不同场景下有着独特的应用价值,但也可能引发死锁或线程饥饿等问题。通过合理运用锁机制、条件变量等工具,我们可以在同步和异步之间找到平衡,为程序的稳定性和效率提供保障。掌握这些技巧,不仅是提升编程能力的关键,也是构建高效、健壮系统的基础。