因为多个线程时共享地址空间的,也就是很多资源是共享的。优点是线程间的通信非常方便,缺点是缺乏访问的控制。因为一个线程的操作问题,给其他线程造成了不可控或者引起崩溃异常等,这种现象称之为线程安全。
产生线程安全的原因是多个线程可以同时共享一些资源,比如堆等。想避免线程安全问题就需要对资源进行访问控制。
注意,线程有自己独立的栈结构,所以临时变量不需要控制线程安全。
要保证线程安全,就需要线程之间是互斥和同步的。下面介绍几个概念来引出互斥和同步的概念。
1.临界资源:凡是被线程共享访问的资源都是临界资源(多线程,多进程都有临界资源。比如多个进程向显示器打印数据,显示器就是临界资源)
2.临界区:代码中访问临界资源部分的代码。因此,对临界区的保护本质上就是对临界资源的保护。(通过互斥和同步实现)
3.互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),称之为互斥!
4.原子性:一段代码要么不执行,要么执行完毕。称这段代码具有元祖性
5.同步:一般而言,让访问临界资源的过程在安全的前提下(互斥并且原子的),让访问的资源具有一定的顺序性。
使用一段模拟抢票的代码来验证实现线程同步和互斥的必要性。
#include
#include
#include
#include
using namespace std;
int tickets=1000;
void* ThreadRoutine(void* args)
{
int id=*(int*)args;
delete (int*)args;//接收线程的id
while(true)
{
if(tickets>0)//对临界资源tickets进行操作
{
usleep(1000);
cout<<"我是"<<id<<"我抢到的票是:"<<tickets<<endl;
tickets--;
}
else
{
break;
}
}
}
int main()
{
pthread_t tid[5];
for(int i=0;i<5;i++)
{
int* id=new int(i);
pthread_create(tid+i,nullptr,ThreadRoutine,(void*)id);//创建5个线程,这里传递id而不是i,因为毕竟传递的是地址,担心线程中有代码将i的值进行更改。
}
for(int i=0;i<5;i++)
{
pthread_join(tid[i],nullptr);//线程等待
}
return 0;
}
我们希望这五个线程将这1000张票抢购一空(tickets–至0)。但是我们发现运行结果并不是我们想的那样tickets–到0,甚至有的线程抢到了负数票。
此时我们发现tickets虽然是临界资源,但是并没有线程的安全性来保证。
那么为什么会出现这种情况呢?
tickets作为临界资源,所有的线程都要对它进行判断ticket是否大于0,以及ticket–的操作。用ticket–操作举例,虽然他看起来是一行C语言的代码,但是实际上它的底层汇编经历了三个阶段,分别是load命令,减法命令,以及store命令。
由于线程是不断在切换的,因此一个线程在执行完load命令之后,很可能还没来得及做减法或者写回操作,就被切走了。CPU开始执行下一个线程。
当线程A被切走的时候,它会抱着它的临时数据,也就是还没来得及进行–操作的1000。此时线程B进来,假设它执行了–操作,并成功将tickets–到了10,并写回到了内存中。
过了一会,A线程带着它的临时数据1000回来了,它认为tickets的值还是原来的1000,执行–操作,将值变成了999,此时写回内存中。写回的过程中,就将原来B写回的数据进行了覆盖。B的–白白进行执行了。
因此我们发现,如果多个线程同时执行的话,这是一个相当混乱的状态。
我们还可以分析一下,出现负数的情况,当一个线程进行判断操作后发现tickets是大于0的(此时还没将tickets放入CPU中),突然线程被切换走了。另一个线程来了,并将tickets–到了0,此时再将原来的线程切换回来,它认为自己已经判断完tickets的大小了。拿到ticktes的值后直接就放入CPU中进行了–操作,因此出现了负数。
为了解决这一问题,我们引入了线程锁的概念。
对于上述的问题,我们只需要保证在一个线程对tickets的操作的时候,其他线程不会对tickets进行操作。(注意,不是保证线程不会被切走。)
使用线程锁,我们需要了解一个类型,以及四个线程锁有关的函数。
其中参数中的pthread_mutex_t就是一个锁的类型,我们使用它来定义一把锁。
函数pthread_mutex_init是用来初始化的函数,第一个参数是一个指针指向要初始化的锁,第二个参数是锁的属性,我们置为NULL即可。
函数pthread_mutex_destroy是销毁锁的函数,它的参数指向要销毁的锁。
函数pthread_mutex_lock是加锁函数,pthread_mutex_unlock是解锁函数。
我们只需要在访问临界资源的区域(临界区),前进行加锁,在访问后进行解锁即可以保证在某个线程访问临界资源的时候,其他线程无法访问该资源。
#include
#include
#include
#include
#include
using namespace std;
class Ticket
{
private:
pthread_mutex_t mtx;//定义一把锁
int tickets=1000;//定义票数
public:
Ticket():tickets(1000)
{
pthread_mutex_init(&mtx,nullptr);//构造函数中,对锁进行初始化
}
bool GetTicket()
{
bool res=true;
pthread_mutex_lock(&mtx);//访问临界资源tickets要进行加锁
if(tickets>0)
{
usleep(1000);
cout<<"我是"<<pthread_self()<<"我抢到的票是:"<<tickets<<endl;
tickets--;
}
else
{
res=false;
cout<<"票被抢光了"<<endl;
printf("");
}
pthread_mutex_unlock(&mtx);//访问结束,进行解锁
return res;
}
~Ticket()
{
pthread_mutex_destroy(&mtx);//析构函数中对锁进行销毁
}
};
void* ThreadRoutine(void* args)
{
Ticket* t=(Ticket*)args;
cout<<"我是线程"<<pthread_self()<<endl;
while(true)
{
if(t->GetTicket())
{
continue;
}
else
{
break;
}
}
}
int main()
{
Ticket* t=new Ticket();
pthread_t tid[5];
for(int i=0;i<5;i++)
{
pthread_create(tid+i,nullptr,ThreadRoutine,(void*)t);
}
for(int i=0;i<5;i++)
{
pthread_join(tid[i],nullptr);
}
return 0;
}
为了实现这一过程,可以定义一个Ticket类,在其中定义ticket和一把锁。使用访问函数来帮助线程对锁进行访问,在临界区处进行加锁,解锁的操作。
此时再运行代码,我们发现是我们期望的抢票结果。
我们也可以使用C++提供的函数来进行锁的操作,需要包含头文件
mutex mymtx//定义锁
mymtx.lock();//加锁
mymtx.unlock;//解锁
我们还可以定义静态锁,此时不再需要调用初始化函数和销毁函数:
static pthread_mutex_t mtx:PTHREAD_MUTEX_INITIALIZER
我们发现,tickets是所有线程都可以看到的资源,因此是临界资源。而锁也是所有线程都可以看到的资源,那么为什么它不会造成线程安全问题呢?这是因为加锁和解锁的过程是原子性的。它的关键在于:只使用一条汇编,就将内存中的数据和CPU寄存器中的数据进行交换了。
它的汇编代码如下:
lock:
movb $0,%al #将0值赋给寄存器
xchgb %al ,mutex #将内存中的mutex值和寄存器中的数据进行交换
if(al寄存器中的内容>0)
{
return 0;
}
else
{
挂起等待;
}
goto lock;
unlock:
movb $1 mutex
唤醒等待Mutex的线程;
return 0
要理解这一过程,首先要理解上下文数据的概念。其实就是线程被切换后它的PCB中保存着执行的数据。
当线程A到来时,假设它要抢锁,目前其他线程没有它来的快。它的寄存器al的数据现在是0,执行交换操作,将内存中mutex的值交换给A的寄存器al中,此时线程A的al值为1,内存中mutex的值为0。
当线程A被切换走时(是带着上下文数据1一起被切走的),线程B到来,它的al寄存器中的值为0(线程设置的是自己的上下文数据,互相不冲突),进行交换mutex的值和al寄存器的值(0和0交换),最终B拿到的值是0,发生挂起等待。此时就可以根据每个线程的al中寄存器的值判断是哪一个线程抢到了锁,从而只允许该线程访问临时资源。当A访问完资源后,释放锁,唤醒等待的线程,并将mutex的值置为1。
注意,拿到锁的A在访问临时资源的时候还是会被切换的,只不过其他线程此时无法访问临时资源。
站在其他线程的视角,要么A没有申请锁不能访问临界资源,要么A申请锁了访问了临界资源。因此线程A访问临界资源的动作具有原子性。
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源,而处于的一种永久等待状态。
互斥条件:一个资源只能被一个执行流使用。
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
循环等待条件:若干执行流之间形参一种首尾相接的循环等待资源的关系。
破坏死锁的四个必要条件。
加锁顺序一致。
避免加锁未释放的场景。
资源一次性分配。
死锁检测算法
银行家算法
这里有一个小结论:线程安全不一定是可重入问题,可重入问题会导致线程安全。