Bootstrap

【Linux】23.进程间通信(2)


3. 进程间通信

3.1 进程间通信介绍

3.1.1 进程间通信目的

数据传输:一个进程需要将它的数据发送给另一个进程

资源共享:多个进程之间共享同样的资源。

通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止

时要通知父进程)。

进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另

一个进程的所有陷入和异常,并能够及时知道它的状态改变。


3.1.2 进程间通信发展

  • 管道

    • System V进程间通信

    • POSIX进程间通信

  • 进程间通信分类

    • 管道

    • 匿名管道pipe

    • 命名管道

  • System V IPC

    • System V 消息队列

    • System V 共享内存

    • System V 信号量

  • POSIX IPC

    • 消息队列

    • 共享内存

    • 信号量

    • 互斥量

    • 条件变量

    • 读写锁


3.2 什么是进程间通信

什么是进程间通信?

是两个或者多个进程实现数据层面的交互。

通信是有成本的,因为进程独立性的存在,导致进程通信的成本比较高

  1. 进程间通信的本质:必须让不同的进程看到同一份"资源

  2. 资源:特定形式的内存空间

  3. 这个"资源"谁提供?

    一般是操作系统(第三方空间)。

    为什么不是我们两个进程中的一个呢?

    假设一个进程提供,这个资源属于谁?

    如果是这个进程独有,就会破坏进程独立性。

  4. 我们进程访问这个空间,进行通信,本质就是访问操作系统。

    进程代表的就是用户,(一般而言)“资源”从创建,使用,释放。

    这整个过程通过系统调用接口。

    从底层设计,从接口设计,都要由操作系统独立设计。一般操作系统,会有一个独立的通信模块(隶属于文件系统),这个模块在系统里叫做:IPC通信模块。

    出现了很多的通信方案,所以要定制标准 – 进程间通信是有标准的:system Vposix两套标准。

  5. 基于文件级别的通信方式:管道

直接原理:

11bd99a8a31f23fec0238119c3bb6c0c

进程间通信本质前提是需要先让不同的进程,看到同一份资源。

管道就是文件。

多执行流共享的,难免出现访问冲突的问题(临界资源竞争的问题)。


3.3 管道

什么是管道

管道是Unix中最古老的进程间通信的形式。

我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”

319a2238f4430afe4c5c679d78ee356d

管道读写规则:

  • 当没有数据可读时

    • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。

    • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。

  • 当管道满的时候

    • O_NONBLOCK disable: write调用阻塞,直到有进程读走数据

    • O_NONBLOCK enable:调用返回-1,errno值为EAGAIN

  • 如果所有管道写端对应的文件描述符被关闭,则read返回0

  • 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出

  • 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。

  • 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

管道特点

  • 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。

  • 管道提供流式服务

  • 一般而言,进程退出,管道释放,所以管道的生命周期随进程

  • 一般而言,内核会对管道操作进行同步与互斥

  • 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道

4761637510e78523bf38edbbed3c556d


3.4 匿名管道

f9f834aa9620cb16f0dee53b45a078b0

#include <unistd.h>
功能:创建一无名管道
原型
	int pipe(int fd[2]);
参数
	fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
	返回值:成功返回0,失败返回错误代码

3.4.1 站在文件描述符角度-深度理解管道

7025236b4c30c1d05fd3c89db0c3faa0


3.4.2 站在内核角度-管道本质

51b85cc07816a663ff0d2b6615bf8948


3.4.3 用fork来共享管道原理

0424e04200696a6d142d083b1a74fd03


3.4.5 管道相关知识

如果我们想要双向通信,可以建立两个管道。

如果两个进程没有关系,就不能用这个原理进行通信了。

父子关系,兄弟关系,爷孙关系也可以用管道通信。

管道通信适用于:进程之间需要有血缘关系,常用于父子关系。

至此,我们父进程和子进程还没有通信,只是建立了通信的信道。

这里看起来很费劲,实际上是因为进程具有独立性,通信有成本导致的。

管道的特征:

  1. 具有血缘关系的进程进行进程间通信

  2. 管道只能单向通信

  3. 父子进程是会进程协同的,同步与互斥的 — 保护管道文件的数据安全

  4. 管道是面向字节流的

  5. 管道是基于文件的,而文件的生命周期是随进程的

管道的4种情况:

  1. 读写端正常,管道如果为空,读端就要阻塞(有数据就读,没数据就等)

  2. 读写端正常,管道如果被写满,写端就要阻寒(管道是有固定大小的,在不同内核里可能不一样,所以可以被写满,写满了之后就只能等读端读,才能继续写)

  3. 读端正常读,写端关闭,读端就会读到0,表明读到了文件(pipe)结尾,不会被阻塞

  4. 写端是正常写入,读端关闭了。操作系统就要杀掉正在写入的进程(因为操作系统是不会做低效,浪费等类似的工作的。如果做了,就是操作系统的bug)

    如何干掉?通过信号杀掉。这个信号是13号信号。


3.4.6 代码一

makefile

testPipe:testPipe.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f testPipe

testPipe.cc

// 必要的头文件包含
#include <iostream>     // C++输入输出
#include <cstdio>      // C标准输入输出
#include <string>      // C++字符串
#include <cstring>     // C字符串操作
#include <cstdlib>     // C标准库函数
#include <unistd.h>    // Unix标准函数定义
#include <sys/types.h> // 基本系统数据类型
#include <sys/wait.h>  // wait()函数

#define N 2            // 管道的两端:读端和写端
#define NUM 1024       // 缓冲区大小

using namespace std;

// 子进程写数据的函数
void Writer(int wfd)   // wfd是管道的写端文件描述符
{
    string s = "hello, I am child";  // 待发送的消息
    pid_t self = getpid();           // 获取进程ID
    int number = 0;                  // 计数器

    char buffer[NUM];                // 数据缓冲区
    while (true)
    {
        sleep(1);    // 每次写入间隔1秒

        // 以下是发送/写入字符串的方式(被注释)
        //buffer[0] = 0;    // 清空缓冲区 
        //snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
        //write(wfd, buffer, strlen(buffer));

        // 当前使用的是发送单个字符的方式
        char c = 'c';
        write(wfd, &c, 1);  // 向管道写入一个字符
        number++;
        cout << number << endl;  // 显示写入次数

        if(number >= 5) break;   // 写入5次后退出
    }
}

// 父进程读数据的函数
void Reader(int rfd)   // 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)  // 读到EOF(管道写端关闭)
        {
            printf("father read file done!\n");
            break;
        }
        else break;      // 读取出错
    }
}

int main()
{
    // 创建管道
    int pipefd[N] = {0};  // pipefd[0]读端,pipefd[1]写端
    int n = pipe(pipefd); // 调用了 pipe() 函数,用于创建一个管道,并将管道的文件描述符存储在 pipefd 数组中。
    if (n < 0)           // 管道创建失败
        return 1;

    // 创建子进程
    pid_t id = fork();
    if (id < 0)          // 进程创建失败
        return 2;
        
    if (id == 0)         // 子进程
    {
        close(pipefd[0]); // 关闭读端
        Writer(pipefd[1]); // 写数据
        close(pipefd[1]); // 关闭写端
        exit(0);         // 子进程退出
    }
    
    // 父进程
    close(pipefd[1]);    // 关闭写端
    Reader(pipefd[0]);   // 读数据

    // 等待子进程结束
    pid_t rid = waitpid(id, nullptr, 0);
    if(rid < 0) return 3;

    close(pipefd[0]);    // 关闭读端

    sleep(5);            // 暂停5秒
    return 0;
}

运行结果:

ydk_108@iZuf68hz06p6s2809gl3i1Z:~/108/lesson26/pipe$ ./testPipe
1
father get a message[276150]# c
2
father get a message[276150]# c
3
father get a message[276150]# c
4
father get a message[276150]# c
5
father get a message[276150]# c
father read file done!
ydk_108@iZuf68hz06p6s2809gl3i1Z:~/108/lesson26/pipe$ 

代码主要实现:

  1. 创建匿名管道用于父子进程通信
  2. fork创建子进程
  3. 子进程通过管道每秒向父进程发送一个字符’c’,发送5次后退出
  4. 父进程持续从管道读取数据并显示,直到管道关闭
  5. 父进程等待子进程结束后退出

重要的系统调用:

  • pipe(): 创建管道
  • fork(): 创建子进程
  • write(): 写入数据
  • read(): 读取数据
  • waitpid(): 等待子进程结束

3.4.7 代码二 验证杀掉正在写入进程的信号

makefile

testPipe:testPipe.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f testPipe

testPipe.cc

#include <iostream>
#include <cstdio>
#include <string>
#include <cstring>
#include <cstdlib> // 提供exit()函数
#include <unistd.h> // 提供 pipe(), fork(), read(), write(), close()等系统调用
#include <sys/types.h> // 提供pid_t类型
#include <sys/wait.h> // 提供waitpid()函数

#define N 2    // pipe数组大小,0用于读端,1用于写端
#define NUM 1024  // 缓冲区大小 

using namespace std;

// 子进程写数据的函数
void Writer(int wfd)
{
    string s = "hello, I am child";
    pid_t self = getpid();  // 获取当前进程ID
    int number = 0;         // 消息序号

    char buffer[NUM];       // 写缓冲区
    while (true)
    {
        sleep(1);          // 每秒写入一次数据
        // 构建发送字符串,格式为:"hello, I am child-进程ID-序号" 
        buffer[0] = 0;     // 清空缓冲区
        snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
        
        // 通过管道写端写入数据给父进程
        write(wfd, buffer, strlen(buffer));  // 写入字符串长度的数据
    }
}

// 父进程读数据的函数 
void Reader(int rfd)
{
    char buffer[NUM];     // 读缓冲区
    int cnt = 0;         // 读取次数计数
    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");
            break;
        }
        else break;       // 读取错误

        cnt++;
        if(cnt>5) break;  // 最多读取5次
    }
}

int main()
{
    int pipefd[N] = {0};  // 创建管道文件描述符数组
    int n = pipe(pipefd); // 创建管道,pipefd[0]为读端,pipefd[1]为写端
    if (n < 0)           // 创建管道失败
        return 1;

    // 创建子进程
    pid_t id = fork();
    if (id < 0)         // 创建子进程失败
        return 2;
    if (id == 0)       // 子进程
    {
        close(pipefd[0]);  // 关闭读端

        // 执行写操作
        Writer(pipefd[1]); 

        close(pipefd[1]);  // 关闭写端
        exit(0);          // 子进程退出
    }
    
    // 父进程
    close(pipefd[1]);    // 关闭写端

    // 执行读操作
    Reader(pipefd[0]);   // 读取5次数据
    close(pipefd[0]);    // 关闭读端
    cout << "father close read fd: " << pipefd[0] << endl;
    sleep(5);           // 等待5秒,此时子进程已经成为僵尸进程

    // 等待子进程退出并获取退出状态
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);    
    if(rid < 0) return 3;  // 等待失败

    // 打印子进程退出信息
    // status>>8 & 0xFF 获取退出码
    // status & 0x7F 获取信号值
    cout << "wait child success: " << rid << " exit code: " << ((status>>8)&0xFF) << " exit signal: " << (status&0x7F) << endl;

    sleep(5); // 再等待5秒

    cout << "father quit" << endl;  // 父进程退出
    return 0;
}

运行结果:

ydk_108@iZuf68hz06p6s2809gl3i1Z:~/108/lesson27/1.pipe$ ./testPipe
father get a message[277093]# hello, I am child-277094-0
father get a message[277093]# hello, I am child-277094-1
father get a message[277093]# hello, I am child-277094-2
father get a message[277093]# hello, I am child-277094-3
father get a message[277093]# hello, I am child-277094-4
father get a message[277093]# hello, I am child-277094-5
father close read fd: 3
wait child success: 277094 exit code: 0 exit signal: 13
father quit
ydk_108@iZuf68hz06p6s2809gl3i1Z:~/108/lesson27/1.pipe$ 

3.4.8 代码三 使用管道实现一个简易版本的进程池

makefile

ProcessPool:ProcessPool.cc
	g++ -o $@ $^ -g -std=c++11
.PHONY:clean
clean:
	rm -f ProcessPool

Task.hpp

#pragma once

#include <iostream>
#include <vector>

typedef void (*task_t)(); //定义了一个函数指针类型task_t,它指向返回类型为void且不接受任何参数的函数。

void task1()
{
    std::cout << "lol 刷新日志" << std::endl;
}
void task2()
{
    std::cout << "lol 更新野区,刷新出来野怪" << std::endl;
}
void task3()
{
    std::cout << "lol 检测软件是否更新,如果需要,就提示用户" << std::endl;
}
void task4()
{
    std::cout << "lol 用户释放技能,更新用的血量和蓝量" << std::endl;
}

void LoadTask(std::vector<task_t> *tasks) // 该函数接受一个指向std::vector<task_t>的指针,并将其作为参数
{
    tasks->push_back(task1); //将task1函数的地址添加到向量中。
    tasks->push_back(task2);
    tasks->push_back(task3);
    tasks->push_back(task4);
}

ProcessPool.cc

#include "Task.hpp"  // 包含任务相关的头文件,定义了任务类型和函数
#include <string>    // 字符串操作
#include <vector>    // 使用vector容器
#include <cstdlib>   // 系统函数
#include <ctime>     // 时间函数
#include <cassert>   // 断言
#include <unistd.h>  // Unix标准函数
#include <sys/stat.h>    // 文件状态
#include <sys/wait.h>    // 进程等待

const int processnum = 10;      // 定义进程池中的进程数量
std::vector<task_t> tasks;      // 存储所有可执行的任务

// channel类:管理父子进程间的通信通道
class channel
{
public:
    // 构造函数:初始化通信管道
    channel(int cmdfd, int slaverid, const std::string &processname)
    :_cmdfd(cmdfd), _slaverid(slaverid), _processname(processname)
    {}
public:
    int _cmdfd;               // 命令管道的文件描述符(写端)
    pid_t _slaverid;          // 对应子进程的进程ID
    std::string _processname; // 进程的名称,用于显示和日志
};

// 子进程的主要执行函数
void slaver()
{
    while(true)
    {
        int cmdcode = 0;
        // 从标准输入读取命令(标准输入被重定向到了管道)
        int n = read(0, &cmdcode, sizeof(int)); 
        if(n == sizeof(int))
        {
            // 收到命令后打印信息并执行对应任务
            std::cout <<"slaver say@ get a command: "<< getpid() << " : cmdcode: " <<  cmdcode << std::endl;
            if(cmdcode >= 0 && cmdcode < tasks.size()) tasks[cmdcode]();
        }
        if(n == 0) break;  // 管道被关闭,退出循环
    }
}

// 初始化进程池,创建子进程和通信管道
void InitProcessPool(std::vector<channel> *channels)
{
    std::vector<int> oldfds;  // 存储已创建的管道文件描述符
    for(int i = 0; i < processnum; i++)
    {
        int pipefd[2];
        int n = pipe(pipefd);  // 创建管道,pipefd[0]读端,pipefd[1]写端
        assert(!n);
        (void)n;

        pid_t id = fork();     // 创建子进程
        if(id == 0) // 子进程执行的代码
        {
            // 关闭继承自父进程的所有历史文件描述符
            std::cout << "child: " << getpid() << " close history fd: ";
            for(auto fd : oldfds) {
                std::cout << fd << " ";
                close(fd);
            }
            std::cout << "\n";

            close(pipefd[1]);  // 关闭写端
            dup2(pipefd[0], 0);  // 将管道读端重定向到标准输入
            close(pipefd[0]);    // 关闭原读端
            slaver();            // 执行子进程的主要逻辑
            std::cout << "process : " << getpid() << " quit" << std::endl;
            exit(0);
        }
        // 父进程执行的代码
        close(pipefd[0]);  // 关闭读端

        // 创建新的channel对象并保存
        std::string name = "process-" + std::to_string(i);
        channels->push_back(channel(pipefd[1], id, name));
        oldfds.push_back(pipefd[1]);  // 保存文件描述符

        sleep(1);  // 等待1秒,确保进程创建的有序性
    }
}

// 打印所有channel的信息,用于调试
void Debug(const std::vector<channel> &channels)
{
    for(const auto &c :channels)
    {
        std::cout << c._cmdfd << " " << c._slaverid << " " << c._processname << std::endl;
    }
}

// 显示操作菜单
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;  // 退出条件
        
        int cmdcode = select - 1;  // 将选项转换为命令代码

        // 向选中的子进程发送任务,并打印信息
        std::cout << "father say: " << " cmdcode: " <<
            cmdcode << " already sendto " << channels[which]._slaverid << " process name: " 
                << channels[which]._processname << std::endl;
        
        write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode));

        which++;  // 轮询选择下一个进程
        which %= channels.size();
    }
}

// 清理进程池,关闭所有管道和进程
void QuitProcess(const std::vector<channel> &channels)
{
    for(const auto &c : channels){
        close(c._cmdfd);  // 关闭管道
        waitpid(c._slaverid, nullptr, 0);  // 等待子进程结束
    }
}

int main()
{
    LoadTask(&tasks);  // 加载任务列表
            
    // 初始化随机数种子
    srand(time(nullptr)^getpid()^1023);
    
    std::vector<channel> channels;
    InitProcessPool(&channels);  // 初始化进程池
    
    ctrlSlaver(channels);  // 运行任务分发循环
    
    QuitProcess(channels);  // 清理资源
    return 0;
}

这个程序的详细解释我单独放在了一篇博客里进行讲解,有兴趣的可以看看:

使用管道实现一个简易版本的进程池

这个程序实现了一个简单的进程池系统,主要功能如下:

  1. 核心功能:
  • 创建一个包含10个子进程的进程池
  • 通过管道实现父子进程间的通信
  • 父进程可以向子进程分配不同的任务
  • 使用轮询方式分配任务
  1. 工作流程:
  • 程序启动后创建10个子进程
  • 每个子进程都有独立的管道用于接收命令
  • 父进程通过菜单界面接收用户输入
  • 根据用户选择的任务,轮询分配给子进程执行
  • 子进程接收到命令后执行对应的任务

3.5 命名管道

  • 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。

  • 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。

  • 命名管道是一种特殊类型的文件


3.5.1 为什么命名管道可以在不相关的进程之间交换数据

进程间通信的前提:先让不同进程看到同一份资源

一般进程通信,我们只想要使用它的内存级缓冲区,不想要把数据写入磁盘文件(刷盘)。但是打开普通文件的话就会把数据写进去,所以就出现了一个新的文件类型:管道文件。

管道文件是一个内存级文件,不需要把数据写进磁盘去(刷盘)。

理解:

  1. 如果两个不同的进程,打开同一个文件的时候,在内核中,操作系统会打开几个文件?

打开一个文件。

  1. 你怎么知道你们两个打开的是同一个文件?为什么要打开同一个文件?

同路径下同一个文件名 = 路径 +文件名


3.5.2 命名管道的打开规则

如果当前打开操作是为读而打开FIFO时

  • O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO

  • O_NONBLOCK enable:立刻返回成功

如果当前打开操作是为写而打开FIFO时

  • O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO

  • O_NONBLOCK enable:立刻返回失败,错误码为ENXIO


3.5.3 命名管道代码+一个简单的日志函数实现

makefile

.PHONY:all
all:server client

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

.PHONY:clean
clean:
	rm -f server client

comm.hpp

#pragma once  // 防止头文件重复包含

// 包含必要的系统头文件
#include <iostream>
#include <string>
#include <cerrno>      // 错误号定义
#include <cstring>     // 字符串操作
#include <cstdlib>     // 标准库函数
#include <sys/types.h> // 基本系统数据类型
#include <sys/stat.h>  // 文件状态
#include <unistd.h>    // UNIX标准函数
#include <fcntl.h>     // 文件控制

// 定义命名管道文件名和权限
#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);
        }
    }
};

log.hpp

#pragma once

// 包含必要的系统头文件
#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>

#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;
    }

    // 将日志级别转换为字符串
    std::string levelToString(int level)
    {
        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";
        }
    }

    



    // void logmessage(int level, const char *format, ...)
    // {
    //     time_t t = time(nullptr);
    //     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;
    //     // va_start(s, 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);

    //     // printf("%s", logtxt); // 暂时打印
    //     printLog(level, logtxt);
    // }
    // 打印日志的具体实现
    void printLog(int level, const std::string &logtxt)
    {
        switch (printMethod)
        {
        case Screen:
            std::cout << logtxt << std::endl;
            break;
        case Onefile:
            printOneFile(LogFile, logtxt);
            break;
        case Classfile:
            printClassFile(level, logtxt);
            break;
        default:
            break;
        }
    }

    // 输出到单个文件
    void printOneFile(const std::string &logname, const std::string &logtxt)
    {
        std::string _logname = path + logname;
        int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);
        if (fd < 0)
            return;
        write(fd, logtxt.c_str(), logtxt.size());
        close(fd);
    }

    // 根据日志级别输出到不同文件
    void printClassFile(int level, const std::string &logtxt)
    {
        std::string filename = LogFile;
        filename += ".";
        filename += levelToString(level);
        printOneFile(filename, logtxt);
    }

    // 重载函数调用运算符,支持格式化输出
    void operator()(int level, const char *format, ...)
    {
        // 获取当前时间
        time_t t = time(nullptr);
        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;
        va_start(s, 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);
    }

    ~Log()
    {
    }
    

private:
    int printMethod;        // 日志输出方式
    std::string path;       // 日志文件路径
};

// int sum(int n, ...)
// {
//     va_list s; // char*
//     va_start(s, n);

//     int sum = 0;
//     while(n)
//     {
//         sum += va_arg(s, int); // printf("hello %d, hello %s, hello %c, hello %d,", 1, "hello", 'c', 123);
//         n--;
//     }

//     va_end(s); //s = NULL
//     return sum;
// }

server.cc

#include "comm.hpp"
#include "log.hpp"

using namespace std;

int main()
{
    Init init;  // 创建命名管道
    Log log;    // 创建日志对象
    log.Enable(Onefile);  // 设置日志输出到文件

    // 以只读方式打开管道
    int fd = open(FIFO_FILE, O_RDONLY);  // 阻塞等待写入方打开
    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};
        // read函数从管道(fd)中读取数据
        // sizeof(buffer)指定最多读取的字节数
        // x 存储实际读取的字节数
        int x = read(fd, buffer, sizeof(buffer));
        if (x > 0)  // 读取成功
        {
            buffer[x] = 0; // 在读取到的数据末尾添加字符串结束符'\0'
            cout << "client say# " << buffer << endl; // 打印客户端发送的消息
        }
        else if (x == 0)  // x == 0 表示客户端关闭了连接(发送了EOF)
        {
            log(Debug, "client quit, me too!, error string: %s, error code: %d", strerror(errno), errno); // 记录日志,包含错误信息和错误码
            break;
        }
        else  // 读取错误
            break;
    }

    close(fd);  // 关闭管道
    return 0;
}

client.cc

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

using namespace std;

int main()
{
    // 以只写方式打开命名管道
    int fd = open(FIFO_FILE, O_WRONLY);
    if(fd < 0) // 打开失败时fd返回-1
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    cout << "client open file done" << endl;

    // 循环读取用户输入并发送到服务端
    string line;
    while(true)
    {
        cout << "Please Enter@ ";
        getline(cin, line);  // 读取一行输入

        // 将输入写入管道
        write(fd, line.c_str(), line.size());
    }

    close(fd);  // 关闭管道
    return 0;
}

实现client.ccserver.cc的通信

运行结果:

20c61708ab31ac5bb52c6036f5827e5e

日志:

65de8ad203a3dee663be3de95e72c4c3


3.5.4 匿名管道与命名管道的区别

匿名管道由pipe函数创建并打开。

命名管道由mkfifo函数创建,打开用open

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


3.6 systemv共享内存

共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。

进程间通信的本质是:先让不同的进程,看到同一份资源。

72881d84255fc303b20a3ab2711e9853

如果要释放共享内存:要去除关联,释放共享内存

上面的操作都是进程直接做的吗?

不是。直接由操作系统来做。

共享内存的生命周期是随内核的。

用户不主动关闭,共享内存会一直存在。除非内核重启(用户释放)


3.6.1 共享内存函数

shmget函数

功能:用来创建共享内存
原型
	int shmget(key_t key, size_t size, int shmflg);
参数
	key:这个共享内存段名字
	size:共享内存大小
	shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1

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

981fea39a7c0c97a0551b9f2613d1954


3.6.2 共享内存代码实现

makefile

.PHONY:all
all:processa processb

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

.PHONY:clean
clean:
	rm -f processa processb

log.hpp

#pragma once  // 防止头文件重复包含

// 包含必要的系统头文件
#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>

#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;
    }

    // 将日志级别转换为字符串
    std::string levelToString(int level)
    {
        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";
        }
    }

    // 根据不同的输出方式打印日志
    void printLog(int level, const std::string &logtxt)
    {
        switch (printMethod)
        {
            case Screen:
                std::cout << logtxt << std::endl;
                break;
            case Onefile:
                printOneFile(LogFile, logtxt);
                break;
            case Classfile:
                printClassFile(level, logtxt);
                break;
            default:
                break;
        }
    }

    // 输出到单个文件
    void printOneFile(const std::string &logname, const std::string &logtxt)
    {
        std::string _logname = path + logname;
        int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);
        if (fd < 0)
            return;
        write(fd, logtxt.c_str(), logtxt.size());
        close(fd);
    }

    // 根据日志级别输出到不同文件
    void printClassFile(int level, const std::string &logtxt)
    {
        std::string filename = LogFile;
        filename += ".";
        filename += levelToString(level);
        printOneFile(filename, logtxt);
    }

    // 重载函数调用运算符,支持可变参数的日志打印
    void operator()(int level, const char *format, ...)
    {
        // 获取当前时间
        time_t t = time(nullptr);
        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;
        va_start(s, 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);
    }
    
    ~Log()
    {
    }



    // void logmessage(int level, const char *format, ...)
    // {
    //     time_t t = time(nullptr);
    //     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;
    //     // va_start(s, 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);

    //     // printf("%s", logtxt); // 暂时打印
    //     printLog(level, logtxt);
    // }

    private:
    int printMethod;       // 日志输出方式
    std::string path;      // 日志文件路径
};

// int sum(int n, ...)
// {
//     va_list s; // char*
//     va_start(s, n);

//     int sum = 0;
//     while(n)
//     {
//         sum += va_arg(s, int); // printf("hello %d, hello %s, hello %c, hello %d,", 1, "hello", 'c', 123);
//         n--;
//     }

//     va_end(s); //s = NULL
//     return sum;
// }

comm.hpp

#ifndef __COMM_HPP__
#define __COMM_HPP__

// 包含必要的头文件
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <sys/ipc.h>    // 系统IPC功能
#include <sys/shm.h>    // 共享内存
#include <sys/types.h>
#include <sys/stat.h>

#include "log.hpp"

using namespace std;

Log mylog;  // 全局日志对象

// 共享内存的大小一般建议是4096的整数倍
const int size = 4096; 
const string pathname="/home/ydk_108";  // 用于生成key的路径
const int proj_id = 0x6666;         // 项目ID

// 获取IPC key
key_t GetKey()
{
    key_t k = ftok(pathname.c_str(), proj_id);
    if(k < 0)
    {
        mylog(Fatal, "ftok error: %s", strerror(errno));
        exit(1);
    }
    mylog(Info, "ftok success, key is : 0x%x", k);
    return k;
}

// 获取共享内存的辅助函数
int GetShareMemHelper(int flag)
{
    key_t k = GetKey();
    int shmid = shmget(k, size, flag);
    if(shmid < 0)
    {
        mylog(Fatal, "create share memory error: %s", strerror(errno));
        exit(2);
    }
    mylog(Info, "create 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()
    {
        // 先尝试删除已存在的管道文件
        unlink(FIFO_FILE);  // 忽略返回值,因为文件可能不存在
        // 创建命名管道
        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

processa.cc

#include "comm.hpp"

extern Log mylog;

int main()
{
    Init init;  // 创建命名管道
    int shmid = CreateShm();  // 创建共享内存
    // 将共享内存映射到进程地址空间
    char *shmaddr = (char*)shmat(shmid, nullptr, 0);

    // 以只读方式打开命名管道
    int fd = open(FIFO_FILE, O_RDONLY);
    if (fd < 0)
    {
        mylog(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;  // 写端关闭
        else if(s < 0) break;  // 读取错误

        // 直接从共享内存读取数据
        cout << "client say@ " << shmaddr << endl;
        sleep(1);

        // 获取并打印共享内存的状态信息
        shmctl(shmid, IPC_STAT, &shmds);
        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;
}

processb.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)
    {
        mylog(Fatal, "error string: %s, error code: %d", strerror(errno), errno);
        exit(FIFO_OPEN_ERR);
    }

    while(true)
    {
        cout << "Please Enter@ ";
        // 读取用户输入并直接写入共享内存
        fgets(shmaddr, 4096, stdin);

        // 向管道写入一个字符,通知接收端
        write(fd, "c", 1);
    }

    // 清理资源
    shmdt(shmaddr);  // 解除内存映射
    close(fd);  // 关闭管道
    return 0;
}

打印:

c297c18f8ca1b2702ec33357f46ef20e

关于key:

  1. key是一个数字,这个数字是几,不重要。关键在于它必须在内核中具有唯一性,能够让不同的进程进行唯一性标识。

  2. 第一个进程可以通过kev创建共享内存,第二个之后的进程,只要拿着同一个key就可以和第一个进程看到同一个共享内存了。

  3. 对于一个已经创建好的共享内存,key在哪?

    key在共享内存的描述对象中。

  4. 第一次创建的时候,必须有一个key了。怎么有?

  5. key 类似路径唯一


3.6.3 key和shmid的主要区别:

  1. 基本概念

    key: 是一个用户定义的值,用来标识共享内存段的访问权限,类似于文件路径名

    shmid: 是系统分配的共享内存段标识符,是系统内部使用的唯一标识符

  2. 使用时机

    key: 在创建或获取共享内存时使用

    shmid: 在共享内存创建后由系统返回,后续操作都使用shmid

  3. 代码示例

#include <sys/shm.h>

// 使用key创建共享内存
key_t key = ftok("/tmp", 'A');  // 创建key
int shmid = shmget(key, 1024, IPC_CREAT | 0666); // 用key获取shmid

// 后续操作使用shmid
void *addr = shmat(shmid, NULL, 0);  // 连接共享内存
shmctl(shmid, IPC_RMID, NULL);  // 删除共享内存
  1. 关系

    一个key可以对应一个shmid

    key是用户层面的标识

    shmid是系统层面的标识

  2. 生命周期

    key: 可以重复使用

    shmid: 随共享内存段的存在而存在,删除后失效


3.7 systemv消息队列(了解)

消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法

每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值

特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核

通过消息队列想让A和B进行通信那么首先要让不同进程看到同一份资源。

  1. 必须让不同进程看到同一个队列

  2. 允许不同的进程,向内核中发送带类型的数据块(通过类型来区分数据块是属于谁的)

A进程可以把它的数据块放到队列中,B进程可以把它的数据块放到队列中。

A进程就可以从队列中拿B进程给A进程发的数据块,反之亦然。

可以让A进程 <--以数据块的形式发送数据--> B进程。

2afaf5fef54063425c71bea5f3ab0008


3.8 systemv信号量(了解)

信号量主要用于同步和互斥的。什么是同步和互斥?

进程互斥

由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥。

系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。

在进程中涉及到互斥资源的程序段叫临界区。

特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核


四个问题

当我们的A正在写入,写入了一部分,就被B拿走了,导致双方发和收的数据不完整 – 数据不一致问题

  1. A B看到的同一份资源,共享资源,如果不加保护,会导致数据不一致问题

  2. 我们可以通过“加锁”形成互斥访问 – 任何时刻,只允许一个执行流访问共享资源 – 互斥

  3. 共享的,任何时刻只允许一个执行流访问(就是执行访问代码)的资源我们一般称为:临界资源(一般是操作系统和用户维护的内存空间)(管道也是临界资源)

  4. 举例:100行代码,5~10行代码才在访问临界资源。 我们访问临界资源的代码在:临界区


理解信号量

信号量的本质是一个计数器。

描述临界资源数量的多少。

  1. 申请计数器成功,就表示我具有访问资源的权限了

  2. 申请了计数器资源,我当前访问我要的资源了吗?没有。申请了计数器资源是对资源的预订机制

  3. 计数器可以有效保证进入共享资源的执行流的数量

  4. 所以每一个执行流,想访问共享资源中的一部分的时候,不是直接访问,而是先申请计数器资源。

程序员把这个"计数器",叫做信号量。

申请信号量,本质是对计数器--,P操作

释放资源,释放信号量,本质是对计数器进行++操作,V操作

申请和释放PV操作,原子性操作。

要么不做,要做就做完 — 两态的。没有“正在做”这样的概念。

信号量本质是一把计数器,PV操作,原子的。

执行流申请资源,必须先申请信号量资源,得到信号量之后,才能访问临界资源。

信号量值1,0两态的,二元信号量,就是互斥功能

申请信号量的本质:是对临界资源的预订机制。

信号量凭什么是进程间通信的一种?

  1. 通信不仅仅是通信数据,双方互相协同也是。

  2. 要协同,本质也是通信,信号量首先要被所有的通信进程看到。

mmap函数 – 也是共享内存。(仅作了解)

后面学习的信号和这里的信号量没有任何关系。

;