【Linux】进程间通信

发布于:2025-09-12 ⋅ 阅读:(16) ⋅ 点赞:(0)

一.进程间通信介绍

简称IPC(Inter-Process Communication)

  • 是什么?

进程间通信是两个或者多个进程实现数据层面交互,因为进程独立性存在所以通信是有成本的

  • 进程间通信的目的

数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变

  • 进程间通信分类

1.管道:
匿名管道pipe
命名管道
2.System V IPC:(适用本机内部的标准)
System V 消息队列
System V 共享内存
System V 信号量
3.POSIX IPC:(适用网络通信的标准)
消息队列
共享内存
信号量
互斥量
条件变量
读写锁

  • 进程间通信的共识性原理

1.进程间通信的本质:让不同的进程看到同一份资源
2.资源指的是特定形式的内存空间,一般由操作系统提供。为什么不是通信间进程的其中一个提供呢?假设一个进程提供,这个资源虽然被共享,但本质还是属于提供的进程,会破坏进程的独立性,所以需要操作系统提供第三方空间
3.进程访问这个第三方空间进行通信,本质就是访问操作系统,进程代表的就是用户,因为群众中可能有坏人所以操作系统不可能直接允许用户访问内存并进行操作,所以这个第三方空间资源从创建、使用到释放,都需要系统调用接口
4.从底层设计、接口设计都需要操作系统独立设计,一般操作系统会有独立的通信模块(隶属于文件系统),通信模块是有标准的,才允许这么多不同硬件和操作系统的设备间进行通信,上述进程间通信分类就是定制的标准

二.匿名管道

  • 什么是管道

管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”

  • 管道的由来

父进程创建子进程时,会给子进程拷贝一份内核数据结构如task_struct和files_struct,而文件不会被拷贝,而是通过files_struct来共享,父子进程可以通过共享的文件来进行通信,只是会设计磁盘的IO过程,那么就想到可以创建一个内核级文件,不与磁盘交互来实现进程间的通信,这就是管道,管道的本质是文件,它与其他文件不同的一点是不需要inode属性来做区分,但和其他文件一样拥有file_operators(声明IO方法)和文件页缓冲区,实现信息传输功能,管道也会像其他文件一样被父子进程共享

1.通过fork和文件描述符来理解管道

在这里插入图片描述
在这里插入图片描述

  • 子进程只能继承父进程的读写模式,所以父进程对管道不能只读或只写,要在文件描述符中创建读端和写端,这样父子进程都能实现读写的通信操作,图中fd数组是调用pipe函数时传入的数组fd[0]是这个数组的下标0,代表读端,而不是系统内核文件描述符数组的下标0,fd是自定义数组跟内核文件描述符表fd_array无关
  • 管道只能进行单向通信,也就是父子进程一个读一个写,若同时读或同时写还需要区分是谁写的就更复杂了,在进行通信时父子进程都必须关掉一端,实现一读一写的管道通信
  • 如果想要进行双向通信,父子进程间可以使用多个管道,能进行匿名管道通信的进程之间必须要有血缘关系,这样管道才能被有血缘关系的进程继承共享2,比如父子关系、兄弟关系,爷孙关系

2.系统接口

管道是操作系统提供的内核资源,用户必须通过系统调用接口来访问。
在这里插入图片描述

  • pipefd[0]代表读下标,pipefd[1]代表写下标,参数为输出型参数可以返回结果(文件描述符)供用户使用
  • 管道是有大小的,一般为4kb文件页大小,不同内核里大小可能有差别

3.代码实现

  • testPipe.cc
#include <iostream>
#include <stdio.h>
#include <string>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define N 2
#define NUM 1024

using namespace std;

void writer(int wfd)
{
    string s="hello world";
    pid_t self=getpid();
    int number=0;

    char buffer[NUM];
    while(true)
    {
        sleep(1);
        // 构建发送字符串
        buffer[0] = 0; // 字符串清空, 只是为了提醒阅读代码的人,我把这个数组当做字符串了
        snprintf(buffer,sizeof(buffer),"%s-%d-%d",s.c_str(),self,number++);//构建格式化消息
        cout<<buffer<<endl;
        //发送给父进程
        write(wfd,buffer,strlen(buffer));
        if(number>=5) break;
    }
}

void reader(int rfd)
{
    char buffer[NUM];
    while(true)
    {
        buffer[0]=0;
        ssize_t n=read(rfd,buffer,sizeof(buffer));
        if(n>0)
        {
            buffer[n]=0;
            cout<<"father get a message["<<getpid()<<"]#"<<buffer<<endl;
        }
        else if(n==0)
        {
            printf("father read file done\n");
        }
        else break;
    }
}

int main()
{
    int pipefd[N]={0};
    int n =pipe(pipefd);
    if(n<0) return 1;
    //child->w,father->r
    pid_t id=fork();
    if(id<0) return 2;
    if(id==0)
    {
        //child
        close(pipefd[0]);//关闭读端,只写
        //IPC code
        writer(pipefd[1]);
        //进程间通信结束后可手动关闭,系统也会自动关闭
        close(pipefd[1]);
        exit(0);
    }
    //father
    reader(pipefd[0]);
    pid_t rid=waitpid(id,nullptr,0);//回收子进程
    if(rid<0) return 3;

    close(pipefd[0]);
    return 0;
}
  • makefile
testPipe:testPipe.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f testPipe

4.管道的特点

1.具有血缘关系的进程进行进程间通信
2.管道只能单向通信
3.父子进程是会进程协同的、同步与互斥的
4.管道是面向字节流的:

管道仅作为字节的传输通道,不关心数据的含义、格式或边界,仅保证字节的顺序性和完整性。这种特性使得管道使用灵活(适用于任意数据类型),但也要求通信双方必须提前约定数据解析规则,否则会出现数据混乱

读取的原子性问题:
为了确保读取数据的完整性,比如子进程想写一个hello world到管道,结果刚把hello写完就给父进程读走了,数据不完整了,为了解决这样的问题,引入了PIPE_BUF,可以通过ulimit指令(用于查看操作系统对很多重要资源的限制)查看其大小,pipe_size为PIPE_BUF的大小,实际管道的大小不同内核会有差别,当写入数据小于P[IPE_BUF时就要保证读取的原子性,即保证数据的完整性
在这里插入图片描述

  • 验证管道大小
while(true)
    {
        char c='c';
        write(wfd,&c,1);
        number++;
        cout<<number<<endl;
        //if(number>=5) break;
    }

将代码实现中writer部分的循环代码改成一次输入一个字符并计数,不读取,这样等管道填满时的计数就是管道总比特位数,打印结果为65536比特位,也就是64KB,不同于ulimit中规定管道的大小为4KB,验证了不同内核管道大小可能有差别
在这里插入图片描述
通过man 7 pipe指令可以查看系统手册,有明确说明在内核多少版本后管道大小发生改变

  • 管道中通信的四种情况

1.读写端正常,管道如果为空,读端就要堵塞
2.读写端正常,管道如果被写满,写端就要堵塞
3.读端正常,写端关闭,读端就会读到0,表明读到了文件pipe的结尾,不会被阻塞
4.写端正常写入,读端关闭了。操作系统通过13号信号杀掉正在写入的进程
在这里插入图片描述

5.管道的应用场景:进程池

  • 回顾之前|符号的使用

在这里插入图片描述
通过重定向,实现进程间匿名管道的单向读写通信,将标准输入或输出重定向为管道的读端写端,前一个指令的输入作为后一个指令的输出。

  • 使用匿名管道实现一个简易的进程池

为什么要进程池,因为每次fork等系统调用是有成本的,可以一次性根据需求创建部分进程来等待进程间通信

5.1Task.hpp

typedef void (*task_t)();

void task1()
{
    cout<<"刷新屏幕使用时长"<<endl;
}
void task2()
{
    cout<<"输出内存使用情况"<<endl;
}
void task3()
{
    cout<<"检查更新状态"<<endl;
}
void task4()
{
    cout<<"更新错误日志"<<endl;
}

void LoadTask(vector<task_t> *tasks)
{
    tasks->push_back(task1);//函数名本身为地址
    tasks->push_back(task2);
    tasks->push_back(task3);
    tasks->push_back(task4);
}

文件.hpp后缀代表函数的声明和实现放在同一文件中。
1.
typedef定义了一个无参数无返回值的函数指针,即符合void func() 形式的函数。
作用:统一所有任务函数的 “接口”,让不同的任务函数可以被相同类型的指针引用,便于批量管理。
2.
LoadTask中用vector批量存储函数指针,像管理普通数据一样管理函数。通过索引tasks[i]就能循环调用所有任务函数。使函数可以像普通变量一样被传递

5.2ProcessPool.cc文件

.cc文件后缀与.cpp一样用于标识以 C++ 语法编写的程序源代码文件,告诉编译器因用那种语言的编译器。注意后缀本身不决定语言特性,仅为 “约定俗成的标识”

  • 头文件与全局变量
#include"Task.hpp"
#include<string>
#include<vector>
#include<cstdlib>
#include<ctime>
#include<cassert>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/wait.h>

const int processnum=10;//进程池大小
vector<task_t> tasks;//任务列表,存储任务函数,task_t 是函数指针类型,定义在 Task.hpp 中,指向 “无参数、无返回值” 的函数

关于头文件后缀.h与开头的c,以time.h和ctim举例,<time.h> 是C语言中标准头文件,ctime 是其在 C++ 中的 别名,功能上完全兼容 <time.h>,只是遵循 C++ 的头文件命名规范。写C++代码时优先, 是 C++ 标准推荐的风格,避免潜在的兼容性问题

  • 多进程下进程间通信的信息封装类
//先描述
class channel
{
public:
    channel(int cmdfd,int slaverid,string &processname)
    :_cmdfd(cmdfd),
    _slaverid(slaverid),
    _processname(processname)
    {}
public:
    int _cmdfd;         //发送任务的文件描述符
    pid_t _slaverid;    //子进程的pid
    string _processname;//子进程的名字-方便打印日志
};

channel 类不属于通用的 “管道模板”,而是针对当前多进程任务调度场景设计的 “管道通信信息封装类”,主要作用是记录父进程与某个子进程通信所需的全部关键信息,相当于为每个子进程建立一个 “通信档案”,方便父进程管理和操作多个子进程。
与模板区分:若存在,因该是通用的管道操作工具,目标是 “提供可复用的管道操作接口”,与具体业务场景解耦,任何需要管道通信的程序都能直接使用。

  • 子进程的工作逻辑
void slaver()
{
    while(true)
    {
    // 用于存储父进程发送的任务索引(0-3,对应4个任务)
    // 从标准输入(已被重定向为管道读端)读取数据
    // 参数:0(标准输入)、存储数据的地址、读取的字节数(int类型占4字节)
        int cmdcode=0;
        int n=read(0,&cmdcode,sizeof(int));//如果父进程不给子进程发数据就阻塞等待
        if(n==sizeof(int))
        {
            //执行cmdcode对应的任务列表
            cout<<"slaver say@ get a command: "<<getpid()<<" cmdcode: "<<cmdcode<<endl;
            // 检查任务索引是否合法(在tasks列表范围内)
            // 执行对应的任务函数(通过函数指针调用)
            if(cmdcode>=0&&cmdcode<tasks.size()) tasks[cmdcode](); 
        }
        if(n==0) break;
    }
}

负责持续阻塞等待父进程发送的任务指令,并在收到指令后执行对应的任务,实现 “接收指令→执行任务” 的循环工作模式,直到父进程通知退出。

  • 进程池的初始化
void InitProcessPool(vector<channel> *channels)
{
    for(int i=0;i<processnum;i++)
    {
        int pipefd[2];//临时空间
        int n=pipe(pipefd);
        assert(!n);//演示检查管道打开失败,注意只能在debug版本中使用
        (void)n;//显式处理未使用变量 n 以避免编译警告。

        pid_t id=fork();
        if(id==0){
            //子进程
            close(pipefd[1]);//关闭不使用的端口
            dup2(pipefd[0],0);//进行重定向,将管道读端(pipefd[0])绑定到标准输入(0),之后子进程从管道中读取数据=直接从标准输入中读
            close(pipefd[0]);// 重定向后,原管道读端(pipefd[0])已无用,关闭
            slaver();//子进程执行命令操作
            std::cout << "process : " << getpid() << " quit" <<endl;//执行完后退出语句
            exit(0);
        }
        //父进程
        close(pipefd[0]);//关闭不用的端
        //容器中添加新的channel
        // 为子进程创建名称(如process-0、process-1)
        string name= "process-" + to_string(i);
        channels->push_back(channel(pipefd[1],id,name));
        // 休眠1秒:避免子进程创建过快导致资源竞争或日志混乱
        sleep(1);
    }
}

通过循环创建 10 个子进程,为每个子进程单独创建匿名管道用于父子通信,并将管道写端、子进程 PID 等信息封装成 channel 对象存入容器,完成进程池的初始化。

  • 问题

这样的初始化存在问题,在程序运行时输入0无法正常退出,
在这里插入图片描述
导致无法按子进程创建顺序正常退出,正常退出是写端关闭,子进程读到管道结尾0就会退出,但现在问题是除了最后一个被创建的子进程只有一个写端可以正常退出,其他都有多个写端,父进程所对应的写端退出后子还有继承下去的子进程写端,无法正常退出,只能从后往前依次退出子进程,这样从后往前子进程所链接的写端也依次退出

  • 优化更改版
void InitProcessPool(vector<channel> *channels)
{
    vector<int> oldfds;//确保每个子进程只有一个写端
    for(int i=0;i<processnum;i++)
    {
        int pipefd[2];//临时空间
        int n=pipe(pipefd);
        assert(!n);//演示检查管道打开失败,注意只能在debug版本中使用
        (void)n;//显式处理未使用变量 n 以避免编译警告。

        pid_t id=fork();
        if(id==0){
            //子进程
            cout<<"child: "<<getpid()<<" close history fd: ";
            for(auto fd:oldfds){
                cout<<fd<<" ";
                close(fd);
            }
            cout<<"\n";

            close(pipefd[1]);//关闭不使用的端口
            dup2(pipefd[0],0);//进行重定向,目的是不用从管道中读取数据直接从标准输入中读
            close(pipefd[0]);
            slaver();//子进程执行命令操作
            std::cout << "process : " << getpid() << " quit" <<endl;//执行完后退出语句
            exit(0);
        }
        //父进程
        close(pipefd[0]);//关闭不用的端
        oldfds.push_back(pipefd[1]);//添加继承下去的写端
        //容器中添加新的channel
        string name= "process-" + to_string(i);
        channels->push_back(channel(pipefd[1],id,name));
        sleep(1);
    }
}

在这里插入图片描述
图示运行证明之前的重复写端问题存在,创建一个oldfds来在父进程执行期间收集继承下去的重复写端,每次创建子进程后先进行删除oldfds元素再执行自己任务就可以正常退出

  • 调试辅助函数
void Debug(const std::vector<channel> &channels)
{
    // test
    for(const auto &c :channels)
    {
        std::cout << c._cmdfd << " " << c._slaverid << " " << c._processname << std::endl;
    }
}

用于打印输出 channels 容器中所有 channel 对象的信息,方便开发者查看进程池的初始化状态和子进程通信通道的关键参数

  • 任务调度函数
void Menu()
{
    std::cout << "################################################" << std::endl;
    std::cout << "# 1. 刷新日志             2. 刷新出来野怪        #" << std::endl;
    std::cout << "# 3. 检测软件是否更新      4. 更新用的血量和蓝量  #" << std::endl;
    std::cout << "#                         0. 退出               #" << std::endl;
    std::cout << "#################################################" << std::endl;
}

void ctrlSlaver(const std::vector<channel> &channels)
{
    int which = 0;// 用于轮询选择子进程的索引
    while(true) // 无限循环,直到用户选择退出
    {
        int select = 0;
        Menu();
        std::cout << "Please Enter@ ";
        std::cin >> select;

        if(select <= 0 || select >= 5) break;
        // select > 0&& select < 5
        // 1. 选择任务
        // int cmdcode = rand()%tasks.size();
        int cmdcode = select - 1;

        // 2. 选择进程
        // int processpos = rand()%channels.size();

        std::cout << "father say: " << " cmdcode: " <<
            cmdcode << " already sendto " << channels[which]._slaverid << " process name: " 
                << channels[which]._processname << std::endl;
        // 3. 发送任务
        write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode));

        which++;
        which %= channels.size();//避免子进程索引超出范围

        // cnt--;
        // sleep(1);
    }
}

Menu 函数负责用户交互界面,清晰展示可执行的任务;
ctrlSlaver 函数负责任务调度逻辑,通过 “用户输入→任务索引转换→子进程轮询→指令发送” 的流程,将用户选择的任务分配给不同子进程执行。

  • 可采取随机值的方式选择进程和任务

int cmdcode = rand()%tasks.size();
int processpos = rand()%channels.size();该变量对应轮询方法中的which索引

  • 进程池资源清理函数
void QuitProcess(const std::vector<channel> &channels)
{//只有进程池初始化函数的优化版本可用
    for(const auto &c : channels){
        close(c._cmdfd);
        waitpid(c._slaverid, nullptr, 0);
    }
    // version1 逆序关闭并等待,初始化有bug的版本可用
    // int last = channels.size()-1;
    // for(int i = last; i >= 0; i--)
    // {
    //     close(channels[i]._cmdfd);
    //     waitpid(channels[i]._slaverid, nullptr, 0);
    // }


    // for(const auto &c : channels) close(c._cmdfd);
    // // sleep(5);批量关闭后批量等待,初始化有bug的版本可用
    // for(const auto &c : channels) waitpid(c._slaverid, nullptr, 0);
    // // sleep(5);
}
  • main函数
int main()
{// 关键:必须在创建子进程前加载任务,确保子进程复制到完整的tasks列表
    LoadTask(&tasks);

    srand(time(nullptr)^getpid()^1023); // 种一个随机数种子,用于随机选择任务和进程
    // 在组织
    std::vector<channel> channels;
    // 1. 初始化 --- bug?? -- 找一下这个问题在哪里?然后提出一些解决方案!
    InitProcessPool(&channels);
    // Debug(channels);

    // 2. 开始控制子进程
    ctrlSlaver(channels);

    // 3. 清理收尾
    QuitProcess(channels);
    return 0;
}

将各功能函数封装实现,展现清晰的逻辑

  • 控制参数传递

输入型参数:const &(常量引用)

不拷贝数据:
直接引用实参,避免大对象拷贝的性能消耗(如 vector、string 或自定义类)。
只读不可改:
const 修饰确保函数内部不能修改 param,从而保护原变量不被意外修改。
可接收临时对象:
可以直接传递字面量或表达式结果(如 func(10)、func(a + b))。

输出型参数:*(指针)

传递参数的 “指针”—— 本质是指向原变量内存地址的变量,通过指针间接访问和修改原变量。注意指针可以指向 nullptr(空指针),需在函数内判断有效性,避免崩溃。作为 “输出型参数”,用于将函数内部的结果传递到函数外部(如需要返回多个值时)

输入输出型参数:&(引用)

可以理解为原变量的 “别名”,对引用的操作等同于直接操作原变量。作为 “输入输出型参数”—— 既需要读取原变量的值(输入),又需要修改原变量(输出)

三. 命名管道

  • 没有血缘关系的进程可以通过命名管道来进行进程间通信。两个不同的进程打开同一个文件时,在内核中操作系统会打开一个文件,为每个进程创建pcb和文件描述符表,在内核中的管道存在与链接情况,与匿名管道相同都是单向管道

  • 同一路径下同一文件名能确定两个没有血缘关系的进程打开的是同一文件,印证了进程间通信的前提:先让不同进程看到同一份资源

  • 匿名管道与命名管道区别
    匿名管道由pipe函数创建并打开。
    命名管道由mkfifo函数创建,打开用open
    FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

1.处理可变参数

  • va系列函数解析
    都定义在C语言标准库中<stdarg.h>头文件中
  • va_liist:
    本质:
    1.是一个类型定义(通常是 char* 或类似的指针类型),用于声明一个 “可变参数列表指针”,该指针将指向栈上可变参数的存储位置。
    2.栈上参数大致分布如下:举例
    在这里插入图片描述
    va_list 的作用就是指向栈上这些可变参数的起始位置,并在后续访问时不断移动,从而逐个取出参数。va_list 本身不直接操作参数,它需要配合 va_start、va_arg、va_end 这三个函数才能工作,而这三个函数的第一个参数必须是 va_list 类型的指针变量。
  • va_start(ap, last)
    1.功能:初始化可变参数列表指针 ap,使其指向第一个可变参数的位置
    2.参数:
    ap:va_list 类型的指针(即前面声明的变量)。
    last:函数参数列表中 “最后一个确定的参数”(即可变参数 … 之前的那个参数,如代码中的 n)。
    3.工作原理:通过 last 的地址和类型,计算出第一个可变参数在栈上的地址,并赋值给 ap
  • va_arg(ap, type)
    1.功能:从可变参数列表中取出下一个参数,并自动更新 ap 指针,使其指向下一个参数。
    2.参数:
    ap:va_list 类型的指针(已通过 va_start 初始化)。
    type:要取出的参数的数据类型(如 int、double 等)。
    3.返回值:取出的参数值(类型为 type)
    4.注意:type 必须与实际传入的参数类型完全匹配(否则会导致内存访问错误)
  • va_end(ap)
    1.功能:清理可变参数列表指针 ap(通常会将其置为 NULL),避免指针悬空,是使用可变参数的必要收尾操作
    2.原理:在某些平台上,va_start 可能会分配临时资源,va_end 用于释放这些资源,确保安全。
  • 注意:

C 语言的可变参数本质是 “直接操作栈内存”,而栈内存没有 “参数边界” 的天然标记。因此:
数量决定了 “该读多少个参数”,避免多读 / 少读导致的无效内存访问;
类型决定了 “每个参数读多少字节”,避免错位读取导致的连锁错误。
通常用0来当作结束标注来控制可变参数数量

  • 代码实例
int sum(int n,...)
{
    va_list s;//实际为char*类型指针
    va_start(s,n);
    int sum=0;
    while(n)
    {
        sum+=va_arg(s,int);
        n--;
    }
    va_end(s);
    return sum;
}

在这里插入图片描述
通过打印可得结果分别为9、5、2

2.代码实现:

-2.1common.hpp

#pragma once

#include<iostream>
#include<string>
#include<cerrno>
#include<cstring>
#include<cstdlib>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#define FIFO_FILE "./myfifo"//固定匿名文件绝对路径
#define MODE 0664

enum{
     FIFO_CREATE_ERR = 1, // FIFO 创建失败的错误码
	 FIFO_DELETE_ERR,     // FIFO 删除失败的错误码(自动递增为 2)
     FIFO_OPEN_ERR        // 预留:FIFO 打开失败的错误码(自动递增为 3)
};

class Init
{
public:
    Init()
    {
        int n=mkfifo(FIFO_FILE,MODE);
        if(n==-1)
        {
            perror("mkfifo");
            exit(FIFO_CREATE_ERR);       
        }
    }

    ~Init()
    {
        int m=unlink(FIFO_FILE);
        if(m==-1)
        {
            perror("unlink");
            exit(FIFO_DELETE_ERR);
        }
    }
};
  • mkfifo
    在这里插入图片描述

Linux 系统调用,专门用于创建 FIFO 文件(FIFO 不是普通文件,需用该函数创建),参数为 “FIFO 路径” 和 “权限”,成功返回 0,失败返回 -1,文件属性开头有个p来标识其为管道文件

  • unlink
    在这里插入图片描述
    Linux 系统调用,用于 “删除文件的目录项”(释放文件占用的路径)。对于 FIFO 文件,unlink 会删除其路径,若此时无进程打开 FIFO,资源会被彻底释放。若此时有进程正在打开或使用该 FIFO,unlink 只会删除路径名,但 FIFO 的内核资源不会立即释放,而是会继续存在,直到最后一个打开它的进程关闭文件描述符后,内核才会彻底释放其资源
  • Init 类的设计本质是 RAII,将 “资源的生命周期” 与 “对象的生命周期” 绑定

-2.2服务端server.cc

#include "common.hpp"
#include "log.hpp"

using namespace std;

int main()
{
    Init init;// 创建 Init 对象,自动创建 FIFO 文件(./myfifo)
    Log log;// 创建 Log 对象,初始化日志系统
    log.Enable(Classfile);// 设置日志输出方式为“按级别分类文件”

    //打开管道
    int fd=open(FIFO_FILE,O_RDONLY);// 以“只读”方式打开 FIFO
    if(fd<0)
    {
        log(Fatal,"error string: %s, error code: %d",strerror(errno),errno);
        exit(FIFO_OPEN_ERR);
    }

    //打印错误日志,记录不同级别的日志,均包含“操作描述 + 错误信息 + 错误码”
    log(Info,"server open file done, error string: %s, error code: %d",strerror(errno),errno);
    log(Warning,"server open file done, error string: %s, error code: %d",strerror(errno),errno);
    log(Fatal,"server open file done, error string: %s, error code: %d",strerror(errno),errno);
    log(Debug,"server open file done, error string: %s, error code: %d",strerror(errno),errno);
    
    //开始通信
    while(true)// 无限循环,持续接收消息
    {
        char buffer[1024]={0};// 缓冲区,用于存储读取到的消息
        int x=read(fd,buffer,sizeof(buffer));// 从 FIFO 读取数据
        if(x>0)//读取有效
        {
            buffer[x]=0;//字节流读取,手动置0
            cout<<"client say# "<<buffer<<endl;
        }
        else if(x==0)
        {
            log(Debug,"client quit, me too!, error string: %s, error code;%d",strerror(errno),errno);
            break;
        }
        else 
            break;
    }
    close(fd);
     return 0;
}
  • 注意细节问题:
    1.若客户端未启动,服务器的 open(FIFO_FILE, O_RDONLY) 会一直阻塞(等待写端打开)若需要非阻塞启动,可添加 O_NONBLOCK 模式:open(FIFO_FILE, O_RDONLY | O_NONBLOCK);
    2.日志默认路径为 ./log/ 或 ./Log/,若目录不存在会导致日志无法写入。可以在 Log 类构造函数中自动创建目录

-2.3客户端client

#include<iostream>
#include"common.hpp"

using namespace std;

int main()
{
    int fd=open(FIFO_FILE,O_WRONLY);// 以“只写”模式打开 FIFO 文件
    if(fd<0)
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    cout<<"client open file done"<<endl;
    string line;// 存储用户输入的字符串
    while(true)// 无限循环:持续接收用户输入并发送
    {
        cout<<"Please Enter@ ";
        // 读取用户输入的一整行(包括空格)
        getline(cin,line);//用cin读取数据会用空格当分隔符
        
        write(fd,line.c_str(),line.size());
    }

    close(fd);
    return 0;
}
  • 注意细节:
    1.只打开写端不打开读端,也会阻塞等待,若需要非阻塞启动,可添加 O_NONBLOCK 模式:open(FIFO_FILE, O_WRONLY | O_NONBLOCK);

-2.4makefile自动化构建工具

.PHONY:all
all: server client

server:server.cc
	g++ -o $@ $^ -std=c++11

client:client.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf server client

-2.5日志Log.hpp

#pragma once
using namespace std;
#include<iostream>
#include<time.h>
#include<stdarg.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<stdlib.h>
#include<string>

#define SIZE 1024

//日志级别,按重要性划分,从普通信息到致命错误,数字是日志级别的标识实现高效的逻辑判断、传递和存储
#define Info 0
#define Debug 1
#define Warning 2
#define Error 3 
#define Fatal 4
//输出方式:控制日志输出到屏幕、单个文件或按级别分文件;
#define Screen 1
#define Onefile 2
#define Classfile 3

#define LogFile "log.txt"

class Log
{
public:
    Log()
    {
        PrintMethod=Screen;// 默认输出到屏幕
        path="./Log/"; // 默认日志路径 
    }
    void Enable(int method)//决定输出方式
    {
        PrintMethod=method;
    }
    
    string levelToString(int level)//辅助转化函数
    {//将表示日志级别的整数(如 0、1)转换为对应的字符(如"Info"、"Debug")
        switch(level)
        {
        case Info:
            return "Info";
        case Debug:
            return "Debug";
        case Warning:
            return "Warning";
        case  Error:
            return "Error";
        case Fatal:
            return "Fatal";
        default:
            return "None";
        }
    }
    
    //核心输出控制器,作用是根据当前配置的输出方式(PrintMethod),将格式化好的日志内容(logtxt)分发到不同的目标(屏幕 / 单个文件 / 按级别分类的文件)
    void PrintLog(int level,const string &logtxt)
    {
        switch(PrintMethod)
        {
        case Screen:
            cout<<logtxt<<endl;
            break;
        case Onefile:
            PrintOneFile(LogFile,logtxt);
            break;
        case Classfile:
            PrintClassFile(level,logtxt);
            break;
        default:
            break;
        }
    }
    
    //将日志内容(logtxt)追加写入到指定的日志文件(logname)中
    void PrintOneFile(const string &logname,const string &logtxt)
    {
        string _logname=path+logname;
        int fd=open(_logname.c_str(),O_WRONLY|O_CREAT|O_APPEND,0666);
        if(fd<0) 
        {
            perror("open: ");
            return;}
        write(fd,logtxt.c_str(),logtxt.size());
        close(fd);
    } 
    
    //作用是为不同级别的日志创建独立的文件名,然后调用 PrintOneFile 函数将日志写入对应文件,实现日志的分级管理
    void PrintClassFile(int level,const string &logtxt)
    {
        string filename=LogFile;
        filename+=".";
        filename+=levelToString(level);
        PrintOneFile(filename,logtxt);
    }
    ~Log()//析构让这个类看起来完整一些
    {
    }
   
    //日志生成函数,通过重载 () 操作符,让日志调用像函数一样简洁
    void operator()(int level,const char *format,...)
    {
        time_t t=time(nullptr);// 获取当前时间戳(从1970年到现在的秒数)
        struct tm *ctime=localtime(&t);
        char leftbuffer[SIZE];// 缓冲区,存储级别和时间前缀
        snprintf(leftbuffer,sizeof(leftbuffer),"[%s][%d-%d-%d %d:%d:%d]",levelToString(level).c_str(),
                 ctime->tm_year+1900,ctime->tm_mon+1,ctime->tm_mday,
                 ctime->tm_hour,ctime->tm_min,ctime->tm_sec);
        va_list s;// 可变参数列表(用于接收用户传入的 format 后的参数)
        va_start(s,format);// 初始化参数列表,绑定到 format 后的参数
        char rightbuffer[SIZE];// 缓冲区,存储用户消息
        vsnprintf(rightbuffer,sizeof(rightbuffer),format,s);
        va_end(s);

        // 格式:默认部分+自定义部分
        char logtxt[SIZE*2];// 缓冲区,存储完整日志
        snprintf(logtxt,sizeof(logtxt),"%s %s\n",leftbuffer,rightbuffer); // 拼接前缀和用户消息
        PrintLog(level,logtxt);  // 调用输出函数,根据配置输出到屏幕/文件
    }
private:
    int PrintMethod;
    string path;
};
  • 注意细节:

time_t 是时间戳类型,localtime 将其转换为人类可读的 struct tm 结构体,包含:
tm_year:从 1900 年开始的年份(需 +1900 得到实际年份);
tm_mon:月份(0-11,需 +1 得到 1-12);
tm_mday:日(1-31);
tm_hour/tm_min/tm_sec:时、分、秒(0-23/0-59/0-59)
在这里插入图片描述

在这里插入图片描述

四. 共享内存

  • 操作系统内核在物理内存中划出一块连续的内存区域,作为多个进程的共享空间。每个需要通信的进程会通过系统调用系统接口将这块物理内存映射到自己的虚拟地址空间中。这样,每个进程都能在自己的地址空间中看到这块共享内存,就像访问自己的私有内存一样。进程读写共享内存时,本质是直接操作物理内存(通过虚拟地址映射),数据从一个进程的内存空间 “直接进入” 另一个进程的内存空间
    在这里插入图片描述
  • 共享内存是最快的IPC形式,因为进程通过系统调用接口直接访问内存进行通信操作,与匿名和命名管道同属于内存级通信,正常情况下不与磁盘进行交互。由于内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,进程不再通过执行进入内核的系统调用来传递彼此的数据,大大减少了数据的拷贝次数,管道操作(数据需在 “进程内存→内核缓冲区→另一进程内存” 间复制)
  • 操作系统中肯定存在多个共享内存,所以需要管理,“先组织再描述”,存在内核结构体来描述共享内存相关信息
  • 共享内存一般不释放,进程先与共享内存去关联,然后共享内存继续存在等待其他进程链接,也可以手动释放,这些工作都是由操作系统完成需要系统调用接口,因为内存属于内核资源,操作系统不放心进程(用户)直接访问
  • 共享内存生命周期是随内核的,用户不主动关闭,共享内存会一直存在,除非内核重启或用户释放
  • 共享内存是没用同步互斥机制的,也就是说随读随写不能保证数据的完整性,可以通过管道来实现读取控制,后续会学锁

1.系统调用接口

  • shmget函数:用来创建共享内存

原型: int shmget(key_t key, size_t size, int shmflg);
参数:
key:这个共享内存段标识符
size:共享内存大小,单位是字节
shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的,是通过宏定义的二进制标志位(位掩码)每个宏对应一个特定的二进制位,可组合使用。
返回值:成功返回一个非负整数,即该共享内存段的标识码shmid;失败返回-1

  • 判断共享内存是否存在
    分析shmflg中权限标志位:
    IPC_CREAT:
    如果申请的内存不存在,就创建,存在就获取key值并返回,该权限位可单独使用
    IPC_CREAT|IPC_EXCL:
    如果申请的共享内存不存在就创建,存在就出错返回。目的是确保申请成功了一个内存,并且这个一定是新的。
    IPC_EXCL不可以单独使用,必须搭配前者使用
    IPC_PRIVATE:用于创建 “私有共享内存”,此时 key 参数无效,返回的共享内存只能被创建进程及其子进程访问(适合父子进程通信)

  • 关于key
    1.key是一个数字,这个数字是几不重要,关键它必须在内核中具有唯一性,能够让不同进程进行唯一标识
    2.第一个进程可以通过key创建共享内存,第二个后的进程只要拿着同一个key就可以和第一个进程看到统一个内存了
    3.对于一个已经创建好的共享内存,key在其描述对象中存储
    4.key类似命名管道路径+文件名,具有唯一性

  • ftok函数创建key
    在这里插入图片描述
    本质是一套算法,对pathname和proj_id进行了数值计算来生成key,两个参数都由用户自由指定来维护,若是生成了内核中已存在的key更换一下参数即可

  • shmat函数:将共享内存段连接到进程地址空间

原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
shmid: 共享内存标识
shmaddr:指定连接的地址
shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1

注意:

shmaddr为NULL,核心自动选择一个地址
shmaddr不为NULL且shmflg无SHM_RND标记,进程明确要求将共享内存映射到 shmaddr 所指定的虚拟地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)确保共享内存的映射地址满足系统对齐要求
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存

  • shmdt函数:将共享内存段与当前进程脱离

原型
int shmdt(const void *shmaddr);
参数
shmaddr: 由shmat所返回的指针
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段

  • shmctl函数:用于控制共享内存

原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
shmid:由shmget返回的共享内存标识码
cmd:将要采取的动作(有三个可取值)
在这里插入图片描述
buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0;失败返回-1

2.区分key和shmid

shmid作为创建共享内存函数shmget的返回值,也是共享内存的唯一标识符,那么如何与key区分?key是在操作系统内标定唯一性的,shmid是在进程内用来表示资源唯一性的,一个在内核层一个在用户层

3.代码实现

  • ipcs -m指令可查看系统中的共享内存
  • ipcrm -m shmid删除共享内存操作(删除时shmid为十进制)

-3.1comm.hpp

#ifndef __COMM_HPP__
#define __COMM_HPP__

#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/types.h>
#include <sys/stat.h>

#include "log.hpp"
using namespace std;
Log log;

// 共享内存的大小一般建议是4096的整数倍
// 若申请4097,实际上操作系统给你的是4096*2的大小
const int size=4096;
const string pathname="/home/ywb";
const int proj_id=0666;

key_t GetKey()//获取共享内存在内核中的唯一标识符
{
    key_t k=ftok(pathname.c_str(),proj_id);//创建key值
    if(k<0)
    {
        log(Fatal,"ftok error",strerror(errno));
    }
    log(Info,"ftok success, key is :0x%x",k);
    return k;
}

int GetShareMemHelper(int flag)//创建共享内存并获取进程中的唯一标识符shmid
{
    key_t k=GetKey();
    int shmid=shmget(k,size,flag);
    if(shmid<0)
    {
        log(Fatal,"creat share memory error: %s", strerror(errno));
        exit(2);
    }
    log(Info,"creat share memory success, shmid:%d",shmid);
    return shmid;
}

//进行封装,简化调用
int CreateShm()
{
    return GetShareMemHelper(IPC_CREAT | IPC_EXCL | 0666);
}

int GetShm()
{
    return GetShareMemHelper(IPC_CREAT);
}

#define FIFO_FILE "./myfifo"
#define MODE 0664

enum
{
    FIFO_CREATE_ERR = 1,
    FIFO_DELETE_ERR,
    FIFO_OPEN_ERR
};

class Init
{
public:
    Init()
    {
        // 创建管道
        int n = mkfifo(FIFO_FILE, MODE);
        if (n == -1)
        {
            perror("mkfifo");
            exit(FIFO_CREATE_ERR);
        }
    }
    ~Init()
    {

        int m = unlink(FIFO_FILE);
        if (m == -1)
        {
            perror("unlink");
            exit(FIFO_DELETE_ERR);
        }
    }
};

#endif

-3.2processa.cc

#include "comm.hpp"

extern Log log;

int main()
{
    Init init;//创建管道
    int shmid=CreateShm();//创建共享内存,获得标识符
    char* shmaddr=(char*)shmat(shmid,nullptr,0);//连入地址空间,获得虚拟地址
    //IPC code
    //一旦有人把数据写到共享内存,不需要经过系统调用,立马就能看到

    int fd=open(FIFO_FILE,O_RDONLY);// 等待写入方打开之后,自己才会打开文件,向后执行, open 阻塞了
    if(fd<0)
    {
        log(Fatal,"error string: %s,error code:%d",strerror(errno),errno);
        exit(FIFO_OPEN_ERR);
    }
    struct shmid_ds shmds;//存储共享内存信息的表
    while(true)
    {
        char c;
        ssize_t s=read(fd,&c,1);//从管道中读到字节流才能访问共享内存
        if(s<=0) break;
        cout<<"clinet say@"<<shmaddr<<endl;
        sleep(1);//避免多进程中读取信息的混乱

        shmctl(shmid,IPC_STAT,&shmds);//控制行为,将shmid_ds中的值设置为当前共享内存关联值
        cout<<"shm size: "<<shmds.shm_segsz<<endl;
        cout<<"shm nattch: "<<shmds.shm_nattch<<endl;
        printf("shm key: 0x%x\n",shmds.shm_perm.__key);
        cout<<"shm mode: "<<shmds.shm_perm.mode<<endl;
    }

    shmdt(shmaddr);//与共享内存去关联
    shmctl(shmid,IPC_RMID,nullptr);//删除共享内存
    close(fd);
    return 0;
}

-3.3processb.cc

#include "comm.hpp"

int main()
{
    int shmid=GetShm();//获取标识符
    char*shmaddr=(char*)shmat(shmid,nullptr,0);//获取虚拟地址

    int fd=open(FIFO_FILE,O_WRONLY);//打开管道
    if(fd<0)
    {
        log(Fatal,"error string: %s, error code: %d",strerror(errno),errno);
        exit(FIFO_OPEN_ERR);
    }
    //IPC code
    while(true)//无限循环进行共享内存通信
    {
        cout<<"please enter@ ";
        fgets(shmaddr,4096,stdin);//输入一行字符串,避免空格分隔符分割数据
        write(fd,"c",1);//通知对方可以读取了
    }
    shmdt(shmaddr);//去关联
    close(fd);
    return 0;
}

日志代码复用命名管道中的即可

五.消息队列(了解)

进程间通信的前提是不同进程看到同一份资源,这个资源可以呈现多种形式,比如文件缓冲区(管道)、内存块(共享内存)、队列(消息队列),不同的资源形式提供了不同的进程间通信形式。

  • 消息队列原理
    允许不同的进程向内核中发送带类型的数据块,带类型是为了区分数据块的拥有者。数据块会被消息链表链接,组织成一个双向链表,保证了消息的 “先进先出(FIFO)” 特性 —— 默认情况下,新消息被添加到链表尾部,接收时从头部读取

大致流程:

1.创建队列:进程 A 通过 msgget() 函数创建一个消息队列,获取唯一的 “消息队列 ID(msgqid)”;
2.发送消息:进程 A 通过 msgsnd() 函数,将消息(带指定 “消息类型”)写入该队列,若队列未满则直接写入,若队列已满则阻塞(或返回错误,取决于配置);
3.接收消息:进程 B 通过 msgget() 函数获取同一队列的 ID,再通过 msgrcv() 函数,按 “消息类型” 从队列中读取消息(读取后消息会从队列中删除),若队列为空则阻塞(或返回错误);
4.销毁队列:所有进程操作完成后,通过 msgctl() 函数(参数为 IPC_RMID)销毁队列,释放系统资源。

  • 消息队列所需要使用的系统调用接口与共享内存类型基本思路一致,唯一的区别在于发送消息和接收消息所使用的接口,不再是read和write
    在这里插入图片描述
    msgp:指向消息结构的指针。消息结构一般需要用户自定义,通常包含两个部分:一个长整型的消息类型字段(用于标识消息的类别,接收方可以根据该类型有选择地接收消息)和实际的消息数据字段。见下:
    在这里插入图片描述

IPC在内核中的数据结构设计

在这里插入图片描述

  • 不同内核数据结构中第一个存储的都是struct ipc_perm ???_perm,记录每个结构的所属者和所有权信息,继承自struct ipc_perm通用 “权限与标识” 结构体。
  • 内核中数据结构的设计可以理解为用C语言实现的多态,其中子类结构体中第一个字段的地址会被一个数组管理起来,如图所示,数组下标对应的就是每个数据结构在进程中唯一的标识符xxxid ,下标是线性递增的由内核进行管理,当达到最大时会循环开始重新计数

六. 信号量

  • 概念、问题引入:
    1.共享内存是没有同步互斥保护机制的。当a写入时,写了一部分就被b拿走,导致双方发出和接收的数据不完整。若看到不加保护的共享资源,会导致数据不一致问题
    2.加锁-互斥访问:指任何时刻只允许一个执行流访问共享资源,叫做互斥
    3.共享的,任何时刻只允许一个执行流访问(执行访问代码)的资源,叫做临界资源,一般是内存空间
    4.假如有100行代码,其中5-10行代码才访问临界资源。访问临界资源的代码称为临界区
    5.共享资源加上互斥保护就是临界资源

  • 信号量本质是一把计数器,用于描述临界资源中数量的多少

  • 临界资源可以划分为多个,每个供一个不同的执行流使用,为了避免多个执行流访问同一个资源的情况,需要引入信号量(计数器)来控制执行流访问

  • 一个计数器的值可能很大,也可能为1,把值只能为1、0两态的计数器叫做二元信号量—本质就是一个锁;资源为1的本质就是,将临界资源当作一个整体,统一申请和释放

  • 关于申请计数器
    1.每一个执行流,想访问共享资源中的一部分时候,不直接访问,而是先申请计数器资源
    2.申请了计数器资源,不代表访问了,是一种资源的预定机制
    3.计数器可以有效保证进入共享资源的执行流数量
    4.这个计数器就叫做信号量

  • 关于计数器操作

要访问临界资源,先申请信号量计数器资源,所以信号量计数器也是一种共享资源,信号量是一种用来辅助数据传输来保证传输安全的一种共享资源,本身不参与数据传输,通信不仅仅是通信数据,互相协同也属于。要保证他人安全,首先要保证自己安全!

1.申请信号量本质是对计数器–,p操作;释放本质是++,v操作
2.对一个变量进行加减操作是不安全的,在语言层面上可能就一条语句,但在汇编层面上可能经过将cnt变量内容由内存传输到寄存器中、在cpu内操作、将计算结果返回对应内存位置这样三条语句,进程在运行时可以随时被切换,所以可能执行到某一条汇编时就被切换了,所以说不安全,具体会在线程部分学习
3.为了保证安全性,申请和释放PV操作都是原子的,本质是压缩为一条汇编语句,一条就不会再被切割,表现为要么不做、或者做完这样的两态概念,没有正在做的概念

mmap函数(了解)

  • 传统read、write流程:

磁盘数据 → 内核页缓存 → 用户缓冲区(read)→ 内核页缓存 → 磁盘(write)(共 2 次用户态 / 内核态数据拷贝)

  • mmap 流程:
    磁盘数据 → 内核页缓存,直接映射到用户内存空间,应用程序通过指针直接读写页缓存。(无需用户态 / 内核态数据拷贝,仅 1 次磁盘到页缓存的拷贝)
  • mmap也是系统调用函数用于内存映射

高效的数据访问:
mmap 将文件映射到内存后,应用程序可以像访问普通内存一样访问文件数据,减少了数据在内核缓冲区和用户缓冲区之间的拷贝次数(零拷贝技术),提高了数据访问效率。对于大文件的随机读写场景,mmap 的优势尤为明显,因为不需要像常规文件 I/O 那样每次都进行系统调用指定读写位置。
在这里插入图片描述

  • 与传统文件函数对比:
    mmap 优势显著的场景:
    1.大文件的随机读写(如数据库、编辑器);
    2.进程间高频数据共享(如多进程协作处理数据);
    3.需要零拷贝提升性能的场景(如高性能服务器)。

传统文件函数更适合:
1.小文件的简单读写(mmap 初始化开销可能高于收益);
2.流式读写(如日志写入,顺序读写时 read/write 效率接近 mmap)。


网站公告

今日签到

点亮在社区的每一天
去签到