有了之前对线程的初步了解我们学习了什么是线程,线程的原理及其控制。这篇文章将继续讲解关于线程的内容以及重要的知识点。
线程的优缺点:
线程的缺点
在这里我们来谈一谈线程健壮性:
首先我们先思考一个问题,如果一个线程出现了问题,那么它会影响其他线程吗?
我们写代码验证一下:
#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;
void* start_route(void* args)
{
string name =static_cast<char*>(args);
while(true)
{
cout<<"我是一个新线程,我的名字是:"<<name<<endl;
sleep(1);
}
}
int main()
{
pthread_t id;
pthread_create(&id,nullptr,start_route,(void*)"thread one");
while(true)
{
cout<<"我是主线程!!!!"<<endl;
sleep(1);
}
return 0;
}
代码运行的结果如下:
我们用ps命令进行查看:
pid和lwp的值相同的是主线程 ,不一样的是创建出来的新线程。我们继续修改代码,使其中的一个线程崩溃,看看会不会影响另一个进程:
结果如下:
当我们再用ps命令进行查找时发现两个线程不复存在,原因是:当一个线程对野指针进行访问时,操作系统会发送信号终止进程,而两个线程的pid相同,同属于一个进程,因此两个线程同时崩溃!
之前我们说过操作系统里面没有真正线程的概念,它只提供轻量级进程的使用接口,而创建进程或者线程底层调用的是clone:
这里我们了解一下底层使用的接口就行,我们还是使用平常用的fork和pthread_create创建。
如何看待线程库?(语言版)
#include <iostream>
#include <string>
#include <unistd.h>
#include <thread>
void* thread_run()
{
while(true)
{
std::cout<<"我是一个新线程!!"<<std::endl;
}
}
int main()
{
std::thread t1(thread_run);
while(true)
{
std::cout<<"我是主线程!!!"<<std::endl;
}
t1.join();
}
当我们用c++11中的线程时,不引入线程库时,程序运行报错:
错误显示有未被定义的“pthread_create",由此可以看出在linux环境下,c++语言中的线程本质是对linux底下线程库的进一步封装。
上面的代码也能在windows底下运行,因为语言帮我们解决了平台差异性问题,实现了跨平台!!而原生线程库的接口都是不可跨平台的,暴露和使用原生线程库都是自己决定的!
全局变量的安全性
现在我们写一个抢票的代码,抢票逻辑没有任何问题。但如果多个线程并发的执行就会出现bug:
#include <iostream>
#include <string>
#include <unistd.h>
#include <memory>
#include "Thread.hpp"
int ticket = 10000;
void *getTicket(void *args)
{
string username = static_cast<const char *>(args);
while (true)
{
if (ticket > 0)
{
usleep(1234);
std::cout << username << "正在抢票:" << ticket << std::endl;
--ticket;
}
else{
break;
}
}
}
int main()
{
std::unique_ptr<Thread> thread1(new Thread(getTicket, (void *)"user1", 1));
std::unique_ptr<Thread> thread2(new Thread(getTicket, (void *)"user2", 2));
std::unique_ptr<Thread> thread3(new Thread(getTicket, (void *)"user3", 3));
std::unique_ptr<Thread> thread4(new Thread(getTicket, (void *)"user4", 4));
thread1->start();
thread2->start();
thread3->start();
thread4->start();
thread1->join();
thread2->join();
thread3->join();
thread4->join();
return 0;
}
运行结果:
这时我们发现票数竟然变成了负数,原因就是当我们进行usleep操作时线程被不停的切换。例如线程a刚进入判断语句相对票数进行减减时,线程a被切换,保存在寄存器的上下文也相应地被切走。这时线程b又来了,它和线程a就一起进入了判断语句对票数进行删减操作。当b进行了20次循环操作(假设)票数减了20次,但这时线程a又被切换回来时,cpu先读取线程a中的上下文,在进行减减操作,最后再将结果写回到内存中。这样线程b再回来的时候结果就变得翻天覆地!!
发生以上问题的原因主要是++、--操作不是原子性的,在汇编语句上至少是三条语句:
在这里我先补充一些概念:
临界资源:多个执行流进行安全访问的共享资源。
临界区:多个执行流中,访问临界资源的代码。--往往是代码的很小一部分
互斥:让多个执行流串行访问共享资源。
原子性:对一个资源进行访问时,要么不做,要么就一次性做完。换句话来说执行的语句用一条汇编就能完成。
解决以上问题的手段:加锁!!!!!
锁的常用接口:
锁的初始化:
当你定义一个锁时,如果是全局锁就可以用以下方式定义:‘
初始化以后就不需要对锁进行以下接口的调用:
但如果是一个局部的锁,就需要对锁进行以上的初始化和销毁的操作。下面定义的一个全局锁:
lock和unlock之前的代码区域就是临界区,临界区中访问的ticket就是临界资源,访问它们的方式都是安全的!!
现在我们使用一个局部的锁(全局的锁太简单):
class ThreadData
{
public:
ThreadData(const string&threadname,pthread_mutex_t* pmutex =nullptr)
:_threadname(threadname)
,_pmutex(pmutex)
{
}
~ThreadData(){}
public:
string _threadname;
pthread_mutex_t* _pmutex;
};
int ticket = 10000;
void *getTicket(void *args)
{
ThreadData* td =static_cast<ThreadData*>(args);
while (true)
{
pthread_mutex_lock(td->_pmutex);
if (ticket > 0)
{
usleep(1234);
std::cout <<td->_threadname << "正在抢票:" << ticket << std::endl;
--ticket;
pthread_mutex_unlock(td->_pmutex);
}
else{
pthread_mutex_unlock(td->_pmutex);
break;
}
}
return nullptr;
}
int main()
{
#define NUM 4
pthread_mutex_t lock;
pthread_mutex_init(&lock,nullptr);
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,getTicket,td);
}
for(const auto& tid:tids)
{
pthread_join(tid,nullptr);
}
pthread_mutex_destroy(&lock);
return 0;
}
当我们用了全局所以后发现几个现象:
1.抢票的速度变慢了!!!原因:就是加锁和解锁的过程是多个线程串行执行的。
2.抢票的时候基本上都是一个线程抢了好多票,其他线程没有机会抢票。原因:锁只是规定互斥访问,锁是多个执行流竞争的结果。因为我们抢票的逻辑还不完整导致一个线程释放锁以后,这个线程再次进入循环申请锁。抢完票以后不应该立即再次进入循环,而是出现抢票结果的响应,但我们没有写,简单的用usleep(最后一行)代替一下:
现在我们来谈一谈对锁的认识:
1.锁是用来保护共享资源时其变得安全,但多个执行流申请锁致使锁也是个共享资源。因此申请锁和释放锁也是原子性的。
2.使用锁来保护共享资源实际上是一个执行流串行运行的结果。因此为了提高效率和速度,用锁保护的代码区域的粒度越小越好。
3.如果一个线程申请锁成功,即使线程被切换,它是抱着锁被切换走的!!因此当另一个线程来申请锁的时候就必须挂起等待。我们所学的锁也称为挂起等待锁。
4.谁持有锁谁就进入临界区!!!!!
锁的原子性实现原理:
在理解原理之前我们必须要有两个共识:
1.cpu只有一套寄存器被所有执行流共享。
2.cpu内寄存器的内容是每个执行流私有的,是执行流的上下文。
现在我为大家展示底层原理的代码实现:
为了保证申请锁和释放锁的原子性,大多数体系结构都提供了swap或exchange指令,它们的作用就是将寄存器里的数据和内存里的数据进行交换,并且是一步到位。
保证申请锁为原子性的方式如下:
首先1代表有锁,0代表没有锁,先将0置于一个线程的寄存器中,使寄存器中的数值成为线程a的上下文:
然后使用exchange指令将寄存器中的0和内存中的1进行交换,这样线程a就持有了锁:
由于交换数值在汇编上只有一条指令,因此保证了申请锁的原子性,那么锁被申请到了,线程a能被随意的切走吗??答案是:当然可以!!!!因为线程a被切走时,它的上下文也随之被切走,因此当别的进程来申请锁时就申请不到内存中的锁了(内存中数值为0表示没有锁),因此被挂起等待。
如果这是线程a又被切回来,它会带着它的1(它自己的上下文)回来。这时就保证了只有一个持有锁的进程能够访问临界资源!!!释放锁的原理和上面差不多这里就不多说了。这时有人会问:假如我让一个几个线程必须持有锁才能访问资源,让一个线程不需要锁进行访问,那不就不能保证只有一个线程访问了吗?? ---------------这里必须强调一下,加锁使程序员的工作,要访问就必须让所有线程持有锁访问,如果搞特殊的话这是你程序员自己代码上的失误喔!!!
锁的封装设计:(RAII)
首先我们来介绍一下什么是RAII:
以下是代码的实现:
class Mutex
{
public:
Mutex(pthread_mutex_t* pmutex =nullptr)
:_pmutex(pmutex)
{
}
void lock()
{
if(_pmutex) pthread_mutex_lock(_pmutex);
}
void unlock()
{
if(_pmutex) pthread_mutex_unlock(_pmutex);
}
~Mutex()
{
}
pthread_mutex_t* _pmutex;
};
class Guard_Mutex
{
public:
Guard_Mutex(pthread_mutex_t* pmutex):_mutex(pmutex)
{
_mutex.lock(); //构造函数中进行加锁
}
~Guard_Mutex()
{
_mutex.unlock(); //析构函数中进行解锁
}
private:
Mutex _mutex;
};
可重入和线程安全:
重入的概念:同一个函数被不同的执行流调用。一个执行流还没有调用完,其他执行流就再次进入这个函数。
可重入的概念:一个函数在重入的前提结果不会出现任何的问题,则称为可重入函数。
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
常见可重入的情况
前面讲了这么多相信大家听的云里雾里的,总结一句话就是:可重入是形容函数的,而线程安全表现的是整个程序运行的结果正不正确(例如有没有对全局变量的数据做保护、有没有对函数里的共享资源上锁等等)。线程安全可能调用函数,也可能不调用。因此可重入函数一定是线程安全的,而线程安全不一定是可重入的。
常见锁概念
死锁的概念:
多个线程在不释放自己锁资源的情况下,不断的请求对方的锁资源而导致永久等待的状态。
死锁产生的四个必要条件:
1.互斥:这是锁的特性,每个资源每次只能被一个执行流使用。
2.请求与保持条件:对自己已经获得的资源不释放,并且不断请求对方的资源。
3.不剥夺:不强行获取对方的资源。
4.环路等待:若干执行流之间形成头尾相连的循环等待资源的关系。
破坏死锁的方法:
为了解决死锁问题,我们至少需要破坏死锁的必要条件中的一个。因为互斥是锁的基本特性,如果没有所那还谈什么死锁呢,所以互斥条件我们是没有办法解决的。因此我们根据后三条来解决:
不请求与保持:如果一个执行流申请锁失败时,可以先立即释放自己拥有的锁资源。
剥夺:提高某些执行流的优先级,我们当前执行流需要锁却申请不到,直接强行将锁给它。
破坏环路等待:如果有两把锁A、B,两个执行流依次以A、B的顺序申请和释放锁,而不是一个先申请A后申请B,一个先申请B再申请A。
到这里线程中篇就结束了,线程下篇持续更新,希望大家多多支持!