Linux相关概念和易错知识点(30)(线程互斥、线程同步)

ops/2025/2/27 5:12:44/

目录

1.线程互斥

(1)临界资源和临界区

(2)互斥和原子性

①互斥

②原子性

(3)加锁和解锁(互斥锁)的原理

(4)pthread_mutex系列函数和变量

①lock、unlock

②pthread_mutex_t

(5)加锁常见错误

(6)对互斥锁的认识

2.线程同步

(1)线程互斥引发的问题

(2)pthread_cond系列函数和变量

①pthread_cond_t

②wait、signal


1.线程互斥

(1)临界资源和临界区

临界资源:多执行流中需要被保护的共享的资源。如某一个函数可被所有线程访问,但是在同一时刻所有线程都进入该函数会导致这个函数执行异常(如打印混乱),那么这个函数就是不可重入函数,也就是需要被保护的共享资源,即临界资源对于线程来说,所有函数代码都是共享的,因此对多线程来说临界资源就是要防止多个线程同时访问的资源。

临界区:每个线程内部访问临界资源的代码。临界区是代码,保护临界资源的手段就是保护临界区,只要访问临界资源的代码受到保护,那么对应的临界资源就会受到保护。简而言之,保护临界区是手段,保护临界资源才是目的

(2)互斥和原子性

①互斥

先看一下下面的代码,最后m的结果应该是多少呢?


#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;int m = 10;void *fun(void *p)
{while (m > 0){sleep(1);m--;}return nullptr;
}int main()
{pthread_t pid_1, pid_2, pid_3, pid_4, pid_5;pthread_create(&pid_1, nullptr, fun, (void *)nullptr);pthread_create(&pid_2, nullptr, fun, (void *)nullptr);pthread_create(&pid_3, nullptr, fun, (void *)nullptr);pthread_create(&pid_4, nullptr, fun, (void *)nullptr);pthread_create(&pid_5, nullptr, fun, (void *)nullptr);pthread_join(pid_1, nullptr);pthread_join(pid_2, nullptr);pthread_join(pid_3, nullptr);pthread_join(pid_4, nullptr);pthread_join(pid_5, nullptr);printf("m = %d\n", m);return 0;
}

结果是

事实上,每次结果都可能不相同,在以上实例中,m可以被所有线程访问,即共享资源,访问修改这个共享资源的代码就是fun函数。如果所有线程同时访问这块代码,就有可能出现错误。

例如在m = 1时,pid_1线程执行到while (m > 0)这个语句时,会先将m的值从内存读到寄存器中,当读完之后,此时该线程时间片到了被切走后,该线程的寄存器的上下文被保存了下来。切到另一个线程之后,如果它执行到while (m > 0)这个语句时,它也会将m的值从内存读到寄存器中,其余线程也如此,这样就会导致判断语句将多个线程放进去了。每个线程的状态都不一样,当pid_1将m--完成之后,可能pid_2才会执行到这里,这个时候m又被--,同理,pid_3和pid_4都参与m--,导致m最终为负数。

因此这个m其实是需要被保护起来的,即临界资源,同样,fun函数中访问临界资源的代码(临界区)也需要被保护。我们通过保护临界区来保护临界资源。

这就叫线程互斥,即任意时刻只能有一个执行流进入临界区,访问临界资源。

②原子性

执行判断语句,CPU分为3步执行,即从内存获取数据到寄存器、分析、执行指令三步。

自减语句也分为3步,即从内存获取数据到寄存器、自减、写回内存三步。

以上两个语句均会被拆分为三个指令执行,并且这三个指令都同等重要,当任意一个指令被执行后就立马切走线程,就有可能导致出现错误。也就是说,执行判断语句和自减不是原子性的。

但是,导致上述代码的错误并不是单纯是因为这两个语句不是原子的,而是while循环的代码这个整体不是原子的。原子指的就是要么完成,要么还没完成,它不会被任何调度机制打断。从while循环的第一个判断语句的第一条指令开始,就视为这个代码块开始执行了,如果这个while循环的代码是原子的,就只有执行完和还没执行完两种状态,那么当第一个线程开始执行第一条指令后,就算存在时间片的轮转,其它线程也无法进入while的执行,因为第一个线程还没执行完。

从另一个角度来理解,一行指令肯定是原子的,因为无论时间片是怎样的,这条指令要么还没执行,要么就必须执行完,不存在执行了一半就被切走了。对于这条指令来说,同一时间内一定只有一个线程在执行它,只要当前线程开始执行它,那么就必须等这个线程执行完这个指令后,其它线程才有机会执行这个指令。

现在我们拓展一条指令为一个代码块,就是我前面所讲的例子。

总结:导致临界资源出现混乱的原因是临界区同时被多个线程访问。其根本原因是while循环代码块整体不是原子的,能同时放进来多个线程。如果while循环整体是原子的,那么一次性就只能允许一个线程进入,也就不会导致临界资源的错误了。

下面我们就要详细讲讲如何将一整个代码块变为原子的。

(3)加锁和解锁(互斥锁)的原理

首先当锁被创建之后,内存中就会新建一个变量mutex,这个变量默认为1。CPU内部有一个寄存器eax,在执行lock指令前默认先设为0,当调用lock指令时,eax和mutex里面的数据会进行交换(寄存器和内存里面的值进行交换),这个时候eax里面就为1,mutex里面的值为0。mutex不会主动修改自己的值,因此它会一直是0。如果执行lock指令之后eax为1,那么就让这个线程执行被加锁的代码。

在该线程执行代码期间,如果有其它线程来申请锁同样需要上面的步骤,eax设置为0,和mutex交换,判断eax是否为1。关键之处在于,当第二个线程交换mutex和eax时,eax本身是0不必多说,但mutex里面也是0,因为第一个线程已经把mutex里面的1交换走了!当线程切换时,第一个线程的eax的1被当作硬件上下文保存起来了,只留下了一个值为0的mutex在内存中。也就是说不管线程怎么切换,只要第一个线程不把mutex的1交换回去,其他线程无论怎么申请,都无法得到那个1。如果eax不是1,那么线程会被阻塞在加锁的函数里,隔一段时间后再次设eax为0,和mutex交换,判断eax。

因此加锁的核心就是那个交换寄存器和内存变量值的指令,只要谁先把内存中mutex的1拿走,谁就拥有临界区的访问权,而其它线程都必须等在原地。只有解锁时,线程把那个1还给mutex之后,其它线程才有机会进入临界区。通过这种方式,就实现了对整个代码块加锁,使其在线程看来是原子的。

形象的理解:mutex就是一间屋子外面墙上挂着的钥匙,并且这钥匙一次性只能给一个人用,谁拿到钥匙谁就可以用这个房间,并且其他人永远进不来,只能在外面等着。使用屋子的人可以中途上厕所、吃饭等,只要不重新把钥匙挂回墙上,这个屋子始终是他的。对其他人来说,拿着钥匙的人怎么用屋子这件事就是原子的,要么没用完,要么用完了。当使用这屋子的人将钥匙重新挂回墙上,其它人才能来抢钥匙,抢夺占有权。

为了实现互斥锁的操作,大多数体系结构都提供了swap和exchange指令,这个指令可以让寄存器内存单元交换数据。

(4)pthread_mutex系列函数和变量

①lock、unlock

pthread_mutex_lock( pthread_mutex_t * mutex )加锁,pthread_mutex_unlock( pthread_mutex_t * mutex )解锁。

pthread_mutex_lock就是执行上述eax设置为0,交换eax和mutex,判断eax是否为1的指令集合,那个1就是锁,凡是没有申请到锁的线程会被阻塞在函数中,直到1被还回来了。相对的,unlock就是还回那把锁的过程,也就是说,lock和unlock两行代码作为临界区的首尾,在这两个函数中间的代码对线程来说就是原子的,一次性只能通行一个线程。也要注意,锁有借有还,lock之后一定要unlock。

除此之外,还有一个函数pthread_mutex_trylock,它和lock唯一的区别是当它申请不到锁时不会一直阻塞在函数中,它申请锁后会返回一个值,让线程自主判断要不要继续申请。

②pthread_mutex_t

lock和unlock会用到一个变量,这个变量就是锁pthread_mutex_t。我们通过pthread_mutex_t mutex来定义它,它的底层定义如下,是一个联合体。

这把锁就对应内存中创建的mutex的1,每当创建一个pthread_mutex_t就会有一个1在内存中创建,可以说这个变量就是互斥锁的核心。结合前面的知识,体会它在加锁的整个流程的作用,体会为什么申请锁要传pthread_mutex_t *

下面是对上面代码的改进

通过加锁,锁间的代码在线程看来就变成原子的了,即实现了线程互斥,因此临界区的代码就不会被多个线程同时访问,也就不会出现临界资源错误了。

当创建的锁是全局或静态对象时,我们就用宏PTHREAD_MUTEX_INITIALIZER来对这个锁初始化,就像上图那样。我们之后也无需对锁进行任何其它操作。

除此之外,这个锁对象还可以是局部的,但这个时候需要手动init和destroy。

#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;int m = 10;void *fun(void *p)
{pthread_mutex_t* pmutex = static_cast<pthread_mutex_t*>(p);pthread_mutex_lock(pmutex);while (m > 0){sleep(1);m--;}pthread_mutex_unlock(pmutex);return nullptr;
}int main()
{pthread_mutex_t mutex;pthread_mutex_init(&mutex, nullptr);pthread_t pid_1, pid_2, pid_3, pid_4, pid_5;pthread_create(&pid_1, nullptr, fun, (void *)&mutex);pthread_create(&pid_2, nullptr, fun, (void *)&mutex);pthread_create(&pid_3, nullptr, fun, (void *)&mutex);pthread_create(&pid_4, nullptr, fun, (void *)&mutex);pthread_create(&pid_5, nullptr, fun, (void *)&mutex);pthread_join(pid_1, nullptr);pthread_join(pid_2, nullptr);pthread_join(pid_3, nullptr);pthread_join(pid_4, nullptr);pthread_join(pid_5, nullptr);pthread_mutex_destroy(&mutex);printf("m = %d\n", m);return 0;
}

注意init函数要在还处于单线程时就使用了,如果在多线程的代码下使用,每个线程访问这行代码后都能申请到锁,那就没有意义了。同理destroy就是销毁一把锁,一定要保证在完全用完这把锁之后才能使用。

(5)加锁常见错误

①init和destroy使用位置不当,每个线程来访问都能申请到锁

②创建锁的位置不当,多个线程生成了多把锁,临界区相当于没加锁

③部分临界区在锁外,导致加锁后一定会稳定地发生错误

④每次循环后都会解锁,直接破坏了代码块的原子性,和没加锁没区别

⑤加锁解锁没有配对,导致一直有线程被阻塞在lock函数里,这使得代码永远无法结束。

(6)对互斥锁的认识

我们能够认识到锁本身也是共享资源,但由于加解锁的核心指令是原子的,使得锁本身是安全的通过锁,我们能够将一段代码变为原子性的,使得线程访问必须一个一个进去,避免了同时访问会带来的错误。

加锁针对的是代码块,但一定不能无脑对大块代码加锁,要保证细粒度,最好只对临界区加锁。加锁会导致运行速度变慢,因为多线程执行任务时,在加锁段只能一个线程一个线程过去,好比独木桥,这座桥在保证足够用的情况下应尽量短。

我们前面讲的互斥锁都是软件实现的,加锁这件事还可以靠硬件实现。如OS一直在时钟中断,每次中断后会减去进程的时间片。当把外部中断和时钟中断禁用,不减时间片,直到执行完代码后恢复时,就实现了硬件的加锁。

2.线程同步

(1)线程互斥引发的问题

当一个线程申请到锁之后,其它线程再申请锁就会被lock,需要等待锁被还回来,当unlock后所有线程再去争夺同一把锁。这看似合理,但原本持有锁的那个线程在新一轮竞争锁的过程中最占有优势。就好比一个人还完钥匙后其它人还没反应过来的时候,他就可以再把锁拿走。这种不公平会引发效率问题,可能导致一直是同一个线程在使用锁。

要解决长期得不到锁引发的饥饿问题,需要定义一个新的规则,即被lock的线程都放到一个新的等待队列,一个线程还了锁后必须排到队列最后。这些在队列里的线程只有被唤醒后才能申请锁,这个唤醒操作每次都是针对队列头部的一个线程。这样一来,线程经过放入等待队列 -> 归还锁 -> 唤醒队头线程 -> 队头线程申请锁,就可以完美解决上述不公平问题。

于是说,多个线程访问临界资源需要线程互斥,多个线程访问临界资源还必须有顺序性,这就叫线程同步。线程互斥和同步紧密关联,互斥保证安全性,同步用于解决互斥锁带来的bug,保证系统调度更加合理和高效。

(2)pthread_cond系列函数和变量

①pthread_cond_t

这就是等待队列对应的联合体。当创建等待队列时,就需要通过pthread_cond_t cond来创建。当把线程放入等待队列的过程中,就要使用这个类型来接收线程。

它和pthread_mutex_t的创建销毁规则一模一样,全局或静态对象可用PTHREAD_COND_INITIALIZER且无需销毁,局部对象需用init和destroy手动创建销毁

②wait、signal

先看一下下面的使用,基于原先的代码进行同步改动

详细讲讲wait的详细操作,wait一定在lock和unlock之间,通常来说就紧跟在lock之后。当拿着锁的单个线程执行wait时,它要传递参数pthread_cond_t * 用于将线程加入等待队列中,还有第二个参数pthread_mutex_t * 用于还回锁。举个例子,A线程lock后拿到锁,执行wait后,A进入了等待队列并且把锁还回去。B线程就能从lock处申请到锁被放了进来,并且同样执行wait后,进入等待队列并还回锁,线程C、D亦是如此。因此,lock、wait后所有线程都被阻塞在等待队列之中。

注意lock后的线程只要有锁就不会被阻塞了,会放进去一个线程。而wait函数阻塞的线程会一直在等待队列里面阻塞,只有当对该等待队列执行signal时才会释放队头的线程。注意wait还有很重要的作用,signal后队头的线程不会马上被释放,还会被阻塞,这个线程会申请锁直到成功。所以wait后放出的线程会拿再次回这把锁,带着锁往下执行。

为什么会有这种功能?因为wait是在临界区,被阻塞在wait等待队列的线程手上都没有锁,当要往下执行时必须重新拿到锁,没有锁为何能够向下执行临界区的代码,这不就和之前的规定(进入临界区的线程拿着唯一的一把锁,目的是要保证该代码块的原子性)相悖吗?

这才是wait的线程被唤醒后一定要拿到锁才会向下执行的根本原因,注意体会逻辑的严密,理解大于记忆。

signal唯一的参数是pthread_cond_t * ,作用是唤醒相应等待队列的队头线程,只要保证指向的等待队列pthread_cond_t相同,该函数在任意地方执行均有效。

在上述代码中,所有执行fun函数的线程都被阻塞在wait队列中,主线程的signal唤醒了wait队列的队头线程,往下继续执行。unlock函数前是signal,它又唤醒了下一个线程,但这个线程必须在锁被还回去后才会被释放,也就是说unlock和signal的顺序不会改变执行逻辑,wait释放线程前都必须要申请到锁!

还有一个唤醒方法pthread_cond_broadcast( &cond ),它会立马唤醒所有队列里面的线程,参与到竞争中,一般来说用的很少,毕竟同步的目的就是保证顺序性,signal显然更严谨地保证顺序性。

下面是局部cond对象的代码

#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;int m = 10;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;void *fun(void *p)
{pthread_cond_t *pcond = static_cast<pthread_cond_t *>(p);pthread_mutex_lock(&mutex);pthread_cond_wait(pcond, &mutex);{sleep(1);printf("当前线程:0x%lx\n", pthread_self());m--;}pthread_cond_signal(pcond);pthread_mutex_unlock(&mutex);return nullptr;
}int main()
{pthread_cond_t cond;pthread_cond_init(&cond, nullptr);pthread_t pid_1, pid_2, pid_3, pid_4, pid_5;pthread_create(&pid_1, nullptr, fun, (void *)&cond);pthread_create(&pid_2, nullptr, fun, (void *)&cond);pthread_create(&pid_3, nullptr, fun, (void *)&cond);pthread_create(&pid_4, nullptr, fun, (void *)&cond);pthread_create(&pid_5, nullptr, fun, (void *)&cond);sleep(1);pthread_cond_signal(&cond);pthread_join(pid_1, nullptr);pthread_join(pid_2, nullptr);pthread_join(pid_3, nullptr);pthread_join(pid_4, nullptr);pthread_join(pid_5, nullptr);pthread_cond_destroy(&cond);printf("m = %d\n", m);return 0;
}

执行结果是


 


http://www.ppmy.cn/ops/161595.html

相关文章

【自学笔记】Spring Boot框架技术基础知识点总览-持续更新

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 Spring Boot框架技术基础知识点总览一、Spring Boot简介1.1 什么是Spring Boot&#xff1f;1.2 Spring Boot的主要特性 二、Spring Boot快速入门2.1 搭建Spring Boo…

Ajax数据采集与分析详解

文章目录 1. 什么是 Ajax&#xff1f;2. Ajax 的工作原理3. Ajax 在网页中的应用场景4. 爬取 Ajax 数据的方法4.1 分析网络请求4.2 模拟 Ajax 请求4.3 使用 Selenium 模拟浏览器4.4 使用 Headless 浏览器 5. 处理动态参数6. 处理分页和滚动加载7. 处理反爬虫机制8. 数据存储9. …

Linux 第三次脚本作业

源码编译安装httpd 2.4&#xff0c;提供系统服务管理脚本并测试&#xff08;建议两种方法实现&#xff09; 一、第一种方法 1、把 httpd-2.4.63.tar.gz 这个安装包上传到你的试验机上 2、 安装编译工具 (俺之前已经装好了&#xff09; 3、解压httpd包 4、解压后的httpd包的文…

【初阶数据结构】森林里的树影 “堆” 光:堆

文章目录 1.堆的概念及结构2.堆的接口实现2.1 堆的初始化2.2 堆的销毁2.3 堆的交换2.4 堆的向上调整2.5 堆的插入2.6 堆的向下调整2.7 堆的删除2.8 堆顶获取2.9 堆的判空2.10 堆的节点个数2.11 堆的打印2.12 堆的排序&#xff08;向上建堆&#xff09;2.13 堆的排序&#xff08…

C# String.Intern 方法 详解

总目录 前言 在C#开发中&#xff0c;字符串作为最常用的数据类型之一&#xff0c;其内存管理直接影响程序性能。当处理海量文本数据时&#xff0c;重复字符串的内存占用可能成为性能瓶颈。string.Intern 方法正是为解决这一问题而生的核心工具&#xff0c;它通过字符串驻留池&…

网络原理--TCP的特性

TCP报文的结构&#xff1a; TCP的报头前20字节是固定长度&#xff0c;也可以通过“选项”来增加。 一、用来确保可靠性&#xff0c;最核心的机制&#xff0c;称为“确认应答” 引入一个情景&#xff1a; A向B询问cat和dog的意思&#xff1a; 这种情况是理想情况&#xff0c;…

Python的那些事第三十一篇:快速数据帧处理与可视化的高效工具Vaex

Vaex:快速数据帧处理与可视化的高效工具 摘要 在大数据时代,高效的数据处理和可视化工具对于数据科学家和分析师至关重要。Vaex作为一种开源的Python库,专为处理超大数据集而设计,通过惰性计算、内存映射和并行化技术,显著提升了数据处理的效率和性能。本文详细介绍了Va…

k8s集群内的pod连接集群外部的mysql, k8s集群内部服务如何连接集群外部mysql? 一文搞明白

一、为什么不将mysql服务部署到k8s集群中使用呢&#xff1f; 1.有状态服务在K8s中的管理比较复杂&#xff0c;特别是持久化存储的问题。虽然K8s有StatefulSet和PV/PVC&#xff0c;但配置和维护起来需要更多工作,同时以下问题仍需解决&#xff1a;-存储可靠性&#xff1a;如果使…