Linux信号量sem实战:从接口原理到踩坑排查一次说清
发布时间:2026/9/14 4:23:54 锦皓数字建站

先讲个真实场景。前两年我维护某个服务某天凌晨收到告警线上线程全部卡死请求堆积不消费。我gdb attach上去thread apply all bt一看几乎所有工作线程都停在sem_wait上。第一反应是死锁了可把所有线程栈翻了个底朝天没看到明显的锁交叉。后来才查出罪魁祸首是sem_timedwait的超时时间——我按“相对时间”给它传了1秒可接口要的是“绝对时间戳”结果线程直接等到天荒地老。这个坑回头看特别低级但在现场排查的时候真的能把人绕到沟里去。Linux下的多线程同步聊来聊去无非就那几样互斥锁、条件变量、读写锁还有信号量。信号量是其中最“古老”的一个1965年Dijkstra就提出了后来POSIX把它标准化成sem_*这套接口。很多初学者把它当成一个简单的计数器可真正用起来里面的细节一点都不少。这篇文章不打算讲太多空泛理论就围绕信号量sem把概念、接口、实战、选型和排查一次说清楚。不管你是刚开始写Linux C/C多线程的新手还是写了几年同步代码的老手信号量里总有几个细节值得重新过一遍。文中涉及的代码我都用gcc实测过可以直接拿去做实验。1. 先搞懂信号量到底是啥1.1 信号量的本质一个计数器加一个等待队列信号量在计算机科学的定义里是一个非负整数计数器外加一个等待队列。这个定义看起来抽象但你可以把它想象成一个停车场的剩余车位显示屏sem_wait相当于“尝试进停车场”。如果剩余车位大于0直接进入显示屏数字减1如果剩余车位是0就在门口排队等着直到有车出来。sem_post相当于“有一辆车开走了”显示屏数字加1。如果门口有人在排队就放一个人进来。这里最重要的一点是计数器加1、减1这些操作必须是原子的。如果两个线程同时执行sem_wait恰好信号量值又是1绝不能出现两个线程都认为“我拿到这个资源了”的情况。原子性由操作系统和glibc在底层保证你用的时候不需要自己加锁但理解这一点能帮你避免很多误用。很多教材喜欢把信号量描述成“用于临界区保护的同步原语”这话没错但不完整。信号量真正解决的问题有两个一个是互斥保证同一时刻只有一个线程访问共享资源另一个是同步保证多个线程按正确的时机、正确的顺序执行。二值信号量天然适合做互斥而计数信号量更像是“资源池”的记账本。1.2 从P操作和V操作说起教科书里几乎都会提PV操作。P和V这两个字母来自荷兰语P是Proberen意思是“测试”V是Verhogen意思是“增加”。提出信号量的人是大名鼎鼎的Dijkstra荷兰人所以他用自己的母语命名了这两个原语。后来POSIX标准把这套思想搬到了C语言接口上对应关系非常清晰P操作测试/请求资源对应sem_wait、sem_trywait、sem_timedwaitV操作增加/释放资源对应sem_post信号量这个名词本身也很有来头它来自铁路上的“臂板信号机”英文就是semaphore。信号机只有两种状态放行或者禁止正好对应信号量的“0”和“非0”。Dijkstra借用这个概念设计同步机制名字起得相当传神。理解这层背景再去看sem_wait和sem_post这对接口就不容易搞混了——一个负责“等信号”一个负责“发信号”。1.3 二值信号量与计数信号量初始值决定了它的命信号量的初始值value非常关键同一个信号量初始值不同用途完全不一样初始值为0它就是一个“事件通知”或者说“任务完成标志”。线程A执行sem_wait会阻塞直到线程B执行sem_post释放一个信号。典型场景是主线程等子线程干活干完。初始值为1它是二值信号量大多数情况下可以当互斥锁使用。两个线程同时sem_wait只有一个能成功另一个阻塞。初始值为N它是计数信号量相当于一个容量为N的资源池。最多允许N个线程同时进入临界区第N1个线程只能排队。我见过不少新手拿到信号量就只想着当锁用结果初始值设成1用完又觉得“这东西和pthread_mutex没啥区别”。实际上初始值为0的信号量才是真正体现同步价值的地方它在C语言里几乎可以模拟Java的CountDownLatch或Semaphore的部分功能后面代码实战部分我会详细演示。2. sem接口全家桶参数、返回值与隐藏细节2.1 sem_init初始化时最容易埋雷的地方先看函数原型#include semaphore.h int sem_init(sem_t *sem, int pshared, unsigned int value);三个参数里sem指向你要初始化的信号量变量value是上文说的初始值。最容易出错的是pshared。pshared为0表示这个信号量只在本进程内的线程间使用这是最常用的情况。pshared为非0表示信号量可以在进程间共享但有个前提这个sem_t结构体必须放在多个进程都能访问到的共享内存里比如mmap出来的共享映射区。很多人口头说要搞进程间信号量结果只是把pshared传成1sem_t变量还是定义在普通堆里fork出来的子进程根本看不到小问题藏成大bug。还有一个安全准则不要在信号量已经被线程wait或post之后再对它重复sem_init。标准里没有定义“重复初始化正在使用的信号量”的行为实测可能直接覆盖旧状态导致正在等待的线程永久阻塞。如果你要复用信号量先确定所有线程都不再使用它再执行sem_destroy然后才能重新初始化。2.2 sem_wait与sem_post一对必须成对出现的PV操作sem_wait和sem_post是信号量体系里最核心的两个接口int sem_wait(sem_t *sem); int sem_post(sem_t *sem);sem_wait原子地把信号量值减1如果减之前已经是0线程就阻塞直到另一个线程执行sem_post把值加1并唤醒它。sem_post原子地把信号量值加1并且会唤醒一个正在等待该信号量的线程。注意这里有个和mutex很不一样的地方pthread_mutex_lock有一个严格的规矩——谁加锁谁解锁不能线程A加锁、线程B解锁但信号量没有这个限制任何一个线程都可以对同一个信号量执行sem_post甚至可以在信号处理函数里调用sem_post因为它是异步信号安全的。这种灵活性是信号量的优点也是它容易出混乱的根源。你完全可以让线程A负责sem_wait让不相关的线程B负责sem_post只要逻辑设计得合理这就是一种同步机制但如果设计得乱七八糟代码会非常难维护。另外sem_wait还有一个经典坑位它可能被信号打断。如果你的进程里注册了任意信号处理函数sem_wait有可能返回-1并且errno被设置成EINTR。这种“假失败”不是信号量本身坏了而是系统调用被信号中断了。正确做法是循环重试我后面第5章会专门讲。2.3 sem_trywait与sem_timedwait非阻塞版本和限时版本有时候你不想让线程无限期等下去这时候有两个变种int sem_trywait(sem_t *sem); int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);sem_trywait是非阻塞的。信号量值大于0就立刻减1并返回0信号量值已经是0立即返回-1errno被设置成EAGAIN。它适合用在“能拿到就干拿不到就干别的”的轮询场景比如后台任务队列尝试申请批量任务许可证。sem_timedwait是限时等待第二个参数abs_timeout是重点中的重点它是一个绝对时间不是相对时间。也就是说你不能直接传“等待100毫秒”你要传“到哪个时间点为止”。正确写法是用clock_gettime取当前时间再加上你要等待的时长然后传给接口struct timespec ts; clock_gettime(CLOCK_REALTIME, ts); ts.tv_sec 1; // 最多等1秒 ts.tv_nsec 0; int ret sem_timedwait(sem, ts); if (ret -1 errno ETIMEDOUT) { // 超时了 }我前面说的线上事故就是这里把相对时间直接传了进去。abs_timeout的内部含义是“从1970年1月1日0点0分0秒以来的秒数”你传一个比如0.1秒的小数类型都不对。传一个小整数语义可能瞬间变成“等到1970年某个时刻”已经过去了或者等于等了一个极其漫长的时间。正确的做法就是要先取当前时间再加上超时时长。这个坑几乎每个大规模项目里都有人踩过所以我敢说值得单独拿出来当重点讲。2.4 sem_getvalue一个看似无害实则容易误导的接口int sem_getvalue(sem_t *sem, int *sval);这个接口看起来非常方便把当前信号量值取出来放到sval里。但它有个致命问题返回值在返回的那一刻就已经可能过期了。你拿到sval1正准备执行sem_wait另一个线程抢先一步sem_wait成功信号量值变成了0轮到你就阻塞了。反过来也一样sem_getvalue不能作为同步决策的依据除非你有额外的锁保护整个“先检查后操作”的过程。那它到底能干什么我的经验是调试和监控非常好用。代码里打日志的时候周期性打印信号量当前值可以直观看出生产者生产速度快还是消费者消费速度快线上排查问题的时候用一个定时脚本调用程序暴露的状态接口看信号量值是不是一直卡在0。它不能帮你做正确的并发控制但能帮你发现问题。下面这张表把这几个接口的关键点整理了一下接口核心作用阻塞行为典型errnosem_init初始化信号量不阻塞EINVALsem_waitP操作申请资源可能阻塞EINTRsem_trywait非阻塞P操作不阻塞EAGAINsem_timedwait带超时的P操作限时阻塞ETIMEDOUT, EINTRsem_postV操作释放资源不阻塞EINVALsem_getvalue读取当前值不阻塞无调试用为主3. 实战代码三种高频用法写给你看3.1 场景一主线程等待N个子线程全部完成很多时候我们需要“等所有线程都干完再继续”Java里有CountDownLatch干这事C语言里用信号量也能模拟而且非常优雅。思路很简单信号量初始值设为0每个子线程干完活就sem_post一次主线程执行N次sem_wait每次sem_wait成功代表“有一个子线程完成了”。完整代码我贴出来#include stdio.h #include stdlib.h #include unistd.h #include pthread.h #include semaphore.h #define WORKER_NUM 3 static sem_t g_sem; static void *worker(void *arg) { int id *(int *)arg; printf(worker %d start\n, id); sleep((id % 3) 1); // 模拟不同耗时 printf(worker %d done\n, id); sem_post(g_sem); free(arg); return NULL; } int main(void) { pthread_t tids[WORKER_NUM]; sem_init(g_sem, 0, 0); // 初始0表示“还没有完成事件” for (int i 0; i WORKER_NUM; i) { int *id malloc(sizeof(int)); *id i; pthread_create(tids[i], NULL, worker, id); } for (int i 0; i WORKER_NUM; i) sem_wait(g_sem); // 等满3次完成事件 printf(all workers done\n); sem_destroy(g_sem); return 0; }编译命令gcc -o sem_demo sem_demo.c -lpthread运行结果类似下面这样worker 0 start worker 1 start worker 2 start worker 0 done worker 1 done worker 2 done all workers done注意一个细节我在创建线程时用malloc给每个线程分配独立的内存放id千万不要在循环里直接传i。因为i是栈上变量每次循环结束它的值就变了而且所有线程拿到的可能是同一个地址读出来的都是同一个数。这个错我亲眼见过很多次属于多线程入门必备避坑项。这里信号量充当的是“完成计数器”它不关心具体是哪个线程完成只关心完成了几次。如果你用pthread_join必须拿到明确的线程id并且一个join只能等一个线程遇到“动态创建线程、不确定数量”的场景就会很被动。信号量这种方式就灵活得多。3.2 场景二生产者-消费者模型生产者-消费者是最经典的并发模型用mutex加条件变量能写用信号量写更直观。核心思想是空位和满位分别用两个信号量来计数生产者每次生产前申请一个空位生产后释放一个满位消费者反过来。完整的单生产者、单消费者版本#include stdio.h #include unistd.h #include pthread.h #include semaphore.h #define BUFFER_SIZE 8 #define TOTAL_ITEMS 20 static int buffer[BUFFER_SIZE]; static int in 0, out 0; static sem_t g_empty, g_full; static void *producer(void *arg) { for (int i 0; i TOTAL_ITEMS; i) { sem_wait(g_empty); // 申请一个空位 buffer[in] i; printf(produce %d at slot %d\n, i, in); in (in 1) % BUFFER_SIZE; sem_post(g_full); // 释放一个满位 } return NULL; } static void *consumer(void *arg) { for (int i 0; i TOTAL_ITEMS; i) { sem_wait(g_full); // 申请一个满位 int val buffer[out]; printf(consume %d from slot %d\n, val, out); out (out 1) % BUFFER_SIZE; sem_post(g_empty); // 释放一个空位 } return NULL; } int main(void) { pthread_t tid_producer, tid_consumer; sem_init(g_empty, 0, BUFFER_SIZE); // 空位初始为8 sem_init(g_full, 0, 0); // 满位初始为0 pthread_create(tid_producer, NULL, producer, NULL); pthread_create(tid_consumer, NULL, consumer, NULL); pthread_join(tid_producer, NULL); pthread_join(tid_consumer, NULL); sem_destroy(g_empty); sem_destroy(g_full); return 0; }这个模型能跑通的关键在于两个信号量的数量关系初始时g_empty BUFFER_SIZE表示8个空位g_full 0表示还没有数据。生产一件空位减1、满位加1消费一件满位减1、空位加1。整个过程的“空位总数 满位总数”始终等于BUFFER_SIZE不多不少。如果要改成多生产者多消费者就需要再加一把pthread_mutex保护buffer、in、out这些共享变量。因为两个生产者同时申请到空位后都有可能去写buffer[in]然后同时更新in数据就乱了。信号量负责“有几个位置可用”mutex负责“谁此刻能碰缓冲区”两者配合是这类模型的标准姿势。3.3 场景三用一个信号量控制最大并发数假设你要写一个批量任务调度器上游一次性丢过来100个任务但下游服务只能承受4个并发请求。信号量天然适合这个限流场景初始值设成最大并发数每个任务执行前sem_wait拿许可执行完sem_post归还许可。#include stdio.h #include stdlib.h #include unistd.h #include pthread.h #include semaphore.h #define MAX_CONCURRENT 4 #define TOTAL_TASKS 10 static sem_t g_perm; static void *task_entry(void *arg) { int task_id *(int *)arg; free(arg); sem_wait(g_perm); // 申请许可超过并发数就在这里排队 printf(task %d start\n, task_id); sleep(2); // 模拟耗时任务 printf(task %d end\n, task_id); sem_post(g_perm); // 归还许可 return NULL; } int main(void) { pthread_t tids[TOTAL_TASKS]; sem_init(g_perm, 0, MAX_CONCURRENT); for (int i 0; i TOTAL_TASKS; i) { int *id malloc(sizeof(int)); *id i; pthread_create(tids[i], NULL, task_entry, id); } for (int i 0; i TOTAL_TASKS; i) pthread_join(tids[i], NULL); printf(all tasks done\n); sem_destroy(g_perm); return 0; }运行后你会看到同时进入“start”状态的线程最多4个之后每有一个end才会有一个新的start。这就把并发顶住了。如果要做得更精细可以把sem_wait换成sem_trywait拿不到许可的任务直接返回一个“系统繁忙”的错误码而不是傻等。这两种策略分别对应“排队等待”和“快速失败”具体选哪种取决于业务对延迟的要求。不过要注意信号量没有“自动回收许可”机制如果某个线程拿完许可后崩溃了许可就永久丢失了。服务端做限流不能只靠信号量还要配合超时、监控和运维手段兜底。4. 说得清的选型sem、mutex、条件变量到底怎么挑4.1 为什么工程上默认用mutex而不是sem做互斥如果你去翻一些老书会看到大量用二值信号量模拟互斥锁的例子。但到了现代Linux编程实践里临界区保护的首选是pthread_mutex而不是信号量。原因有几个mutex的语义更“窄”也更防呆。mutex规定谁加锁谁解锁这个约束能把很多潜在bug挡在编译期和Code Review阶段信号量允许任意线程sem_post代码写多了你根本分不清哪个post是哪个wait的对应关系。mutex和条件变量组合起来可以表达复杂的等待条件比如“等待队列不为空且某个标志位为真”。信号量只记录一个数字表达这种多维条件非常别扭。mutex有明确的owner概念调试工具和死锁检测器都能更好地识别信号量的状态就是一个计数出问题之后排查成本更高。所以我的默认建议是保护共享数据、临界区互斥用pthread_mutex等待某个复杂条件用pthread_cond资源计数、完成事件、限流这种“数字增减”的场景才是信号量的主场。各干各擅长的别把信号量当成万能钥匙。4.2 信号量不可替代的几个场景虽然互斥锁占了半壁江山但信号量在以下场景里依然是更顺手的选择资源池计数。比如数据库连接池、线程池“最多N个并发”信号量初始值设N天然就是计数器。完成事件计数。不关心具体是哪个线程完成只关心“完成了几次”用信号量比反复pthread_join灵活得多。生产者-消费者的容量控制和数据计数。两个信号量分别维护“空位”和“满位”逻辑清晰代码量比条件变量版本少。跨进程同步。POSIX命名信号量可以方便地让多个进程打开同一个信号量协调访问共享资源。信号量的线程语义比mutex更轻有时候它甚至可以当“一次性通知”用主线程等一个异步任务子线程就一个sem_post调用干净利落。4.3 扩展一点进程间信号量sem_open最小示例线程之外进程间同步也可以使用信号量。无名信号量放在共享内存里可以跨进程但更常用的是命名信号量接口是sem_open、sem_close、sem_unlink。一个最典型的用法是进程A和进程B交替访问一个共享日志文件用命名信号量保证同一时刻只有一个进程在写。进程A代码骨架#include fcntl.h #include semaphore.h #include stdio.h int main(void) { sem_t *s sem_open(/my_log_sem, O_CREAT, 0666, 1); if (s SEM_FAILED) { perror(sem_open); return 1; } sem_wait(s); // 写共享日志文件 sem_post(s); sem_close(s); sem_unlink(/my_log_sem); // 注意需要的时候才unlink return 0; }命名信号量的name必须以/开头后面不能再有斜杠比如/my_log_sem、/app.sem都行。还要注意sem_open创建的命名信号量不会随进程退出自动消失Linux上它通常落在/dev/shm/sem.*这类文件里。如果你只sem_open不sem_unlink下次启动进程再次O_CREAT时之前遗留的信号量可能还带着旧状态。所以完全不用它的时候记得sem_unlink清理。4.4 顺带聊聊嵌入式里RTOS信号量的差异搜索热度里经常出现FreeRTOS信号量很多从rtos转Linux开发的人会对信号量的语义有点疑惑。RTOS里的信号量基本分三类二值信号量、计数信号量、互斥量。其中二值信号量适合做任务与中断之间的“事件通知”计数信号量适合做资源计数。但要注意RTOS里的互斥量专门实现了优先级继承机制用来解决优先级反转问题而计数信号量没有这个机制。如果你在FreeRTOS里有互斥需求优先选互斥量而不是二值信号量。Linux的pthread_mutex也有优先级继承的属性选项但默认不开启。理解这些差异能帮你在不同平台上写出风格一致、又不出幺蛾子的同步代码。5. 实战中踩过的坑与排查实录5.1 EINTR信号打断sem_wait造成的“假失败”第一次遇到EINTR是很多年前一个项目主线程sem_wait等待某个事件结果程序时不时地提前往后跑日志顺序完全乱了。查了半天发现是进程里注册了一个定时器信号每次定时器到期sem_wait就被信号打断返回-1。我们当时没有判断errno把它当成“等到了”往下执行了。信号量的wait类接口被信号打断后等于“什么都没发生”你必须重新等待。标准做法是封装一层安全等待int safe_sem_wait(sem_t *sem) { int ret; do { ret sem_wait(sem); } while (ret -1 errno EINTR); return ret; }sem_timedwait也一样超时返回ETIMEDOUT被信号打断返回EINTR两个值要分开处理。把EINTR当成超时逻辑会提前失败把超时当成EINTR又会死循环重试。细节决定成败。5.2 sem_timedwait的绝对时间是我踩过最深的坑开头提到的线上事故就是这个。当时的代码大概是这样的错误写法struct timespec ts; ts.tv_sec 1; // 错误以为这是“等待1秒” ts.tv_nsec 0; sem_timedwait(sem, ts);abs_timeout要的是绝对时间戳这个ts会被解释成“1970年1月1日0点0分1秒”早就过去了sem_timedwait会立刻超时返回ETIMEDOUT线程根本不会等1秒。反过来的坑是有时候恰好传了一个巨大的数字线程等于无限期等下去表现和死锁一模一样。正确写法务必是这样struct timespec ts; clock_gettime(CLOCK_REALTIME, ts); ts.tv_sec 1; // 等待1秒 ts.tv_nsec 0; int ret sem_timedwait(sem, ts); if (ret -1 errno ETIMEDOUT) { // 超时处理 }另外一个进阶知识点sem_timedwait用的是CLOCK_REALTIME这个时钟会受系统时间调整NTP同步、手动改时间影响。系统时间往前跳你的等待可能瞬间超时往后跳你的线程可能卡很久。如果等待时长对可靠性要求很高GNU还提供了sem_clockwait可以指定CLOCK_MONOTONIC不受墙上时钟跳变影响。Linux下可用跨平台移植时要注意。5.3 死锁和阻塞排查gdb三板斧线程集体卡死第一反应别慌按这套流程来用gdb attach pid挂上进程。输入info threads看线程列表找出卡住的线程。输入thread apply all bt打印所有线程的调用栈。分析栈上等的是哪个信号量、哪个锁。比如多个线程都卡在sem_wait你就要看谁应该sem_post却没sem_post。这时候可以打印信号量的当前值辅助判断。gdb里可以直接调用sem_getvalue不过要先声明一个变量让人有点绕实际操作如下(gdb) p int val (gdb) p sem_getvalue(g_sem, val) (gdb) p val如果返回的值长期是0说明“资源被消耗完了且没人归还”这时候要看业务逻辑上谁负责sem_post它是不是提前return了、崩了或者压根没走到那一步。如果信号量值大于0但线程还是卡在sem_wait那要怀疑是另一个线程拿到了信号量但没释放属于逻辑死锁。还有一个小细节glibc不同版本里sem_t结构体内部实现不一样有的版本直接p *sem能看到内部字段有的版本只有__size和__align这种union看起来像一堆乱码。这时候别纠结内部结构直接用sem_getvalue或者打日志比扒结构体高效得多。5.4 性能与调度临界区要多短唤醒顺序能不能依赖现代Linux上POSIX信号量的实现基于futex无竞争时sem_wait和sem_post都是用户态操作不会陷入内核性能非常高。一旦有线程竞争才会调用FUTEX_WAIT或FUTEX_WAKE陷入内核代价立刻上一个数量级。所以临界区或者说“持锁时间”越短越好不要在sem_wait和sem_post之间做耗时的IO、网络请求、大内存拷贝这些操作应该挪到锁外面去做。另外sem_post一次只唤醒一个等待者这个“一个”是有讲究的。它并不会保证唤醒哪个线程也不会保证“先等的先被唤醒”。在Linux默认的futex实现里唤醒顺序大体上是等待队列的顺序但POSIX标准从未承诺这一点所以你的业务逻辑绝对不能依赖“唤醒顺序按FIFO”。如果面对的是多个等待线程、某次事件需要同时唤醒多个那你要么sem_post多次要么直接用条件变量配合pthread_cond_broadcast。还有一个容易误判的点sem_post唤醒等待线程只表示这个线程从“阻塞状态”回到“就绪状态”并不表示它立刻被调度上CPU。它还要参与调度器的排队。所以“post之后立刻再wait一次”这种操作可能会遇到“明明post了第二次wait还是阻塞”的情况这不是bug是调度细节。正确地理解它能帮你少写很多看似正确实则随缘的代码。最后再分享一个我自己的调试习惯在新代码里使用信号量时我一定会在注释里写清楚三件事——初始值为什么是这个数、谁来sem_wait、谁来sem_post。这三行注释看起来简单但能让后来接手的人少走很多弯路。我自己就吃过无数次“只看代码猜不出来设计意图”的亏后来才发现很多时候问题不是代码写错了而是写代码的人根本没想清楚这个信号量到底在同步谁和谁。想清楚了再写比写完再猜要省事得多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。