目录
1.读者写者模型
(1)读者写者问题
和生产者-消费者模型类似,线程在该模型中承担读者、写者的角色。遵循“321原则”,即3种关系(读者读者、读者写者、写者写者),2种角色(读者、写者),1个交易场所。
读者写者问题应该用制作黑板报的实例来理解。一个人来教室制作黑板报,制作的过程中别人不能来看,也没必要看。当黑板报制作完成之后,写者就走出教室,喊别人来看黑板报。重点是可以多个人同时来看黑板报!在还有人看黑板报的时候制作黑板报的人不能擦掉黑板报 ,当所有人看完之后,制作人才会进入教室,擦掉黑板报,重新制作新的黑板报,做完之后又叫别人来读。
(2)互斥与同步关系
我们可以发现,读者写者模型和生产者-消费者模型存在一些不同之处,其中最大的不同就是:生产者-消费者模型中的消费者会取走数据(存在竞争),因此消费者之间是互斥的。但读者写者模型中,读者之间并不互斥,我就看看黑板报,又不取走数据,不存在竞争,所以凭什么同一时间就只能一个人看?
所以,根据对比,我们就能总结出读者写者模型中的3种关系:
写者、写者:互斥关系,同一时间黑板报只能由一个人来写。
读者、写者:互斥(写黑板报时你不能来读,黑板报没读完之前你不能来写)、同步(写了你赶快过来读,读完了你赶快来写)。
读者、读者:并发关系(你在读,我也能读)。
这引入了一个新的概念,并发关系。下面介绍一下不同关系之间的区别。
(3)并发、并行、串行、同步、异步
串行: 按顺序排队执行任务,一个任务完全结束后,下一个才能开始。
并发: 在同一时间段内轮询执行任务,某一时刻只在执行一个任务,但从宏观上看任务是同时进行的,例如采用时间片轮询的进程调度,单核处理器即可实现并发。 因此,除了读者和读者之间并发之外,生产者消费者模型从宏观上看也是并发的!
并行: 可认为是并发的子类。 它也意味着宏观上看任务是同时进行的,但区别在于对于并行来说,某一时刻有多个任务正在执行,而非单一任务。 所以并行需要多核处理器来实现(例如,8核处理器可在一时刻可以并行8个任务,但在一个时间段内可并发的任务可以远超8个),这是硬件要求。满足并行的,都可以说是并发。所以说读者和读者之间并发,具体实现可以并行。
同步: 同步体现在事件之间存在很强的依赖、先后关系。 例如生产者生产数据之后,超市里才会有数据,才会去唤醒消费者。消费者消费了数据之后,才会有空位,才会去唤醒生产者。在读者写者模型中,只有写者写完数据后,才有完整的数据,才会唤醒读者去读。同理,只有读者读完数据之后,才说明黑板报无意义了,才会唤醒写者去写。
异步: 异步说明事件之间并没有太多依赖关系,逻辑链条是分离的。 例如,异步IO中,线程只需要派发任务、执行其它任务、使用数据,全程不需要主动参与IO中。而同步IO中,IO的等和拷贝至少其一需要线程参与。所以对于异步IO来说,之所以叫异步,是因为线程执行的任务和进行IO操作本身就是两条逻辑链,它们之间没有交集! 同理,我们能很轻松地理解到,中断也是异步的。
(4) 饥饿问题、优先策略
我们其实从3个关系中不难发现,写者和写者之间互斥(竞争关系),读者和读者之间并发(非竞争关系)。这种关系相比较生产者-消费者模型是不对称的,这就会导致读者饥饿问题和写者饥饿问题。
一般的读者写者模型中,读者很多,写者较少。就会出现一个读者进去读完数据,正准备出来时,另一个读者也进来了。 当这个读者读完数据准备出来的时候,又一个进去了。写者这个时候只能站在门口看着,一直进不去,由于这种读者和写者的互斥特性,导致出现写者饥饿问题。 同时,像这种读者没读完之前,写者无论如何都不能进入,就算写者都站在门口准备进去了,结果来了个读者,写者就得等它进去读完的策略,叫做读者优先。
一般默认使用读者优先。
那写者优先呢?
写者优先和读者优先只是两个不同的策略,它们仍然遵守321原则,只不过某些地方处理原则不同。 写者写完数据之后交给读者去读(同步),可以有多个读者进去读(并发),同样读者在读的时候写者不能进去写(互斥)。但是,门口开始排队了,写者和读者排在同一个队列里面,一旦写者站在门口了,就会等者现在看黑板报的人看完,读者全部出来后自己就进去写,后面的读者就被阻塞。站在写者身后的人说我也要读那个数据,但写者根本不想理身后的读者,只有队列里站在写者之前的读者能进入教室并发读取。
这解决写者饥饿的问题了吗?解决了! 但相对应的,如果使用写者优先策略时,读者很少,写者很多呢?
排在教室外面的队列中,写者占多数,零零星星有几个读者。在写者优先且写者很多的情况下,读者基本读不到什么数据,因为排队时写者一拥而上,读者就排在队尾,等着每个前面的写者进去写数据后,自己才能进去读一次,读完后又排在全是写者的队伍后面,这就是读者饥饿问题。
读者优先下,读者读了之后要进行处理,写者能趁机进来,所以说写者饥饿问题不算特别严重。同理,写者优先下,写者也要花时间准备数据,读者能趁机进去读,读者饥饿问题也不算特别严重。
因此,读者饥饿问题和写者饥饿问题不可能同时解决,读者优先和写者优先都只能解决其中之一的问题。更准确的说,饥饿问题算是读者写者模型中的特性,不同策略需要在不同情况下使用。
2.读写锁
读写锁是针对读者写者模型设计的,满足读者写者模型的特性。
(1)代码示例
pthread库中提供的读写锁仅针对读者优先设计,以适应一般情况(读者多,写者少),所以下面的代码实例是以读者优先为策略的读者写者模型示例代码:
#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)
{
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 nullptr;
}
// 写者线程函数
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 nullptr;
}
// pthread库只提供了读者优先策略
int main()
{
srand(time(nullptr)); // 初始化随机数种子
pthread_rwlock_init(&rwlock, NULL); // 初始化读写锁
const int reader_num = 2; // 假设读者数量为2
const int writer_num = 2; // 假设写者数量为2
const int total = reader_num + writer_num;
pthread_t threads[total]; // 线程数组,保存创建的线程的线程id
// 创建读者线程
for (int i = 0; i < reader_num; ++i)
{
int* id = new int(i); // 线程号
pthread_create(&threads[i], nullptr, Reader, id);
}
// 创建写者线程
for (int i = reader_num; i < total; ++i)
{
int* id = new int(i - reader_num); // 线程号
pthread_create(&threads[i], nullptr, Writer, id);
}
// 等待所有线程完成
for (int i = 0; i < total; ++i)
{
pthread_join(threads[i], nullptr);
}
pthread_rwlock_destroy(&rwlock); // 销毁读写锁
return 0;
}
其使用和互斥锁非常相似,我们只要学习过生产者消费者模型,这个代码基本不成问题。
下面是代码运行效果:

我们能充分理解这种写者饥饿问题。所以我们尝试让读者变少,写者变多,这样的话读写者竞争锁就要好一些。

(2)关键代码说明
注意我们是申请的一把读写锁而不是两把,后续是通过两个接口来判断是读者申请读写锁还是写者申请读写锁,进而进行读者写者模型规则判断是否放行。

注意全局定义的锁pthread_rwlock_t需要调用pthread_rwlock_init()和destroy()函数,这和互斥锁一样。
下面是最关键的读写锁的申请代码,也是体现读者写者模型的核心。

我们发现读者和写者调用的是不同的接口,pthread_rwlock_rdlock以读身份加锁,pthread_rwlock_wrlock以写身份加锁,pthread_rwlock_unlock释放锁。 其锁的申请规则很简单:写者申请到锁的话其余写者和读者都申请不到锁,只要写者释放锁读者就可以申请锁,并且多个读者都可以申请到读写锁,因为读者和读者是并发的,这点和互斥锁有很大的不同。只有全部读者都释放锁之后写者才有机会申请到锁。
同时我们也能发现,锁之间管理的其实就是所谓的共享资源,这再次验证了对临界资源进行保护就是对临界区进行保护,加锁、解锁之间的代码就是临界区。
关于这个读写锁的底层逻辑,其实需要一个计数器来维护。这个计数器负责统计获取到读写锁的读者的数量,如果计数器为0,写者就能申请到锁。当写者获取到锁时,计数器一定为0,并且在写者释放锁之前读者申请不到锁。 因此,读写锁通过读者写者模型的规则保护着临界资源的代码,让其按读者优先运行。
3.自旋锁
(1)互斥锁的申请
对临界资源进行保护就是对临界区进行保护,加锁、解锁之间的代码就是临界区。这是我们在互斥锁、信号量里面建立的观念,并在上面的读写锁中再次得到验证。
当信号量和互斥锁申请锁失败时,这个线程需要阻塞等待,等待方式是挂起等待,该线程对应的task_struct被链入到了专门的等待队列里面(和IO阻塞被链入struct device队列有区别,相应的状态也会有差异)。当锁被释放时,会有系统调用自动唤醒队列中的线程。 整个过程中系统开销算是能够接受,因为没有申请到的线程会被链入等待队列里面,等待期间并不会占用CPU资源。 并且一般我们也会使用互斥锁 + 条件变量,手动控制同步过程,通过Notify手动唤醒线程。 因此,互斥锁单独使用时开销较低,结合条件变量控制同步简单,是最常用的锁之一。
但是,这始终是有开销的。互斥锁单独使用时,申请不到锁的线程被链入等待队列,当锁被释放时会有系统调用自动来唤醒队列里的线程,整个过程依然有开销。 举个例子:有个线程占用着临界区,外面有个线程也要申请这个临界区。外面这个线程打电话给临界区里面的线程,问它还要多久。 当临界区的线程告诉站在门口的线程还需要再等1个小时 / 1分钟时,外面的线程需要根据等的时间决定等待方式,要么一直站在外面继续等,要么先跑去上网打打游戏。 如果人家告诉你1分钟后就出来了,外面这个线程会选择跑去上网吗?不会!为什么?去网吧路上花时间,跑回来也要花时间,相比较直接站在这里等开销占比更大。
而在互斥锁的处理逻辑中,只要申请不到锁,这个线程就要跑到网吧里面去,极有可能外面这线程还没到网吧,里面的线程就出来了,这太不划算了!
那有没有那种一直站在外面等的线程呢?这就引入了自旋锁。
(2)自旋锁的申请
根据刚才的例子我们知道,一个线程在临界区里面执行的时长有长有短,而互斥锁切换等待状态是需要花时间的。要是有线程占有临界资源时间非常非常短,那么互斥锁的等待逻辑就不划算。 在线程占有临界资源时间非常非常短的情况下,这个时候我们可以选择循环式申请锁,即持续自旋,在一个死循环中不断检查锁的可用性,直到申请到锁。整个过程中不存在切换线程状态的问题,自然也没有切换队列的开销,这种锁叫做自旋锁。 这种自旋,就对应线程一直站在临界区门口,不断给临界区的线程打电话,而不会选择去网吧。
(3)代码示例
下面是一个典型的售票系统,通过加锁防止票被卖到负数,只不过这次采用自旋锁而非互斥锁。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
// 售票系统
int ticket = 100;
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申请票,当前剩余票数:%d\n", id, ticket);
ticket--;
pthread_spin_unlock(&lock); // 解锁
}
else
{
pthread_spin_unlock(&lock); // 解锁
break; // 只要票数不足,所有线程都会依次申请到锁,并退出循环
}
}
return nullptr;
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE); // 初始化自旋锁,函数第二个参数pshared默认设置成0也可以
pthread_create(&t1, nullptr, route, (void *)"线程1");
pthread_create(&t2, nullptr, route, (void *)"线程2");
pthread_create(&t3, nullptr, route, (void *)"线程3");
pthread_create(&t4, nullptr, route, (void *)"线程4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
pthread_spin_destroy(&lock); // 销毁自旋锁
return 0;
}
我们发现这个自旋锁的使用和互斥锁可以说是一模一样(init、destroy、lock、unlock、trylock使用均一致)。当然这也确实能够理解,因为自旋锁和互斥锁的差异主要来自于底层针对等待的不同处理方式,它们遵循的整体逻辑是一样的。申请锁函数我们用户感知不到自旋,但函数内部其实一直循环自旋。
下面是代码结果,可以发现没有任何问题。

(4)自旋锁和互斥锁的选择
①自旋锁的底层
自旋锁的底层其实依靠的是一个共享标记位。 这个标记用于标记锁是否正在被使用,false表示当前没有被使用,可以申请,此时线程申请到锁就会修改它为true。如果其它线程来了,监测到标记为true时,就会循环申请(一直调用CPU资源,开销大)。当然,这个过程必须原子(C++11的atomic_flag可模拟实现)。
②活锁的形成
所以,到底用不用自旋锁取决于线程对临界资源占用时间的情况,如果时间过长或者时间不可控,使用自旋锁会导致CPU资源浪费,申请锁的线程会疯狂地循环。如果某个线程一直占用临界区,而其余所有线程都在循环申请自旋锁,但都无法进入临界区,这就形成了活锁。
要解决活锁,其实使用退避策略即可,申请锁时使用trylock,这样申请锁失败了就会立马返回 ,我们可以在外部自定义维护一个超时时间,这样的话开销相对小一些。当然,使用互斥锁是也是一个方案。
自旋锁一般用于操作系统底层,因为切换线程状态,切换task_struct所在链表对于系统底层级别的运行成本太高了,再加上系统底层的锁申请时间确实非常短,因此采用自旋锁较多。
在用户层,还是更建议使用互斥锁,因为用户层的锁持有时间相对较长,也不太好控制,一味使用自旋锁只会增加软件占用,把CPU打满。

被折叠的 条评论
为什么被折叠?



