提到并发编程里的同步,你大概率已经听过多线程共用一份数据时,要用互斥锁(mutex)把临界区包起来。互斥锁好用,但用久了你会发现一个隐隐的别扭:它把所有线程一律平等对待,读和写一视同仁地"排他"。可现实世界里的数据访问,常常是"读得多、写得少"。让十个都在"读"的线程排队一个个来,明明互不干扰,却要陪着耽误工夫——这太浪费了。

这就是这篇文章要解决的痛点。我们先讲清楚一个教科书级的经典问题——读者写者问题,再引出它的官方解决武器读写锁(Read-Write Lock),随后一路看接口、跑代码、聊公平性,最后用信号量亲手实现一遍,并扒开它表面华美之下的种种坑。你放心,这是一篇把"掰开揉碎"四个字落到笔头的文章,我们慢慢来。

在正式开讲之前,先建立两个直觉,它们会贯穿全文:"读"这个动作天然可以并发,因为多个人同时看一份数据不会把数据看坏;而"写"这个动作必须独占,因为理解正在修改中的数据,读到的一定是残缺或矛盾的中间状态。 所有读者写者的玩法、所有读写锁的设计,追根究底,都是在这两句话上做文章。

你应该有的知识准备

  • 多线程与线程创建:pthread_create、pthread_join。读完第一个线程时对它们的用法有印象即可。
  • 临界区与互斥锁:多个线程访问共享数据的代码段叫临界区(critical section),互斥锁(mutex)用来保证同一时间只有一个线程进入临界区。
  • 信号量:sem_t、sem_wait(P 操作,减一,小于 0 阻塞)、sem_post(V 操作,加一,唤醒阻塞者)。后面我们要用信号量自己实现读者写者模型,熟悉它的两三个 API 够用了。

你不需要已经精通线程,只需要"听过互斥锁"这种程度。读下去的时候,凡是出现新的东西,我都会先解释再使用。

读者写者问题:从两个经典之源说起

课本上通常把"读者写者问题"和"生产者消费者问题"并列为操作系统的同步经典问题。先复习一下你更可能熟悉的生产者消费者:有一块缓冲区,生产者往里放数据,消费者从里取数据,两者共存时的核心矛盾是"缓冲区的容量"——放得太多放不下,取得太空没得取,还得防止生产者和消费者同时动同一个槽位。

读者写者问题看起来有点像,但骨子里完全不同。它假设有一份共享数据(比如一份配置表、一张成绩单、一个地图文件),访问它的线程分成两类:

  • 读者(Reader):只读这份数据,不修改它。读多少遍、多少个读者同时读,数据都不会坏。
  • 写者(Writer):要修改这份数据。写的时候任何其他线程(无论读者还是写者)一碰,数据就乱套。

于是读者写者问题的核心需求提炼成一句话:允许多个读者同时读,但写的时候只允许一个写者独占,且读和写不能同时发生。 写成规则就是三条:

  1. 多个读者可以同时进入读取区,互不阻塞;
  2. 写者必须独占进入写入区,同一时刻只能有一个写者在写;
  3. 读者和写者互斥:正在读的时候不能让写者写,正在写的时候不能让读者读。

这里"读者与读者并发、写者与其他一切排他"的不对称性,正是它能成为独立一类问题的关键。你也看到,它和生产消费者问题关心的"供需/容量"完全不沾边——这一点我们下一段专门掰扯清楚。

顺带一提,在 Linux 的世界里我们最熟悉的一个"读者写者"实例,就是文件锁:多个进程可以同时以读模式打开同一个文件,但一旦某个进程要以写模式打开,就得等前面的读者全部走完,而且写进程之间也互斥。理解了本文的大半内容,你回头看文件锁会格外顺。

读者写者 vs 生产消费者:到底哪里不同

很多同学一上来就把这两个问题搞混,因为它们都出现在"共享数据 + 多线程"的大背景下。我来给你一张反差表,把它们的分野钉死:

对比维度生产者消费者问题读者写者问题
线程角色生产者、消费者(角色不对称)读者、写者(角色不对称)
核心矛盾容量/供需:缓冲区满了生产者要等,空了消费者要等读写的排他性:写要独占,读可并发
谁跟谁互斥生产者 vs 消费者、生产者 vs 生产者、消费者 vs 消费者,全都排他写 vs 一切排他,但读 vs 读可并发
读读是否冲突消费者也会"拿走"数据,多个消费者也可能互斥多个读者之间天然不冲突,这正是本文要利用的特性
本质动作数据在中间"流动/转移"(放进来、取出去)数据不变,只有"看"和"改"两种访问
典型目的解耦生产速度与消费速度尽量放大读的并发度同时保证写安全

一句话总结二者的本质差异:生产者消费者问题的矛盾在"物"——缓冲区能装多少、快慢能不能匹配;读者写者问题的矛盾在"权"——谁能看、谁能让别人暂时都别看。 前者关心产能匹配,后者关心访问权限的公平分配。

再往下挖一层,读者写者问题之所以难,难在"多个读者同时进去"这一个点。互斥锁能轻松解决"同时只进一个人",但要实现"读者们可以一起来、写者来了又必须清场",就需要在读者之间维护一个计数、并由这个计数来决定何时让写者进场。这个思路,等一下讲伪代码和信号量实现时会反复出现。

读者写者的同步逻辑:一张伪代码看懂全局

理解读者写者问题,最好的入口不是看现成的 API,而是先看我们"如果只靠最基础的锁,该怎么拼出这个行为"。下面是经典的读者优先实现思路。它用两把锁:一把 writer_lock 管"写者的排他",一把 count_lock 管"读者计数的安全"。

公共部分(全局):

uint32_t reader_count = 0;   // 当前正在读的读者数量
lock_t count_lock;           // 保护 reader_count 的小锁(互斥锁)
lock_t writer_lock;          // 写者准入锁

写者端(极为简单):

lock(writer_lock);   // 尝试获得写者准入权
// ... 执行写入 ...
unlock(writer_lock); // 释放写者准入权

写者只需要一把锁:拿到就是独占,拿不到就等着。真正的巧思全在读者端:

// 读者进入
lock(count_lock);            // 先锁上计数锁,准备改计数
if (reader_count == 0)       // 如果我是第一个读者
    lock(writer_lock);       //   就要去抢写者准入锁,从此写者进不来
++reader_count;              // 读者数量加一
unlock(count_lock);          // 解锁计数区
// ... 执行读取 ...          // 在这里可以放心读:写者被挡在门外
// 读者离开
lock(count_lock);            // 再次锁上计数锁
--reader_count;              // 读者数量减一
if (reader_count == 0)       // 如果我是最后一个读者
    unlock(writer_lock);     //   就释放写者准入锁,让写者重新进场
unlock(count_lock);          // 解锁计数区

这段伪代码值得你盯着看三遍,因为它把"读并发、写排他"这一整篇论文的精华都浓缩进去了。我拆开讲:

  • 第一个读者负责"锁门":当 reader_count 从 0 变成 1 时,第一个读者去执行 lock(writer_lock)。这意味着从这一刻起,写者再想进来就得排队,读与写的互斥成立了。为什么只有第一个读者锁?因为只要已经有一个读者持有 writer_lock,其他读者自然就都处在"写者被挡"的安全区里,后面的读者不需要重复锁。
  • 最后一个读者负责"开门":当 reader_count 从 1 变成 0 时,说明最后一个读者也读完了。此时再去 unlock(writer_lock),把清场的门打开,放写者进来,读者与写者互斥到此解除。
  • 中间的读者只动计数:非首非尾的读者进出时,既不锁也不放 writer_lock,只在自己进来时 n++、离开时 n--,顺带检查是不是第一个/最后一个。读者与读者之间完全不互相阻塞——这就是"读并发"的实现。
  • count_lock 负责保护计数本身的安全:reader_count 是多个读者共享的变量,++、-- 不是原子操作,若让多个读者同时改它就会竞态(race condition),所以计数区用 count_lock 单独护起来。它和 writer_lock 是两层职责,千万别混在一起。

只要你把这段伪代码背进脑子的逻辑,后面场景里无论是读写锁的语义,还是我们亲手写的信号量版本,全都逃不出这个骨架——用"读者计数 + 一把给写者的准入锁"来织出"读并发、写排他"。

一个立刻能发现的隐患:这个版本是读者优先的。只要读者络绎不绝地来,读者数量永远不为 0,writer_lock 就永远拿不到——写者可能被活活饿死。这个问题的深度讨论放后面的公平性章节,这里先记下这句观察。

读写锁:为"读多写少"量身定做的锁

伪代码是思想prep,但工程上你通常不用自己拼这两把锁——POSIX 线程库早就把"读者写者"的同步逻辑打包成了一件成品武器,那就是读写锁。

读写锁(Read-Write Lock,常缩写为 rwlock)是一种特殊的锁,它把"锁"分成了两种口味:

  • 读锁(Read Lock / 也叫共享锁 Shared Lock):多个线程可以同时持有读锁。只要大家都是在"读",就互不阻塞,一起进来。
  • 写锁(Write Lock / 也叫独占锁 Exclusive Lock):同一时刻只能有一个线程持有写锁,其他线程无论想读还是想写,都必须等它释放。

一句话记忆:读锁共享、写锁独占。这八个字就是读写锁的全部灵魂——它把"读与读"的音量开到了最大(共享),把"写"的排他性也守到了最严(独占),并天然保证了"读与写互斥"。回头看读者写者问题的三条规则,读写锁一条不差全部覆盖。

那它到底解决了什么问题?回到文章开头那种"多读少写"的场景:一份常见的数据结构,比如一个全局的配置查找表,绝大多数时候大家只是读(查),偶尔才有人写(更新)。用互斥锁会把所有读线程排成一队,明明是各自读各自的,却白白串行化;用读写锁,读线程可以火车并排开进,只有写线程进场时才清场。读的比例越高,读写锁的收益越明显。

补充一个会反复强调但值得先说透的边界:读写锁只有在"读多写少"时才划算。 如果你的数据是"写多读少",读写锁不但没收益,反而因为机制比互斥锁重,往往还更慢。这一点后面专门有一节展开,这里先立起"读多写少才用它"的判断存进脑子里。

在深入代码前,先认识几个将会出现的术语(首次出现,逐个解释):

  • 共享(Shared):指多个线程可同时持有同一种资源或锁。读写锁的读锁就是"共享"的。
  • 独占(Exclusive):指同一时刻只允许一个线程持有。写锁、以及普通互斥锁都是"独占"的。
  • 竞争/竞态(Race Condition):多个线程同时读写同一个变量,最终结果取决于谁先谁后的不确定行为,是并发 bug 的头号来源。
  • 饥饿(Starvation):某个线程长期得不到它想要的资源/锁,明明没人违规,却一直轮不到它。读者优先策略下写者可能被读者排挤到饥饿。

读写锁的接口:属性、初始化、加解锁

POSIX 线程库里读写锁的主角是一批 pthread_rwlock_* 函数,配合两个类型:锁对象 pthread_rwlock_t 和属性对象 pthread_rwlockattr_t。头文件是 <pthread.h>,链接时照例要加 -lpthread。

初始化与销毁

// 用默认属性初始化一把读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
                        const pthread_rwlockattr_t *restrict attr);
 
// 销毁读写锁,释放它占用的资源
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
  • 两个 restrict 关键字的意思是"这两个指针不会指向同一块内存",编译器可以依据这一点做优化,你完全不用管它。
  • attr 传 NULL 时使用库默认的属性(默认是读者优先),临时不想深究属性可以先传 NULL。
  • 销毁的前提是当前没有线程持有这把锁,否则行为是未定义的(最终可能出现难以定位的崩溃或悬空)。
  • 实用建议:拿到一把 pthread_rwlock_t 变量后,永远先 init 再用、用完 destroy。也可以像互斥锁提供 PTHREAD_MUTEX_INITIALIZER 那样做静态初始化,但 glibc 对读写锁没有官方推荐的静态初始化宏,老老实实用函数初始化是地理所当然的稳妥做法。

属性对象与"读写优先"设置

先解释属性对象:它是配置锁各种行为的参数容器,用法套路和互斥锁的属性几乎一致——先 pthread_rwlockattr_init 让属性有个默认值,再调用各种 setter 调整,最后把它传给 pthread_rwlock_init。用完记得 pthread_rwlockattr_destroy。

其中最值得关注的 setter 是 pthread_rwlockattr_setkind_np。注意那个 _np 后缀,它是 Non-Portable(不可移植)的缩写,意思是这个接口不是 POSIX 标准规定的,而是 glibc 的扩展——换到别的系统(比如 macOS、某些 BSD)上可能就没这个函数,移植时要注意。原型如下:

int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);

第二个参数 pref 决定这把读写锁的"偏好",也就是当读者和写者同时等待时,优先满足谁。glibc 提供三个取值:

常量中文含义说明
PTHREAD_RWLOCK_PREFER_READER_NP读者优先默认值。优先放行读者,代价是写者可能饥饿。
PTHREAD_RWLOCK_PREFER_WRITER_NP写者优先语义上想优先写者,但目前 glibc 实现有 bug,实际行为仍等同于读者优先,别指望它。
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP写者优先(非递归)真正能实现写者优先的那一个,同时规定"写锁不可递归加",用来绕开上面那个 bug。

这里有个坑必须明确写给你:不要因为觉得"写者优先好"就顺手选 PTHREAD_RWLOCK_PREFER_WRITER_NP。它在当下 glibc 里并没有实现出"写者优先"的效果,选了等于白选,还是读者优先。想要写者优先的效果,要用最后那个 ..._NONRECURSIVE_NP。这一点是我研究的现行 glibc 行为,各 distro 的实现可能存在细微差异,生产代码里若要依赖它,建议先用一个小测试脚本实际验证一把锁在重压下的行为,再落进架构里。

加锁与解锁

// 加"读锁":多个线程可以同时成功
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
 
// 加"写锁":同一时刻只允许一个线程成功
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
 
// 解锁:读锁、写锁都是这一个函数释放
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
 
// 还有非阻塞、限时的变体,先混个脸熟:
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);   // 加不到读锁就立刻返回 EBUSY
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);   // 加不到写锁就立刻返回 EBUSY
int pthread_rwlock_timedrdlock(pthread_rwlock_t *rwlock,
                               const struct timespec *abs_timeout); // 限时等待读锁
int pthread_rwlock_timedwrlock(pthread_rwlock_t *rwlock,
                               const struct timespec *abs_timeout); // 限时等待写锁

使用规范:

  • 读代码就 rdlock(...) ... unlock(...);写代码就 wrlock(...) ... unlock(...)。千万不要"读也加写锁、写也加读锁"——那一方面会让读写锁失去意义,另一方面可能造成死锁,属于最典型的使用错误。
  • tryrdlock/trywrlock 一旦拿不到锁不会阻塞,而是立刻返回错误码 EBUSY,适合"能抢到就抢、抢不到就干别的"的场景。
  • timed*lock 的第二个参数必须是绝对时间点(CLOCK_REALTIME 上的某个时刻),不是"相对时长";调用前要拿 clock_gettime 写进 timespec,这个细节最容易踩。
  • 解锁和加锁不强制要求是同一个线程(信号量也没有这个要求),但加了几次锁就必须解几次锁,尤其读锁的重入问题我们单独讲,那里是重灾区。

一个完整的读写锁案例:动手跑一跑

纸上谈兵到此为止,直接上一份能编译、能跑、能看到"读并发、写排他"的完整代码。我基于经典课堂案例,把它整理成纯 C(用 gcc 编译)、输出更清晰、逻辑完全一致的样子,每一行都做了注释。

// reader_writer_lock.c
// 编译:gcc -o rwtest reader_writer_lock.c -lpthread
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <pthread.h>
 
int           shared_data = 0;   // 共享资源:被读者读、被写者写
pthread_rwlock_t rwlock;         // 读者写者问题的主角:读写锁
 
// 读者线程函数:加读锁,读共享数据
void *Reader(void *arg)
{
    int number = *(int *)arg;        // 从参数解出这是第几个读者
    for (int i = 0; i < 3; i++) {    // 每个读者循环读 3 次
        pthread_rwlock_rdlock(&rwlock);            // 加读锁:读者们可以同时进来
        printf("[读者-%d] 读到数据 %d\n", number, shared_data);
        usleep(200000);                             // 模拟"读"很耗时,放大并发窗口,方便看现象
        pthread_rwlock_unlock(&rwlock);            // 释放读锁
        usleep(50000);                             // 模拟两次读之间的短暂间隔
    }
    free(arg);                       // 释放 main 里 new 出来的 id
    return NULL;
}
 
// 写者线程函数:加写锁,修改共享数据
void *Writer(void *arg)
{
    int number = *(int *)arg;        // 第几个写者
    for (int i = 0; i < 2; i++) {    // 每个写者循环写 2 次
        pthread_rwlock_wrlock(&rwlock);            // 加写锁:同一时刻只有一个写者,且读者全被挡在外面
        shared_data = rand() % 100;   // 修改共享数据
        printf("[写者-%d] 写入新数据 %d\n", number, shared_data);
        usleep(400000);                             // 模拟"写"耗时更长
        pthread_rwlock_unlock(&rwlock);            // 释放写锁,放别的线程进来
        usleep(50000);
    }
    free(arg);
    return NULL;
}
 
int main(void)
{
    // 用当前时间为随机数种子(异或上进程 pid,避免两个进程撞出相同序列)
    srand((unsigned)time(NULL) ^ (unsigned)getpid());
 
    pthread_rwlock_init(&rwlock, NULL);  // 初始化读写锁,用默认属性(读者优先)
 
    const int reader_num = 3;            // 读者线程数
    const int writer_num = 2;            // 写者线程数
    const int total = reader_num + writer_num;
    pthread_t threads[total];
 
    // 创建 3 个读者线程
    for (int i = 0; i < reader_num; i++) {
        int *id = (int *)malloc(sizeof(int));
        *id = i;
        pthread_create(&threads[i], NULL, Reader, id);
    }
    // 创建 2 个写者线程
    for (int i = reader_num; i < total; i++) {
        int *id = (int *)malloc(sizeof(int));
        *id = i - reader_num;
        pthread_create(&threads[i], NULL, Writer, id);
    }
    // 等待所有线程结束
    for (int i = 0; i < total; i++)
        pthread_join(threads[i], NULL);
 
    pthread_rwlock_destroy(&rwlock);     // 全部结束后销毁读写锁
    return 0;
}

运行效果示例(每次的数值和顺序都不同,这是并发的正常现象):

[写者-1] 写入新数据 82
[读者-0] 读到数据 82
[读者-1] 读到数据 82
[写者-0] 写入新数据 32
[读者-2] 读到数据 32
[写者-1] 写入新数据 30
[读者-0] 读到数据 30
[读者-1] 读到数据 30
[写者-0] 写入新数据 27
...

怎么从这份输出里"看到"读写锁在干活?观察两个特征:

  1. "读到数据 X"会成串出现——多个读者几乎在同一刹那都读到了同一个 X,说明读者们确实是同时进入读取区并发的,而不是像互斥锁那样一个个排队读。这是"读共享"的直接证据。
  2. "[读者-X] 读到数据 Y"里前后两个读者读到的值往往连续一致——因为写者被隔离在读者全部离开之后才会进场,读者读到的都是"写者的某一次完整结果",而不是写到一半的残缺值。这是"写排他、读写互斥"的直接证据。

一个立刻可以做的实验:把读者数量从 3 调到 10、写者数量保持 2,你会发现读者连番轰炸,"读到"几乎一直是连续的,而"写入"被挤到角落里半天才轮到一次——这正是读者优先策略的活生生表现,也是写者饥饿的现场。反过来,如果你想让输出里写者的身影更频繁,可以把默认属性换成 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP(改法见下),再对比两种现象。

// 展示如何显式设置"写者优先(非递归)",取代上面初始化行的 NULL:
#include <pthread.h>
pthread_rwlockattr_t attr;
pthread_rwlockattr_init(&attr);                                  // 先初始化属性对象
pthread_rwlockattr_setkind_np(&attr,
        PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);          // 设为写者优先
pthread_rwlock_init(&rwlock, &attr);                             // 用该属性初始化锁
pthread_rwlockattr_destroy(&attr);                               // 属性用完销毁
// 提示:该变体要求"写锁不可递归加",一旦读到需要递归加写锁的代码会出问题,详见下文"坑"一节。

读者优先 vs 写者优先:公平性的大是大非

同一把读写锁,读者和写者同时在门外排队时,先放谁进去?这决定了两种截然不同的策略(/优先机制),也决定了谁会饿死。

读者优先(Reader-Preference)

定义:系统尽可能多地放多个读者同时进来读,只要还能让读者进场就绝不等待写者。新到的读者只要没有写者正在写,就立刻被放行;而写者必须等到所有的读者都离开读取区之后才可能进场。

  • 优点:读的并发度拉满,读者体验最好,吞吐高。
  • 致命缺点:写者饥饿(Writer Starvation)。只要读者到达的频率足够高,读者数量就永远降不到 0,写者就一直排在门外干瞪眼。极端情况下写者可能被饿死到永远等不到一次写入机会。
  • 心理模型:一个从不关门的图书馆——读者进进出出源源不断,管理员(写者)想进来整理书架,却永远等不到"里面空无一人"的那个瞬间。

写者优先(Writer-Preference)

定义:系统优先照顾写者。一旦有写者请求写权限,从这一刻起所有新到的读者都被阻塞,直到这位写者写完离开;甚至允许在"当前还有读者在读但暂时空闲"的时候为了让写者尽早进场而提前拦截后续读者。

  • 优点:写者的等待时间大幅缩短,不会饿死;读者在读时如果恰好没写者来,仍然能高效并发。
  • 代价:可能走向反面——读者饥饿(Reader Starvation)。如果写者络绎不绝,读者可能长期抢不到读的机会(要等所有排队的写者都写完才有空档)。
  • 心理模型:一个"写者为王"的数据库——写者一进门就虚掩门禁,后来的读者统统在门外按号排队,等这次写完后才统一放闸。

用一句话记住二者的权衡:读者优先牺牲写者,写者优先牺牲读者。工程上没有绝对平等,只有你在"谁更不能被冷落"上做的取舍。 读者优先是默认值(写者被冷落的容忍度往往更高,因为读者多一点往往更能反映"读多写少"的应用常态);而在写者真的不能长期被卡住的应用里(比如某条数据必须周期性刷新),就得选用写者优先。

如果你在内核级接触过 Linux 的"读写自旋锁"(rwlock_t),会发现内核默认策略也偏读者优先、写者会饿。而 Linux 的"RCU"(Read-Copy-Update,读-复制-更新)机制堪称"终极读者优先":读者几乎不被阻塞,写者通过先复制再发布的方式来避免幸存读者看到中间状态。读到这如果你能联想到 RCU,说明你已经开始看到这套思想在更广的系统级设计里的投影了。

用信号量亲手实现一个读者写者模型

剥掉库封装,我们用原始信号量(sem_t)把前面的伪代码变为真代码。这既是加深理解的最佳方式,也是很多笔试面试真会让你手写的题。依旧做读者优先版本(逻辑最直观),并和读写锁版本对照着看。

我们的设计骨架就是刚讲过的那段伪代码,但要注意一个细节:伪代码里的 count_lock 用普通互斥锁(pthread_mutex_t)最贴切(它只需要互斥,不需要计数),而 writer_lock 用信号量(sem_t)来当"关闸",sem_wait/sem_post 对应进去/出来。为什么写者那把用信号量而非互斥锁?是因为我们想让"第一个读者进来"和"写者进来"争夺的是同一个信号量资源(初值 1,谁先拿到谁进),用信号量更贴合"资源计数"的语义。当然你也可以两把都用互斥锁,逻辑完全一样——信号量和互斥锁在很多场景可互换,这里选信号量是为了呼应标题"信号量实现"。

// rw_semaphore.c
// 编译:gcc -o rw_sem rw_semaphore.c -lpthread -lrt
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>
 
int   shared_data = 0;                 // 共享资源
int   reader_count = 0;                // 当前读者数
pthread_mutex_t count_lock;            // 保护 reader_count 的互斥锁
sem_t           writer_sem;            // 写者准入信号量:初值 1,作为"写者门禁"
 
void *reader(void *arg)
{
    int id = *(int *)arg;
    for (int i = 0; i < 3; i++) {
        // ---- 读者进场 ----
        pthread_mutex_lock(&count_lock);        // 锁住计数区
        if (reader_count == 0)                   // 我是第一个读者?
            sem_wait(&writer_sem);               // 是 → 抢写者门禁,把写者挡在外面
        reader_count++;                          // 读者数 +1
        pthread_mutex_unlock(&count_lock);      // 解锁计数区
        // ---- 读取临界区:此时所有写者都被挡在门外 ----
        printf("[读者-%d] 读到 %d\n", id, shared_data);
        usleep(200000);                          // 模拟耗时读
        // ---- 读者离场 ----
        pthread_mutex_lock(&count_lock);        // 再次锁计数区
        reader_count--;                          // 读者数 -1
        if (reader_count == 0)                   // 我是最后一个读者?
            sem_post(&writer_sem);               // 是 → 释放写者门禁,放写者进来
        pthread_mutex_unlock(&count_lock);      // 解锁计数区
        usleep(50000);
    }
    free(arg);
    return NULL;
}
 
void *writer(void *arg)
{
    int id = *(int *)arg;
    for (int i = 0; i < 2; i++) {
        sem_wait(&writer_sem);                   // 拿写者门禁:这里有读者就等,读者也抢不过我(读者优先下需读者先走光)
        shared_data = rand() % 100;              // 修改共享资源
        printf("[写者-%d] 写入 %d\n", id, shared_data);
        usleep(400000);                          // 模拟耗时写
        sem_post(&writer_sem);                   // 释放门禁
        usleep(50000);
    }
    free(arg);
    return NULL;
}
 
int main(void)
{
    srand((unsigned)time(NULL) ^ (unsigned)getpid());
    pthread_mutex_init(&count_lock, NULL);       // 初始化计数互斥锁
    sem_init(&writer_sem, 0, 1);                 // 初始化写者信号量,初值 1(代表"门开着")
 
    const int RNUM = 3, WNUM = 2, TOTAL = RNUM + WNUM;
    pthread_t t[TOTAL];
 
    for (int i = 0; i < RNUM; i++) { int *id = malloc(sizeof(int)); *id = i;
        pthread_create(&t[i], NULL, reader, id); }
    for (int i = RNUM; i < TOTAL; i++) { int *id = malloc(sizeof(int)); *id = i - RNUM;
        pthread_create(&t[i], NULL, writer, id); }
    for (int i = 0; i < TOTAL; i++)               // 等待所有线程结束
        pthread_join(t[i], NULL);
 
    sem_destroy(&writer_sem);                     // 清理信号量
    pthread_mutex_destroy(&count_lock);          // 清理互斥锁
    return 0;
}

对照上面的代码,你要能自己讲出四个"为什么"才算真的会了这个模型:

  1. 为什么 writer_sem 初值是 1、读者只在"第一个进入"和"最后一个离开"时动它? 因为初值 1 表示"此刻写者可以进场";第一个读者进场它变成 0(写者被关门外),最后一个读者离场它又变回 1(门重新打开)。中间的读者进进出出不动它,于是读者们并行——对应"读共享"。
  2. 为什么计数区要用 count_lock 包住? 因为 reader_count++ 不是原子操作(分"读—加—写"三步),两个线程同时改会丢更新。它是一把独立的互斥锁,负责保护"读者计数"这个更小的临界区。
  3. 为什么读者优先在这里天然成立? 因为写者 sem_wait(&writer_sem) 需要读者计数降到 0;而读者进场只需要自己加锁计数、并不和写者抢同一把锁(首读者抢门禁之前,后续读者根本无所谓门禁状态)。所以只要读者不停地来,写者永远等不到计数归零——饥饿。
  4. 如果我想改成写者优先,代码变化在哪? 核心是要引入"写者计数"和一把"读者准入信号量":当第一个写者到达时,就把"读者准入"这把闸门关掉,让后续读者连计数区都进不去;等最后一个写者离开再打开。这样读者被拦截、写者得以优先。它的代码量明显更大、也更难调对,这里不展开全部,只点出设计关键——用"谁先占住了准入闸门"来表达优先级。

说明一下:本项目里写者门禁我用信号量是为了贴题"信号量实现读者写者模型",这也是面试题里最常见的考法。而 POSIX 也允许用两把互斥锁实现完全相同的逻辑(结论不变),你只要掌握这个骨架,两种写法都数得出身体。

读写锁不是万能:必须知道的边界与坑

读写锁看起来完美,但工程上到处是暗礁。这一节是全篇的重要度之最,请逐条过。

坑一:读写锁只在"读多写少"时划算,否则是负优化

读写锁的内部实现比互斥锁复杂——它要多维护"当前是读状态还是写状态"、读者计数、等待队列等一堆状态,加锁解锁的指令路径更长。所以:

  • 当读的比例极高(比如 100:1)时,读共享带来的并发收益远远盖过它的机制开销,明显赚钱;
  • 当读和写接近(比如 1:1)时,读写锁几乎没有并发收益可言,因为每次写都要清场,机制却还要多付一套开销,往往不如一个普通互斥锁;
  • 当写多于读时,读写锁纯粹是负资产,请改用互斥锁。

判断口诀:"读多写少"那就是它,其余情况快跑。

坑二:读者优先会让写者饿死

这是读者写者问题自带的天性,不是 bug,是 feature 用错了地方。默认属性就是读者优先,如果读者请求源源不断,写者可能永远轮不到。假如你的应用里"写"是"必须周期性发生"的用户操作(比如保存配置、提交报表),读者优先就可能让这些写操作迟迟得不到执行。此时要么改用写者优先(PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP),要么从架构上节流读者的到来频率。记得我们前面说过的那个坑:PTHREAD_RWLOCK_PREFER_WRITER_NP 在 glibc 里目前跟读者优先行为一致,别被名字骗了。

坑三:读锁的实际"可重入"与配平要求

先解释递归/可重入(Recursion / Reentrant):一个线程在已经拿到某把锁的情况下,再次对同一把锁加同类型锁并成功,就叫递归加锁。比如 rdlock; rdlock; ...; unlock; unlock;。

  • 读锁在 glibc 里是可重入的:同一个线程可以连续多次 rdlock 而全部成功(因为读者本来就共享),但相应地也得解锁同样多次,否则计数悬空,别的线程(尤其写者)会永远等不到全部释放。这个"加几次就要解几次"的配平,是读锁最容易翻车的地方——reduce 到实际代码里往往是一段逻辑里 rdlock 了 N 处,却只在一处 unlock。
  • 写锁不可重入:同一个线程先 wrlock 再 wrlock,第二个 wrlock 会直接把当前线程自己阻塞住,从而死锁(自己等自己释放)。这正是读写锁默认策略里"写者优先但非递归"(..._NONRECURSIVE_NP)这个后缀的含义来源。

坑四:锁升级(Upgrade)会死锁

锁升级(Lock Upgrading):线程先持有读锁,中途又想把权限提级为写锁(比如"先读一下,发现要改,于是想边改")。做法上最自然的写法是"在持有读锁的情况下直接再调 wrlock"。

  • 危险之处:若有两个线程同时都想"读→写"晋级,它们都持有读锁,又都等对方释放读锁去拿写锁,就会互相掐住——升级死锁。POSIX 读写锁没有设计出"从读锁原地升级为写锁"的安全接口,所以请记住结论:不要在持有读锁的线程里再去要写锁,升级要么放弃读锁再重新请求写锁,要么一开始就用写锁。
  • 正确姿势:把"先读尝试、需要时再写"做成两段——释放读锁 → 重新 wrlock → 检验数据是否符合需要更新的条件;或者干脆用 wrlock 包到底(写得少时这种保守写法无妨)。

坑五:锁降级(Downgrade)通常也不被直接支持

锁降级(Lock Downgrading):线程持有写锁,中途想让出写权、只保留读权(比如"写完就想立刻当着大家的面查一查"。实现上你可能会想"先 wrlock,内部再来个 rdlock")。

  • 直接 wrlock 之后再 rdlock 同样可能出问题:因为写锁状态下再来读锁,如果库不认"写锁可兼读锁",就会死锁或行为未定义。POSIX/glibc 不提供"原地把写锁转成读锁"的原子接口。
  • 正确姿势:unlock(把写锁整个释放)→ 重新 rdlock。中间这个空当其他写者可能插进来抢到写锁,属于正常并发行为,你要能接受这种"让渡"。

把坑四、坑五合起来记一句话:写锁和读锁之间不存在"挂着这把锁平滑过渡到那把锁"的魔法,升降级都要"先释放再重新获取",中间的空当由调度器自由安排。 凡是那种"我怕麻烦想挂着一把锁改另一种锁"的念头,先踩一下这个坑再反省。

坑六:别让锁忘了释放

这是所有锁的通用告诫,读写锁尤其要小心"读锁可重入造成的计数泄漏"。一旦某条路径上 unlock 少了,读者计数永远回不到 0,写者就永远进不来——表现为"程序不死,但写线程卡住"。排查时数清楚"rdlock/wrlock 的调用次数是否严格等于 unlock 次数"。C 语言没有 RAII,请务必不可在提前 return/goto 收尾的分支里漏掉解锁;如果条件允许,封装一个"锁守卫"(lock guard,进作用域加锁、出作用域靠析构自动解锁)能根治这类问题。

坑七:另一种"直接用读写锁"反而更差的情形——临界区过小

如果临界区极小(比如只是读一个 int),创建线程、加锁本身的开销都比"加锁保护那么点事"大,这时读写锁和互斥锁都不如干脆不用锁(用原子变量 atomic)。读写锁适合"临界区内工作量大、读的次数特别多"的场景,而不是任何共享数据都要套。

坑八:不要轻易用 _np 系列做跨平台迁移的依赖

pthread_rwlockattr_setkind_np 是 glibc 扩展。若你的代码要跑在 macOS / 其他 POSIX 系统的 libpthread 上,这个函数可能压根不存在。如果你想用"写者优先",最好用宏做平台适配,或者干脆写自己可控的读写策略(比如用信号量那套实现),避免在不可移植接口上赌产品的可移植性。

结尾:从读者写者问题看向更大的舞台

我们把读者写者问题从"现象"讲到了"本质",再落到了"代码"。回头看这一路:从"为什么不能只用互斥锁"的痛点出发,看清了它与生产消费者问题的分野,用一个"读者计数 + 写者准入锁"的骨架理解了读并发、写排他的全部秘密,接着请出 POSIX 读写锁这把成品武器,熟悉了它的初始化、属性、三种加解锁用法,跑了一个能亲眼看到并发现象的案例,又深挖了读者优先与写者优先的公平性博弈,最后用信号量亲手编织了一遍这套模型,并把它花团锦簇表面下的八口暗井一一点名。

现在你可以底气十足地回答这几个问题了:为什么不用互斥锁? 因为读并发被白白牺牲了;读写锁的加锁语义是什么? 读锁共享、写锁独占、读写互斥;读写锁的默认策略坑是什么? 读者优先、会写者饿死,想写者优先得用 ..._NONRECURSIVE_NP;读写锁什么时候坚决不能碰? 写多读少、临界区过小、需要锁升级/降级、依赖不可移植接口要迁移的时候。

读者写者问题虽然是操作系统课本上的经典,但它背后的建模思想——"谁与谁可以并存、谁与谁必须排他、用计数表达并存、用准入表达排他、再为公平性做取舍"——早已溢出课本,渗进数据库的锁模式(MVCC、行级锁)、文件系统的读写锁、乃至 RCU 这类精妙的系统机制。你把这一章的骨架吃透,等于同时读懂了并发世界里一长串看似高深的实现。剩下的坑,就交给时间去踩,踩到时别慌——你已经知道坑在哪里、为什么会痛了。

思考与练习(附详解答案)

  1. 读者写者问题和生产者消费者问题的核心区别到底是什么?

    答:二者的角色都不对称(读者/写者、生产者/消费者),但矛盾性质完全不同。生产者消费者问题的本质矛盾是容量与供需——缓冲区满了生产者暂停、空了消费者暂停,所有对缓冲区的访问(甚至多个消费者)都排他,关心的是上下游速度如何匹配、缓冲如何周转。读者写者问题的本质矛盾是访问权限的排他性——读与读可以并存,写与一切互斥,关心的是如何在保证写入安全的前提下尽量放大读并发。一句话:前者争的是"物(缓冲/产能)",后者争的是"权(谁能读、谁能写)"。

  2. 读者进场代码里,"只有第一个读者才 sem_wait 抢占写者门禁、最后一个读者才 sem_post 释放"这一设计的用意是什么?

    答:这是实现"读并发 + 读写互斥"的精髓。writer_sem 初值为 1,代表写者门禁此刻"开着"。第一个读者进场时执行 sem_wait 让它变成 0,等于把写者挡在门外——从这一刻起,所有读者都处在"没有写者能进来"的安全区,所以后续读者不需要重复 sem_wait,直接计数 +1 就能进,互不阻塞地并行读了。最后一个读者离场时 reader_count 归零,执行 sem_post 把它恢复为 1,重新为写者开门。中间的读者进进出出不动这把信号量,就保证了读者之间存在"一丝缝隙的并发",而写者只有在读者全部清空后才有唯一窗口进场。若每个读者都去 sem_wait,第一读者之后的读者就会把自己阻塞在门禁上,读并发直接退化成串行,与初衷完全相悖。

  3. 为什么 PTHREAD_RWLOCK_PREFER_WRITER_NP 千万不能随手用作"写者优先"?

    答:该宏在命名上写着"写者优先",但在现行 glibc 的实现里存在缺陷,实际表现仍等同于读者优先(PTHREAD_RWLOCK_PREFER_READER_NP),选了等于白选,还会给阅读代码的人造成"已经处理了写者饥饿"的假象。真正能实现写者优先效果的是 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP(后缀同时提醒你它要求写锁不可递归加)。同时这些接口是 glibc 的非标准扩展(_np = Non-Portable),跨平台迁移时要格外留意。生产环境依赖它之前,建议用重压小测试亲自验证锁的行为。

  4. 一个线程持有读锁后,它内部再调用 pthread_rwlock_wrlock(锁升级)会怎样?两个线程同时这么做又会怎样?

    答:单个线程这么做同样处于危险区——因为你要的写锁需要"没有任何读者在读",而你自己正抱着一个读锁不放,等于自己等自己,它自己就先把自己卡死(若库对同一线程的"读锁再升级"不特殊放行的话)。而两个线程同时"持有读锁又去等写锁"的场景,会形成经典的升级死锁:双方都各自握着一个读者位置的读锁,又都在等待"所有读者读锁都释放"才能拿到写锁,读锁之间又互不阻塞、谁也不肯先放,于是互相等待、永不前进。这正是"读锁重入 + 写锁升级"叠加出的教科书级死锁。正确做法是先释放读锁,再重新请求写锁(或干脆一开始就用写锁),不要在持有读锁时直接升级。

  5. 读写锁在哪些场景下不但没好处,反而比互斥锁还差?

    答:三个典型:一是写多读少——读写锁机制更重,而读共享的收益却几乎榨不出来,纯负优化;二是临界区过小(比如只保护读一个 int)——加锁本身的开销比重大于所保护的收益,甚至不如用原子变量;三是需要频繁升降级的场景——因为它不支持原地"读↔写"平滑过渡,硬求升级会死锁,降级也要先释放再重拿,中间还有调度空档,机制负担大而收益小。判断标准就一句话:读写锁的价值完全来自"读的并发度"能否大过他更重的机制成本,读的比例越高它越好用,一旦读写接近或写更多,就该退回互斥锁。