目录
一、死锁
死锁是指一组进程中的各个进程均占有不会释放的资源,并且因互相申请被其他进程占用的不会释放的资源而处于一种永久等待的状态。具体来说,死锁发生时,每个进程都在等待某个资源,而这些资源恰好是由其他进程所持有的,导致所有相关进程都无法继续执行下去。
1、死锁的四个必要条件
-
互斥条件:
- 定义:一个资源每次只能被一个执行流(线程或进程)使用。如果一个资源已经被占用,其他执行流必须等待,直到该资源被释放。
- 例子:互斥量(mutex)确保同一时间只有一个线程可以访问共享资源。
-
请求与保持条件:
- 定义:一个执行流已经持有一个或多个资源,并且在等待获取其他资源时,它不会释放已经持有的资源。这种情况下,如果其他执行流也持有某些资源并等待其他资源,就可能导致死锁。
- 例子:线程 A 持有资源 1 并请求资源 2,而线程 B 持有资源 2 并请求资源 1,两个线程都会等待对方释放资源。
-
不剥夺条件:
- 定义:一个执行流已经获得的资源,在它使用完之前,不能被其他执行流强行剥夺。只有当持有资源的执行流主动释放资源时,其他执行流才能获得该资源。
- 例子:线程 A 持有资源 1,线程 B 不能强制从线程 A 那里夺取资源 1,除非线程 A 自愿释放它。
-
循环等待条件:
- 定义:存在一个由两个或多个执行流组成的循环链,每个执行流都在等待下一个执行流持有的资源。这种循环依赖关系会导致所有参与的执行流都无法继续执行。
- 例子:线程 A 等待线程 B 持有的资源,线程 B 等待线程 C 持有的资源,线程 C 又等待线程 A 持有的资源,形成一个循环等待链。
2、避免死锁的方法
1. 破坏互斥条件
- 方法:允许资源被多个执行流共享,而不是独占。这可以通过引入读写锁(reader-writer lock)来实现,允许多个读取者同时访问资源,但写入者必须独占资源。
- 适用场景:适用于那些可以安全地允许多个执行流同时访问的资源,例如只读数据。
2. 破坏请求与保持条件
- 方法:要求执行流在请求新资源之前,必须先释放所有已持有的资源。这样可以避免一个执行流在持有资源的同时等待其他资源。
- 适用场景:适用于那些可以在请求新资源前释放现有资源的场景。例如,在数据库事务中,可以在提交或回滚事务之前释放所有锁。
3. 破坏不剥夺条件
- 方法:允许系统或程序在必要时剥夺执行流持有的资源。例如,操作系统可以强制终止一个长时间持有资源而不释放的进程,或者在多线程环境中,使用超时机制来自动释放锁。
- 适用场景:适用于那些可以容忍资源被强行剥夺的场景,例如在高优先级任务需要立即获得资源的情况下。
4. 破坏循环等待条件
- 方法:通过引入某种顺序或规则,确保执行流之间的资源请求不会形成循环等待。常见的做法是:
- 加锁顺序一致:为所有资源分配一个全局唯一的编号,要求所有执行流按照相同的顺序请求资源。例如,如果资源 A 的编号小于资源 B,则所有线程都必须先请求资源 A,再请求资源 B。这样可以避免循环等待。
- 避免锁未释放的场景:确保每个执行流在请求新资源之前,必须先释放所有已持有的资源。这可以通过使用 RAII(Resource Acquisition Is Initialization)模式来实现,确保资源在作用域结束时自动释放。
- 资源一次性分配:要求执行流在开始执行之前,一次性申请所有需要的资源。如果无法获得所有资源,则拒绝执行,避免部分资源被占用后等待其他资源的情况。
二、条件变量实现线程同步
1、为什么需要线程同步
线程同步是多线程编程中的一个重要概念,其目的是确保多个线程在共享资源时能够正确协作,避免数据竞争、死锁、饥饿等问题的发生。
1. 避免数据竞争
在多线程环境中,当多个线程同时访问共享资源时,如果没有适当的同步措施,可能会导致数据竞争。数据竞争指的是两个或更多的线程访问同一个变量,并且至少有一个线程修改了这个变量,这样的情况下如果没有正确的同步机制,程序的行为将是未定义的。例如,两个线程同时递增一个全局计数器,最终结果可能不是预期的两次递增后的值。
2. 防止死锁
死锁是指两个或多个线程在执行过程中因争夺资源而造成的一种相互等待的现象。如果线程A持有资源X并等待资源Y,而线程B持有资源Y并等待资源X,那么这两个线程就会陷入死锁状态。良好的线程同步策略可以帮助避免这种情况。
3. 解决饥饿
饥饿是指某些线程由于资源分配不均或其他原因,长时间得不到执行的情况。例如,一个优先级较低的线程可能一直被优先级较高的线程抢占CPU时间片,导致它始终无法获得执行的机会。通过合理的同步机制可以避免这种不公平现象。
4. 提高效率和性能
没有同步机制时,线程可能会不断地轮询(Polling)共享资源的状态,试图获取对资源的访问权。这种轮询不仅消耗CPU资源,而且效率低下。条件变量等同步工具可以让线程在等待资源变为可用时进入睡眠状态,直到其他线程发出信号表明资源可用为止,这样可以大大提高系统的整体效率。
5. 维护数据一致性
在并发环境中,保持数据的一致性是非常重要的。比如,在数据库事务处理中,多个线程可能需要读写同一份数据。为了保证数据的一致性,需要使用同步机制来确保在一个事务完成之前,其他事务不能干扰其执行。
6. 顺序执行
有时候,我们希望线程按照一定的顺序执行任务。例如,在生产者消费者模式中,生产者产生的数据需要被消费者消费。如果没有适当的同步机制,生产者可能过快地产生数据,导致缓冲区溢出;或者消费者可能因为没有数据可消费而空转。通过使用互斥锁、条件变量等工具,我们可以控制生产者和消费者的交互,确保它们按照预定的顺序工作。
2、条件变量、同步、竞态条件
为什么需要条件变量?
临界资源的检测
在申请临界资源之前,首先需要检测该资源是否可用。这一过程本质上也是对临界资源的访问,因此必须在加锁和解锁之间进行。这意味着在检测资源状态时,必须确保没有其他线程正在修改该资源,以避免出现竞争条件。
常规方式的局限性
常规的方式通常是通过频繁地申请和释放锁来检测条件是否就绪。这种方法虽然可以确保线程在访问资源时的安全性,但也带来了性能上的问题。频繁的锁申请和释放会导致上下文切换的开销,降低系统的整体效率。
有没有办法让我们的线程检测到资源不就绪的时候:
-
等待:让线程在资源未就绪时进入等待状态,而不是频繁地进行检测。这可以通过条件变量来实现,条件变量允许线程在某个条件不满足时挂起,直到被其他线程通知。
-
通知机制:当条件就绪时,通知相应的线程进行资源申请和访问。这种方式可以有效减少不必要的资源消耗和上下文切换,提高系统的响应速度。
我们可以使用条件变量实现线程同步来解决
条件变量
条件变量是一种同步原语,允许线程在某个条件不满足时进入等待状态,并在条件满足时被唤醒。这样可以有效减少不必要的锁申请和释放,提高程序的性能。
- 当一个线程试图访问或操作一个依赖于某种条件的临界资源时,比如一个空的队列,若此时没有元素可供消费,线程就会陷入无法继续执行的状态。
- 这时,条件变量允许线程在该条件不满足时进入等待状态,而不是持续消耗CPU资源进行无效循环检查(自旋等待)。
- 当另一个线程修改了状态,例如向队列中添加了一个新的元素,满足了原先等待线程的需求条件,这时可以通过发送一个信号告知等待的线程,使其从等待状态恢复执行。
条件变量的使用
使用条件变量的基本步骤如下:
-
加锁:在访问共享资源之前,首先需要获取锁,以确保对资源的安全访问。
-
检查条件:在加锁后,检查条件是否满足。
-
如果条件已经满足:
- 直接处理:线程可以直接处理共享资源,而不需要调用条件变量的等待方法。
- 解锁:在处理完共享资源后,线程释放锁,继续执行其他任务。
-
如果条件不满足,线程可以调用条件变量的等待方法,释放锁并进入等待状态。
-
-
等待通知:当其他线程修改了共享资源并改变了条件后,可以通过条件变量的通知方法唤醒等待的线程,被唤醒后将再次获取锁。
-
重新检查条件:被唤醒的线程在继续执行之前,应该再次检查条件是否满足,以确保在被唤醒时条件确实是满足的。线程可能会被虚假唤醒(即在没有实际条件变化的情况下被唤醒),或者在多个线程同时被唤醒的情况下,条件可能已经被其他线程处理。
-
解锁:在完成对共享资源的访问后,释放锁。
同步
-
数据一致性:确保在任何时刻,多个线程对共享数据的访问不会导致数据状态的不一致。例如,如果两个线程同时对一个计数器进行加一操作,最终的结果可能会不正确。
-
防止数据混乱:在没有同步的情况下,多个线程可能会同时读取和写入数据,导致数据的混乱和错误。
-
避免资源竞争:当多个线程同时请求对同一资源的访问时,可能会导致资源竞争,进而引发不公平调度和饥饿问题。同步机制可以确保某个线程在访问资源时,其他线程必须等待,从而避免这种情况。
竞态条件
- 竞态条件(Race Condition)是指程序的结果依赖于多个线程执行的相对时机,如果这些线程都在访问和修改同一个共享数据,且没有采取适当的同步措施,那么可能会出现不可预期的行为。
- 具体表现为:
-
数据不一致:由于线程的执行顺序不确定,可能会导致最终结果与预期不符。例如,两个线程同时对一个变量进行加一操作,最终的结果可能会比预期少一。
-
难以调试:竞态条件通常是间歇性的,可能在某些情况下出现,而在其他情况下则不会。这使得调试变得非常困难,因为问题可能在某次运行中出现,而在下一次运行中又消失。
-
安全性问题:在某些情况下,竞态条件可能导致安全漏洞。例如,多个线程同时修改用户权限时,可能会导致权限被错误地授予或撤销。
-
例子:共享银行账户
假设有两个朋友,小明和小红,他们共同拥有一个银行账户。这个账户的初始余额是 $100。两个人都可以随时存款或取款,但他们并没有事先约定好谁在什么时候进行操作。
-
小红想要取款:小红计划从账户中取出 80。她查看账户余额,发现有80。她查看账户余额,发现有100,于是她决定进行取款。
-
小明也想取款:与此同时,小明也想从同一个账户中取出 50。他也查看了账户余额,看到有50。他也查看了账户余额,看到有100,于是他也决定进行取款。
-
操作执行:
- 小红先执行了取款操作。她从账户中取出了 80,账户余额变为80,账户余额变为20。
- 然后,小明执行了他的取款操作。他也认为账户中有 100(因为他在小红取款之前查看的余额),于是他也取出了50。
-
最终结果:
- 经过这两次操作后,账户的实际余额应该是 20,但由于小明的操作基于过时的信息(他看到的余额是100),最终账户的余额变成了 $-30(即透支)。
在这个例子中,竞态条件发生的原因是 Alice 和 Bob 同时访问和修改同一个共享资源(银行账户),而没有采取适当的同步措施来确保操作的顺序和一致性。由于他们的操作是并发进行的,导致了最终结果的不一致。
因此,使用互斥锁和条件变量可以有效避免竞态条件的发生:
- 互斥锁用于确保每次只有一个线程可以访问临界区(如队列结构),从而消除数据竞争。
- 条件变量则在此基础上提供了额外的逻辑层,使得线程可以在特定条件成立时才执行后续操作,而不是盲目地尝试访问资源。
3、条件变量函数:初始化 销毁 等待 唤醒
在Linux多线程编程中,条件变量相关的函数主要用于线程间的同步与通信。以下是对这些函数的详细解释:
pthread_cond_init: 这个函数用于初始化一个条件变量。
pthread_cond_t cond;
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
pthread_cond_t
是条件变量的类型。- 参数
cond
是指向条件变量结构体的指针,这个函数会将其初始化为可用状态。 - 通常情况下,第二个参数
attr
设置为NULL,表示使用默认属性初始化条件变量。不过,也可以通过创建和设置pthread_condattr_t
类型的属性对象来自定义条件变量的属性,例如指定条件变量的类型(是否支持广播等)。
pthread_cond_destroy: 此函数用于销毁一个已初始化的条件变量。
int pthread_cond_destroy(pthread_cond_t *cond);
- 在不再需要条件变量或者所有使用该条件变量的线程都已完成之前,应调用此函数。只有当没有线程在条件变量上等待时,才能成功销毁。
pthread_cond_wait
这个函数会让当前线程阻塞,直到指定的条件满足。
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
- 线程首先必须持有与条件变量关联的互斥锁(通过参数
mutex
指定),然后调用此函数时会释放互斥锁,进入等待状态。 - 当其他线程调用666
dcast
唤醒等待在此条件变量上的线程时,等待的线程会重新获取互斥锁并返回。
参数
cond
:指向条件变量的指针。条件变量必须已经通过pthread_cond_init
或pthread_cond_t
初始化。mutex
:指向互斥锁的指针。互斥锁必须已经通过pthread_mutex_init
或pthread_mutex_t
初始化,并且在调用pthread_cond_wait
之前必须已经锁定。
返回值
pthread_cond_wait
返回以下值之一:
0
:成功。- 非零错误码:表示发生错误。可能的错误码包括:
EINVAL
:条件变量或互斥锁的值无效。EDEADLK
:互斥锁已经被当前线程锁定,导致死锁。
如何使用 pthread_cond_wait
使用 pthread_cond_wait
的典型模式如下:
- 锁定互斥锁:在调用
pthread_cond_wait
之前,需要先锁定互斥锁。 - 调用
pthread_cond_wait
:线程调用pthread_cond_wait
并释放互斥锁。 - 等待条件满足:当前线程会被立即阻塞,线程进入等待状态,直到另一个线程通过
pthread_cond_signal
或pthread_cond_broadcast
唤醒它。 - 重新获取互斥锁:当线程被唤醒时,它会重新获取互斥锁。
- 检查条件:线程在继续执行之前应该再次检查条件是否满足,因为可能有其他线程在唤醒当前线程之前改变了条件。
pthread_cond_signal 和 pthread_cond_broadcast: 这两个函数用于唤醒正在条件变量上等待的线程。
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
pthread_cond_signal
: 唤醒一个(任意一个)正在等待条件变量cond
的线程。如果有多个线程在等待,仅选择一个线程解除阻塞。pthread_cond_broadcast
: 唤醒所有正在等待条件变量cond
的线程。所有等待的线程都会收到信号,但具体哪个线程能立即恢复执行还取决于互斥锁的获取顺序。
4、实现简单的多线程程序
这段代码是一个简单的多线程程序,在POSIX环境下使用C++编写,利用pthread
库实现线程间的同步。程序创建了一个互斥锁(mutex)和一个条件变量(condition variable),以及四个线程,每个线程分别执行func1
、func2
、func3
和func4
中的任务。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
// 定义线程数量常量
#define TNUM 4
// 定义回调函数类型,该类型函数接收字符串引用、互斥锁指针和条件变量指针作为参数
typedef void (*func_t)(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond);
// 定义一个结构体,用于存储传递给线程的数据
class ThreadData
{
public:
// 构造函数,初始化线程数据
ThreadData(const std::string &name, func_t func, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
: name_(name), func_(func), pmtx_(pmtx), pcond_(pcond) {}
// 线程名
std::string name_;
// 需要执行的函数指针
func_t func_;
// 互斥锁指针
pthread_mutex_t *pmtx_;
// 条件变量指针
pthread_cond_t *pcond_;
};
// 函数1,线程执行体之一
void func1(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
while (true)
{
// 线程在此等待条件变量的信号
pthread_cond_wait(pcond, pmtx);
std::cout <&