扩展—Linux System V IPC理论
在学习 Linux 进程间通信的时候,我们经常会接触到 System V IPC 这一套经典的机制,它提供了信号量、消息队列、共享内存三种方式来实现进程间的同步与通信。不过原生的系统接口往往比较繁琐,参数多、步骤杂,新手很容易踩坑。
那有没有办法把这些底层接口封装得更易用,同时还能兼顾扩展性?今天我们就结合两个实践案例,看看如何用建造者模式封装信号量,用责任链模式扩展消息队列的处理能力,把系统编程和设计模式结合起来,写出更优雅的代码。
一、信号量
信号量是我们实现进程间同步与互斥的基础工具,它最早由 Dijkstra 提出,核心就是通过 P、V 两个原子操作来实现对资源的控制。
1.1 核心原理
我们先回顾一下信号量的基础概念:
-
信号量本质上是一个计数器,用来表示可用资源的数量
-
P 操作:申请资源,计数器减 1,如果减完后小于 0,说明资源不够,当前进程进入等待队列
-
V 操作:释放资源,计数器加 1,如果加完后小于等于 0,说明有进程在等待,唤醒等待队列里的第一个进程
信号量的值的含义也很明确:
-
S > 0:表示当前有 S 个可用资源
-
S = 0:没有可用资源,也没有等待的进程
-
S < 0:绝对值表示当前等待队列里的进程数量
用伪代码来表示的话,信号量的结构体和 P、V 操作是这样的:
// 信号量结构体
struct semaphore
{
int value; // 计数器
pointer_PCB queue; // 等待进程的队列
}
// P操作:申请资源
void P(semaphore &s) {
s.value--;
if (s.value < 0) {
// 资源不足,当前进程进入等待状态
将当前进程PCB插入等待队列末尾
}
}
// V操作:释放资源
void V(semaphore &s) {
s.value++;
if (s.value <= 0) {
// 有进程在等待,唤醒一个
从等待队列取出一个进程,放到就绪队列
}
}
1.2 原生接口
Linux 下的 System V 信号量,提供了三个核心的系统调用,不过这些接口的参数比较多,新手很容易搞混:
semget:创建 / 获取信号量集
(1)semget:创建 / 获取信号量集
int semget(key_t key, int nsems, int semflg);
-
key:信号量集的键值,用来唯一标识这个 IPC 对象,和消息队列、共享内存的 key 是同一个逻辑
-
nsems:信号量集中信号量的个数,System V 信号量是支持一组信号量的
-
semflg:标志位,比如IPC_CREAT表示不存在则创建,0666是权限位
(2)semctl:信号量的控制操作
这个接口是可变参数的,用来做初始化、删除等控制操作:
int semctl(int semid, int semnum, int cmd, ...);
这里有个很容易踩的坑:系统没有给我们定义 union semun 这个联合体,需要我们自己手动定义!
// 必须自己定义这个联合体
union semun {
int val; // 用于SETVAL,设置信号量的初始值
struct semid_ds *buf; // 用于IPC_STAT/IPC_SET,获取/设置属性
unsigned short *array; // 用于GETALL/SETALL,批量获取/设置信号量值
struct seminfo *__buf; // Linux特有的,用于获取系统信息
};
(3)semop:执行 P/V 操作
这个接口用来对信号量执行加减操作,也就是我们的 P、V 原语:
int semop(int semid, struct sembuf *sops, size_t nsops);
其中 sembuf结构体 是系统定义的,用来描述要执行的操作:
struct sembuf {
unsigned short sem_num; // 要操作的信号量在集合中的序号
short sem_op; // 操作数:-1就是P操作,1就是V操作
short sem_flg; // 标志位,比如SEM_UNDO
};
这里的 SEM_UNDO 是个很实用的标志:当进程意外退出的时候,内核会自动撤销该进程对信号量的操作,避免进程崩溃没释放信号量导致的死锁问题,实际开发中建议加上。
1.3 封装信号量
原生的接口要创建一个信号量,需要先调用 ftok生成 key,然后semget创建信号量集,然后semctl初始化,步骤很多,而且参数分散,用起来很麻烦。
这时候建造者模式就派上用场了:建造者模式可以把复杂对象的构建和表示分离,让我们可以一步步构建对象,还能支持链式调用,让代码更清晰。
建造者模式的核心结构是这样的:

我们可以把信号量的创建步骤都封装在建造者里,对外只暴露简单的设置接口,最后调用 Build方法 就能得到一个可用的信号量对象。
(1)代码实现
我们来看看封装后的代码,首先是信号量的类,还有建造者:
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>
const std::string pathname = "/tmp";
int proj_id = 0x77;
// 标志位:获取还是创建
#define GET_SEM IPC_CREAT
#define BUILD_SEM (IPC_CREAT | IPC_EXCL | 0666)
// 信号量类,封装P/V操作和生命周期
class Semaphore
{
public:
Semaphore(int semid, int flag) : _semid(semid), _flag(flag) {}
// P操作:申请锁
void P()
{
struct sembuf sb;
sb.sem_num = 0;
sb.sem_op = -1;
sb.sem_flg = SEM_UNDO;
int n = ::semop(_semid, &sb, 1);
(void)n;
}
// V操作:释放锁
void V()
{
struct sembuf sb;
sb.sem_num = 0;
sb.sem_op = 1;
sb.sem_flg = SEM_UNDO;
int n = ::semop(_semid, &sb, 1);
(void)n;
}
~Semaphore()
{
if(_flag == GET_SEM) return;
// 如果是创建者,析构的时候自动删除信号量集
int n = ::semctl(_semid, 0, IPC_RMID);
(void)n;
std::cout << "sem set destroy!" << std::endl;
}
private:
int _semid;
int _flag;
};
using sem_sptr = std::shared_ptr<Semaphore>;
// 建造者类,用来构建信号量对象
class SemaphoreBuilder
{
public:
SemaphoreBuilder() : _val(-1) {}
// 链式调用:设置信号量的初始值
SemaphoreBuilder &SetVal(int val)
{
_val = val;
return *this;
}
// 构建信号量对象
sem_sptr Build(int flag)
{
// 合法性检查
if (_val < 0)
{
std::cerr << "you must init first!" << std::endl;
return nullptr;
}
// 1. 生成key
key_t k = ::ftok(pathname.c_str(), proj_id);
if (k < 0) exit(1);
// 2. 创建/获取信号量集
int semid = ::semget(k, 1, flag);
if (semid < 0) exit(2);
// 3. 如果是创建者,初始化信号量的初始值
if (BUILD_SEM == flag)
{
union semun un;
un.val = _val;
int n = ::semctl(semid, 0, SETVAL, un);
if (n < 0) exit(3);
}
// 4. 返回信号量对象
return std::make_shared<Semaphore>(semid, flag);
}
~SemaphoreBuilder() = default;
private:
int _val; // 信号量的初始值
};
(2)测试
我们来测试一下这个封装好的信号量,实现父子进程交替打印,保证输出不会乱序:
#include "Sem.hpp"
#include <cstdio>
#include <time.h>
#include <unistd.h>
int main()
{
SemaphoreBuilder sb;
// 创建一个初始值为1的信号量,也就是二元信号量,当做互斥锁
auto fsem = sb.SetVal(1).Build(BUILD_SEM);
if (fork() == 0)
{
// 子进程:获取信号量,打印C
auto csem = sb.Build(GET_SEM);
int cnt = 10;
while (cnt--)
{
csem->P();
printf("C");
fflush(stdout);
usleep(rand() % 95270);
printf("C ");
usleep(rand() % 43990);
fflush(stdout);
csem->V();
}
exit(0);
}
// 父进程:打印F
int cnt = 50;
while (cnt--)
{
fsem->P();
printf("F");
fflush(stdout);
usleep(rand() % 95270);
printf("F ");
usleep(rand() % 43990);
fflush(stdout);
fsem->V();
}
return 0;
}
运行之后,我们会发现输出的 CC和FF 都是成对出现的,不会出现交叉的情况,说明信号量的互斥作用生效了。
1.4 生命周期
这里要提醒大家一个很重要的点:System V 信号量的生命周期是随内核的!也就是说,就算你的进程退出了,信号量也不会自动删除,会一直留在内核里,直到你手动删除,或者系统重启。
所以我们可以用ipcs命令来查看系统里的信号量:
ipcs -s # 查看所有信号量
ipcrm -s <semid> # 删除指定的信号量
我们的封装里已经在析构函数里自动处理了删除的逻辑,不过如果你的进程意外崩溃,创建者没来得及析构,就需要手动用这个命令清理一下,避免资源泄露。
二、消息队列
说完了信号量,我们再来看看 System V 的另一个 IPC 机制:消息队列。消息队列可以让我们进程间传递有类型的数据块,比管道更灵活,支持按类型接收消息,还能实现全双工通信。
2.1 基础特性
消息队列的核心特点是:
-
每个消息都有一个类型,接收方可以根据类型选择性接收,不用按顺序读
-
自带同步机制,队列空的时候读会阻塞,队列满的时候写会阻塞
-
同样,生命周期也是随内核的
不过消息队列也有系统层面的限制,这些限制是新手很容易踩的坑,我们整理了 Linux 下的默认参数:
|
参数 |
含义 |
默认值 |
|
MSGMAX |
单条消息的最大字节数 |
8192 字节 |
|
MSGMNB |
单个消息队列的总字节上限 |
16384 字节 |
|
MSGMNI |
系统中消息队列的总数上限 |
1730(Ubuntu 默认) |
也就是说,你不能发超过 8k 的单条消息,一个队列里所有消息加起来也不能超过 16k,超过的话就会阻塞或者报错。
消息队列的通信形式很灵活,多个发送方可以给同一个队列发消息,多个接收方也可以根据自己的需求取不同类型的消息:

2.2 内核结构
在内核里,消息队列是用链表来组织的,每个队列都有一个msqid_ds的结构体来管理它的属性,里面记录了权限、时间、消息数量等信息,而消息本身则是一个链表节点,串在队列里。
内核里的结构大概是这样的:

对应的结构体定义是这样的:
struct msqid_ds {
struct ipc_perm msg_perm; // 权限信息
struct msg *msg_first; // 队列里的第一个消息
struct msg *msg_last; // 队列里的最后一个消息
__kernel_time_t msg_stime; // 最后一次发送消息的时间
__kernel_time_t msg_rtime; // 最后一次接收消息的时间
__kernel_time_t msg_ctime; // 最后一次修改的时间
unsigned short msg_cbytes; // 当前队列里的总字节数
unsigned short msg_qnum; // 当前队列里的消息数量
unsigned short msg_qbytes; // 队列的最大字节数
__kernel_ipc_pid_t msg_lspid; // 最后发送的进程pid
__kernel_ipc_pid_t msg_lrpid; // 最后接收的进程pid
};
2.3 原生接口
和信号量类似,消息队列也有四个核心的接口:
(1)msgget:创建 / 获取消息队列
int msgget(key_t key, int msgflg);
和semget逻辑差不多,根据 key 创建或者获取消息队列的标识符。
(2)msgctl:控制操作
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
用来设置属性、删除队列等,比如IPC_RMID就是删除队列。
(4)msgsnd:发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
发送消息的时候,消息的结构必须以long mtype开头,也就是消息类型,后面才是数据:
struct msgbuf {
long mtype; /* 消息类型,必须大于0 */
char mtext[1]; /* 消息数据 */
};
(5)msgrcv:接收消息
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
这里的msgtyp是消息队列最灵活的地方,它可以让我们选择性接收:
-
msgtyp=0:接收队列里的第一条消息,不管类型
-
msgtyp>0:接收队列里第一条类型等于msgtyp的消息
-
msgtyp<0:接收队列里类型小于等于msgtyp绝对值的最小的消息,这就可以实现简单的优先级队列
2.4 基础的封装
首先我们先做一个基础的封装,把消息队列的收发逻辑包装起来,实现客户端和服务端的通信:
#pragma once
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#define SIZE 1024
#define PATHNAME "/tmp"
#define PROJID 0x4321
#define CREATE_NEW_MSGQUEUE (IPC_CREAT | IPC_EXCL | 0666)
#define GET_MSGQUEUE (IPC_CREAT)
// 消息结构体
typedef struct
{
long mtype;
char mtext[SIZE];
} msg_t;
// 消息队列基类
class MsgQueueBase
{
public:
MsgQueueBase() = default;
bool BuildMsgQueue(int flg)
{
_key = ::ftok(PATHNAME, PROJID);
if (_key < 0) exit(1);
_msgid = ::msgget(_key, flg);
if (_msgid < 0) exit(2);
return true;
}
// 发送消息
bool SendMessage(const std::string &in, long type)
{
msg_t msg;
msg.mtype = type;
memset(msg.mtext, 0, sizeof(msg.mtext));
strncpy(msg.mtext, in.c_str(), in.size());
int n = ::msgsnd(_msgid, &msg, in.size(), 0);
if (n < 0) return false;
return true;
}
// 接收消息
bool RecvMessage(std::string *out, long type)
{
msg_t msg;
int n = ::msgrcv(_msgid, &msg, SIZE, type, 0);
if (n < 0) return false;
msg.mtext[n] = 0;
*out = msg.mtext;
return true;
}
// 删除消息队列
bool DeleteMsgQueue()
{
int n = ::msgctl(_msgid, IPC_RMID, nullptr);
return n == 0;
}
~MsgQueueBase() = default;
protected:
key_t _key;
int _msgid;
};
// 客户端:获取已有的队列
class MsgQueueClient : public MsgQueueBase
{
public:
MsgQueueClient()
{
bool res = MsgQueueBase::BuildMsgQueue(GET_MSGQUEUE);
(void)res;
}
};
// 服务端:创建新的队列
class MsgQueueServer : public MsgQueueBase
{
public:
MsgQueueServer()
{
bool res = MsgQueueBase::BuildMsgQueue(CREATE_NEW_MSGQUEUE);
(void)res;
}
~MsgQueueServer()
{
bool res = MsgQueueBase::DeleteMsgQueue();
(void)res;
}
};
#define SERVER 1
#define CLIENT 2
然后客户端和服务端的代码就很简单了:
// 客户端
#include "MsgQueue.hpp"
int main()
{
std::string msg = "hello bite";
MsgQueueClient mq;
mq.SendMessage(msg, CLIENT);
return 0;
}
// 服务端
#include "MsgQueue.hpp"
int main()
{
std::string msg;
MsgQueueServer mq;
mq.RecvMessage(&msg, CLIENT);
std::cout << "get message: " << msg << std::endl;
return 0;
}
这个基础版本可以实现简单的通信,但是如果我们有新的需求呢?
2.5 扩展消息处理
比如我们现在有了新的需求:
-
收到的消息,要加上时间戳和进程 pid,方便排查问题
-
处理后的消息要持久化保存到日志文件里
-
如果日志文件太大了,要自动备份打包,避免文件过大
如果我们把这些逻辑都硬编码到接收消息的函数里,那代码会越来越臃肿,而且如果我有时候不需要备份,有时候不需要加时间戳,改起来也很麻烦,耦合度太高了。
这时候责任链模式就派上用场了!责任链模式是一种行为设计模式,它可以让我们把请求的处理者连成一条链,每个处理者都可以选择自己处理这个请求,或者把它传给下一个处理者。这样就把发送者和接收者解耦了,每个处理者都是独立的,可以随时开关,也可以随时新增。
责任链模式的结构是这样的:


每个处理者都有一个指向下一个处理者的指针,请求过来的时候,先自己处理,然后如果有下一个,就传给下一个,这样就把每个处理步骤都解耦开了。
(1)代码实现
我们来实现这个责任链,首先是处理者的基类:
#pragma once
#include <iostream>
#include <filesystem>
#include <memory>
#include <unistd.h>
#include <sstream>
#include <fstream>
#include <ctime>
#include <sys/types.h>
#include <sys/wait.h>
// 处理者基类
class HandlerText
{
public:
HandlerText() : _enable(true) {}
virtual ~HandlerText() = default;
// 设置下一个处理者
void SetNextHandler(std::shared_ptr<HandlerText> handler)
{
_next_handler = handler;
}
// 开关:是否启用这个处理者
void Enable() { _enable = true; }
void DisEnable() { _enable = false; }
bool IsEnable() { return _enable; }
// 执行处理,子类实现
virtual void Execute(std::string &info) = 0;
protected:
std::shared_ptr<HandlerText> _next_handler;
bool _enable;
};
然后我们实现三个具体的处理者,分别对应我们的三个需求:
第一个:格式化处理,给消息加上时间和 pid:
// 格式化处理:加时间戳和pid
class HandlerTextFormat : public HandlerText
{
public:
HandlerTextFormat() = default;
void Execute(std::string &info) override
{
if (HandlerText::IsEnable())
{
std::cout << "Format ..." << std::endl;
// 拼接时间和pid
std::stringstream ss;
ss << time(nullptr) << " - " << getpid() << " - " << info << "\n";
info = ss.str();
sleep(1);
}
// 传给下一个处理者
if (_next_handler)
_next_handler->Execute(info);
else
std::cout << "责任链节点结束,处理完成" << std::endl;
}
};
第二个:保存到文件:
std::string defaultpath = "./tmp/";
std::string defaultfilename = "test.log";
// 保存到文件
class HandlerTextSaveFile : public HandlerText
{
public:
HandlerTextSaveFile() : _filepath(defaultpath), _filename(defaultfilename)
{
// 自动创建目录
if (std::filesystem::exists(_filepath))
return;
try
{
std::filesystem::create_directories(_filepath);
}
catch (std::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}
~HandlerTextSaveFile() = default;
void Execute(std::string &info) override
{
if (HandlerText::IsEnable())
{
std::cout << "Save ..." << std::endl;
sleep(1);
// 追加写入文件
const std::string file = _filepath + _filename;
std::ofstream out(file, std::ios::app);
if (!out.is_open())
return;
out << info;
out.close();
}
if (_next_handler)
_next_handler->Execute(info);
else
std::cout << "责任链节点结束,处理完成" << std::endl;
}
private:
std::string _filepath;
std::string _filename;
};
第三个:备份文件,当文件行数超过阈值的时候,自动打包备份:
const int maxline = 5; // 测试用的阈值,实际可以调大
// 备份处理:文件过大自动打包
class HandlerTextBackupFile : public HandlerText
{
public:
HandlerTextBackupFile() : _max_line_number(maxline),
_filepath(defaultpath), _filename(defaultfilename) {}
~HandlerTextBackupFile() = default;
void Execute(std::string &info) override
{
if (HandlerText::IsEnable())
{
std::cout << "Backup ..." << std::endl;
sleep(1);
const std::string filename = _filepath + _filename;
// 统计当前文件的行数
std::ifstream in(filename);
if (!in.is_open()) return;
int currentlines = 0;
std::string line;
while (std::getline(in, line))
{
currentlines++;
}
in.close();
// 超过阈值就备份
if (currentlines > _max_line_number)
{
std::cout << "消息行数超过" << _max_line_number << ", 触发日志备份" << std::endl;
Backup();
}
}
if (_next_handler)
_next_handler->Execute(info);
else
std::cout << "责任链节点结束,处理完成" << std::endl;
}
private:
// 备份逻辑:重命名然后打包
void Backup()
{
std::string newname = _filename + "." + std::to_string(time(nullptr));
pid_t id = fork();
if (id == 0)
{
chdir(_filepath.c_str());
// 重命名原文件
std::filesystem::rename(_filename, newname);
std::string tarname = newname + ".tgz";
// 子进程执行tar打包
execlp("tar", "tar", "czf", tarname.c_str(), newname.c_str(), nullptr);
exit(1);
}
waitpid(id, nullptr, 0);
// 删除原文件,只保留压缩包
std::string tempfile = _filepath + newname;
std::filesystem::remove(tempfile);
}
int _max_line_number;
std::string _filepath;
std::string _filename;
};
最后,我们做一个入口类,用来构建责任链,对外提供统一的调用接口:
class HandlerEntry
{
public:
HandlerEntry()
{
// 创建所有处理者
_format = std::make_shared<HandlerTextFormat>();
_save = std::make_shared<HandlerTextSaveFile>();
_backup = std::make_shared<HandlerTextBackupFile>();
// 把处理者连成链
_format->SetNextHandler(_save);
_save->SetNextHandler(_backup);
}
// 控制每个处理者的开关
void EnableHandler(bool isformat, bool issave, bool isbackup)
{
isformat ? _format->Enable() : _format->DisEnable();
issave ? _save->Enable() : _save->DisEnable();
isbackup ? _backup->Enable() : _backup->DisEnable();
}
// 执行处理
void Run(std::string &info)
{
_format->Execute(info);
}
private:
std::shared_ptr<HandlerText> _format;
std::shared_ptr<HandlerText> _save;
std::shared_ptr<HandlerText> _backup;
};
(2)优势
这样封装之后,我们的代码就变得非常灵活了:
-
每个处理步骤都是独立的,改格式化的逻辑不会影响保存,改备份的逻辑也不会影响其他的
-
可以随时开关某个步骤,比如我今天不需要备份,只需要调用
EnableHandler(true, true, false)就可以了,不用改任何代码 -
新增处理步骤也很简单,比如我要加一个过滤敏感词的处理,只需要新增一个
HandlerTextFilter的类,然后加到链里就可以了,完全符合开闭原则
这就是设计模式的魅力,把复杂的逻辑拆分成独立的模块,让代码更容易维护和扩展。
2.6 生命周期
和信号量一样,消息队列的生命周期也是随内核的,所以我们也可以用ipcs命令来管理:
ipcs -q # 查看所有消息队列
ipcrm -q <msgid> # 删除指定的消息队列
同样,我们的封装里也自动处理了删除的逻辑,不过如果进程崩溃了,还是需要手动清理一下。
三、总结
今天我们结合两个实践案例,把 System V IPC 的底层接口和设计模式结合了起来:
-
用建造者模式封装了信号量,把复杂的创建步骤隐藏起来,对外提供了简洁的链式调用接口,让信号量的使用变得简单
-
用责任链模式扩展了消息队列的处理能力,把消息的多个处理步骤解耦,让代码的扩展性大大提升
其实设计模式并不是只有在业务开发里才有用,在系统编程里,同样可以用设计模式来封装底层的复杂接口,让我们的代码更优雅、更容易维护。对于我们计算机专业的同学来说,把系统编程的知识和面向对象的设计思想结合起来,才能写出真正高质量的代码。
如果你在实践中遇到了 IPC 相关的问题,或者对设计模式的应用有其他的想法,欢迎在评论区交流~
更多推荐


所有评论(0)