为了了解线程并发访问问题,我们通过一个示例来理解。首先,我们要知道计算机完成对一个内存中的数据的减法操作对应的三条汇编指令,第一步是将内存中的数据加载到CPU的寄存器中,第二步是将寄存器中的数据进行减法操作,第三步是将计算后的数据写回到内存中。
存在以下场景,两个线程都要对同一个数据进行操作,两个线程看到的同一个地址空间,当第一个线程将该数据加载到CPU中,并完成计算要写回的时候,由于操作系统的调度策略,切换成了第二个线程,第二个线程也做了将该数据加载到CPU中,并完成计算,并且成功写回,此时又轮到第一个线程被调度了,第一个线程会继续执行之前未完成的操作,将数据写回。由于第一个线程对数据的计算没有写回内存,第二个线程操作前的数据和第一个线程操作前的数据是一样的,因此第一个线程回写时会将第二个线程的操作覆盖,造成并发访问问题。
以上场景就是由于并发访问导致的多个线程的数据不一致的问题。
提出如下概念:
编写如下代码模拟抢票操作产生的并发访问问题:
#include
#include
#include
using namespace std;
int tickets = 10000;//模拟共享资源
void *threadRoutine(void *args)
{
char *name = static_cast<char*>(args);
while(true)
{
if(tickets > 0)
{
usleep(1000);//模拟抢票操作
cout << name << " get a ticket: " << tickets-- << endl;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t tids[4];
int n = sizeof(tids)/sizeof(tids[0]);
for (int i = 0; i < n; i++)//创建4个线程
{
char* tname = new char[64];
snprintf(tname, 64, "thread->%d", i + 1);
pthread_create(tids + i, nullptr, threadRoutine, tname);
}
for (int i = 0; i < n; i++)//回收线程
{
pthread_join(tids[i], nullptr);
}
return 0;
}
编译代码运行并查看结果:
由于在执行判断逻辑时,多个线程都成功进入了临界区,当其中一个线程将票数改为0时,其他线程已经在执行临界区代码无法终止,导致最终票数为负数。
线程互斥是指在多线程编程中,通过使用某种机制来保护共享资源,以确保在任意时刻只有一个线程能够访问或修改共享资源。
锁是Linux操作系统原生线程库中提供的pthread_mutex_t
数据类型,通过对锁的使用能够完成线程的互斥控制。
锁的特性: 一把锁只能同时被一个线程使用。
Linux操作系统下提供了pthread_mutex_init
函数用于初始化锁。
//pthread_mutex_init所在的头文件和函数声明
#include
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
pthread_mutex_init
函数用于初始化。pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
的方式进行初始化。Linux操作系统下提供了pthread_mutex_destroy
函数用于销毁锁。
//pthread_mutex_destroy所在的头文件和函数声明
#include
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_destroy
函数用于局部变量锁的销毁。Linux操作系统下提供了pthread_mutex_lock
函数用于申请锁。
//pthread_mutex_lock所在的头文件和函数声明
#include
int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_lock
函数用于申请锁。Linux操作系统下提供了pthread_mutex_unlock
函数用于释放锁。
//pthread_mutex_lock所在的头文件和函数声明
#include
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_unlock
函数用于释放锁。为了验证加锁控制线程互斥能够解决线程并发访问问题,对前文抢票模拟代码进行改进,具体代码如下:
#include
#include
#include
#include
using namespace std;
int tickets = 1000; // 模拟共享资源
class Tdata//作为传入线程执行函数接收的类
{
public:
Tdata(char *name, pthread_mutex_t *pmutex) : _name(name), _pmutex(pmutex)
{}
public:
string _name;
pthread_mutex_t *_pmutex;
};
void *threadRoutine(void *args)
{
Tdata *td = static_cast<Tdata *>(args);
while(true)
{
pthread_mutex_lock(td->_pmutex);//申请锁
if(tickets > 0)
{
usleep(1000);//模拟抢票操作
cout << td->_name << " get a ticket: " << tickets-- << endl;
pthread_mutex_unlock(td->_pmutex);//释放锁
}
else
{
pthread_mutex_unlock(td->_pmutex);//释放锁
break;
}
usleep(200);//模拟抢票后续操作,并且避免同一个线程总能申请到锁
}
return nullptr;
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr); // 初始化锁
pthread_t tids[4];
int n = sizeof(tids) / sizeof(tids[0]);
for (int i = 0; i < n; i++) // 创建4个线程
{
char tname[64];
snprintf(tname, 64, "thread->%d", i + 1);
Tdata* td = new Tdata(tname, &mutex);
pthread_create(tids + i, nullptr, threadRoutine, (void *)td);
}
for (int i = 0; i < n; i++) // 回收线程
{
pthread_join(tids[i], nullptr);
}
pthread_mutex_destroy(&mutex); // 销毁锁
return 0;
}
编译代码运行并查看结果:
由于每个线程在访问共享资源前都需要申请锁,线程操作共享资源的操作是互斥的,因此多个线程对共享资源的操作完全是串行化的,不会造成多个线程进入临界区,但是进入临界区的条件不满足了的情况。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。以下是加锁实现的伪代码:
加锁伪代码的主要步骤如下: 第一步向%al寄存器中写入0,第二步执行exchange指令将%al寄存器中的数据和内存中锁中的数据(锁初始化后锁中数据大于0)进行交换,第三步判断%al中的数据,大于0即是申请到了锁,否则就是锁被别人申请走了。
第二步数据交换,就是加锁的本质,当线程执行这段代码把锁中数据交换成0后,锁中原有数据就被该线程“私有化了”,即使线程切换寄存器中的数据也会作为线程上下文被切走,其他线程在执行加锁的代码交换到%al的寄存器数据都是0,因此只能挂起等待,保证了锁的互斥性。
加锁的本质是一条命令,保证了加锁的原子性, 代码执行的基本单位是一条指令,因此加锁过程一定是要么没做,要么做完的,是具有原子性的。
锁被申请到后,其他线程无法申请到锁, 由于加锁的本质是一条交换命令,因此一个线程执行交换命令完成加锁后,其他线程想加锁也只是使用交换命令将0交换,无法申请到锁。
以下是解锁实现的伪代码:
解锁的本质是将大于0的数据写回至内存中的锁,由于只有一条指令,因此解锁也是原子性的。
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重 入函数,否则,是不可重入函数。
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁形成示意图:
互斥:资源1和资源2只能被线程1和线程2中的一方使用
请求与保持条件:线程1申请资源2并且占有资源1不释放,线程2申请资源1并且占有资源2不释放
循环等待条件:线程1占有资源1申请资源2,线程2占有资源2申请资源1
不剥夺条件:线程1不能强行剥夺线程2已占有的资源2,线程2不能强行剥夺线程1已占有的资源1
避免死锁的方法是破坏死锁四个必要条件中的任意一个条件,具体方法如下:
说明: 申请锁的线程和释放锁的线程可以是不同的线程。