线程安全的单例模式、自旋锁,以及读者写者问题
线程安全的单例模式、自旋锁,以及读者写者问题
上一次花了不少时间拆解线程池和环形队列的代码,不知道大家有没有发现,线程池的本质其实就是生产消费模型——把任务当作“产品”,线程池当作“工厂”,扔进去任务就能自动处理。往后我们完全可以把线程池当成一个组件,需要的时候封装任务、定义线程池、抛入任务,就能快速完成并发处理。而且我们还对线程做了简单封装:把线程当作类,包含线程ID、名字、启动时间戳、运行状态和要执行的任务,启动时创建线程执行run函数,再通过回调完成核心逻辑。
好了,回顾完之前的内容,我们今天要聚焦三个核心话题:线程安全的单例模式、自旋锁,以及读者写者问题。
一、单例模式:让类只有“一个分身”
先提出一个问题:如果我们写一个服务器程序,配置文件只需要加载一次,线程池也只需要一个实例,这种场景下,怎么保证一个类不会被创建多个对象?答案就是单例模式——它的核心就是让某个类在整个程序生命周期里,只能实例化出一个对象。
单例模式有两种经典实现思路,我们用生活里的例子就能讲明白。
1. 饿汉模式:“提前备好,随用随取”
饿汉模式就像吃完饭立马洗碗的人——当下就把后续要用的准备好,下次吃饭时直接拿碗盛饭就行。对应到代码里,就是程序加载时(也就是进程启动时),就直接创建好单例对象,后续需要用的时候,直接返回这个现成的对象。
为什么叫“饿汉”?因为它“迫不及待”地把对象创建出来,不等人要。比如我们定义一个类,里面放一个静态的类对象(不是指针),程序一加载,这个静态对象就会在全局数据区初始化——就像全局变量一样,进程启动时就存在,进程退出时才释放。
这种模式的优点很明显:访问速度快,因为对象早就创建好了,不需要临时创建;而且不存在线程安全问题,毕竟加载时只有一个进程初始化阶段,没有并发。但缺点也很直接:如果对象很大,或者初始化逻辑复杂,会拖慢程序的启动速度——就像你出门前要把所有可能用到的东西都装进包里,虽然用的时候方便,但收拾包的时间会变长。
大家可以做个小实验验证一下:定义一个全局的类对象,在构造函数里打印日志,再在main函数里先休眠3秒。运行后会发现,日志会在程序启动时就打印出来,而不是等3秒后——这就说明全局对象(包括饿汉模式的单例对象)在进程加载时就已经创建了。
2. 懒汉模式:“需要再做,不浪费时间”
懒汉模式则相反,就像吃完饭把碗放着,下次要用时再洗——核心是“延迟加载”:程序加载时不创建对象,第一次调用获取对象的接口(比如getInstance)时,才创建对象,后续调用直接返回已创建的对象。
这种思路在很多地方都有应用,比如我们打开一个10G的游戏,不会一开始就把所有资源加载到内存,而是玩到某个关卡再加载对应内容;再比如操作系统的缺页中断——你申请内存时,系统只是在地址空间标记一下,等你真正访问时才会分配物理内存。这些都是“延迟加载”的体现。
懒汉模式的优点是提升启动速度——程序加载时不用花时间创建大对象,适合初始化成本高的场景。但缺点也很关键:多线程环境下会出问题。比如两个线程同时调用getInstance,都判断“对象还没创建”,然后都去创建对象,最后就会出现多个实例,违背单例的初衷。
3. 多线程安全:给单例“上把锁”
怎么解决懒汉模式的线程安全问题?最直接的办法是加锁——在创建对象的逻辑前后加互斥锁(pthread_mutex_t),确保同一时间只有一个线程能执行创建对象的代码。
但这里有个优化点:如果每次调用getInstance都要加锁,哪怕对象已经创建好了,后续线程还是要排队加锁、解锁,会浪费性能。就像电影院检票,电影开始后所有人都要排队检票,但其实只要确认票是有效的,直接进就行,不用每次都排队。
所以我们需要“双重检查锁定”(Double-Checked Locking):
- 第一次检查:判断对象是否已创建,如果已创建,直接返回,不用加锁;
- 如果对象没创建,加锁;
- 第二次检查:加锁后再判断一次对象是否已创建(防止加锁前有其他线程创建了对象);
- 如果还是没创建,就创建对象,最后解锁。
这样一来,只有第一次创建对象时需要加锁,后续调用都能直接返回,兼顾了线程安全和性能。
另外,实现懒汉模式时,还要注意两点:
- 把类的构造函数、拷贝构造函数、赋值运算符重载设为私有,并且禁用(用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),用法和互斥锁类似:
- 初始化自旋锁:pthread_spin_init;
- 加锁:pthread_spin_lock(阻塞版,自旋等待)或pthread_spin_trylock(非阻塞版,失败返回);
- 解锁:pthread_spin_unlock;
- 销毁自旋锁:pthread_spin_destroy。
大家可以对比一下:互斥锁是“挂起等待”,自旋锁是“循环等待”,选择哪种锁,本质上是权衡“临界区时间”和“挂起唤醒开销”。
三、读者写者问题
接下来我们聊聊另一个经典的并发模型——读者写者问题。它比生产消费模型更灵活,生活中随处可见:比如博客(作者写,读者看)、12306票务系统(系统更新票务数据是写,用户查票是读)、黑板报(同学写,其他同学看)。
要理解这个模型,我们可以用“321原则”拆解:
1. 321原则:理清模型的核心要素
- 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解锁。这样既能保证查票时的并发效率,又能保证抢票时的库存一致性。
四、多线程的总结
当我们把单例模式、自旋锁、读者写者问题串联起来,会发现多线程编程的核心其实是“平衡安全与效率”:
- 单例模式用双重检查锁定,既保证线程安全,又避免多余的锁开销;
- 自旋锁和互斥锁的选择,是权衡临界区时间和挂起唤醒成本;
- 读者写者模型的优先策略,是平衡读效率和写延迟。
回顾整个多线程部分,我们从线程的基本概念,到互斥、同步,再到生产消费、线程池、单例、自旋锁、读者写者——这些知识点不是孤立的,而是层层递进的:理解了互斥锁,才能看懂单例的线程安全;理解了生产消费,才能对比出读者写者的不同;理解了锁的本质,才能选择自旋锁还是互斥锁。