
🔥铅笔小新z:个人主页
🎬博客专栏:Linux学习
💫滴水不绝,可穿石;步履不休,能至渊。

目录
- 进程间通信介绍
- 管道概述
- 匿名管道
- 命名管道(FIFO)
- [System V 共享内存](#System V 共享内存)
- [System V 消息队列](#System V 消息队列)
- [System V 信号量](#System V 信号量)
- [内核管理 IPC 资源的方式](#内核管理 IPC 资源的方式)
- [附录:minishell 管道实现](#附录:minishell 管道实现)
1. 进程间通信介绍
1-1 进程间通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它们发生了某种事件(如进程终止时要通知父进程)
- 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
1-2 进程间通信发展
| 阶段 | 代表 |
|---|---|
| 管道 | 匿名管道 pipe、命名管道 FIFO |
| System V IPC | 消息队列、共享内存、信号量 |
| POSIX IPC | 消息队列、共享内存、信号量、互斥量、条件变量、读写锁 |
1-3 进程间通信分类
进程间通信 (IPC)
├── 管道
│ ├── 匿名管道 (pipe)
│ └── 命名管道 (FIFO)
├── System V IPC
│ ├── System V 消息队列
│ ├── System V 共享内存
│ └── System V 信号量
└── POSIX IPC
├── 消息队列
├── 共享内存
├── 信号量
├── 互斥量
├── 条件变量
└── 读写锁
2. 管道概述
管道是 Unix 中最古老的进程间通信形式。我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"。
管道的本质:管道在内核中是一个缓冲区(环形队列),通过文件描述符来访问,符合 Linux 的"一切皆文件"思想。
3. 匿名管道
3-1 pipe 函数
c
#include <unistd.h>
/*
* 功能:创建一个匿名管道
* 参数 fd:文件描述符数组
* fd[0]:读端(从管道读取数据)
* fd[1]:写端(向管道写入数据)
* 返回值:成功返回 0,失败返回 -1 并设置 errno
*/
int pipe(int fd[2]);
3-2 实例:从键盘读取数据写入管道,再读取管道写到屏幕
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
int fds[2]; // 文件描述符数组,fds[0]读端,fds[1]写端
char buf[100];
int len;
// 1. 创建匿名管道
if (pipe(fds) == -1) {
perror("make pipe");
exit(1);
}
// 2. 从标准输入循环读取数据
while (fgets(buf, 100, stdin)) {
len = strlen(buf);
// 3. 将数据写入管道写端
if (write(fds[1], buf, len) != len) {
perror("write to pipe");
break;
}
// 清空缓冲区
memset(buf, 0x00, sizeof(buf));
// 4. 从管道读端读取数据
if ((len = read(fds[0], buf, 100)) == -1) {
perror("read from pipe");
break;
}
// 5. 将读取的数据写入标准输出
if (write(1, buf, len) != len) {
perror("write to stdout");
break;
}
}
return 0;
}
3-3 fork 后共享管道的原理
-
父进程调用
pipe()创建管道,获得两个文件描述符fd[0](读端)和fd[1](写端) -
父进程调用
fork()创建子进程,子进程继承父进程的文件描述符表 -
此时父子进程都拥有管道的读写两端
-
关键步骤:父子进程各自关闭不需要的一端------父进程关闭读端保留写端,子进程关闭写端保留读端(或反之),形成单向数据流
父进程 子进程 fd[0]──┐ ┌──fd[0] │ │ fd[1]──┤ fork() 后 ├──fd[1] │ ─────────> │ │ │ 关闭 fd[0](读) 关闭 fd[1](写) 保留 fd[1](写) 保留 fd[0](读)
3-4 站在文件描述符角度理解管道
- 管道被创建时,内核会分配一个 inode 节点和两个文件对象(file 结构体),分别对应读端和写端
- 两个文件对象指向同一个 inode,inode 指向内核中的管道缓冲区(page 缓存)
- 每个文件对象有自己的 file 指针位置,但读写共享同一个缓冲区
fork()后,父子进程共享这些文件对象(引用计数增加)
3-5 站在内核角度理解管道本质
- 管道在内核中是通过环形队列实现的,通常大小为 4KB(一页)
- 内核使用
struct pipe_inode_info管理管道,包含读/写指针位置、等待队列等信息 - 读写操作会更新指针位置,当缓冲区满或空时,相应的进程会被放入等待队列
3-6 管道读写规则
| 场景 | O_NONBLOCK disable(默认) | O_NONBLOCK enable |
|---|---|---|
| 读空管道 | read() 阻塞,等待数据写入 |
read() 返回 -1,errno = EAGAIN |
| 写满管道 | write() 阻塞,等待数据被读取 |
write() 返回 -1,errno = EAGAIN |
| 写端关闭后读 | read() 返回 0(类似 EOF) |
read() 返回 0(类似 EOF) |
| 读端关闭后写 | 内核发送 SIGPIPE 信号,进程终止 |
内核发送 SIGPIPE 信号,进程终止 |
原子性保证:
- 写入数据量
<= PIPE_BUF(通常为 4096 字节)时,内核保证写入是原子操作 - 写入数据量
> PIPE_BUF时,内核不再保证原子性,可能与其他写操作交错
3-7 管道特点
- 亲缘关系限制:只能用于具有共同祖先的进程之间通信(父子、兄弟等)
- 流式服务:管道是一个字节流,没有消息边界
- 生命周期随进程:进程退出,管道释放
- 内核同步互斥:内核会对管道操作进行同步与互斥保护
- 半双工:数据只能向一个方向流动;需要双向通信时,必须建立两个管道
3-8 验证管道通信的四种情况
- 读正常 && 写满:验证管道满时的阻塞行为
- 写正常 && 读空:验证管道空时的阻塞行为
- 写关闭 && 读正常:验证读端读取到 0(EOF)
- 读关闭 && 写正常:验证写端收到 SIGPIPE 信号
3-9 实例:测试管道读写(父子进程通信)
c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
// 定义错误处理宏:打印错误信息并退出
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main(int argc, char *argv[])
{
int pipefd[2]; // 管道文件描述符
pid_t pid;
// 1. 创建匿名管道
if (pipe(pipefd) == -1)
ERR_EXIT("pipe error");
// 2. 创建子进程
pid = fork();
if (pid == -1)
ERR_EXIT("fork error");
if (pid == 0) {
// 子进程:关闭读端,只写
close(pipefd[0]); // 关闭读端
write(pipefd[1], "hello", 5); // 向管道写入数据
close(pipefd[1]); // 写入完毕,关闭写端
exit(EXIT_SUCCESS);
}
// 父进程:关闭写端,只读
close(pipefd[1]); // 关闭写端
char buf[10] = {0};
read(pipefd[0], buf, 10); // 从管道读取数据
printf("buf=%s\n", buf); // 输出:buf=hello
return 0;
}
3-10 实例:基于管道的进程池
Channel.hpp ------ 通信信道封装
cpp
#ifndef __CHANNEL_HPP__
#define __CHANNEL_HPP__
#include <iostream>
#include <string>
#include <unistd.h>
/*
* Channel 类:封装父进程与单个子进程之间的通信信道
* 每个 Channel 对应一个管道的写端和子进程的 PID
*/
class Channel
{
public:
/*
* 构造函数
* @param wfd 管道的写端文件描述符(父进程持有)
* @param who 子进程的 PID
*/
Channel(int wfd, pid_t who) : _wfd(wfd), _who(who)
{
// 生成信道名称,格式:Channel-{写端fd}-{子进程PID}
_name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
}
/* 获取信道名称 */
std::string Name() { return _name; }
/* 向子进程发送一个整数命令 */
void Send(int cmd)
{
::write(_wfd, &cmd, sizeof(cmd));
}
/* 关闭写端 */
void Close() { ::close(_wfd); }
/* 获取子进程 PID */
pid_t Id() { return _who; }
/* 获取写端文件描述符 */
int wFd() { return _wfd; }
~Channel() {}
private:
int _wfd; // 管道写端文件描述符
std::string _name; // 信道名称(用于调试)
pid_t _who; // 子进程 PID
};
#endif
Task.hpp ------ 任务管理
cpp
#pragma once
#include <iostream>
#include <unordered_map>
#include <functional>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>
// 任务类型:无参无返回的可调用对象
using task_t = std::function<void()>;
/*
* TaskManger 类:管理一组待执行的任务
*/
class TaskManger
{
public:
TaskManger()
{
srand(time(nullptr)); // 初始化随机数种子
// 注册四种不同类型的任务
tasks.push_back([]() {
std::cout << "sub process[" << getpid() << "] 执行访问数据库的任务\n"
<< std::endl;
});
tasks.push_back([]() {
std::cout << "sub process[" << getpid() << "] 执行url解析\n"
<< std::endl;
});
tasks.push_back([]() {
std::cout << "sub process[" << getpid() << "] 执行加密任务\n"
<< std::endl;
});
tasks.push_back([]() {
std::cout << "sub process[" << getpid() << "] 执行数据持久化任务\n"
<< std::endl;
});
}
/* 随机选择一个任务,返回任务索引 */
int SelectTask()
{
return rand() % tasks.size();
}
/* 根据任务编号执行对应任务 */
void Excute(unsigned long number)
{
if (number >= tasks.size())
return;
tasks[number]();
}
~TaskManger() {}
private:
std::vector<task_t> tasks; // 任务列表
};
// 全局任务管理器
TaskManger tm;
/*
* Worker 函数:子进程的工作循环
* 从标准输入(被 dup 为管道的读端)读取命令并执行对应任务
*/
void Worker()
{
while (true) {
int cmd = 0;
// 从管道读取任务编号(阻塞等待父进程发送数据)
int n = ::read(0, &cmd, sizeof(cmd));
if (n == sizeof(cmd)) {
tm.Excute(cmd); // 执行任务
} else if (n == 0) {
// 管道写端已关闭,退出
std::cout << "pid: " << getpid() << " quit..." << std::endl;
break;
}
// n < 0 则忽略错误,继续循环
}
}
ProcessPool.hpp ------ 进程池管理
cpp
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__
#include <iostream>
#include <string>
#include <vector>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <functional>
#include "Task.hpp"
#include "Channel.hpp"
using work_t = std::function<void()>;
// 错误码枚举
enum {
OK = 0,
UsageError,
PipeError,
ForkError
};
/*
* ProcessPool 类:管理一组工作子进程
* 父进程称为 master,子进程称为 worker
* master 通过管道向 worker 派发任务
*/
class ProcessPool
{
public:
ProcessPool(int n, work_t w)
: processnum(n), work(w)
{
}
/* 初始化进程池:创建 pipeline 和子进程 */
int InitProcessPool()
{
for (int i = 0; i < processnum; i++) {
// 1. 创建管道(每个子进程一个独立的管道)
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0) return PipeError;
// 2. 创建子进程
pid_t id = fork();
if (id < 0) return ForkError;
// 3. 建立通信信道
if (id == 0) {
// ----- 子进程代码 -----
// 关闭之前父进程中创建的其他管道的写端(子进程不需要)
for (auto &c : channels) {
c.Close();
}
// 关闭当前管道的写端,只保留读端
::close(pipefd[1]);
// 将管道读端重定向到标准输入
dup2(pipefd[0], 0);
// 执行工作函数(Worker)
work();
::exit(0);
}
// ----- 父进程代码 -----
// 关闭管道的读端,只保留写端
::close(pipefd[0]);
// 将写端包装为 Channel 对象存入 channels 列表
channels.emplace_back(pipefd[1], id);
}
return OK;
}
/* 派发任务给子进程 */
void DispatchTask()
{
int who = 0;
int num = 20; // 总共派发 20 个任务
while (num--) {
// a. 随机选择一个任务
int task = tm.SelectTask();
// b. 轮询选择一个子进程
Channel &curr = channels[who++];
who %= channels.size();
std::cout << "######################" << std::endl;
std::cout << "send " << task << " to " << curr.Name()
<< ", 任务还剩: " << num << std::endl;
std::cout << "######################" << std::endl;
// c. 发送任务编号
curr.Send(task);
sleep(1); // 模拟任务派发间隔
}
}
/* 清理进程池:关闭所有管道并回收子进程 */
void CleanProcessPool()
{
// 依次关闭每个信道并 wait 对应子进程
for (auto &c : channels) {
c.Close(); // 关闭管道写端,子进程的 read() 将返回 0,从而退出
pid_t rid = ::waitpid(c.Id(), nullptr, 0); // 阻塞等待子进程退出
if (rid > 0) {
std::cout << "child " << rid << " wait ... success" << std::endl;
}
}
}
/* 调试输出所有信道信息 */
void DebugPrint()
{
for (auto &c : channels) {
std::cout << c.Name() << std::endl;
}
}
private:
std::vector<Channel> channels; // 信道列表
int processnum; // 子进程数量
work_t work; // 子进程工作函数
};
#endif
Main.cc ------ 主入口
cpp
#include "ProcessPool.hpp"
#include "Task.hpp"
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " process-num" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2) {
Usage(argv[0]);
return UsageError;
}
int num = std::stoi(argv[1]); // 从命令行获取子进程数量
// 创建进程池
ProcessPool *pp = new ProcessPool(num, Worker);
// 1. 初始化进程池(创建管道和子进程)
pp->InitProcessPool();
// 2. 派发任务
pp->DispatchTask();
// 3. 回收进程池
pp->CleanProcessPool();
delete pp;
return 0;
}
Makefile
makefile
BIN=processpool
CC=g++
FLAGS=-c -Wall -std=c++11
LDFLAGS=-o
SRC=$(wildcard *.cc)
OBJ=$(SRC:.cc=.o)
$(BIN):$(OBJ)
$(CC) $(LDFLAGS) $@ $^
%.o:%.cc
$(CC) $(FLAGS) $<
.PHONY:clean
clean:
rm -f $(BIN) $(OBJ)
.PHONY:test
test:
@echo $(SRC)
@echo $(OBJ)
4. 命名管道(FIFO)
解决的问题:匿名管道只能用于具有亲缘关系的进程之间通信。若要在不相关的进程之间交换数据,可以使用 FIFO(命名管道)。
4-1 创建命名管道
命令行创建:
bash
$ mkfifo filename
程序中创建:
c
#include <sys/types.h>
#include <sys/stat.h>
/*
* 功能:创建命名管道(FIFO)
* 参数 filename:FIFO 文件名
* 参数 mode:权限模式(如 0644)
* 返回值:成功返回 0,失败返回 -1
*/
int mkfifo(const char *filename, mode_t mode);
示例:
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
int main(int argc, char *argv[])
{
// 创建一个名为 "p2" 的命名管道,权限 0644
mkfifo("p2", 0644);
return 0;
}
4-2 匿名管道与命名管道的区别
| 特性 | 匿名管道 (pipe) | 命名管道 (FIFO) |
|---|---|---|
| 创建方式 | pipe() 创建并打开 |
mkfifo() 创建,open() 打开 |
| 通信范围 | 仅限亲缘关系进程 | 任意进程 |
| 文件系统表示 | 无,仅在内核中存在 | 文件系统中的一个特殊文件(可用 ls -l 看到类型为 p) |
| 打开方式 | 直接获得 fd | 需要像普通文件一样 open |
| 使用语义 | 创建完成后语义相同 | 打开完成后语义与匿名管道相同 |
4-3 命名管道的打开规则
| 打开方式 | O_NONBLOCK disable | O_NONBLOCK enable |
|---|---|---|
| 只读打开 | 阻塞直到有进程为写打开该 FIFO | 立刻返回成功 |
| 只写打开 | 阻塞直到有进程为读打开该 FIFO | 立刻返回失败,errno = ENXIO |
4-4 实例1:用命名管道实现文件拷贝
进程1:读取文件,写入命名管道
c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
// 错误处理宏
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main(int argc, char *argv[])
{
// 1. 创建命名管道 "tp"
mkfifo("tp", 0644);
// 2. 打开源文件(只读)
int infd = open("abc", O_RDONLY);
if (infd == -1) ERR_EXIT("open");
// 3. 打开命名管道(只写)- 会阻塞直到读端打开
int outfd = open("tp", O_WRONLY);
if (outfd == -1) ERR_EXIT("open");
// 4. 从源文件读取数据,写入命名管道
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) {
write(outfd, buf, n);
}
// 5. 关闭文件描述符
close(infd);
close(outfd);
return 0;
}
进程2:读取管道,写入目标文件
c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main(int argc, char *argv[])
{
// 1. 打开目标文件(只写,不存在则创建,存在则截断)
int outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (outfd == -1) ERR_EXIT("open");
// 2. 打开命名管道(只读)- 会阻塞直到写端打开
int infd = open("tp", O_RDONLY);
if (infd == -1) ERR_EXIT("open");
// 3. 从命名管道读取数据,写入目标文件
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) {
write(outfd, buf, n);
}
// 4. 关闭文件描述符
close(infd);
close(outfd);
// 5. 删除命名管道文件
unlink("tp");
return 0;
}
4-5 实例2:用命名管道实现 Server & Client 通信
serverPipe.c ------ 服务端(读取)
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main()
{
umask(0); // 设置 umask,使创建的文件权限不受影响
// 1. 创建命名管道 "mypipe"
if (mkfifo("mypipe", 0644) < 0) {
ERR_EXIT("mkfifo");
}
// 2. 以只读方式打开管道(阻塞直到有写端打开)
int rfd = open("mypipe", O_RDONLY);
if (rfd < 0) {
ERR_EXIT("open");
}
// 3. 循环读取客户端消息
char buf[1024];
while (1) {
buf[0] = 0;
printf("Please wait...\n");
ssize_t s = read(rfd, buf, sizeof(buf) - 1);
if (s > 0) {
buf[s - 1] = 0; // 去掉末尾换行符
printf("client say# %s\n", buf);
} else if (s == 0) {
// 客户端已关闭写端
printf("client quit, exit now!\n");
exit(EXIT_SUCCESS);
} else {
ERR_EXIT("read");
}
}
close(rfd);
return 0;
}
clientPipe.c ------ 客户端(写入)
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main()
{
// 1. 以只写方式打开命名管道(阻塞直到有读端打开)
int wfd = open("mypipe", O_WRONLY);
if (wfd < 0) {
ERR_EXIT("open");
}
// 2. 从标准输入读取用户输入,写入管道
char buf[1024];
while (1) {
buf[0] = 0;
printf("Please Enter# ");
fflush(stdout);
ssize_t s = read(0, buf, sizeof(buf) - 1);
if (s > 0) {
buf[s] = 0; // 字符串结束
write(wfd, buf, strlen(buf)); // 发送给服务端
} else if (s <= 0) {
ERR_EXIT("read");
}
}
close(wfd);
return 0;
}
Makefile
makefile
.PHONY:all
all:clientPipe serverPipe
clientPipe:clientPipe.c
gcc -o $@ $^
serverPipe:serverPipe.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f clientPipe serverPipe
5. System V 共享内存
共享内存区是最快的 IPC 形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核------进程不再通过执行进入内核的系统调用来传递彼此的数据。
注意 :共享内存本身没有同步与互斥机制,缺乏访问控制,会带来并发问题。需要结合信号量等机制实现同步。
5-1 共享内存数据结构
c
struct shmid_ds {
struct ipc_perm shm_perm; /* 操作权限 */
int shm_segsz; /* 段大小(字节) */
__kernel_time_t shm_atime; /* 最后挂接时间 */
__kernel_time_t shm_dtime; /* 最后脱接时间 */
__kernel_time_t shm_ctime; /* 最后修改时间 */
__kernel_ipc_pid_t shm_cpid; /* 创建者 PID */
__kernel_ipc_pid_t shm_lpid; /* 最后操作者 PID */
unsigned short shm_nattch; /* 当前挂接数量 */
unsigned short shm_unused; /* 兼容性保留 */
void *shm_unused2; /* 兼容性保留 */
void *shm_unused3; /* 兼容性保留 */
};
5-2 共享内存函数
shmget ------ 创建/获取共享内存
c
#include <sys/ipc.h>
#include <sys/shm.h>
/*
* 功能:创建或获取一个共享内存段
* 参数 key:共享内存段的键值(类似文件名)
* 参数 size:共享内存段的大小(字节),建议为页大小(4096)的整数倍
* 参数 shmflg:权限标志
* IPC_CREAT:不存在则创建,存在则获取
* IPC_CREAT | IPC_EXCL:不存在则创建,存在则出错返回
* 低9位:权限模式(如 0666)
* 返回值:成功返回共享内存标识符(shmid),失败返回 -1
*/
int shmget(key_t key, size_t size, int shmflg);
shmat ------ 挂接共享内存
c
/*
* 功能:将共享内存段连接到进程的地址空间
* 参数 shmid:共享内存标识符
* 参数 shmaddr:指定连接地址,通常设为 NULL 让内核自动选择
* 参数 shmflg:
* SHM_RDONLY:只读挂接
* SHM_RND:与 shmaddr 配合使用,地址自动向下对齐
* 返回值:成功返回指向共享内存的指针,失败返回 (void*)-1
*/
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmaddr 说明:
shmaddr == NULL:内核自动选择合适的地址shmaddr != NULL && 无 SHM_RND:以 shmaddr 为连接地址shmaddr != NULL && 有 SHM_RND:地址向下调整为 SHMLBA 的整数倍:shmaddr - (shmaddr % SHMLBA)
shmdt ------ 脱接共享内存
c
/*
* 功能:将共享内存段从当前进程的地址空间脱离
* 参数 shmaddr:由 shmat 返回的指针
* 返回值:成功返回 0,失败返回 -1
* 注意:脱离不等于删除共享内存段
*/
int shmdt(const void *shmaddr);
shmctl ------ 控制共享内存
c
/*
* 功能:对共享内存执行控制操作
* 参数 shmid:共享内存标识符
* 参数 cmd:控制命令
* IPC_RMID:删除共享内存段
* IPC_SET:设置 shmid_ds 参数
* IPC_STAT:获取 shmid_ds 参数
* 参数 buf:用于存储/设置 shmid_ds 结构
* 返回值:成功返回 0,失败返回 -1
*/
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
5-3 实例1:共享内存实现通信(基础版)
文件结构:
.
├── comm.h // 公共头文件
├── comm.c // 公共函数实现
├── server.c // 服务端(创建共享内存,读取数据)
├── client.c // 客户端(获取共享内存,写入数据)
└── Makefile
comm.h ------ 公共头文件
c
#ifndef _COMM_H_
#define _COMM_H_
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "." // ftok 使用的路径
#define PROJ_ID 0x6666 // ftok 使用的项目 ID
int createShm(int size); // 创建共享内存
int destroyShm(int shmid); // 销毁共享内存
int getShm(int size); // 获取共享内存
#endif
comm.c ------ 公共函数
c
#include "comm.h"
/*
* commShm:内部函数,封装 shmget 的通用逻辑
* @param size 共享内存大小
* @param flags 打开标志(IPC_CREAT 等)
* @return shmid 或 -1
*/
static int commShm(int size, int flags)
{
// 使用 ftok 生成唯一键值
key_t key = ftok(PATHNAME, PROJ_ID);
if (key < 0) {
perror("ftok");
return -1;
}
int shmid = 0;
if ((shmid = shmget(key, size, flags)) < 0) {
perror("shmget");
return -2;
}
return shmid;
}
/* 销毁共享内存 */
int destroyShm(int shmid)
{
// IPC_RMID:标记共享内存段为删除
// 实际删除在所有进程 detach 后发生
if (shmctl(shmid, IPC_RMID, NULL) < 0) {
perror("shmctl");
return -1;
}
return 0;
}
/* 创建全新的共享内存(CREATE + EXCL) */
int createShm(int size)
{
return commShm(size, IPC_CREAT | IPC_EXCL | 0666);
}
/* 获取已有的共享内存(CREATE,不存在则创建) */
int getShm(int size)
{
return commShm(size, IPC_CREAT);
}
server.c ------ 服务端
c
#include "comm.h"
int main()
{
// 1. 创建共享内存(大小为 4096 字节)
int shmid = createShm(4096);
// 2. 将共享内存挂接到本进程地址空间
char *addr = shmat(shmid, NULL, 0);
sleep(2);
// 3. 循环读取共享内存中的数据并打印
int i = 0;
while (i++ < 26) {
printf("client# %s\n", addr);
sleep(1);
}
// 4. 脱接共享内存(非删除)
shmdt(addr);
sleep(2);
// 5. 销毁共享内存
destroyShm(shmid);
return 0;
}
client.c ------ 客户端
c
#include "comm.h"
int main()
{
// 1. 获取已创建的共享内存
int shmid = getShm(4096);
sleep(1);
// 2. 挂接共享内存
char *addr = shmat(shmid, NULL, 0);
sleep(2);
// 3. 向共享内存中写入字母序列 'A' ~ 'Z'
int i = 0;
while (i < 26) {
addr[i] = 'A' + i;
i++;
addr[i] = 0; // 写入字符串终止符,保证 server 端 printf 不出错
sleep(1); // 每 1 秒写入一个字符
}
// 4. 脱接共享内存
shmdt(addr);
sleep(2);
return 0;
// 注意:共享内存由 server 销毁,client 不销毁
}
Makefile
makefile
.PHONY:all
all:server client
client:client.c comm.c
gcc -o $@ $^
server:server.c comm.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f client server
常用 IPC 管理命令:
bash
# 查看系统中所有的共享内存段
$ ipcs -m
# 输出示例:
# ------ Shared Memory Segments --------
# key shmid owner perms bytes nattch
# 0x66026a25 688145 root 666 4096 0
# 手动删除某个共享内存段
$ ipcrm -m 688145
# 注意:IPC 资源不会随进程退出而自动释放
# 即使创建进程终止,共享内存仍然存在,直到被显式删除或系统重启
5-4 实例2:借助管道实现访问控制版的共享内存
单纯的共享内存没有同步机制,这里通过命名管道实现简单的访问控制(让进程执行产生一定的顺序性)。
Comm.hpp ------ 公共头文件
cpp
#pragma once
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cassert>
#include <cstdio>
#include <ctime>
#include <cstring>
#include <iostream>
using namespace std;
// 日志级别定义
#define Debug 0
#define Notice 1
#define Warning 2
#define Error 3
const std::string msg[] = {
"Debug", "Notice", "Warning", "Error"
};
/* 日志输出函数:输出时间戳 + 级别 + 消息 */
std::ostream &Log(std::string message, int level)
{
std::cout << " | " << (unsigned)time(nullptr) << " | "
<< msg[level] << " | " << message;
return std::cout;
}
#define PATH_NAME "/home/hyb" // ftok 路径
#define PROJ_ID 0x66 // ftok 项目 ID
#define SHM_SIZE 4096 // 共享内存大小(页的整数倍)
#define FIFO_NAME "./fifo" // 命名管道路径
/* Init 类:初始化命名管道,RAII 方式管理生命周期 */
class Init {
public:
Init() {
umask(0);
int n = mkfifo(FIFO_NAME, 0666);
assert(n == 0);
(void)n;
Log("create fifo success", Notice) << "\n";
}
~Init() {
unlink(FIFO_NAME);
Log("remove fifo success", Notice) << "\n";
}
};
#define READ O_RDONLY
#define WRITE O_WRONLY
/* 打开命名管道 */
int OpenFIFO(std::string pathname, int flags) {
int fd = open(pathname.c_str(), flags);
assert(fd >= 0);
return fd;
}
/* 关闭命名管道 */
void CloseFifo(int fd) {
close(fd);
}
/* 等待信号:阻塞读取一个 uint32_t */
void Wait(int fd) {
Log("等待中....", Notice) << "\n";
uint32_t temp = 0;
ssize_t s = read(fd, &temp, sizeof(uint32_t));
assert(s == sizeof(uint32_t));
(void)s;
}
/* 发送信号:写入一个 uint32_t */
void Signal(int fd) {
uint32_t temp = 1;
ssize_t s = write(fd, &temp, sizeof(uint32_t));
assert(s == sizeof(uint32_t));
(void)s;
Log("唤醒中....", Notice) << "\n";
}
/* 将 key_t 转换为 16 进制字符串 */
string TransToHex(key_t k) {
char buffer[32];
snprintf(buffer, sizeof buffer, "0x%x", k);
return buffer;
}
ShmServer.cc ------ 服务端
cpp
#include "Comm.hpp"
Init init; // 全局对象,构造函数中创建 FIFO
int main()
{
// 1. 创建公共的 Key 值
key_t k = ftok(PATH_NAME, PROJ_ID);
assert(k != -1);
Log("create key done", Debug) << " server key : " << TransToHex(k) << endl;
// 2. 创建全新的共享内存(通信的发起者)
int shmid = shmget(k, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);
if (shmid == -1) {
perror("shmget");
exit(1);
}
Log("create shm done", Debug) << " shmid : " << shmid << endl;
// 3. 挂接共享内存到本进程地址空间
char *shmaddr = (char *)shmat(shmid, nullptr, 0);
Log("attach shm done", Debug) << " shmid : " << shmid << endl;
// 4. 打开命名管道读端,等待 client 的信号
int fd = OpenFIFO(FIFO_NAME, O_RDONLY);
while (true) {
Wait(fd); // 阻塞等待 client 发出信号
printf("%s\n", shmaddr); // 临界区:读取共享内存
if (strcmp(shmaddr, "quit") == 0)
break;
}
CloseFifo(fd);
// 5. 脱接共享内存
int n = shmdt(shmaddr);
assert(n != -1);
(void)n;
Log("detach shm done", Debug) << " shmid : " << shmid << endl;
// 6. 删除共享内存(即使有其他进程挂接着,也会标记删除)
n = shmctl(shmid, IPC_RMID, nullptr);
assert(n != -1);
(void)n;
Log("delete shm done", Debug) << " shmid : " << shmid << endl;
return 0;
}
ShmClient.cc ------ 客户端
cpp
#include "Comm.hpp"
int main()
{
// 1. 创建公共的 Key 值
key_t k = ftok(PATH_NAME, PROJ_ID);
if (k < 0) {
Log("create key failed", Error)
<< " client key : " << TransToHex(k) << endl;
exit(1);
}
Log("create key done", Debug)
<< " client key : " << TransToHex(k) << endl;
// 2. 获取共享内存(使用 IPC_CREAT 但不带 EXCL)
int shmid = shmget(k, SHM_SIZE, 0);
if (shmid < 0) {
Log("create shm failed", Error) << " client key : " << TransToHex(k) << endl;
exit(2);
}
Log("create shm success", Error)
<< " client key : " << TransToHex(k) << endl;
// 3. 挂接共享内存
char *shmaddr = (char *)shmat(shmid, nullptr, 0);
if (shmaddr == nullptr) {
Log("attach shm failed", Error)
<< " client key : " << TransToHex(k) << endl;
exit(3);
}
Log("attach shm success", Error)
<< " client key : " << TransToHex(k) << endl;
// 4. 打开命名管道写端,写入数据后发信号通知 server
int fd = OpenFIFO(FIFO_NAME, O_WRONLY);
while (true) {
ssize_t s = read(0, shmaddr, SHM_SIZE - 1); // 从键盘读取输入
if (s > 0) {
shmaddr[s - 1] = 0; // 去掉末尾换行符
Signal(fd); // 通知 server 读取
if (strcmp(shmaddr, "quit") == 0)
break;
}
}
CloseFifo(fd);
// 5. 脱接共享内存
int n = shmdt(shmaddr);
assert(n != -1);
Log("detach shm success", Error)
<< " client key : " << TransToHex(k) << endl;
return 0;
}
6. System V 消息队列
选学了解即可
- 消息队列提供了一个从一个进程向另一个进程发送一块数据的方法
- 每个数据块都被认为有一个类型值,接收者可以按类型接收数据
- 特性:IPC 资源必须显式删除,否则不会自动清除(除非重启),生命周期随内核
7. System V 信号量
选学了解即可
7-1 并发编程概念铺垫
| 概念 | 说明 |
|---|---|
| 共享资源 | 多个执行流(进程)能看到的同一份公共资源 |
| 临界资源 | 被保护起来的共享资源 |
| 互斥 | 任何时刻,只允许一个执行流访问资源 |
| 同步 | 多个执行流访问临界资源时具有一定的顺序性 |
| 临界区 | 代码中访问临界资源的那部分代码 |
| 保护的本质 | 保护共享资源本质是保护访问共享资源的代码(临界区) |
7-2 信号量
| 维度 | 说明 |
|---|---|
| 理解 | 信号量是一个计数器 |
| 作用 | 保护临界区 |
| 本质 | 对资源的预订机制 |
| P 操作 | 申请资源,计数器减 1(semop(semid, &op, 1) 其中 op.sem_op = -1) |
| V 操作 | 释放资源,计数器加 1(semop(semid, &op, 1) 其中 op.sem_op = +1) |
| 特性 | IPC 资源必须显式删除,生命周期随内核 |
电影院例子的理解:
- 电影院座位数就是信号量的初始值
- 每个观众买票相当于 P 操作(座位数 -1)
- 观众离场相当于 V 操作(座位数 +1)
- 当座位数为 0 时,后来的观众需要等待(P 操作阻塞)
8. 内核是如何组织管理 IPC 资源的
9-1 内核中的 IPC 对象管理
内核使用 IDR(ID Radix Tree) 或类似机制来管理 IPC 对象(共享内存、消息队列、信号量)。每个 IPC 对象被分配一个唯一的标识符(shmid/msgid/semid)。
9-2 ipc_perm 结构
每个 IPC 资源都有一个 struct ipc_perm 结构:
c
struct ipc_perm {
key_t __key; /* 键值 */
uid_t uid; /* 拥有者 UID */
gid_t gid; /* 拥有者 GID */
uid_t cuid; /* 创建者 UID */
gid_t cgid; /* 创建者 GID */
unsigned short mode; /* 权限模式 */
unsigned short seq; /* 序列号 */
};
9-3 IPC 对象的生命周期
- System V IPC 资源的生命周期随内核(不是随进程)
- 进程退出时 IPC 资源不会自动释放
- 必须通过
shmctl(IPC_RMID)、msgctl(IPC_RMID)、semctl(IPC_RMID)显式删除 - 或者重启系统才能清除残留的 IPC 资源
- 使用
ipcs命令查看,ipcrm命令删除
10. 附录:minishell 管道实现
以下代码展示如何在 minishell 中添加管道支持(课堂不做说明,供有兴趣的同学参考):
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <ctype.h>
#define MAX_CMD 1024
char command[MAX_CMD];
char *pipe_command[32]; // 存储管道分隔后的各条命令
/* 读取用户输入的命令行 */
int do_face()
{
memset(command, 0x00, MAX_CMD);
printf("minishell$ ");
fflush(stdout);
// 读取一行输入(直到换行符)
if (scanf("%[^\n]%*c", command) == 0) {
getchar(); // 清除缓冲区中的换行符
return -1;
}
return 0;
}
/* 解析命令行参数(按空格分割) */
char **do_parse(char *buff)
{
int argc = 0;
static char *argv[32];
char *ptr = buff;
while (*ptr != '\0') {
if (!isspace(*ptr)) {
argv[argc++] = ptr; // 记录参数起始位置
// 跳过非空字符
while ((!isspace(*ptr)) && (*ptr) != '\0') {
ptr++;
}
continue;
}
*ptr = '\0'; // 将空白字符替换为字符串终止符
ptr++;
}
argv[argc] = NULL;
return argv;
}
/* 处理输出重定向 > 和 >> */
int do_redirect(char *buff)
{
char *ptr = buff, *file = NULL;
int redirect_type = -1, fd;
while (*ptr != '\0') {
if (*ptr == '>') {
*ptr++ = '\0';
redirect_type++; // > 为 0, >> 为 1
if (*ptr == '>') {
*ptr++ = '\0';
redirect_type++;
}
// 跳过空格
while (isspace(*ptr)) ptr++;
file = ptr;
// 找到文件名末尾
while ((!isspace(*ptr)) && *ptr != '\0') ptr++;
*ptr = '\0';
if (redirect_type == 0) {
fd = open(file, O_CREAT | O_TRUNC | O_WRONLY, 0664);
} else {
fd = open(file, O_CREAT | O_APPEND | O_WRONLY, 0664);
}
dup2(fd, 1); // 将标准输出重定向到文件
}
ptr++;
}
return 0;
}
/* 解析管道符 |,返回管道数量 */
int do_command(char *buff)
{
int pipe_num = 0;
char *ptr = buff;
pipe_command[pipe_num] = ptr;
while (*ptr != '\0') {
if (*ptr == '|') {
pipe_num++;
*ptr++ = '\0';
pipe_command[pipe_num] = ptr;
continue;
}
ptr++;
}
pipe_command[pipe_num + 1] = NULL;
return pipe_num;
}
/* 执行管道命令 */
int do_pipe(int pipe_num)
{
int pid = 0, i;
int pipefd[10][2] = {{0}};
char **argv = {NULL};
// 1. 为每个管道段创建 pipe
for (i = 0; i <= pipe_num; i++) {
pipe(pipefd[i]);
}
// 2. 为每个管道段创建子进程
for (i = 0; i <= pipe_num; i++) {
pid = fork();
if (pid == 0) {
// 子进程执行命令
do_redirect(pipe_command[i]); // 处理重定向
argv = do_parse(pipe_command[i]); // 解析参数
if (i != 0) {
// 不是第一条命令:从上一个管道读
close(pipefd[i][1]);
dup2(pipefd[i][0], 0);
}
if (i != pipe_num) {
// 不是最后一条命令:写入下一个管道
close(pipefd[i + 1][0]);
dup2(pipefd[i + 1][1], 1);
}
execvp(argv[0], argv); // 执行命令
exit(1); // execvp 失败则退出
}
}
// 父进程关闭所有管道并等待子进程
for (i = 0; i <= pipe_num; i++) {
close(pipefd[i][0]);
close(pipefd[i][1]);
waitpid(pid, NULL, 0);
}
return 0;
}
int main(int argc, char *argv[])
{
int num = 0;
while (1) {
if (do_face() < 0)
continue; // 输入为空,继续
num = do_command(command); // 解析管道数量
do_pipe(num); // 执行管道命令
}
return 0;
}
总结对比
| IPC 方式 | 通信类型 | 是否有亲缘关系要求 | 同步机制 | 生命周期 |
|---|---|---|---|---|
| 匿名管道 | 字节流 | 是(父子/兄弟) | 内核自动同步 | 随进程 |
| 命名管道 | 字节流 | 否 | 内核自动同步 | 随进程(文件需手动删除) |
| 共享内存 | 内存共享 | 否 | 无(需自己加锁) | 随内核(需手动删除) |
| 消息队列 | 消息(有类型) | 否 | 内核自动同步 | 随内核 |
| 信号量 | 同步控制 | 否 | N/A(本身就是同步工具) | 随内核 |
性能对比
- 共享内存 > 管道 > 消息队列
- 共享内存是最快的 IPC,因为数据直接从发送方的用户空间到接收方的用户空间,无需内核介入
- 管道和消息队列需要在内核空间和用户空间之间拷贝数据
选择指南
- 简单通信、有亲缘关系 → 匿名管道
- 无亲缘关系、字节流 → 命名管道(FIFO)
- 高性能、大数据量 → 共享内存(System V)
- 需要消息边界和类型 → 消息队列
- 需要同步/互斥 → 信号量 + 共享内存
- 进程池/任务分发 → 管道(每个子进程一个管道)
