【Linux】进程间通信(IPC)

🔥铅笔小新z:个人主页

🎬博客专栏:Linux学习

💫滴水不绝,可穿石;步履不休,能至渊。


目录

  1. 进程间通信介绍
  2. 管道概述
  3. 匿名管道
  4. 命名管道(FIFO)
  5. [System V 共享内存](#System V 共享内存)
  6. [System V 消息队列](#System V 消息队列)
  7. [System V 信号量](#System V 信号量)
  8. [内核管理 IPC 资源的方式](#内核管理 IPC 资源的方式)
  9. [附录: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 后共享管道的原理

  1. 父进程调用 pipe() 创建管道,获得两个文件描述符 fd[0](读端)和 fd[1](写端)

  2. 父进程调用 fork() 创建子进程,子进程继承父进程的文件描述符表

  3. 此时父子进程都拥有管道的读写两端

  4. 关键步骤:父子进程各自关闭不需要的一端------父进程关闭读端保留写端,子进程关闭写端保留读端(或反之),形成单向数据流

    复制代码
    父进程                            子进程
    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 管道特点

  1. 亲缘关系限制:只能用于具有共同祖先的进程之间通信(父子、兄弟等)
  2. 流式服务:管道是一个字节流,没有消息边界
  3. 生命周期随进程:进程退出,管道释放
  4. 内核同步互斥:内核会对管道操作进行同步与互斥保护
  5. 半双工:数据只能向一个方向流动;需要双向通信时,必须建立两个管道

3-8 验证管道通信的四种情况

  1. 读正常 && 写满:验证管道满时的阻塞行为
  2. 写正常 && 读空:验证管道空时的阻塞行为
  3. 写关闭 && 读正常:验证读端读取到 0(EOF)
  4. 读关闭 && 写正常:验证写端收到 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)
  • 需要消息边界和类型 → 消息队列
  • 需要同步/互斥 → 信号量 + 共享内存
  • 进程池/任务分发 → 管道(每个子进程一个管道)

相关推荐
极客先躯2 小时前
高级java每日一道面试题-2025年12月11日-实战篇[Docker]-如何配置 Docker 的资源限制(CPU、内存、磁盘)?
java·docker·如何配置docker的资源限制·资源限制的底层支柱·linux cgroups·cpu 限制·从逻辑到策略
WL_Aurora2 小时前
Shell编程从入门到实战
linux
汪汪大队u2 小时前
校园资源共享平台搭建与Shell自动化监控实战
运维·自动化
stanleyrain2 小时前
Windows 实现 Linux 风格“选中即复制,中键即粘贴”操作指南
linux·运维·windows
_Evan_Yao2 小时前
计算机大一新生如何选择方向(前端/后端/AI/运维)?
运维·前端·人工智能·后端
Elihuss2 小时前
关于RK3506 的MCU软复位后跑不起问题
linux·单片机·嵌入式硬件
小王C语言2 小时前
Linux给指定用户添加sudo权限
linux·运维·服务器
總鑽風2 小时前
单点登录sso 微服务加网关gateway
java·微服务·gateway·jwt·单点登录
IpdataCloud2 小时前
游戏安全运营中,如何用IP代理识别服务快速检测作弊网络出口?操作指南来了
运维·网络·tcp/ip·安全·游戏