文章目录
1. 死锁
1.1 概念
死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用且不会释放的资源
而处于的⼀种永久等待状态。
所以,可能会造成死锁的局面。
1.2 死锁形成的四个必要条件
- 互斥条件:⼀个资源每次只能被⼀个执行流使用
- 请求与保持条件:⼀个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:⼀个执行流已获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成⼀种头尾相接的循环等待资源的关系
1.3 避免死锁
方式:破坏死锁的四个必要条件。
互斥条件:
- 破坏难度:由于互斥性是资源访问的基本特性,因此很难或不应该被破坏。
请求和保持条件:
- 破坏策略:可以采用静态分配策略,即进程在运行前一次性申请完它所需要的全部资源,在资源未满足前,它不启动。这样就不会出现占有资源又等待其他资源的情况,从而破坏请求和保持条件。
不剥夺条件
- 破坏策略:可以采用剥夺式调度策略,即当一个进程申请新资源而得不到满足时,可以释放它所占有的部分资源,以便其他进程使用,从而破坏不剥夺条件。
循环等待条件
- 破坏策略:可以采用顺序资源分配法,即首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源,只能申请编号比之前大的资源。这样可以避免形成循环等待链,从而破坏循环等待条件。
例如:破环请求与保持条件,使资源⼀次性分配。
lock函数可确保所传递的锁对象全部获取成功,本质就是先申请一把锁,在申请的锁种再申请提供的锁对象,因为申请一把锁的操作是原子的。
#include <iostream>
#include <mutex>
#include <unistd.h>
// 定义两个共享资源(整数变量)和两个互斥锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;
// ⼀个函数,同时访问两个共享资源
void access_shared_resources()
{
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
// // 使⽤ std::lock 同时锁定两个互斥锁
std::lock(lock1, lock2);
// 现在两个互斥锁都已锁定,可以安全地访问共享资源
int cnt = 10000;
while (cnt)
{
++shared_resource1;
++shared_resource2;
cnt--;
}
// 当离开 access_shared_resources 的作⽤域时,lock1 和 lock2 的析构函数会被⾃动调⽤
// 这会导致它们各⾃的互斥量被⾃动解锁
}
2. 读者写者问题与读写锁
2.1 读者写者问题
读者写者问题其实与生产者消费者问题类似,都是多线程之间互相同步的一种策略。
例如我们在写博客的时候,在我写的时候你是看不到的,直到我发布出去,你才能看到;与此同时在你看的时候,可能还有很多人都在看。
读者写者问题也应该遵循“321"
原则:3种关系,2种角色,1个交易场所。
三种关系如下:
- 写者与写者之间互斥,即一个写者在修改数据时,其他写者不能访问。
- 读者与写者之间互斥&&同步,即不能同时有一个线程在读,而另一个线程在写。
- 当一个读者申请进行读操作时,如果已有写者在访问共享资源,则该读者必须等到没有写者访问后才能开始读操作。
- 当一个写者申请进行写操作时,如果已有读者正在读取数据,写者必须等待所有读者完成读取后才能开始写操作。
- 读者之间可以
并发
(即没关系),即可以有一个或多个读者在读。
读者写者 vs 生成者消费者
二者最大的区别就是:消费者会“取走”共享资源,而读者不会。
伪代码理解读者写者的逻辑:
2.2 读写锁的使用
初始化:
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
销毁:
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
加锁:
- 读者加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
- 写者加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
解锁:
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
pthread_rwlock_t rwlock; // 读写锁
static int g_data = 0; // 共享资源
pthread_mutex_t lockScreen;
void *Reader(void *args)
{
int id = *(int *)args;
while (true)
{
pthread_rwlock_rdlock(&rwlock); // 读者加锁
pthread_mutex_lock(&lockScreen);
std::cout << "读者-" << id << " 正在读取数据, 数据是: " << g_data << std::endl;
pthread_mutex_unlock(&lockScreen);
sleep(2);
pthread_rwlock_unlock(&rwlock); // 读者解锁
sleep(1);
}
delete (int *)args;
return nullptr;
}
void *Writer(void *args)
{
int id = *(int *)args;
while (true)
{
pthread_rwlock_wrlock(&rwlock); // 写者加锁
g_data += 2; // 修改共享数据
pthread_mutex_lock(&lockScreen);
std::cout << "写者- " << id << " 正在写入. 新的数据是: " << g_data << std::endl;
pthread_mutex_unlock(&lockScreen);
sleep(1);
pthread_rwlock_unlock(&rwlock); // 解锁
sleep(1);
}
delete (int *)args;
return NULL;
}
int main()
{
pthread_rwlock_init(&rwlock, nullptr); // 初始化读写锁
pthread_mutex_init(&lockScreen,nullptr);
const int reader_num = 5; // 读者数量
const int writer_num = 10; // 写者数量
const int total = reader_num + writer_num;
pthread_t threads[total];
for (int i = 0; i < reader_num; i++)
{
int *id = new int(i);
pthread_create(&threads[i], 0, Reader, id);
}
for (int i = reader_num; i < total; i++)
{
int *id = new int(i - reader_num);
pthread_create(&threads[i], 0, Writer, id);
}
for (int i = 0; i < total; i++)
{
pthread_join(threads[i], nullptr);
}
return 0;
}
2.3 读写策略
- 读者优先
在这种策略中, 系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据) , 而不会优先考虑写者。 这意味着当有读者正在读取时, 新到达的读者会立即被允许进入读取区, 而写者则会被阻塞, 直到所有读者都离开读取区。 读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限), 特别是当读者频繁到达时。 - 写者优先
在这种策略中, 系统会优先考虑写者。 当写者请求写入权限时, 系统会尽快地让写者进入写入区, 即使此时有读者正在读取。 这通常意味着一旦有写者到达, 所有后续的读者都会被阻塞, 直到写者完成写入并离开写入区。 写者优先策略可以减少写者等待的时间, 但可能会导致读者饥饿(即读者长时间无法获得读取权限) , 特别是当写者频繁到达时。
选择合适的策略时,需要根据具体的应用场景和需求进行权衡。例如,在需要频繁读取而写入较少的应用中,读者优先策略可能更为合适;而在需要频繁写入的应用中,写者优先策略可能更为合适。
3. 自旋锁
3.1 概念
在我们之前讲的信号量或互斥锁,都有一个特点:申请锁失败,申请线程都要阻塞挂起等待。
但是,当一个线程在临界区内执行的时长非常短时,那么等待线程阻塞、挂起、唤醒的代价是比较大的。所以有一种锁在申请临界区的时候,可以不阻塞等待,它会持续自旋(即在一个循环中不断检查锁是否可用),这种状态的锁我们称之为自旋锁
。
这种机制减少了线程切换的开销, 适用于短时间内锁的竞争情况。
3.2 原理
自旋锁通常使用一个共享的标志位(如一个布尔值)来表示锁的状态。 当标志位为true 时,表示锁已被某个线程占用;当标志位为 false 时,表示锁可用。 当一个线程尝试获取自旋锁时,它会不断检查标志位:
- 如果标志位为 false,表示锁可用, 线程将设置标志位为 true, 表示自己占用了锁, 并进入临界区。
- 如果标志位为 true(即锁已被其他线程占用),线程会在一个循环中不断自旋等待, 直到锁被释放
上面检测标志位的操作一定是原子性的。
伪代码理解原理:
3.3 自旋锁的使用
Linux 提供的自旋锁系统调用
- 初始化:
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
-
销毁:
int pthread_spin_destroy(pthread_spinlock_t *lock);
-
加锁
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
申请锁失败就返回,就可以使用适当的退避策略了。 -
解锁:
int pthread_spin_unlock(pthread_spinlock_t *lock);
使用:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
pthread_spinlock_t spinlock; //自旋锁
static int g_ticket = 5000;
void* func(void* args)
{
char* name = (char*)args;
while(true)
{
pthread_spin_lock(&spinlock);
if(g_ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", name, g_ticket);
g_ticket--;
pthread_spin_unlock(&spinlock);
}
else
{
pthread_spin_unlock(&spinlock);
break;
}
}
return nullptr;
}
int main()
{
pthread_spin_init(&spinlock,PTHREAD_PROCESS_PRIVATE); //初始化
pthread_t t1,t2,t3,t4,t5;
pthread_create(&t1,nullptr,func,(void*)"thread-1");
pthread_create(&t2,nullptr,func,(void*)"thread-2");
pthread_create(&t3,nullptr,func,(void*)"thread-3");
pthread_create(&t4,nullptr,func,(void*)"thread-4");
pthread_create(&t5,nullptr,func,(void*)"thread-5");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
pthread_join(t4,nullptr);
pthread_join(t5,nullptr);
pthread_spin_destroy(&spinlock); //销毁
return 0;
}
3.4 优点与缺点
优点
- 低延迟: 自旋锁适用于短时间内的锁竞争情况, 因为它不会让线程进入休眠状态, 从而避免了线程切换的开销, 提高了锁操作的效率。
- 减少系统调度开销: 等待锁的线程不会被阻塞, 不需要上下文切换, 从而减少了系统调度的开销。
缺点
- CPU 资源浪费: 如果锁的持有时间较长,等待获取锁的线程会一直循环等待,导致 CPU 资源的浪费。
- 可能引起活锁: 当多个线程同时自旋等待同一个锁时, 如果没有适当的退避策略, 可能会导致所有线程都在不断检查锁状态而无法进入临界区, 形成活锁。
使用场景
- 短暂等待的情况: 适用于锁被占用时间很短的场景, 如多线程对共享数据进行简单的读写操作。
- 多线程锁使用: 通常用于系统底层, 同步多个 CPU 对共享资源的访问。