欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 财经 > 金融 > 【Linux】线程安全与锁概念——自旋锁、读写锁

【Linux】线程安全与锁概念——自旋锁、读写锁

2024/12/3 6:31:07 来源:https://blog.csdn.net/weixin_50776420/article/details/144139302  浏览:    关键词:【Linux】线程安全与锁概念——自旋锁、读写锁

🌈 个人主页:Zfox_
🔥 系列专栏:Linux

目录

  • 一:🔥 线程安全和重⼊问题
    • 🦋 可重⼊与线程安全联系
    • 🦋 可重⼊与线程安全区别
  • 二:🔥 常⻅锁概念
    • 🦋 2-1 死锁
    • 🦋 2-2 死锁四个必要条件
    • 🦋 2-3 避免死锁
    • 🦋 2-4 避免死锁算法
  • 三:🔥 STL,智能指针和线程安全
    • 🦋 3-1 STL中的容器是否是线程安全的?
    • 🦋 3-2 智能指针是否是线程安全的?
  • 四:🔥 其他常⻅的各种锁
  • 五:🔥 读者写者问题与读写锁
    • 🦋 读者写者问题如何理解
    • 🦋 读写锁
    • 🦋 读者优先(Reader-Preference)
    • 🦋 写者优先(Writer-Preference)
  • 六:🔥 自旋锁
    • 🦋 概述
    • 🦋 原理
    • 🦋 优点与缺点
    • 🦋 使用场景
    • 🦋 纯软件自旋锁类似的原理实现
    • 🦋 注意事项
    • 🦋 结论
  • 五:🔥 共勉

一:🔥 线程安全和重⼊问题

线程安全:就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结果,也不会出现数据不一致的问题。⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。

重⼊:同⼀个函数被不同的执⾏流调⽤,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊,我们称之为重⼊。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被称为可重⼊函数,否则,是不可重⼊函数。

🍥 学到现在,其实我们已经能理解重⼊其实可以分为两种情况

  • 多线程重⼊函数。
  • 信号导致⼀个执⾏流重复进⼊函数。
    在这里插入图片描述

结论 :
不要被上⾯绕⼝令式的话语唬住,你只要仔细观察,其实对应概念说的都是⼀回事。

🦋 可重⼊与线程安全联系

  • 🍡 函数是可重⼊的,那就是线程安全的 (其实知道这⼀句话就够了) :
  • 🍡 函数是不可重⼊的,那就不能由多个线程使⽤,有可能引发线程安全问题
  • 🍡 如果⼀个函数中有全局变量,那么这个函数既不是线程安全也不是可重⼊的。

🦋 可重⼊与线程安全区别

  • 🍡 可重⼊函数是线程安全函数的⼀种
  • 🍡 线程安全不⼀定是可重⼊的,⽽可重⼊函数则⼀定是线程安全的。
  • 🍡 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重⼊函数若锁还未释放则会产⽣死锁,因此是不可重⼊的。

📌 注意:

  • 如果不考虑 信号导致⼀个执⾏流重复进⼊函数 这种重⼊情况,线程安全和重⼊在安全⻆度不做区分
  • 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是 并发线程的特点
  • 可重⼊描述的是⼀个函数是否能被重复进⼊,表⽰的是 函数的特点

二:🔥 常⻅锁概念

🦋 2-1 死锁

  • 死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站⽤不会释放的资源⽽处于的⼀种永久等待状态。
  • 为了⽅便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进⾏后续资源的访问
    在这里插入图片描述
    🌶️ 申请⼀把锁是原⼦的,但是申请两把锁就不⼀定了
    在这里插入图片描述
    🌶️ 造成的结果是
    在这里插入图片描述

🦋 2-2 死锁四个必要条件

  • 互斥条件⼀个资源每次只能被⼀个执⾏流使⽤
    • 好理解,不做解释
  • 请求与保持条件⼀个执⾏流因请求资源⽽阻塞时,对已获得的资源保持不放

在这里插入图片描述

  • 不剥夺条件 : ⼀个执⾏流已获得的资源,在末使⽤完之前,不能强⾏剥夺
    在这里插入图片描述
  • 循环等待条件: 若⼲执⾏流之间形成⼀种头尾相接的循环等待资源的关系
    在这里插入图片描述

🦋 2-3 避免死锁

  • 破坏死锁的四个必要条件
    • 破坏循环等待条件问题:资源⼀次性分配, 使⽤超时机制、加锁顺序⼀致
    • 避免锁未释放的场景

🦋 2-4 避免死锁算法

• 死锁检测算法(了解)
• 银⾏家算法(了解)

三:🔥 STL,智能指针和线程安全

🦋 3-1 STL中的容器是否是线程安全的?

不是.
原因是, STL 的设计初衷是将性能挖掘到极致, ⽽⼀旦涉及到加锁保证线程安全, 会对性能造成巨⼤的影响.
⽽且对于不同的容器, 加锁⽅式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使⽤, 往往需要调⽤者⾃⾏保证线程安全.

🦋 3-2 智能指针是否是线程安全的?

对于 unique_ptr, 由于只是在当前代码块范围内⽣效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共⽤⼀个引⽤计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原⼦操作(CAS)的⽅式保证 shared_ptr 能够⾼效, 原⼦的操作引⽤计数.

四:🔥 其他常⻅的各种锁

  • 悲观锁:在每次取数据时,总是担⼼数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,⾏锁等),当其他线程想要访问数据时,被阻塞挂起。
  • 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进⾏修改。主要采⽤两种⽅式:版本号机制和CAS操作。
  • CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则⽤新值更新。若不等则失败,失败则重试,⼀般是⼀个⾃旋的过程,即不断重试。

五:🔥 读者写者问题与读写锁

🦋 读者写者问题如何理解

下面是一段伪代码, 帮助我们理解读者写者的逻辑
公共部分

uint32_t reader_count = 0;
lock_t count_lock;
lock_t writer_lock;

Reader

// 加锁
lock(count_lock);
if(reader_count == 0)lock(writer_lock);
++reader_count;
unlock(count_lock);// read;//解锁
lock(count_lock);
--reader_count;
if(reader_count == 0)unlock(writer_lock);
unlock(count_lock);

Writer

C++
lock(writer_lock);
// write
unlock(writer_lock);

🦋 读写锁

在编写多线程的时候, 有一种情况是十分常见的。 那就是, 有些公共数据修改的机会比较少。 相比较改写, 它们读的机会反而高的多。 通常而言, 在读的过程中, 往往伴随着查找的操作, 中间耗时很长。 给这种代码段加锁, 会极大地降低我们程序的效率。 那么有没有一种方法, 可以专门处理这种多读少写的情况呢?有, 那就是读写锁。
在这里插入图片描述

  • 注意: 写独占, 读共享, 读锁优先级高

读写锁接口
🍡 设置读写优先

int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, intpref);pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先, 可能会导致写者饥饿情况PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先, 目前有 BUG, 导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先, 但写者不能递归加锁

🍡 初始化

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, constpthread_rwlockattr_t *restrict attr);

🍡 销毁

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 <pthread.h>
#include <unistd.h>
#include <vector>
#include <cstdlib>
#include <ctime>// 共享资源
int shared_data = 0;// 读写锁
pthread_rwlock_t rwlock;// 读者线程函数
void *Reader(void *arg)
{//sleep(1); //读者优先,一旦读者进入&&读者很多,写者基本就很难进入了int number = *(int *)arg;while (true){pthread_rwlock_rdlock(&rwlock); // 读者加锁std::cout << "读者-" << number << " 正在读取数据, 数据是: " << shared_data << std::endl;sleep(1);                       // 模拟读取操作pthread_rwlock_unlock(&rwlock); // 解锁}delete (int*)arg;return NULL;
}// 写者线程函数
void *Writer(void *arg)
{int number = *(int *)arg;while (true){pthread_rwlock_wrlock(&rwlock); // 写者加锁shared_data = rand() % 100;     // 修改共享数据std::cout << "写者- " << number << " 正在写入. 新的数据是: " << shared_data << std::endl;sleep(2);                       // 模拟写入操作pthread_rwlock_unlock(&rwlock); // 解锁}delete (int*)arg;return NULL;
}int main()
{srand(time(nullptr)^getpid());pthread_rwlock_init(&rwlock, NULL); // 初始化读写锁// 可以更高读写数量配比,观察现象const int reader_num = 2;const int writer_num = 2;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], NULL, Reader, id);}// 创建写者线程for (int i = reader_num; i < total; ++i){int *id = new int(i - reader_num);pthread_create(&threads[i], NULL, Writer, id);}// 等待所有线程完成for (int i = 0; i < total; ++i){pthread_join(threads[i], NULL);}pthread_rwlock_destroy(&rwlock); // 销毁读写锁return 0;
}

🦋 读者优先(Reader-Preference)

在这种策略中, 系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据) , 而不会优先考虑写者。 这意味着当有读者正在读取时, 新到达的读者会立即被允许进入读取区, 而写者则会被阻塞, 直到所有读者都离开读取区。 读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限) , 特别是当读者频繁到达时。

🦋 写者优先(Writer-Preference)

在这种策略中, 系统会优先考虑写者。 当写者请求写入权限时, 系统会尽快地让写者进入写入区, 即使此时有读者正在读取。 这通常意味着一旦有写者到达, 所有后续的读者都会被阻塞, 直到写者完成写入并离开写入区。 写者优先策略可以减少写者等待的时间, 但可能会导致读者饥饿(即读者长时间无法获得读取权限) , 特别是当写者频繁到达时。

六:🔥 自旋锁

🦋 概述

自旋锁是一种多线程同步机制, 用于保护共享资源免受并发访问的影响。 在多个线程尝试获取锁时, 它们会持续自旋(即在一个循环中不断检查锁是否可用) 而不是立即进入休眠状态等待锁的释放。 这种机制减少了线程切换的开销, 适用于短时间内锁的竞争情况。 但是不合理的使用, 可能会造成 CPU 的浪费。

🦋 原理

自旋锁通常使用一个共享的标志位(如一个布尔值) 来表示锁的状态。 当标志位为true 时, 表示锁已被某个线程占用; 当标志位为 false 时, 表示锁可用。 当一个线程尝试获取自旋锁时, 它会不断检查标志位:

  • 如果标志位为 false, 表示锁可用, 线程将设置标志位为 true, 表示自己占用了锁, 并进入临界区。
  • 如果标志位为 true(即锁已被其他线程占用) , 线程会在一个循环中不断自旋等待, 直到锁被释放。

🦋 优点与缺点

🌶️ 优点

  1. 低延迟自旋锁适用于短时间内的锁竞争情况, 因为它不会让线程进入休眠状态, 从而避免了线程切换的开销, 提高了锁操作的效率。
  2. 减少系统调度开销等待锁的线程不会被阻塞, 不需要上下文切换, 从而减少了系统调度的开销。

🌶️ 缺点

  1. CPU 资源浪费如果锁的持有时间较长, 等待获取锁的线程会一直循环等待, 导致 CPU 资源的浪费。
  2. 可能引起活锁当多个线程同时自旋等待同一个锁时, 如果没有适当的退避策略, 可能会导致所有线程都在不断检查锁状态而无法进入临界区, 形成活锁。

🦋 使用场景

  1. 短暂等待的情况: 适用于锁被占用时间很短的场景, 如多线程对共享数据进行简单的读写操作。
  2. 多线程锁使用: 通常用于系统底层, 同步多个 CPU 对共享资源的访问。

🦋 纯软件自旋锁类似的原理实现

自旋锁的实现通常使用原子操作来保证操作的原子性, 常用的软件实现方式是通过 CAS(Compare-And-Swap) 指令实现。 以下是一个简单的自旋锁实现示例(伪代码) :

#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h>
#include <unistd.h>// 使用原子标志来模拟自旋锁
atomic_flag spinlock = ATOMIC_FLAG_INIT; // ATOMIC_FLAG_INIT 是 0// 尝试获取锁
void spinlock_lock() {while (atomic_flag_test_and_set(&spinlock)) {// 如果锁被占用, 则忙等待}
} // 释放锁
void spinlock_unlock() {atomic_flag_clear(&spinlock);
}typedef _Atomic struct
{
#if __GCC_ATOMIC_TEST_AND_SET_TRUEVAL == 1_Bool __val;
#elseunsigned char __val;
#endif
} atomic_flag;
  • 功能描述
    atomic_flag_test_and_set 函数检查 atomic_flag 的当前状态。 如果atomic_flag 之前没有被设置过(即其值为 false 或“未设置”状态) , 则函数会将其设置为 true(或“设置”状态) , 并返回先前的值(在这种情况下为 false) 。 如果atomic_flag 之前已经被设置过(即其值为 true) , 则函数不会改变其状态, 但会返回 true。
  • 原子性
    这个操作是原子的, 意味着在多线程环境中, 它保证了对 atomic_flag 的读取和修改是不可分割的。 当一个线程调用此函数时, 其他线程无法看到这个操作的任何中间状态, 这确保了操作的线程安全性。

🎯 Linux 提供的自旋锁系统调用

#include <pthread.h>int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);

🦋 注意事项

  • 在使用自旋锁时, 需要确保锁被释放的时间尽可能短, 以避免 CPU 资源的浪费。
  • 在多 CPU 环境下, 自旋锁可能不如其他锁机制高效, 因为它可能导致线程在不同的 CPU 上自旋等待。

🦋 结论

自旋锁是一种适用于短时间内锁竞争情况的同步机制, 它通过减少线程切换的开销来提高锁操作的效率。 然而, 它也存在 CPU 资源浪费和可能引起活锁等缺点。 在使用自旋锁时, 需要根据具体的应用场景进行选择, 并确保锁被释放的时间尽可能短。

🍩 样例代码:

// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>int ticket = 1000;
pthread_spinlock_t lock;void *route(void *arg)
{char *id = (char *)arg;while (1){pthread_spin_lock(&lock);if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--;pthread_spin_unlock(&lock);}else{pthread_spin_unlock(&lock);break;}}return nullptr;
}int main(void)
{pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE);pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, route, (void *)"thread 1");pthread_create(&t2, NULL, route, (void *)"thread 2");pthread_create(&t3, NULL, route, (void *)"thread 3");pthread_create(&t4, NULL, route, (void *)"thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);pthread_spin_destroy(&lock);return 0;
}

五:🔥 共勉

以上就是我对 【Linux】线程安全与锁概念——自旋锁、读写锁 的理解,觉得这篇博客对你有帮助的,可以点赞收藏关注支持一波~😉
在这里插入图片描述

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com