〖Linux进程间通信〗IPC全家桶:匿名管道、FIFO、共享内存、消息队列与信号量一篇打通
🌌 进程间通信真正要解决的,其实从头到尾只有一个问题:
进程天生彼此独立,它们怎么才能看到同一份资源?
管道给它们一块共同访问的内核缓冲区;
共享内存直接把同一组物理页映射进多个进程;
消息队列让内核替进程保存一条条有边界的消息;
信号量甚至不负责传递数据,而是告诉多个进程:
“现在谁能动这份公共资源。”
把这条主线抓住以后,IPC 就不再是一堆零散接口,而是一套非常自然的设计。
📖 本文关键词
IPC·pipe·fork·FIFO·mkfifo·共享内存·ftok·shmget·shmat·消息队列·msgsnd·msgrcv·信号量·semget·semop·P/V操作
@TOC
1. 为什么需要进程间通信?
学习 IPC 之前,第一件事不是背:
pipe()
shmget()
msgget()
semget()
而是回答:
IPC 为什么会存在?
根本原因是:
1.1 进程具有独立性
Linux 中每个进程都有自己的:
进程地址空间
页表
代码区
数据区
堆
栈
...
例如两个进程:
Process A Process B
虚拟地址空间 A 虚拟地址空间 B
┌─────────────┐ ┌─────────────┐
│ Stack │ │ Stack │
│ Heap │ │ Heap │
│ Data │ │ Data │
│ Code │ │ Code │
└─────────────┘ └─────────────┘
A 修改自己的变量:
int x = 100;
B 不会突然看到:
x == 100;
因为:
两个进程拥有各自独立的虚拟地址空间。
这保证了进程之间的安全性和独立性,但同时带来了一个新的问题。
1.2 两个独立进程怎么交换数据?
既然:
进程 A 看不到进程 B 的地址空间
那么:
A 不能直接修改 B 的变量
B 也不能直接读取 A 的内存
所以如果我们希望:
Process A
│
│ "hello"
↓
Process B
必须想办法建立一个:
双方都能够访问的公共区域。
因此,进程间通信的核心可以压缩成一句话:
让不同进程看到同一份 OS 提供的公共资源
这份公共资源具体长什么样,就形成了不同的 IPC 方式。
2. IPC 的统一理解:公共资源不同,通信方式就不同
先不要急着研究接口,我们先看所有 IPC 的共同模型。
Process A
\
\
↓
公共资源
↑
/
/
Process B
例如:
匿名管道
→ 内核管道缓冲区
命名管道
→ 通过文件系统名字找到同一个内核管道
共享内存
→ 多个进程映射同一组物理页
消息队列
→ 内核维护的消息队列
信号量
→ 内核维护的资源计数器
所以真正变化的不是 IPC 的本质。
变化的是:
操作系统到底给这两个进程提供了什么公共资源。
下面沿着这条主线展开。
3. 匿名管道:父子进程之间最经典的通信方式
管道是 Linux IPC 中最经典的一种方式。
先看模型:
父进程
│
│ pipe()
↓
┌─────────────────┐
│ 内核管道缓冲区 │
└─────────────────┘
↑ ↓
读端 fd 写端 fd
它有两个文件描述符:
pipefd[0] → 读端
pipefd[1] → 写端
应用程序通过:
read()
write()
访问这个管道。
3.1 为什么叫“匿名管道”?
因为这种管道:
没有一个可以在文件系统中直接找到的路径名。
你执行:
ls
并不会看到:
my_pipe
这种文件。
它主要存在于内核中,并通过:
文件描述符
访问。
所以称为:
Anonymous Pipe —— 匿名管道。
3.2 pipe 接口
函数:
#include <unistd.h>
int pipe(int pipefd[2]);
成功:
返回 0
失败:
返回 -1
其中:
pipefd[0]
→ 管道读端
pipefd[1]
→ 管道写端
注意:
int pipefd[2];
pipe(pipefd);
调用之前:
pipefd 里面没有有效的管道 fd
调用之后,内核把两个 fd 写进去:
pipefd[0] = 某个 fd
pipefd[1] = 某个 fd
所以:
pipefd是一个输出型参数。
3.3 父子进程为什么能通过匿名管道通信?
这才是匿名管道最重要的原理。
如果两个完全不相关的进程:
Process A
Process B
一个进程自己:
pipe();
另一个进程根本不知道它创建的管道 fd。
所以匿名管道通常借助:
fork 后子进程继承父进程文件描述符。
典型顺序一定是:
pipe
↓
fork
↓
父子进程共同持有管道 fd
而不是:
fork
↓
父进程自己 pipe
假设父进程首先:
int pipefd[2];
pipe(pipefd);
此时:
父进程 fd 表
pipefd[0] ──→ 管道读端
pipefd[1] ──→ 管道写端
随后:
fork();
子进程会继承父进程已经打开的文件描述符。
因此:
内核管道
┌──────────┐
│ │
└──────────┘
↑ ↑
读端 写端
↑ ↑
┌────┴────┐ ┌────┴────┐
│ 父进程 │ │ 子进程 │
└─────────┘ └─────────┘
父子进程因此:
共同引用了同一个内核管道。
这就是它们能够通信的根本原因。
3.4 为什么必须关闭不需要的管道端?
假设我们希望:
父进程写
子进程读
那么正确做法通常是:
父进程:
关闭读端
保留写端
子进程:
关闭写端
保留读端
也就是:
Parent Child
close(pipefd[0]) close(pipefd[1])
pipefd[1] ──────→ PIPE ──────→ pipefd[0]
write() read()
这样就形成:
单向通信。
为什么不能两个端都留着?
不仅仅是“浪费两个 fd”。
后面的:
EOF
SIGPIPE
判断,都与:
还有没有进程持有管道某一端
密切相关。
这就引出了管道最重要的四种情况。
3.5 管道的四种经典情况
这四个一定要理解,面试非常喜欢问。
情况一:写端还在,但一直不写
读进程调用:
read(pipefd[0], ...);
如果:
管道没有数据
+
还有进程持有写端
那么:
read 阻塞
为什么?
因为内核知道:
还有写端存在,未来仍然可能有人写数据。
所以不能直接告诉读取者:
通信结束了
只能让它继续等待。
情况二:读端不读取,写端疯狂写
管道缓冲区不是无限大的。
假设:
PIPE
[xxxxxxxxxxxxxxxxxxxxxxxxxxxx]
↑
满了
如果:
写进程继续 write
+
读进程一直不读取
最终:
管道被写满
此时默认阻塞模式下:
write 阻塞
直到:
读进程取走一些数据
腾出空间。
于是前两种情况实际上告诉我们:
没有数据
→ read 等待
没有空间
→ write 等待
因此管道天然带有:
一定程度的同步机制。
3.6 情况三:所有写端关闭
假设:
所有持有管道写端的进程
全部:
close(pipefd[1]);
此时读取者继续:
read();
如果管道缓冲区里还有数据:
先把剩余数据读完
等所有数据都读完后:
read()
返回:
0
也就是:
EOF。
这是一个非常关键的判断:
ssize_t n = read(fd, buf, sizeof(buf));
if (n == 0)
{
// 对方所有写端都关闭了
}
为什么父子进程必须关闭多余的写端?
假设子进程负责读。
但子进程自己却一直没有关闭:
pipefd[1]
即使父进程退出:
系统依然发现:
还有一个写端存在!
这个写端恰好就在子进程自己手里。
于是:
read()
可能一直等不到 EOF。
所以:
关闭不需要的文件描述符不是“代码规范”,而是管道语义正确运行的必要条件。
3.7 情况四:所有读端关闭
反过来。
如果所有管道读端都被关闭:
没人能够读取这个管道
但是某个进程仍然执行:
write(pipefd[1], ...);
继续写数据已经没有意义。
所以内核会向写进程发送:
SIGPIPE
默认动作:
终止进程。
如果程序忽略或捕获了 SIGPIPE,对应的:
write()
通常会失败:
返回 -1
errno = EPIPE
所以:
读端全部关闭
↓
继续 write
↓
SIGPIPE
也是管道中非常经典的行为。
3.8 四种情况总结
① 管道为空,但仍存在写端
↓
read 阻塞
② 管道满了,但读端不读取
↓
write 阻塞
③ 所有写端关闭
↓
剩余数据读完以后
read 返回 0(EOF)
④ 所有读端关闭
↓
write
↓
SIGPIPE
前两个体现:
同步
后两个体现:
为什么必须正确关闭多余 fd
3.9 PIPE_BUF 和 PIPE_CAPACITY 千万不要混
管道还有两个经常被混为一谈的概念:
PIPE_BUF
和:
Pipe Capacity
它们不是一回事。
PIPE_CAPACITY
表示:
整个管道缓冲区能够保存多少数据。
也就是:
┌──────────────────────────────────┐
│ Pipe Buffer │
└──────────────────────────────────┘
←----------- Capacity ------------→
当管道被写满以后:
阻塞 write
PIPE_BUF
它解决的不是“整个管道有多大”。
它主要与:
多个写进程同时向管道写数据时的原子性保证
有关。
POSIX 对不超过:
PIPE_BUF
大小的一次管道写入提供原子写保证。
例如:
Process A 写:"AAAA"
Process B 写:"BBBB"
在满足对应条件时,不希望得到:
AABABABB
而是希望一条写操作作为整体进入管道。
所以必须记住:
PIPE_BUF
→ 原子写保证有关
PIPE_CAPACITY
→ 整个管道缓冲区容量
不要把两个概念混在一起。
3.10 ls | grep cpp 本质上也是管道
我们每天可能都在使用:
ls | grep cpp
中间这个:
|
就是匿名管道。
Shell 大体上会完成这样的工作:
PIPE
┌─────────┐
│ │
└─────────┘
↑ ↓
│ │
stdout stdin
↑ ↓
ls grep
Shell:
-
创建管道;
-
fork 出执行
ls的子进程; -
fork 出执行
grep的子进程; -
把
ls的标准输出重定向到管道写端; -
把
grep的标准输入重定向到管道读端。
其中就会用到我们前面文件系统学过的:
dup2();
大致关系:
ls
fd = 1
↓ dup2
管道写端
grep
fd = 0
↓ dup2
管道读端
于是:
ls 输出的数据
↓
PIPE
↓
grep 作为输入读取
这就是 Shell 管道背后的核心原理。
3.11 匿名管道最小完整代码
#include <sys/wait.h>
#include <iostream>
#include <cstring>
#include <unistd.h>
int main()
{
int pipefd[2];
if (pipe(pipefd) == -1)
{
perror("pipe");
return 1;
}
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
return 2;
}
else if (pid == 0)
{
// 子进程负责读,所以关闭写端
close(pipefd[1]);
char buf[1024];
ssize_t n =
read(pipefd[0], buf, sizeof(buf) - 1);
if (n > 0)
{
// read 读取的是裸字节,需要手动补 '\0'
buf[n] = '\0';
std::cout
<< "child get a message: "
<< buf
<< std::endl;
}
close(pipefd[0]);
_exit(0);
}
else
{
// 父进程负责写,所以关闭读端
close(pipefd[0]);
const char* msg = "hello child";
// write 不认识 C 字符串,
// 所以要明确告诉它真正写多少字节
write(pipefd[1], msg, strlen(msg));
// 写完关闭写端
close(pipefd[1]);
waitpid(pid, nullptr, 0);
}
return 0;
}
整个过程就是:
pipe
↓
fork
↓
父子继承相同管道 fd
↓
父关闭读端
子关闭写端
↓
父 write
↓
内核管道
↓
子 read
4. 命名管道 FIFO:没有血缘关系怎么办?
匿名管道存在一个明显问题:
pipe 创建以后
↓
双方得能够拿到对应 fd
父子进程可以利用:
fork 的 fd 继承
解决。
但是如果是两个完全独立启动的程序:
server
client
它们之间没有:
fork
关系。
那怎么办?
核心问题仍然是:
双方怎样找到同一份公共资源?
4.1 给管道一个名字
解决办法非常自然:
在文件系统中创建一个有路径的管道对象。
例如:
./myfifo
于是:
Server
↓
./myfifo
↑
Client
两个互不相关的进程只要约定:
大家都访问 "./myfifo"
就能够找到同一个管道。
这就是:
FIFO / Named Pipe —— 命名管道。
一个非常重要的区别
虽然 FIFO 在文件系统中有:
路径
但这并不意味着通信数据像普通文件那样被不断写入磁盘。
真正用于进程通信的数据仍然走:
内核中的管道机制
文件系统中的路径主要承担:
让两个无血缘关系的进程能够找到同一个管道对象。
所以可以理解成:
FIFO 路径
↓
负责“找到它”
真正通信数据
↓
内核管道
4.2 创建 FIFO
Shell 可以直接:
mkfifo myfifo
删除:
rm myfifo
程序中则可以使用:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char* pathname, mode_t mode);
例如:
mkfifo("./myfifo", 0666);
成功:
0
失败:
-1
这里:
0666
同样会受到:
umask
影响。
最终权限仍然遵循:
mode & ~umask
这一套规则。
4.3 FIFO 的生命周期
这一点和匿名管道区别很明显。
程序退出以后:
FIFO 路径不会自动从文件系统消失
所以如果创建:
./myfifo
之后不删除:
ls
仍然能够看到它。
可以使用:
unlink("./myfifo");
或者:
rm myfifo
删除路径。
因此常见服务器流程:
mkfifo
↓
open
↓
通信
↓
close
↓
unlink
4.4 open FIFO 时的阻塞规则
FIFO 一个非常值得记住的地方,就是:
open();
本身也可能阻塞。
O_RDONLY
open(path, O_RDONLY);
默认情况下:
等待某个写端打开 FIFO。
也就是:
读者来了
↓
没有写者
↓
等待
O_WRONLY
open(path, O_WRONLY);
默认情况下:
等待某个读端出现。
写者来了
↓
没有读者
↓
等待
所以:
FIFO 默认情况下
一端会等待另一端
这本身就是一种同步。
4.5 O_NONBLOCK 下有什么变化?
如果:
O_RDONLY | O_NONBLOCK
即使当前没有写端:
open 也可以立即成功
而:
O_WRONLY | O_NONBLOCK
如果当前根本没有读端:
open 立即失败
通常:
errno = ENXIO
还有一种比较特别的用法:
O_RDWR
Linux 上一个进程可以同时以读写方式打开 FIFO。
这往往可以:
立即成功
因为它自己同时拥有:
读端 + 写端
不过这更偏 Linux 行为,写实际程序时仍然要清楚自己为什么这么做,而不是为了“逃避阻塞”随手使用。
4.6 FIFO 打开以后,剩下和匿名管道非常像
一旦双方成功:
open()
并拿到 fd:
Server fd
Client fd
后面的:
read()
write()
本质上仍然是管道语义。
因此匿名管道中的:
四种读写情况
PIPE_BUF
Pipe Capacity
在 FIFO 中仍然非常重要。
这也说明:
匿名管道和命名管道最核心的区别,不在数据怎么传,而在双方怎么找到同一个管道。
匿名管道:
靠 fork 继承 fd
命名管道:
靠文件系统 pathname
4.7 FIFO Server
// server.cpp
#include <iostream>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cerrno>
int main()
{
const char* path = "./myfifo";
if (mkfifo(path, 0666) == -1)
{
// 已经存在则可以继续使用
if (errno != EEXIST)
{
perror("mkfifo");
return 1;
}
}
// 默认情况下会等待写端
int fd = open(path, O_RDONLY);
if (fd == -1)
{
perror("open");
return 2;
}
char buf[1024];
while (true)
{
ssize_t n =
read(fd, buf, sizeof(buf) - 1);
if (n > 0)
{
buf[n] = '\0';
std::cout
<< "server recv: "
<< buf
<< std::endl;
}
else if (n == 0)
{
// 所有写端关闭并且数据已经读完
break;
}
else
{
perror("read");
break;
}
}
close(fd);
// 删除 FIFO 路径
unlink(path);
return 0;
}
4.8 FIFO Client
// client.cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <fcntl.h>
int main()
{
const char* path = "./myfifo";
// 默认情况下会等待 FIFO 出现读端
int fd = open(path, O_WRONLY);
if (fd == -1)
{
perror("open");
return 1;
}
std::string msg;
while (std::getline(std::cin, msg))
{
write(fd, msg.c_str(), msg.size());
}
close(fd);
return 0;
}
现在即使两个程序分别从不同终端启动:
Terminal 1
./server
Terminal 2
./client
它们也可以借助:
./myfifo
找到同一个 IPC 资源。
5. 共享内存:把“搬数据”这件事直接省掉
接下来是 IPC 中非常重要的一种方式:
Shared Memory —— 共享内存。
先思考管道通信发生了什么。
大体可以理解为:
Process A 用户空间
↓
write
↓
内核中的管道缓冲区
↓
read
↓
Process B 用户空间
数据存在:
搬移过程
那么有没有更加直接的方法?
当然有。
5.1 直接让两个进程操作同一组物理页
我们以前学进程地址空间时已经知道:
虚拟地址
↓
页表
↓
物理内存
不同进程完全可以拥有不同的虚拟地址:
Process A Process B
0x7000 0x9000
│ │
└─────────┐ ┌──────────┘
↓ ↓
同一组物理页
于是:
Process A
写物理页
Process B 立刻就能通过自己的映射看到这份数据。
不需要:
A → 内核管道 → B
来回搬。
所以共享内存的核心就是:
把同一组物理页映射进多个进程的虚拟地址空间。
最终:
A 操作自己的虚拟地址
B 操作自己的虚拟地址
但实际上
↓
它们操作的是同一块物理内存
5.2 这也是为什么共享内存非常快
共享内存建立完成以后:
char* p = ...;
后续甚至可以直接:
strcpy(p, "hello");
另一个进程:
std::cout << p;
它不需要每次通信都重新调用:
write()
read()
完成数据搬运。
所以共享内存最大的特点就是:
数据交换非常直接,效率很高。
但是别急。
这种“太自由”的设计马上又会带来新的问题。
我们等接口讲完再回来。
6. System V IPC:key 和 id 到底是什么?
共享内存这里第一次遇到 System V IPC。
后面的:
共享内存
消息队列
System V 信号量
都有一个非常类似的模型。
它们需要解决:
两个完全独立的进程,怎样找到同一个内核 IPC 对象?
常用方式就是:
key
6.1 ftok:生成 key
接口:
#include <sys/ipc.h>
key_t ftok(const char* pathname, int proj_id);
例如:
key_t key = ftok("/tmp", 'S');
双方只要约定:
相同 pathname
+
相同 proj_id
就可以得到相同的 key,用它寻找同一份 IPC 资源。
需要注意:
ftok生成的 key 并不是“绝对不会碰撞的全球唯一 ID”。
它主要承担:
双方约定资源身份
的作用。
6.2 key 和 shmid 不是一个东西
共享内存中会同时遇到:
key
和:
shmid
不要混。
可以这样理解:
key
↓
查找 / 创建共享内存时使用
找到以后
↓
得到 shmid
shmid
↓
后续操作这个共享内存对象
也就是:
key
→ 找对象
shmid
→ 操作已经找到的对象
7. 共享内存四个核心接口
整个流程可以压成:
shmget
↓
得到 shmid
↓
shmat
↓
映射进入进程地址空间
↓
直接用指针访问
↓
shmdt
↓
解除映射
↓
shmctl IPC_RMID
↓
删除共享内存对象
下面一个一个看。
7.1 shmget:创建 / 获取共享内存
接口:
#include <sys/shm.h>
int shmget(key_t key,
size_t size,
int shmflg);
成功:
返回 shmid
失败:
返回 -1
例如:
int shmid =
shmget(key,
4096,
IPC_CREAT | 0666);
表示:
如果不存在
→ 创建
如果已经存在
→ 获取
IPC_CREAT | IPC_EXCL
如果使用:
IPC_CREAT | IPC_EXCL
则语义变成:
不存在
→ 创建
已经存在
→ 报错
这在服务器创建资源时非常常见。
例如:
int shmid =
shmget(key,
4096,
IPC_CREAT | IPC_EXCL | 0666);
这样服务器就能知道:
这块资源到底是不是我刚创建出来的。
7.2 size 为什么经常看到 4096?
例如:
shmget(key, 4096, ...);
因为内存底层以:
页
为基本管理单位。
很多常见 Linux 环境中页大小是:
4KB
也就是:
4096 Bytes
但不要把:
Linux 页一定等于 4096
当成永远成立的规则。
实际页大小应以具体系统为准。
例如可以查看:
getconf PAGESIZE
7.3 shmat:把共享内存映射到当前进程
有了:
shmid
还不够。
当前进程必须把共享内存:
挂接 / attach 到自己的虚拟地址空间。
接口:
void* shmat(int shmid,
const void* shmaddr,
int shmflg);
例如:
void* addr =
shmat(shmid, nullptr, 0);
成功:
返回映射后的虚拟地址
失败:
(void*)-1
所以判断失败时不要写:
if (addr == nullptr)
而应该判断:
if (addr == (void*)-1)
shmaddr = nullptr
表示:
让操作系统帮我们选择合适的虚拟地址。
一般直接这么使用即可。
shmflg = 0
表示正常读写挂接。
如果:
SHM_RDONLY
则以只读方式挂接。
7.4 shmat 以后就可以直接用指针操作
例如:
char* p = static_cast<char*>(addr);
之后:
strcpy(p, "hello client");
就已经在写共享内存。
另一个进程映射同一个共享内存以后:
std::cout << p;
就可以直接看到。
这就是共享内存与管道一个非常明显的区别:
管道
→ read / write
共享内存建立映射以后
→ 直接指针访问
7.5 shmdt:解除当前进程的映射
接口:
int shmdt(const void* shmaddr);
例如:
shmdt(addr);
这里最容易产生一个误解:
shmdt 是不是把共享内存删掉了?
不是。
它只是:
当前进程
X
共享内存
把当前进程与共享内存之间的映射解除。
也就是:
detach。
共享内存对象本身依然可能存在于内核中。
7.6 shmctl:真正控制共享内存对象
接口:
int shmctl(int shmid,
int cmd,
struct shmid_ds* buf);
最常见的删除:
shmctl(shmid,
IPC_RMID,
nullptr);
表示:
将共享内存对象标记删除。
这里注意“标记”两个字。
如果还有进程挂接在这块共享内存上:
shm_nattch > 0
Linux 不会粗暴地立即让这些进程手里的地址失效。
而是:
IPC_RMID
↓
标记删除
↓
等待所有进程 detach
↓
shm_nattch == 0
↓
真正销毁
这和文件系统中:
unlink
+
仍有进程持有打开文件
其实有一点相似的设计思想。
8. System V 共享内存的生命周期
共享内存还有一个必须掌握的特点:
它的生命周期随内核,而不是简单随创建它的进程。
也就是说:
Server 创建共享内存
↓
Server 崩了
共享内存不一定自动消失。
所以你重新运行程序可能看到:
shmget(... IPC_CREAT | IPC_EXCL ...)
失败:
File exists
因为上一次留下的共享内存对象还存在。
可以查看:
ipcs -m
删除:
ipcrm -m shmid
或者程序:
shmctl(shmid,
IPC_RMID,
nullptr);
因此 System V IPC 学习中要建立一个非常重要的意识:
程序退出
≠
IPC 对象一定销毁
9. 共享内存完整示例
Server
// server.cpp
#include <iostream>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
int main()
{
key_t key = ftok("/tmp", 'S');
if (key == -1)
{
perror("ftok");
return 1;
}
int shmid =
shmget(key,
4096,
IPC_CREAT | IPC_EXCL | 0666);
if (shmid == -1)
{
perror("shmget");
return 2;
}
void* addr =
shmat(shmid, nullptr, 0);
if (addr == (void*)-1)
{
perror("shmat");
return 3;
}
char* p =
static_cast<char*>(addr);
strcpy(p, "hello client");
std::cout
<< "server write: "
<< p
<< std::endl;
// 暂停一下,给 client 读取
std::cin.get();
shmdt(addr);
shmctl(shmid,
IPC_RMID,
nullptr);
return 0;
}
Client
// client.cpp
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
int main()
{
key_t key = ftok("/tmp", 'S');
if (key == -1)
{
perror("ftok");
return 1;
}
// 获取已有共享内存
int shmid =
shmget(key, 4096, 0666);
if (shmid == -1)
{
perror("shmget");
return 2;
}
void* addr =
shmat(shmid, nullptr, 0);
if (addr == (void*)-1)
{
perror("shmat");
return 3;
}
char* p =
static_cast<char*>(addr);
std::cout
<< "client read: "
<< p
<< std::endl;
shmdt(addr);
return 0;
}
整个链路:
Server Client
│ │
ftok ftok
│ │
└────────── same key ──────────┘
↓
Shared Memory
↑
┌────────┴────────┐
│ │
shmat shmat
│ │
↓ ↓
Virtual Addr A Virtual Addr B
│ │
└──── same physical pages ────┘
10. 共享内存这么爽,缺点是什么?
看起来共享内存非常完美:
不用反复 read/write
不用经过管道来回搬
直接指针操作
但是它恰好因为:
太自由了
导致了最大的问题。
假设:
Process A
Process B
同时操作:
*shared_data
A 正写到一半:
HEL...
B 就开始读取。
或者:
A 在修改结构
B 同时也在修改
那就可能产生竞争。
因此共享内存本身:
不提供同步和互斥。
也就是说:
Shared Memory
→ 解决“数据放哪里”
Semaphore
→ 解决“谁什么时候可以操作”
这就是为什么共享内存经常和:
信号量
一起出现。
但在研究信号量之前,我们还有一种非常重要的 IPC。
11. 消息队列:我想按“一条消息一条消息”通信
管道有什么特点?
本质是一串字节流
例如:
helloABC123...
数据本身没有天然告诉你:
hello 是一条消息
ABC 是下一条消息
123 又是一条消息
应用层通常还需要设计自己的:
消息协议 / 消息边界
共享内存则更加“裸”:
给你一块内存
自己随便操作
同步互斥都得自己解决。
于是就产生了另一种思路:
干脆让内核直接保存一条条消息。
这就是:
Message Queue —— 消息队列。
11.1 消息队列的本质
内核维护:
Message Queue
┌───────────────┐
│ Message 1 │
├───────────────┤
│ Message 2 │
├───────────────┤
│ Message 3 │
└───────────────┘
多个进程通过同一个队列:
Process A
↓
消息队列
↓
Process B
于是:
不同进程再次通过“看到同一份 OS 公共资源”实现通信。
11.2 消息队列相比管道最大的特点:消息有边界
管道:
byte byte byte byte byte...
消息队列:
Message 1
Message 2
Message 3
因此应用程序天然是:
发一条消息
收一条消息
而不是:
从一片连续字节流里自己重新切消息
11.3 消息还可以带 type
System V 消息队列中的消息通常长这样:
struct Message
{
long mtype;
char mtext[128];
};
第一个成员必须是:
long mtype;
而且:
mtype > 0
正文:
mtext
为什么要有 type?
因为它可以用于:
对消息分类。
例如:
type = 1
→ Client 发给 Server
type = 2
→ Server 发给 Client
于是同一个消息队列甚至可以实现:
双向通信
或者:
type = 1 → 用户 A
type = 2 → 用户 B
type = 3 → 用户 C
接收方可以只取:
自己感兴趣的消息类型
12. 消息队列核心接口
流程:
ftok
↓
msgget
↓
msgsnd / msgrcv
↓
msgctl
12.1 msgget:创建 / 获取消息队列
#include <sys/msg.h>
int msgget(key_t key,
int msgflg);
例如:
int msqid =
msgget(key,
IPC_CREAT | 0666);
成功:
返回 msqid
失败:
-1
这里与共享内存非常像:
key
→ 找对象
msqid
→ 操作对象
12.2 msgsnd:发送消息
接口:
int msgsnd(int msqid,
const void* msgp,
size_t msgsz,
int msgflg);
例如:
struct Message
{
long mtype;
char mtext[128];
};
Message msg;
msg.mtype = 1;
strcpy(msg.mtext, "hello");
msgsnd(msqid,
&msg,
strlen(msg.mtext) + 1,
0);
这里有一个非常非常容易错的点:
msgsz不包含 mtype 的大小。
假设结构:
struct Message
{
long mtype;
char mtext[128];
};
不能简单写:
sizeof(Message)
然后认为这就是正文大小。
msgsz 描述的是:
mtype 后面的消息正文大小
12.3 队列满了怎么办?
如果消息队列已经无法继续容纳消息:
Queue Full
默认情况下:
msgsnd()
会:
阻塞
直到有空间。
如果使用:
IPC_NOWAIT
则:
不等待
立即失败
12.4 msgrcv:接收消息
接口:
ssize_t msgrcv(int msqid,
void* msgp,
size_t msgsz,
long msgtyp,
int msgflg);
其中最值得学的是:
msgtyp
它决定:
到底取哪一种消息。
msgtyp > 0
例如:
msgtyp = 5;
表示:
找第一条
mtype == 5的消息。
msgtyp == 0
表示:
直接读取当前队列中的第一条消息。
也就是不关心类型。
msgtyp < 0
这个最容易记错。
例如:
msgtyp = -5;
此时会在:
mtype <= 5
的消息中:
找到满足条件的最小类型值,然后读取这一类型中最靠前的消息。
例如当前有:
mtype = 4
mtype = 2
mtype = 5
mtype = 2
调用:
msgrcv(..., -5, ...);
满足:
mtype <= 5
的都有。
其中最小类型为:
2
因此读取最前面的:
mtype = 2
消息。
12.5 没有符合条件的消息怎么办?
默认:
阻塞
直到对应消息到来。
如果使用:
IPC_NOWAIT
则立即失败。
还有一个非常重要的特点:
消息被成功 msgrcv
↓
从消息队列中移除
也就是说:
消息默认是“消费掉”的。
不是简单复制一份之后仍然留在那里。
12.6 msgctl:控制 / 删除消息队列
接口:
int msgctl(int msqid,
int cmd,
struct msqid_ds* buf);
删除:
msgctl(msqid,
IPC_RMID,
nullptr);
同样:
System V Message Queue
生命周期随内核。
因此创建者退出:
消息队列可能依然存在
可以查看:
ipcs -q
删除:
ipcrm -q msqid
12.7 消息队列还能实现时间上的解耦
假设通信要求是:
发送方发消息时
接收方必须正在那里等着
那么双方耦合会很严重。
消息队列提供了一定程度的:
时间解耦。
例如:
Process A
↓
send Message
↓
内核消息队列保存
↓
Process A 可以继续干别的事
过了一会儿
Process B
↓
从消息队列读取
发送者与接收者不必时时刻刻同时执行到同一个地方。
这也是队列模型在各种系统中如此常见的原因之一。
13. 终于来到信号量:它到底“通信”了什么?
很多人第一次学 IPC 时都有一个疑问:
管道传字符串,我理解。
共享内存传数据,我理解。
消息队列传消息,我也理解。
信号量连一串数据都不传,它凭什么算 IPC?
这个问题问得非常好。
因为:
进程间通信并不只意味着交换字符串。
不同执行流之间还需要:
协调
同步
互斥
也就是:
告诉另一个进程什么时候能够干什么。
这同样属于进程之间的信息交流。
13.1 为什么共享内存尤其需要信号量?
回到共享内存。
假设:
共享内存中有一个账户余额:
balance = 100
两个进程同时:
Process A
读取 100
Process B
读取 100
A:
+ 50
写回:
150
B:
- 30
写回:
70
最终:
balance = 70
但正确结果应该是:
120
问题就在于:
两个执行流同时操作了同一个共享资源。
因此我们需要某种机制告诉它们:
这块资源现在有人用了
你先等等
这就是信号量的用途之一。
13.2 信号量的本质:资源计数器
可以把信号量简单理解成:
资源数量的计数器。
例如:
sem = 1
表示:
当前有一个资源可以使用
某进程申请:
sem--
变成:
0
意味着:
资源被占用了
其他进程再申请:
没有资源
↓
等待
释放资源:
sem++
回到:
1
于是可以唤醒等待者。
13.3 P 操作和 V 操作
信号量中最经典的两个操作:
P
V
P 操作
含义:
申请资源。
可以粗略理解成:
sem--
如果没有资源:
等待
V 操作
含义:
归还资源。
粗略理解:
sem++
并且:
可能唤醒等待资源的进程
特别注意:
P/V 不是让程序自己
sem--、sem++。
如果是普通变量:
sem--;
操作可能被其他执行流打断。
那么信号量就失去意义了。
因此:
对信号量值的修改必须由内核提供原子操作。
这就是:
semop()
存在的原因。
14. 临界资源与临界区
信号量正式使用之前,还需要明确两个概念。
临界资源
多个执行流都能够访问,但是:
必须控制访问过程的共享资源。
例如:
共享内存
全局共享数据
共享文件
某个硬件资源
都可能成为临界资源。
临界区
程序中:
访问临界资源的那段代码。
例如:
// P
shared_data++;
// V
其中:
shared_data++;
就是临界区中的操作。
15. 互斥和同步不是一回事
这两个词非常容易被一起念成:
同步互斥
最后反而不知道区别。
互斥
强调的是:
同一时刻只能有一个执行流访问临界资源。
例如:
Process A
进入临界区
Process B
必须等
典型模型:
sem = 1
同步
强调的是:
控制多个执行流之间的执行顺序或者执行条件。
例如:
生产者先产生数据
↓
消费者才能消费
重点不是:
永远只有一个人进去
而是:
事情必须按照某种条件 / 顺序推进
所以:
互斥
→ 防止同时操作
同步
→ 控制先后关系 / 条件
16. System V 信号量
System V 中创建的其实不是单纯一个 semaphore,而是:
Semaphore Set —— 信号量集合。
因此接口:
semget()
里面需要告诉操作系统:
我要几个信号量
16.1 semget:创建 / 获取信号量集合
#include <sys/sem.h>
int semget(key_t key,
int nsems,
int semflg);
例如:
int semid =
semget(key,
1,
IPC_CREAT | 0666);
这里:
nsems = 1
表示:
这个集合中有一个信号量。
成功:
返回 semid
16.2 semctl:设置、查询和删除
接口:
int semctl(int semid,
int semnum,
int cmd,
...);
其中:
semid
→ 哪个信号量集合
semnum
→ 集合中的第几个信号量
注意:
semnum 从 0 开始
SETVAL:初始化
例如我们希望:
sem[0] = 1
严谨的 System V 写法一般会准备:
union semun
{
int val;
struct semid_ds* buf;
unsigned short* array;
};
然后:
union semun arg;
arg.val = 1;
semctl(semid,
0,
SETVAL,
arg);
这样完成初始化。
很多教学代码为了突出含义,会把它简写理解为:
SETVAL → 把信号量设为 1
真正写程序时应按照系统接口要求准备第四个参数。
GETVAL:查询
int value =
semctl(semid,
0,
GETVAL);
得到当前信号量值。
IPC_RMID:删除
semctl(semid,
0,
IPC_RMID);
删除整个信号量集合。
16.3 semop:真正执行 P / V
接口:
int semop(int semid,
struct sembuf* sops,
size_t nsops);
关键结构:
struct sembuf
{
unsigned short sem_num;
short sem_op;
short sem_flg;
};
sem_num
表示:
操作集合中的第几个信号量
例如:
sem_num = 0;
sem_op
这是核心。
sem_op < 0
表示:
申请资源。
例如:
sem_op = -1;
就是最典型的:
P 操作
如果资源不足:
默认阻塞等待
sem_op > 0
表示:
释放资源。
例如:
sem_op = +1;
也就是:
V 操作
sem_op == 0
这个比较特殊。
表示:
等待对应信号量的值变为 0。
不是 P,也不是 V。
16.4 sem_flg
最常见:
sem_flg = 0;
代表:
默认阻塞行为
也可以设置:
IPC_NOWAIT
表示:
条件不满足时不要等待
直接返回失败
还有:
SEM_UNDO
后面单独解释。
16.5 nsops 到底是什么意思?
semop(semid,
&op,
1);
最后这个:
1
就是:
sops数组中有几个操作。
例如:
struct sembuf ops[2];
那么:
semop(semid,
ops,
2);
表示一次提交两个信号量操作。
更重要的是:
System V 可以把这一组操作作为一个整体进行协调处理。
这也是它比简单地:
value--;
强很多的地方。
17. P/V 操作代码
P 操作
bool P(int semid)
{
struct sembuf op;
op.sem_num = 0;
op.sem_op = -1;
op.sem_flg = 0;
return semop(semid,
&op,
1) == 0;
}
这里:
sem_op = -1
表示:
申请一份资源
如果资源不存在:
阻塞
V 操作
bool V(int semid)
{
struct sembuf op;
op.sem_num = 0;
op.sem_op = +1;
op.sem_flg = 0;
return semop(semid,
&op,
1) == 0;
}
这里:
sem_op = +1
表示:
释放一份资源
于是保护共享内存时可以形成:
P
↓
进入临界区
↓
操作共享内存
↓
离开临界区
↓
V
代码思路:
P(semid);
// 临界区
shared_data->value++;
V(semid);
18. SEM_UNDO 是干什么的?
考虑一个问题。
进程:
P
↓
成功拿到资源
↓
sem = 0
然后还没来得及:
V
程序突然:
崩溃 / 异常退出
那怎么办?
信号量可能永远停留在一个不正确的状态。
所以 System V 提供:
SEM_UNDO
让内核为相关操作记录调整信息。
当进程退出时:
内核尝试对这些信号量变化进行恢复
从而降低:
进程异常死亡
→ 信号量永久丢失
的问题。
不过必须注意:
SEM_UNDO 不是“打开以后系统就百分之百永远不会出问题”的万能保险。
它存在:
系统限制
复杂语义
资源限制
等问题。
所以正确理解是:
它为进程退出场景提供一种内核辅助恢复机制,而不是替代整个并发正确性设计。
19. System V 信号量同样具有内核生命周期
创建:
Semaphore Set
以后:
创建者退出
对象并不会因此自动消失。
查看:
ipcs -s
删除:
ipcrm -s semid
程序中:
semctl(semid,
0,
IPC_RMID);
所以现在可以发现一个共同特点:
System V Shared Memory
System V Message Queue
System V Semaphore
基本都有:
key
↓
创建 / 获取
↓
得到 id
↓
操作
↓
显式 IPC_RMID
这一整套风格。
20. 为什么信号量也算 IPC?
现在可以正式回答这个问题。
IPC 全称:
Inter-Process Communication
这里的:
Communication
不能狭义理解为:
只有把
"hello world"从 A 发给 B 才算通信。
进程之间还可能需要传递:
资源是否可用
某件事情是否已经完成
什么时候可以继续
谁应该先执行
这些本质上也是:
进程之间的信息与状态协调。
因此:
管道
→ 传数据
共享内存
→ 共享数据
消息队列
→ 交换消息
信号量
→ 传递资源状态 / 实现协同
都属于 IPC。
21. 五种 IPC 放在一起看,就突然很清楚了
| IPC | 公共资源是什么 | 数据特点 | 是否天然有消息边界 | 同步特点 | 生命周期 |
|---|---|---|---|---|---|
| 匿名管道 | 内核管道缓冲区 | 字节流 | ❌ | read/write 有阻塞语义 | 随引用关系 |
| FIFO | 有名字的管道对象 | 字节流 | ❌ | 与管道类似 | 路径需显式删除 |
| 共享内存 | 多进程映射同一组物理页 | 直接内存访问 | 自己定义 | ❌ 本身不提供 | System V 对象随内核 |
| 消息队列 | 内核消息队列 | 一条条 message | ✅ | 队列空/满可阻塞 | System V 对象随内核 |
| 信号量 | 内核信号量集合 | 不主要传业务数据 | — | ✅ 专门解决同步/互斥 | System V 对象随内核 |
这张表不要死背。
真正要抓的是:
进程彼此独立
↓
必须找到一份公共资源
↓
公共资源形式不同
↓
产生不同 IPC
22. 再从“数据到底怎么走”看一遍
匿名管道 / FIFO
Process A
用户空间
↓
write
↓
内核管道
↓
read
↓
Process B
用户空间
共享内存
Process A VA ───┐
│
↓
Physical Pages
↑
│
Process B VA ───┘
双方直接访问同一组物理页。
消息队列
Process A
↓
msgsnd
↓
┌─────────────────┐
│ Kernel MsgQueue │
│ │
│ type + message │
│ type + message │
└─────────────────┘
↓
msgrcv
↓
Process B
信号量
Process A
↓
P
↓
Semaphore
↓
临界资源被占用
↑
Process B
↓
P
↓
等待
Process A
↓
V
↓
释放资源
↓
唤醒等待者
IPC 到这里已经变成一个完整体系,而不是几个 API 名字。
23. System V IPC 为什么总是长得这么像?
现在把三套 System V 接口放一起:
Shared Memory
ftok
↓
shmget
↓
shmat
↓
shmdt
↓
shmctl
Message Queue
ftok
↓
msgget
↓
msgsnd / msgrcv
↓
msgctl
Semaphore
ftok
↓
semget
↓
semop
↓
semctl
规律非常明显:
key
↓
xxxget
↓
得到 xxxid
↓
执行具体操作
↓
xxxctl
所以面试准备时,真没必要把所有 System V API 当成几十个毫无关系的函数死背。
它们本来就是同一种设计风格。
24. IPC 最值得记住的面试问题
最后把真正容易问的地方收一下。
① IPC 为什么存在?
因为:
进程具有独立性,各进程拥有自己的虚拟地址空间,因此需要借助操作系统提供的公共资源完成数据交换或协同。
② 匿名管道为什么通常用于父子进程?
因为:
pipe
↓
fork
之后子进程可以继承父进程的文件描述符,从而让父子进程共同引用同一个内核管道。
③ 为什么 pipe 一般要在 fork 之前创建?
因为需要:
fork 时
把已经创建好的管道 fd
继承给子进程
这样双方才能找到同一个管道。
④ 为什么管道必须关闭不用的端?
不仅为了节省 fd。
更重要的是:
所有写端是否关闭
→ 决定 read 能不能看到 EOF
所有读端是否关闭
→ 决定 write 是否触发 SIGPIPE
所以关闭多余描述符直接影响管道通信语义。
⑤ 管道的四种情况?
有写端但没数据
→ read 阻塞
有读端但管道满
→ write 阻塞
所有写端关闭
→ 数据读完后 read 返回 0
所有读端关闭
→ write 触发 SIGPIPE
⑥ PIPE_BUF 和管道容量区别?
PIPE_BUF
→ 原子写保证
Pipe Capacity
→ 整个管道缓冲区容量
⑦ 匿名管道和命名管道最大区别?
真正数据传输机制都属于管道模型。
主要区别在于:
匿名管道
→ 通常靠 fork 继承 fd 找到同一管道
FIFO
→ 靠文件系统 pathname 找到同一管道
⑧ FIFO 的数据真的写进磁盘文件了吗?
不是普通磁盘文件那种意义。
FIFO 的:
pathname
存在于文件系统中,主要用于:
定位管道对象。
真正通信的数据仍通过内核管道机制传输。
⑨ 共享内存为什么快?
因为把:
同一组物理页
映射进多个进程地址空间。
建立映射后,进程直接通过指针访问共享区域,避免了管道式的重复数据搬移。
⑩ 共享内存最大的缺陷?
本身不提供同步和互斥。
多个进程可以同时修改共享数据,所以通常还需要:
信号量
互斥锁
其他同步机制
配合。
⑪ key 和 shmid / msqid / semid 区别?
key
→ 查找 / 创建 IPC 对象时使用
id
→ 找到对象后,由内核返回,后续直接用它操作对象
⑫ shmdt 会删除共享内存吗?
不会。
shmdt
→ 当前进程解除映射
shmctl(... IPC_RMID ...)
→ 删除共享内存对象
是两件不同的事。
⑬ IPC_RMID 后为什么共享内存可能还存在?
如果仍然存在进程挂接:
shm_nattch > 0
Linux 会先标记删除。
等:
最后一个进程 detach
之后再真正销毁。
⑭ 消息队列和管道最大的区别?
管道主要是:
字节流
而消息队列:
以 message 为单位
天然保留消息边界,并且消息还可以拥有:
mtype
用于分类。
⑮ msgsnd 的 msgsz 包不包含 mtype?
不包含。
msgsz 表示:
mtype 后面正文部分的大小
⑯ msgrcv 的 msgtyp < 0 怎么理解?
如果:
msgtyp = -N;
则寻找:
mtype <= N
范围内:
类型值最小的消息类型中的第一条消息。
这个点非常容易记反。
⑰ 信号量的本质是什么?
可以理解为:
内核维护的资源计数器。
用于在多个执行流之间实现:
同步
互斥
⑱ P/V 分别是什么?
P
→ 申请资源
→ sem_op < 0
V
→ 释放资源
→ sem_op > 0
⑲ 为什么信号量操作必须是原子的?
因为信号量本身就是用于解决:
并发竞争
的。
如果修改信号量自身:
都会被并发破坏
那整个同步机制就失去意义了。
所以信号量核心操作必须由内核原子完成。
⑳ 为什么信号量也属于 IPC?
因为 IPC 不仅是:
传业务数据
还包括:
进程之间交换状态
控制执行顺序
完成同步与互斥
信号量正是在帮助不同进程进行协同。
25. 最后一张图,收掉整个 IPC
进程具有独立性
│
↓
不能直接访问对方地址空间
│
↓
怎么让不同进程发生联系?
│
↓
让不同进程看到同一份 OS 公共资源
│
┌─────────────────┼──────────────────┐
│ │ │
↓ ↓ ↓
管道 System V 其他 IPC
│ │
┌────┴────┐ ┌─────┼────────┐
↓ ↓ ↓ ↓ ↓
匿名管道 FIFO 共享内存 消息队列 信号量
│ │ │ │ │
│ │ │ │ └─同步 / 互斥
│ │ │ │
│ │ │ └─按消息交换
│ │ │
│ │ └─映射同一组物理页
│ │
│ └─文件系统路径找到管道
│
└─fork 继承 fd 找到管道
如果继续再压缩:
匿名管道
→ fork 继承 fd
FIFO
→ pathname
共享内存
→ shared physical pages
消息队列
→ message + type
信号量
→ resource counter
但这五句话背后的共同本质只有一句:
进程本来彼此独立,IPC 就是操作系统替它们创造一块双方都能认识的公共资源。
🌙 写在最后
IPC 这一章第一次看的时候特别容易产生一种感觉:
pipe 一个接口
mkfifo 又一个接口
shmget
shmat
shmdt
shmctl
msgget
msgsnd
msgrcv
msgctl
semget
semctl
semop
...
越背越多。
最后:
函数名全眼熟
实际啥关系不知道
但真正把底层逻辑串起来以后,会发现它根本没这么乱。
管道在解决:
我给你一条共同的字节通道
FIFO 只是进一步解决:
咱俩没有父子关系
怎么找到这条通道?
共享内存在解决:
干脆别搬数据了
咱俩直接看同一块物理内存
然后马上产生新问题:
那咱俩同时改怎么办?
于是信号量出现:
先告诉我谁有资格操作
消息队列则走了另一条路:
别给我一串没有边界的字节
我直接一条消息一条消息地传
所以 IPC 真正值得记住的不是:
十几个函数原型
而是:
进程独立
↓
需要公共资源
↓
公共资源如何被双方找到
↓
数据如何通过它交换
↓
多个进程同时访问时怎么协调
只要这条链通了,后面的接口只是实现手段。
🧭 操作系统真正有意思的地方,就是你以为自己在学几个函数,最后却发现它们全都是同一个设计思想的不同答案。
今天这一块,IPC 收官。
👀 关注:继续沿着 Linux 系统这棵大树往下挖
❤️ 点赞:如果这篇真的把 IPC 从“接口地狱”变成一条完整主线
⭐ 收藏:面试前重新顺一遍五种 IPC 的共同本质
💬 评论:如果还有哪个 IPC 机制感觉没彻底通,欢迎一起讨论不要背散装知识点。真正建立体系以后,很多东西根本不需要硬记。
🐾 今日份 Linux 练级完成
IPC 核心思想 ✅
匿名管道
├── pipe ✅
├── fork + fd 继承 ✅
├── 四种读写情况 ✅
├── SIGPIPE / EOF ✅
├── PIPE_BUF ✅
└── Shell 管道 ✅
命名管道 FIFO
├── mkfifo ✅
├── open 阻塞规则 ✅
├── O_NONBLOCK ✅
└── Server / Client ✅
共享内存
├── ftok ✅
├── shmget ✅
├── shmat ✅
├── shmdt ✅
├── shmctl ✅
└── 物理页共享原理 ✅
消息队列
├── msgget ✅
├── msgsnd ✅
├── msgrcv ✅
├── mtype ✅
└── msgctl ✅
System V 信号量
├── 临界资源 / 临界区 ✅
├── 同步 / 互斥 ✅
├── semget ✅
├── semctl ✅
├── semop ✅
├── P / V ✅
└── SEM_UNDO ✅
下一次看到 IPC,不应该再想到一堆 API,而应该先想到:
如何让两个独立进程看到同一份公共资源?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)