目录
用全局变量当做共享资源来使用的方式,实际上是不安全的,很有可能因为两个线程同时访问该共享资源而造成数据不一致问题,就像下面抢票程序
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
int tickets = 10000; // 火车票总数,全局变量
void *train_tickets(void *args) // 线程要执行的函数
{
while (true)
{
if (tickets > 0)
{
usleep(1145);//模拟抢票时间
printf("%s正在进行抢票...票号:%d\n", (char *)args, tickets);
tickets--;
}
else
{
break;
}
}
}
int main()
{
//创建4个线程同时抢票
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, train_tickets, (void *)"线程1");
pthread_create(&t2, nullptr, train_tickets, (void *)"线程2");
pthread_create(&t3, nullptr, train_tickets, (void *)"线程3");
pthread_create(&t4, nullptr, train_tickets, (void *)"线程4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
}
该程序在票为0之前会一直进行抢票,按理来说到0时就不会再继续运行了,可是

最后抢到-2才结束
为什么会出现这种情况呢?只需要模拟最极端的情况就可以了
当tickets为1时,假设线程1开始运行,首先判断tickets > 0是否成立,因为成立,所以继续往下走,此时CPU中的上下文里的tickets就是1

由于下一个语句是usleep,因此会发生线程切换,此时线程1会带着CPU上下文一起走,也就会把tickets = 1带走
假设接着线程2开始运行,由于此时内存中的tickets还是1,因此还是会进入if成立的内部 ,usleep,会继续线程切换,此时线程2会带着CPU上下文一起走,也就会把tickets = 1带走。
以此类推,当线程1被唤醒时,打印tickets,此时为1,当tickets--时,会先从内存中读取当前tickets的值,-1后再写回内存
当线程2被唤醒时,打印tickets,此时已经为0了,当tickets--时,就会从0变为-1
当线程3被唤醒时,打印tickets,此时已经为-1了,再变为-2
当线程4被唤醒时,打印tickets,此时已经为-2,再变为-3

这段代码会出错,是因为if判断和tickets--更新分开,但即使没有上面那些usleep、判断,一个循环中只有tickets--这一行代码,也会出错!
对变量进行++、--操作,看似只有一条语句,但转成汇编后有三条语句:
- 从内存读取数据到CPU寄存器中
- 在寄存器中让CPU进行对应的算数逻辑运算
- 写回新的结果到内存中变量的位置
假设有两个线程,线程1在执行完第2步,准备将新的值写回内存时,发生了线程切换

假设线程2执行了好一阵子,已经将tickets--到了500,此时又发生了线程切换,线程1就会先将上次没做完的工作做完,也就是将减完的999写回到内存!此时内存中的tickets就由500变成了999!

这就是因为共享资源没有被保护所发生的数据不一致问题!
通过上面两个例子,可以得知:全局变量在没有保护的时候,并不安全 ,很容易发生数据不一致问题,要想解决这个问题,可以对共享资源进行加锁,即对共享资源作保护 ,也就是将共享资源变为临界资源,访问临界资源的代码,就称为临界区
为了资源安全,让多个线程串行访问共享资源,就称为互斥
互斥锁
要加的这把锁,为pthread_mutex_t 类型,对该类型的锁进行初始化和销毁,就要用到pthread_mutex_init() 和pthread_mutex_destory()

如果定义的锁是局部的,就需要用pthread_mutex_init()和pthread_mutex_destory()进行初始化和销毁,但如果定义的锁是全局或静态 的,就只需要在定义时赋值PTHREAD_MUTEX_INITIALIZER。
要对pthread_mutex_t类型进行加锁和解锁,又要用到pthread_mutex_lock() 和pthread_mutex_unlock()

在加锁和解锁之间的代码,就是被保护起来的代码段,一般称为临界区
cpp
pthread_mutex_t pm = PTHREAD_MUTEX_INITIALIZER;//初始化锁
void *train_tickets(void *args) // 线程要执行的函数
{
while (true)
{
pthread_mutex_lock(&pm);//加锁
if (tickets > 0)
{
usleep(1145);//模拟抢票时间
printf("%s正在进行抢票...票号:%d\n", (char *)args, tickets);
tickets--;
pthread_mutex_unlock(&pm);//解锁
}
else
{
pthread_mutex_unlock(&pm);//如果是else也需要解锁
break;
}
}
}
此时再运行,就不会出现tickets--到-1的情况了,但是由于所有线程都是在串行运行,因此抢票会很慢

但现在不管怎么运行,始终都只有一个线程在抢票了,其他线程处于饥饿状态 ,这是因为锁只规定互斥访问,没有规定必须让谁优先执行,++当线程1的锁被解开后,线程1紧接着就可以继续执行加锁,相比于其他没有在运行的进程,在运行的进程竞争力更强++
只要让线程在解锁后仍有别的事情干,就有机会将锁让给其他线程,这里用usleep模拟一下(后面讲同步时会再说)
cpp
pthread_mutex_t pm = PTHREAD_MUTEX_INITIALIZER;//初始化锁
void *train_tickets(void *args) // 线程要执行的函数
{
while (true)
{
pthread_mutex_lock(&pm);//加锁
if (tickets > 0)
{
usleep(1145);//模拟抢票时间
printf("%s正在进行抢票...票号:%d\n", (char *)args, tickets);
tickets--;
pthread_mutex_unlock(&pm);//解锁
}
else
{
pthread_mutex_unlock(&pm);//如果是else也需要解锁
break;
}
usleep(1000);//干其他事情
}
}

现在就会让多个线程串行访问了
上面代码一直用的是全局互斥锁,下面也来用局部互斥锁实现一下
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string>
using namespace std;
int tickets = 10000; // 火车票总数,全局变量
class ThreadArgs//线程属性
{
public:
ThreadArgs(const string& name, pthread_mutex_t* mutex)
:_name(name)
,_plock(mutex)
{}
public:
pthread_mutex_t* _plock;//线程名
string _name;//锁指针
};
void *train_tickets(void *args) // 线程要执行的函数
{
ThreadArgs* targs = static_cast<ThreadArgs*>(args);//安全类型转换
while (true)
{
pthread_mutex_lock(targs->_plock);//加锁
if (tickets > 0)
{
usleep(1145);//模拟抢票时间
printf("%s正在进行抢票...票号:%d\n", targs->_name.c_str(), tickets);
tickets--;
pthread_mutex_unlock(targs->_plock);//解锁
}
else
{
pthread_mutex_unlock(targs->_plock);//else也别忘了解锁
break;
}
//usleep(1000);//干其他事情
}
}
int main()
{
pthread_mutex_t pm;
pthread_mutex_init(&pm,nullptr);//初始化互斥锁
//创建4个线程同时抢票
pthread_t t1, t2, t3, t4;
ThreadArgs ta1("线程1",&pm);
ThreadArgs ta2("线程2",&pm);
ThreadArgs ta3("线程3",&pm);
ThreadArgs ta4("线程4",&pm);
pthread_create(&t1, nullptr, train_tickets, &ta1);//把存着线程名和锁地址的结构体当做参数传给线程
pthread_create(&t2, nullptr, train_tickets, &ta2);
pthread_create(&t3, nullptr, train_tickets, &ta3);
pthread_create(&t4, nullptr, train_tickets, &ta4);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
pthread_mutex_destroy(&pm);
return 0;
}
理解互斥锁
要对共享资源加锁时,线程需要先看到该锁,也就是说锁本身也是共享资源 。既然锁是用来保护全局资源的,那么锁这个共享资源由谁来保护?所以pthread_mutex_lock和pthread_mutex_unlock的过程都是原子性的
pthread_mutex_lock时,如果申请成功,就继续向后执行,否则该执行流就会阻塞 ,直到申请成功的线程解锁后,当前线程再上锁继续运行。
如果申请失败后不想阻塞,可以用pthread_mutex_trylcok() ,它只会尝试申请一次 ,若失败则返回错误码并继续向后运行
例如上面代码,如果线程1申请锁成功,进入临界资源,正在访问临界资源期间(也就是解锁之前),其他线程都在阻塞等待。
而在持有锁时(也就是访问临界资源时),也可以进行线程切换,只不过它是抱着锁走,其他线程依旧无法申请锁成功
因此,对于其他线程而言,有意义的锁的状态只有申请锁前 和释放锁后,就是原子性的!
在对临界区加锁时,要保证加锁粒度小,锁内的代码越少越好。在加锁时,要么将所有共享资源都加锁,要么都不加,否则视为有bug
互斥锁实现原理
现在我们都知道,i++和++i都不是原子性的,想对i加一需要3行汇编代码,如果在这中途线程被切换走就可能会出现问题。而pthread_mutex_lock的汇编中,用一行汇编exchange 语句交换寄存器和内存中的值从而达到原子性

例如有线程1和线程2,当线程1想要申请锁时,会先在%al寄存器中放入0,此时即使线程1被切换走,%al中的0值也可以一起带走
若没有发生线程切换,会通过exchange指令将%al和mutex的值作交换(在没有申请锁之前mutex的值是1)

因为它是由一条语句完成了交换,即使交换完后发生线程切换,线程1带着%al中的1走 ,mutex还是0,当线程2上来想申请锁时,依旧先将0值放入%al,但交换了%al和mutex后,%al的值还是0,此时就会进入等待,线程1会继续上来

而解锁就是将1值放入mutex变量中。
注意,只是将1值放入mutex,并不是将%al中的1和mutex的0作交换 !!这就代表其他线程也可以释放锁!
互斥锁封装
下面简单封装一个RAII风格的互斥锁
Mutex.hpp:
cpp
#pragma once
#include <iostream>
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t* mutex)
:_mutex(mutex)
{
pthread_mutex_lock(_mutex);
}
~Mutex()
{
if(_mutex)
pthread_mutex_unlock(_mutex);
}
private:
pthread_mutex_t* _mutex;
};
在使用该封装时,只需要在加锁时实例化一个Mutex,该Mutex出作用域后会自动释放(解锁)
cpp
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//全局锁初始化
int tickets = 10000; // 火车票总数,全局变量
void *train_tickets(void *args) // 线程要执行的函数
{
while (true)
{
{//为了不涉及后面的非临界区代码,自己设一个作用域
Mutex m(&lock); // 出作用域后自动释放
if (tickets > 0)
{
usleep(1145); // 模拟抢票时间
printf("%s正在进行抢票...票号:%d\n", (char *)args, tickets);
tickets--;
}
else
{
break;
}
}
usleep(1000);//干其他事情
}
}
可重入函数和线程安全
可重入函数是指一个函数在同一线程 中被多次调用时能正确执行;
线程安全是指一个函数在多线程环境中可以被多个线程同时调用而不会导致数据竞争或错误结果。
可重入一定是线程安全 的,因为可重入函数不依赖任何共享状态,自然可以在多线程环境中安全使用;
线程安全不一定是可重入的,例如上面的抢票函数,虽然是线程安全的,但如果同一线程再次进入,就会导致死锁
死锁
死锁是指在多任务系统中,两个或多个进程/线程因相互竞争资源而造成的一种僵持状态 。在这种状态下,每个进程/线程都在等待 其他进程释放其所占有的资源,导致所有进程/线程都无法继续向前推进,形成永久性的阻塞。
简单来说,就是在有多把锁的场景下,持有自己的锁不释放并且还要对方的锁,但对方也是如此,这就造成了死锁。
一把锁也有可能造成死锁,若在该锁没被解锁时重新加锁,就会造成永久性阻塞
cpp
pthread_mutex_lock(&lock);
pthread_mutex_lock(&lock);
死锁的产生需要四个必要条件:
- 互斥:一个资源每次只能被一个执行流使用
- 请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 环路等待:若干执行流之间形成一种头尾相接的循环等待资源的关系
只要破坏这4个条件中的一种,就可以避免死锁
由于互斥是保护临界资源的必要条件,因此不能被破坏。但剩下的三个条件都可以通过被破坏而避免死锁:
- 请求与保持:可以用pthread_mutex_trylock() ,若申请失败,就将之前申请的锁也释放掉
- 不剥夺:若两个线程都想在持有自己的锁的前提下要对方的锁,可以通过设置优先级的方式决定到底谁先抢过来
- 环路等待:若本来两个线程申请两把锁的顺序一个是申请A再申请B,一个是申请B再申请A,这样就会造成环路等待问题,那么只要将这两个线程都变成同时申请A再申请B,就不会发生一个线程一把锁,且同时在等待的情况了

除了上述避免死锁条件,还可以用避免死锁的算法,例如银行家算法等,这里不详细展开
同步
在上面的抢票代码中,为了保证线程安全,要为线程加入互斥锁,而加入互斥锁后发现整个抢票过程都是那一个线程在抢,其他线程没有机会,这就造成了其他线程的饥饿问题

只要规定在一个线程释放锁后,不能直接进行申请锁 ,需要先进入一个队列中排队 ,这样就设定了线程对共享资源的访问顺序 ,这就是线程同步
关于实现线程同步的条件变量,可以看下面文章:
哇UIDNA会啊哈覅哦按粉碎无法还不ISO和覅搜额合法伴随额胡搜if就胡思哦