先来用代码模拟一个抢票的场景,四个线程不停地抢票,一共有1000张票,抢完为止,代码如下:
#include
#include
#include
#include
#include
#include
#include
int tickets=1000;
void* get_ticket(void* args)
{
std::string username=static_cast<const char*>(args);
while(true)
{
if(tickets>0)
{
usleep(1000);//用来模拟抢票花费时间
std::cout<<username<<" 抢票ing"<<tickets<<std::endl;
tickets--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1,t2,t3,t4;
pthread_create(&t1,nullptr,get_ticket,(void*)"线程1");
pthread_create(&t2,nullptr,get_ticket,(void*)"线程2");
pthread_create(&t3,nullptr,get_ticket,(void*)"线程3");
pthread_create(&t4,nullptr,get_ticket,(void*)"线程4");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
pthread_join(t4,nullptr);
return 0;
}
执行结果如下:
线程2 抢票ing线程4 抢票ing4
线程3 抢票ing2
4
线程1 抢票ing0
线程4 抢票ing-1
线程3 抢票ing-2
[sny@VM-8-12-centos threaddone]$
可以看到,最后出现了票数为负数的情况,很显然这是错误的,是不应该出现的。
为什么会出现这种情况?
首先要明确,上述的几个线程是不能同时执行抢票的动作的。
但是,这几个线程可以在执行的过程中不断地切换,即当一个线程还没有执行完抢票的动作的时候,就可以被另一个线程切走。
而众所周知,进行计算操作时,数据要被加载到CPU中进行运算,之后再写回内存中,并且CPU中的寄存器只有一套,每一个线程离开CPU时,要将寄存器中的属于自己的上下文带走,到下一次执行时再将上下文数据写回CPU中进行没有完成的操作。
所以,当一个线程准备抢票时,却突然被另一个线程切走。这时,该线程的上下文记录中,tickets是大于0的,但是很有可能另一个线程已经把票抢完了。所以,该线程再一次运行时,就会误以为tickets>0,再次抢票,就出现了票数为负数的情况。
所以,当我们定义全局变量,且有多个线程执行时,该变量很有可能是不安全的。
这里再补充几个概念:
所以,为了能保证共享资源的安全性,就要进行一个加锁的操作:
对资源访问结束后,再进行解锁操作。
先二话不说,用锁对上面的代码中的临界资源做保护,随后再解释原理,代码如下:
int tickets=1000;
class ThreadData
{
public:
ThreadData(const std::string threadname,pthread_mutex_t* mutex_P)
:threadname_(threadname)
,mutex_p_(mutex_P)
{}
~ThreadData()
{}
public:
std::string threadname_;
pthread_mutex_t* mutex_p_;
};
void* get_ticket(void* args)
{
ThreadData* td=static_cast<ThreadData*>(args);
while(true)
{
pthread_mutex_lock(td->mutex_p_);//加锁
if(tickets>0)
{
usleep(1000);//用来模拟抢票花费时间
std::cout<< td->threadname_<<" 抢票ing"<<tickets<<std::endl;
tickets--;
pthread_mutex_unlock(td->mutex_p_);//解锁
}
else
{
pthread_mutex_unlock(td->mutex_p_);
break;
}
}
return nullptr;
}
int main()
{
#define NUM 4
pthread_mutex_t lock;
pthread_mutex_init(&lock,nullptr);//初始化锁
std::vector<pthread_t> tids(NUM);
for(int i=0;i<NUM;i++)
{
char buffer[64];
snprintf(buffer,sizeof(buffer),"thread %d",i+1);
ThreadData* td=new ThreadData(buffer,&lock);
pthread_create(&tids[i],nullptr,get_ticket,td);
}
for(const auto& tid:tids)
{
pthread_join(tid,nullptr);
}
pthread_mutex_destroy(&lock);//销毁锁
return 0;
}
运行结果如下:
thread 4 抢票ing4
thread 4 抢票ing3
thread 4 抢票ing2
thread 4 抢票ing1
[sny@VM-8-12-centos threaddone]$
可以看到,这次抢票的结果没有出现负数的情况,但是,这次只有线程4在抢票,这是因为锁只规定互斥访问,没有规定谁优先执行,锁就是让多个执行流进行竞争的结果。而且,由于加锁之后,所有线程是串行的,所以这次运行速度会慢一些。
当然,抢票结束之后还要让每一个执行流去做自己的事,这样其他执行流就也可以抢到票了,让每个线程抢完票之后usleep一段时间用来模拟实现其他业务。
上文中定义的tickets是一个全局变量,为保护全局变量就要加锁。但是每一个线程访问全局变量之前都要访问锁,所以锁本身就是一个全局变量,那锁的安全怎么保护?
加锁的过程是原子的,所以加锁和解锁的过程是十分安全的。
如果一个执行流申请锁失败怎么办?----答案是执行流会阻塞,直到它被唤醒。
举个例子:
while(true)
{
pthread_mutex_lock(td->mutex_p_);//加锁
pthread_mutex_lock(td->mutex_p_);//加锁
if(tickets>0)
{
usleep(1000);//用来模拟抢票花费时间
std::cout<< td->threadname_<<" 抢票ing"<<tickets<<std::endl;
tickets--;
pthread_mutex_unlock(td->mutex_p_);//解锁
}
else
{
pthread_mutex_unlock(td->mutex_p_);
break;
}
}
如上,对每一个线程申请锁成功之后再一次申请,就必定会失败。
运行结果如下:
可以看到,四个线程是存在的,但是它们都处于阻塞状态。直到该线程现在持有的锁释放,操作系统才会唤醒该线程,让它继续持有下一个锁并向后执行。
也可以使用pthread_mutex_trylock
这个接口可以判断当前线程有没有持有锁,没有就申请一个锁并返回,有就直接返回,不会造成阻塞的问题。
根据以上内容,不难判断,只有持有锁的线程才可以访问临界区!
还要注意几点:
- 如果一个线程申请锁成功并且正在访问临界资源,其他线程会处于阻塞状态
- 如果一个线程申请锁成功并且正在访问临界资源,该线程可以被其他线程切换
- 当持有锁的线程被切走,其他线程依旧无法申请锁,也就无法访问临界资源,不能向后执行,直到持有锁的线程释放锁
- 对于一个线程,有意义的状态只有持有锁和释放锁两种状态。站在其他线程角度,该线程持有锁的过程就是原子的
上文中说了加锁和解锁是原子性的,那么这个过程是怎么实现原子性的呢?
当我们对汇编代码稍有了解之后,就会知道即便是非常简单的诸如i++/i–这样的运算,都不可能只用一条汇编代码就能完成。对于这种不能“瞬间”完成的非原子性的运算,在多线程环境下,很可能被其他线程中断并修改数据,导致数据错误。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据想交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
以上便可实现加锁和解锁的原子性。
封装过程比较简单,直接上代码:
#pragma once
#include
#include
class Mutex
{
public:
Mutex(pthread_mutex_t* lock_P=nullptr):lock_p_(lock_P)
{}
void lock()
{
if(lock_p_) pthread_mutex_lock(lock_p_);
}
void unlock()
{
if(lock_p_) pthread_mutex_unlock(lock_p_);
}
~Mutex()
{}
private:
pthread_mutex_t* lock_p_;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t* mutex) :mutex_(mutex)
{
mutex_.lock();//在构造函数中加锁
}
~LockGuard()
{
mutex_.unlock();//在析构函数中解锁
}
private:
Mutex mutex_;
};
加锁时,就只需要创建一个锁变量,并将其传递给LockGuard对象即可自动初始化,是用完之后会自动销毁。使用比较简单,这里就不演示了。
常见线程不安全的情况:
常见线程安全的情况:
常见不可重入的情况:
常见可重入的情况:
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
用大白话来理解上面这句话就是,在多把锁的场景下,每一个执行流都持有自己的锁,在不释放的情况下,还想申请其他执行流的锁,其他的执行流也是这样的情况,这时就造成了死锁的情况。
形成死锁的四个必要条件:
①互斥:一个资源每次只能被一个执行流使用
②请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
③不剥夺:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
④循环等待:若干执行流之间形成一种头尾相接的循环等待资源的关系
怎么解决死锁?
上述四个是形成死锁的必要条件,所以解决死锁只需要破坏上述任意一个条件即可。
首先,申请锁一定是为了互斥访问资源,所以这个条件一定是成立的,无法破坏。
其次,对于第二个条件。如果一个执行流已经申请到了锁,还想申请下一个,可以采用一定策略使得这次申请失败,或者释放原来的锁,再申请下一个锁,如此便可解决请求与保持的问题。
再次,当多个执行流不能剥夺其他执行流的锁时,我们可以设置一个比较策略,比较出在某一方面较差的一个执行流,让其主动交出自己持有的锁即可。
最后,可以通过控制线程申请锁的顺序,来避免环路等待的问题。
常见的两种解决死锁的算法:
①死锁检测算法
②银行家算法
看兴趣的读者可以通过这两个链接了解一下。
本篇完,下一篇为【线程同步与生产消费模型】,敬请期待!