目录
什么是线程安全问题?
线程安全问题是指在多线程环境下,当多个线程访问共享资源可能会导致的数据不一致问题。
一.多线程抢票问题:
以下面的多线程抢票代码为例:
#include<iostream>
#include<stdio.h>
using namespace std;
int tickets=10000;
void* threadroutine(void* arg)
{
char* name=(char*)arg;
while(true)
{
if(tickets>0)
{
cout<<name<<" get a ticket :"<<tickets<<endl;
tickets--;
}
else
break;
}
return nullptr;
}
int main()
{
pthread_t tids[5];
//创建线程
for(int i=0;i<5;i++)
{
char name[32];
sprintf(name,"%s%d","thread-",i+1);
pthread_create(&tids[i],nullptr,threadroutine,(void*)name);
}
//等待线程
for(int i=0;i<5;i++)
pthread_join(tids[i],nullptr);
return 0;
}
代码执行结果:
为什么会出现抢到负数票的问题呢?
多个线程都执行这个抢票代码,由于线程之间可能是并行的,在票数为1时,多个线程都判断到tickets>0,于是每个线程都进入了if语句里,进而多抢了票。
线程之间可能是并发的,一个线程执行完判断逻辑为true,还没来得及对tickets--就切换了,下一个线程判断tickets依然大于0,于是就有多个线程进入抢不够的票。
二.单核CUP上线程并发没有并行也会引发线程安全问题
举例说明
i++本质也不是线程安全的(不是原子的):
线程a、b并发执行时,线程a先执行第一步将 i 放入AL中,第二步加法器++,此时线程突然被切走带走自己的硬件上下文(由于未进行第三步,内存中的i还未成功++),b线程开始执行,b线程执行完毕,内存中的i变为1,然后a线程又被调度,将自己的硬件上下文拷回寄存器,AL中的值为1,再接着上次的进度执行第三步,将1拷回内存,内存中的i最终为1。但我们想要的结果一定是2,由于两个线程对共享资源i的访问不是原子的,所以出现了数据不一致问题。
线程安全问题的常见解决方案
一.互斥
互斥锁
1.为什么要有互斥锁?
线程安全问题的本质是多线程访问未被保护的临界资源(共享资源)而出现的数据不一致问题。这句话的核心 是 "多线程","临界资源"。致力解决线程安全问题,那就要将临界资源保护起来,保证线程访问临界资源的原子性,即对资源的访问只有完成和未开始两种状态,没有中间状态。
互斥锁的作用就是多线程共用一把锁,一把锁只有一把钥匙,一个线程进入临界区访问临界资源前,先申请锁,申请成功后才能进入临界区,否则就会阻塞在申请锁的位置。
"一把锁只有一把钥匙",就注定了每次进入临界区访问临界资源的线程只能有一个,及线程访问临界资源是互斥的。 这样的机制就很好的将临界资源保护了起来,不会出现在同一段时间多个线程都进入临界区访问临界资源而导致数据不一致问题。即使一个线程进入临界区后被切换调度走,这个线程也会将锁资源带走,在这个线程未访问完临界资源前任何情况下都不会有其他线程进来。
2.互斥锁的使用
互斥锁的封装在线程库当中,下面介绍互斥锁的创建、申请(加锁)、释放(解锁)。
互斥锁创建和初始化
全局互斥锁:
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;//创建并初始化
局部互斥锁:
pthread_mutex_t mutex;//创建局部互斥锁
pthread_mutex_init(&mutex,nullptr);//初始化
加锁
pthread_mutex_lock(&mutex);//阻塞式申请
其中,如果申请锁失败,线程会挂起等待,等待其他线程释放锁时候被唤醒继而重新申请锁。
pthread_mutex_trylock(&mutex);//非阻塞式申请
如果使用trylock来申请锁,申请锁失败后不会挂起等待,立即返回一个失败的状态。进而线程可以去执行其他代码。
解锁
pthread_mutex_unlock(&mutex);//解锁
一旦一个线程进行解锁,会立即释放锁资源,唤醒所有阻塞申请锁的线程,这些线程竞争锁资源。
3.互斥锁的封装
用C++对互斥锁进行封装,在构造函数中自动申请锁,析构函数中自动释放锁。
//Mutex.hpp
#include<pthread.h>
class Mutex
{
Mutex(pthread_mutex_t* mutex):_mutex(mutex)
{
pthread_mutex_lock(_mutex);
}
~Mutex()
{
pthread_mutex_unlock(_mutex);
}
private:
pthread_mutex_t* _mutex;
};
4.互斥锁的原理
互斥锁要对临界资源进行保护,但是互斥锁本身又是临界资源,所以对锁的操作一定是原子的。
每条汇编指令都是原子的,上面的加锁操作无论在哪里线程切换都不会出现线程安全问题。
自旋锁
互斥锁在申请时如果申请失败会直接挂起,等待被释放锁资源的线程唤醒。挂起唤醒成本较高,但是自旋锁申请锁失败时并不会将自己挂起,而是在申请锁函数中频繁申请锁,直到锁资源被释放。自旋锁适用于锁被每个线程使用的时间较短,没必要让申请锁的进程挂起等待的情况。
使用trylock模拟实现:
//lock:
{
while(pthread_mutex_trylock(&mutex)!=0)
{}
}
二.同步
同步和互斥的区别和联系
1.互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
2.同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
3.同步其实已经实现了互斥,所以同步是一种更为复杂的互斥。
4.互斥是一种特殊的同步。
条件变量
使用条件变量是实现线程同步的一种方法,让线程之间的执行具有一定先后性。条件变量是一个封装变量,提供这样的一种机制:
允许一个线程等待某个条件的发生,而其他线程可以在满足条件时通知等待的线程继续执行。
举例说明:
如图线程A,B协作完成一项任务,线程B如果要执行funcB,必须让线程A先执行完funcA,线程之间的执行具有一定先后性,此时单纯的使用锁没办法保证执行的先后性。就可以用条件变量:
如果线程B先执行,就会在cond_wait接口处等待被唤醒,A线程执行完funcA再使用cond_signal函数唤醒在条件变量上等待的线程B。
条件变量的使用:
1.加锁临界资源。
2.B线程来了,申请锁资源,进入临界区,发现要访问临界资源未就绪。
3.在条件变量上等待被唤醒。同时释放锁资源。
4.A线程竞争申请锁资源,进入临界区,A线程将a准备好,唤醒B线程。释放锁资源。
信号量
信号量也是一种实现线程同步的机制。
信号量是一种资源的预定机制,锁只能将共享资源整块保护,信号量可以将共享资源分块维护。
信号量的本质是一把计数器。申请信号量P操作计数器--,释放信号量V操作信号量++。
信号量本质:对一块临界内存资源规定可用资源个数,在线程要申请资源时申请信号量,为线程分配属于自己的独一份资源,线程使用资源过后释放信号量,可用资源数++。至于可用资源如何分配、释放,由程序猿自定义。
多线程的生产消费模型
生产消费模型是一个多线程的互斥和同步的一个应用模型,如上图。
模型中,生产线程向临界资源中不断生产资源,消费线程在临界资源中不断消耗资源。
321原则:
三种关系:生产者和生产者之间的关系(互斥),消费者和消费者之间的关系(互斥),生产者和消费之之间的关系(互斥且同步)。
两种角色:生产者 消费者
一个交易场所:临界内存资源。
基于生产消费模型可以设计出许多 多线程小demo,如阻塞队列,循环队列,线程池......
下篇博客来实际应用一下上面说到的知识。实战设计出阻塞队列、循环队列、线程池。