【Linux】死锁、读写锁、自旋锁

文章目录

  • [1. 死锁](#1. 死锁)
    • [1.1 概念](#1.1 概念)
    • [1.2 死锁形成的四个必要条件](#1.2 死锁形成的四个必要条件)
    • [1.3 避免死锁](#1.3 避免死锁)
  • [2. 读者写者问题与读写锁](#2. 读者写者问题与读写锁)
    • [2.1 读者写者问题](#2.1 读者写者问题)
    • [2.2 读写锁的使用](#2.2 读写锁的使用)
    • [2.3 读写策略](#2.3 读写策略)
  • [3. 自旋锁](#3. 自旋锁)
    • [3.1 概念](#3.1 概念)
    • [3.2 原理](#3.2 原理)
    • [3.3 自旋锁的使用](#3.3 自旋锁的使用)
    • [3.4 优点与缺点](#3.4 优点与缺点)

1. 死锁

1.1 概念

死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用且不会释放的资源而处于的⼀种永久等待状态。

所以,可能会造成死锁的局面。

1.2 死锁形成的四个必要条件

  1. 互斥条件:⼀个资源每次只能被⼀个执行流使用
  2. 请求与保持条件 :⼀个执行流因请求资源而阻塞时,对已获得的资源保持不放
  3. 不剥夺条件 :⼀个执行流已获得的资源,在未使用完之前,不能强行剥夺
  4. 循环等待条件 :若干执行流之间形成⼀种头尾相接的循环等待资源的关系

1.3 避免死锁

方式:破坏死锁的四个必要条件。

互斥条件:

  • 破坏难度:由于互斥性是资源访问的基本特性,因此很难或不应该被破坏。

请求和保持条件:

  • 破坏策略:可以采用静态分配策略,即进程在运行前一次性申请完它所需要的全部资源,在资源未满足前,它不启动。这样就不会出现占有资源又等待其他资源的情况,从而破坏请求和保持条件。

不剥夺条件

  • 破坏策略:可以采用剥夺式调度策略,即当一个进程申请新资源而得不到满足时,可以释放它所占有的部分资源,以便其他进程使用,从而破坏不剥夺条件。

循环等待条件

  • 破坏策略:可以采用顺序资源分配法,即首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源,只能申请编号比之前大的资源。这样可以避免形成循环等待链,从而破坏循环等待条件。

例如:破环请求与保持条件,使资源⼀次性分配。

lock函数可确保所传递的锁对象全部获取成功,本质就是先申请一把锁,在申请的锁种再申请提供的锁对象,因为申请一把锁的操作是原子的。

cpp 复制代码
#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);
cpp 复制代码
#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);

使用:

cpp 复制代码
#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 对共享资源的访问。
相关推荐
Curtis09801 小时前
RHCE——Ubuntu Linux操作系统
linux·服务器·ubuntu
YRr YRr4 小时前
在Ubuntu 20.04和ROS中使用RViz进行数据可视化:详解Fixed Frame参数的选择与应用
linux·ubuntu·信息可视化
Hansel_Zhang4 小时前
CentOS 7 docker部署jar包
linux·docker·centos
运维&陈同学5 小时前
【Dubbo03】消息队列与微服务之dubbo-admin 二进制与编译安装
linux·运维·服务器·后端·微服务·云原生·架构·dubbo
Lemon_man_6 小时前
算法——反转字符串中的单词(leetcode151)
java·linux·前端
灼华十一6 小时前
消息队列-kafka
linux·docker·golang·kafka
MineGi7 小时前
0017. shell命令--tac
linux·bash
caz287 小时前
Windows远程桌面连接到Linux
linux·运维·服务器
Jackey_Song_Odd8 小时前
github webhooks 实现网站自动更新
linux·服务器·网络·ssh·github
zyy291829189 小时前
嵌入式Linux无窗口系统下搭建 Qt 开发环境
linux·嵌入式硬件·qt·物联网·iot