当前位置: 首页 > news >正文

线程安全的单例模式、自旋锁,以及读者写者问题

线程安全的单例模式、自旋锁,以及读者写者问题

上一次花了不少时间拆解线程池和环形队列的代码,不知道大家有没有发现,线程池的本质其实就是生产消费模型——把任务当作“产品”,线程池当作“工厂”,扔进去任务就能自动处理。往后我们完全可以把线程池当成一个组件,需要的时候封装任务、定义线程池、抛入任务,就能快速完成并发处理。而且我们还对线程做了简单封装:把线程当作类,包含线程ID、名字、启动时间戳、运行状态和要执行的任务,启动时创建线程执行run函数,再通过回调完成核心逻辑。

好了,回顾完之前的内容,我们今天要聚焦三个核心话题:线程安全的单例模式、自旋锁,以及读者写者问题。

一、单例模式:让类只有“一个分身”

先提出一个问题:如果我们写一个服务器程序,配置文件只需要加载一次,线程池也只需要一个实例,这种场景下,怎么保证一个类不会被创建多个对象?答案就是单例模式——它的核心就是让某个类在整个程序生命周期里,只能实例化出一个对象。

单例模式有两种经典实现思路,我们用生活里的例子就能讲明白。

1. 饿汉模式:“提前备好,随用随取”

饿汉模式就像吃完饭立马洗碗的人——当下就把后续要用的准备好,下次吃饭时直接拿碗盛饭就行。对应到代码里,就是程序加载时(也就是进程启动时),就直接创建好单例对象,后续需要用的时候,直接返回这个现成的对象。

为什么叫“饿汉”?因为它“迫不及待”地把对象创建出来,不等人要。比如我们定义一个类,里面放一个静态的类对象(不是指针),程序一加载,这个静态对象就会在全局数据区初始化——就像全局变量一样,进程启动时就存在,进程退出时才释放。

这种模式的优点很明显:访问速度快,因为对象早就创建好了,不需要临时创建;而且不存在线程安全问题,毕竟加载时只有一个进程初始化阶段,没有并发。但缺点也很直接:如果对象很大,或者初始化逻辑复杂,会拖慢程序的启动速度——就像你出门前要把所有可能用到的东西都装进包里,虽然用的时候方便,但收拾包的时间会变长。

大家可以做个小实验验证一下:定义一个全局的类对象,在构造函数里打印日志,再在main函数里先休眠3秒。运行后会发现,日志会在程序启动时就打印出来,而不是等3秒后——这就说明全局对象(包括饿汉模式的单例对象)在进程加载时就已经创建了。

2. 懒汉模式:“需要再做,不浪费时间”

懒汉模式则相反,就像吃完饭把碗放着,下次要用时再洗——核心是“延迟加载”:程序加载时不创建对象,第一次调用获取对象的接口(比如getInstance)时,才创建对象,后续调用直接返回已创建的对象。

这种思路在很多地方都有应用,比如我们打开一个10G的游戏,不会一开始就把所有资源加载到内存,而是玩到某个关卡再加载对应内容;再比如操作系统的缺页中断——你申请内存时,系统只是在地址空间标记一下,等你真正访问时才会分配物理内存。这些都是“延迟加载”的体现。

懒汉模式的优点是提升启动速度——程序加载时不用花时间创建大对象,适合初始化成本高的场景。但缺点也很关键:多线程环境下会出问题。比如两个线程同时调用getInstance,都判断“对象还没创建”,然后都去创建对象,最后就会出现多个实例,违背单例的初衷。

3. 多线程安全:给单例“上把锁”

怎么解决懒汉模式的线程安全问题?最直接的办法是加锁——在创建对象的逻辑前后加互斥锁(pthread_mutex_t),确保同一时间只有一个线程能执行创建对象的代码。

但这里有个优化点:如果每次调用getInstance都要加锁,哪怕对象已经创建好了,后续线程还是要排队加锁、解锁,会浪费性能。就像电影院检票,电影开始后所有人都要排队检票,但其实只要确认票是有效的,直接进就行,不用每次都排队。

所以我们需要“双重检查锁定”(Double-Checked Locking):

  1. 第一次检查:判断对象是否已创建,如果已创建,直接返回,不用加锁;
  2. 如果对象没创建,加锁;
  3. 第二次检查:加锁后再判断一次对象是否已创建(防止加锁前有其他线程创建了对象);
  4. 如果还是没创建,就创建对象,最后解锁。

这样一来,只有第一次创建对象时需要加锁,后续调用都能直接返回,兼顾了线程安全和性能。

另外,实现懒汉模式时,还要注意两点:

  • 把类的构造函数、拷贝构造函数、赋值运算符重载设为私有,并且禁用(用delete),防止外部通过这些方法创建新对象;
  • 用静态指针存储单例对象,静态方法(getInstance)访问静态指针——因为静态方法属于类,不依赖对象实例,能直接调用。

二、自旋锁:循环检查

聊完单例,我们再来看看另一种锁——自旋锁。之前我们学过互斥锁,当线程申请不到锁时,会被挂起,放到等待队列,等锁释放后再被唤醒。但如果临界区的执行时间非常短(比如只是给一个整数加1),挂起和唤醒的开销(上下文切换、队列操作)可能比临界区执行时间还长,这时候互斥锁就不太划算了。

自旋锁就是为这种场景设计的——线程申请不到锁时,不挂起,而是循环检测锁的状态(“自旋”),直到拿到锁为止。就像你去便利店买东西,老板说“等我10秒找零”,你不会转身去别的地方,而是站在原地等10秒;但如果老板说“等我1小时盘点”,你肯定会先去别的地方,等老板打电话再回来——这就是自旋锁和互斥锁的区别。

1. 自旋锁的适用场景

只有一个核心原则:临界区执行时间极短,且线程数不多。比如:

  • 对一个全局计数器做自增操作(只需要几条指令);
  • 检测某个资源是否释放(比如硬件寄存器的状态)。

如果临界区有IO操作(比如读文件)、复杂计算,或者线程数很多,自旋锁会浪费CPU资源——因为多个线程都在循环检测,CPU都被用来“空等”了,反而降低效率。

2. 自旋锁的实现方式

有两种常见方式:

  • 用户层实现:用互斥锁的try_lock接口(pthread_mutex_trylock)配合循环。try_lock的特点是:申请不到锁时不会挂起,而是直接返回错误。我们可以写一个while循环,不断调用try_lock,直到申请成功为止——这就是一个简单的自旋锁。
  • 系统层接口:pthread库直接提供了自旋锁接口(pthread_spinlock_t),用法和互斥锁类似:
    1. 初始化自旋锁:pthread_spin_init;
    2. 加锁:pthread_spin_lock(阻塞版,自旋等待)或pthread_spin_trylock(非阻塞版,失败返回);
    3. 解锁:pthread_spin_unlock;
    4. 销毁自旋锁:pthread_spin_destroy。

大家可以对比一下:互斥锁是“挂起等待”,自旋锁是“循环等待”,选择哪种锁,本质上是权衡“临界区时间”和“挂起唤醒开销”。

三、读者写者问题

接下来我们聊聊另一个经典的并发模型——读者写者问题。它比生产消费模型更灵活,生活中随处可见:比如博客(作者写,读者看)、12306票务系统(系统更新票务数据是写,用户查票是读)、黑板报(同学写,其他同学看)。

要理解这个模型,我们可以用“321原则”拆解:

1. 321原则:理清模型的核心要素

  • 1个交易场所:共享的数据区,比如黑板报、博客的数据库、票务系统的库存表——所有读者和写者都围绕这个区域操作。
  • 2类角色:读者(只读取数据,不修改)和写者(修改数据,不读取或兼顾读取),角色由线程扮演。
  • 3种关系:这是模型的核心,我们逐个分析:
    1. 写写互斥:两个写者不能同时操作共享数据。比如两个同学不能同时在黑板上写字,否则内容会混乱——这和生产消费模型里的“生产者互斥”是一个道理。
    2. 读写互斥:写者在操作时,读者不能读;读者在读时,写者不能操作。比如同学在黑板上画画时,其他人不能凑过来读(会看到不完整的内容);其他人在读黑板时,也不能擦黑板重新写——这保证了数据的一致性。
    3. 读读共享:多个读者可以同时读共享数据。比如一群同学可以一起看黑板,不用排队;多个用户可以同时查12306的余票,不用等别人查完——这是读者写者模型和生产消费模型最本质的区别。

2. 与生产消费模型的本质区别

为什么读者写者模型里“读读共享”,而生产消费模型里“消费者互斥”?答案很简单:是否“拿走”数据

  • 生产消费模型中,消费者拿到数据后会“拿走”——比如工厂生产的零件,消费者(组装线)拿走后,其他消费者就拿不到了,所以必须互斥。
  • 读者写者模型中,读者只是“读取”数据,不会拿走——黑板上的内容,你看了之后还在,别人还能看;12306的余票,你查了之后数据还在,别人还能查,所以读读可以共享。

这个区别看似简单,却决定了两个模型的应用场景:生产消费适合“数据需要流转”的场景(比如任务队列),读者写者适合“数据需要反复读取、偶尔修改”的场景(比如配置文件、缓存)。

3. 优先策略:解决“写者饥饿”问题

读者写者模型的常见场景是“读多写少”——比如一篇博客,看的人远多于写的人;一个缓存系统,查询次数远多于更新次数。这种场景下,默认的“读者优先”策略可能会导致“写者饥饿”。

(1)读者优先(默认)

读者优先的逻辑是:如果有读者正在读,或者有新的读者进来,写者必须等待,直到所有读者都读完。比如黑板前有一群同学在看,新的同学还能继续加入看,但想擦黑板的同学只能等所有人都看完。

这种策略的优点是提高读的效率,符合“读多写少”的场景;但缺点是写者可能长时间等待——如果一直有新读者进来,写者就永远没机会操作,这就是“写者饥饿”。

(2)写者优先(可选)

为了解决写者饥饿,我们可以用“写者优先”策略:当有写者申请操作时,会阻止新的读者进来,等当前正在读的读者读完后,优先让写者操作;写者操作完后,再让等待的读者读。

比如想擦黑板的同学来了,会告诉新过来的同学“先等一下”,等当前看黑板的同学看完后,先擦黑板、重新写,写完后新的同学再看。这种策略保证了写者不会被无限期等待,但会降低读的效率——适合“写操作不能延迟”的场景(比如票务系统的库存更新)。

4. 读写锁:实操层面的同步工具

理解了读者写者模型,实操时就需要“读写锁”(pthread_rwlock_t)——pthread库专门为这个模型设计的锁,用法和互斥锁类似,但区分“读加锁”和“写加锁”。

读写锁的核心接口:
  • 初始化:pthread_rwlock_init,和互斥锁的初始化参数类似,不需要特殊属性时传NULL。
  • 读加锁:pthread_rwlock_rdlock——读者调用这个接口加锁,多个读者可以同时加锁成功(读读共享)。
  • 写加锁:pthread_rwlock_wrlock——写者调用这个接口加锁,同一时间只有一个写者能加锁成功(写写互斥、读写互斥)。
  • 解锁:pthread_rwlock_unlock——不管是读者还是写者,解锁都用这个统一接口,简化代码。
  • 销毁:pthread_rwlock_destroy——不再使用时销毁锁,释放资源。

举个例子:模拟抢票系统,“查票”是读操作,调用pthread_rwlock_rdlock加锁;“抢票”是写操作(修改库存),调用pthread_rwlock_wrlock加锁;操作完后都用pthread_rwlock_unlock解锁。这样既能保证查票时的并发效率,又能保证抢票时的库存一致性。

四、多线程的总结

当我们把单例模式、自旋锁、读者写者问题串联起来,会发现多线程编程的核心其实是“平衡安全与效率”:

  • 单例模式用双重检查锁定,既保证线程安全,又避免多余的锁开销;
  • 自旋锁和互斥锁的选择,是权衡临界区时间和挂起唤醒成本;
  • 读者写者模型的优先策略,是平衡读效率和写延迟。

回顾整个多线程部分,我们从线程的基本概念,到互斥、同步,再到生产消费、线程池、单例、自旋锁、读者写者——这些知识点不是孤立的,而是层层递进的:理解了互斥锁,才能看懂单例的线程安全;理解了生产消费,才能对比出读者写者的不同;理解了锁的本质,才能选择自旋锁还是互斥锁。

http://www.dtcms.com/a/394337.html

相关文章:

  • U盘长期插在电脑上的影响
  • Windows 系统部署 PaddleOCR —— 基于 EPGF 架构
  • 数据一致性指的是什么?如何实现数据一致性?
  • 初识消息队列的世界
  • Python快速入门专业版(三十八):Python字典:键值对结构的增删改查与进阶用法
  • SpringCloudOAuth2+JWT:微服务统⼀认证方案
  • LeetCode 分类刷题:2517. 礼盒的最大甜蜜度
  • 深度学习优化器进阶:从SGD到AdamW,不同优化器的适用场景
  • C++ 之 【C++的IO流】
  • truffle学习笔记
  • 现代循环神经网络
  • vlc播放NV12原始视频数据
  • ThinkPHP8学习篇(七):数据库(三)
  • 链家租房数据爬虫与可视化项目 Python Scrapy+Django+Vue 租房数据分析可视化 机器学习 预测算法 聚类算法✅
  • MQTT协议知识点总结
  • C++ 类和对象·其一
  • TypeScript里的类型声明文件
  • 【LeetCode - 每日1题】设计电影租借系统
  • Java进阶教程,全面剖析Java多线程编程,线程安全,笔记12
  • DCC-GARCH模型与代码实现
  • 实验3掌握 Java 如何使用修饰符,方法中参数的传递,类的继承性以及类的多态性
  • 【本地持久化】功能-总结
  • 深入浅出现代FPU浮点乘法器设计
  • LinkedHashMap 访问顺序模式
  • 破解K个最近点问题的深度思考与通用解法
  • 链式结构的特性
  • 报表1-创建sql函数get_children_all
  • 9月20日 周六 农历七月廿九 哪些属相需要谨慎与调整?
  • godot实现tileMap地图
  • 【Unity+VSCode】NuGet包导入