🏠大家好,我是Yui_💬 🍑如果文章知识点有错误的地方,请指正!和大家一起学习,一起进步👀 🚀如有不懂,可以随时向我提问,我会全力讲解~ 🔥如果感觉博主的文章还不错的话,希望大家关注、点赞、收藏三连支持一下博主哦~! 🔥你们的支持是我创作的动力! 🧸我相信现在的努力的艰辛,都是为以后的美好最好的见证! 🧸人的心态决定姿态! 💬欢迎讨论:如有疑问或见解,欢迎在评论区留言互动。 👍点赞、收藏与分享:如觉得这篇文章对您有帮助,请点赞、收藏并分享!
🚀分享给更多人:欢迎分享给更多对编程感兴趣的朋友,一起学习!
前面的文章,我们讲解了线程的基础知识和如何控制线程。但是线程中最重要的互斥和同步机制还没有涉及,那么本篇文章将会带领大家理解线程的互斥与同步。 在此之前,先让我们来看一段经典的多线程抢票程序吧。
1. 多线程抢票
思路很简单,假设有1000张票,让5个线程去抢,抢到为0为止。
我们可以通过这个程序看到,最后居然抢到了负数的票。但是我们的程序好像没有错误啊。怎么回事呢?
2.原因分析-资源共享问题
在上面的抢票程序中,全局变量是ticket
,所以它也是线程的共享资源。
如果我们想要对ticket
进行修改,需要几步?
答案是3步。
- 将
ticket
从内存拷贝到寄存器中。- 在
CPU
内完成计算。- 从寄存器中转移到内存。
如下图所示:
这是单线程情况下,改变一个值需要3步,其实好像也没什么吧,比较计算机的速度非常块,用户根本也感受不到。
但是如果我们变成多线程呢?
虽然改变一个值的步骤仍然不变,但是多线程对共享资源的处理是存在竞争的现象的。
我们假设,有两个线程分别为:thread1
和thread2
.
在某个时刻,thread1
准备对ticket
进行修改,当ticket
通过内存拷贝到寄存器时,thread2
出现把ticket
直接切走,导致原来的操作滞后。
大部分情况下这种事件都不会发送,因为CPU
的计算速度超级快,会执行完全部操作的。但是在上面的抢票程序中这种还是发送了。
这是由于休眠操作导致了,这里我们仅仅分析当ticket=1
时的这瞬间。
当ticket=1
时,满足循环中的if
条件(ticket>0
)。假设此时是线程thread-1
在执行该操作,进入if
语句后,执行休眠。但是CPU
可以不会开始休眠,它会马上运行下一个线程,假设此时CPU
选择的是thread-2
,那么又因为ticket
的值还没有修改,导致ticket
还是等于1
,那么thread-2
满足了if
条件,其他线程同理,过了一段时间thread-1
醒了,开始ticket--
操作,其他线程后续醒了也会执行ticket--
操作,导致最后的票被抢成负数了。
那是不是只要把usleep
给去掉就不会出现负数情况了,也不是,只是概率会很低。
正确的做法是加锁。
3知识补充-临界资源
在多线程的场景中,对于像前文中的ticket
这种可以被多线程看到的同一份资源称为临界资源,涉及对临界资源进行操作的上下文代码区域称为临界区。
<font color=red>临界资源的本质就是多线程共享资源,而临界区为涉及共享资源操作的代码区间。</font>
4. 知识补充-'锁'
如果我们想要安全的访问临界资源,就必须确保临界资源在使用时的安全性,也就是有锁
。
用生活中的例子来说就是:
公共厕所,众所周知公共厕所是公共资源,所有人都可以使用,但是你也不想你在使用的时候被人打扰吧,所以公共厕所的卫生间都是有门的而且都有锁。
对于临界资源也是如此,为了访问时的安全,可以通过加锁来实现。实现多线程间的互斥访问、互斥锁是解决多线程并发访问问题的手段之一。
具体操作就是:在进入临界区之前加锁,出临界区之后解锁。
还是以前面的抢票程序为例。
假设此时正在执行的线程为thread-1
,当它在访问ticket
时如果进行了加锁,在thread-1
被切走了后,假设此时进入的线程为thread-2
,thread-2
无法对ticket
进行操作,因为此时锁被thread-1
持有,thread-2
只能堵塞式等待锁,直到thraed-1
解锁。
因此,对于thread-1
来说,在加锁环境中,只要接手了访问临界资源ticket
的任务,要么完成,要么不完成,不会出现中间状态,像这种不会出现了中间状态】结果可预期的特性称为原子性
。
也就是说,加锁的本质是为了实现原子性
。
在加锁的同时,我们还需要注意以下几点:
- 加锁、解锁是比较耗费系统资源的,会在一定程序上降低程序的运行速度。
- 加锁后的代码是串行执行的,势必会影响多线程场景中的运行速度。
- 为了尽可能降低影响,加锁粒度要尽可能地细。
上面的内容都是为了引出下面线程互斥与同步的操作。
5. 线程互斥
线程互斥(Thread Mutual Exclusion)是多线程编程中为避免多个线程同时访问共享资源而导致数据不一致的问题。通过线程互斥机制,可以确保在任意时刻,只有一个线程可以访问临界区(Critical Section),从而保证共享数据的完整性和一致性。 正如我们上面讲的那样,总结下来就是两个原因使得我们需要线程互斥。
- 共享资源冲突:多个线程同时修改同一共享变量可能会导致数据错误。
- 例如,一个线程正在写入数据,另一个线程同时读取,可能导致读取的数据不完整。
- 原子性问题:某些操作本身并不是原子的,需要将其保护起来。
- 例如,对一个变量执行
x = x + 1
实际上是三步操作:读取、加一、写回。如果没有互斥,多个线程同时执行会导致结果不正确。 那么在Linux,我们要怎么做到线程互斥呢?
- 例如,对一个变量执行
5.1 常见的线程互斥机制
加锁。
提供一个锁机制,在临界区时上锁,离开时解锁。
Linux下的原生线程库,提供了类型为pthread_mutex_t
的互斥锁,互斥锁需要进行初始化和销毁。
5.1.1 pthread_mute_init
函数
在 Linux 多线程编程中,互斥锁是用来保护共享资源,防止多个线程同时访问同一个资源而导致数据竞争的问题。pthread_mutex_init
函数用于初始化一个互斥锁(mutex)。
参数说明
pthread_mutex_t *mutex
指向一个互斥锁对象的指针。需要在使用前分配内存空间,通常定义为全局或堆内存变量。const pthread_mutexattr_t *attr
指向一个互斥锁属性对象的指针,用于设置互斥锁的行为。如果为NULL
,则使用默认属性。 返回值
- 0:函数执行成功。
- 非 0 错误码:函数执行失败,常见错误包括:
EAGAIN
:系统资源不足,无法初始化互斥锁。ENOMEM
:内存不足。EINVAL
:传递了无效参数。
5.1.2 pthread_mutex_destroy
函数
pthread_mutex_destroy
用于销毁已经初始化的互斥锁对象。它释放互斥锁占用的系统资源。在多线程程序中,互斥锁在使用结束后必须销毁,否则可能导致资源泄漏。
参数说明
pthread_mutex_t *mutex
指向需要销毁的互斥锁对象。 返回值- 0:销毁成功。
- 非 0 错误码:
EBUSY
:互斥锁当前被其他线程锁定,无法销毁。EINVAL
:传递的互斥锁无效或未初始化。
下面来看这两个函数在程序中的生态位。
注意:
- 互斥锁是一种资源,因此[初始化互斥锁]操作应该在线程之前完成,[销毁互斥锁]操作应该在线程运行结束后执行;总结就是使用前创建,使用后销毁。
- 对于多线程来说,应该让他们看到同一把锁,否则无意义。
- 不能重复销毁互斥锁。
- 已经销毁的互斥锁无法使用。
知识补充
我们在使用pthread_mutex_init
初始化互斥锁的方式称为动态分配,需要手动初始化与销毁,除此之外还存在静态分配,也就是在定义互斥锁时初始化为PTHREAD_MUTEX_INITIALIZER
静态分配的优点在于无需手动初始化和销毁,锁的生命周期伴随程序,缺点就是定义的互斥锁一定是全局互斥锁。
5.2 加锁与解锁
当我定义完锁以及初始化后就可以开始加锁操作了。
互斥锁的加锁和解锁操作主要由pthread_mutex_lock
和pthread_mutex_ulock
来完成。
5.2.1 pthread_mutex_lock
函数
加锁一个互斥锁对象。如果互斥锁已经被其他线程锁住,调用的线程会阻塞,直到该锁可用。
参数
pthread_mutex_t *mutex
:指向需要加锁的互斥锁对象。 返回值- 0:加锁成功。
- 非 0 错误码:
EINVAL
:传递的锁无效或未初始化。EDEADLK
:发生死锁(调用线程已经持有该锁,或者死锁检测机制发现潜在问题)。
5.2.2 pthread_mutex_unlock
函数
解锁一个互斥锁对象。如果有其他线程因等待该锁而阻塞,解锁后会唤醒一个阻塞的线程。
参数
pthread_mutex_t *mutex
:指向需要解锁的互斥锁对象。 返回值- 0:解锁成功。
- 非 0 错误码:
EINVAL
:传递的锁无效或未初始化。EPERM
:当前线程不是锁的拥有者。
5.3 重写抢票程序
理解锁后,我们就可以重新书写原来的抢票代码了。
上面是静态版本,下面我们优化下代码,写一个动态版本。
无论运行多少次,最终的剩余票数都是0,并且所有线程抢到的票数之和为1000.
注意:
- 凡是访问同一个临界资源的线程,都需要进行加锁保护,而且必须是同一把锁,这是规定。
- 每一个线程访问临界区资源之前都要加锁,本质上是给临界区加锁
5.4 细节补充—锁是不是临界资源?
锁是临界资源 那岂不是还要给锁也搞一个锁?那就无限递归下去了。 虽然锁是临界资源,但是锁是原子的。 锁的设计者在设计锁时就已经考虑到这个问题了,对于锁这个临界资源进行了特殊化的处理:加锁和解锁的操作都是原子的,不存在中间状态,也就不需要保护了。
5.5 知识补充-死锁问题
死锁是指在多线程或多进程环境下,多个线程或进程因争夺资源而相互等待,导致它们都无法继续执行的一种状态。 造成死锁的4个必要条件。
- 互斥条件:
某些资源在某一时刻只能被一个线程占用(如互斥锁)。 - 占有且等待条件:
一个线程已经占有了某些资源,同时又请求其他资源,并处于等待状态。 - 不可剥夺条件:
已经占有的资源无法被强制剥夺,只能由占有者主动释放。 - 循环等待条件:
存在一个线程等待环,比如线程 A 等待线程 B 持有的资源,线程 B 等待线程 C 持有的资源,而线程 C 又等待线程 A 持有的资源。 如果这四个条件同时满足,就可能发生死锁。 演示:
- 线程 1:先锁定
lock1
,然后尝试锁定lock2
。 - 线程 2:先锁定
lock2
,然后尝试锁定lock1
。 - 如果线程 1 和线程 2 分别获得了
lock1
和lock2
后同时等待对方释放锁,就会发生死锁。 避免死锁的方法 1.避免循环等待 通过为资源编号,线程按固定顺序请求资源。例如,始终先锁定lock1
,再锁定lock2
。
2. 避免长时间持有锁
- 减少锁定的时间窗口。
- 解锁尽可能早,避免锁长时间占用。
6. 线程同步
线程同步是多线程编程中用于协调线程间访问共享资源的技术,目的是避免因竞争条件(Race Conditions)导致的数据不一致或程序异常。通过线程同步,能够保证多线程在正确的顺序下安全地访问共享数据。
6.1 知识补充-饥饿问题
饥饿(Starvation)问题是计算机系统中资源管理的常见问题之一,发生在某些线程或进程长期无法获得所需的资源,导致其无法执行或严重延迟。 如果我们仅仅只是加锁,假设此时锁由线程1占有,一段时间后,线程1打算解锁,解锁后线程1仍然是最容易拿到锁的一个线程,因为距离锁是最近的,那么它就可以一直拿,拿完放,放完拿。那么其他线程不就拿不到了吗,这就导致了饥饿问题。为了避免这种问题,便引入了同步机制,当一个线程解锁后不能马上再拿锁,必须到后面排队。
这是我前面写的代码,这段代码在运行过程中可能其他线程根本抢不到票。
6.2 条件变量
条件变量(Condition Variable)是一种线程同步机制,通常与互斥锁(Mutex)一起使用,提供了一种线程间的等待-通知机制。通过条件变量,线程可以在等待某个条件满足时释放锁,并进入等待状态;条件满足后,另一个线程可以通知它继续执行。
条件变量的本质就是 衡量访问资源的状态
6.2.1 同步相关操作
作为出自 原生线程库 的 条件变量,使用接口与 互斥锁 风格差不多,比如 条件变量 的类型为 pthread_cond_t
,同样在创建后需要初始化
6.2.1.1 pthread_cond_init
函数
pthread_cond_init
函数用于初始化一个条件变量,使其可以在多线程同步中被使用。
参数说明
cond
- 指向条件变量的指针。
- 通常是一个
pthread_cond_t
类型的变量。
attr
- 条件变量的属性指针,通常设置为
NULL
,表示使用默认属性。 - 自定义属性需要通过
pthread_condattr_t
设置。 返回值
- 条件变量的属性指针,通常设置为
- 0:初始化成功。
- 其他值:错误码,表示初始化失败。
6.2.1.2 pthread_cond_destroy
函数
pthread_cond_destroy
函数用于销毁条件变量,释放与其相关的资源。
参数说明
cond
- 指向条件变量的指针。 返回值
- 0:销毁成功。
- 其他值:错误码,表示销毁失败。 注意事项
- 销毁前,必须确保没有线程在等待该条件变量。
- 条件变量销毁后不能再被使用,除非重新初始化。
6.2.1.3 pthread_cond_wait
函数
pthread_cond_wait
用于阻塞当前线程,直到接收到其他线程发送的信号。此函数需要与互斥锁一起使用,确保在等待条件时线程的操作是线程安全的。
参数说明
cond
- 指向条件变量的指针。
mutex
- 指向一个已加锁的互斥锁。当线程进入等待状态时,会释放该互斥锁;线程被唤醒后,重新获得互斥锁。 返回值
- 0:成功。
- 其他值:错误码,表示失败。 关键点
- 释放互斥锁:在调用
pthread_cond_wait
时,线程会自动释放与之关联的互斥锁。 - 重新加锁:被唤醒后,线程重新获得互斥锁。
- 假唤醒:条件变量可能出现假唤醒(spurious wakeup),因此
pthread_cond_wait
通常与循环配合使用以检查实际条件是否满足。
6.2.1.4 pthread_cond_signal
函数
pthread_cond_signal
用于唤醒一个等待在条件变量上的线程。如果有多个线程在等待,唤醒其中一个线程。
参数说明
cond
- 指向条件变量的指针。 返回值
- 0:成功。
- 其他值:错误码,表示失败。 关键点
- 仅唤醒一个线程:如果有多个线程在等待条件变量,仅一个线程会被唤醒。
- 被唤醒的线程重新获得互斥锁:唤醒线程时,条件变量相关的互斥锁必须已经解锁。
注意:同步机制也支持全局条件变量,允许自动初始化、自动销毁
6.3 简单的线程同步代码
可以看到,子线程正在以一种既定的顺序执行,这就是同步的作用。
7. 总结
在多线程编程中,线程同步与异步是两个核心概念,它们在保障程序稳定性和提升性能方面各司其职。同步通过协调线程间的执行顺序,避免了资源竞争和数据不一致的问题;而异步则通过允许线程独立执行任务,提升了系统的响应效率和并行能力。两者在不同场景下有着独特的应用价值,但也可能引发死锁或线程饥饿等问题。通过合理运用锁机制、条件变量等工具,我们可以在同步和异步之间找到平衡,为程序的稳定性和效率提供保障。掌握这些技巧,不仅是提升编程能力的关键,也是构建高效、健壮系统的基础。 往期Linux文章:Linux