目录
一、线程互斥相关背景概念
1. 临界资源与临界区
1.1 临界资源
1.2 临界区
🌴代码示例:线程通信
2. 原子性与互斥
2.1 原子性
2.2 互斥
🌴示例代码:抢票系统
3. 互斥量(Mutex)
3.1 核心API
3.2 初始化互斥量
3.3 销毁互斥量
3.4 加锁
3.5 解锁
🌴示例代码(使用互斥量的抢票系统)
4. 互斥量实现原理
4.1 加锁后的原子性
4.2 临界区内的线程切换
4.3 锁的自我保护
4.4 操作系统的工作原理
4.5 lock 和 unlock 的伪代码
二、可重入与线程安全:概念、联系与区别
1.概念
1.1 线程安全
1.2 可重入
2.常见的线程不安全情况
2.1 不保护共享变量的函数
2.2 函数状态随调用变化
2.3 返回指向静态变量指针的函数
2.4 调用线程不安全函数的函数
3. 常见的线程安全情况
3.1 只读全局变量
3.2 原子操作
3.3 无二义性结果
4. 常见的不可重入情况
4.1 调用 malloc 或 free
4.2 标准 I/O 库函数
4.3 使用静态数据结构
5. 常见的可重入情况
5.1 不使用全局变量或静态变量
5.2 不使用动态内存分配
5.3 不调用不可重入函数
5.4 返回局部数据
5.5 使用本地数据或全局数据的本地拷贝
6. 可重入与线程安全的联系
7. 可重入与线程安全的区别
三、死锁与阻塞
1. 死锁的定义
1.1 什么是死锁?
1.2 单执行流是否可能产生死锁?
2. 阻塞的定义
2.1 什么是阻塞?
2.2 阻塞的类型
3. 死锁的四个必要条件
3.1 互斥条件
3.2 请求与保持条件
3.3 不剥夺条件
3.4 循环等待条件
4. 避免死锁的方法
4.1 破坏死锁的四个必要条件
4.2 加锁顺序一致
4.3 避免锁未释放的场景
4.4 资源一次性分配
4.5 死锁检测算法
4.6 银行家算法
四、Linux线程同步
1. 同步的概念
1.1 什么是同步?
1.2 同步的重要性
2. 竞态条件
2.1 什么是竞态条件?
2.2 竞态条件的产生原因
2.3 单纯加锁的局限性
3. 条件变量
3.1 什么是条件变量?
3.2 条件变量的使用场景
3.3 条件变量的函数
3.3.1 初始化条件变量
3.3.2 销毁条件变量
3.3.3 等待条件变量
3.3.4 唤醒等待线程
4. 条件变量的使用示例
4.1 示例代码
4.2 示例说明
5. 为什么 pthread_cond_wait 需要互斥锁?
6. 条件变量的使用规范
6.1 等待条件变量的代码
6.2 唤醒等待线程的代码
一、线程互斥相关背景概念
1. 临界资源与临界区
1.1 临界资源
临界资源是指多个线程共享的资源。例如,一个全局变量可能被多个线程访问和修改。如果多个线程同时操作这些资源,可能会导致数据不一致。
1.2 临界区
临界区是指线程中访问临界资源的代码段。在临界区内,线程对共享资源进行读写操作。为了保证数据一致性,任何时刻只能有一个线程进入临界区。
🌴代码示例:线程通信
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>int count = 0; //临界资源
void *Routine(void *arg)
{while(1){count++; //临界区(写操作)sleep(1);}pthread_exit((void*)0);
}int main()
{pthread_t tid;pthread_create(&tid, NULL, Routine, NULL);while(1){printf("count: %d\n", count); //临界区(读操作)sleep(1);}pthread_join(tid, NULL);return 0;
}
该示例中:
count
是临界资源
count++
和printf
是临界区未加互斥保护可能导致数据不一致
2. 原子性与互斥
2.1 原子性
原子性是指一个操作不会被任何调度机制打断,要么完全执行,要么完全不执行。例如,++
和 --
操作并不是原子操作,因为它们需要多个步骤完成(加载、更新、存储)。
2.2 互斥
互斥是一种机制,确保任何时刻只有一个线程可以进入临界区。互斥通常通过锁来实现。
🌴示例代码:抢票系统
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>int tickets = 1000; //临界资源void *TicketGrabbing(void *arg)
{const char *name = (char *)arg;while (tickets){if (tickets > 0){usleep(10000); //模拟业务操作printf("[%s] get a ticket, leave: %d\n", name, --tickets); //临界区}elsebreak;}printf("%s quit!\n", name);pthread_exit((void *)0);
}int main()
{pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, TicketGrabbing, "thread-1");pthread_create(&t2, NULL, TicketGrabbing, "thread-2");pthread_create(&t3, NULL, TicketGrabbing, "thread-3");pthread_create(&t4, NULL, TicketGrabbing, "thread-4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);return 0;
}
运行结果显然不符合我们的预期,因为其中出现了剩余票数为负数的情况。
为什么无法获得正确结果呢?
1. --tickets
操作不是原子操作
--tickets
操作看似简单,但实际上它需要三个步骤完成:
-
加载(Load):将共享变量
tickets
从内存加载到寄存器中。 -
更新(Update):在寄存器中对值进行减一操作。
-
存储(Store):将更新后的值写回内存中的
tickets
。
由于这三步操作不是原子性的,多个线程可能会同时看到相同的 tickets
值,从而导致重复减票。
🌵例如:
假设
tickets = 1000
,线程 A 和线程 B 同时进入以下代码段:if (tickets > 0) {printf("[%s] get a ticket, leave: %d\n", name, --tickets); }
线程 A 加载
tickets
的值为 1000。线程 A 被调度器切换出去,线程 B 开始执行。
线程 B 加载
tickets
的值为 1000,执行减一操作后将 999 写回内存。线程 A 恢复执行,继续执行减一操作,将寄存器中的 1000 减一后写回内存,导致
tickets
变为 999。此时,线程 A 和线程 B 都认为自己成功减了一张票,但实际上只有一张票被减掉,导致票数计算错误。
2. 线程切换导致的并发问题
usleep(10000)
模拟了漫长的业务操作,这会导致线程在执行过程中被调度器切换到其他线程。这种切换可能会发生在 --tickets
操作的任意步骤之间,从而引发数据竞争。
🌵例如:
线程 A 判断
tickets > 0
为真,进入临界区。线程 A 执行
usleep(10000)
,被调度器切换到线程 B。线程 B 判断
tickets > 0
为真,进入临界区。线程 B 执行
--tickets
,将tickets
减一。线程 A 恢复执行,继续执行
--tickets
,基于过时的tickets
值进行减一操作。这种情况下,线程 A 和线程 B 可能同时看到相同的
tickets
值,导致重复减票。
3. if (tickets > 0)
条件判断的非原子性
if (tickets > 0)
条件判断本身并不是原子操作。线程可能在判断条件为真后被切换出去,而其他线程可能在此期间修改了 tickets
的值。这种情况下,当线程恢复执行时,tickets
的值可能已经发生了变化。
🌵例如:
线程 A 判断
tickets > 0
为真(假设tickets = 1
)。线程 A 被切换出去,线程 B 进入临界区。
线程 B 将
tickets
减一为 0。线程 A 恢复执行,继续执行
--tickets
,将tickets
减一为 -1。这会导致票数变为负数,显然不符合实际逻辑。
4. 多线程竞争导致的票数计算错误
由于多个线程同时访问和修改共享资源 tickets
,而没有适当的同步机制,导致票数计算错误。
🌵例如:
线程 A 和线程 B 同时看到
tickets = 1000
。线程 A 和线程 B 都执行
--tickets
,将tickets
减一为 999。线程 A 和线程 B 都认为自己成功减了一张票,但实际上只有一张票被减掉。
这种情况下,票数的计算会出现偏差,最终可能导致票数少于实际售出的票数,甚至出现负数。
5. 解决方法:引入互斥量
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
3. 互斥量(Mutex)
互斥量是一种锁机制,用于保护临界资源。它确保同一时间只有一个线程可以进入临界区。
3.1 核心API
函数 | 功能说明 | 参数说明 |
---|---|---|
pthread_mutex_init | 动态初始化互斥量 | mutex: 互斥量对象,attr: 属性(通常NULL) |
pthread_mutex_destroy | 销毁互斥量 | 已初始化的互斥量对象 |
pthread_mutex_lock | 加锁(阻塞) | 需要加锁的互斥量 |
pthread_mutex_unlock | 解锁 | 需要解锁的互斥量 |
3.2 初始化互斥量
🌵函数原型:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // 动态初始化
// 或者
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化
🌵参数说明:
mutex
:
- 指向互斥量对象的指针。该互斥量将被初始化。
- 互斥量对象必须是动态分配的,不能是静态初始化的(如
PTHREAD_MUTEX_INITIALIZER
)。
attr
:
- 指向互斥量属性对象的指针。用于设置互斥量的行为(如是否是递归锁、优先级继承等)。
- 如果不需要特殊属性,通常设置为
NULL
,使用默认属性。
🌵返回值说明:
0
:成功初始化互斥量。非零值:失败,返回错误码。
EINVAL
:attr
指向的属性对象无效。
ENOMEM
:内存不足,无法初始化互斥量。
EBUSY
:互斥量已经被初始化。
🌵注意事项:
如果互斥量是静态初始化的(如
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
),则不需要调用pthread_mutex_init
。初始化失败时,互斥量的状态是未定义的,必须重新初始化或销毁。
初始化后的互斥量必须通过
pthread_mutex_destroy
销毁,不能直接释放内存。
3.3 销毁互斥量
🍓函数原型:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_destroy(&mutex);
🍓参数说明:
mutex
:
- 指向需要销毁的互斥量对象的指针。
- 该互斥量必须是通过
pthread_mutex_init
动态初始化的。
🍓返回值说明:
0
:成功销毁互斥量。非零值:失败,返回错误码。
EINVAL
:互斥量无效。
EBUSY
:互斥量当前处于锁定状态,无法销毁。
🍓注意事项:
静态初始化的互斥量(如
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
)不需要销毁。销毁互斥量之前,必须确保没有线程持有该互斥量的锁。
销毁后的互斥量不能再使用,需要重新初始化。
如果销毁失败(如互斥量被锁定),互斥量的状态是未定义的。
3.4 加锁
🍀函数原型:
int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_lock(&mutex); // 加锁
// 临界区代码
pthread_mutex_unlock(&mutex); // 解锁
🍀参数说明:
mutex
:
- 指向需要加锁的互斥量对象的指针。
- 该互斥量必须是已初始化的。
🍀返回值说明:
0
:成功加锁。非零值:失败,返回错误码。
EINVAL
:互斥量无效。
EBUSY
:互斥量已被其他线程锁定,当前线程被阻塞。
EDEADLK
:当前线程已经持有该互斥量的锁(非递归锁情况下)。
🍀注意事项:
如果互斥量已被其他线程锁定,调用线程会被阻塞,直到互斥量被解锁。
如果互斥量是递归锁(通过属性设置),则同一个线程可以多次加锁,但需要等量的解锁次数。
加锁失败(如互斥量无效)时,线程的行为是未定义的。
加锁后必须确保在适当的地方解锁,否则会导致死锁。
3.5 解锁
🍂函数原型:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock(&mutex); // 加锁
// 临界区代码
pthread_mutex_unlock(&mutex); // 解锁
🍂参数说明:
mutex
:
- 指向需要解锁的互斥量对象的指针。
- 该互斥量必须是已初始化的,并且当前线程持有该互斥量的锁。
🍂返回值说明:
0
:成功解锁。非零值:失败,返回错误码。
EINVAL
:互斥量无效。
EPERM
:当前线程未持有该互斥量的锁。
EINVAL
:互斥量类型不支持解锁(如错误的递归锁操作)。
🍂注意事项:
只有持有互斥量锁的线程才能解锁。
如果互斥量是递归锁,则需要等量的解锁次数才能完全释放锁。
如果解锁失败(如线程未持有锁),互斥量的状态是未定义的。
解锁后,其他等待的线程会尝试获取锁,具体调度顺序取决于操作系统的实现。
🌴示例代码(使用互斥量的抢票系统)
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>int tickets = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化互斥量void *TicketGrabbing(void *arg)
{const char *name = (char *)arg;while (1){pthread_mutex_lock(&mutex); // 加锁if (tickets > 0){usleep(100); // 模拟业务操作printf("[%s] get a ticket, left: %d\n", name, --tickets); // 临界区}else{pthread_mutex_unlock(&mutex); // 解锁break;}pthread_mutex_unlock(&mutex); // 解锁}printf("%s quit!\n", name);pthread_exit((void *)0);
}int main()
{pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, TicketGrabbing, "thread 1");pthread_create(&t2, NULL, TicketGrabbing, "thread 2");pthread_create(&t3, NULL, TicketGrabbing, "thread 3");pthread_create(&t4, NULL, TicketGrabbing, "thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);pthread_mutex_destroy(&mutex); // 销毁互斥量return 0;
}
4. 互斥量实现原理
4.1 加锁后的原子性
引入互斥量后,当一个线程申请到锁并进入临界区时,其他线程只能看到两种状态:要么该线程没有申请锁,要么锁已经被释放。这种状态的二元性确保了其他线程在检测到锁被占用时会被阻塞,从而保证了互斥量的原子性。
例如,线程1进入临界区后,线程2、3、4只能看到线程1要么没有申请锁,要么已经释放了锁。这种机制使得线程2、3、4认为线程1的操作是原子的,从而避免了数据竞争。
4.2 临界区内的线程切换
即使线程在临界区内被切换出去,其他线程也无法进入临界区,因为锁仍然被持有。这意味着,线程切换不会导致数据不一致,因为其他线程必须等待当前线程释放锁后才能申请锁并进入临界区。
// 示例代码:互斥量保护的临界区
pthread_mutex_lock(&mutex); // 加锁
// 临界区代码
pthread_mutex_unlock(&mutex); // 解锁
4.3 锁的自我保护
互斥量本身是临界资源,需要被保护。然而,互斥量通过原子操作自我保护。只要申请锁的过程是原子的,互斥量就是安全的。
如何保证申请锁的过程是原子的?
为了实现互斥锁操作,大多数体系结构提供了 swap
或 exchange
指令。这些指令将寄存器和内存单元的数据交换,由于是一条指令,保证了原子性。即使在多处理器平台上,访问内存的总线周期也有先后顺序,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待。
4.4 操作系统的工作原理
操作系统启动后进入一个死循环,时钟硬件会定期发起时钟中断,操作系统根据中断执行对应的函数。CPU和内存通过系统总线连接,总线周期区分不同操作,确保数据交互的有序性。
4.5 lock 和 unlock 的伪代码
加锁(lock
)伪代码
lock:movb $0, %al ; 清空寄存器xchgb %al, mutex ; 原子交换操作if (%al > 0) {return 0; ; 获取锁成功} else {wait(); ; 挂起等待}
解锁(unlock
)伪代码
unlock:movb $1, mutex ; 重置锁状态wakeup(); ; 唤醒等待线程
实现原理:
-
加锁过程:使用原子比较交换指令将互斥量的值从
0
更新为1
。如果更新成功,线程获得锁;否则,线程会不断重试。 -
解锁过程:使用原子存储指令将互斥量的值从
1
更新为0
,释放锁,唤醒等待的线程。
二、可重入与线程安全:概念、联系与区别
1.概念
1.1 线程安全
线程安全是指多个线程并发执行同一段代码时,不会因为竞争条件导致数据不一致或程序行为异常。换句话说,无论有多少个线程同时调用某个函数,只要函数内部的逻辑正确,结果都是正确的。
1.2 可重入
可重入是指一个函数在被多次调用时,即使前一次调用尚未完成,也不会影响后续调用的正确性。换句话说,函数在任何时刻都可以被中断,而中断后的重新调用不会导致数据损坏或逻辑错误。
2.常见的线程不安全情况
2.1 不保护共享变量的函数
如果多个线程同时访问一个共享变量,而没有使用锁或其他同步机制保护,就可能导致数据不一致。例如:
int global_counter = 0;
void increment_counter() {global_counter++; // 没有锁保护,线程不安全
}
2.2 函数状态随调用变化
如果函数的内部状态(如局部静态变量)在调用过程中发生变化,就可能导致线程不安全。例如:
int next_id() {static int id = 0;return id++; // 静态变量导致线程不安全
}
2.3 返回指向静态变量指针的函数
如果函数返回指向静态变量的指针,多个线程可能会同时修改该变量,导致数据混乱。例如:
char* get_message() {static char message[100];// 填充 messagereturn message; // 返回静态变量指针,线程不安全
}
2.4 调用线程不安全函数的函数
如果一个函数调用了线程不安全的函数,那么它本身也可能线程不安全。例如:
void unsafe_function() {increment_counter(); // 调用了线程不安全的函数
}
3. 常见的线程安全情况
3.1 只读全局变量
如果多个线程只读取全局变量,而没有写操作,通常是线程安全的。例如:
const int GLOBAL_VALUE = 42;
void read_global() {printf("%d", GLOBAL_VALUE); // 只读操作,线程安全
}
3.2 原子操作
如果函数中的操作是原子的(即不可分割的),通常是线程安全的。例如:
#include <stdatomic.h>atomic_int atomic_counter = 0;
void safe_increment() {atomic_fetch_add(&atomic_counter, 1); // 原子操作,线程安全
}
3.3 无二义性结果
如果多个线程的调用不会导致函数执行结果存在二义性,通常是线程安全的。例如:
int add(int a, int b) {return a + b; // 无共享状态,线程安全
}
4. 常见的不可重入情况
4.1 调用 malloc
或 free
malloc
和 free
使用全局链表管理堆内存,因此它们是不可重入的。例如:
void allocate_memory() {int* ptr = malloc(sizeof(int)); // 调用了不可重入函数
}
4.2 标准 I/O 库函数
标准 I/O 库函数(如 printf
)通常使用全局缓冲区,因此是不可重入的。例如:
void print_message() {printf("Hello, World!"); // 标准 I/O 函数,不可重入
}
4.3 使用静态数据结构
如果函数内部使用了静态数据结构,就可能是不可重入的。例如:
void process_data() {static int buffer[100]; // 静态数据结构,不可重入
}
5. 常见的可重入情况
5.1 不使用全局变量或静态变量
如果函数完全依赖传入的参数,而不使用全局或静态变量,通常是可重入的。例如:
int multiply(int a, int b) {return a * b; // 无全局状态,可重入
}
5.2 不使用动态内存分配
如果函数不调用 malloc
或 new
,通常是可重入的。例如:
void calculate(int a, int b, int* result) {*result = a + b; // 使用传入的指针,可重入
}
5.3 不调用不可重入函数
如果函数不调用不可重入的函数,通常是可重入的。例如:
void safe_function() {// 不调用不可重入函数
}
5.4 返回局部数据
如果函数返回局部数据或通过参数传递数据,通常是可重入的。例如:
void get_message(char* buffer, size_t size) {snprintf(buffer, size, "Hello, World!"); // 使用传入的缓冲区,可重入
}
5.5 使用本地数据或全局数据的本地拷贝
如果函数使用本地数据或全局数据的本地拷贝,通常是可重入的。例如:
void safe_copy(const char* src, char* dst, size_t size) {strncpy(dst, src, size); // 使用传入的缓冲区,可重入
}
6. 可重入与线程安全的联系
-
可重入函数一定是线程安全的
如果一个函数是可重入的,那么它在多线程环境下一定是安全的,因为可重入性意味着函数不会因为重入导致数据损坏或逻辑错误。 -
线程安全函数未必是可重入的
如果一个函数通过加锁保护共享资源,它可能是线程安全的,但未必是可重入的。例如:#include <pthread.h>pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void safe_function() {pthread_mutex_lock(&lock);// 操作共享资源pthread_mutex_unlock(&lock); }
这个函数是线程安全的,但如果在函数内部再次调用自身,会导致死锁,因此不可重入。
7. 可重入与线程安全的区别
-
讨论的侧重点不同
-
可重入讨论的是函数是否可以被多次调用而不影响结果。
-
线程安全讨论的是多个线程同时调用函数时是否安全。
-
-
实现方式不同
-
可重入函数通常通过避免使用全局变量、静态变量和动态内存分配来实现。
-
线程安全函数可以通过加锁、原子操作或其他同步机制来实现。
-
-
适用场景不同
-
可重入函数适用于需要高并发、无锁的场景。
-
线程安全函数适用于需要保护共享资源的场景。
-
三、死锁与阻塞
1. 死锁的定义
1.1 什么是死锁?
死锁是指在一组进程或线程中,每个进程或线程都占有某种资源,但同时又在等待其他进程或线程所占有的资源,从而导致所有进程或线程都无法继续执行的一种状态。
1.2 单执行流是否可能产生死锁?
单执行流也可能产生死锁。例如,当一个线程连续申请两次同一个锁时,就会导致死锁。第一次申请锁成功,但第二次申请时,由于锁已经被占用(自己占用),线程会被挂起,等待锁被释放。然而,这个锁只有在线程继续执行并释放时才会被释放,但由于线程已经被挂起,锁永远不会被释放,从而导致死锁。
#include <stdio.h>
#include <pthread.h>pthread_mutex_t mutex;void* Routine(void* arg) {pthread_mutex_lock(&mutex); // 第一次申请锁成功pthread_mutex_lock(&mutex); // 第二次申请锁失败,线程被挂起pthread_exit((void*)0);
}int main() {pthread_t tid;pthread_mutex_init(&mutex, NULL);pthread_create(&tid, NULL, Routine, NULL);pthread_join(tid, NULL);pthread_mutex_destroy(&mutex);return 0;
}
运行上述代码,程序将进入死锁状态。
2. 阻塞的定义
2.1 什么是阻塞?
阻塞是指进程或线程在等待某种资源(如锁、磁盘 I/O、网络资源等)时,无法继续执行,进入等待状态。操作系统会将阻塞的进程或线程放入相应的等待队列中,直到所需资源可用。
2.2 阻塞的类型
-
资源阻塞:等待硬件资源(如磁盘、网卡)或软件资源(如锁)。
-
CPU 阻塞:等待 CPU 调度。
3. 死锁的四个必要条件
死锁的发生需要同时满足以下四个条件:
3.1 互斥条件
一个资源每次只能被一个进程或线程使用。例如,一个文件或锁只能被一个线程占用。
3.2 请求与保持条件
一个进程或线程在请求新的资源时,已经占用了某些资源,并且不释放已占用的资源。
3.3 不剥夺条件
一个进程或线程已获得的资源,在未使用完之前,不能被其他进程或线程强行剥夺。
3.4 循环等待条件
若干进程或线程之间形成一种头尾相接的循环等待资源的关系。例如,进程 A 等待进程 B 的资源,进程 B 等待进程 C 的资源,进程 C 等待进程 A 的资源。
4. 避免死锁的方法
4.1 破坏死锁的四个必要条件
-
互斥条件:无法破坏,因为资源本身需要互斥访问。
-
请求与保持条件:要求进程或线程在申请新资源前释放已占用的资源。
-
不剥夺条件:允许系统强行剥夺已分配的资源。
-
循环等待条件:通过资源分配图避免循环等待。
4.2 加锁顺序一致
确保所有线程或进程按照相同的顺序申请锁,避免循环等待。
4.3 避免锁未释放的场景
确保在任何情况下(包括异常)都能释放已占用的锁。
4.4 资源一次性分配
在进程或线程开始时一次性申请所有需要的资源,避免中途申请新资源。
4.5 死锁检测算法
定期检测系统中是否存在死锁,并采取措施解决。
4.6 银行家算法
通过资源分配的安全性检查,确保系统始终处于安全状态。
四、Linux线程同步
1. 同步的概念
1.1 什么是同步?
同步是指在多线程环境中,通过某种机制确保线程按照特定的顺序访问共享资源,从而避免数据不一致或程序异常。同步的目的是保证线程安全,同时避免饥饿问题。
1.2 同步的重要性
-
避免数据竞争:确保多个线程不会同时修改同一份数据。
-
保证执行顺序:确保线程按照预期的顺序执行。
-
解决饥饿问题:避免某些线程长时间无法获取资源。
2. 竞态条件
2.1 什么是竞态条件?
竞态条件是指程序的执行结果依赖于多个线程的执行顺序,而这种顺序是不可预测的。竞态条件通常会导致程序行为异常。
2.2 竞态条件的产生原因
-
共享资源的无序访问:多个线程同时访问共享资源,而没有适当的同步机制。
-
时序问题:线程的执行顺序与预期不符。
2.3 单纯加锁的局限性
虽然加锁可以保证同一时间只有一个线程进入临界区,但如果某些线程竞争力过强,可能会导致其他线程长时间无法获取锁,从而引发饥饿问题。
pthread_mutex_t mutex;void* Routine(void* arg) {while (true) {pthread_mutex_lock(&mutex); // 竞争力强的线程可能一直获取锁// 执行临界区代码pthread_mutex_unlock(&mutex);}
}
3. 条件变量
3.1 什么是条件变量?
条件变量是一种线程同步机制,用于描述某种资源是否就绪。它允许线程在条件不满足时挂起,直到条件满足时被唤醒。
3.2 条件变量的使用场景
-
生产者-消费者问题:生产者在缓冲区满时等待,消费者在缓冲区空时等待。
-
线程间协作:线程需要等待某种条件成立才能继续执行。
3.3 条件变量的函数
3.3.1 初始化条件变量
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
-
参数:
cond
是需要初始化的条件变量,attr
是条件变量的属性(通常设置为NULL
)。 -
返回值:成功返回 0,失败返回错误码。
静态初始化:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
3.3.2 销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
-
参数:
cond
是需要销毁的条件变量。 -
返回值:成功返回 0,失败返回错误码。
3.3.3 等待条件变量
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
-
参数:
cond
是需要等待的条件变量,mutex
是当前线程所处临界区对应的互斥锁。 -
功能:释放互斥锁并等待条件变量,直到被唤醒时重新获取互斥锁。
-
返回值:成功返回 0,失败返回错误码。
3.3.4 唤醒等待线程
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
-
pthread_cond_signal
:唤醒等待队列中的首个线程。 -
pthread_cond_broadcast
:唤醒等待队列中的所有线程。 -
参数:
cond
是需要唤醒的条件变量。 -
返回值:成功返回 0,失败返回错误码。
4. 条件变量的使用示例
4.1 示例代码
下面是一个使用条件变量的示例,主线程创建三个新线程,主线程通过键盘输入控制新线程的活动。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>pthread_mutex_t mutex;
pthread_cond_t cond;
int counter = 0;void *Routine(void *arg)
{char *name = (char *)arg;pthread_detach(pthread_self());printf("%s run...\n", name);while (1){pthread_mutex_lock(&mutex);pthread_cond_wait(&cond, &mutex); // 阻塞等待条件变量counter++;printf("%s 活动... 次数: %d\n", name, counter);pthread_mutex_unlock(&mutex);}
}int main()
{pthread_t t1, t2, t3;pthread_mutex_init(&mutex, NULL);pthread_cond_init(&cond, NULL);pthread_create(&t1, NULL, Routine, (void *)"线程1");pthread_create(&t2, NULL, Routine, (void *)"线程2");pthread_create(&t3, NULL, Routine, (void *)"线程3");while (1){getchar(); // 等待键盘输入pthread_mutex_lock(&mutex);pthread_cond_signal(&cond); // 唤醒一个等待线程pthread_mutex_unlock(&mutex);}pthread_mutex_destroy(&mutex);pthread_cond_destroy(&cond);return 0;
}
4.2 示例说明
-
线程等待:每个新线程在
pthread_cond_wait
处挂起,等待条件变量。 -
线程唤醒:主线程通过
pthread_cond_signal
唤醒一个等待线程。 -
顺序性:每次唤醒的是等待队列中的首个线程,线程执行后会重新排到队列尾部。
-
计数器:每次线程被唤醒时,计数器递增,显示线程活动的次数。
5. 为什么 pthread_cond_wait
需要互斥锁?
条件变量需要配合互斥锁使用,原因如下:
-
保护共享数据:条件变量的条件通常依赖于共享数据,互斥锁可以确保共享数据的安全访问。
-
避免死锁:
pthread_cond_wait
会在等待时自动释放互斥锁,并在被唤醒时重新获取互斥锁。
错误的设计:
pthread_mutex_lock(&mutex);
while (condition_is_false)
{pthread_mutex_unlock(&mutex); // 解锁pthread_cond_wait(&cond); // 等待条件变量pthread_mutex_lock(&mutex); // 重新加锁
}
pthread_mutex_unlock(&mutex);
这种设计会导致信号丢失,因为解锁和等待不是原子操作。
正确的设计:
pthread_mutex_lock(&mutex);
while (condition_is_false)
{pthread_cond_wait(&cond, &mutex); // 自动释放锁并等待
}
// 修改条件
pthread_mutex_unlock(&mutex);
6. 条件变量的使用规范
6.1 等待条件变量的代码
pthread_mutex_lock(&mutex);
while (条件为假) {pthread_cond_wait(&cond, &mutex);
}
// 修改条件
pthread_mutex_unlock(&mutex);
6.2 唤醒等待线程的代码
pthread_mutex_lock(&mutex);
// 设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);