【Linux下】 线程操作及线程互斥
线程操作
注:以下函数使用时,编译文件时需带上-lpthread选项,因为下面的函数都是线程库里面的函数
pthread_self
#include <pthread.h>
pthread_t pthread_self(void);
**作用:**获取当前线程的tid
1. 创建线程
pthread_create
作用描述:
参数:
thread: 输出型参数,传入pthread_t类型的变量,然后获取到线程的id(pthread库里面的id,也是该线程结构体存放的地址(虚拟内存地址))
attr: 线程的属性配置,一般都使用NULL,代表默认属性
start_routine: 线程所要执行的函数 (返回值为void* 参数为void* 的函数指针)
arg: start_routine的函数参数(类型为void*)
返回值:
返回值同其他函数不同,该函数错误时返回的是错误码
例:
void *run_pthread(void *argc) { while(1) { sleep(1); printf("i am a new pthread:%d my name:%s \n",pthread_self(),argc); } } int main() { pthread_t id=0; pthread_create(&id,NULL,run_pthread,"thread 1"); while(1) { sleep(1); printf("I am main pthread:%u\n",pthread_self()); } return 0; }
运行结果:
2. 等待线程
为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间
同进程等待一样,线程也是需要被等待的,不然可能会导致“僵尸线程的出现”
操作接口
pthread_join
**作用:**等待指定的线程退出,并可选择获取线程所执行函数的退出码
参数:
thread:我们需要等待的线程id --pthread_create中的第一个参数
retval: 线程所执行函数stashedinrt_routine的返回值,也称为线程退出时的退出码,特殊:当我们的线程被cancel时,返回值会自动被设定成PTHREAD_CANCELED(-1),也是输出型参数
返回值:
同pthread_create一样,错误时直接返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到所等待的线程的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数**PTHREAD_ CANCELED。 **
- 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
3. 线程退出
回顾:
我们在之前进程的博客里曾讲过进程退出的3种场景
- 代码执行完毕,运行结果正确
- 代码执行完毕,运行结果不正确
- 代码异常,进程异常终止
之前判断进程正常退出时,运行结果是否正确是通过进程退出码判断的,进程异常可以通过事后调试的方法更加清楚的知道错误在哪里;而我们的线程其实也是存在上面3种情况,也可以设置退出码来判断是否运行结果是否正确
线程退出的情况
- 程序异常
- 异常退出,不需要处理,因为我们进程那就已经设计好了处理方案,而我们线程终止就代表其所在的进程也被终止了,所以线程异常终止的情况是是不需要我们用户进行处理的,os会认为是进程异常然后去处理
- 程序正常退出,通过线程函数返回值判断运行是否正确(程序猿自己定,os中并没有规定)
- 使用pthread_exit函数退出,同return;主线程使用该函数进行退出时,情况会很特殊,会出现主线程呈现僵尸线程的情况,而其他线程仍然在与运行的情况
- 使用cancel函数取消线程,可以终止其他线程,也可以终结自己
- 使用return,从子线程中返回–相当于函数返回,这个方法不适用于主线程(在主线程中返回相当于退出整个进程)
相关操作接口:
pthread_exit
作用描述:退出当前线程,并返回退出码
参数:
- retval: 退出码,pthread_join函数中获得的退出码
**注:**pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel
作用: 取消指定线程
参数:
- pthread: 我们所需要取消的线程id
返回值:
同前面一样,失败返回错误码
**特殊点:**该函数如果成功将目标线程cancel掉,pthread_join中退出码会设置成 **PTHREAD_CANCELED(-1)**为系统定义的宏值
代码:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
using namespace std;
#define NUM 5
void * pthread_run(void *args)
{
int id=*(int *)args;
while(1)
{
printf("I am 新线程:[%d]:%p\n",id,pthread_self());
sleep(5);
//pthread_cancel(pthread_self()); //pthread_cancel取消成功后给父进程的退出码是-1
//pthread_exit((void*)123);
return (void*)123;
}
}
int main()
{
pthread_t tid[NUM];
for(int i=0;i<NUM;i++)
{
printf("I am mainpthread:%d\n",getpid());
int * id=new int (i);
pthread_create(&tid[i],nullptr,pthread_run,(void* )id);
}
void * code;
//线程等待
for(int i=0;i<NUM;i++)
{
int res=pthread_join(tid[i],&code);
printf("res:%d ;退出码为:%d\n",res,code); //等待已经被分离的线程会得到22结果
}
return 0;
}
运行结果:
小结:
- 使用return和pthread_exit的效果是差不多的,可以返回我们所想返回的值
- 使用pthread_cancel中,取消线程成功的标志是,该线程的退出码为-1–系统中定义的宏
4. 线程分离
线程分离类似于进程当中,子进程退出时会给父进程发送SIGCHID信号,将该信号的处理方法设置为SIG_IGN之后,os会自动回收子进程资源,而不再需要父进程对子进程进行资源回收等工作
线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
pthread_detach
作用:将线程分离出来,被分离的线程的资源会被系统自动回收,不用在需要我们主线程去回收他
参数:
thread: 我们所要分离的线程的编号
线程tid vs LWP
- tid: 我们通过pthread_create的第一个参数获得的线程id,进行线程操作时所使用的线程唯一标识
- LWP: os级别的线程标识,使用于CPU调度线程时,对线程的唯一标识
而这俩者的关系是怎么样的呢?
我们知道我们上面所使用的线程操作函数,实际上都来自pthread线程库,而不是系统调用接口,而线程库就是动态库,我们使用该库时和使用c语言库实际上并没有什么区别;而在动静态库的博客中,我们曾提到过,动态库使用的动态链接,会在需要的时候把所需要的代码映射到我们的程序地址空间的共享区里;当然线程库也不例外
我们之前曾经提过线程是有自己的栈结构的,实际上这个栈结构(也称为用户栈)是在动态库里面就定义好的(如上图),即实际上pthread库中还设计了线程用户级别的描述块,所以说实际上线程tid实际上就是进程地址空间的一个虚拟地址–保存的是当前线程的用户级线程描述块在进程地址空间的地址
所以从这我们就可以得知,tid就是用户级别的线程唯一标识,而这个用户级线程描述块一定是封装了线程os级别的唯一标识LWP,因为调用系统接口操作线程时肯定是通过LWP进行操作的
小结:
- tid实际上是一个具体的虚拟地址的值,用于操作线程,而LWP是os级别的线程唯一标识
- 俩者不同主要在于使用场景的不同,tid用于用户操作线程,LWP用于os进行调度线程时的标识
相关概念补充
- 临界资源: 凡是被线程所共享访问的资源都称为临界资源
- 临界区: 线程的代码中访问临界资源的代码就叫做临界区
- 对临界区进行保护,实际上就是对临界资源进行保护。 保护方式:互斥或者同步
- 互斥: 在任何时刻,只允许一个执行流访问某段代码(该代码用来访问临界资源),就称为互斥
- 同步: 一般而言,在保证访问临界资源是线程安全的前提下,让访问资 源具有一定的顺序性,即让不同的线程更均衡的享受临界资源
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
线程安全问题
我们曾在前面说过,在进程的地址空间中,定义一个全局变量,或者是函数;因为线程之间是共享地址空间的,所以对于当前地址空间的所有线程来说,都是可以操作全局变量或者是调用该函数的;
什么是线程安全问题?
当多个线程同时对一个共享资源进行非原子性操作时,就会出现该数据出现异常情况,这就是线程安全问题。
例子:
我们创建出来5个线程,执行抢票逻辑,5个线程抢1000张票
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
#include <string>
#include <cstdlib>
#include <ctime>
int ticket =1000;
void* tickettach(void* argv)
{
bool ref=true;
int id=*( (int*)argv);
delete argv;
while(ref)
{
ref=t->buytickets();
if(ticket>0)
{
usleep(10000);
printf("i am[%d]:%u,我正在抢票:%d \n",id,pthread_self(),ticket);
ticket--;
pthread_mutex_unlock(&mutex);
}
else{
printf("票已经被枪光了\n");
break;
}
}
return (void *)111;
}
//实现多个进程抢票逻辑
int main()
{
buyticket * t=new buyticket;
pthread_t pid[5];
for(int i=0;i<5;++i)
{
int *id =new int (i); //这里可能会造成内存泄漏
pthread_create(pid+i,nullptr,tickettach,(void*)id);
}
for(int i=0;i<5;i++)
{
pthread_join(pid[i],nullptr);
}
return 0;
}
运行结果:
我们会发现:
当多个线程对ticket进行–时,最后ticket明明已经到0了,而线程依旧在抢票,这是为什么呢?
解释:
ticket–在c语言代码中看似是一行代码,但实际上在汇编代码是分成了3条指令的,也就是说ticket–并不是原子性操作–补充:一条汇编指令的操作通常是原子的
-
**load:**将共享变量ticket从内存加载到寄存器中
-
update: 更新寄存器里面的值,执行-1操作
-
**store:**将新值,从寄存器写回共享变量ticket的内存地址
如图:
所以实际上ticket–并不是原子性操作,而我们之前说过一条汇编代码往往是原子的;
解释:为什么ticket最后会出现负值的情况
因为每个线程执行自己的代码都是有时间限制的(时间片),例如:假设现在的ticket为1000,当线程A正要执行上面的ticket–的最后一步将运算结果写回时,线程A的时间片到了,线程A就会将运算好的ticket结果(999)考到自己的上下文数据中,等待下一次时间片的到来;而线程B进行执行抢票代码时,也是先将对应的数据拷贝到CPU的寄存器中,然后对ticket进行运算,而后将运算结果拷回内存中,经过进程B的运算之后,ticket成功减到了10,恰好这时候进程B的时间片到了,进程B就被挂起等待了;而此时又到了进程A的时间片,于是进程A又将自己的上下文数据拷贝到CPU的寄存器中,继续执行上次还未执行完毕的代码,而后将运算结果写回到内存中,此时就会出现矛盾;实际上ticket已经减少到了10,而经过进程A的操作之后,ticket又变成了999;
ticket–实际上就是临界区,访问的是临界资源(全局变量ticket)
如何解决上面出现的问题的呢?
-
代码必须要有互斥行为:当线程进入临界区执行时,不允许其他线程进入该临界区。
-
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
-
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
简单图示:
其中就是使用的互斥量保持的代码的互斥性,下面进行详细讲解
线程互斥
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
所以就有了互斥量的出现,使用互斥量可以保证每次只有一个线程在访问临界资源;互斥量也被称为锁
互斥量操作接口
初始化互斥量
pthread_mutex_init
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
**作用:**初始化互斥量
参数:
- **mutex: ** 所需要初始化的锁
- attr: 通过这个可以设置锁有关的属性 – 一般填nullptr即可
返回值:
成功返回0,失败返回错误码
销毁互斥量
pthread_mutex_destroy
int pthread_mutex_destroy(pthread_mutex_t *mutex);
**作用:**释放掉互斥量
参数:
- mutex: 所需要销毁的互斥量
返回值:
成功返回0,失败返回错误码
上锁–获取互斥量
pthrlead_mutex_lock
int pthread_mutex_lock(pthread_mutex_t *mutex);
函数作用: 申请互斥量,只有携带互斥量的线程才能进入临界区
参数:
- mutex: 所需申请的互斥锁
返回值: 成功返回0,失败返回错误码
解锁–释放互斥量
pthread_mutex_unlock
int pthread_mutex_unlock(pthread_mutex_t *mutex);
函数作用: 释放互斥量,允许其他线程申请互斥量
参数:
- mutex: 所需释放的互斥量
返回值: 成功返回0,失败返回错误码
线程互斥改良抢票逻辑
通过互斥量实现线程互斥的本质是:
- 每个线程想要访问临界区资源之前,都要先申请互斥量
- 只有携带了互斥量的线程,才能进入临界区,对临界资源进行操作
- 每个进程使用完临界区资源后,都需要将互斥量释放,允许其他的线程对互斥量进行申请,继续访问临界区
基于上面的理论,改良之后的代码:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
#include <string>
#include <cstdlib>
#include <ctime>
int ticket =1000;
pthread_mutex_t mutex;
void* tickettach(void* argv)
{
//buyticket* t=(buyticket* )argv;
bool ref=true;
int id=*( (int*)argv);
while(ref)
{
//ref=t->buytickets();
pthread_mutex_lock(&mutex);
if(ticket>0)
{
usleep(10000);
printf("i am[%d]:%u,我正在抢票:%d \n",id,pthread_self(),ticket);
ticket--;
pthread_mutex_unlock(&mutex);
}
else{
printf("票已经被枪光了\n");
pthread_mutex_unlock(&mutex);
break;
}
}
return (void *)111;
}
//实现多个进程抢票逻辑
int main()
{
//buyticket * t=new buyticket;
pthread_t pid[5];
pthread_mutex_init(&mutex,nullptr);
for(int i=0;i<5;++i)
{
int *id =new int (i); //这里可能会造成内存泄漏
pthread_create(pid+i,nullptr,tickettach,(void*)id);
//pthread_create(pid+i,nullptr,tickettach,(void*) t);
}
for(int i=0;i<5;i++)
{
pthread_join(pid[i],nullptr);
}
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果:
我们可以发现抢票的逻辑实现是顺畅的,即最后不会出现票被减少到负数的情况
逻辑图理解互斥量实现线程互斥的方法:
互斥量底层
线程要申请互斥量,是不是得先知道互斥量这个变量,也就是说实际上互斥量是先被每个线程“见到”才能申请的,也就是说,实际上互斥量也属于临界资源;
而从上面的认识,我们知道,每个线程都是可以同时访问和申请锁🔒的,而os又是如何保证申请锁资源的这部分的代码的原子性的呢?
如图:
底层互斥锁申请锁伪代码
解释:
lock:
movb $0 ,%al //线程将自己的上下文数据加载到CPU的寄存器中(al)
xchgb %al, mutex //使用一条汇编指令将寄存器中的数据和内存中的锁的数据进行交换
if( al寄存器的内容>0 ) {
return 0;
}else
挂起等待;
goto lock ;
unlock:
movb $1, mutex //释放锁资源,将寄存器数据和内存中的锁的数据进行交换
唤醒等待Mutex的线程;return 0;
所以实际上:
- 保证线程访问锁🔒资源时,保证线程安全的是,os提供了swap或者是exchange指令,可以实现一条汇编完成内存和寄存器里的数据的交换–通过一条汇编指令保证对🔒资源的操作是原子性的
- 例子帮助理解:lock变量里面原来存放的值是1,代表该锁目前没有被任何线程所拥有,而这个时候来了俩个线程,分别是线程A,B;俩个线程都是先将自己的数据加载到CPU的上下文中,而后调用第二条指令,进行竞争🔒,而这时候进程A的速度快一些,迅速将1拿到了自己的寄存器,线程B执行第二条指令时,🔒已经被A拿走了,所以B的寄存器中的数据依旧为0–即代表没有抢到锁,所以在下面的分流中,就进入了else,而线程A就拿着🔒进入if里面,开始访问临界资源
理解:当线程A的时间片到了,被CPU挂起等待下次调度,这时候其他线程是否能进入临界区呢?
答案是当然不能,线程A被挂起等待之前,是先将寄存器中的变量(包括🔒)保存到自己的上下文数据中的,而后再被挂起的,也就是说线程A是“抱着🔒”被挂起的,所以没有🔒,其他线程依旧无法进入临界区中,除非线程A将🔒释放了(只可能在线程A执行完了自己的临界区的代码)
所以说:互斥量的设计是能保证线程安全的
补充: 线程运行代码时,CPU寄存器中的临时变量也是属于当前线程的
抽象图理解:
线程互斥的优缺点
优点:
- 使用线程互斥,可以保证多线程在对共享资源进行访问的线程安全,这是很有意义的–例如:实际中电影院买票,最后买到的是负票,就会出很大问题
缺点:
- 但是线程互斥带来安全的同时,其实也是做出了效率牺牲的,因为原来线程访问临界资源是并行的,而加了互斥锁之后,访问方式变成了串行–可以观察上面俩个运行动图,明显第二张图的运行速度是要比第一张图要慢的
- 可能会造成线程饥饿问题,如果某个线程竞争锁资源的能力太强,就会导致其他线程无法享用到临界资源 ,从第二张运行动图看来,实际上有俩个线程是没有参与到抢票中的
- 加大了编写代码难度
- 操作不当,可能会造成死锁之类的问题
所以侧面说明:任何东西都是具有俩面性的,我们既要看到其的缺点,也要看到其的优点
而为了解决线程饥饿问题,后面又会引出线程同步进行解决这个问题,下篇博客会进行讲解
可重入和线程安全
概念
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
死锁问题
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态
死锁四个必要条件
-
互斥条件:一个资源每次只能被一个执行流使用
-
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
-
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
-
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
-
破坏死锁的四个必要条件
-
加锁顺序一致
-
避免锁未释放的场景
-
oc或者new开辟出的空间