十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux信号量详解:原理、API与工程实践

Linux信号量详解:原理、API与工程实践 1. 从停车场说起信号量到底在“数”什么1.1 一个计数器背后是一套纪律做Linux开发这些年信号量semaphore是我最早接触、也最晚敢说“真正理解”的并发原语之一。操作系统课上老师画PV操作流程图黑板上又是P又是V考试背得滚瓜烂熟等真到业务代码里写sem_wait和sem_post才发现自己其实根本没搞懂这个计数器为什么能解决同步问题。信号量的核心本质就是一个带原子性的计数器配套两个操作P操作sem_wait把计数减一如果减完小于零就阻塞V操作sem_post把计数加一如果有阻塞的线程就唤醒一个。为什么叫P和V荷兰语Proberen尝试和Verhogen增加当年Dijkstra留下的传统今天代码里就是wait和post。用停车场类比最直观一个停车场有10个车位门口的电子屏显示剩余车位数这就是信号量。每进一辆车屏上数字减1减到0以后再来车就得排队每走一辆车数字加1排队的车放一辆进来。屏幕上的数字就是信号量的值进场出场就是sem_wait和sem_post。但这里有一个很多人忽略的点信号量解决问题靠的不仅是“计数”而是“计数变化必须原子完成”。两个线程同时sem_wait如果先读值再写值就会出两个线程都进去的bug。所以信号量内部是拿操作系统底层的原子指令保护的这个前提才是整个机制的基石。理解了这一点后面排查问题才有方向。1.2 两类信号量无名与有名选哪个Linux下的POSIX信号量分两种无名信号量sem_init创建和有名字号量sem_open创建。区别不在强弱在作用域和生命周期。无名信号量没有名字只靠一个sem_t类型的变量在内存里存着。它一般放在全局数据段、共享内存或者堆上适用于线程之间或者有亲缘关系的进程之间比如fork出来的父子进程共享同一份匿名映射。我自己的经验是90%的线程同步场景用无名信号量就够了因为你根本不需要给同步原语起名字。有名信号量不一样它会在文件系统里挂一个/dev/shm/sem.xxx或/sem.xxx这样的路径进程A创建进程B按路径打开同一个内核对象。它适用于没有亲缘关系的进程之间做同步比如两个独立的服务共同控制一台打印机或者一份资源的访问。要注意这个“名”不是普通文件打开以后要用sem_unlink清理路径否则系统重启前会一直留在/dev/shm里。从维护角度我的建议是能用无名就无名别把信号量搞成全局注册表。名字一多谁来创建、谁来清理、崩溃了残留怎么办全是麻烦。2. 核心API与第一个可运行示例2.1 初始化sem_init的三个参数怎么填先看函数原型#include semaphore.h int sem_init(sem_t *sem, int pshared, unsigned int value);三个参数sem是你要初始化的信号量对象pshared填0表示线程间共享填非0表示进程间共享前提是sem放在共享内存区域value是计数的初值。初值这一项是信号量的灵魂也是最容易拍脑袋的地方。把它拆开看初值 1典型互斥锁场景同一时刻只允许一个线程进入临界区。初值 N资源池场景最多允许N个线程同时访问比如数据库连接池有5个连接就初始化成5。初值 0同步场景常用于“先等别人干完活再继续”的通知机制。初值定错了后面全乱。比如你想做互斥结果手滑填了3那就有3个线程同时进临界区数据立刻花掉你想做同步初值填1那被通知的线程根本不用等就直接通过了。所以每次写sem_init先问自己一句“我要限制几个并发”另外注意到sem_t只是一个结构体类型官方没说它内部长什么样而且不同平台实现可能不同。所以别把它直接赋值、拷贝、memset在有些glibc版本上memset(sem, 0, sizeof(sem))会破坏内部状态。正确的做法是定义好变量后立刻调sem_init之后只通过API操作。2.2 一段能跑的代码P/V操作是长什么样理论说多了没用直接上一段能编译能跑的代码#include stdio.h #include pthread.h #include semaphore.h #include unistd.h sem_t sem; void *worker(void *arg) { int id *(int *)arg; printf([thread %d] 准备进入临界区调用 sem_wait\n, id); sem_wait(sem); printf([thread %d] 已进入临界区\n, id); sleep(2); // 模拟干点活 printf([thread %d] 离开临界区调用 sem_post\n, id); sem_post(sem); return NULL; } int main() { pthread_t tids[3]; int ids[3] {1, 2, 3}; sem_init(sem, 0, 1); // 初值为1同一时刻只放一个 for (int i 0; i 3; i) { pthread_create(tids[i], NULL, worker, ids[i]); } for (int i 0; i 3; i) { pthread_join(tids[i], NULL); } sem_destroy(sem); return 0; }编译命令gcc -o sem_demo sem_demo.c -lpthread运行结果大致是这样[thread 1] 准备进入临界区调用 sem_wait [thread 1] 已进入临界区 [thread 2] 准备进入临界区调用 sem_wait [thread 3] 准备进入临界区调用 sem_wait [thread 1] 离开临界区调用 sem_post [thread 2] 已进入临界区 [thread 2] 离开临界区调用 sem_post [thread 3] 已进入临界区 [thread 3] 离开临界区调用 sem_post注意thread 2和thread 3的“准备进入”会先打印但“已进入”必须等thread 1释放。这个顺序就是信号量在背后起作用。有几个细节我想特别提醒pthread_create传参时ids[i]的地址要保证在子线程读取时仍然有效。上面代码里ids是main的局部数组pthread_join之前都活着所以没问题但你如果传临时变量的地址那就是经典use-after-scope bug。sem_wait返回后临界区里别忘了写sem_post而且要保证所有return路径都会执行。实际项目里建议统一在函数结尾维护一个goto out或者用RAII风格封装否则漏一个post就是一颗死锁的雷。2.3 步长变体trywait和timedwait的实际使用sem_wait是阻塞的如果信号量拿不到线程会一直睡下去。但很多业务场景不允许无限等所以一定要知道sem_trywait和sem_timedwait。sem_trywait非阻塞版本。能拿就拿拿不到立即返回-1并设置errno为EAGAIN。适合做轮询但轮询本身有CPU开销我一般只在模拟器或测试代码里用它。sem_timedwait带超市时间的阻塞版本。时间用绝对时间的struct timespec指定比如“最多等3秒”要先取当前时间再加3秒。用法示例#include stdio.h #include semaphore.h #include errno.h #include time.h int wait_with_timeout(sem_t *sem, int timeout_ms) { struct timespec ts; clock_gettime(CLOCK_REALTIME, ts); ts.tv_sec timeout_ms / 1000; ts.tv_nsec (timeout_ms % 1000) * 1000000L; if (ts.tv_nsec 1000000000L) { ts.tv_sec; ts.tv_nsec - 1000000000L; } int ret sem_timedwait(sem, ts); if (ret -1 errno ETIMEDOUT) { printf(等待超时\n); return -1; } return 0; }注意sem_timedwait用的是绝对时间不是“相对多少毫秒”每次调用都要自己换算。你写相对时间的代码两小时后才会出问题但一出就是大问题。3. 信号量与互斥锁、条件变量的关系3.1 wait/post不是lock/unlock别混着用很多人把sem_wait/sem_post和pthread_mutex_lock/pthread_mutex_unlock搞混以为都是一进一出。它们的机制确实有重叠但语义上有本质差别。互斥锁mutex追求的是“同一时刻最多一个线程持有锁”而且只有持有锁的线程才能解锁。你用A线程加锁B线程没法帮你解锁这是mutex的强所有权概念。信号量呢它不关心的谁持有谁释放任何线程都能sem_post。这个特性在一番场景下是优点在另一些场景下就是坑。举一个我实际踩过的坑某个服务里线程A等着拿信号量线程B负责在异常路径上sem_post把A叫醒。后来B的代码改的异常路径没走通A就挂在那里永远等。如果用mutexB根本没法解锁问题会在编码期就暴露但用信号量代码能编译能跑只有运行时才会死锁。所以信号量的典型用法是“资源计数”而不是“临界区保护”。互斥锁才是保护临界区的第一选择。初值设为1的信号量虽然能做到mutex的效果但建议只在面试题里展示思路实际工程里尽量用pthread_mutex_t。3.2 条件变量为什么不能替代信号量条件变量condition variable和信号量经常被拿来对比因为它们都能阻塞和唤醒。但条件变量本身不携带状态它连“有没有资源”都不记录。条件变量必须搭配一个互斥锁和一个由程序员维护的谓词条件。比如“队列不为空”这件事你必须自己用一个变量存着检查条件和等待唤醒要在一个原子区间内完成否则就会出现“先判断条件、后睡、中间资源被人拿走一睡不醒”的经典丢失唤醒问题。而信号量自带一个计数值相当于把“资源是否可用”的状态打包进了内核对象里。生产者sem_post的时候即使消费者还没睡计数也会加一后面消费者sem_wait时直接就能拿到不会丢失通知。有同事问我说条件变量更省内存、更精细能不能全面替代信号量我的看法是反过来如果你只是想等一个资源、数一堆资源信号量天生顺手如果你要实现复杂的唤醒条件、广播唤醒一批等待者、或者等一个结构化的状态变化条件变量更灵活。两者不是替代关系是互补关系。3.3 选型建议表平时我会按下面这个表快速选型需求推荐方案原因保护一段临界区只允许一个线程进入pthread_mutex_t所有权明确不会误释放账号同时读一个状态量用读写锁pthread_rwlock_t读多写少场景性能更好限制最多N个线程同时访问资源池信号量初值N信号量天然适合计数限制生产者消费者有界缓冲区的空闲/满信号通知信号量空/满各一个语义直接不需要额外条件变量等待一个复杂条件成立如文件上传完成且校验通过互斥锁 条件变量条件表达式复杂需要谓词循环检查这张表会省掉很多“我到底该用什么”的纠结时间。4. 实战单生产者单消费者模型4.1 一个环形缓冲区配三个信号量信号量最经典的工程模型就是生产者消费者我拿一个单生产者单消费者的环形缓冲区版本来讲。代码如下#include stdio.h #include pthread.h #include semaphore.h #include unistd.h #define CAP 5 int buf[CAP]; // 环形缓冲区 int in 0, out 0; // 写指针和读指针 sem_t empty; // 空位数量 sem_t full; // 有数据的位置数量 sem_t lock; // 保护 in/out 指针的互斥信号量 void *producer(void *arg) { for (int i 0; i 10; i) { sem_wait(empty); // 申请一个空位 sem_wait(lock); // 加锁改 in buf[in] i; printf(produce %d at slot %d\n, i, in); in (in 1) % CAP; sem_post(lock); // 解锁 sem_post(full); // 数据多了一个 sleep(1); } return NULL; } void *consumer(void *arg) { for (int i 0; i 10; i) { sem_wait(full); // 申请一个“满位” sem_wait(lock); // 加锁改 out int val buf[out]; printf(consume %d from slot %d\n, val, out); out (out 1) % CAP; sem_post(lock); // 解锁 sem_post(empty); // 空位多了一个 sleep(2); } return NULL; } int main() { pthread_t t1, t2; sem_init(empty, 0, CAP); // 空位初始为5 sem_init(full, 0, 0); // 满位初始为0 sem_init(lock, 0, 1); // 互斥初值1 pthread_create(t1, NULL, producer, NULL); pthread_create(t2, NULL, consumer, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); sem_destroy(empty); sem_destroy(full); sem_destroy(lock); return 0; }运行结果会是生产者先生产消费者每两秒消费一个到后面缓冲区满了生产者会被sem_wait(empty)挡住等消费者腾出位置才会继续。4.2 两个信号量的初值为什么是5和0这里很多人第一眼看不懂empty为什么要初始化成CAPfull为什么要初始化成0把“空位”和“满位”看成两个独立的资源池empty代表缓冲区里还能塞进去多少数据初始时缓冲区全空所以是5。full代表缓冲区里已有多少数据初始时一个都没有所以是0。生产者每生产一个数据先消耗一个“空位”empty--再产生一个“满位”full。消费者反过来先消耗一个“满位”再释放一个“空位”。这样信号量的值就精确描述了缓冲区的状态你不需要单独用一个变量纪录“缓冲区满没满”信号量自己就是那个最权威的状态。这就是信号量的精髓——把资源状态变成计数让计数器代替业务逻辑判断。这里的lock信号量主要是保护in和out指针防止多生产多消费场景下索引更新出现竞争。单生产者单消费者场景其实可以去掉lock但加上它才能演示“信号量虽然计数但保护共享变量还是要锁”这个点。顺便说一句真正的多生产者多消费者lock建议用pthread_mutex_t不要用信号量当锁。4.3 执行顺序和死锁风险分析盯一下代码里的等待顺序sem_wait(empty); sem_wait(lock);这是标准的“先资源后锁”。如果把顺序交换成sem_wait(lock); sem_wait(empty);就会引入一个隐蔽的死锁风险消费者此时持有锁然后等empty而生产者想释放empty需要在生产完成后执行sem_post(empty)但生产前它要先拿lock。于是消费者拿着lock不放等empty生产者等lock才能释放empty——两头互相等死锁。这类问题单靠gdb很难第一时间看穿因为两个线程都停在sem_wait上。所以写代码时有一个死板但有效的原则信号量或锁的获取顺序要全局一致不要在一条路径上先拿A再拿B另一条路径上先拿B再拿A。信号量数量一多顺序多层嵌套这个原则就是你的第一道防线。5. 常见问题与排查技巧实录5.1 sem_destroy报EBUSY的坑我见过不少同事在程序退出时被sem_destroy返回-1卡住看一眼errno是EBUSY代码一头雾水明明所有线程都join完了怎么还busy原因很简单还有线程阻塞在这个信号量的sem_wait上或者信号量的计数不为0。EBUSY不是“有锁没释放”而是“内核对象还有人引用”。遇到这种情况别硬扛先检查是不是有线程在退出路径上没有走到sem_post导致别的线程饿死再检查是否在sem_destroy前没把所有阻塞的线程唤醒。我的建议主程序退出时先设置一个“停止”标志然后对所有可能阻塞的信号量执行足够的sem_post确保阻塞线程都醒来并退出再pthread_join最后sem_destroy。代码里加一行注释“这里post是为了唤醒可能阻塞的线程”能救未来的自己。5.2 死锁定位从gdb到valgrind如果程序卡住了首先想到的是把当前线程栈打出来。用gdb附加到进程gdb -p pid然后输入info threads thread apply all bt如果看到好几个线程都卡在sem_wait的调用栈上那基本就锁定“信号量等待”环节出问题了。接下来分两种情况有一个线程卡在sem_wait上另一个线程在临界区里睡了或卡在别的锁上那可能是临界区代码没执行到sem_post。所有线程都卡在不同的sem_wait上大概率是获取顺序不一致导致循环等待。如果gdb不够直观valgrind的helgrind工具能帮你找锁序问题valgrind --toolhelgrind ./sem_demohelgrind会报告疑似死锁、锁序反转、数据竞争输出很啰嗦但真能查出肉眼发现不了的并发问题。唯一缺点是慢调试时数据规模要调小。5.3 信号处理函数里能不能sem_post这个问题被问过多次。程序收到SIGINT或者SIGTERM时想直接在一个信号处理函数里sem_post唤醒一个工作线程看起来顺理成章但暗藏风险。信号处理函数能够安全调用系统函数有严格限制只有async-signal-safe的函数才能在信号处理器里调用。sem_post在这方面的安全性不同平台、不同glibc版本表现不一致你不能拿“在A机器上能跑”去赌“在B机器上永远没事”。稳妥做法是在信号处理函数里只写一个self-pipe或者用signalfd把信号转化成普通IO事件由专门的线程读取后在做sem_post。也就是“信号只在信号上下文里做最小的事业务唤醒放到外面做”。5.4 计数失衡多post一次或少wait一次信号量出问题最常见的症状不是死锁而是“明明应该只有一个线程能进来实际进来了两个”或“频繁的超时等待”。这两类问题多半是计数失衡。多一次sem_post容易出现在异常路径上函数里先sem_wait中途出错goto的跳转目标包括sem_post但前期有个分支也sem_post了结果一次wait配了两次post。信号量值变成2临界区理论并发数翻倍。排查计数问题我通常会在关键节点临时打印信号量值int val; sem_getvalue(sem, val); printf(sem value %d\n, val);sem_getvalue返回的是当前计数看到它不符合预期再顺着wait/post成对检查。注意sem_getvalue在glibc较新的版本里对带等待者的信号量返回的是负数值的大小代表等待线程数这个细节容易让人误解但用来判断“有没有人等”反而很好用。5.5 命名规范和方法论信号量虽然简单但工程里维护起来要定规矩。我的习惯信号量变量命令要直接表达“资源是什么”比如sem_empty_slots、sem_data_items不要叫sem1、sem2。sem_init和sem_destroy尽量在同一个模块、同一个生命周期里成对出现。所有sem_wait之后立刻考虑所有可能的退出分支确保每个分支都有对应的sem_post必要时用defer或goto cleanup集中处理。提交代码之前全局搜一遍sem_post和sem_wait的数量配对检查。这些规则不复杂但能省掉大量调试时间。6. 排查工具、RTOS拓展与个人经验6.1 POSIX信号量和SysV信号量的工具差异排查时要知道自己用哪套API。POSIX信号量sem_wait/sem_post是最常用的但它没有统一的命令行查看工具/dev/shm下面的sem.xxx路径只能看到名字看不到计数。如果是SysV信号量semget/semop那套老接口可以用ipcs -s查看所有内核信号量集合ipcs -s输出会显示每个信号量的nsems集合中信号量个数、key、owner和权限。清理残留可以用ipcrm -s semid两套API别混着用。POSIX信号量语义更清晰接口更简单SysV信号量功能更底层支持多值集操作但复杂度也更高。新项目一律推荐POSIX信号量。6.2 学透信号量RTOS和Linux内核里也受益热搜词里的freertos信号量、rtos信号量其实和Linux用户态信号量是同一套思维的迁移。RTOS里常见的二进制信号量对应初值1的POSIX信号量计数信号量对应初值N互斥信号量还多了优先级继承等机制。在嵌入式面试里面试官往往先问“信号量和互斥锁的区别是什么”本质上就是在考你对“计数资源共享”和“临界区互斥”这两个维度的理解深度。Linux内核里的struct semaphore也是类似的计数逻辑只是实现和用户态完全不同涉及自旋锁、睡眠和唤醒队列。虽然写业务代码用不到内核源码级细节但心里有这套谱系以后排查死锁、设计同步方案时思路会清晰很多。6.3 几个我沉淀下来的个人习惯最后聊几点只有踩过坑才会总结出来的经验。第一永远不要在持有一个信号量的时候做阻塞式的IO。信号量本质是资源计数器你占着一个坑然后睡觉等于把资源白白占用后面所有等待者全都被拖慢。如果真需要长时间IO释放掉信号量做完再重新获取。第二初值尽量用宏定义而不是裸数字。sem_init(sem, 0, 5)里的5过一个月你就不记得是缓冲区大小还是并发上限了。写#define MAX_CONN 5代码就是可读的文档。第三别把信号量当万能灵药。实际工程里的并发问题八成用mutex加条件变量更合理剩下两成才是信号量的主场。如果你发现自己用信号量管理着一个非常复杂的“业务状态机”回头想想是不是模型选错了。信号量这个东西教科书上三页纸讲完工程里能坑你三年。希望这篇笔记能帮你少走一段弯路也欢迎同行在评论区聊聊自己栽过的信号量。
返回列表