恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

Linux多进程并发:信号量原理、实战与避坑指南

  • 首页
  • 资讯中心
  • /
  • Linux多进程并发:信号量原理、实战与避坑指南

相关资讯

用Pandas DataFrame轻松驾驭多维数据:宽表、长表与MultiIndex实战指南 2026/10/21 14:33:57
进程暂停工具实战:从信号机制到线程挂起的跨平台实现 2026/10/21 14:28:56
GPU显存不足怎么办?从混合精度到量化推理的完整优化指南 2026/10/21 14:28:56

最新资讯

网络层设备是误区?IP转发本质在芯片与协议栈
Linux下MySQL自动备份实战:脚本设计、定时任务与排错指南
智能体技能工程:构建可声明、可验证、可编排的生产级Skill体系
微网日前调度中V2G与改进多目标灰狼算法建模方法
AI驱动市场分析系统架构设计:机器学习、特征工程与数据漂移实战
Windows主机痕迹管控:从日志审计到应急响应的闭环方案

今日推荐

SAP 屏幕上的金额未必存在数据库表里,从 WB2R_SV 的 Net Amount 看懂运行时计算
具身智能中‘指哪打哪’的视觉语言动作联合建模
AI时代人工翻译技术博客的价值与实践指南

本周热门

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

Linux多进程并发:信号量原理、实战与避坑指南

发布时间:2026/10/21 14:33:57
Linux多进程并发:信号量原理、实战与避坑指南 前阵子我在写一个并发压测的小工具开 8 个子进程每个进程往一块共享内存里的计数器累加 5000 次理所当然地认为结果应该是 40000。第一次跑出来 32581我不信邪又跑一遍变成 29877第三遍 34012。没有一遍是对的而且每次错的方向都不一样。这个Linux学习日记系列写到第 23 篇前面零零散散接触过 fork、管道和共享内存但一直没系统解决多个进程同时修改同一个资源这个并发编程最本质的问题。这次不绕了我把信号量从原理到实战、再到踩坑记录完整捋一遍。这篇日记适合正在学 Linux 系统编程、或者被多进程共享数据污染折磨过的读者看完你至少能明白为什么裸写多进程共享变量必挂信号量到底怎么用才不出洋相以及二值信号量和互斥锁之间那笔说不清的账。1. 计数器错乱那天我先记下这段翻车代码1.1 为什么 8 个进程能算出 12 个答案先复现一下当时的场景。我最初的实现用 mmap 划了一块共享内存结构体里只有一个 int 计数器每个子进程循环 5000 次执行counter。核心代码大概是这样的typedef struct { int counter; } shared_block; /* 子进程 */ for (int j 0; j 5000; j) { p-counter; }counter看起来是一行操作但 CPU 执行它其实要拆三步把值从内存读进寄存器、在寄存器里加一、把结果写回内存。假设现在 counter 是 10进程 A 读到 10 还没来得及写回进程 B 也从内存读到 10两边各自加一后写回内存里还是 11而不是 12。这一步的丢失就产生了错误。更关键的是进程调度是抢占式的。操作系统的调度器随时可能把正在执行的进程换下去把 CPU 卖给另一个进程。哪怕是单核 CPU8 个进程轮流执行读-改-写三步只要中途发生切换计数就会丢。我跑三次得到三个完全不同的数字正是这个原因——数据竞争的典型症状就是结果不确定。1.2 一个容易被忽略的前提fork 出来的是各自的地址空间这里还有一道坎必须先说清楚普通的全局变量根本不能用于跨进程同步。fork 之后子进程拿到的是父进程地址空间的一份拷贝而且基于写时复制机制谁先写谁才真正复制。你在子进程里改全局变量父进程完全看不见。所以第一步必须是 mmap 共享内存让所有子进程看到同一块物理页面。共享内存解决了看得见的问题但没有解决改得对的问题。两个进程同时写同一块共享内存还是会产生数据竞争。看见资源只是入场券互斥和同步才是真正的主角这就轮到信号量登场了。2. 信号量的底层逻辑一个资源票箱模型2.1 票箱规则拿票与还票信号量的概念我一直用票箱来理解。它内部维护一个整数计数器表示当前可用的资源票数量并提供两个原子操作sem_wait对应教科书里的 P 操作拿一张票。如果有票计数器减一立即返回如果没票了进程就地阻塞排队等票。sem_post对应 V 操作还一张票。计数器加一如果有人在排队等票唤醒其中一个。所谓原子就是检查有没有票 减一这个组合动作不可分割。内核保证这两个操作不会被其他进程插队打断不存在两个进程同时判定自己拿到了最后一张票的情况。这一点和counter被拆成三步形成鲜明对比也正是信号量敢拍胸脯说不会丢的根本原因。2.2 阻塞还是忙等内核怎么处理拿不到票的进程初学的时候我有个疑问sem_wait 没票的时候为什么不去 while 循环忙等原因很简单忙等白白烧 CPU。真实实现里拿不到票的进程会被加入一个等待队列调度器直接把它挂起直到 sem_post 唤醒它。唤醒之后也未必能立刻拿到票内核会重新检查条件安排合适的人进去。Linux 上 POSIX 信号量的底层是基于 futex 实现的。futex 是一个用户态快速路径 内核态慢速路径的机制大部分时候票是够的用户态一条原子指令就完成减一根本不需要进内核只有票不够需要睡眠时才真正陷入内核挂起。这也是为什么信号量在竞争不激烈的场景下开销很低别一听内核同步原语就觉得它笨重。2.3 二值信号量、计数信号量与互斥锁的关系信号量按照计数范围分成两类计数信号量计数器可以从 0 到任意正整数适合限流、资源池、生产者消费者计数。二值信号量计数器只取 0 或 1效果上接近互斥锁。二值信号量常被拿来当锁用但它和真正的互斥锁pthread_mutex不是一回事。互斥锁有所有权概念——谁上的锁谁解二值信号量没有互斥锁可以实现优先级继承二值信号量不行。这些小差别在低负载下无感在实时系统里就可能是事故。具体怎么选我会放到第 5 节专门说。3. POSIX 信号量实战从无名到命名两种姿势3.1 无名信号量 mmap 共享内存修复计数器先看修复版计数器。无名信号量的初始化函数是sem_init第二个参数 pshared 决定它是线程间共享还是进程间共享进程间必须传非 0 值。这里我把信号量直接放在共享内存结构体里让 fork 出来的每个子进程都能访问同一个sem_t对象#include stdio.h #include stdlib.h #include sys/mman.h #include sys/wait.h #include semaphore.h #include unistd.h #define WORKERS 8 #define TIMES 5000 typedef struct { sem_t lock; int counter; } shared_block; int main(void) { shared_block *p mmap(NULL, sizeof(shared_block), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); if (p MAP_FAILED) { perror(mmap); exit(EXIT_FAILURE); } /* pshared 传非 0表示这个信号量要用于多进程 */ if (sem_init(p-lock, 1, 1) ! 0) { perror(sem_init); exit(EXIT_FAILURE); } p-counter 0; for (int i 0; i WORKERS; i) { pid_t pid fork(); if (pid 0) { perror(fork); exit(EXIT_FAILURE); } if (pid 0) { for (int j 0; j TIMES; j) { sem_wait(p-lock); p-counter; sem_post(p-lock); } munmap(p, sizeof(shared_block)); exit(EXIT_SUCCESS); } } while (wait(NULL) 0) { /* 等待所有子进程结束 */ } printf(counter %d (预期 %d)\n, p-counter, WORKERS * TIMES); sem_destroy(p-lock); munmap(p, sizeof(shared_block)); return 0; }编译命令gcc -o counter_demo counter_demo.c -pthread ./counter_demo这次输出稳定是counter 40000。跑十遍十遍一样不用再提心吊胆。这里有个细节值得单独说明为什么不能用单独的全局sem_t因为 fork 之后子进程复制的是那一份sem_t的内存拷贝父进程和子进程手里的锁已经不是同一个对象了。必须让所有进程共享同一个物理内存里的sem_t锁才有意义。所以把sem_t放进 mmap 映射的结构体是无名信号量 多进程的标准做法千万别省这一步。3.2 命名信号量真实场景下的生产者消费者无名信号量的局限在于只能用于有亲缘关系的进程能共享同一块 mmap 的进程。如果两个完全不相关的进程要协作比如两个独立服务进程共享一个任务队列就该用命名信号量。命名信号量通过sem_open创建名字形如/xxx内核在/dev/shm下给它分配实体任意进程只要知道名字就能打开同一个信号量。下面这个例子是经典生产者消费者模型一个环形缓冲用三个信号量控制。free_slots记录空位数量初始化为缓冲大小used_slots记录已占用数量初始化为 0再配一个二值信号量mutex保护环形队列的头尾指针。#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include sys/stat.h #include semaphore.h #include unistd.h #include sys/wait.h #include sys/mman.h #define SLOTS 4 #define ITEMS 10 #define SEM_FREE /pc_free_demo #define SEM_USED /pc_used_demo #define SEM_MUX /pc_mux_demo typedef struct { int buf[SLOTS]; int head, tail; } ring_buffer; int main(void) { sem_t *free_slots sem_open(SEM_FREE, O_CREAT | O_EXCL, 0644, SLOTS); sem_t *used_slots sem_open(SEM_USED, O_CREAT | O_EXCL, 0644, 0); sem_t *mutex sem_open(SEM_MUX, O_CREAT | O_EXCL, 0644, 1); if (free_slots SEM_FAILED || used_slots SEM_FAILED || mutex SEM_FAILED) { perror(sem_open); exit(EXIT_FAILURE); } ring_buffer *rb mmap(NULL, sizeof(ring_buffer), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); if (rb MAP_FAILED) { perror(mmap); exit(EXIT_FAILURE); } memset(rb, 0, sizeof(ring_buffer)); pid_t pid fork(); if (pid 0) { perror(fork); exit(EXIT_FAILURE); } if (pid 0) { /* 消费者 */ for (int i 0; i ITEMS; i) { sem_wait(used_slots); sem_wait(mutex); int v rb-buf[rb-tail]; rb-tail (rb-tail 1) % SLOTS; sem_post(mutex); sem_post(free_slots); printf(消费: %d\n, v); usleep(180000); } exit(EXIT_SUCCESS); } /* 生产者 */ for (int i 0; i ITEMS; i) { sem_wait(free_slots); sem_wait(mutex); rb-buf[rb-head] i 1; rb-head (rb-head 1) % SLOTS; sem_post(mutex); sem_post(used_slots); printf(生产: %d\n, i 1); usleep(80000); } wait(NULL); sem_close(free_slots); sem_close(used_slots); sem_close(mutex); sem_unlink(SEM_FREE); sem_unlink(SEM_USED); sem_unlink(SEM_MUX); munmap(rb, sizeof(ring_buffer)); return 0; }运行起来能看到生产速率快于消费速率时生产者到第 4 个产品后会被free_slots挡住等消费者腾出空位再继续消费者反过来会被used_slots挡住。两个进程一快一慢却能严格保持步调一致这就是信号量的同步能力。这个例子里free_slots和used_slots是真正的计数信号量它们不再单纯当锁用而是在充当缓冲区状态的记数器。这是互斥锁完全替代不了的角色。3.3 System V 信号量老代码里常见但新代码我不推荐遇到老项目时很可能撞上 System V 信号量它的接口围绕semget、semop、semctl展开。一个最简单的二值信号量长这样#include stdio.h #include stdlib.h #include sys/types.h #include sys/ipc.h #include sys/sem.h /* Linux 不会自动定义这个联合体必须自己写 */ union semun { int val; struct semid_ds *buf; unsigned short *array; }; int main(void) { int semid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); if (semid 0) { perror(semget); exit(EXIT_FAILURE); } union semun su; su.val 1; if (semctl(semid, 0, SETVAL, su) 0) { perror(semctl); exit(EXIT_FAILURE); } struct sembuf P {0, -1, 0}; /* 拿票 */ struct sembuf V {0, 1, 0}; /* 还票 */ semop(semid, P, 1); /* 临界区 */ semop(semid, V, 1); semctl(semid, 0, IPC_RMID, 0); return 0; }用 System V 时你会明显感到两处别扭一是要自己定义union semun二是创建信号量和设置初值是两个独立步骤中间一旦进程崩溃内核里的信号量就成了永久残留物用ipcs命令能看到它一直挂在那里。相比之下 POSIX 信号量创建即带初值命名信号量还能用sem_unlink主动清理整体收敛得多。两种接口的差异我整理成了一张对比表对比项POSIX 信号量System V 信号量接口风格sem_open/sem_init sem_wait/sem_postsemget semop semctl命名方式字符串名字/xxx或内存对象整数 id 或 ftok 生成的 key创建与初始化一步完成直接给初值创建后还要 semctl 单独设值释放清理sem_close sem_unlinksemctl IPC_RMID漏了就是垃圾底层实现futex轻量较重的内核对象生态现状新项目主流老项目存量多结论很直接新写的代码我建议一律用 POSIX 信号量。除非你在维护一个历史仓库否则没必要把精力花在 System V 的奇怪接口上。4. 排错实录用信号量时的五个翻车现场4.1 翻车一sem_init 的 pshared 填了 0这个坑我怀疑每个新手都踩过。sem_init第二个参数是 pshared填 0 表示线程间共享填非 0 表示进程间共享。我一开始给共享内存里的信号量传了 0结果子进程跑起来后计数器依旧错乱。原因在于 pshared 为 0 时glibc 会把它当作私有 futex处理语义上只保证同一个进程内的线程可用。我把一个线程锁放到多进程场景里用属于未定义行为实际表现基本等于没锁。修复方式就一行把第二个参数改成 1。但这一行背后是整段内存模型的理解你要是没想明白改了也白改。4.2 翻车二把 sem_t 当普通变量拷贝信号量对象不允许拷贝sem_t内部对用户来说应该视为不透明类型只能通过初始化或打开得到的指针去操作。我见过有人图方便写出这样的代码sem_t tmp shared-lock; /* 错误拷贝的是内部状态 */ sem_wait(tmp);这样做属于未定义行为。表面上看有时能跑通但信号量的内核状态、等待队列信息根本不是一份简单拷贝能带走的。一旦有进程阻塞在这个拷贝出来的信号量上行为就完全不可预测。守则很简单sem_t永远使用指针传递绝不整块赋值绝不当函数参数按值传递。4.3 翻车三sem_wait 被信号打断后没人处理 EINTRsem_wait是可能被信号打断的。如果进程收到了一个信号而信号处理函数执行完返回sem_wait可能返回 -1errno 为 EINTR。新手看到这里往往直接perror(sem_wait)然后退出但实际上这只是被打断不是出错应该重试。我第一次写网络程序时就因为这个原因信号一来整个同步逻辑就崩了。正确的写法要给 EINTR 单独开一条路while (sem_wait(sem) ! 0) { if (errno ! EINTR) { perror(sem_wait); /* 真正的错误按业务处理 */ break; } /* EINTR只是被信号打断继续等 */ }另外提醒一句sem_wait是可被信号打断的调用点如果服务里大量使用信号建议评估改用sem_timedwait加超时重试至少能让主流程在异常情况下有出口。4.4 翻车四忘记 sem_unlink第二次运行直接 File exists我上面生产者消费者的代码里用了O_CREAT | O_EXCL这个组合要求信号量不存在才能创建。第一次运行正常第二次运行直接报错File exists。因为上一次进程结束时只调用了sem_close没有调sem_unlink命名信号量在内核里还留着。正确姿势是程序退出前sem_unlink清掉名字。或者反过来在sem_open之前先sem_unlink一次把上次残留清掉再创建。排查这个问题时可以看/dev/shm目录命名信号量会以文件形式出现在那里ls -l /dev/shm | grep pc_看到残留文件就知道是谁没清理了。4.5 翻车五P/V 操作顺序写反进程集体卡死生产者消费者模型里信号量的操作顺序有严格讲究。生产者必须是先拿空位票再拿互斥票释放时先放互斥再放满位票。如果把sem_wait(free_slots)和sem_wait(mutex)写反或者把初值初始化错很容易出现两个进程互相等待的死锁局面。我当时犯过的错误是把free_slots初始化成了 0、used_slots初始化成了缓冲大小结果生产者一开始就阻塞消费者也永远拿不到票两个进程双双挂在sem_wait上。这种死锁排查起来很直观用strace -p PID挂上去能看到进程卡在futex调用上再用pstack看线程栈基本能定位是哪个信号量没等到。记住一个原则信号量不是普通的加减法它是对资源状态的声明初始化值必须真实反映资源当前状态写错一个数字就是一次死锁事故。5. 信号量、互斥锁、条件变量并发场景到底怎么选5.1 二值信号量 vs 互斥锁优先级反转这笔账二值信号量和互斥锁在功能上高度重叠但互斥锁多了两个关键能力所有权和优先级继承。所谓所有权就是只有加锁的线程才能解锁这把使用双方绑死了杜绝了A 加锁、B 误解锁的错乱而二值信号量任何进程都能 post。优先级继承解决的是实时系统里的优先级反转问题低优先级任务拿着锁的时候高优先级任务在后面排队中等优先级任务抢占了低优先级任务导致高优先级任务迟迟拿不到 CPU。互斥锁可以临时把持有者的优先级抬到等待者水平信号量没有这个机制。所以在多线程场景里保护临界区默认应该用pthread_mutex而不是拿二值信号量硬凑。5.2 计数信号量的不可替代场景互斥锁只有一个有人/没人的状态它表达不了还剩多少可用资源。常见的限流场景一个服务最多允许 10 个并发请求操作某个资源池计数器就是这个 10。sem_init(sem, 0, 10)每进入一个任务 sem_wait 减一每完成一个任务 sem_post 加一超过 10 个请求自然阻塞。用互斥锁实现这个需求你得维护一个计数变量再加一个条件变量代码复杂度和出错率都会上升。生产者消费者的空位/满位计数也是同一个道理。面对这类场景计数信号量不是可选而是正确工具。理解了这一点你就不会问出信号量能不能被互斥锁替代这种问题——要看你是想锁住一个资源还是想数一堆资源。5.3 我的选择清单与一个排查技巧整理一份我实际项目里用的选择清单场景推荐工具多线程保护共享变量pthread_mutex多进程保护共享内存变量进程共享互斥锁或二值信号量限制同时访问资源的数量计数信号量生产者消费者步调对齐计数信号量组空位/满位等待某个自定义条件成立条件变量 互斥锁事件通知、唤醒一个等待者信号量最后分享一个我自己很受用的排查习惯任何进程卡住先用strace -p 进程号看一眼如果输出反复停在futex(0x7f..., FUTEX_WAIT)上基本就是同步原语出了问题。再用cat /proc/进程号/stack和pstack 进程号对照着看通常几分钟内就能定位到是哪个信号量没等到释放。学信号量这几年最大的体会是并发 bug 不是写出来的是想当然想出来的。每次动手前先画一遍资源使用顺序比事后调试省太多时间。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号