Linux系列
前言
在前两篇文章中,我们已经对线程的相关概念及基本操作进行了深入介绍。在本篇中,我们将深入探讨编写多线程程序时非常重要的一个环节——保证线程的同步互斥,以此确保程序执行结果的正确性。
一、前提知识
在正式介绍之前,我们需要对前面提到的知识进行简单回顾:
以下概念,在我们介绍示例时,会再次点出
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成(后面会结合实现分析)。
- 并发:多个任务在重叠的时间内交替执行(线程切换),但不一定是同时的。
- 竞态条件:是并发编程和多线程编程环境中因执行顺序的不确定性导致程序结果不可预测的一种缺陷。当多个执行流未经合理同步的访问同一共享资源时,其执行结果可能因调度顺序不同而产生影响(后面结合示例分析)。
二、线程互斥概念引入
接下来,我们将通过编写程序模拟"黄牛"抢购演唱会门票的场景。这个程序不仅生动还原了多线程环境下的竞争状态,更直观展现出线程同步互斥问题的核心矛盾与解决需求,请认真分析:
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<vector>
#include<stdio.h>
#include<cstring>
using namespace std;
#define NUM 3
int ticket =100;//模拟100张票
class threadDate{//用来记录线程名,方便对结果观察
public:
threadDate(int num)
{
str="thread "+to_string(num);
}
string str;
};
void* getTicket(void*args)//用来模拟抢票
{
threadDate*ptr=(threadDate*)args;
const char*name=(const char*)ptr->str.c_str();
while(true)
{
if(ticket>0)//票数为0,停止抢购
{
usleep(50000);
printf("who=%s, get a ticket: %d\n", name, ticket);//抢到后输出票号及个人信息
ticket--;//票数减一
}
else break;
}
return nullptr;
}
int main()
{
vector<pthread_t> vtid;
vector<threadDate*> thread_date;
for(int i=0;i<NUM;i++)//创建三个线程(黄牛)
{
threadDate*tdate=new threadDate(i);
thread_date.push_back(tdate);
pthread_t tid;
pthread_create(&tid,nullptr,getTicket,thread_date[i]);
vtid.push_back(tid);
}
for(auto x:vtid)//等待线程
{
pthread_join(x,nullptr);
}
for(auto x:thread_date)//释放申请空间
{
delete x;
}
cout<<"main quit...."<<endl;
return 0;
}
观察程序的抢票结果会发现一个异常现象:当剩余票数已经为 0
时,程序并未按照预期停止抢票,甚至出现了 -1
这样不合理的票号。这种情况的根源在于多线程的并发特性:由于线程的执行顺序具有不确定性,当某个线程执行到 if
条件判断并确认剩余票数大于 0
后,在执行后续票数扣减操作前,CPU 调度可能将其暂停转而执行其他线程。
假设此时剩余票数为 1
,新调度的线程通过 if
条件检查后进入抢票逻辑,将票数减为 0
并完成操作。当先前被暂停的线程重新获得调度权时,它会从暂停处继续执行,由于其在被暂停前已经通过 if
条件判断,因此会继续执行扣减操作,最终导致抢到的票号变为 0
甚至出现负数,所以违背了我们的预期。
这里补充一个关键知识点:上述异常结果频发,与usleep
系统调用密切相关。在Linux系统中,当进程因usleep
调用从用户态切换至内核态,再返回用户态时,操作系统会触发时间片检查机制。一旦当前执行流的时间片耗尽,操作系统便会立即执行上下文切换,将CPU资源分配给其他就绪线程。这一机制使得多线程并发执行时,线程执行顺序充满不确定性,极大增加了数据竞争与执行逻辑混乱的风险,从而导致如抢票程序中出现的异常情况,其根本原因就是因为该操作不是原子的。
上述程序还存在一个不易触发的潜在问题,即在类似执行逻辑中对共享资源进行--/++
操作,都可能导致程序执行逻辑错误,接下来我们将结合硬件进行详细分析:
这里以 --
操作为例进行详细说明。当程序被编译成可执行程序时,--
语句会被转化为三条汇编指令,这意味着它在 CPU 中需要分三个步骤来执行。例如对于 ticket--
操作,其对应的汇编指令依次为:1. mov [xxx] eax
,该指令的作用是将内存中存储 ticket
值的位置内容读取并拷贝到寄存器 eax
中;2. --
,这一步在寄存器 eax
中对数值执行减一操作;3. mov eax [xxx]
,此指令负责将寄存器 eax
中经过减一处理后的结果重新写回到内存中存储 ticket
的位置。通过这样的过程,完成了对共享资源 ticket
的减一操作,而在这个过程在,执行流又随时都又被切换的可能,这就造成了下面的问题:
从上面的执行逻辑很容易看出,程序的执行,存在潜在风险。
为解决上述两个核心问题,必须确保线程在访问临界资源时满足严格的互斥条件。只有当线程对临界资源的访问具备互斥性,才能避免因并发访问导致的数据竞争与逻辑错误,从而保证多线程程序执行结果的准确性与可靠性。
三、线程互斥
3.1 什么是线程的互斥
线程互斥是一种重要的并发控制机制,其核心在于同一时刻仅允许单个线程访问共享资源。借助互斥机制,当某个线程正在对共享内存进行读写操作时,其他线程会被暂时阻止访问同一资源。这一约束能够有效规避竞态条件——即多个线程同时访问和修改共享数据可能引发的不可预测行为,从而确保数据的一致性和程序执行的正确性。
3.2 线程互斥的实现
在多线程编程的场景中,为了实现线程互斥,通常会采用互斥量这一重要工具。具体而言,我们会在每个线程进入临界区(也就是访问共享资源的代码段)的入口处,借助互斥量来设置锁机制。当一个线程尝试进入临界区时,它会尝试获取互斥量对应的锁。一旦某个线程成功获取到锁,就意味着它获得了在该时刻访问共享资源的唯一权限。此时,其他试图进入同一临界区的线程会被阻塞,它们必须等待持有锁的线程完成对共享资源的访问,并释放互斥量对应的锁之后,才有机会去竞争获取锁并进入临界区。通过这种方式,互斥量确保了共享资源在同一时刻只能被一个线程安全访问,有效避免了因多线程并发访问共享资源而引发的数据竞争和不一致问题。
互斥量本质上是一个特殊的变量,它有两种状态:锁定(Locked)和解锁(Unlocked)。在多线程环境中,当多个线程需要访问同一共享资源时,互斥量就可以发挥作用,保证在同一时刻只有一个线程能够访问该资源,从而避免数据竞争和不一致问题。
接口函数
动态初始化互斥量:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
功能:用于动态初始化互斥量
参数
- mutex:这是一个指向 pthread_mutex_t 类型的指针,该指针指向需要被初始化的互斥量对象。pthread_mutex_t 是互斥量的数据类型,代表一个互斥锁。
- attr:这是一个指向 pthread_mutexattr_t 类型的指针,该指针指向互斥量的属性对象。如果将其设置为 NULL,则表示使用默认的互斥量属性。
- restrict 是 C 语言里的一个类型限定符,它的作用是向编译器提供一种优化提示,告知编译器某个指针是访问其所指向对象的唯一途径
在使用该函数之前首先要在定义一个互斥量,动态初始化的形式可以指定互斥的的属性。
静态初始化互斥量:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
使用 PTHREAD_MUTEX_INITIALIZER
宏,这是一种简单直接的方式,适用于不需要自定义属性,使用默认属性的情况。这个宏它会将互斥量初始化为默认属性状态。在编译时,这个宏会被替换成一个合适的初始值,使得互斥量处于可用的初始状态。
加锁
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
功能:用于对互斥量进行加锁操作,以实现线程间对共享资源的互斥访问。
参数
- mutex:这是一个指向 pthread_mutex_t 类型的指针,代表要进行加锁操作的互斥量对象。在调用此函数之前,该互斥量必须已经被正确初始化
返回值
- 成功:若函数调用成功,会返回 0,表示当前线程已经成功获取到互斥量的锁,此时该线程可以访问被该互斥量保护的共享资源。若互斥量已经被其他线程锁定,当前线程会被阻塞,进入等待状态。(阻塞是正常现象,并不代表函数执行失败)
- 失败:若函数调用失败,会返回一错误码。
解锁
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
功能:用于释放互斥量锁
参数
- mutex:这是一个指向 pthread_mutex_t 类型的指针,代表要进行解锁操作的互斥量对象。在调用此函数之前,该互斥量必须已经被正确初始化,并且当前线程必须已经成功获取到该互斥量的锁。
返回值
- 成功:若函数调用成功,会返回 0,表示当前线程已经成功释放了互斥量的锁,此时其他等待该锁的线程有机会获取锁并进入临界区。
- 失败:若函数调用失败,会返回错误码。
销毁锁
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
功能:用于销毁已经初始化的互斥量
参数
-mutex:这是一个指向 pthread_mutex_t 类型的指针,代表要进行销毁操作的互斥量对象。在调用此函数之前,该互斥量必须已经被正确初始化。
返回值
- 成功:若函数调用成功,会返回 0,表示互斥量已被成功销毁,与之关联的系统资源已被释放。
- 失败:若函数调用失败,会返回错误码。
注意:在销毁互斥量之前,必须确保没有任何线程正在持有该互斥量的锁,也没有线程正在等待获取该互斥量的锁。否则会发生未定义错误。
下面我们就使用互斥量对我们的抢票程序进行加锁:
#define NUM 3
int ticket =100;
pthread_mutex_t mutex;//定义一个互斥量
class threadDate{//用来记录线程名,方便对结果观察
public:
threadDate(int num)
{
str="thread "+to_string(num);
}
string str;
};
void* getTicket(void*args)//用来模拟抢票
{
threadDate*ptr=(threadDate*)args;
const char*name=(const char*)ptr->str.c_str();
while(true)
{
pthread_mutex_lock(&mutex);//对互斥量加锁(申请互斥锁)
if(ticket>0)
{
usleep(50000);
printf("who=%s, get a ticket: %d\n", name, ticket);//抢到后输出票号及个人信息
ticket--;//票数减一
pthread_mutex_unlock(&mutex);//对互斥量解锁
}
else
{
pthread_mutex_unlock(&mutex);//对互斥量解锁
break;
}
}
return nullptr;
}
int main()
{
vector<pthread_t> vtid;
vector<threadDate*> thread_date;
pthread_mutex_init(&mutex,nullptr);//初始化互斥量,属性默认
for(int i=0;i<NUM;i++)//创建三个线程(黄牛)
{
threadDate*tdate=new threadDate(i);
thread_date.push_back(tdate);
pthread_t tid;
pthread_create(&tid,nullptr,getTicket,thread_date[i]);
vtid.push_back(tid);
}
for(auto x:vtid)//等待线程
{
pthread_join(x,nullptr);
}
for(auto x:thread_date)//释放申请空间
{
delete x;
}
cout<<"main quit...."<<endl;
return 0;
}
从程序的执行结果能够清晰地看到,竞态条件已然得到妥善解决。然而,令人疑惑的是,所有的票似乎都被一号线程抢购一空,这背后究竟隐藏着怎样的缘由呢?
这种现象,就是因为锁分配不够合理,导致其他线程得不到执行,我们称这种现象为线程的饥饿,具体原因也很好解释:在多线程的抢票场景中,当线程 1 成功申请到锁后,便获得了进入临界区的权限。此时,其他线程由于锁已被占用,会被无情地阻塞(挂起),只能无奈地处于等待状态。
当线程 1 完成了抢票逻辑,并释放了手中的锁,它会毫不犹豫地再次进入循环,发起锁的申请。而那些被阻塞的线程,要想重新参与竞争,首先得经历一个状态转变的过程——从阻塞恢复为运行状态。这个转变并非一蹴而就,需要耗费较长的时间,这也就造成了线程对锁的竞争能力不同。
就在其他线程还在努力完成状态转变,尚未能开展申请锁的操作时,反应迅速的线程 1 很可能已经再次成功获取到锁,从而又一次堂而皇之地进入临界区。如此循环往复,其他线程始终难以获得进入临界区的机会,进而导致了饥饿问题的出现,使得它们在抢票过程中几乎毫无收获。
要解决这个问题我们就需要让所有的线程获取锁,是按照一定顺序的,可以通过让抢票执行后休眠一段时间,让其他线程有足够的时间完成由阻塞状态转为运行状态,从而公平的竞争锁。按照一定的顺序性获取资源-----同步!!!
void* getTicket(void*args)//用来模拟抢票
{
threadDate*ptr=(threadDate*)args;
const char*name=(const char*)ptr->str.c_str();
while(true)
{
pthread_mutex_lock(&mutex);//对互斥量加锁(申请互斥锁)
if(ticket>0)
{
usleep(50000);
printf("who=%s, get a ticket: %d\n", name, ticket);//抢到后输出票号及个人信息
ticket--;//票数减一
pthread_mutex_unlock(&mutex);//对互斥量解锁
}
else
{
pthread_mutex_unlock(&mutex);//对互斥量解锁
break;
}
usleep(100);//执行完抢票逻辑,休眠100微秒
}
return nullptr;
}
从程序的执行结果能够清晰地看到,线程饥饿已然得到了解决。
锁本身就是共享资源,而锁又需要保护临界资源,这也就要求锁的申请和释放必须是原子性的,这是如何做到的呢?下面我们就来感性的理解一下锁定实现原理。
四、锁的实现原理
我们常常提及的原子性这一概念,其实可以进行一个相对通俗的理解:通常而言,一条汇编语句所代表的操作具备原子性。也就是说,该操作在执行过程中是不可分割的,要么完整地执行完毕,要么完全不执行,不会出现执行到一半被中断的情况。
我们申请锁的代码,会被编译为:
当然该执行逻辑在执行过程中依然有被切换的情况,但由于线程上下文数据的保存机制,所以该逻辑即使被切换依然是原子性的,大家结合我之前的分析逻辑,自行分析吧,这里就不演示了。
在执行解锁操作时,直接修改互斥量即可,这种方式可以让我们使用其他的线程对该锁进行释放,让我们编写的多线程代码更具灵活性。
本篇就介绍到这里了,在使用线程锁时,依然会面临许多问题,这些问题我就方在下篇介绍了,希望本篇对你有所帮助。