目录
引入
- 在计算机科学中,锁是一种同步机制,用于控制对共享资源的访问
- 锁可以防止多个线程或进程同时访问临界区,从而防止数据竞争和保护共享资源的一致性
- 前面我们已经学习了互斥锁,但其实还有很多锁类型
两种加锁思想
悲观锁和乐观锁是并发控制中两种不同的策略,用于处理多个线程或进程同时访问共享资源的情况
悲观锁
思想
- 在整个数据处理过程中,会悲观地担心数据会被其他线程修改
- 因此在访问共享资源之前,先进行加锁,确保在同一时刻只有一个线程或进程能够访问该资源
- 而同时想要进入的其他线程或进程,需要等待锁的释放
实现方式
比如互斥锁,数据库中的锁机制(行级锁、表级锁等)
使用场景
在并发度较低、冲突概率较高的情况下,悲观锁可能更为合适
乐观锁
思想
- 乐观的认为在整个数据处理过程中,数据不会被其他线程修改
- 因此,不在访问共享资源之前加锁,而是在实际更新数据时检查是否发生了冲突
- 如果没有冲突,则更新成功;如果发生了冲突,采取相应的处理策略(例如重试或放弃更新)
实现方式
- 通常使用版本号(Version Number)或时间戳等机制
- 每次更新时都会比对当前版本号([当前数据的版本号]是否与线程[开始时读取的版本号]一致)
- 只有在版本号匹配的情况下(说明没有其他线程修改过),才能成功更新
使用场景
在并发度较高、冲突概率较低、以及希望减少加锁对系统性能影响的情况下,乐观锁可能更为合适
自旋锁
自旋
- 自旋是一种并发编程中的等待机制
- 其基本思想是 -- 在等待共享资源的线程中,通过循环检查资源是否可用,而不是立即进入阻塞状态
- 这个循环过程被称为自旋
引入
- 从前我们的加锁策略都是 -- 如果申请锁失败,就阻塞当前线程,直到对应锁释放
- 这种锁被称为挂起等待锁
- 但是,阻塞和唤醒线程都是要花费时间的
- 如果出现这种情况 -- 需要访问的临界区很小,其他线程很快就释放了锁
- 这时候再采取上面的措施,就有点浪费时间(可以但没必要)
- 就像你询问家人什么时候可以出门,对方说我马上就出门,而你此时选择开把游戏
- 很不合理对吧,明明对方很快就会出去
- 所以就出现了自旋锁 -- 不断询问对方是否完成,让自身处于随时就绪的状态,就可以减少中间的成本浪费
使用场景
取决于其他线程在临界区中运行的时间长短
- 如果长 -- 就使用挂起等待锁
- 如果短 -- 就使用自旋锁
接口
pthread_mutex_trylock()
如果将这个函数外面套一层while循环,就可以实现自旋
当然,linux也提供了直接的自旋锁接口(spin系列函数)
pthread_spin_*
和pthread_mutex_t类型一样,标识自旋锁的类型为pthread_spinlock_t
创建与销毁
- pshared用于指定自旋锁的共享性质,通常设置为0
当然,也可以静态获取自旋锁: PTHREAD_SPINLOCK_INITIALIZER
加锁和解锁
其中:
- lock会不断自旋,直到自旋锁被释放
- 而trylock在自旋锁被其他线程持有时,会立即返回
读写锁
引入
- 在编写多线程的时候,有一种情况是十分常见的 -- 有些公共数据修改的机会比较少,读的机会反而高的多 ,也就是读者写者问题
- 通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长,如果给这种代码段加锁,会降低我们程序的效率
- 为了解决这种多读少写的情况,读写锁的概念就被提出了
概念
- 是一种多线程同步机制,允许多个线程同时对共享资源进行读操作,但只允许一个线程进行写操作
- 这样可以提高并发性,因为多个线程可以同时读取数据而无需相互等待
理解
就比如,班级上需要办黑板报(但这里是小的那种,只能容纳一个人的空间去修改)
- 那么,每次只能有一个人去画
- 而班级是有空间的,可以允许多名同学观看黑板报
但是,如果同学在画的时候,有其他同学在看
- 看的同学只能得到部分的信息,这对双方都不公平(读者可能会因为部分的信息而误解写者的意图)
- 所以,写者在写的时候,不允许读者观看
同理,有其他同学在看时,有同学直接把它擦掉了
- 这样对看的同学不公平
- 所以,读者在读的时候,不允许写者修改
321原则
和之前学习生产消费者模型一样:生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点-CSDN博客读者写者问题也可以总结为321原则
- 1个场所 -- 公共资源
- 2个角色 -- 读者,写者
- 3种关系 -- 读者与读者(共享),读者与写者(互斥),写者与读者(互斥)
接口
读写锁的数据类型是pthread_rmlock_t
创建和销毁
attr为读写锁的属性,通常设置nullpt,表示使用默认属性
加锁和解锁
加锁分为读者加锁,写者加锁
不同的对象使用不同的加锁方式:
不同的加锁方式,遇到不同的锁状态,其行为也不同:
而解锁是通用的:
伪代码理解
我们使用伪代码来模拟一下加读者锁和写者锁:
- 因为读锁允许多个线程同时进入临界区,所以读取过程不进行加锁(也就是读锁申请时,不阻塞其他读锁请求)
- 但是,为了保证读取期间没有写者打扰,需要在第一个读者进来时,申请写者锁(也就是阻塞写锁请求,保证了读写之间的互斥)
- 注意,访问记录读者数量这个临界资源时,需要加锁保护
- 当读取过程中的最后一个读者离开时,需要释放写锁
- 而写者加锁很简单,就是在加锁区域内进行写入即可(这样就保证了写者之间的互斥)
- 因为读者访问期间,写者是阻塞状态,所以不需要额外操作就可以保证与读者互斥
- 而写者访问过程中,会让读者卡在申请写锁那里(也就保证了写读互斥)
读者优先和写者优先
由于读者比写者多的原因,读者竞争锁的概率会更高,自然会形成读者优先的局面
- 这是本身就存在的饥饿问题,是正常的
- 这里的饥饿问题是一个中性的概念,并不是平时说的那种
如果想要设置成写者优先(这样更公平一些),可以使用pthread_rwlockattr_setkind_np函数
pthread_rwlockattr_setkind_np
attr
- 读写锁属性对象的指针
- 通过pthread_rwlockattr_init来初始化:
pref
- 用于设置读写锁的类型
- (似乎第二个有bug,和读者优先有一样的表现)
- 默认类型取决于特定系统和库的实现