目录
一、自旋锁
1.自旋锁和挂起等待锁
2.自旋锁的接口
二、读写锁
1.读者写者模型与读写锁
2.读写锁接口
3.加锁的原理
4.读写优先级
一、自旋锁
1.自旋锁和挂起等待锁
互斥锁的类型有很多,我们之前使用的锁实际上是互斥锁中的挂起等待锁。互斥锁比较有代表性的锁类型是挂起等待锁和自旋锁,在这里我们讲解他们的不同。
- 多线程在竞争一个挂起等待锁时,申请到锁的线程会进入临界区,而没有申请到锁的线程会被放入操作系统维护的等待队列中,也就是阻塞等待。在合适的时候,操作系统会将放到运行队列中继续申请锁。
- 互斥锁也能保护共享资源的安全。多线程以同样的方式竞争自旋锁,申请到锁的线程会进入临界区,而没有申请到锁的线程会继续不停申请锁,直到申请锁成功进入临界区,与进程的轮询等待非常的相似。
- 挂起等待锁会将未申请到锁的线程挂起,CPU暂时就不需要调度它,可以去执行其他任务。而未申请到自旋锁的线程会不断申请,始终占用CPU资源。
那为什么会出现自旋锁呢?
因为线程等待的时长决定了线程等待的方式。
当线程访问临界资源的时间较短的时候,就可以使用自旋锁。挂起线程再唤醒线程的过程也是有不小的开销的,所以执行时间短的线程如果保持自旋状态,线程免去了挂起的过程,可以很快进入临界区,一定程度上提高了效率。
当线程访问临界资源的时间较长的时候,就要使用挂起等待锁。对运行时间长的线程,将申请锁失败的线程挂起,CPU资源就空闲了出来。此时如果使用自旋锁,CPU一直被占用,效率就会下降。
那等待的时间长短怎么衡量呢?
- 对于处理简单的线程,比如前面的多线程抢票代码,对tickets的访问就可以使用自旋锁。
- 对于需要进行复杂运算,高IO以及等待某些软件标志就位的情况,就必须使用挂起等待锁。
上面的描述只是一种经验,等待时间的长短并没有明确的定义。自旋锁和挂起等待锁的取舍需要根据具体情况选择。最简单有效的方式就是分别测试两种锁,哪种效率高就用哪种。
2.自旋锁的接口
以下是自旋锁(pthread_spinlock_t)的一些成员函数,与挂起等待锁基本一致:
int pthread_spin_init(pthread_spinlock_t* lock, int shared);
头文件:pthread.h
功能:初始化互斥锁。
参数:pthread_spinlock_t* lock表示需要被初始化的自旋锁的地址。int shared表示锁的是否进程间共享,0表示共享,非0表示不共享,一般都设为0。
返回值:取消成功返回0,取消失败返回错误码。
int pthread_spin_destroy(pthread_spinlock_t* lock);
头文件:pthread.h
功能:销毁互斥锁。
参数:pthread_spinlock_t* lock表示需要被销毁的自旋锁的地址。
返回值:销毁成功返回0,失败返回-1。
int pthread_spin_lock(pthread_spinlock_t* lock);
头文件:pthread.h
功能:对lock到unlock的部分代码加锁(仅允许线程串行)。
参数:pthread_spinlock_t* lock表示需要加锁的锁指针。
返回值:加锁成功返回0,失败返回-1。
int pthread_spin_unlock(pthread_spinlock_t* lock);
头文件:pthread.h
功能:标识走出lock到unlock的部分代码解锁(恢复并发)。
参数:pthread_spinlock_t* lock表示需要解锁的锁指针。
返回值:解锁成功返回0,失败返回-1。
我们下去可以把之前写的抢票代码改出一个自旋锁版本,使用上就不强调了。
二、读写锁
1.读者写者模型与读写锁
读写锁主要应用于读者写者模型,读者写者模型和生产者消费者模型很相似,也遵循321原则。
- 三种关系:写者和写者(互斥),读者和写者(同步和互斥),读者和读者(没关系)。
- 两种角色:读者和写者。
- 一个交易场所:任意类型的数据结构。
读者线程和写者线程并发访问一块临界资源:
- 写者向临界资源中写数据。
- 读者从临界资源中读数据。
- 读者和写者之间是互斥关系
这里只有三种关系不太好理解:
(1)写者和写者直接是互斥关系。
如果一个写者正在写数据,另一个写者也来写。他们如果写的是同一块资源,就有可能发生覆盖,数据就会出错。
(2)读者和读者之间没有关系。
读者只从临界区中读取数据,并不改变临界资源,所以读者之间不会相互影响。
(3)写者和读者是互斥关系,也是同步关系。
写者在写数据时,如果允许读者读取,读者读到的数据就会不全。所以写者写数据时,读者不能读数据,它们是互斥关系。
写者向数据结构中写数据,读者从中读数据,二者动态平衡才能维持程序运行。所以它们也是同步关系。
读者写者模型可以在哪些场景下使用?
这种模型适用于:一次发布且很长时间不修改,大部分时间都是在被读取,比如你现在看的这篇博客。
所以,读者写者模型与生产者消费者模型最大区别就是消费者会拿走临界资源中的数据,而读者不会。
那这跟读写锁又有什么关系呢?
有些共享资源的数据和我们的博客一样,很少修改,反而需要频繁读取,它们被读取的机会比被修改高得多。
在读取这种数据的时候,往往需要大量的查找时间,如果我们再给这样的代码加锁,那么程序的效率将会严重下降。
读写锁就是专门用于读者写者模型中的一种锁,可以维护读者写者的321原则。(这里不太理解的话可以看后面的伪代码)
临界区的状态 | 读者申请 | 写者申请 |
不加锁 | 可以 | 可以 |
读锁 | 可以 | 阻塞 |
写锁 | 阻塞 | 阻塞 |
持有写锁的线程独占临界资源,持有读锁的线程,读者之间共享临界资源。
2.读写锁接口
以下是自旋锁(pthread_rwlock_t)的一些成员函数:
int pthread_rwlock_init(pthread_rwlock_t* rwlock, const pthread_rwlockattrt_t* attr);
头文件:pthread.h
功能:初始化互斥锁。
参数:pthread_rwlock_t* rwlock表示需要被初始化的读写锁的地址。const pthread_rwlockattrt_t* attr表示读写锁属性结构体指针,一般设置成nullptr即可。
返回值:取消成功返回0,取消失败返回错误码。
int pthread_rwlock_destroy(pthread_rwlock_t* rwlock);
头文件:pthread.h
功能:销毁互斥锁。
参数:pthread_rwlock_t* rwlock表示需要被销毁的读写锁的地址。
返回值:销毁成功返回0,失败返回-1。
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
头文件:pthread.h
功能:对lock到unlock的部分代码加读锁(仅允许线程串行)。
参数:pthread_spinlock_t* lock表示需要加读锁的锁指针。
返回值:加读锁成功返回0,失败返回-1。
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
头文件:pthread.h
功能:对lock到unlock的部分代码加写锁(仅允许线程串行)。
参数:pthread_spinlock_t* lock表示需要加写锁的锁指针。
返回值:加写锁成功返回0,失败返回-1。
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);
头文件:pthread.h
功能:标识走出lock到unlock的部分代码解锁(恢复并发)。
参数:pthread_spinlock_t* lock表示需要解锁的锁指针。
返回值:解锁成功返回0,失败返回-1。
3.加锁的原理
在任何时刻,读写锁只允许一个写者写入,但允许多个读者并发读取(读者读时写者阻塞)。
这是不是就很奇怪,明明锁应该只有一把,怎么还出了读锁和写锁,读锁还能同时给多个线程。
读写锁(pthread_rwlock_t)是一个结构体,它封装的也是互斥锁。只是针对读者有一把,针对写者有一把,二者申请的思路不同罢了。
加读锁伪代码: pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
pthread_mutex_t rdlock;//创建读锁
int reader_count = 0;//读者计数
------------------------------------------------------------
lock(&rdlock);//读加锁
reader_count++;//读者数量加一
if(reader_count == 1)
{
//只要有读者在访问临界资源,就将写锁也申请走
lock(&rwlock);//写加锁
}
unlock(&rdlock);//解读锁
------------------------------------------------------------
//读取数据....
------------------------------------------------------------
lock(&rdlock);//再次加读锁
read_count--;//读者数量减一
if(reader_count == 0)
{
//读者全部读完以后,释放写锁
unlock(&rwlock);//写解锁
}
unlock(&rdlock);//读解锁
伪代码的含义:
加读锁时,有一个所有读者线程共享的计数器,用来统计访问公共资源的读者数量。
每个读者访问公共资源时,都需要将计数值加1,考虑到线程安全,所以计数值要加锁。
当第一个读者到来后,它会先申请读锁,然后申请写锁。如果第二个读者线程也要申请读锁,只需要将计数器加一即可。此时,由于写锁在读者手里,写者线程申请不到写锁,也就无法访问临界资源了。
一个读者读完数据后,计数器将通过原子性的操作将值将减一。当值被减到0时,说明没有读者再来读数据了。此时写锁会被解锁,写者线程就可以申请写锁并输入数据。
通过这样的方式就实现了读者和写者间的互斥,也使得读者线程可以并发执行。
加写锁伪代码: pthread_rwlock_wrlock(pthread_rwlock_t* lock);
pthread_mutex_t wrlock;//创建写锁
------------------------------------------------------------
lock(&wrlock);//写加锁
//向临界资源中写入数据
unlock(&wrlock);//写解锁
写者间直接使用挂起等待锁,实现了写者间的互斥关系。
4.读写优先级
上面的模型中,读线程一旦申请到锁,那么写锁也被同时被申请走了。除了所有读线程都未在读取数据时,读线程和写线程可以根据自己的竞争能力申请锁。如果有读者在访问共享资源,此时读写锁就是读者优先的。
如果读者非常多,那读者申请到锁的几率会非常大,而且读者还会抱着写锁不放。写者申请不到锁,始终无法进入临界区访问临界资源,就会导致写者饥饿问题。所以说,读写锁很适合管理读取次数多,修改次数极少的数据。
读写锁虽然设计上更偏向于读者,但也是可以设置成写者优先的。
大体逻辑是这样的:读者申请到读锁和写锁并访问公共资源,即使写锁已经被申请,写线程也会被调度。
根据线程的运行顺序,系统将会把写线程后的所有读线程阻塞,不允许它们访问公共资源。
当前面的所有读线程都读完数据后,由于挂起的读线程不会加到计数器上,所以此时计数器数字为0。读线程归还写锁,写线程申请到读锁后开始写入数据。
pthread库就已经提供了设置读写锁的读写优先级的接口:
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t* attr, int pref);
头文件:pthread.h
功能:设置读写优先级
参数:pthread_rwlockattr_t* attr是一个读写锁设置锁属性的结构体指针,pref是读者写者优先选项,有三种选项。
PTHREAD_RWLOCK_PREFER_READER_NP:(默认设置)读者优先,可能会导致写者饥饿情况。
PTHREAD_RWLOCK_PREFER_WRITER_NP:写者优先
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP:写者优先,但写者不能递归加锁。
返回值:加读锁成功返回0,失败返回-1。
此时我们关于自旋锁和读写锁的问题就都解决了,这一章一方面操作于原来使用的挂起等待锁基本一致,另一方面,我们能使用的地方不多。所以希望大家能尝试一些代码练习以巩固知识。