概念
线程同步是指当一个线程在对某个临界资源进行操作时,其它线程都不可以对这个资源进行操作,直到该线程完成操作,其他线程才能操作,也就是协调同步,让线程按预定的先后次序进行运行。线程同步的方法有四种:互斥锁、信号量、条件变量、读写锁
竞态条件:当多个执行单元以不受控的方式同时访问共享资源,并且最终的结果依赖于各线程的执行顺序时,就会产生竞态条件。这种情况会导致结果具有不确定性。
临界区:存在竞态条件的代码段叫临界区代码段,该区域在同一时刻只允许一个线程或进程进入并执行,以确保共享资源的一致性和完整性。
为什么线程同步
在多线程环境中,多个线程可能会同时访问和修改共享资源。如果没有合适的同步机制,就可能出现以下问题:
- 竞争条件:多个线程同时对共享资源进行读写操作,由于执行顺序的不确定性,可能会导致数据的不一致。例如,两个线程同时对一个计数器进行加 1 操作,可能会出现最终结果比预期小的情况。
- 数据不一致:多个线程对共享数据的修改可能会导致数据处于不一致的状态。例如,一个线程正在修改一个数据结构,而另一个线程同时读取该数据结构,可能会读取到不完整或错误的数据。
多线程在工作时是分时复用时间片
CPU对应寄存器、一级缓存、二级缓存、三级缓存是独占的,用于存储处理的数据和线程的状态信息,数据被CPU处理完需要再次被写入到物理内存中,物理内存也可以通过IO操作写入磁盘。
L1 缓存最接近 CPU,速度最快但容量最小,一般分为数据缓存(L1D)和指令缓存(L1I);L2 缓存速度稍慢但容量较大,用于存储 L1 缓存未命中的数据;L3 缓存容量更大,多个核心可以共享,进一步减少对主存的访问。
以多线程数数为例子:
当线程A变成运行态后开始数数,从物理内存加载数据,让后将数据放到CPU进行运算,最后将更新结果更新到物理内存中。 如果线程A执行这个过程期间就失去了CPU时间片,,线程A进入就绪态被挂起,最新的数据没能更新到物理内存中,线程B抢到CPU时间片,进入运行状态后从物理内存读取数据,很显然没有拿到最新数据,失去CPU时间片挂起,线程A得到时间片,第一件事就是将上次没更新到内存的数据更新进去,但这与线程B已经更新到存的数据重复,最终导致有些数据被重复多次
线程同步方式
互斥锁:
通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有线程只能顺序执行(不能并行),这样多线程访问共享资源数据混乱的问题就可以被解决,需要付出的代价就是执行效率降低
创建锁:
pthread_mutex_t mutex;
在创建的锁对象保存了当前这把锁的状态信息:锁定还是打开,如果是锁定状态还记录了给这把锁加锁的线程信息(线程ID)。一个互斥锁变量只能被一个线程锁定,别锁定之后其他线程在对互斥锁加锁会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞,一般情况下,每一个共享资源对应一把互斥锁,锁的个数与线程的个数无关
读写锁
读写锁是互斥锁的升级版,适用于读操作频繁而写操作较少的场景。读写锁允许多个线程同时进行读操作,但在进行写操作时,只允许一个线程进行,以此提高并发性能。
#include <stdio.h>
#include <pthread.h>
// 定义读写锁
pthread_rwlock_t rwlock;
// 共享资源
int shared_data = 0;
// 读线程函数
void *read_thread(void *arg) {
for (int i = 0; i < 5; i++) {
// 获取读锁
pthread_rwlock_rdlock(&rwlock);
printf("读线程 %ld 读取到的数据: %d\n", (long)arg, shared_data);
// 释放读锁
pthread_rwlock_unlock(&rwlock);
// 模拟一些工作
sleep(1);
}
return NULL;
}
// 写线程函数
void *write_thread(void *arg) {
for (int i = 0; i < 3; i++) {
// 获取写锁
pthread_rwlock_wrlock(&rwlock);
shared_data++;
printf("写线程 %ld 写入数据后的值: %d\n", (long)arg, shared_data);
// 释放写锁
pthread_rwlock_unlock(&rwlock);
// 模拟一些工作
sleep(2);
}
return NULL;
}
int main() {
pthread_t readers[3], writers[2];
// 初始化读写锁
pthread_rwlock_init(&rwlock, NULL);
// 创建读线程
for (int i = 0; i < 3; i++) {
pthread_create(&readers[i], NULL, read_thread, (void *)(long)i);
}
// 创建写线程
for (int i = 0; i < 2; i++) {
pthread_create(&writers[i], NULL, write_thread, (void *)(long)i);
}
// 等待读线程结束
for (int i = 0; i < 3; i++) {
pthread_join(readers[i], NULL);
}
// 等待写线程结束
for (int i = 0; i < 2; i++) {
pthread_join(writers[i], NULL);
}
// 销毁读写锁
pthread_rwlock_destroy(&rwlock);
return 0;
}
如果读写锁已经锁定读操作,在加读锁也是可以的,因为读锁是共享的,如果读写锁已经锁定了写操作,此时读加锁会被阻塞。
如果读写锁已经锁定读操作或者写操作,此时写加锁会被阻塞。
自旋锁
自旋锁(SpinLock
)是一种轻量级的同步原语,用于在多线程环境中保护共享资源的访问。与传统的互斥锁(Mutex)不同,自旋锁在等待锁的过程中不会使线程进入阻塞状态,而是让线程在一个循环中不断检查锁的状态,直到成功获取锁为止。这种行为被称为“自旋”。
获取锁:当一个线程尝试获取自旋锁时,它会检查锁的状态。如果锁是未被占用的(通常表示为 false),线程会将锁的状态设置为占用(通常表示为 true),并成功获取锁。
如果锁已经被其他线程占用,线程会进入一个循环,不断检查锁的状态,直到锁变为可用。
释放锁:当线程完成对共享资源的访问后,它会释放自旋锁,将锁的状态设置为未占用。
自旋等待: 当一个线程在尝试获取自旋锁时,如果发现锁已经被其他线程持有,它会进入自旋等待状态,即在一个循环中不断检查锁的状态,直到获取到锁为止。自旋等待的优点在于它避免了线程阻塞和切换的开销,适用于对临界资源的短期占用情况。
限制自旋次数: 为了避免自旋等待时间过长导致性能下降,通常会对自旋次数进行限制。如果自旋次数达到了限制,当前线程会放弃自旋等待,转而选择其他方式(如进入睡眠状态或者阻塞状态)等待锁的释放。
原子操作: 自旋锁的实现通常依赖于原子操作,这些操作能够保证在执行期间不会被中断。典型的原子操作是CAS(compare-and-swap),它可以原子地检查某个内存位置的值,并在满足条件时将新值写入该位置。如果CAS操作失败,则表示其他线程已经修改了内存位置的值,当前线程需要重试。
优点:
低延迟: 自旋锁在获取锁时不会进行上下文切换,因此在短时间的临界区中可以提供较低的延迟。
简单实现: 自旋锁的实现相对简单,通常只依赖于原子操作
缺点:
CPU 占用: 自旋锁在等待时会占用 CPU 资源,可能导致其他线程无法执行,尤其是在锁持有时间较长或竞争激烈的情况下。
不适合长时间等待: 如果锁的持有时间较长,使用自旋锁可能会导致性能下降,因为其他线程会在自旋中浪费 CPU 时间。
死锁
当多个线程访问共享资源,需要加锁,如果锁使用不当,就会造成死锁现象,其后果是所有线程的阻塞无法解开。
加锁后忘记解锁;重复加锁;程序中有多个共享资源,因此有多把锁,随意加锁导致相互阻塞;
死锁产生的必要条件
1. 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
2. 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
3. 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
4. 循环等待:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有
条件变量
条件变量本身并不保护任何数据,它通常和互斥锁一起使用。互斥锁用于保护共享资源,确保同一时间只有一个线程可以访问该资源;而条件变量用于在线程之间传递状态信息,让线程能够根据特定条件进行等待或唤醒操作。主要用于处理生产者消费者模型。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define BUFFER_SIZE 5
// 共享缓冲区
int buffer[BUFFER_SIZE];
int in = 0;
int out = 0;
int count = 0;
// 互斥锁和条件变量
pthread_mutex_t mutex;
pthread_cond_t not_full;
pthread_cond_t not_empty;
// 生产者线程函数
void *producer(void *arg) {
for (int i = 0; i < 10; i++) {
// 加锁
pthread_mutex_lock(&mutex);
// 等待缓冲区不为满
while (count == BUFFER_SIZE) {
pthread_cond_wait(¬_full, &mutex);
}
// 生产数据
buffer[in] = i;
printf("生产者生产了数据: %d\n", buffer[in]);
in = (in + 1) % BUFFER_SIZE;
count++;
// 通知消费者缓冲区不为空
pthread_cond_signal(¬_empty);
// 解锁
pthread_mutex_unlock(&mutex);
// 模拟生产时间
sleep(1);
}
return NULL;
}
// 消费者线程函数
void *consumer(void *arg) {
for (int i = 0; i < 10; i++) {
// 加锁
pthread_mutex_lock(&mutex);
// 等待缓冲区不为空
while (count == 0) {
pthread_cond_wait(¬_empty, &mutex);
}
// 消费数据
int data = buffer[out];
printf("消费者消费了数据: %d\n", data);
out = (out + 1) % BUFFER_SIZE;
count--;
// 通知生产者缓冲区不为满
pthread_cond_signal(¬_full);
// 解锁
pthread_mutex_unlock(&mutex);
// 模拟消费时间
sleep(2);
}
return NULL;
}
int main() {
pthread_t producer_thread, consumer_thread;
// 初始化互斥锁和条件变量
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(¬_full, NULL);
pthread_cond_init(¬_empty, NULL);
// 创建生产者和消费者线程
pthread_create(&producer_thread, NULL, producer, NULL);
pthread_create(&consumer_thread, NULL, consumer, NULL);
// 等待线程结束
pthread_join(producer_thread, NULL);
pthread_join(consumer_thread, NULL);
// 销毁互斥锁和条件变量
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(¬_full);
pthread_cond_destroy(¬_empty);
return 0;
}
信号量
信号量本质上是一个整数变量,它的值表示可用资源的数量。线程或进程在访问共享资源之前需要先获取信号量,如果信号量的值大于 0,则线程可以获取信号量并将其值减 1;如果信号量的值为 0,则线程需要等待,直到有其他线程释放信号量。用于处理生产者消费者模型
在 POSIX 线程库中,使用 `sem_t` 类型来表示信号量,并且有一系列函数用于操作它。
//初始化信号量
int sem_init(sem_t *sem, int pshared, unsigned int value)
//尝试获取信号量 原子减一,若为0则阻塞
int sem_wait(sem_t *sem);
//尝试非阻塞地获取信号量 如果信号量的值为 0,则不会阻塞,而是立即返回 -1
int sem_trywait(sem_t *sem);
//释放信号量 如果有其他线程正在等待该信号量,则会唤醒其中一个线程。
int sem_post(sem_t *sem);
//销毁信号量,释放相关资源。
int sem_destroy(sem_t *sem);