1. 问题提出
假设,大多数人都是查询火车票的余票数量,只有少量的人是购买火车票。比方说同时有 99 个人查访余票,而只有 1 个人买票……如果此时还用互斥量,就会显的效率非常低了,比方有任何一个人在查票的时候,其它 98 个想查票的人只能等待,这说不通。
为了提高读并发性,也就是说允许多个人同时查票(这并不会带来什么问题),于是发明了读写锁 rwlock。
- 只要有买票的人拿到了读写锁,即锁处于写模式的加锁状态,其它任何人都只能等待。
- 只要有查票的人拿到了读写锁,即锁处于读模式的加锁状态,这时候:
- 在没有取票人加锁的情况下,其它想查票的人都可以拿到锁,并继续加读模式的锁。
- 其它任何想取票的人只能等待锁,同时在此取票人之后,想查票的人也只能等待锁。(这是为了防止写线程处于饥饿状态,即长时间不能加写模式锁)
从上面的叙述中我们可以知道,读写锁有三种状态:读加锁状态、写加锁状态和不加锁状态。同学们需要好好的把前面的读写锁的特性读上几遍。
2. 读写锁
2.1 读写锁的数据类型
读写锁的数据类型是 pthread_rwlock_t
,通常它是一个结构体。同样也有两种方法可以对读写锁进行初始化:
- 使用宏
PTHREAD_RWLOCK_INITIALIZER
静态初始化读写锁。 - 使用
pthread_rwlock_init
函数动态初始化锁。
// 初始化读写锁的函数
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
// 回收读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
上面的函数和互斥量中的初始化函数是类似的,同样有关读写锁的属性暂时不讨论,现阶段 attr 参数传 NULL 值就行了。读写锁用完了,记得需要使用 destroy 函数进行回收。
2.2 读写锁的加锁和解锁
- 读模式加锁函数
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
- 写模式加锁函数
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
- 解锁函数
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
上面所有的函数返回 0 表示成功,否则失败。
上面的两个加锁函数都是阻塞版本,当然读写锁也提供了非阻塞版本,即 try 方式加锁,通常不推荐使用它们,不过这里也列出来:
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
无论加锁是否成功,上面的函数都会立即返回,成功返回 0,失败返回 EBUSY.
3. 读写锁示例
程序 trainticket 中,有 100 个线程,其中 90 个线程是查余票数量的,只有 10 个线程抢票,每个线程一次买 10 张票。初始状态下一共有 1000 张票。因此执行完毕后,还会剩下 900 张票。
程序 trainticket 在运行的时候需要传入参数,即 ./trainticket <0|1|2>:
- 参数 0:表示不加任何锁
- 参数 1:表示使用读写锁
- 参数 2:表示使用互斥量
在后面将会演示这三种情况,它们的结果也是不同的。
3.1 程序清单
// trainticket.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
struct Ticket{
int remain; // 余票数,初始化为 1000
pthread_rwlock_t rwlock; // 读写锁
pthread_mutex_t mlock; // 互斥锁,主要是为了和读写锁进行对比
}ticket;
// 通过命令行传参数来取得这个值,用来控制到底使用哪一种锁
// 0:不加锁 1:加读写锁 2:加互斥锁
int lock = 0;
// 查票线程
void* query(void* arg) {
int name = (int)arg;
sleep(rand() % 5 + 1);
if (lock == 1)
pthread_rwlock_rdlock(&ticket.rwlock); // 读模式加锁
else if (lock == 2)
pthread_mutex_lock(&ticket.mlock);
int remain = ticket.remain;
sleep(1);
printf("%03d query: %d\n", name, remain);
if (lock == 1)
pthread_rwlock_unlock(&ticket.rwlock);
else if (lock == 2)
pthread_mutex_unlock(&ticket.mlock);
return NULL;
}
// 抢票线程
void* buy(void* arg) {
int name = (int)arg;
if (lock == 1)
pthread_rwlock_wrlock(&ticket.rwlock); // 写模式加锁
else if (lock == 2)
pthread_mutex_lock(&ticket.mlock);
int remain = ticket.remain;
remain -= 10; // 一次买 10 张票
sleep(1);
ticket.remain = remain;
printf("%03d buy 10 tickets\n", name);
if (lock == 1)
pthread_rwlock_unlock(&ticket.rwlock);
else if (lock == 2)
pthread_mutex_unlock(&ticket.mlock);
sleep(rand() % 5 + 2);
return NULL;
}
int main(int argc, char* argv[]) {
lock = 0;
if (argc >= 2) lock = atoi(argv[1]);
int names[100];
pthread_t tid[100]; // 之前写成了 pthread_t tid[10],感谢 @juruiyuan111 指出错误
int i;
for (i = 0; i < 100; ++i) names[i] = i;
ticket.remain = 1000;
printf("remain ticket = %d\n", ticket.remain);
pthread_rwlock_init(&ticket.rwlock, NULL);
pthread_mutex_init(&ticket.mlock, NULL);
for (i = 0; i < 100; ++i) {
if (i % 10 == 0)
pthread_create(&tid[i], NULL, buy, (void*)names[i]);
else
pthread_create(&tid[i], NULL, query, (void*)names[i]);
}
for (i = 0; i < 100; ++i) {
pthread_join(tid[i], NULL);
}
pthread_rwlock_destroy(&ticket.rwlock);
pthread_mutex_destroy(&ticket.mlock);
printf("remain ticket = %d\n", ticket.remain);
return 0;
}
3.2 编译和运行
$ gcc trainticket.c -o trainticket -lpthread
- 不加锁的运行方式和结果
$ ./trainticket 0
这种方式的运行的结果是错误的,最后打印的余票数量是 990 张:
图1 不加锁时的运行结果
- 使用读写锁的运行结果
$ ./trainticket 1
图2 使用读写锁的运行结果
可以发现程序最后的结果是没问题的。
- 使用互斥量的运行结果
$ ./trainticket 2
图3 使用互斥量的运行结果
可以发现,程序最后的结果仍然是正确的。
3.3 结果分析
到此为止,你看文字叙述和图 1、图 2 和图 3 中的结果是看不出来读写锁和互斥锁有什么区别,但是如果你亲自动手做了这个实验,我相信你一辈子也忘不了。
不过对于不想做实验的人,我直接告诉你结果:当使用互斥量的时候,这个程序会运行的非常非常慢,你可能没有耐心等待到它执行完毕……
4. 总结
- 理解读写锁的使用场合
- 读写锁有三种状态
- 读写锁与互斥锁的区别
- 读写锁是如何防止写饥饿的
练习:复制本文的代码,编译运行。