深入理解多线程


前言

前面我们已经学习了多线程的基本概念,在深入了解多线程之前也可以回顾一下---->初始多线程


1、线程退出

线程退出都会有两种情况:

  1. 主线程没有等待新线程结束而提前退出,则整个进程退出,但新线程也是一个执行流,此时就可能会造成内存泄漏
void* thread_run(void* args)
{
	const char* ch = (const char*)args;
	while(1)
	{
		printf("我是%s, pid: %d, ID: %p", ch, getpid(), pthread_self());
		sleep(1);
	}
}

int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, thread_run, (void*)"新 pthread");

	printf("我是主pthread, pid: %d, ID:%p\n",getpid(), pthread_self());
	sleep(1);
	return 0;
}

主线程创建了一个新线程,新线程进入死循环,而主线程退出,进程也会随着主线程退出而退出
在这里插入图片描述

  • 新线程退出而主线程不退出。主线程继续执行
void *thread_run(void *args)
{
	const char *ch = (const char *)args;

	printf("我是%s, pid: %d, ID: %p\n", ch, getpid(), pthread_self());
	sleep(1);
}

int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, thread_run, (void *)"新 pthread");

	//父进程一直死循环
	while (1)
	{
		printf("我是主pthread, pid: %d, ID:%p\n", getpid(), pthread_self());
		sleep(1);
	}
	return 0;
}

在这里插入图片描述
新线程的退出并没有导致整个进程的退出。主线程还是在一直执行。

线程正常退出的结果也有两种:
1.结果正常
2.结果不正常

新进程异常不会被主线程不知道,因为异常是进程级别的,如果一个线程异常就会导致整个进程异常,最终的结果可能导致进程崩溃

2、线程等待

  • 为什么需要线程等待?

因为和进程一样,新线程创建出来就是用来分摊主线程的工作的,主线程需要知道新线程是否完成了工作,完成的工作怎么样了

在这里插入图片描述
参数:
thread: 线程ID
retval: 输出型参数,获取线程的返回值
返回值: 成功返回0;失败返回错误码。

调用该函数的线程将挂起等待,直到id为thread的线程终止

void *thread_run(void *args)
{
	const char *ch = (const char *)args;

	printf("我是%s, pid: %d, ID: %p\n", ch, getpid(), pthread_self());
	sleep(5);
	return (void*)"我是返回值";
}

int main()
{
	pthread_t tid;
	void* ret;
	pthread_create(&tid, NULL, thread_run, (void *)"新 pthread");


	printf("我是主pthread, pid: %d, ID:%p\n", getpid(), pthread_self());
	pthread_join(tid, &ret);
	printf("%s\n", (char*)ret);
	return 0;
}

在这里插入图片描述
新线程的返回值可以是任意数据类型或者对象的地址,但是前提是不能是临时数据

3、线程终止

  • 如果只需要终止某个线程,而不终止进程的方法有3种:
  1. 函数中的return(a.main函数退出return的时候代表(主线程和进程的退出) b.其他线程函数return,只代表当前线程的退出)
  2. 线程可以调用pthread_exit函数终止自己
  3. 一个线程可以代用pthread_ cancel函数去终止同一进程中的另一个线程,主线程可以终止新线程,新线程也可以终止主线程(不推荐这样做)

使用return终止线程

void* thread_run(void* args)
{
	const char* ch = (const char*)args;
	printf("我是%s\n",ch);
	return 123;
}

int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, thread_run, (void*)"new thread");
	void* status = NULL;
	pthread_join(tid, &status);
	printf("新线程的退出信息: %d\n", (int)status);
	return 0;
}

在这里插入图片描述

使用pthread_exit函数终止线程
在这里插入图片描述
参数:
retval:不能是一个局部变量或者临时变量
无论在线程的哪个位置调用该函数都会终止该线程,和return一样
只需要将return 123改为 *pthread_exit((void)123)**即可

在这里插入图片描述
线程中能不能使用exit函数终止线程?
答案是不能的,因为eixt是终止进程的,无论在哪个线程调用exit函数都会终止整个线程

void* thread_run(void* args)
{
	const char* ch = (const char*)args;
	printf("我是%s\n",ch);
	sleep(2);
	exit(12);
}

int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, thread_run, (void*)"new thread");
	void* status = NULL;
	pthread_join(tid, &status);
	printf("新线程的退出信息: %d\n", (int)status);
	sleep(5);
	return 0;
}

在这里插入图片描述
通过验证可以发现,在线程内调用exit是直接终止进程的,通过echo $? 查看退出码为12,和exit的参数是一致的

使用pthread_ cancel取消目标线程
不建议新进程取消主线程,因为会产生类似僵尸进程的危害
在这里插入图片描述
参数:
thread:线程ID
返回值:成功返回0,失败返回错误码

void* thread_run(void* args)
{
	const char* ch = (const char*)args;
	while(1)
	{
		printf("我是%s\n", ch);
		sleep(1);
	}
}

int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, thread_run, (void*)"new thread");
	printf("wait sub thread...\n");
	sleep(3);

	printf("cancel sub thread..\n");
	pthread_cancel(tid);
	sleep(2);
	void* status = NULL;
	pthread_join(tid, &status);
	printf("新线程的退出信息: %d\n", (int)status);
	sleep(3);
	return 0;
}

在这里插入图片描述
主线程通过调用pthread_cancel确实将新线程终止了。
为什么被pthread_cancel()退出以后退出码是-1呢?

答:用pthreat_cancel()函数退出别的别线程以后,该线程是被异常终止掉的,value_ ptr所指向的单元里存放的是常数PTHREAD_CANCELED。该函数是一个宏定义。就是-1。
在这里插入图片描述

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参

4、线程分离

  • 当不需要关心某个线程执行结果如何,或者执行的对与错,就可将这个线程进行分离。线程分离带来的最直观的感受就是主线程不需要关心这个线程的执行如何,线程分离之后不需要被join,运行完毕后,会自动释放Zpcb(类似sigsignal(SIGCHLD, SIG_IGN))
  1. 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏
  2. 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源
  3. 线程组内其他线程对目标线程进行分离,也可以是线程自己分离

分离其他线程:

int pthread_detach(pthread_t thread);

分离自己:

pthread_detach(pthread_self());

主线程调用pthread_detach(pthread_t thread)分离新线程

void *thread_run(void *args)
{
	const char *ch = (const char *)args;
	while (1)
	{
		printf("我是%s\n", ch);
		sleep(1);
	}
	return (void *)123;
}

int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, thread_run, (void *)"new thread");

	sleep(3);
	//进行线程分离
	printf("thread_dedach begin\n");
	pthread_detach(tid);

	void *status = NULL;
	int ret = pthread_join(tid, &status);
	printf("join的返回值: %d,新线程的退出信息: %d\n", ret, (int)status);
	return 0;
}

在这里插入图片描述
运行代码,我们发现join的返回值为22,不是0,说明join是失败的。而status也没有拿到新线程的退出结果。
所以一个线程被设置分离之后,一定不能再join了,因为一定会失败的

小总结:

  • 线程分离退出码是0,而不是线程返回的退出码,是因为将线程分离以后,拿不到线程的退出码,也不需要拿到它的退出码
  • 线程分离以后,被分离的线程会自动释放自己的资源。不需要被等待,退出码也不会写进PCB
  • 但是线程被分离以后,如果线程中有异常,还是会影响当前进程

5、线程互斥

5.1 线程间的互斥相关背景概念

  • 临界资源: 多线程执行流共享的资源就叫做临界资源(多线程,多进程打印数据到显示器【临界资源】)
  • 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 同步: 一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥和原子性的),让访问资源具有一定的顺序性
  • 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

在这里插入图片描述

5.2 互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
  • 多个线程并发的操作共享变量,会带来一些问题

例如下面的购票例子就有很大的问题

#define NUM 5
int tickets = 100;//临界资源

void *ThreadRoutine(void *args)
{
    int id = *(int *)args;
    delete (int *)args;

    while (true)
    {
        if (tickets > 0)
        {
            usleep(1000);
            cout << "我是[" << id << "] 我要抢的票是: " << tickets << endl;
            tickets--;
        }
        else
            //没有票了
            break;
    }
}

int main()
{
    pthread_t tid[NUM];
    for (int i = 0; i < NUM; ++i)
    {
        int *id = new int(i);
        pthread_create(&tid[i], nullptr, ThreadRoutine, (void *)id);
    }

    for (int i = 0; i < NUM; ++i)
    {
        pthread_join(tid[i], nullptr);
    }
    return 0;
}

在这里插入图片描述

一共有5个线程会去抢100张票,按常理来说当100票都被抢完了,就没有票抢了,但是结果却不是这样。因为tickets是全局变量,没有进行加锁,所以它不是安全的

要解决以上问题,需要做到三点:

  1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  2. 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量

在这里插入图片描述

5.3 互斥量的接口

初始化互斥量:

  • 初始化互斥量有两种方法:
  1. 静态分配

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

  1. 动态分配

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t
*restrict attr);

参数:

  • mutex:要初始化的互斥量
  • attr:NULL

销毁互斥量:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:

  • mutex:需要销毁的互斥量

销毁互斥量需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

互斥量加锁和解锁:

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:

  1. mutex:要初始化的互斥量

返回值:

  • 成功返回0,失败返回错误号

调用 pthread_ lock 时,可能会遇到以下情况:

  1. 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  2. 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁

5.4 通过加锁改进购票系统

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;

#define NUM 5

class Ticket
{
private:
    int tickets;
    pthread_mutex_t mtx;

public:
    Ticket()
        : tickets(100)
    {
    	//初始化锁
        pthread_mutex_init(&mtx, nullptr);
    }

    bool GetTicket()
    {
        bool res = true;
        //抢票时需要加锁
        pthread_mutex_lock(&mtx);
        //执行这部分代码的执行流式互斥的,是串行执行的!
        if (tickets > 0)
        {
            usleep(1000); // 1s = 1000ms  1ms = us
            cout << "我是[" << (long long)pthread_self() << "] 我要抢的票是: " << tickets << endl;
            tickets--;
        }
        else
        {
            res = false;
            printf("票已经被抢空了\n");
        }
        //不抢票时,需要解锁
        pthread_mutex_unlock(&mtx);
        return res;
    }

    ~Ticket()
    {
    	//销毁锁
        pthread_mutex_destroy(&mtx);
    }
};

void *ThreadRoutine(void *args)
{
    Ticket *t = (Ticket *)args;
    while (true)
    {
        if (!t->GetTicket())
            break;
    }
}

int main()
{
    Ticket *t = new Ticket();
    pthread_t tid[NUM];
    for (int i = 0; i < NUM; ++i)
    {
        pthread_create(&tid[i], nullptr, ThreadRoutine, (void *)t);
    }
	
    for (int i = 0; i < NUM; ++i)
    {
        pthread_join(tid[i], nullptr);
    }
    return 0;
}

在这里插入图片描述

5.5 互斥量实现原理

  • 经过上面的例子,如果不是原子的,有可能会有数据出现问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期

在这里插入图片描述

6、互斥量总结

  1. 对临界区进行保护,所有的执行线程都必须遵守这个规则。
  2. 基本过程:lock->访问临界区->unlock。
  3. 所有的线程必须先看到同一把锁,锁本身就是临界资源!锁本身得先保证自身安全!申请锁的过程,不能有中间状态,也就是两态的,lock->原子性,unlock->原子性。
  4. lock->访问临界区(花时间)->unlock,在特定线程/进程拥有锁的时候,期间有新线程过来申请锁,一定不能申请到,那么新线程进行阻塞,将进程,线程对应的PCB投入到等待队列。
  5. 加锁时候效率变低,本来是并行/并发执行流,加锁以后成了串行执行流。
  6. 一次保证只有一个线程进入临界区,访问临界资源,就叫做互斥。

7、可重入和线程安全

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数

7.1 常见的线程不安全的情况

  1. 不保护共享变量的函数
  2. 函数状态随着被调用,状态发生变化的函数
  3. 返回指向静态变量指针的函数
  4. 调用线程不安全函数的函数

7.2 常见的线程安全的情况

  1. 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  2. 类或者接口对于线程来说都是原子操作
  3. 多个线程之间的切换不会导致该接口的执行结果存在二义性

7.3 常见不可重入的情况

  1. 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  2. 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  3. 可重入函数体内使用了静态的数据结构

7.4 常见可重入的情况

  1. 不使用全局变量或静态变量
  2. 不使用用malloc或者new开辟出的空间
  3. 不调用不可重入函数
  4. 不返回静态或全局数据,所有数据都有函数的调用者提供
  5. 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

7.5 可重入与线程安全联系

  1. 函数是可重入的,那就是线程安全的
  2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  3. 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的

7.6 可重入与线程安全区别

  1. 可重入函数是线程安全函数的一种
  2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
  3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
  • 9
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值