System V信号量semget参数详解:key、nsems与semflg实战

发布时间:2026/10/27 12:31:26
System V信号量semget参数详解:key、nsems与semflg实战

1. 把 semget 放回它该在的位置

很多人第一次写 System V 信号量,都是被semget的三个参数卡住的——尤其是key和semflg,前者动不动返回-1并甩给你一个ENOENT或者EEXIST,后者你随手写个0666看起来能跑,换台机器就EACCES。我从早期做多进程数据采集程序开始,中间断断续续用过好几轮 System V 信号量,每次隔一段时间重新捡起来,都得把semget的参数含义再核对一遍。这篇就把semget(int key, int nsems, int semflg)这三个参数掰开揉碎讲清楚,顺带把ftok、ipcs、ipcrm这一整套配套操作串起来,目标是让你看完之后能自己判断"这个场景到底该传什么参数",而不是靠复制粘贴碰运气。

先说清楚它是什么。semget是 System V IPC 三件套(消息队列msgget、信号量semget、共享内存shmget)里负责"申请一个信号量集合"的系统调用。它本身不做事,只是拿到一个整数 ID(semid),后续所有的 P/V 操作(semop)和属性操作(semctl)都靠这个 ID 进行。它解决的问题很具体:多个互不相干的进程(不是线程,线程用 pthread mutex 更省事)需要在没有亲缘关系的前提下做计数型同步,比如生产者-消费者队列、限流闸门、读写互斥的资源池。

适合谁看:写过fork但还没碰过 System V IPC 的;被semget返回值搞懵的;线上程序异常退出留下一堆僵尸信号量不知道怎么清的;以及想把老代码里的信号量参数彻底搞明白的。如果你只用过 POSIX 的sem_open/sem_init,那更该看看,因为 System V 这套的参数设计和 POSIX 完全是两种思路,用 POSIX 的直觉去套会一直踩坑。

1.1 信号量不是锁,是带原子操作的计数器

这一点必须先立住,否则后面参数怎么传都是糊涂的。信号量的本质是一个由内核维护的整数,semop对它的加减是原子的,仅此而已。P 操作(sem_op = -1)的意思是"如果值大于 0 就减一并且立刻返回,否则阻塞等着";V 操作(sem_op = +1)是"加一,并唤醒等待者"。二值信号量(初值 1)确实能当互斥锁用,但它只是计数器的一个特例。

semget之所以叫 "get",是因为它同时承担了创建和查找两个职责,具体走哪条路由semflg决定。这个设计跟open()的O_CREAT很像,但比open更容易出错,因为信号量没有文件名,只有一个由key_t表示的"钥匙"。钥匙是全局命名空间里的东西,内核里所有信号量集合挂在一张全局表上,key就是这张表的索引键。理解这一点,你就明白为什么key冲突会引发那么多诡异现象。

还有一个关键差异:semid不是文件描述符。fd是进程私有的,close就释放;semid是系统全局的句柄,进程退出不会自动销毁集合,必须显式semctl(..., IPC_RMID, ...)。这就是为什么你在老服务器上ipcs -s经常能看到一堆nattch 0的残留——程序崩了,信号量还躺在内核里占着名额。

1.2 三个参数决定了它能干什么、不能干什么

把三个参数的分工用一句话概括:key决定"找哪个",nsems决定"要几个",semflg决定"创建规则和访问权限"。这三个维度是正交的,但互相牵制,比如nsems在"查找已存在集合"时会退化成 0,semflg里的IPC_CREAT又直接决定了nsems到底有没有被用到。

参数类型语义常见误用
keykey_t(32 位 int)全局命名键硬编码 1234,与其他程序撞车
nsemsint集合内信号量个数查找时也填非零值,触发EINVAL
semflgint低 9 位权限 + 高位的IPC_*标志权限位写成0777或漏写IPC_CREAT

返回值只有两种:成功返回非负整数semid,失败返回-1并设置errno。绝大多数让人抓狂的问题,都是因为没检查errno就继续往下调semop,结果拿着-1去操作,报的是另一个错,排查方向直接跑偏。

注意:semget创建的信号量集合,内部每个信号量的初值是 0,不是 1。内核没有给semget提供"初始值"参数,这是 System V 和 POSIX 最大的差异点之一。你必须紧接着用semctl的SETVAL/SETALL显式赋值,否则第一次 P 操作就会永久阻塞。

2. key 参数:最容易出玄学的地方

key_t在 Linux 上就是个 32 位整数,取值范围0到0x7FFFFFFF(实际上key_t有符号,负值也能传但行为不规范)。内核用这个数在全局 IPC 表里查对应项。问题在于:这个数是人为约定的,内核不检查你有没有权限用某个值,所以两个毫无关系的程序如果都用了1234,就会互相干扰——后启动的那个要么拿到前一个的信号量(如果它没加IPC_EXCL),要么直接报EEXIST。

更麻烦的是,key的取值和机器、容器、文件系统都有耦合关系。

2.1 ftok 到底拼了什么,为什么换个目录 key 就变了

标准做法是用key_t ftok(const char *pathname, int proj_id)生成。它的实现不是哈希文件名,而是去stat()那个路径,然后按位拼接:

/* glibc 的简化逻辑 */ key_t key = (st.st_dev & 0xff) << 24 | (st.st_ino & 0xffff) << 8 | (proj_id & 0xff);

也就是说,最终key由三部分组成:设备号低 8 位、inode 号低 16 位、你给的proj_id低 8 位。这意味着三件事:

第一,pathname所指的文件或目录必须存在且可 stat,否则ftok返回-1。用不存在的路径初始化,semget必然拿到key = -1,然后你会看到semget直接失败。

第二,只要文件被删除重建,inode 号就变了,key跟着变。我踩过最典型的一次:程序启动时用/tmp/app.lock去ftok,结果系统重启后/tmp被清理,新创建的同名文件 inode 完全不同,导致上一轮残留的信号量永远"找不回来",ipcs -s里一堆孤儿。

第三,跨主机、跨容器不可移植。st_dev和st_ino是本地文件系统的概念,两台机器上同一个路径生成的key大概率不同。你把程序从开发机拷到测试机,明明代码没改,行为就是不一样,根源就在这里。

影响key的因素是否稳定备注
proj_id低 8 位稳定只能区分 256 个值,超了会撞
inode 号低 16 位文件重建就变容器镜像重建、/tmp清理都会触发
设备号低 8 位挂载点变化就变换分区、换存储驱动都会变
路径字符串本身不直接参与计算改名字但不换 inode,key 不变

实操建议:proj_id的取值一定要在项目内部形成约定,比如整个模块统一用'X'(0x58),另一个模块用'Y',别随手写1、2、3,因为proj_id只取低 8 位,1和257会生成同一个 key,这种隐性碰撞极难排查。另外,ftok用的路径建议选一个生命周期比程序长的文件,比如配置目录下一个固定的pid文件或锁文件,别用会被系统清理的临时目录。

2.2 IPC_PRIVATE 不是"私有",是"每次都给新的"

IPC_PRIVATE这个宏的值是0。名字起得有误导性,它的真实语义是:跳过 key 查找,直接在全局表里新建一个集合,并把它的 key 字段设为 0(表示"无公开钥匙")。所以每次用IPC_PRIVATE调用semget,只要不报错,返回的一定是一个全新的semid。

这个特性适合两类场景:一是父子进程之间传递——父进程semget(IPC_PRIVATE, ...)拿到semid,fork后子进程直接继承这个整数,天然共享,不需要任何命名;二是你明确不想让别的程序通过 key 找到它,只要不主动传递semid,外部进程无从下手。

但反过来,IPC_PRIVATE有个大坑:它无法"查找已存在的集合"。你调semget(IPC_PRIVATE, nsems, semflg)永远都是新建,即使上一轮的同名集合还残留着也没法复用。如果程序重启时忘了IPC_RMID,每次重启就在内核里多堆一个集合,直到撞上SEMMNI上限,报ENOSPC——而这时候ipcs -s的输出会非常壮观,一堆key 0x00000000的条目。

所以IPC_PRIVATE的正确用法是:只有确定生命周期内一定能够清理掉(比如fork后子进程负责IPC_RMID),或者进程本身极短命,才用它。想长期复用、跨进程找到同一个集合,就必须用ftok派生 key。

提示:semget的key传IPC_PRIVATE时,semflg里的IPC_CREAT和IPC_EXCL实际上都不起作用,内核会无条件新建。别以为加了IPC_EXCL就能报错,它不会。

2.3 key 冲突如何稳定复现与规避

定位 key 冲突有个非常朴素但好用的办法:先查再建。程序启动时用ipcs -s打印当前所有key,或者在自己的日志里把最终的key值以十六进制打出来,然后跟ipcs -s -i <semid>的输出对照。我一般会在初始化函数里加这么一行日志:

fprintf(stderr, "[sem] key=0x%08x nsems=%d flags=0%o\n", (unsigned)key, nsems, semflg & 0777);

这样出问题时第一眼就能确认 key 是不是符合预期。

规避冲突的工程化手段有几条:一是proj_id用项目内唯一的约定值,写进头文件宏定义,别散落在各处;二是初始化流程统一采用"先IPC_EXCL试建,EEXIST则降级为查找",这个模式后面第 5 节会给出完整代码;三是给信号量集合的权限设成0660并把属主固定在服务账号上,从权限层面挡住其他用户的误用。

有时候你会遇到一种更隐蔽的情况:key冲突不是自己造成的,而是系统里另一个老服务用了同一个值。这时候ipcs -s能帮你看到那个集合的owner和cpid(创建者 pid),顺着 cpid 找到对应进程或服务名,然后换一个proj_id避开就行。别指望"抢"过来,semget遇到已存在的 key 且没加IPC_EXCL时是直接返回对方的semid的,你会莫名其妙地操作别人的信号量,后果不可预测。

3. nsems 参数:填几不是拍脑袋

nsems是"集合大小",也就是这个semid下面挂了多少个独立的信号量。这是 System V 的一个特色设计:一次申请一组,每个成员用下标(sem_num,从 0 开始)访问。POSIX 信号量是个体独立的,想要一组就得自己定义一组变量,System V 直接把它做进了系统调用。

3.1 集合语义与创建后不可变

最常见的用法是用一个集合里的多个成员表示不同资源。比如一个生产者-消费者队列,可以约定idx 0是"空槽数量"(初值 = 队列容量),idx 1是"已填槽数量"(初值 0)。生产者先 P 0 再 V 1,消费者反过来。这样一次semget就把两个计数器绑在一起,后面semop也支持一次操作多个成员(原子地同时 P 0 和 V 1),比分别维护两个集合更不容易出现中间态。

nsems一旦在创建时确定,后续无法通过semget或semctl改变。想扩容只能IPC_RMID掉重建。所以初始化时要一次性留够余量,不要想着以后再加。我见过有人按"当前资源数"精确创建,后来业务扩容,只能改代码重启,还连带把残留清理了一遍。

3.2 nsems 与内核上限 SEMMSL 的关系

内核并不是无限接受nsems,它受SEMMSL(单个集合内信号量最大个数)限制。这个值可以从/proc/sys/kernel/sem读出来,四个字段依次是SEMMSL、SEMMNS、SEMOPM、SEMMNI:

$ cat /proc/sys/kernel/sem 32000 1024000000 500 32000
  • SEMMSL = 32000:单个集合最多 32000 个信号量
  • SEMMNS = 1024000000:系统内信号量总数上限
  • SEMOPM = 500:单次semop最多操作多少个成员
  • SEMMNI = 32000:系统内集合个数上限

老内核(2.6 时代)的默认值往往是250 32000 32 128,SEMMSL只有 250,SEMMNI只有 128。如果你的程序跑在嵌入式或老旧发行版上,nsems填了 300 就会直接EINVAL。这不是权限问题,别去查权限,先去看这个文件。

还有一组容易混淆的错误码需要区分清楚:

错误码触发条件排查方向
EINVALnsems < 0或nsems > SEMMSL检查/proc/sys/kernel/sem
EINVALkey 已存在,但nsems大于已有集合大小集合已存在,改用nsems = 0查找
ENOSPC系统内集合数达到SEMMNI或信号量总数达SEMMNSipcs -s查残留,ipcrm清理
ENOENT没加IPC_CREAT,且 key 不存在加上IPC_CREAT或先创建
EEXIST加了 `IPC_CREATIPC_EXCL`,但 key 已存在

第二条特别值得说:"查找已存在的集合"时,nsems应该传 0。传 0 表示"我不关心大小,把已有的给我"。传一个非零值且大于已有集合的大小时,内核认为是参数不一致,直接EINVAL;传非零且小于等于时,虽然能成功,但语义上会让人误解,不如统一传 0。这一点在写"先试建、失败则查找"的逻辑时非常关键,很多人卡在EEXIST之后改用同样的nsems去查,结果因为两边大小不一致又报EINVAL,排查半天。

4. semflg 参数:权限位和标志位混在一起写

这个参数是最"反直觉"的,因为它把两套完全不同的东西塞进了一个int:低 9 位是文件权限位(跟open的mode一个意思),高位是行为标志(IPC_CREAT、IPC_EXCL等)。两者用按位或拼在一起,写法上很像,语义上完全不同。

4.1 低九位是权限,不是随便填个 0666

权限位的含义和文件权限完全一致:0400读、0200写、0100执行(信号量没有执行语义,实际起作用的主要是读写),三组分别是属主、属组、其他。但信号量只用到读和写,而且semop需要的是"写"权限,semctl的查询类命令需要"读"权限。

实际写法上有两个常见错误:

一是写成0777。表面看是"全开放",但0777包含了执行位,虽然对信号量没实际影响,但不符合习惯,而且容易被安全扫描工具标记。正确写法是0666(全开放)或0660(仅属主属组)。

二是忘了 umask。这一点极其隐蔽:semflg里的权限位会被进程的 umask 过滤,跟open创建文件的行为一模一样。如果你的服务是以umask 022启动的,写0666实际生效的是0644,其他用户写不进去;写0660实际生效0640,同组用户也没有写权限。我遇到过最迷惑的一次:开发机上umask是002,一切正常;部署到服务环境(umask 027)后,同一个用户组里的另一个进程EACCES,翻了半天代码才发现权限位根本没生效成预期值。

解决办法有两个:一是在创建前显式umask(0)(但会影响进程内其他文件创建,需谨慎,创建完再恢复);二是直接用chmod语义的写法,创建后用semctl(IPC_SET)把sem_perm.mode强制改一遍。我倾向于第二种,副作用小:

union semun arg; struct semid_ds ds; arg.buf = &ds; semctl(semid, 0, IPC_STAT, arg); arg.buf->sem_perm.mode = 0660; semctl(semid, 0, IPC_SET, arg);

4.2 IPC_CREAT 与 IPC_EXCL 的四种组合

这两个标志的作用等价于open的O_CREAT和O_EXCL,但组合语义值得单独列个表,因为semget的行为矩阵比想象中更有细节:

IPC_CREATIPC_EXCLkey 不存在key 已存在
否否ENOENT返回已有semid
是否新建并返回返回已有semid
否是ENOENTEEXIST(实测中此项行为不稳定,别依赖)
是是新建并返回EEXIST

日常能用的就两种组合:IPC_CREAT("有就用,没有就建")和IPC_CREAT | IPC_EXCL("只建新的,已存在就报错")。后者的价值在于明确知道本次是不是创建者,只有创建者才应该去初始化信号量的初值。如果两个进程同时启动,都走IPC_CREAT,可能出现 A 刚建好还没SETVAL,B 就拿到同一个semid开始 P 操作,直接阻塞在值为 0 的信号量上——这个竞态在生产环境里出现频率不高,但一旦踩到就是死锁,非常难查。

所以标准套路是:先IPC_CREAT | IPC_EXCL试建,失败且errno == EEXIST时,再以nsems = 0和纯权限位去查找。这样职责清晰:创建者负责初始化,查找者只负责使用。代码在第 5 节。

IPC_EXCL单独使用(不带IPC_CREAT)没有意义,某些内核版本上行为也不一致,别这么写。

4.3 常见错误写法与后果

总结几条我在代码评审里反复见到的:

  • semget(key, 1, 0666):漏了IPC_CREAT。第一次跑必然ENOENT,作者加了个if (semid < 0) exit(1),然后以为是权限问题。
  • semget(key, 1, IPC_CREAT | 0777):权限位用了八进制0777但没走"读改写"逻辑,umask 一过滤就变0755,其他用户的写操作全部EACCES。
  • semget(key, 1, IPC_CREAT | 0666)后在多进程场景里直接semctl(SETVAL):多个进程同时启动时会重复初始化,把已经 P 到一半的计数重置,同步逻辑直接失效。
  • 把key和semflg位置写反:semget(IPC_CREAT | 0666, 1, 0x1234),编译能过(都是int),运行时报EINVAL或创建一个诡异 key 的集合。这种错误只能靠代码评审或静态检查抓。

提示:semflg的高位标志建议统一用#define写清楚,比如#define SEM_FLAGS (IPC_CREAT | IPC_EXCL | 0660),别在调用点直接拼,位置写反了编译器不报错。

5. 完整实操:从创建到销毁走一遍

理论说得差不多了,走一遍能跑的流程。环境是任意 Linux,需要 root 或对应用户权限。

5.1 环境确认与内核参数查看

先确认当前系统的信号量资源状况,这一步养成习惯,出问题时能快速定位是资源耗尽还是参数不对:

# 当前所有信号量集合 ipcs -s # 查看某个集合的详细信息 ipcs -s -i <semid> # 内核参数四元组 cat /proc/sys/kernel/sem

ipcs -s输出里几个字段的含义:key就是semget的 key(十六进制);semid是返回的 ID;owner是创建者;perms是权限位;nsems是集合大小;nattch是当前附加进程数,但注意 System V 信号量的nattch统计不可靠,进程退出后经常不减少,别拿它判断有没有人在用。

清理某条记录用:

ipcrm -s <semid> # 按 semid 删 ipcrm -S <key> # 按 key 删(注意 S 大写)

ipcrm -S里的key要用十六进制形式,比如0x00000001,写成十进制会删错或删不掉。

5.2 一份带注释的初始化代码

下面是完整可编译的示例,重点看sem_init里"试建—降级查找—仅创建者初始化"的三段式结构。这个结构我认为是 System V 信号量初始化的标准答案,几乎所有出错场景都能被它兜住。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <unistd.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> #include <sys/wait.h> /* glibc 不一定定义 semun,自己声明 */ union semun { int val; struct semid_ds *buf; unsigned short *array; }; #define SEM_PERM 0660 #define SEM_NSEMS 2 /* idx0: 空槽, idx1: 已填 */ #define SEM_SLOTS 4 /* 队列容量 */ static int g_semid = -1; /* * 返回值: >=0 成功且本次为创建者; 0 成功且复用了已有集合; -1 失败 */ static int sem_setup(const char *path, int proj_id) { key_t key = ftok(path, proj_id); if (key < 0) { perror("ftok"); return -1; } fprintf(stderr, "[sem] key = 0x%08x\n", (unsigned)key); int created_here = 0; int id = semget(key, SEM_NSEMS, IPC_CREAT | IPC_EXCL | SEM_PERM); if (id < 0) { if (errno != EEXIST) { perror("semget create"); return -1; } /* 已存在: nsems 必须传 0, 表示不校验大小 */ id = semget(key, 0, SEM_PERM); if (id < 0) { perror("semget lookup"); return -1; } } else { created_here = 1; } if (created_here) { unsigned short init[SEM_NSEMS] = { SEM_SLOTS, 0 }; union semun arg; arg.array = init; if (semctl(id, 0, SETALL, arg) < 0) { perror("semctl SETALL"); semctl(id, 0, IPC_RMID, 0); /* 初始化失败要回滚 */ return -1; } } g_semid = id; fprintf(stderr, "[sem] semid = %d, %s\n", id, created_here ? "created" : "reused"); return created_here; }

几个设计决定值得解释:

为什么用SETALL而不是逐个SETVAL:SETALL一次把整个集合的值写进去,中间没有"一个已经初始化、另一个还没有"的窗口。SEM_NSEMS只有 2 时差异不明显,如果集合有十几个成员,逐个SETVAL的竞态窗口会明显变长。

为什么初始化失败要立刻IPC_RMID:不这么做的话,集合已经创建成功了,但值是 0(未初始化),下次启动走"复用"分支,永远不会重新初始化,程序就永久死锁。这个回滚动作是很多示例代码里没有的,但在生产环境里能救命。

为什么返回"是否本次创建":某些场景下需要区分,比如只有创建者才去清理残留资源,或者创建者负责启动生产者进程。即使不用,知道这个信息也没坏处。

配套的 P/V 封装:

static int sem_p(int idx) { struct sembuf op = { (unsigned short)idx, -1, SEM_UNDO }; while (semop(g_semid, &op, 1) < 0) { if (errno == EINTR) continue; perror("semop P"); return -1; } return 0; } static int sem_v(int idx) { struct sembuf op = { (unsigned short)idx, 1, SEM_UNDO }; while (semop(g_semid, &op, 1) < 0) { if (errno == EINTR) continue; perror("semop V"); return -1; } return 0; } /* 原子地"占一个空槽 + 标记已填" */ static int sem_pv(int p_idx, int v_idx) { struct sembuf ops[2] = { { (unsigned short)p_idx, -1, SEM_UNDO }, { (unsigned short)v_idx, 1, SEM_UNDO }, }; while (semop(g_semid, ops, 2) < 0) { if (errno == EINTR) continue; if (errno == EAGAIN) return -2; /* 非阻塞模式下的失败 */ perror("semop PV"); return -1; } return 0; }

SEM_UNDO这个标志值得单独说。它让内核为每个进程维护一份"撤销表":进程对信号量做了哪些加减,退出时(包括异常崩溃、被SIGKILL)内核自动反向操作。上面sem_p里是-1,进程崩溃时内核补一个+1,槽位不会永久丢失。对于互斥类、计数类的信号量,几乎应该无条件加SEM_UNDO。不加的后果我实测过:一个采集进程被kill -9,它占着的槽位再也不会归还,队列容量永久少一格,跑几天就彻底卡死。

但SEM_UNDO也不是万能的:它只管"进程退出"这个时机,如果进程逻辑上算错了多减一次,SEM_UNDO会老老实实记成"欠一格",退出时补回来,等于把 bug 延续下去。所以它防的是崩溃,不防逻辑错误。

注意:semop一次操作多个成员时,这多个操作是作为一个原子事务执行的。内核会先检查所有操作是否都能满足(所有 P 的成员当前值都够),全部满足才一起执行,任何一个不满足就整体阻塞。这个特性让sem_pv这种组合操作比两次独立调用安全得多,不会出现"占了空槽但没标记已填"的中间态。

5.3 跑起来、观察、然后销毁

主函数用两个子进程模拟生产者消费者:

int main(void) { if (sem_setup("/tmp", 'Q') < 0) return 1; pid_t pid = fork(); if (pid == 0) { /* 子进程: 生产者, 填 3 次 */ for (int i = 0; i < 3; i++) { if (sem_p(0) < 0) _exit(1); /* 等空槽 */ fprintf(stderr, "[producer] put item %d\n", i); usleep(200 * 1000); sem_v(1); /* 标记已填 */ } _exit(0); } /* 父进程: 消费者, 取 3 次 */ for (int i = 0; i < 3; i++) { if (sem_p(1) < 0) break; /* 等已填 */ fprintf(stderr, "[consumer] get item %d\n", i); usleep(100 * 1000); sem_v(0); /* 归还空槽 */ } waitpid(pid, NULL, 0); /* 显式销毁: 不调用就永远留在内核里 */ if (semctl(g_semid, 0, IPC_RMID, 0) < 0) perror("semctl IPC_RMID"); else fprintf(stderr, "[sem] removed %d\n", g_semid); return 0; }

编译运行:

gcc -Wall -O2 -o semdemo semdemo.c ./semdemo

预期输出大致是生产者消费者交替打印,最后一行[sem] removed N。跑完立刻ipcs -s,应该看不到刚才那个 key 了。如果还能看到,说明IPC_RMID没执行到——多半是前面的分支提前return了。

这里有个调试技巧:在semop阻塞时想看它在等谁,可以另开一个终端ipcs -s -i <semid>,看semval字段。System V 的ipcs -i输出里有每个成员的当前值和等待进程数,配合semctl的GETNCNT、GETZCNT能看到具体有几个进程在等它变大或变小。这比加打印快得多。

6. 常见问题与排查记录

6.1 errno 对照速查表

semget失败时errno是唯一的线索,把这些码记住能省下大量时间:

errno含义最常见原因处理办法
ENOENTkey 不存在且没加IPC_CREAT漏写IPC_CREAT,或ftok算出的 key 与预期不同打印 key 值核对,补上IPC_CREAT
EEXIST加了IPC_EXCL但 key 已存在上次残留未清理,或另一个进程先创建了降级为nsems=0的查找
EACCES权限不足semflg权限位不够,或 umask 过滤后变小用IPC_STAT看实际 mode,必要时IPC_SET
EINVAL参数非法nsems < 0或超过SEMMSL;或 key 存在但nsems大于已有大小查/proc/sys/kernel/sem,查找时nsems传 0
ENOSPC系统资源耗尽集合数达SEMMNI或总数达SEMMNSipcs -s找残留,ipcrm清理
ENOMEM内核内存不足极罕见,通常是SEMMNI相关的表分配失败清理残留或调大参数

EINVAL是最容易误判的一个,因为它既可能来自nsems越界,也可能来自"已存在集合但大小不匹配"。区分方法:ipcs -s看有没有同 key 的记录;有的话就是后一种,改传 0 即可。

6.2 一个真实的排查过程

前段时间有个跑在工控板上的采集程序,重启后卡在初始化阶段不动。日志停在semget之后,但没有任何错误输出。这种情况说明semget本身成功了,卡在后面。

第一步,ipcs -s看现场,发现同一个 key 有两个条目(nsems一个 2 一个 3),owner相同,cpid一个指向早已退出的 pid。

第二步,ipcs -s -i <semid>看semval,其中一个集合的两个成员都是 0。这就是根因:程序上一版用nsems=3创建,新版改成nsems=2后走IPC_CREAT分支,但因为没加IPC_EXCL,直接复用了旧集合,而旧集合的成员从没被新版代码初始化过,值还是 0,第一次 P 操作就永久阻塞。

修复方案就是前面给的"试建—降级查找—仅创建者初始化",同时把残留集合ipcrm -s清掉。这件事让我彻底放弃"直接用IPC_CREAT一把梭"的写法,因为只要出现一次版本切换、大小变化,就会撞上这个隐性复用。

还有一类问题是"进程数一多就报ENOSPC"。查ipcs -s | wc -l发现堆了几百个集合,基本都是历史遗留。根因是程序在信号量创建后、业务跑完后没有IPC_RMID,正常退出走的是exit(0),把清理逻辑漏了。这类问题在开发环境完全看不出来,因为开发机经常重装/重启,测试环境也清理得勤,到了长期运行的生产机器才爆发。

6.3 遗留信号量清理的正确姿势

清理之前先看清楚,别一把ipcrm -a全删,那会把同机器上其他程序的信号量一起干掉。

安全流程:

# 1. 列出所有, 按 owner 过滤 ipcs -s | awk '$3 == "yourapp" { print $2, $1, $5 }' # 2. 挑出 nattch 为 0 且 cpid 已不存在的 # 注意 nattch 不完全可靠, 建议结合 ps 确认 cpid 不在 for id in $(ipcs -s | awk '$5 == 0 { print $2 }'); do echo "removing semid=$id" ipcrm -s "$id" done

更进一步,可以把清理逻辑做进程序本身:启动时用固定的 key 范围,检查到nattch为 0 且cpid不是活进程的集合,主动IPC_RMID。这样程序自愈,运维不用手工介入。但这套逻辑有风险——nattch不准,如果误删了别人正在用的集合,后果比残留严重得多。所以我一般只在自己独占 key 段的前提下做自动清理,并且加一个年龄判断:sem_otime超过 24 小时未更新才动。

还有一个常被忽略的点:IPC_RMID不是"立刻销毁"。如果还有进程阻塞在semop上等待这个集合,内核标记为待删除,等所有等待者被唤醒(通常以EIDRM错误返回)之后才真正释放。所以调用IPC_RMID后立刻ipcs -s可能还能看到它,这是正常现象,别以为是删除失败。

7. 几个我认为值得记住的结论

semget这三个参数里,key是契约(决定谁能找到它),nsems是容量(创建后锁死),semflg是行为 + 权限(决定创建策略和访问边界)。真正容易出错的从来不是"记不住参数顺序",而是这三点背后的隐性行为:ftok的 inode 依赖、nsems在查找时的特殊语义、semflg被 umask 过滤。

我个人现在的固定写法就三条:key用ftok配一个项目内唯一的proj_id,路径选配置目录下不会消失的文件;创建一律IPC_CREAT | IPC_EXCL试建,失败降级查找且nsems传 0;初始化用SETALL一次写完,失败立刻IPC_RMID回滚。这三条覆盖了我过去几年遇到的绝大多数信号量初始化问题。

最后分享一个自查小习惯:任何用到 System V 信号量的程序,在main返回前、以及SIGTERM的处理函数里,都打一行ipcs -s -i的输出到日志。程序跑挂的时候,这行日志往往能直接告诉你信号量的值停在哪里、还有几个进程在等,比事后登机器猜要快得多。