👍作者主页:进击的1++
🤩 专栏链接:【1++的Linux】
文章目录
- 一,对上一篇内容的补充
- 二,Linux线程互斥
- 1. 互斥的引出
- 2. 互斥量
- 3. 剖析锁的原理
一,对上一篇内容的补充
线程创建: pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID
**线程终止:**需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
线程等待: 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。(线程等待的原因)
int pthread_join(pthread_t thread, void **value_ptr);
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数
PTHREAD_ CANCELED。 - 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参
数。 - 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
线程分离: 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
二,Linux线程互斥
1. 互斥的引出
我们再来回顾一下我们以前曾提到过的临界资源,临界区和原子性。
临界资源:被多个执行流所共享的资源叫做临界资源。
临界区:每个线程内部执行访问临界资源的代码叫做临界区。
原子性:在别人看来只有两种状态,做一件事情,要么没做,要么做完。
首先我们来回答为什么要有线程互斥。
我们来看一段代码:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
int tickets=1000;
void* get_tickets(void* argv)
{
while(true)
{
if(tickets>0)
{
cout<<"我是:"<<pthread_self()<<" "<<"我抢的第"<<tickets<<"票"<<endl;
tickets--;
}
else
{
break;
}
}
cout<<"票完了"<<endl;
return nullptr;
}
int main()
{
pthread_t tid[3];
for(int i=0;i<3;i++)
{
pthread_create(tid+i,nullptr,get_tickets,(void*)"thread");//创建多个线程
}
//等待线程
for(int i=0;i<3;i++)
{
pthread_join(tid[i],nullptr);
}
return 0;
}
我们看到,最终的结果竟然有两个线程抢到了同一个编号的票,这样岂不是一个作为我们卖出去了两张甚至更多的票。
这是为什么呢?
我们用下面这张剖析图来理解:
有如下场景:线程A先执行,票数减减的步骤在我们看到就只有一行,实际转换为汇编代码后是有三条语句,图中我已经写出,当A执行完前两步,要将数据写入内存中去时,因为时间片等某种原因,其被切换了下来,换进程B去执行,此时在内存中票的数仍然为1000,所以B拿到的仍然是编号为1000的票,因此就发生了上述结果。
因此就要有互斥的存在了!!!
多线程是共享地址空间的,所以有很多资源都是共享的。
这种方式带来的优势:方便了线程间的通信
缺点:并发访问一些共享的数据时,回由于时序问题而导致数据不一致的问题。
那么什么是互斥呢?
我们先来看其概念:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
以上述代码为例,那么我们的临界资源就是票的数量,临界区就为抢票过程的一段代码
互斥就是对临界区的保护的一种方式,其本质就是保护临界资源
2. 互斥量
要解决上述数据不一致的问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
锁的初始化:
锁的初始化有两种,一种是用函数做初始化,将你的锁的地址传入进去,属性可以设置为nullptr,另一种是对于全局的锁或者是static修饰的锁,可以直接用宏PTHREAD_MUTEX_INITIALIZER 进行初始化。
锁的销毁:
pthread_mutex_destroy是销毁锁。
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
加锁和解锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);//加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);//加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁
返回值:成功返回0,失败返回错误号。
调用 pthread_ lock 时:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
trylock函数,:
这个函数是非阻塞调用模式, 也就是说, 如果互斥量没被锁住, trylock函数将把互斥量加锁, 并获得对共享资源的访问权限; 如果互斥量被锁住了, trylock函数将不会阻塞等待而直接返回EBUSY, 表示共享资源处于忙状态。
有了锁之后我们对抢票系统做出改进:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
pthread_mutex_t mt=PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;//创建锁并初始化
int tickets=1000;
void* get_tickets(void* argv)
{
(void*)argv;
while(true)
{
pthread_mutex_lock(&mt);//加锁
if(tickets>0)
{
cout<<"我是:"<<pthread_self()<<" "<<"我抢的第"<<tickets<<"票"<<endl;
tickets--;
pthread_mutex_unlock(&mt);
}
else
{
pthread_mutex_unlock(&mt);
break;
}
}
cout<<"票完了"<<endl;
return nullptr;
}
int main()
{
pthread_t tid[3];
for(int i=0;i<3;i++)
{
pthread_create(tid+i,nullptr,get_tickets,(void*)"thread");//创建多个线程
}
//等待线程
for(int i=0;i<3;i++)
{
pthread_join(tid[i],nullptr);
}
return 0;
}
此时我们可以看到运行结果就达到了我们所期望的。
下面我们换一种锁的初始化方式来进行验证:
#define TH_NUM 5
class Get_tickets
{
public:
Get_tickets(string& name,pthread_mutex_t* mut)
:_name(name)
,_mut(mut)
{}
public:
string _name;
pthread_mutex_t* _mut;
};
void* get_tickets(void* argv)
{
Get_tickets* Lock=(Get_tickets*)argv;
while(true)
{
pthread_mutex_lock(Lock->_mut);
if(tickets>0)
{
cout<<"I am "<<Lock->_name<<": "<<tickets<<endl;
tickets--;
pthread_mutex_unlock(Lock->_mut);
usleep(100000);
}
else
{
pthread_mutex_unlock(Lock->_mut);
break;
}
}
cout<<"票完了"<<endl;
}
int main()
{
pthread_t tid[TH_NUM];
pthread_mutex_t mut;
pthread_mutex_init(&mut,nullptr);
string name="thread";
for(int i=0;i<TH_NUM;i++)
{
Get_tickets* pLock=new Get_tickets(name,&mut);
pLock->_name+=to_string(i+1);
pthread_create(tid+i,nullptr,get_tickets,(void*)pLock);
}
for(int i=0;i<TH_NUM;i++)
{
pthread_join(tid[i],nullptr);
}
return 0;
}
我们发现结果符合我们的预期。
3. 剖析锁的原理
由于我们对临界区加了锁,因此多个执行流在访问临界值的时候都是串行的,也就是说每次只让一个执行流区访问临界资源直到出了临界区,也就是解锁后才回再让这些执行流去竞争进入临界区。(对于临界区,我们的临界区要尽量短小精悍,因为锁是回影响执行效率的,这违背了我们创建线程的初衷,因此非必要不适用锁) 我们的临界资源加了锁后我们就可以说它是原子的。我们在访问临界资源时,先访问的是锁,先会去判断是否已经加锁了,并且会有多个执行流看到它,那么锁是不是临界资源呢?或者说是互斥锁是不是原子的呢?锁自己都保护不好自己怎么去保护别人,那么该如何去保证锁的安全呢?
我们先抛出答案,锁是具有原子性的。
我们来看看关于加锁和解锁的两段伪代码:
我们再次以一张图来理解这段伪代码:
我们有如下场景:
A线程正在进行加锁的过程,我们可以把申请的这把锁中的内容 “1” 看作一个令牌(一个锁只有一个)先是将0写入特定的寄存器当中,接着将锁的内容和寄存器中的0进行交换(这一步在汇编中只有一行代码,因此这一操作也是原子的) 若,这是,A线程被切换掉,B线程执行,此时会在从第一步开始执行,将0写入,然后交换,但这是交换到寄存器中的值是A进行交换时交换过去的0,因此在判断是,其会被挂起等待,此时A线程被换上去继续执行,恢复其上下文数据后,(这段数据中,也会记录A上次执行到了那一步),此时寄存器中的值就为恢复上来的1,进行判断后,加锁成功。
这就好比上面所提到的只有一个令牌,只要执行完交换语句后,A就拿到了这个令牌,成为它上下文中的一部分,哪怕被切下去,也没有关系,因为,寄存器只有一份,但寄存器中的数据可以有很多分。寄存器中的内容,是每一个执行流私有的。
此时B虽然被调度执行,但令牌已经没了,所以B只能等待。
对于解锁,就是将令牌归还与锁,这一动作也是有原子的。归还后,等待的线程会再次重复上述争令牌的过程。
交换的现象:内存<---->寄存器
交换的本质:原本锁中的数据:共享---->私有