能证明独立栈的存在
2.1相关概念
2.2引入
我们利用上次自己封装的Thread来写一段多线程抢票代码
Thread.hpp
最后一运行发现:
问题分析
为什么会抢到负数?:对全局的的判断不是原子的
此时,当第一个进程从内存里把g_tickets读到CPU的寄存器中,进行判断,此时成立。然后因为sleep(),线程挂起(带走自己是上下文数据),CPU调度线程让下一个来了,又是同样的,因为把g_tickets读到CPU的寄存器中(还是1)……
最后,新线程都在等待队列里面时_tickets 都是1,然后遇到了这条语句,都开始执行,先从内存读数据- ->>每次自减后都要写会回物理内存,那么就会导致,下一个线程执行 td-> _tickets–时,又会从内存里把已经减过一次的数据读回来
其实不是原子的。本质上是这三步
那么最后的汇编语句大概率也是三条语句,在这三条语句之间都有可能发生时间片到了导致线程切换
汇编语句只有一句,那么就是原子的
问题解决思路
要解决以上问题,需要做到三点:
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
2.3Linux中互斥量/互斥锁(mutex)
接口介绍
关于静态变量与全局变量的小知识:
初始化:
静态初始化的互斥锁是在编译时就已经初始化好了,而不是在运行时动态初始化。 宏会将互斥锁初始化为一个静态的、已经被初始化的状态,这样就可以不用显式调用 来初始化互斥锁 不需要显式调用 函数来销毁互斥锁。这是因为静态初始化的互斥锁是在编译时就已经初始化好了,并且通常会在程序结束时自动被系统释放
函数原型:
参数说明:
返回值:
销毁互斥量:
销毁互斥锁是在不再需要使用互斥锁时释放其资源的重要操作。在销毁互斥锁时需要注意以下几点:
函数原型:
参数说明:
返回值:
互斥量加锁和解锁:
在多线程编程中,互斥锁(mutex)是一种用于保护共享资源的同步机制。互斥锁需要在访问共享资源之前进行加锁操作,访问完成后进行解锁操作,以确保同一时刻只有一个线程可以访问共享资源,避免数据竞争和不确定行为的发生。
pthread_mutex_lock 函数:
当调用 函数时,如果互斥量处于未锁定状态,那么该函数会成功将互斥量锁定,并且立即返回成功。这意味着当前线程已经获得了对互斥量的独占访问权限。 然而,如果在调用 函数时,其他线程已经锁定了互斥量,或者有其他线程同时尝试锁定互斥量但未竞争成功,那么当前线程的调用将会被阻塞(即执行流被挂起),直到互斥量被解锁为止。这种行为确保了只有一个线程能够同时访问临界区,避免了数据竞争和不确定行为的发生。 只有一个线程会申请锁成功,成功的会接着执行。其余申请锁失败都会阻塞在那
pthread_mutex_unlock 函数:
开始解决问题
解决方案1:出现的并发访问的问题,本质是因为多个执行流执行访问全局数据的代码导致的。保护全局共享资源的本质是通过保护临界区完成的。那我们就加锁让一个线程去抢票(全局互斥锁)
但是如果我们换个操作系统就有可能发生,全部都是一个相同的线程来抢票(它的竞争力太强了) 竞争锁是自由竞争的,竞争锁的能力太强的线程,会导致其他线程抢不到锁 — 造成了其他线程的饥饿问题 下面我们会利用同步来解决
局部互斥锁
先来复习一下线程的状态 除了正在执行(running)和挂起(blocked/sleeping/waiting)状态外,还有几种常见的线程状态:
在操作系统中,挂起、等待和阻塞是相关但不完全相同的概念:
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令(汇编指令),该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。 现在我们把lock和unlock的伪代码改一下
本来我们定义的是在内存中的。数据在内存里,所有线程都能访问,属未共享的。但是如果转移到CPU内部寄存器中,就属于一个线程私有 当线程1竞争成功时,1被交换到寄存器内,也就是线程1的上下文中。CPU寄存器硬件只有一套,但是CPU寄存器内的是数据线程的硬件上下文 而且我们执行的是交换,不是拷贝,这保证了只有一个。加之交换是原子的,即便线程被切换的时机是随时的,发生了切换,但是那时mutex已经到了某个线程的上下文中了,凭借这个值,就能执行下方代码,而其他线程就阻塞了
那现在还有个问题:在临界区内部,正在访问临界区的线程可以被OS切换调度吗?——答案是可以的。正在执行的线程是可以被操作系统(OS)切换调度的。即使一个线程已经获取了锁并进入了临界区,仍然有可能被操作系统暂时挂起
现在假设有一个线程 A 正在访问临界区(已经获取了锁),而其他线程 B、C、D 正在等待获取这个锁。在这种情况下,这条语句对于其他线程只有两种情况是有意义的(锁被释放,或者没线程申请到了锁):
临界区的代码对于其他线程是原子的,因为只有一个线程能够同时访问临界区。其他线程在等待获取锁的过程中不会执行临界区的代码,从而确保了临界区操作的原子性和线程安全性。
概念
线程安全是针对线程执行时,各个线程的相互关系。而重入是属于函数的特点
常见的线程不安全的情况
常见的线程安全的情况
常见不可重入的情况
常见可重入的情况
死锁是指在并发系统中的一种状态,其中每个进程都在等待系统资源,但这些资源被其他进程占用,导致所有进程都无法继续执行,形成一种互相等待的僵局状态。 死锁是多线程对锁不合理的使用,导致代码不会继续向后正常推进
死锁是在并发系统中常见的一种问题,指的是多个进程或线程因竞争系统资源而陷入无限等待对方释放资源的状态,导致所有进程都无法继续执行,形成一种僵局。死锁的发生通常总是伴随着系统资源的互相占用和互相等待。
死锁发生的必要条件通常包括:
当满足以上四个条件时,就会发生死锁。
避免死锁的最有效方式是破坏死锁的四个必要条件:
还可以采取以下具体措施来避免死锁:
在了解线程同步之前先明确几个概念:串行、并发和并行。描述了多任务处理的不同方式。
在多核处理器中,可以实现并行处理,即同时在多个核心上执行不同的任务,以提高整体系统的执行效率。而并发则更多指的是在单个处理器上通过快速切换实现多任务间的交替执行
线程同步是指多个线程之间协调和控制其执行顺序,以避免出现竞态条件(Race Condition)和数据竞争(Data Race)等问题。
在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。由于多个线程的操作顺序不确定或不对称而导致的错误结果或异常情况。当多个线程在对共享资源进行读写操作时,如果它们的操作顺序不正确,可能会导致程序出现意外的结果
6.1条件变量(Condition Variable)
条件变量是一种线程同步的高级机制,它允许线程在某个特定条件下等待。条件变量通常与互斥锁一起使用,用于线程之间的协调和通信。条件变量允许一个线程在某个条件不满足时等待,当条件满足时,其他线程可以通知等待的线程继续执行。
6.2接口介绍
条件变量是多线程编程中用于线程间协调和通信的一种机制。它通常与互斥锁一起使用,用于等待某个条件的发生并在条件满足时唤醒等待的线程。条件变量的接口函数包括初始化、销毁、等待条件满足和唤醒等待等操作。
初始化条件变量
静态初始化条件变量
上述代码使用了宏来进行静态初始化,这样就可以在定义条件变量时直接初始化,无需调用函数。这种方式适用于条件变量的属性使用默认值的情况。
注意事项:
动态初始化
销毁条件变量
等待条件满足
使当前线程等待在指定的条件变量上,直到条件满足或被其他线程唤醒。
当线程调用 时,它会暂时离开临界区,因为 会自动释放传递给它的互斥锁。这是为了允许其他线程能够访问和修改与条件变量相关联的共享数据,同时避免死锁。 具体来说,当线程调用 时,会发生以下步骤:
因此,在调用 时,线程会短暂地离开临界区,等待条件变量被触发,然后再重新进入临界区。这种机制确保了线程在访问共享数据时能够正确地同步,并避免了竞态条件和其他并发问题。
在调用函数时需要传入一个互斥锁(mutex),这是因为条件变量(condition variable)通常与互斥锁一起使用,以确保线程在等待条件时能够正确同步和避免竞态条件(race condition) 在使用条件变量时,通常会遵循以下步骤:
所以就是:线程A得到锁,执行等待条件->释放锁,等条件变化 - -> 另一个线程又申请到锁,又在等条件变化…… 最后所有线程都在条件那里等着 在使用条件变量时,线程在等待条件变化时会先释放之前获取的互斥锁,然后等待在条件变量上的信号。当条件满足时,线程被唤醒后需要重新获取之前释放的互斥锁,这是因为在等待条件变化时释放互斥锁是条件变量机制的一部分。先释放再获取的 具体原因包括:
因此,在使用条件变量时,线程需要在等待条件变化时释放互斥锁,等待条件满足后重新获取互斥锁,以确保线程能够正确同步共享资源的访问。这样可以避免竞争条件和确保线程安全地访问共享资源。
唤醒等待
我们写了这样的一份代码,会发现最一开始输出是乱的
这是因为,所有的进行都向一个文件进行写入(标准输出流),那么此时标准输出流就是共享资源,是临界资源
使用条件变量来解决
就是在slave thread的执行函数里进行加锁和条件等待 在master thread的执行函数里进行唤醒
超市(交易场所):
生产者(Producer):
消费者(Consumer):
3种关系:
生产者 vs 生产者 — 互斥
多个生产者线程可能同时试图向共享缓冲区(如队列或数组)中写入数据。为了防止数据竞争和不一致,我们需要使用互斥机制来确保同一时间只有一个生产者线程能够访问共享资源。 互斥通常通过互斥锁(Mutex)来实现。当一个生产者线程获得互斥锁时,其他生产者线程将被阻塞,直到锁被释放。这样,每个生产者线程在写入缓冲区时都能独占资源,从而避免了数据竞争。
消费者 vs 消费者 — 互斥
多个消费者线程可能同时试图从共享缓冲区中读取数据。为了确保数据的正确性和一致性,我们同样需要使用互斥机制来防止多个消费者线程同时访问缓冲区。 互斥锁在这里同样起到关键作用。当一个消费者线程获得互斥锁时,其他消费者线程将被阻塞,直到锁被释放。这样,每个消费者线程在读取缓冲区时都能独占资源,避免了潜在的冲突和不一致。
生产者 vs 消费者 — 互斥 && 同步
生产者线程和消费者线程需要共享一个缓冲区。这要求我们使用互斥机制来确保同一时间只有一个线程(生产者或消费者)能够访问缓冲区,以避免数据竞争和不一致。 但是,仅仅互斥是不够的。我们还需要使用同步机制来确保生产者和消费者之间的协调。例如,当缓冲区为空时,消费者线程应该被阻塞,直到生产者线程向其中添加了数据。同样地,当缓冲区满时,生产者线程也应该被阻塞,直到消费者线程从中取走了数据。 同步通常通过条件变量(Condition Variables)来实现。生产者线程在添加数据到缓冲区后,会向条件变量发送信号(signal),以唤醒等待的消费者线程。类似地,消费者线程在取走数据后,也会向条件变量发送信号,以唤醒等待的生产者线程。通过这种方式,生产者和消费者线程能够协调地工作,确保缓冲区的有效使用和数据的一致性。
优点:
阻塞队列(BlockingQueue)
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。 其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
这里有个疑问,明明我们放任务和拿任务时都是串行的(加了锁,一次只有一个线程),为什么生产消费模型优点还是并发性呢?
我们来尝试实现一个BQ
一个实际应用的例子
Thread.hpp与BlockQueue.hpp我们上面已经进行展示了,接下来只进行剩下二者
Task.hpp
Main.cc
今天也是到这里啦!!!
版权声明:
本文来源网络,所有图片文章版权属于原作者,如有侵权,联系删除。
本文网址:https://www.mushiming.com/mjsbk/5181.html