Linux -- 进程间通信(IPC)-- 进程间通信、管道、system V 共享内存、system V 消息队列、责任链模式 、system V 信号量

news/2025/3/29 2:55:59/

一、什么是进程间通信

1.进程间通信的目的

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

2.进程间通信发展和分类

管道、System V 进程间通信、POSIX 进程间通信。

  • 管道:匿名管道 pipe、命名管道。
  • System V IPC:System V 消息队列、System V 共享内存、System V 信号量。
  • POSIX IPC:消息队列、共享内存、信号量(互斥量、条件变量、读写锁)。

3.如何实现通信?

我们已经知道每个进程都具有独立性,数据各自独有一份;所以想要不同进程之间实现通信,就得先让不同的进程看见同一份资源

这个同一份资源,一定是某种形式的内存空间,并且提供资源的只能是操作系统。(所以这也是本地通信,同一个OS,不同进程之间的通信)

二、管道(匿名管道和命名管道)

1.什么是管道

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

管道其实是一种内核级缓冲区。
管道是 Linux 进程间通信(IPC)的一种机制,它在内核中创建了一个缓冲区用于连接两个进程,使得一个进程的输出可以直接作为另一个进程的输入。当一个进程向管道写入数据时,数据被复制到内核中的管道缓冲区;而另一个进程从管道读取数据时,是从这个内核缓冲区中获取数据。通过这种方式,实现了进程间的数据传输和通信,并且管道提供了一种简单、高效的方式来连接不同进程的输入和输出,在很多命令组合和程序设计中广泛应用。

例如上图的 " | ":就是管道。

进程操作与管道关系
who 进程标准输出通过管道将数据传输给 wc - 1 进程
wc - 1 进程标准输入接收 who 进程通过管道传来的数据
用户无直接操作管道行为通过终端发起命令间接参与管道通信
内核管理管道为管道提供数据传输和存储的支持

关于使用 " | " 管道来使进程间通信:

PID相邻,可以得出结论:通过管道 | 链接的进程,分别独立,且具有血缘关系(例如父子进程PID相邻)

2.匿名管道

在 Linux 中,匿名管道是一种用于在父子进程或有亲缘关系的进程之间进行通信的机制

  • 匿名管道特性
    • 半双工通信:数据只能在一个方向上流动,一端用于写入数据(写端),另一端用于读取数据(读端)。
    • 基于文件描述符:在 Linux 系统中,匿名管道是基于文件系统的通信机制,本质上是一种特殊的内存级文件 。当创建匿名管道时,系统会在内核中创建相应的数据结构,其中就包括file结构体。通过pipe函数创建匿名管道后,会返回两个文件描述符,这两个文件描述符分别对应file结构体的读端和写端。父子进程通过这些文件描述符对管道进行读写操作,而file结构体则用于管理与管道相关的状态信息,如读写位置、引用计数等。比如在父子进程通信场景中,父进程创建管道后,子进程通过继承父进程的文件描述符表获取到与管道关联的file结构体引用,进而实现对同一管道资源的访问。
    • 临时存在:匿名管道是临时的,存在于内存中文件系统中没有对应的实体文件(使用fd操作,但文件系统并没有实体)。当所有使用管道的进程都关闭了管道的文件描述符后,管道占用的资源会被自动释放。
    • 亲缘关系要求:通常用于具有亲缘关系的进程之间的IPC,如父子进程。这是因为管道的创建和文件描述符的传递需要在进程间有一定的关联,以便正确地共享和使用管道。
    • 面向字节流:管道属于流式服务,管道是面向字节流的,这意味着管道中的数据是以连续的字节序列形式存在和传输的,不区分消息的边界。发送方可以将数据以任意大小的块写入管道,接收方则以字节流的方式从管道中读取数据。
    • 生命周期随进程:匿名管道的生命周期是随进程的,当所有打开该管道文件描述符的进程都退出后,管道相关的资源会被释放。
    • 自带同步互斥等保护机制
      1、互斥操作:多个执行流任何一个时刻只允许一个执行流访问同一个资源
      2、同步操作:两个进程执行时,具有一定顺序性,写满就读,读完就写
      3、原子操作:PIPE_BUF,并说明 POSIX.1 标准规定:小于PIPE_BUF字节的write(2)(系统调用 write)操作必须是原子操作。原子操作意味着该操作要么完整执行,要么完全不执行不会被其他进程或线程的操作打断在管道的写入场景中,如果写入的数据量小于PIPE_BUF字节,系统会保证这次写入操作是一个不可分割的整体,不会出现部分写入的情况,其他进程不会看到只写了一部分数据的中间状态PIPE_BUF是一个系统定义的常量,不同的系统中其值可能不同,常见的值为 4096 字节。
  • 工作原理
    • 当一个进程创建匿名管道时,内核会在内存中开辟一块缓冲区用于存储管道数据
    • 进程可以通过文件描述符向管道写端写入数据,数据会被暂存到缓冲区中
    • 从管道读端读取数据的进程会按照先进先出(FIFO)的顺序从缓冲区中获取数据
    • 如果管道缓冲区已满,写入进程会被阻塞,直到有空间可用;如果管道中没有数据,读取进程会被阻塞,直到有数据写入(!!!!)
  • 创建和使用:在 Linux 中,可以使用pipe函数来创建匿名管道。该函数会创建一个管道,并返回两个文件描述符,分别用于读和写。
    #include <unistd.h>
    int pipe(int pipefd[2]); //输出型参数//参数:pipefd是一个包含两个整数的数组,用来存储管道的文件描述符。pipefd[0]代表管道的读端,pipefd[1]代表管道的写端。
    //返回值:如果函数调用成功,返回值为 0;若调用失败,返回 -1,并设置相应的errno。

    例子:从键盘读取数据,写入管道,然后读取管道,写入屏幕。

    #include <iostream>
    #include <cstring>
    #include <string>
    #include <cstdlib>
    #include <unistd.h>
    using namespace std;
    //从键盘读取数据,写入管道,读取管道,写到屏幕int main()
    {int fds[2];char buf[100];int len;if(pipe(fds) == -1){perror("make pipe");exit(1);}//读取数据while(fgets(buf,100,stdin)){len = strlen(buf);if(write(fds[1],buf,len) != len){perror("write to pipe");break;}memset(buf,0x00,sizeof(buf));if(read(fds[0],buf,len) != len){perror("read from pipe");break;}if(write(1,buf,len) != len){perror("write to stdout");break;}}return 0;
    }

3.使用fork和匿名管道实现父子进程间通信 

调用 pipe 的进程在 fork 后,父进程和子进程都拥有同一个管道的文件描述符(原理就是父进程创建子进程,子进程拷贝了父进程的属性,起初时子进程是父进程的拷贝)。父进程和子进程各自关掉不用的描述符,从而实现通过管道进行通信。

进程状态文件描述符状态
调用 pipe 的进程拥有 fd [0](读端)和 fd [1](写端)
fork 之后父进程和子进程都有 fd [0] 和 fd [1]
fork 之后各自关掉不用的描述符父进程关闭 fd [0],子进程关闭 fd [1]

 站在文件描述符的角度理解管道:


站在内核角度理解管道:

在Linux中,一切皆文件,看待管道如同看待文件,管道的使用和文件一致。在内核角度:

1. 管道的创建

当用户进程调用 pipe 系统调用时,内核会执行以下操作:

  • 分配数据结构:内核会为管道分配一个或多个数据结构来管理管道的状态和数据。在 Linux 中,管道通常使用 pipe_inode_info 结构体来表示,该结构体包含了管道的各种信息,如读指针、写指针、缓冲区等。
  • 创建文件描述符:内核会为管道创建两个文件描述符,一个用于读(通常为 pipefd[0]),另一个用于写(通常为 pipefd[1])。这两个文件描述符会被返回给用户进程,用户进程可以通过这些文件描述符对管道进行读写操作。
  • 初始化管道:内核会初始化管道的状态,包括设置读指针和写指针的初始位置,以及分配管道的缓冲区。

2. 数据存储

管道的数据存储在内核的缓冲区中。这个缓冲区是一个环形缓冲区,它允许数据以先进先出(FIFO)的顺序进行存储和读取。缓冲区的大小是有限的,通常为 64KB(可以通过 fpathconf 函数查询)。

3. 读写操作

  • 写操作:当用户进程调用 write 系统调用向管道写入数据时,内核会执行以下操作:
    • 检查缓冲区空间:内核会检查管道缓冲区是否有足够的空间来存储要写入的数据。如果缓冲区已满,写操作会被阻塞,直到有空间可用。
    • 复制数据:如果缓冲区有足够的空间,内核会将用户进程提供的数据复制到管道缓冲区中,并更新写指针的位置。
    • 唤醒读进程:如果有读进程正在等待数据,内核会唤醒这些读进程。
  • 读操作:当用户进程调用 read 系统调用从管道读取数据时,内核会执行以下操作:
    • 检查缓冲区数据:内核会检查管道缓冲区是否有数据可供读取。如果缓冲区为空,读操作会被阻塞,直到有数据写入。
    • 复制数据:如果缓冲区有数据,内核会将数据从管道缓冲区复制到用户进程提供的缓冲区中,并更新读指针的位置。
    • 唤醒写进程:如果有写进程正在等待空间,内核会唤醒这些写进程。

4. 同步机制

为了确保管道的读写操作能够正确同步,内核使用了一些同步机制,如等待队列和信号量。

  • 等待队列:当管道缓冲区已满或为空时,读写进程会被加入到相应的等待队列中。当条件满足时(如缓冲区有空间或有数据),内核会从等待队列中唤醒相应的进程。
  • 信号量:内核使用信号量来保护管道的共享资源,如缓冲区和读写指针。在进行读写操作时,进程需要先获取信号量,操作完成后再释放信号量。

5. 资源管理

  • 关闭文件描述符:当用户进程关闭管道的文件描述符时,内核会减少相应的引用计数。当所有的读或写文件描述符都被关闭时,内核会释放管道占用的资源。
  • 管道销毁:当管道的所有引用计数都变为 0 时,内核会销毁管道的数据结构,并释放相关的内存。
#include <stdio.h>
#include <unistd.h>
#include <string.h>#define BUFFER_SIZE 100int main() {int pipe_fd[2];pid_t child_pid;char buffer[BUFFER_SIZE];// 创建管道if (pipe(pipe_fd) == -1) {perror("pipe");return 1;}// 创建子进程child_pid = fork();if (child_pid == -1) {perror("fork");return 1;} else if (child_pid == 0) {// 子进程关闭读端close(pipe_fd[0]);// 向管道写入数据const char *message = "Hello from child!";write(pipe_fd[1], message, strlen(message));// 关闭写端close(pipe_fd[1]);} else {// 父进程关闭写端close(pipe_fd[1]);// 从管道读取数据ssize_t bytes_read = read(pipe_fd[0], buffer, BUFFER_SIZE);if (bytes_read > 0) {buffer[bytes_read] = '\0';printf("Parent received: %s\n", buffer);}// 关闭读端close(pipe_fd[0]);}return 0;
}

前面说了管道的特点是单向通信,实现的原理是pipe用可读可写打开管道,同时子进程会继承父进程的属性,使子进程指向与父进程同样的空间,然后再各自关闭不需要的一端,形成管道单向通信。
但如果不关闭不需要的一端呢?就会造成fd资源泄露,并且可以会发生误操作。


4.重定向> 与匿名管道

n> 这种形式表示将文件描述符 n 的输出重定向到指定文件,其中 n 是一个有效的文件描述符编号。例如,3> file.txt 表示将文件描述符 3 的输出重定向到 file.txt 文件中。

之前的指令重定向>的完整写法是1>,表示标准输出重定向,但无法重定向标准错误; 2>,表示标准错误重定向,但无法重定向标准输出。 如果需要标准输出和标准错误同时重定向,./mypipe > log.txt 2>&1
(2>&1:这部分是对标准错误输出(stderr)的处理。数字 2 代表标准错误输出,& 是一个特殊符号,用于表示重定向的目标是一个文件描述符,1 代表标准输出。2>&1 的意思是将标准错误输出重定向到标准输出所指向的地方。结合前面的 > log.txt,这样就实现了将标准输出和标准错误输出都写入到 log.txt 文件中。)

已知标准错误与标准输出都是默认显示到显示器中,但使用>直接重定向,最终标准错误和标准输出指向就会不同。
 

C提供了stdout、stderr,C++提供了cout,cerr,加上重定向可以将正确和错误信息分离:

 

5.读取端读取的自由度

含义:读取端的读取数量与写入端写入多少次无关,读取端想读取多少是自由的,不关心写入端写入多少次。也就是说,写入端可以分多次将数据写入管道,而读取端可以一次性读取所有数据,也可以分多次按自己的需求读取部分数据。

例子:进程A向管道写入数据,进程B从管道读取数据

echo -n "abcdef" > pipedata=$(cat pipe)

在这个例子中,echo 命令将字符串“abcdef”写入管道,它并不关心这个字符串会如何被读取,对于管道来说,这只是一连串的字节。接收方 cat 命令从管道中读取这些字节,同样是以字节流的形式处理,不会对数据进行额外的分割或解析。

 例子:分两次写入管道,分两次读取管道

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>int main()
{int pipefd[2];pid_t pid;char buffer[1024];//创建匿名管道if(pipe(pipefd) == -1){perror("pipe");exit(EXIT_FAILURE);}pid = fork();if(pid == -1){perror("fork");exit(EXIT_FAILURE);}else if(pid == 0){//子进程关闭读端close(pipefd[0]);//两次写入数据write(pipefd[1], "Hello ", 6);//每次写入6个字节write(pipefd[1],"world!",6); //每次写入6个字节close(pipefd[1]);}else{//父进程关闭写端close(pipefd[1]);ssize_t nbytes1 = read(pipefd[0],buffer,3);//读取3个字节if(nbytes1 > 0){buffer[nbytes1] = '\0';printf("First read: %s\n",buffer);}ssize_t nbytes2 = read(pipefd[0],buffer,9);//读取9个字节if(nbytes2 > 0){buffer[nbytes2] = '\0';printf("Second read: %s\n",buffer);}close(pipefd[0]);wait(NULL);}return 0;
}


这个示例中,写入端(子进程)分两次将 "hello" 和 "world!" 写入管道,而读取端(父进程)分两次读取数据,第一次读取3个字符,第二次读取9个字符。读取端的读取操作不受写入端写入次数的限制,完全根据自身的需求进行。

注意:

  • 虽然读取端有读取的自由度,但如果管道中没有足够的数据可供读取,读取操作可能会被阻塞,直到有新的数据写入管道或者管道被关闭。
  • 管道的缓冲区大小是有限度的,如果写入数据超过了缓冲区的容量,写入操作可能会被阻塞,直到有数据被读取,腾出缓冲区空间。 

6.匿名管道写端提前关闭 与 匿名管道写端提前关闭

管道写端提前关闭 && 读端继续读取:读端读到0(read的返回值),表示读取到文件结尾。

管道读端提前关闭 && 写端继续写入:读端关闭,写端写入,就会触发SIGNPIPE(13号信号),OS就会把写端的子进程杀掉,子进程就会退出,父进程的waitpid就会拿到子进程的退出信息,可以拿到退出信号。(所以子进程一般写入,父进程一般退出,这样遇到这个情况父进程就可以获取退出信息)
例子:读端提前close,子进程(写端)被13号信号终止。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <fcntl.h>int main()
{int fd[2];pid_t pid;if(pipe(fd) == -1){perror("pipe");exit(EXIT_FAILURE);}pid = fork();if(pid < 0){perror("fork");exit(EXIT_FAILURE);}else if(pid == 0){close(fd[0]);sleep(5);write(fd[1],"hello bea u ti ful world!",25);close(fd[1]);exit(EXIT_SUCCESS);}close(fd[1]);close(fd[0]);//提前关闭int status;pid = waitpid(pid,&status,0);if(pid == 1 ){printf("waitpid error\n");exit(EXIT_FAILURE);}if(WIFSIGNALED(status)){printf("child exit by signal %d\n",WTERMSIG(status));}return 0;
}

总结:

  • 管道为空&&管道正常,read调用会阻塞
  • 管道为满&&管道正常,write调用会阻塞
  • 管道写端关闭&&读端继续,读端读到0,表示读到文件结尾
  • 管道写端正常&&读端关闭,OS会直接发送13号信号SIGPIPE杀掉写入的进程
  • 当写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性
  • 当写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性

 7.匿名管道通信的场景 -- 进程池

进程池是一种用于管理和复用进程的技术。在传统的多进程编程中,每处理一个任务就创建一个新进程,而进程的创建和销毁开销较大,频繁进行这些操作会严重影响系统性能。进程池预先创建一定数量的进程,当有任务到来时,从进程池中选取一个空闲进程来处理该任务任务完成后进程不会被销毁,而是返回到进程池中等待下一个任务。这样可以减少进程创建和销毁的开销,提高系统的性能和响应速度。

利用匿名管道实现进程池的实现思路:

  1. 初始化进程池:创建指定数量的子进程和匿名管道,并让子进程进入等待任务的状态(如在read调用阻塞)
  2. 任务分发:通过轮询的方式找到一个空闲的进程来处理任务
  3. 进程工作:子进程不断从中获取任务并执行,如果没有任务,则进入阻塞状态等待分配任务
  4. 资源释放:释放fd资源和进程资源

代码实现:

//Main.cc
#include "ProcessPool.hpp"
#include "Task.hpp"void Usage(std::string proc)
{std::cout << "Usage: " << proc << " processnum" << 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);// 初始化进程池pp->InitProcessPool();// 派发任务pp->DispatchTask();// 退出进程池pp->CleanProcessPool();delete pp;return 0;
}
//Channel.hpp
#ifndef __CHANNEL_HPP__
#define __CHANNEL_HPP__#include <iostream>
#include <string>
#include <unistd.h>
#include <vector>// 创建的管道,由channel结构体管理
class Channel
{
public:Channel(int wfd, pid_t who) : _wfd(wfd), _who(who){_name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);}// 获取管道名std::string Name(){return _name;}// 向管道发送消息--cmd指向一个命令void Send(int cmd){::write(_wfd, &cmd, sizeof(cmd));}// 关闭管道void Close(){::close(_wfd);}// 获取管道idpid_t Id(){return _who;}// 获取管道fdint wFd(){return _wfd;}~Channel() {}private:int _wfd;          // 子进程读取端操作管道的fdpid_t _who;        // 关键的管道,与之关联的子进程的PIDstd::string _name; // channel name
};#endif
//ProcessPool.hpp
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__#include <iostream>
#include <string>
#include <vector>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <functional>
#include "Channel.hpp"
#include "Task.hpp"using work_t = std::function<void()>;enum
{OK = 0,UsageError,PipeError,ForkError
};class ProcessPool
{
public:ProcessPool(int n, work_t w) : processnum(n), work(w){}// 初始化进程池int InitProcessPool(){// 创建指定个数个进程和管道for (int i = 0; i < processnum; i++){// 创建管道int pipefd[2] = {0};int n = pipe(pipefd);if (n < 0){std::cerr << "pipe error" << std::endl;exit(EXIT_FAILURE);}// 创建子进程pid_t pid = fork();if (pid < 0){std::cerr << "fork error" << std::endl;exit(EXIT_FAILURE);}else if (pid == 0) // 子进程{// 关闭历史wfdstd::cout << getpid() << ",child close history fd:";for (auto &c : channels){std::cout << c.wFd() << " ";c.Close();}std::cout << " over" << std::endl;::close(pipefd[1]); // 关闭写端std::cout << "debug: " << pipefd[0] << std::endl;dup2(pipefd[0], 0);work();::exit(EXIT_SUCCESS);}else // 父进程{::close(pipefd[0]);                    // 父进程的读端不需要channels.emplace_back(pipefd[1], pid); // 插入一组管道}}return OK;}void DispatchTask(){int who = 0;int num = processnum;// 派发num个任务while (num--){// 选取一个任务--返回一个整数,整数代表一个任务int task = tm.SelectTask();// 选择一个管道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;// 派发任务curr.Send(task);sleep(1);}}void CleanProcessPool(){for (auto &c : channels){// 关闭子进程的读取端c.Close();// 依次释放子进程pid_t rid = ::waitpid(c.Id(), nullptr, 0);if (rid > 0){std::cout << "wait child success, child id:" << rid << std::endl;}}}void DebugPrint(){for (auto &c : channels){std::cout << c.Name() << " " << c.Id() << " " << c.wFd() << std::endl;}}private:std::vector<Channel> channels;int processnum;work_t work;
};#endif
//Task.hpp
#pragma once#include <iostream>
#include <unordered_map>
#include <functional>
#include <vector>
#include <ctime>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>using task_t = std::function<void()>; // 任务类型// 任务管理器--任务集合
class TaskManger
{
public:TaskManger(){srand(time(nullptr));tasks.push_back([](){ std::cout << "sub process[" << getpid() << "] 执行访问数据库任务" << std::endl; });tasks.push_back([](){ std::cout << "sub process[" << getpid() << "] 执行访问缓存任务" << std::endl; });tasks.push_back([](){ std::cout << "sub process[" << getpid() << "] 执行访问日志任务" << std::endl; });tasks.push_back([](){ std::cout << "sub process[" << getpid() << "] 执行访问文件任务" << std::endl; });}// 随机选择一个任务int SelectTask(){return rand() % tasks.size();}// 执行任务void Excute(unsigned long number){// 判断选取任务的number是否合法if (number >= tasks.size() || number < 0){std::cout << "number error" << std::endl;return;}// 执行tasks[number]();}~TaskManger() {}private:std::vector<task_t> tasks;
};TaskManger tm; // 定义为全局void Worker()
{while (true){int cmd = 0;int n = ::read(0, &cmd, sizeof(cmd)); // 从管道读取任务编号--如果没有被选到这个进程,管道没有内容就会一直在这里阻塞if (n == sizeof(cmd))                 // read成功,执行任务tm.Excute(cmd);else if (n == 0) // 父进程写入端一定会比子进程读入端提前关闭,所以读取到0就可以关闭子进程写入端{std::cout << "pid: " << getpid() << "quit..." << std::endl;break;}else // read失败{std::cout << "read error" << std::endl;break;}}
}
#MakefileBIN=processpool
CC=g++
FLAGS=-c -Wall -std=c++11
LDFLAGS=-o
SRC=$(shell ls *.cc)
OBJ=$(SRC:.cc=.o)$(BIN):$(OBJ)$(CC) $(LDFLAGS) $@ $^
%.o:%.cc$(CC) $(FLAGS) $<.PHONY:clean
clean:rm -rf $(OBJ) $(BIN)

需要注意:

这里的子进程创建出来后不仅仅需要关闭管道的写端,还需要关闭与之前创建的管道的连接,产生这样的情况主要是因为子进程创建时对父进程进行了拷贝:


可以发现第二次创建子进程时,子进程会拷贝到父进程与第一个管道连接的fd,所以子进程不仅要关闭与第二个管道的写入端,还需要关闭与第一个管道的写入端。

这样递推到第n个子进程创建,子进程不仅要关闭与第n个管道的写入段,还需要关闭之前n-1个管道的写入端。所以代码使用了数组存储channel,依次关闭之前与管道的连接。

同时需要注意,每次父进程连接管道时,都是连接写入端,关闭读入端;这样第一次连接管道时,遵循fd分配的原则(优先分配空闲且下标最小的),读入端会占用 fd[3](0,1,2分别为标准输入,标准输出,标准错误),当读入端被关闭时,fd[3]被闲置,所以第二连接管道时,根据fd分配原则,读入端又会被分配到 fd[3],...,所以父进程与每个管道的读入端每次都是fd[3],且反复断开连接,这样也就导致每个子进程与管道的读入端也都是fd[3]。

8.命名管道

  • 匿名管道的应用的一个限制就是只能在具有共同祖先(具有血缘关系)的进程间通信;如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道
  • 命名管道是一种特殊类型的文件。
  • 本质上是内核文件缓冲区,但是不刷新到磁盘中

命名管道的使用:
 

1、命名管道可以从命令行上创建:

mkfifo filename

2、命名管道也可以从程序中创建,相关函数:

#include <sys/stat.h>
#include <fcntl.h>int mkfifo(const char *pathname, mode_t mode);
  • pathname:这是一个字符串指针,用于指定要创建的命名管道的路径名。路径名可以是绝对路径,也可以是相对路径。
  • mode:用于指定命名管道的权限模式,和使用 open 函数创建文件时的权限模式类似。权限模式可以使用八进制数表示,例如 0666 表示所有用户都有读写权限。也可以使用 S_IRUSRS_IWUSR 等宏来组合表示权限。
  • 如果 mkfifo 函数调用成功,它将返回 0
  • 如果调用失败,它将返回 -1,并设置相应的 errno 来指示错误的类型。常见的错误包括:
    • EEXIST:指定的路径名已经存在。
    • EACCES:没有足够的权限在指定的目录下创建命名管道。
    • ENOENT:指定路径名中的某个目录不存在。

3、命名管道与匿名管道的区别:

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

4、命名管道的打开规则:

  • 如果当前打开操作是为读而打开FIFO时
    O_NONBLOCK disable: 阻塞直到有相应进程为写而打开该FIFO
    O_NONBLOCK enable: 立刻返回成功
  • 如果当前打开操作是为写而打开FIFO时
    O_NONBLOCK disable: 阻塞直到有相应进程为读而打开该FIFO
    O_NONBLOCK enable: 立刻返回失败,错误码为ENXIO
  • 如果读端调用open打开文件时,写端还没调用open打开,读端的open就会阻塞;如果写端调用open打开文件时,读端还没调用open打开,写端的open就会阻塞

3、删除命名管道,相关函数:

#include <unistd.h>int unlink(const char *pathname);

unlink 函数的主要功能是从文件系统里删除指定的文件链接要是该文件的链接数因这次操作降为 0,并且没有进程再打开这个文件,系统就会释放该文件所占用的磁盘空间。

  • pathname:这是一个字符串指针,指向要删除的文件或符号链接的路径名。路径名可以是绝对路径,也可以是相对路径。
  • 若 unlink 函数调用成功,会返回 0
  • 若调用失败,会返回 -1,同时设置相应的 errno 来表明错误类型。常见的错误包括:
    • EACCES:没有足够的权限删除该文件。
    • ENOENT:指定的文件或链接不存在。
    • EPERM:文件是目录,而调用进程没有删除目录的权限。
  • 对于目录的删除,不能使用 unlink 函数,而要使用 rmdir 函数。
  • 若有进程正在打开要删除的文件,文件不会立即被删除,直到所有打开该文件的进程都关闭了文件描述符。

例子1:在同一个进程中,向命名管道写入数据,再从命名管道读取数据:

#include <iostream>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>#define FIFO_BASH "pipe_bash" //在命令行创建的命名管道
#define FIFO_FUNC "pipe_func" //在程序中创建的命名管道
#define BUFFER_SIZE 1024int main()
{int fd_bash;int fd_func;char buffer[BUFFER_SIZE];// 创建命名管道if(mkfifo(FIFO_FUNC,0666) == -1){std::cerr << "mkfifo error" << std::endl;exit(EXIT_FAILURE);}// 以读写打开命名管道fd_bash = open(FIFO_BASH,O_RDWR);if (fd_bash == -1){std::cerr << "open error" << std::endl;exit(EXIT_FAILURE);}fd_func = open(FIFO_FUNC,O_RDWR);if (fd_func == -1){std::cerr << "open error" << std::endl;exit(EXIT_FAILURE);}//向管道写入数据memcpy(buffer,"hello pipe_bash",sizeof(buffer));if (write(fd_func,buffer,strlen(buffer)) == -1){std::cerr << "write error" << std::endl;exit(EXIT_FAILURE);}memcpy(buffer,"helloc pipe_func",sizeof(buffer));if (write(fd_bash,buffer,strlen(buffer)) == -1){std::cerr << "write error" << std::endl;exit(EXIT_FAILURE);}//向管道读取数据memset(buffer,0,sizeof(buffer));ssize_t n = read(fd_bash,buffer,sizeof(buffer));if (n == -1){std::cerr << "read error" << std::endl;exit(EXIT_FAILURE);}buffer[n] = '\0';std::cout << "read from pipe_bash: " << buffer << std::endl;memset(buffer,0,sizeof(buffer));n = read(fd_func,buffer,sizeof(buffer));if (n == -1){std::cerr << "read error" << std::endl;exit(EXIT_FAILURE);}buffer[n] = '\0';std::cout << "read from pipe_func: " << buffer << std::endl;//关闭管道close(fd_bash);close(fd_func);//删除管道if(unlink(FIFO_FUNC) == -1 || unlink(FIFO_BASH) == -1){std::cerr << "unlink error" << std::endl;exit(EXIT_FAILURE);}std::cout << "pipe unlink success" << std::endl;return 0;
}

例子2:打开两个shell,一个向命名管道写入数据,一个命名管道读取数据:

注意:如果当前打开操作是为写而打开FIFO时,阻塞直到有相应进程为读而打开该FIFO;如果当前打开操作是为读而打开FIFO时,阻塞直到有相应进程为写而打开该FIFO;

例子3:用命名管道实现 server & client 通信(服务器与客户端通信)

//serverPipe.cc#include <iostream>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#define ERR_EXIT(m) \do \{ \perror(m); \exit(EXIT_FAILURE); \} while(0)int main()
{umask(0);//设置掩码为0if(mkfifo("mypipe",0666) < 0)ERR_EXIT("mkfifo error");int rfd = open("mypipe",O_RDONLY);if(rfd < 0)ERR_EXIT("open error");char buf[1024];while(1){buf[0] = 0;std::cout << "Please wait..." << std::endl;ssize_t s = read(rfd,buf,sizeof(buf)-1);if(s > 0){buf[s] = 0;std::cout << "client say# " << buf << std::endl;}else if(s == 0){std::cout << "client quit, exit now!" << std::endl;break;}elseERR_EXIT("read error");    }close(rfd);return 0;
}
//clientPipe.cc#include <iostream>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#define ERR_EXIT(m) \do \{ \perror(m); \exit(EXIT_FAILURE); \} while(0)int main()
{int wfd = open("mypipe",O_WRONLY);if(wfd < 0)ERR_EXIT("open error");char buf[1024];while(1){buf[0] = 0;std::cout << "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 error");}}close(wfd);return 0;
}
//Makefile.PHONY: all
all: server clientserver: serverPipe.ccg++ -o $@ $^
client: clientPipe.ccg++ -o $@ $^.PHONY: clean
clean:rm -rf server client

三、system V 共享内存 (SHM)

System V 共享内存是 Linux 系统中一种用于进程间通信(IPC)的机制,它允许不同的进程访问同一块物理内存区域,从而实现高效的数据共享。

1.基本概念:

  • 共享内存原理:多个进程可以将同一块物理内存映射到各自的虚拟地址空间中,这样进程就可以像访问自己的内存一样访问共享内存区域避免了数据的复制,提高了数据传输的效率
  • 共享内存 = 共享内存的内核数据结构 + 内存块
  • System V 共享内存特点
    • 高效性数据直接在共享内存区域读写,无需进行数据的复制(),因此速度较快。
    • 复杂性需要手动进行同步和互斥操作,以避免多个进程同时访问共享内存时产生数据竞争问题。
    • 持久性:共享内存对象在系统中是持久存在的,直到被显式删除,即使创建它的进程已经退出
  • 如何将同一块物理内存映射到各自的虚拟地址空间中:
  1. 创建共享内存段:进程通过系统调用shmget创建一个共享内存段,内核会为其分配相应的物理内存,并在内存管理数据结构中记录该共享内存段的信息,包括其大小、访问权限等。
  2. 映射到虚拟地址空间:创建共享内存段后,进程使用shmat系统调用将共享内存段映射到自己的虚拟地址空间。内核会在进程的页表中添加相应的映射条目,将虚拟地址与共享内存的物理地址建立关联。
  3. 维护页表和内存管理数据结构:内核会维护进程的页表以及系统的内存管理数据结构,确保每个进程的虚拟地址空间与共享内存的物理地址之间的映射关系正确无误。当进程访问共享内存时,通过页表的映射,CPU 能够正确地将虚拟地址转换为物理地址,从而实现对共享内存的访问。
  4. 权限检查:在映射和访问共享内存时,内核会进行权限检查,以确保进程具有适当的权限来访问共享内存。只有具有相应权限的进程才能成功地将共享内存映射到自己的虚拟地址空间并进行读写操作。

2.共享内存示意图:

3.共享内存数据结构:

struct shmid_ds{
#ifdef __USE_TIME_BITS64
# include <bits/types/struct_shmid64_ds_helper.h>
#elsestruct ipc_perm shm_perm;		/* operation permission struct */size_t shm_segsz;			/* size of segment in bytes */
# if __TIMESIZE == 32__time_t shm_atime;			/* time of last shmat() */unsigned long int __shm_atime_high;__time_t shm_dtime;			/* time of last shmdt() */unsigned long int __shm_dtime_high;__time_t shm_ctime;			/* time of last change by shmctl() */unsigned long int __shm_ctime_high;
# else__time_t shm_atime;			/* time of last shmat() */__time_t shm_dtime;			/* time of last shmdt() */__time_t shm_ctime;			/* time of last change by shmctl() */
# endif__pid_t shm_cpid;			/* pid of creator */__pid_t shm_lpid;			/* pid of last shmop */shmatt_t shm_nattch;		/* number of current attaches */__syscall_ulong_t __glibc_reserved5;__syscall_ulong_t __glibc_reserved6;
#endif};

4.共享内存的使用:
 

1、生成key值唯一值

ftok 函数是在 Unix 和类 Unix 系统中用于生成 System V IPC(进程间通信)键值的函数,主要用于消息队列、共享内存和信号量等 IPC 机制。
ftok 函数通过一个已存在的文件路径一个项目 ID 生成一个唯一的键值(key_t 类型),这个键值可以被多个进程使用,以确保它们能够访问同一个 IPC 对象(如共享内存段、消息队列等)。

#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);
  • pathname:这是一个指向已存在文件或目录路径的字符串指针。ftok 函数会根据该文件的 inode 编号和设备编号等信息来参与键值的生成。所以,这个文件必须是实际存在的,而且在不同进程调用 ftok 时使用相同的路径。
  • proj_id:这是一个用户指定的项目 ID,是一个非零的 8 位整数(取值范围是 1 - 255)ftok 函数会结合 pathname 对应的文件信息和这个 proj_id 来生成最终的键值
  • 成功:如果函数调用成功,会返回一个唯一的 key_t 类型的键值,后续可以使用这个键值来创建或访问 IPC 对象。
  • 失败:如果调用失败,会返回 -1,并且会设置 errno 来指示具体的错误原因,常见的错误包括:
    • EACCES:没有权限访问指定的 pathname
    • EFAULTpathname 指向的地址无效。
    • ENOENT:指定的 pathname 不存在。
    • ENOTDIRpathname 中包含的目录部分不存在。

2、创建或获取共享内存段

使用 shmget 函数来创建或获取一个共享内存段。该函数的原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);
  • key:一个唯一的键值,用于标识共享内存段(相当于名字)。可以使用 ftok 函数生成一个键值。
    这个key值,必须是用户输入,为什么OS不能直接自己生成呢?
    调用shmget创建一个共享内存,这就需要在一个进程中调用,如果key由OS生成,就没办法让其他进程获取key(如果可以那还需要什么共享内存实现进程通信?);所以key需要用户输入,由用户管理,这样就可以让多个进程获取同一个key,访问同一个空间;同时key值不同保证shm的唯一性

  • size:共享内存段的大小,以字节为单位。
  • shmflg:标志位,用于指定共享内存的创建方式和权限。
  1. IPC_CREAT:如果我们单独使用IPC_CREAT,如果key对应的shm不存在,就创建;如果key对应的shm存在,就获取它,并返回 ---- 这样保证了调用进程能拿到共享内存
  2. IPC_CREAT | IPC_EXCL:如果key对应的shm不存在,就创建它;如果不存在该shm,则出错返回 --- 这样就保证,只要成功就一定是新的共享内存;绝不返回已经创建过的shm。
  3. IPC_EXCL:单独使用没有意义。
  • 成功时shmget 会返回一个非负整数,这个值被称为共享内存标识符(shared memory identifier),简称为 shmidshmid 是内核为新创建或已存在的共享内存段分配的唯一整数编号,后续在使用 shmat(将共享内存段附加到进程的地址空间)、shmdt(将共享内存段从进程的地址空间分离)和 shmctl(对共享内存段进行控制操作,如删除)等系统调用时,都需要使用这个 shmid 来指定具体操作的共享内存段。

  • 失败时shmget 会返回 -1,并设置 errno 来指示具体的错误原因。以下是一些常见的错误情况及对应的 errno 值:

    • EACCES:表示没有权限访问指定 key 对应的共享内存段。可能是由于请求的操作权限不足,例如尝试以写模式访问只读的共享内存段。
    • EEXIST:若在 shmflg 中同时指定了 IPC_CREAT 和 IPC_EXCL,且 key 对应的共享内存段已经存在,就会返回该错误。
    • EIDRMkey 对应的共享内存段已被标记为删除,当前正处于等待最后一个进程与之分离的状态。
    • ENOENT:在 shmflg 中未指定 IPC_CREAT,而 key 对应的共享内存段并不存在,就会返回此错误。
    • ENOMEM:系统没有足够的内存来创建指定大小的共享内存段。
    • ENOSPC:系统已经达到了允许创建的共享内存段数量上限,或者文件系统中没有足够的空间来支持新的共享内存段。

key和shmid的区别:

  • shmid:只给用户用的一个标识shm的标识符
  • key:只作为内核中,区分shm唯一性的标识符;不作为用户管理shm的id值

3、将共享内存段附加到进程的地址空间

使用 shmat 函数将共享内存段附加到进程的地址空间,以便进程可以访问该共享内存段

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>void *shmat(int shmid, const void *shmaddr, int shmflg);
  • shmid:共享内存段的标识符,由 shmget 函数返回。
  • shmaddr:指定共享内存段在进程地址空间中的附加地址,通常设置为 NULL,让系统自动选择合适的地址。
  • shmflg:一组标志位,用于指定附加操作的一些选项,例如是否以只读模式附加等,使用与上面的shmget的shmflg参数一样。
  • 成功时shmat 会返回一个指向附加到进程地址空间的共享内存段起始地址的指针。进程可以通过这个指针来读写共享内存段中的数据。例如,若将返回的指针赋值给一个字符指针,就可以像操作普通字符数组一样操作共享内存
  • 失败时shmat 会返回 (void *)-1,同时设置 errno 来指示具体的错误原因。以下是一些常见的错误情况及对应的 errno 值:
    • EACCES:没有足够的权限来附加共享内存段。例如,尝试以写模式附加一个只读的共享内存段。
    • EINVALshmid 不是一个有效的共享内存标识符,或者 shmaddr 和 shmflg 的组合不合法。
    • ENOMEM:系统没有足够的内存来完成附加操作。
    • EIDRM:指定的共享内存段已经被标记为删除,正在等待最后一个进程与之分离。

4、在共享内存段中读写数据

一旦共享内存段被附加到进程的地址空间,进程就可以像访问普通内存一样访问共享内存段。


5、将共享内存段从进程的地址空间分离

使用 shmdt 函数将共享内存段从进程的地址空间分离。

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>int shmdt(const void *shmaddr);
  • shmaddr:共享内存段在进程地址空间中的附加地址,由 shmat 函数返回。
  • 成功时shmdt 函数返回 0。这表明共享内存段已经成功从调用进程的地址空间分离。此时,进程不能再使用该指针访问共享内存段,但共享内存段本身仍然存在于系统中,直到它被所有附加的进程都分离并且被标记为删除。
  • 失败时shmdt 函数返回 -1,同时会设置 errno 变量来指示具体的错误原因。常见的错误情况及对应的 errno 值如下:
    • EINVALshmaddr 不是一个有效的指向共享内存段的指针,即该地址并没有被附加到当前进程的共享内存段上。
    • ENOMEM:在某些系统实现中,这个错误可能表示系统没有足够的内存来完成分离操作,但这种情况比较罕见。

6、删除共享内存段

shmctl 是一个用于控制共享内存段的系统调用,它可以对共享内存段执行多种操作,如获取或设置共享内存段的状态信息、删除共享内存段等。

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • shmid:由 shmget 函数返回的共享内存标识符,用于指定要操作的共享内存段。
  • cmd:指定要执行的操作命令,常见的命令有:
    • IPC_STAT:将共享内存段的当前状态信息复制到 buf 指向的 struct shmid_ds 结构体中。
    • IPC_SET:使用 buf 指向的 struct shmid_ds 结构体中的值来设置共享内存段的相关属性,如权限等。
    • IPC_RMID:标记共享内存段为删除状态,当所有附加到该共享内存段的进程都分离后,该共享内存段将被实际删除。
  • buf:一个指向 struct shmid_ds 结构体的指针,用于存储或提供共享内存段的状态信息。在 IPC_STAT 操作中,该结构体用于接收信息;在 IPC_SET 操作中,该结构体用于提供要设置的信息;在 IPC_RMID 操作中,该参数可以为 NULL
  • 成功时:根据不同的 cmd 命令,返回值有所不同:
    • 对于 IPC_STAT 和 IPC_SET 命令,成功时返回 0,表示操作已成功完成。这意味着共享内存段的状态信息已成功获取或设置。
    • 对于 IPC_RMID 命令,成功时也返回 0,表示共享内存段已被标记为删除状态。
  • 失败时shmctl 函数返回 -1,同时会设置 errno 变量来指示具体的错误原因。常见的错误情况及对应的 errno 值如下:
    • EACCES:没有足够的权限来执行指定的操作。例如,尝试以写模式修改共享内存段的属性,但当前用户没有相应的权限。
    • EFAULTbuf 指针指向的内存地址无效,无法访问该内存区域。
    • EIDRM:指定的共享内存段已经被标记为删除,正在等待最后一个进程与之分离。
    • EINVALshmid 不是一个有效的共享内存标识符,或者 cmd 命令不是一个合法的操作命令。
    • EPERM:调用者没有足够的权限来执行 IPC_SET 或 IPC_RMID 操作。

例子1:共享内存实现通信

//comm.h#ifndef _COMM_H_
#define _COMM_H_
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PROJ_ID 0x6666int createShm(int size);
int destroyShm(int shmid);
int getShm(int size);#endif
//comm.cc#include "comm.h"static int commShm(int size, int flags)
{key_t key = ftok(PATHNAME, PROJ_ID);if(key < 0){std::cerr << "ftok error" << std::endl;exit(EXIT_FAILURE);}int shmid = shmget(key,size,flags);if(shmid < 0){std::cerr << "shmget error" << std::endl;exit(EXIT_FAILURE);}return shmid;
}int destroyShm(int shmid)
{if(shmctl(shmid,IPC_RMID,nullptr) < 0){std::cerr << "shmctl error" << std::endl;exit(EXIT_FAILURE);}return 0;
}int createShm(int size)
{return commShm(size,IPC_CREAT|IPC_EXCL|0666);
}
int getShm(int size)
{return commShm(size,IPC_CREAT);
}
//server.cc#include "comm.h"int main()
{// 创建共享内存int shmid = createShm(4096);// 映射共享内存char *addr = (char*)shmat(shmid,nullptr,0);sleep(2);for(int i = 0;i < 26;i++){std::cout << "client#" << addr << std::endl;sleep(1);}// 取消映射shmdt(addr);sleep(2);// 释放共享内存destroyShm(shmid);return 0;
}
//client.cc#include "comm.h"int main()
{int shmid = getShm(4096);sleep(1);char *addr = (char*)shmat(shmid,nullptr,0);sleep(2);for(int i = 0; i < 26; i++){addr[i] = 'A' + i;addr[i+1] = 0;sleep(1);}shmdt(addr);sleep(2);return 0;
}
//Makefile.PHONY:all
all:server client
server:server.cc comm.ccg++ -o $@ $^ -std=c++11
client:client.cc comm.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -rf server client

需要注意:共享内存没有进程同步与互斥,共享内存缺乏访问控制,会带来并发问题。
前面我们已经学过管道,管道自带进程同步与互斥功能,所以可以通过利用管道的的进程同步与互斥功能,来实现共享内存的同步与互斥。

例子2:借助管道实现访问控制版的共享内存

共享内存和管道结合,共享内存实现大量数据通信,管道实现少量数据通信;结合了共享内存通信快特性,又结合了管道的保护机制弥补共享内存没有保护机制的缺点。  

//Comm.hpp#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 <cstdlib>
#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/ayanami/myfile1/system_v_pipe"//例如是当前目录
#define PROJ_ID 0x66
#define SHM_SIZE 4096 // 共享内存的大小,最好是页(PAGE: 4096)的整数倍
#define FIFO_NAME "./fifo"
// 初始化类 -- 用来创建管道文件
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);
}
//利用管道实现同步和互斥--阻塞
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;
}
//利用管道实现同步和互斥--唤醒
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";
}
// 转换为16进制
string TransToHex(key_t k) 
{char buffer[32];snprintf(buffer, sizeof buffer, "0x%x", k);return buffer;
}
//ShmServer.cc#include "Comm.hpp"Init init;//初始化类 -- 用来创建管道文件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. 访问控制int fd = OpenFIFO(FIFO_NAME, O_RDONLY);while (true) {// 阻塞Wait(fd);// 临界区printf("%s\n", shmaddr);if (strcmp(shmaddr, "quit") == 0)break;}CloseFifo(fd);// 5. 将指定的共享内存,从自己的地址空间中去关联int n = shmdt(shmaddr);assert(n != -1);Log("detach shm done", Debug) << " shmid : " << shmid << endl;// 6. 删除共享内存,IPC_RMID即便是有进程和当下的shm挂接,依旧删除共享内存n = shmctl(shmid, IPC_RMID, nullptr);assert(n != -1);(void)n;Log("delete shm done", Debug) << " shmid : " << shmid << endl;return 0;
}
//Shmclient.cc#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. 获取共享内存int shmid = shmget(k, SHM_SIZE, 0);if (shmid == -1) {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. 写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);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;
}
//Makefile# 目标文件名
SERVER_TARGET = ShmServer
CLIENT_TARGET = ShmClient# 源文件
SRCS_SERVER = ShmServer.cc
SRCS_CLIENT = ShmClient.cc# 头文件路径
INC_DIR =./# C++ 编译器
CXX = g++# 编译器选项
CXXFLAGS = -Wall -g -I$(INC_DIR)# 目标文件
OBJS_SERVER = $(SRCS_SERVER:.cc=.o)
OBJS_CLIENT = $(SRCS_CLIENT:.cc=.o).PHONY: all cleanall: $(SERVER_TARGET) $(CLIENT_TARGET)$(SERVER_TARGET): $(OBJS_SERVER)$(CXX) $(CXXFLAGS) -o $@ $^$(CLIENT_TARGET): $(OBJS_CLIENT)$(CXX) $(CXXFLAGS) -o $@ $^%.o: %.cc$(CXX) $(CXXFLAGS) -c -o $@ $<clean:rm -f $(OBJS_SERVER) $(OBJS_CLIENT) $(SERVER_TARGET) $(CLIENT_TARGET)

5.共享内存的效率为什么比管道的高

  • 数据拷贝次数少
    • 管道:数据在写入管道和从管道读取时,需要在内核空间和用户空间之间进行多次拷贝。例如,写进程将数据从用户空间拷贝到内核空间的管道缓冲区,读进程再从内核空间的管道缓冲区将数据拷贝到用户空间,这增加了数据传输的时间开销。
    • 共享内存:多个进程可以直接访问同一块物理内存区域,数据不需要在不同的地址空间之间拷贝。进程直接在共享内存区域中进行读写操作,减少了数据拷贝的时间,提高了数据传输效率。
  • 通信方式更直接
    • 管道:管道是一种半双工的通信方式,数据只能单向流动,若要实现双向通信,需要建立两个管道。并且管道的读写操作是顺序执行的,读操作和写操作需要相互协调,可能会导致一定的阻塞和等待。
    • 共享内存:共享内存允许进程以全双工的方式同时进行读写操作,多个进程可以同时访问共享内存中的不同位置,实现更灵活、更高效的通信。只要进程能够正确地处理同步和互斥问题,就可以充分利用共享内存的并发访问特性,提高通信效率。
  • 无额外的缓冲管理开销
    • 管道:管道有固定大小的缓冲区,当缓冲区满时,写进程会被阻塞;当缓冲区空时,读进程会被阻塞。这就需要额外的机制来管理缓冲区的状态,以及处理进程的阻塞和唤醒,增加了系统的开销。
    • 共享内存:共享内存本身没有内置的缓冲区管理机制,进程可以直接对共享内存进行读写,不需要考虑缓冲区的满空状态。虽然这需要进程自己负责实现同步和互斥机制来保证数据的一致性,但相比于管道的缓冲区管理,减少了系统层面的额外开销。

四、ipc系列的命令

在 Linux 系统里,IPC(Inter-Process Communication,进程间通信)系列命令可用于管理和查看系统的 IPC 资源,像共享内存、消息队列以及信号量等。

ipcs:
此命令用于显示系统当前的 IPC 资源状态。


常用选项:

  • -m:显示共享内存信息。
  • -q:显示消息队列信息。
  • -s:显示信号量信息。
  • -a:显示所有 IPC 资源信息(默认选项)。

示例:

# 显示所有IPC资源信息
ipcs# 仅显示共享内存信息
ipcs -m# 仅显示消息队列信息
ipcs -q# 仅显示信号量信息
ipcs -s

ipcrm:
该命令用于删除 IPC 资源。


常用选项:

  • -m <shmid>:删除指定 ID 的共享内存段。
  • -q <msqid>:删除指定 ID 的消息队列。
  • -s <semid>:删除指定 ID 的信号量集。

示例:

# 删除ID为123456的共享内存段
ipcrm -m 123456# 删除ID为234567的消息队列
ipcrm -q 234567# 删除ID为345678的信号量集
ipcrm -s 345678

ipcmk:
这个命令用于创建 IPC 资源。


常用选项:

  • -M <size>:创建指定大小(以字节为单位)的共享内存段。
  • -Q:创建一个消息队列。
  • -S <nsems>:创建包含指定数量信号量的信号量集。

示例:

# 创建一个大小为1024字节的共享内存段
ipcmk -M 1024# 创建一个消息队列
ipcmk -Q# 创建一个包含5个信号量的信号量集
ipcmk -S 5

ipcstat:
此命令用于显示 IPC 资源的统计信息。


常用选项:

  • -m:显示共享内存的统计信息。
  • -q:显示消息队列的统计信息。
  • -s:显示信号量的统计信息。

示例:

# 显示共享内存的统计信息
ipcstat -m# 显示消息队列的统计信息
ipcstat -q# 显示信号量的统计信息
ipcstat -s

五、system V消息队列 与 责任链模式

1.概述

  • 消息队列提供了一个从一个进程向另外一个进程发送有类型块数据的方法
  • 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
  • 消息队列也有管道一样的不足,就是每个消息的最大长度是有上限的(MSGMAX)
  • 每个消息队列的总的字节数也是有上限的(MSGMNB),系统上消息队列的总数也有上限(MSGMIN)的

2.通信形式和结构对象

IPC对象数据结构:

struct ipc_perm {key_t __key; /* Key supplied to xxxget(2) */uid_t uid; /* Effective UID of owner */gid_t gid; /* Effective GID of owner */uid_t cuid; /* Effective UID of creator */gid_t cgid; /* Effective GID of creator */unsigned short mode; /* Permissions */unsigned short __seq; /* Sequence number */
};

消息队列结构:

struct msqid_ds {struct ipc_perm msg_perm;struct msg msg_first; / first message on queue,unused */struct msg msg_last; / last message in queue,unused */__kernel_time_t msg_stime; /* last msgsnd time */__kernel_time_t msg_rtime; /* last msgrcv time */__kernel_time_t msg_ctime; /* last change time */unsigned long msg_lcbytes; /* Reuse junk fields for 32 bit */unsigned long msg_lqbytes; /* ditto */unsigned short msg_cbytes; /* current number of bytes on queue */unsigned short msg_qnum; /* number of messages in queue */unsigned short msg_qbytes; /* max number of bytes on queue */__kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */__kernel_ipc_pid_t msg_lrpid; /* last receive pid */
};

内核表示:

 3.消息队列特点

  • 消息具有类型:每个消息都有一个整数类型(long)的标识,接收进程可以根据消息类型有选择地接收消息,而不必按照消息进入队列的顺序接收。
  • 异步通信:发送进程可以在任何时候向消息队列发送消息,而不必等待接收进程准备好接收。接收进程也可以在方便的时候从队列中读取消息,这使得进程间的通信更加灵活,解耦了发送方和接收方的执行时间。
  • 有界缓冲区:消息队列有一个最大容量限制,当队列满时,发送消息的操作可能会被阻塞,直到队列中有空间可用。同样,当队列为空时,接收消息的操作也可能会被阻塞,直到有新的消息进入队列。
  • 没有自带同步与互斥机制:当多个进程同时访问消息队列时,需要注意同步和互斥问题,以避免数据竞争和不一致性。可以使用信号量或其他同步机制来确保对消息队列的正确访问。
  • 生命周期:消息队列的生命周期是随内核的
  • 消息队列支持全双工通信

4.消息队列的使用

  • msgget 函数
    • 功能:用于创建一个新的消息队列获取一个已存在的消息队列的标识符。
    • 原型int msgget(key_t key, int msgflg);
    • 参数
      • key:是一个键值,用于唯一标识消息队列。可以使用ftok函数根据路径名和项目标识符生成一个键值,也可以使用IPC_PRIVATE常量来创建一个仅在当前进程及其子进程之间共享的私有消息队列。
      • msgflg:是一组标志位,用于指定消息队列的创建模式和访问权限。常见的标志位有IPC_CREAT(如果消息队列不存在则创建它,存在就返回标识符;这样确保一定返回一个消息队列的标识符)、IPC_EXCL(与IPC_CREAT一起使用,确保创建一个新的消息队列,如果队列已存在则返回错误),以及文件权限位(如0666表示读写权限);例如:IPC_CREAT | IPC_EXCL | 0666。
    • 返回值成功时返回消息队列的标识符,失败时返回-1,并设置errno以指示错误原因。
       

  • msgsnd 函数
    • 功能:用于向指定的消息队列发送一条消息。
    • 原型int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
    • 参数
      • msqid:是要发送消息的消息队列的标识符,由msgget函数返回。
      • msgp:是一个指向消息缓冲区的指针,消息缓冲区的结构通常如下定义:
        struct msgbuf {long mtype;       // 消息类型,必须是长整型char mtext[1];    // 消息正文,可以是任意类型的数据,这里定义为1字节只是为了说明结构,实际使用中会根据需要调整大小
        };
      • msgsz:指定消息正文的长度不包括mtype字段的长度;例如:传参sizeof(msgbuf.mtext)
      • msgflg:用于指定发送消息的方式。如果设置为0,当消息队列已满时,msgsnd函数会阻塞直到队列有空间可用;如果设置了IPC_NOWAIT标志,函数将不会阻塞,而是立即返回-1,并设置errnoEAGAIN表示队列已满。
    • 返回值:成功时返回0,失败时返回-1,并设置errno以指示错误原因。

  • msgrcv 函数
    • 功能:从指定的消息队列中接收一条消息
    • 原型ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
    • 参数
      • msqid:要接收消息的消息队列的标识符。
      • msgp:指向用于接收消息的缓冲区的指针,其结构与msgsnd函数中使用的msgbuf结构相同。
      • msgsz:指定接收缓冲区的大小,不包括mtype字段的长度
      • msgtyp:用于指定要接收的消息类型。如果msgtyp0,则接收队列中的第一条消息,而不考虑消息类型如果msgtyp大于0,则接收类型为msgtyp的第一条消息;如果msgtyp小于0,则接收类型小于或等于msgtyp绝对值的第一条消息。
      • msgflg:用于指定接收消息的方式。如果设置为0,当队列为空时,msgrcv函数会阻塞直到有消息可用;如果设置了IPC_NOWAIT标志,函数将不会阻塞,而是立即返回-1,并设置errnoENOMSG表示队列为空。
    • 返回值:成功时返回实际接收到的消息正文的长度,不包括mtype字段的长度;失败时返回-1,并设置errno以指示错误原因。

  • msgctl 函数
    • 功能:用于对消息队列执行各种控制操作,如获取消息队列的状态信息、设置消息队列的属性、删除消息队列等。
    • 原型int msgctl(int msqid, int cmd, struct msqid_ds *buf);
    • 参数
      • msqid:要操作的消息队列的标识符。
      • cmd:指定要执行的控制命令,常见的命令有IPC_STAT(获取消息队列的状态信息,将信息存储在buf指向的结构中)、IPC_SET(根据buf指向的结构中的值设置消息队列的属性)、IPC_RMID(删除消息队列)。
      • buf:是一个指向msqid_ds结构的指针,用于存储或传递消息队列的属性信息。msqid_ds结构包含了消息队列的各种属性,如队列的所有者、权限、消息数量等;如果是删除消息队列,此处填nullptr。
    • 返回值:成功时返回0,失败时返回-1,并设置errno以指示错误原因。

5.示例代码:实现server接受,client发送

//MsgQueue.hpp#ifndef _MSGQUEUE_HPP_
#define _MSGQUEUE_HPP_#include <iostream>
#include <unistd.h>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/msg.h>
#include <sys/ipc.h>#define PATHNAME "/home/ayanami/myfile1/system_v_message_queue"
#define PROJID 0x1234
#define GET_MSGQUEUE (IPC_CREAT)
#define CREATE_MSGQUEUE (IPC_CREAT | IPC_EXCL | 0666)const int default_fd = -1; //缺省id为-1
const int default_size = 4096;class MsgQueue
{//有类型数据块struct msgbuf{long mtype;  //类型--必须是long类型char mtext[default_size];  //内容};
public:MsgQueue():_msgfd(default_fd){}//创建消息队列void Create(int flag){//获取唯一的键值key_t key = ftok(PATHNAME,PROJID);if(key == -1){std::cerr << "ftok error" << std::endl;exit(EXIT_FAILURE);}//打印键值std::cout << "key: " << std::hex << key << std::endl;//创建消息队列_msgfd = msgget(key,flag);if(_msgfd == -1){std::cerr << "msgget error" << std::endl;exit(EXIT_FAILURE);}std::cout << "msgqueue id: " << _msgfd << std::endl;}//发送消息void Send(int type,const std::string& text){struct msgbuf msg;memset(&msg,0,sizeof(msg));msg.mtype = type;memcpy(msg.mtext,text.c_str(),text.size());//问题:不能填写成为sizeof(msg)int n = msgsnd(_msgfd,&msg,sizeof(msg.mtext),0);if(n == -1){std::cerr << "msgsnd error" << std::endl;exit(EXIT_FAILURE);}std::cout << "send message: " << text << std::endl;}//接收消息,参数设置成输出型参数void Recv(int type,std::string& text){struct msgbuf msg;memset(&msg,0,sizeof(msg));int n = msgrcv(_msgfd,&msg,sizeof(msg.mtext),type,0);if(n == -1){std::cerr << "msgrcv error" << std::endl;return;}msg.mtext[n] = '\0';text = msg.mtext;type = msg.mtype;}//获取消息队列中的属性void GetAttr(){struct msqid_ds outbuffer;int n = msgctl(_msgfd,IPC_STAT,&outbuffer);if(n == -1){std::cerr << "msgctl error" << std::endl;return;}std::cout << "msgqueue attr: " << std::hex << outbuffer.msg_perm.__key << std::endl;}//删除消息队列void Destroy(){int n = msgctl(_msgfd,IPC_RMID,0);if(n == -1){std::cerr << "msgctl error" << std::endl;exit(EXIT_FAILURE);}std::cout << "msgqueue destroyed" << std::endl;}~MsgQueue(){}
private:int _msgfd;
};//定义消息类型
#define MSG_TYPE_CLIENT 1
#define MSG_TYPE_SERVER 2class Server:public MsgQueue
{
public:Server(){MsgQueue::Create(CREATE_MSGQUEUE);std::cout << "Server created msgqueue done" << std::endl;MsgQueue::GetAttr();}~Server(){MsgQueue::~MsgQueue();}
};class Client:public MsgQueue
{
public:Client(){MsgQueue::Create(GET_MSGQUEUE);std::cout << "Client created msgqueue done" << std::endl;}~Client(){}
};#endif
//Server.cc#include "MsgQueue.hpp"int main()
{std::string text;Server server;while(true){//server只接受消息//如果消息队列为空,就会阻塞等待server.Recv(MSG_TYPE_CLIENT,text);std::cout << "Received: " << text << std::endl;//跳出if(text == "quit")break;}return 0;
}
//Client.cc#include "MsgQueue.hpp"int main()
{Client client;while(true){//只让client发送消息std::string input;std::cout << "Please input message: ";std::getline(std::cin,input);client.Send(MSG_TYPE_CLIENT,input);//跳出if(input == "quit")break;}return 0;
}
//Makefile.PHONY: all
all: Client ServerClient: Client.ccg++ -o Client Client.cc -std=c++11
Server: Server.ccg++ -o Server Server.cc -std=c++11.PHONY: clear
clear:rm -rf Client Server

6.责任链模式(Chain of Responsibility Pattren)

在上一个代码添加新需求:

  • client 发送给 server的输入内容,拼接上时间,进程pid信息
  • server收到的内容持久化保存到文件中
  • 文件的内容如果过大,要进行切片保存并在指定的目录下打包保存,命令自定义

解决方案:责任链模式
一种行为设计模式,它允许你将请求沿着处理者链进行传递。每个处理者都对请求进行检查,以决定是否处理它。如果处理者能够处理该请求,它就处理它;否则,它将请求传递给链中的下一个处理者。这个模式使得多个对象都有机会处理请求,从而避免了请求的发送者和接收者之间的紧耦合。

责任链模式的主要角色:

  1. 抽象处理者(Handler):定义了处理请求的接口,通常包含一个指向下一个处理者的引用,以及处理请求的抽象方法。
  2. 具体处理者(Concrete Handler):实现抽象处理者接口,处理请求或者将请求传递给下一个处理者。
  3. 客户端(Client):创建请求并将其发送到责任链的第一个处理者。


优点:

  • 降低耦合度:请求的发送者和接收者解耦,发送者不需要知道哪个处理者会处理请求。
  • 灵活性:可以动态地添加或删除处理者,或者改变处理者的顺序。

缺点:

  • 性能问题:如果责任链过长,可能会影响性能。
  • 调试困难:由于请求的处理过程分散在多个处理者中,调试可能会比较困难。

在上面代码的基础上使用责任链模式处理新添加的需求:
责任链模式的代码结构:

类结构

  1. HandlerText(基类)

    • 定义了责任链节点的基本接口,包含纯虚函数 Excute 用于执行具体的处理任务。
    • 提供了 SetNext 方法用于设置下一个责任链节点,以及 Enable 和 Disable 方法用于启用或禁用当前节点。
    • 包含一个指向下一个节点的智能指针 _next 和一个布尔值 _enable 用于表示节点是否启用。
  2. HandlerTextFormat(格式化处理节点)

    • 继承自 HandlerText,实现了 Excute 方法。
    • 在 Excute 方法中,对输入的文本进行格式化处理,添加时间戳和进程 ID。
    • 将处理结果传递给下一个节点。
  3. HandlerTextSaveFile(文件保存处理节点)

    • 继承自 HandlerText,实现了 Excute 方法。
    • 在构造函数中,检查并创建保存文件的目录。
    • 在 Excute 方法中,将处理后的文本保存到指定的文件中。
    • 将处理结果传递给下一个节点。
  4. HandlerTextBackup(文件备份处理节点)

    • 继承自 HandlerText,实现了 Excute 方法。
    • 在构造函数中,接收文件路径、文件名和最大行数作为参数。
    • 在 Excute 方法中,检查文件是否超过最大行数,如果超过则进行切片和打包备份。
    • 使用 fork 创建子进程进行文件重命名和打包操作,父进程等待子进程完成后删除备份文件。
    • 将处理结果传递给下一个节点。
  5. HandlerEntry(责任链入口类)

    • 负责创建和初始化责任链节点,并设置节点的处理顺序。
    • 提供 EnableHandler 方法用于启用或禁用不同的处理节点。
    • 提供 Run 方法用于启动责任链处理流程。

代码流程

  1. 创建 HandlerEntry 对象,初始化责任链节点。
  2. 调用 EnableHandler 方法启用或禁用不同的处理节点。
  3. 调用 Run 方法,传入需要处理的文本。
  4. 责任链节点依次处理文本,每个节点将处理结果传递给下一个节点,直到处理到责任链结尾。
//MsgQueue.hpp#ifndef _MSGQUEUE_HPP_
#define _MSGQUEUE_HPP_#include <iostream>
#include <unistd.h>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/msg.h>
#include <sys/ipc.h>#define PATHNAME "/home/ayanami/myfile1/system_v_message_queue"
#define PROJID 0x1234
#define GET_MSGQUEUE (IPC_CREAT)
#define CREATE_MSGQUEUE (IPC_CREAT | IPC_EXCL | 0666)const int default_fd = -1; //缺省id为-1
const int default_size = 4096;class MsgQueue
{//有类型数据块struct msgbuf{long mtype;  //类型--必须是long类型char mtext[default_size];  //内容};
public:MsgQueue():_msgfd(default_fd){}//创建消息队列void Create(int flag){//获取唯一的键值key_t key = ftok(PATHNAME,PROJID);if(key == -1){std::cerr << "ftok error" << std::endl;exit(EXIT_FAILURE);}//打印键值std::cout << "key: " << std::hex << key << std::endl;//创建消息队列_msgfd = msgget(key,flag);if(_msgfd == -1){std::cerr << "msgget error" << std::endl;exit(EXIT_FAILURE);}std::cout << "msgqueue id: " << _msgfd << std::endl;}//发送消息void Send(int type,const std::string& text){struct msgbuf msg;memset(&msg,0,sizeof(msg));msg.mtype = type;memcpy(msg.mtext,text.c_str(),text.size());//问题:不能填写成为sizeof(msg)int n = msgsnd(_msgfd,&msg,sizeof(msg.mtext),0);if(n == -1){std::cerr << "msgsnd error" << std::endl;exit(EXIT_FAILURE);}std::cout << "send message: " << text << std::endl;}//接收消息,参数设置成输出型参数void Recv(int type,std::string& text){struct msgbuf msg;memset(&msg,0,sizeof(msg));int n = msgrcv(_msgfd,&msg,sizeof(msg.mtext),type,0);if(n == -1){std::cerr << "msgrcv error" << std::endl;return;}msg.mtext[n] = '\0';text = msg.mtext;type = msg.mtype;}//获取消息队列中的属性void GetAttr(){struct msqid_ds outbuffer;int n = msgctl(_msgfd,IPC_STAT,&outbuffer);if(n == -1){std::cerr << "msgctl error" << std::endl;return;}std::cout << "msgqueue attr: " << std::hex << outbuffer.msg_perm.__key << std::endl;}//删除消息队列void Destroy(){int n = msgctl(_msgfd,IPC_RMID,0);if(n == -1){std::cerr << "msgctl error" << std::endl;exit(EXIT_FAILURE);}std::cout << "msgqueue destroyed" << std::endl;}~MsgQueue(){}
private:int _msgfd;
};//定义消息类型
#define MSG_TYPE_CLIENT 1
#define MSG_TYPE_SERVER 2class Server:public MsgQueue
{
public:Server(){MsgQueue::Create(CREATE_MSGQUEUE);std::cout << "Server created msgqueue done" << std::endl;MsgQueue::GetAttr();}~Server(){MsgQueue::~MsgQueue();}
};class Client:public MsgQueue
{
public:Client(){MsgQueue::Create(GET_MSGQUEUE);std::cout << "Client created msgqueue done" << std::endl;}~Client(){}
};#endif
//ChainOfResponsibility.hpp#ifndef _CHAINOFRESPONSIBILITY_HPP_
#define _CHAINOFRESPONSIBILITY_HPP_#include <iostream>
#include <memory>
#include <string>
#include <sstream>
#include <ctime>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <filesystem> //C++17
#include <fstream>// 基类
class HandlerText
{
public:virtual void Excute(const std::string &text) = 0;void SetNext(std::shared_ptr<HandlerText> next){_next = next;}void Enable(){_enable = true;}void Disable(){_enable = false;}virtual ~HandlerText(){}protected:                              // protected需要被子类继承std::shared_ptr<HandlerText> _next; // 保存下一个责任链节点bool _enable = true;                // 是否启用该节点
};// 对文本进行格式化处理
class HandlerTextFormat : public HandlerText
{
public:void Excute(const std::string &text) override{std::string format_result = text;if (_enable) // 如果该节点被开启{// 对文本进行格式化处理std::stringstream ss;ss << time(nullptr) << "-" << getpid() << "-" << text << "\n";format_result = ss.str();std::cout << "step1 格式化消息:" << text << "结果: " << format_result << std::endl;}// 将处理结果传递给下一个节点if (_next){_next->Excute(format_result);}else // next为空,处理到结尾了{std::cout << "处理到达责任链结尾,完成责任链处理" << std::endl;}}
};// 文件的基本信息:路径,名称
std::string defaultfilepath = "./tmp/";
std::string defaultfilename = "test.log";// 对文本持久化保存在文件里
class HandlerTextSaveFile : public HandlerText
{
public:HandlerTextSaveFile(const std::string &filepath = defaultfilepath,const std::string &filename = defaultfilename): _filepath(filepath), _filename(filename){// 形成默认的目录名,使用filesystem库if (std::filesystem::exists(_filepath))return;try{std::filesystem::create_directories(_filepath);}catch (std::filesystem::filesystem_error const &e){std::cerr << e.what() << '\n';}}void Excute(const std::string &text) override{if (_enable) // 该节点被开启{// 保存到文件中std::string file = _filepath + _filename;std::ofstream ofs(file, std::ios::app);if (!ofs.is_open()){std::cerr << "open file error: " << file << std::endl;return;}ofs << text;ofs.close();std::cout << "step 2: 保存消息:" << text << "到文件:" << file << std::endl;}// 将处理结果传递给下一个节点if (_next){_next->Excute(text);}else // next为空,处理到结尾了{std::cout << "处理到达责任链结尾,完成责任链处理" << std::endl;}}private:std::string _filepath;std::string _filename;
};// 默认最大行数
const int defaultmaxline = 5;// 对文本内容进行长度检查,如果长度过长,对文本内容进行打包备份
class HandlerTextBackup : public HandlerText
{
public:HandlerTextBackup(const std::string &filepath = defaultfilepath,const std::string &filename = defaultfilename,const int &maxline = defaultmaxline): _filepath(filepath), _filename(filename), _maxline(maxline){} // 这里不需要写,能走到这里,说明文件和路径合法,否则早在持久化的部分检测出来了void Excute(const std::string &text) override{if (_enable) // 该节点被开启{// 对文件进行检测,如果超范围,我们就要切片,并且进行打包备份std::string file = _filepath + _filename;std::cout << "step 3: 检查文件:" << file << "是否超过最大行数:" << _maxline << std::endl;if (IsOutOfRange(file)){// 如果超过了范围,我们就需要进行切片,并且进行打包备份std::cout << "文件超过了最大行数,进行切片和打包备份" << std::endl;BackUp(file);}}// 将处理结果表现在text内部,并传递给下一个节点if (_next){_next->Excute(text);}else // next为空,处理到结尾了{std::cout << "处理到达责任链结尾,完成责任链处理" << std::endl;}}private:// 判断文件是否超过了最大行数bool IsOutOfRange(const std::string &file){std::ifstream ifs(file);if (!ifs.is_open()){std::cerr << "open file error: " << file << std::endl;return false;}int line_count = 0;std::string line;while (std::getline(ifs, line)){line_count++;}ifs.close();return (line_count > _maxline);}// 对文件进行切片,并且进行打包备份void BackUp(const std::string &file){std::string suffix = std::to_string(time(nullptr));std::string backup_file = file + "." + suffix;   // 备份文件的名称std::string src_file = _filename + "." + suffix; // 备份文件的名称--只需要文件名不用带路径std::string tar_file = src_file + ".tgz";        // 打包文件的名称--只需要文件名不用带路径// 子进程进行切片备份和打包,删除备份文件由父进程进行等待pid_t pid = fork();if (pid == 0){// child// 1.先对文件进行重命名,Linux上,对文件名进行重命名是原子的// 2.让子进程进行数据备份std::filesystem::rename(file, backup_file); // 重命名std::cout << "step 4 备份文件:" << file << "到文件:" << backup_file << std::endl;// 3.对备份文件进行打包,打包成.tgz文件,需要使用exec*系统调用//"./tmp/test.log.123456789" -> "./tmp/test.log.123456789.tgz"// 需要注意tar命令打包文件如果跟上路径上多个目录,会打包这个文件和这个路径上目录// 但我们只需要打包这个文件,所以需要使用-czvf选项,并且只使用文件名不用带路径// 3.1 对备份文件进行打包.tgz// 3.1.1更改工作目录 -- chdir / filesystemstd::filesystem::current_path(_filepath);// 3.1.2 打包文件execlp("tar", "tar", "-czvf", tar_file.c_str(), src_file.c_str(), nullptr);// 这里不能执行删除备份文件的操作,因为exec*系统调用会替换当前进程的代码和数据// 所以这里的删除备份文件的操作会被覆盖掉,所以需要在父进程中删除备份文件exit(1); // 这里的exit(1)是为了防止exec*系统调用失败,但是我们这里的exec*系统调用是成功的,所以这里的exit(1)是没有意义的}// parentpid_t rid = waitpid(pid, nullptr, 0);if (rid > 0){// 3.2 删除备份文件 -- unlink / filesystemif(WIFEXITED(rid) && WEXITSTATUS(rid) == 0){// 3.2.1 删除备份文件std::filesystem::remove(backup_file);std::cout << "step 5 删除备份文件:" << backup_file << std::endl;}else{std::cerr << "child process exit error" << std::endl;}}}private:std::string _filepath;std::string _filename;int _maxline;
};// 责任链入口类
class HandlerEntry
{
public:HandlerEntry(){// 构造责任链节点_format = std::make_shared<HandlerTextFormat>();_save = std::make_shared<HandlerTextSaveFile>();_backup = std::make_shared<HandlerTextBackup>();// 设置责任链节点的处理顺序_format->SetNext(_save);_save->SetNext(_backup);}void EnableHandler(bool isformat, bool issave, bool isbackup){isformat ? _format->Enable() : _format->Disable();issave ? _save->Enable() : _save->Disable();isbackup ? _backup->Enable() : _backup->Disable();}void Run(const std::string &text){_format->Excute(text);}~HandlerEntry(){}private:std::shared_ptr<HandlerText> _format;std::shared_ptr<HandlerText> _save;std::shared_ptr<HandlerText> _backup;
};#endif
//Server.cc#include "MsgQueue.hpp"
#include "ChainOfResponsibility.hpp"
int main()
{std::string text;Server server;HandlerEntry he;he.EnableHandler(true,true,true); //定制化处理-->责任链节点使能开关while(true){//server只接受消息//如果消息队列为空,就会阻塞等待server.Recv(MSG_TYPE_CLIENT,text);std::cout << "Received: " << text << std::endl;//跳出if(text == "quit")break;//加工处理,采用责任链模式he.Run(text);}return 0;
}
//Client.cc#include "MsgQueue.hpp"int main()
{Client client;while(true){//只让client发送消息std::string input;std::cout << "Please input message: ";std::getline(std::cin,input);client.Send(MSG_TYPE_CLIENT,input);//跳出if(input == "quit")break;}return 0;
}
//Makefile.PHONY: all
all: Client ServerClient: Client.ccg++ -o Client Client.cc -std=c++17
Server: Server.ccg++ -o Server Server.cc -std=c++17.PHONY: clean
clean:rm -rf Client Server

六、system V 信号量

System V 信号量是 Linux 系统中用于进程间通信(IPC)的一种机制,用于实现进程之间的同步和互斥

1.并发编程,概念铺垫

  • 多个执行流(进程),能看到的同一份公共资源:共享资源
  • 被保护起来的资源:临界资源
  • 保护的常见方式:互斥与同步
  • 任何时刻,只允许一个执行流访问资源,叫做互斥
  • 多个执行流访问临界资源的时候,具有一定的顺序性,叫做同步
  • 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源互斥资源
  • 在进程中涉及到互斥资源的程序段叫临界区,你写的代码 = 访问临界资源的代码(临界区) + 不访问临界资源的代码(非临界区)
  • 所谓的对共享资源进行保护,本质就是对访问共享资源的代码进行保护

2.信号量概述

  • 概念:信号量是一个计数器,用于控制多个进程对共享资源的访问。它的值表示当前可用的资源数量。当一个进程想要访问共享资源时,它需要先获取信号量:
    如果信号量的值大于 0,则进程可以继续执行,并将信号量的值减 1;
    如果信号量的值为 0,则进程会被阻塞,直到信号量的值大于 0。
    这就导致:不同进程,访问共享资源,具有一定的并发性
  • 特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
  • 作用:保护临界区
  • 本质:信号量本质是对资源的预定机制
  • 操作:申请资源,计数器--,P操作(原子性)
               释放资源,计数器++,V操作(原子性)
  • 示例:在电影院买票的场景中,我们可以把电影院的座位看作是共享资源,而信号量就像是控制这些座位访问的计数器。

    假设有一家电影院,它有一定数量的座位(例如 10 个)。多个顾客(可以看作多个进程)想要购买电影票,也就是访问这些座位资源。为了保证座位资源不会被超卖(即不会出现一个座位卖给多个顾客的情况),我们可以使用信号量来进行控制。

    信号量的初始值就设置为电影院的座位数量(10)。当一个顾客来买票时,就相当于一个进程要获取信号量。如果信号量的值大于 0,说明还有剩余座位,顾客可以成功购票,同时信号量的值减 1;如果信号量的值为 0,说明座位已经售罄,顾客就需要等待(进程阻塞),直到有其他顾客退票(进程释放信号量,信号量的值加 1)。

  • 信号量就像是一个计数器,它的值表示当前可用资源的数量,所以多个进程要同时看见同一个计数器,进程访问资源都得先申请计数器,要让多个进程看见同一个计数器,那就说明这个计数器就必须是公共资源了,所以信号量被归类到系统的IPC中,这样就可以使多个进程看到同一个计数器了。
    但信号量这个计数器,本身就是控制进程的同步与互斥来保证公共资源的安全,但信号量本身也是公共资源,它如何保证自身安全呢?这个计数器通过PV操作进行计数器的--和++操作,PV操作都是原子性的,操作系统通常会提供底层的硬件支持或机制来实现 PV 操作的原子性,例如使用关中断、测试并设置指令等技术,进而保证了信号量的安全。

3.信号量的使用 

  • semget函数
    • 功能:用于创建一个新的信号量集获取一个已存在的信号量集的标识符
    • 原型int semget(key_t key, int nsems, int semflg);
    • 参数
      • key:是一个键值,用于标识信号量集。可以使用ftok函数生成一个唯一的键值。
      • nsems:指定信号量集中信号量的数量
      • semflg:是一组标志位,用于指定信号量的创建模式和权限。常见的标志有IPC_CREAT(如果信号量不存在则创建)、IPC_EXCL(与IPC_CREAT一起使用,确保创建一个新的信号量,而不是获取已存在的信号量)以及文件权限位(如0666表示可读可写)。
    • 返回值:成功时返回信号量集的标识符,失败时返回 -1,并设置errno变量以指示错误原因。

  • semop函数
    • 功能:用于对信号量集进行操作,例如获取或释放信号量
    • 原型int semop(int semid, struct sembuf *sops, size_t nsops);
    • 参数
      • semid:是信号量集的标识符,由semget函数返回。
      • sops:是一个指向struct sembuf数组的指针,struct sembuf结构体包含了对信号量的操作信息,其定义如下:
        struct sembuf {unsigned short sem_num;  // 信号量在信号量集中的索引,从0开始short sem_op;           // 对信号量的操作值。如果为负数,表示获取信号量;正数表示释放信号量;0表示等待信号量的值为0。short sem_flg;          // 操作标志,通常为0,或使用IPC_NOWAIT表示不阻塞等待。
        };
      • nsops:指定struct sembuf数组中操作的数量。
      • 返回值:成功时返回 0,失败时返回 -1,并设置errno变量。

  • semctl函数
    • 功能:用于对信号量集进行控制操作,如设置信号量的值、获取信号量的状态等。
    • 原型int semctl(int semid, int semnum, int cmd, ...);
    • 参数
      • semid:信号量集的标识符。
      • semnum:要操作的信号量在信号量集中的索引。如果cmd是针对整个信号量集的操作,则semnum通常为 0。
      • cmd:指定要执行的控制命令。常见的命令有SETVAL(设置信号量的值)、GETVAL(获取信号量的值)、IPC_RMID(删除信号量集)等。
      • ...:根据cmd的不同,可能需要传递额外的参数。例如,当cmdSETVAL时,需要传递一个union semun类型的参数来指定信号量的新值。union semun的定义如下:
        union semun {int val;                // 用于SETVAL命令,设置信号量的值struct semid_ds *buf;   // 用于IPC_STAT和IPC_SET命令,获取或设置信号量集的状态信息unsigned short *array;  // 用于GETALL和SETALL命令,获取或设置所有信号量的值struct seminfo *__buf;  // 用于IPC_INFO命令,获取系统信号量的限制和统计信息
        };

        返回值:根据不同的cmd,返回值有所不同。一般来说,成功时返回非 -1 的值,失败时返回 -1,并设置errno变量。


工作原理:

  • 进程通过semget函数创建或获取信号量集的标识符。
  • 然后可以使用semctl函数对信号量进行初始化,设置其初始值。
  • 当进程需要访问共享资源时,它会使用semop函数尝试获取信号量。如果信号量的值大于 0,semop函数会将信号量的值减 1,进程可以继续执行;如果信号量的值为 0,进程会根据semop函数的操作标志决定是阻塞等待还是立即返回错误。
  • 当进程使用完共享资源后,它会使用semop函数释放信号量,将信号量的值加 1,以便其他进程可以获取信号量并访问共享资源。
  • 当不再需要信号量集时,可以使用semctl函数并指定IPC_RMID命令来删除信号量集,释放相关的系统资源。

4.信号量代码示例

首先创建了一个信号量,并将其初始值设置为 1。然后创建了一个子进程,父进程和子进程都尝试获取信号量来访问共享资源。当一个进程获取到信号量后,它会执行一些操作,然后释放信号量,让其他进程有机会获取信号量并访问共享资源。最后,父进程等待子进程结束后,删除了信号量。


示例1:信号量用于实现父进程和子进程对共享资源的互斥访问,确保同一时间只有一个进程可以访问共享资源,从而避免数据竞争和不一致性问题。

//semaphore.cc#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <sys/wait.h>// 定义信号量操作函数
void semaphore_operation(int semid, int op)
{struct sembuf sem_op;sem_op.sem_num = 0;sem_op.sem_op = op;sem_op.sem_flg = 0;if (semop(semid, &sem_op, 1) == -1){perror("semop");exit(EXIT_FAILURE);}
}int main()
{// 生成唯一的键key_t key = ftok(".", 'a');if (key == -1){perror("ftok");return EXIT_FAILURE;}// 创建信号量int semid = semget(key, 1, IPC_CREAT | 0666);if (semid == -1){perror("semget");return EXIT_FAILURE;}// 初始化信号量为 1union semun{int val;} arg;arg.val = 1;if (semctl(semid, 0, SETVAL, arg) == -1){perror("semctl");return EXIT_FAILURE;}// 创建子进程pid_t pid = fork();if (pid == -1){perror("fork");return EXIT_FAILURE;}else if (pid == 0){// 子进程// 获取信号量semaphore_operation(semid, -1);// 打开文件并写入数据int fd = open("shared_file.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);if (fd == -1){perror("open");exit(EXIT_FAILURE);}const char *data = "Child process is writing to the file.\n";if (write(fd, data, strlen(data)) == -1){perror("write");exit(EXIT_FAILURE);}close(fd);std::cout << "Child process has finished writing." << std::endl;// 释放信号量semaphore_operation(semid, 1);}else{// 父进程// 获取信号量semaphore_operation(semid, -1);// 打开文件并写入数据int fd = open("shared_file.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);if (fd == -1){perror("open");return EXIT_FAILURE;}const char *data = "Parent process is writing to the file.\n";if (write(fd, data, strlen(data)) == -1){perror("write");return EXIT_FAILURE;}close(fd);std::cout << "Parent process has finished writing." << std::endl;// 释放信号量semaphore_operation(semid, 1);// 等待子进程结束wait(NULL);// 删除信号量if (semctl(semid, 0, IPC_RMID) == -1){perror("semctl");return EXIT_FAILURE;}}return 0;
}

信号量的初始化

在 main 函数中,使用 semget 函数创建了一个信号量集,其中包含一个信号量,并通过 semctl 函数将该信号量的初始值设置为 1

// 创建信号量
int semid = semget(SEM_KEY, 1, IPC_CREAT | 0666);
if (semid == -1) {perror("semget");std::exit(EXIT_FAILURE);
}// 初始化信号量为1
union semun {int val;
} arg;
arg.val = 1;
if (semctl(semid, 0, SETVAL, arg) == -1) {perror("semctl");std::exit(EXIT_FAILURE);
}
 

这里信号量的初始值为 1,表示在初始状态下,共享资源是可用的,允许一个进程访问。

信号量的获取(P 操作)

在父进程和子进程中,当要访问共享资源时,都会调用 semaphore_operation 函数并传入 -1 作为参数,这实际上是执行了 P 操作

// 获取信号量
semaphore_operation(semid, -1);
 

P 操作会尝试将信号量的值减 1。如果信号量的值大于 0,减 1 操作成功,进程可以继续执行后续代码,访问共享资源;如果信号量的值为 0,进程会被阻塞,直到信号量的值大于 0 为止。这样就保证了同一时间只有一个进程可以进入临界区(访问共享资源的代码段)。

信号量的释放(V 操作)

当进程使用完共享资源后,会再次调用 semaphore_operation 函数并传入 1 作为参数,这是执行了 V 操作

// 释放信号量
semaphore_operation(semid, 1);

V 操作会将信号量的值加 1。这意味着进程已经使用完共享资源,将其释放,其他等待的进程可以尝试获取信号量并访问共享资源。

 

信号量的删除

在父进程中,等待子进程结束后,会使用 semctl 函数并传入 IPC_RMID 命令来删除信号量集

// 删除信号量
if (semctl(semid, 0, IPC_RMID) == -1) {perror("semctl");std::exit(EXIT_FAILURE);
}

这是为了释放系统资源,避免信号量一直占用系统资源。

示例2:模拟一个生产者 - 消费者问题。在这个问题中,有一个缓冲区,生产者向缓冲区中放入数据,消费者从缓冲区中取出数据。为了保证数据的一致性和避免竞争条件,我们会使用三个信号量:

  1. empty 信号量:表示缓冲区中的空闲槽位数量,初始值为缓冲区的大小。
  2. full 信号量:表示缓冲区中已填充的槽位数量,初始值为 0。
  3. mutex 信号量:用于实现对缓冲区的互斥访问,初始值为 1。

生产者函数

  • 等待 empty 信号量,确保缓冲区有空闲槽位。
  • 获取 mutex 信号量,进入临界区。
  • 生产一个物品,并输出缓冲区的状态。
  • 释放 mutex 信号量,离开临界区。
  • 增加 full 信号量,通知消费者有新数据。

消费者函数

  • 等待 full 信号量,确保缓冲区有数据。
  • 获取 mutex 信号量,进入临界区。
  • 消费一个物品,并输出缓冲区的状态。
  • 释放 mutex 信号量,离开临界区。
  • 增加 empty 信号量,通知生产者有空闲槽位。

主函数

  • 创建信号量集并初始化信号量。
  • 创建生产者和消费者进程。
  • 等待生产者和消费者进程结束。
  • 删除信号量集。
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>
#include <sys/wait.h>
#include <cstdlib>// 定义信号量操作函数
void semaphore_operation(int semid, int sem_num, int op)
{struct sembuf sem_op;sem_op.sem_num = sem_num;sem_op.sem_op = op;sem_op.sem_flg = 0;if (semop(semid, &sem_op, 1) == -1){perror("semop");exit(EXIT_FAILURE);}
}// 生产者函数
void producer(int semid, int buffer_size)
{for (int i = 0; i < 5; ++i){// 等待缓冲区有空闲槽位semaphore_operation(semid, 0, -1);// 进入临界区semaphore_operation(semid, 2, -1);std::cout << "Producer produced an item. Buffer now has "<< buffer_size - semctl(semid, 0, GETVAL) << " items." << std::endl;// 离开临界区semaphore_operation(semid, 2, 1);// 通知消费者有新数据semaphore_operation(semid, 1, 1);sleep(1);}
}// 消费者函数
void consumer(int semid, int buffer_size)
{for (int i = 0; i < 5; ++i){// 等待缓冲区有数据semaphore_operation(semid, 1, -1);// 进入临界区semaphore_operation(semid, 2, -1);std::cout << "Consumer consumed an item. Buffer now has "<< buffer_size - semctl(semid, 0, GETVAL) << " items." << std::endl;// 离开临界区semaphore_operation(semid, 2, 1);// 通知生产者有空闲槽位semaphore_operation(semid, 0, 1);sleep(1);}
}int main()
{const int BUFFER_SIZE = 3;// 生成唯一的键key_t key = ftok(".", 'a');if (key == -1){perror("ftok");return EXIT_FAILURE;}// 创建包含三个信号量的信号量集int semid = semget(key, 3, IPC_CREAT | 0666);if (semid == -1){perror("semget");return EXIT_FAILURE;}// 初始化信号量union semun{int val;} arg;// 初始化 empty 信号量为缓冲区大小arg.val = BUFFER_SIZE;if (semctl(semid, 0, SETVAL, arg) == -1){perror("semctl");return EXIT_FAILURE;}// 初始化 full 信号量为 0arg.val = 0;if (semctl(semid, 1, SETVAL, arg) == -1){perror("semctl");return EXIT_FAILURE;}// 初始化 mutex 信号量为 1arg.val = 1;if (semctl(semid, 2, SETVAL, arg) == -1){perror("semctl");return EXIT_FAILURE;}// 创建生产者和消费者进程pid_t producer_pid = fork();if (producer_pid == -1){perror("fork");return EXIT_FAILURE;}else if (producer_pid == 0){// 生产者进程producer(semid, BUFFER_SIZE);exit(EXIT_SUCCESS);}pid_t consumer_pid = fork();if (consumer_pid == -1){perror("fork");return EXIT_FAILURE;}else if (consumer_pid == 0){// 消费者进程consumer(semid, BUFFER_SIZE);exit(EXIT_SUCCESS);}// 等待生产者和消费者进程结束waitpid(producer_pid, nullptr, 0);waitpid(consumer_pid, nullptr, 0);// 删除信号量集if (semctl(semid, 0, IPC_RMID) == -1){perror("semctl");return EXIT_FAILURE;}return 0;
}

七、 内核是如何组织管理IPC资源的

操作系统管理IPC:先描述再组织

1.在应用层面理解IPC



可以发现,无论是共享内存、消息队列还是信号量,用于描述它们的结构体的第一个字段都是同一个类型:struct ipc_perm; 在OS层面上,IPC是同类资源(例如:不同类型的IPC都是使用key来区分自生唯一性,OS认为共享内存,消息队列,信号量是同一种东西)

2.在内核层面理解IPC


Linux内核2.6.11源码:

在内核底层描述共享内存、消息队列、信号量的结构体中,同样的第一个字段是struct kern_ipc_perm,与用户层面描述的结构体一致。

因为每个结构体的第一个字段是stuct kern_ipc_perm,且上面的柔性数组ipc_id_ary里面存在struct kern_ipc_perm的指针数组,结合前面每个结构体的第一个字段是struct kern_ipc_perm,可以得出结论:ipc_id_ary可以直接指向所有IPC资源(结构体的地址与结构体第一个字段的地址是相同的),所以可以在内核中可以使用ipc_id_ary这个柔性数组管理所有的IPC资源,这个柔性数组的下标就是之前的id,即xxxget调用的返回值。

从多态的角度看,kern_ipc_perm作为基类,描述共享内存、消息队列、信号量的结构体是继承了基类的子类,OS通过使用基类来管理所有的子类,进而管理IPC资源;共享内存、消息队列、信号量的个性方法由子类自己实现。

3.再谈共享内存--为什么访问共享内存使用的是用户指针(虚拟地址)而不是用id+柔性数组?

共享内存在底层实际上是文件,是文件就有inode,也有自己的内存块。
用虚拟地址而不是用id:有虚拟地址,就说明这个文件有物理地址和内存块,那就必须被映射到进程的地址空间中!!! 在struct mm_struct,其内部有struct vm_area_struct虚拟内存区域列表,列表每个虚拟内存区域 会对各种虚拟内存的区域进行划分,struct vm_area_struct内有一个字段为struct file* vm_file; 共享内存映射到进程的地址空间:struct vm_area_strcut的file*指向共享内存的file,struct vm_area_strcut的vm_start和vm_end就会与共享内存的文件的内存块建立映射,从而完成从文件到进程的映射。

 

动态库映射到虚拟地址空间也是同理。

system V的共享内存和动态库不占用文件描述符(因为不是用户打开的文件),而是通过映射的地址开始和结束来找到的。

如何不用共享内存和动态库,自己打开一个文件,创建和使用vm_area_struct的vm_start和vm_end的地址空间来访问这个文件,而不使用fd来访问这个文件? ---- 使用接口mmap。

4.mmap系统调用

mmap接口方式打开的文件也是一种共享内存,不过它是POSIX标准的共享内存,会占用文件描述符;它在操作系统中主要用于将一个文件或者设备的内容映射到进程的地址空间,这样进程就可以像访问内存一样直接访问文件或设备,从而避免了传统的 read 和 write 系统调用带来的额外开销。

原型:

#include <sys/mman.h>void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
  • addr:指定映射的起始地址。通常设置为 NULL,让操作系统自动选择合适的地址。
  • length:要映射的内存区域的长度,以字节为单位。
  • prot:指定映射区域的访问权限,可使用以下几个常量进行按位或操作:
    • PROT_READ:映射区域可读。
    • PROT_WRITE:映射区域可写。
    • PROT_EXEC:映射区域可执行。
    • PROT_NONE:映射区域不可访问。
  • flags:控制映射的行为和属性,常见的标志有:
    • MAP_SHARED:对映射区域的写入操作会反映到文件中,并且其他映射了同一个文件的进程也能看到这些修改。
    • MAP_PRIVATE:对映射区域的写入操作不会反映到文件中,而是创建一个该文件的私有副本,其他进程看不到这些修改。
    • MAP_ANONYMOUS:创建一个匿名映射,不与任何文件关联,通常用于创建共享内存区域。此时 fd 参数会被忽略,offset 参数必须为 0。
  • fd:要映射的文件的文件描述符。如果使用 MAP_ANONYMOUS 标志,则该参数会被忽略。
  • offset:从文件的哪个偏移量开始映射,必须是页大小(通常是 4KB)的整数倍。

返回值:

  • 成功时,mmap 函数返回映射区域的起始地址。
  • 失败时,返回 MAP_FAILED(通常是 (void *)-1),并设置 errno 来指示错误原因。

注意:

  • 权限问题:确保进程对文件具有足够的权限,否则 mmap 调用可能会失败。
  • 偏移量offset 参数必须是页大小的整数倍,否则 mmap 调用会失败。
  • 同步问题:如果使用 MAP_SHARED 标志,对映射区域的修改会反映到文件中,但可能不会立即同步到磁盘。可以使用 msync 函数来强制同步。
  • 内存管理:使用完映射区域后,一定要调用 munmap 函数解除映射,避免内存泄漏。

munmap 函数:
munmap 函数用于解除映射,其参数 addr 是 mmap 返回的映射区域的起始地址,length 是映射区域的长度。成功时返回 0,失败时返回 -1 并设置 errno

工作原理

  1. 文件映射:当使用 mmap 映射一个文件时,操作系统会在进程的虚拟地址空间中分配一段连续的地址区域,并将该区域与文件的指定部分建立映射关系。当进程访问这段虚拟地址时,操作系统会根据映射关系从文件中读取数据或者将数据写入文件。
  2. 匿名映射:如果使用 MAP_ANONYMOUS 标志,操作系统会分配一段物理内存,并将其映射到进程的虚拟地址空间。这种方式常用于创建共享内存区域,多个进程可以通过映射同一段匿名内存来实现数据共享。

示例:使用mmap读取文件内容

#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>int main() {int fd = open("test.txt", O_RDONLY);if (fd == -1) {perror("open");return 1;}struct stat sb;if (fstat(fd, &sb) == -1) {perror("fstat");close(fd);return 1;}char *map = mmap(NULL, sb.st_size, PROT_READ, MAP_SHARED, fd, 0);if (map == MAP_FAILED) {perror("mmap");close(fd);return 1;}// 读取映射区域的内容for (size_t i = 0; i < sb.st_size; i++) {putchar(map[i]);}// 解除映射if (munmap(map, sb.st_size) == -1) {perror("munmap");}close(fd);return 0;
}


http://www.ppmy.cn/news/1583286.html

相关文章

初识HTTP

HTTP 概念:HyperText Transfer Protocol&#xff0c;超文本传输协议&#xff0c;规定了浏览器和服务器之间数据传输的规则 HTTP 协议特点: 1.基于TCP协议:面向连接&#xff0c;安全 2.基于请求-响应模型的:一次请求对应一次响应 3.HTTP协议是无状态的协议:对于事务处理没有…

TCP粘包原因分析以及解决方案

一、TCP粘包简介 使用TCP 协议进行数据传输时&#xff0c;多个数据包被连续存储于缓存中&#xff0c;在对数据包进行读取时由于无法确定发送方的发送边界&#xff0c;而采用某一估测值大小来进行数据读取&#xff0c;使得发送方发送的若干个数据包到接收方接收时粘成一包&…

自然语言处理(11:RNN(RNN的前置知识和引入)

系列文章目录 第一章 1:同义词词典和基于计数方法语料库预处理 第一章 2:基于计数方法的分布式表示和假设&#xff0c;共现矩阵&#xff0c;向量相似度 第一章 3:基于计数方法的改进以及总结 第二章 1:word2vec 第二章 2:word2vec和CBOW模型的初步实现 第二章 3:CBOW模型…

在 PostgreSQL 中设置调试环境以更好地理解 OpenSSL API

1. 概述 本文将介绍如何设置一个 gdb 调试环境&#xff0c;以深入了解 TLS 连接并更好地理解 PostgreSQL 中使用的 OpenSSL API。 2. 使用调试符号构建 OpenSSL 首先&#xff0c;检出 OpenSSL 源代码并切换到您想要使用的版本。在本例中&#xff0c;我想使用 OpenSSL 3.0.2 …

NVIDIA NeMo 全面教程:从入门到精通

NVIDIA NeMo 全面教程&#xff1a;从入门到精通 文章目录 NVIDIA NeMo 全面教程&#xff1a;从入门到精通目录框架介绍NeMo的核心特点NeMo的架构NeMo与其他框架的比较NeMo的模型集合NeMo的工作流程NeMo 2.0的新特性 安装指南系统要求使用Docker容器安装步骤1&#xff1a;安装Do…

自由学习记录(47)

刚刚新建的 Color 属性&#xff0c;名字叫 Albedo&#xff0c;是创建的 Shader 可调参数之一。 元素含义Albedo这是你定义的一个公开颜色参数&#xff0c;名字叫 AlbedoReference&#xff08;变量名&#xff09;"_Albedo" 是这个变量在材质中可访问的名字Exposed 勾…

【多媒体交互】Unity+普通摄像头实现UI事件分析

在Unity中&#xff0c;通过普通摄像头实现UI点击事件的核心思路是&#xff1a;利用摄像头捕捉用户的手势或动作&#xff0c;结合坐标映射与事件系统触发UI交互。以下是具体实现方法与技术要点&#xff1a; 技术实现原理 手势识别与坐标映射 通过摄像头捕捉用户手势&#xff…

Sass (Scss) 与 Less 的区别与选择

Sass 与 Less 的区别与选择 1. 语法差异2. 特性与支持3. 兼容性4. 选择建议 在前端开发中&#xff0c;CSS预处理器如Sass&#xff08;Syntactically Awesome Stylesheets&#xff09;和Less被广泛使用&#xff0c;它们通过引入变量、嵌套规则、混合、函数等特性&#xff0c;使C…