进程间通信的基本概念(上)

本篇目标:

• 切换环境

• 进程间通信介绍

• 掌握匿名命名管道原理操作

一.环境切换

1.切换ubuntu

之前我大多用的都是centos环境下操作与写代码,从本篇开始要切换到ubuntu环境,我们仅需要

在云服务器后台,在云服务器实例上找到更多/重装系统,然后点击重新安装即可,但是要记得备

份原来换进下的文件,操作如图所示:

此时设置好密码后,点击确定即可。

2.切换vscode

**<1>.**在开始之前,请确保你手头有这些信息

• Linux 服务器的 IP 地址 (比如 192.168.1.100 或者公网 IP)。

• 登录用户名 (比如 你的个人用户 ubuntu等)。

• 登录密码。

<2>.在vscode上安装插件

先打开本地的vscode,点击左侧活动栏的 扩展(Extensions) 图标,在搜索框输入 Remote -

SSH 。找到 Remote - SSH 插件,点击 安装(Install)即可,如图所示:

<3>.配置并建立连接

安装完成后,按 Ctrl+Shift+P 调出命令面板,输入 Remote-SSH: Connect to Host,在弹出的菜

单中,选择 Connect to Host... (连接到主机...), 选择 + Add New SSH Host... (添加新的 SSH

**主机...),**在输入框中敲入你的 SSH 连接命令,格式如下:

bash 复制代码
ssh 用户名@服务器IP地址

例如:ssh ubuntu@192.168.1.100

接着系统会问你将这个配置保存在哪。通常选择第一个默认路径即可(Windows 下一般是 C:\Users\你的用户名\.ssh\config)。

完成后,可能是这个样子的,如图:

此时我们在点击->或者旁边的加号,再次输入我们的密码,然后我们在当前/新建的窗口下打开文

件,此时就会发现vscode与我们的linux系统连接起来了,但是我们要进入ubuntu下的家目录还要

再次输入密码,加载一段时间后,我们系统下的文件就出现在了vscode上了。

<4>操作

按ctrl+~就可以在终端处进行指令,我们在如图所示的位置可以分别在当前目录下创建文件与目录

,但是要记住一点,当我们向文件里写入内

容时是要按ctrl+s保存的,如果嫌麻烦,可以在上方的文件(F)处点击自动保存。

二.进程间通信介绍

1.为什么要通信?

原因:

在 Linux 中,任何一个运行起来的程序都是一个独立的进程。操作系统为了保证系统的安全性和

稳定性,给每个进程都分配了独立的虚拟地址空间。

这意味着,,进程 A 和进程 B 各自拥有独立的全局变量、堆区动态内存以及栈区局部变量,这些

内存区域在进程间是完全隔离的。哪怕它们在各自的虚拟地址里修改了同一个内存地址(比如

0x400000),底层映射的物理内存也绝对不会是同一块。这种隔离机制保证了进程 A 和进程 B

互相独立 ,但这也带来了一个巨大的麻烦:进程成了互相看不见彼此的孤岛。 如果左边的孤岛

产生了一段数据(比如 Client say# hello),想要送给右边的孤岛,它们自己是绝对办不到的,

所以通信的目的:

<1>.数据传输:⼀个进程需要将它的数据发送给另⼀个进程。

<2>.资源共享:多个进程之间共享同样的资源。

<3>.事件通知:一个进程需要向另一个进程发送信号,告诉它发生某件事情了。

<4>.进程控制:有些特殊的进程,天生就是为了"控制"其他进程而存在的,它们必须能够拦截、修改目标进程的状态。

总的来说:进程间独立是为了安全与稳定,而进程间通信是为了协作与分工。

2.如何通信?

<1>.通信本质

既然是进程间通信,那么进程间通信的本质应该是先让不同的进程先看到同一份资源

**这份资源是由进程提供的吗?**显然不是。如果由进程提供,当该进程退出时,资源就会被销毁,其

他进程就无法访问了。因此,这份资源应该由系统提供,通过系统调用来创建。

<2>.标准由来(了解)

前面我们提到,进程之间若想进行通信,必须低头向操作系统申请公共资源。

但在早期计算机群雄割据的年代,各个厂商(比如 AT&T、伯克利)都有自己的一套系统调用接

口。这就导致了一个极其痛苦的现实:你在 A 系统上写好的通信代码,挪到 B 系统上直接编译报

错。这种"方言"般的割裂,简直是跨平台开发的噩梦。

为了让程序员的心血能做到一次编写,到处运行,业界大佬们决定坐下来统一度量衡。这就好比

规定了全国统一使用普通话:只要所有的操作系统都遵循同一套标准,我们在用户态写的 C/C++

代码就能在不同的 Unix 系统间平滑迁移。

而 POSIX (可移植操作系统接口),正是这场标准化运动中诞生的最优雅、也是现代 Linux 开发中最主流的标准之一。

3.通信发展与分类

以下内容先了解,本篇主要讲的是匿名管道

<1>.发展

• 管道

• SystemV进程间通信

• POSIX进程间通信

<2>.分类

管道 : 匿名管道pipe,命名管道

SystemVIPC : SystemV消息队列 ,SystemV 共享内存 ,SystemV 信号量

POSIXIPC : 消息队列 ,共享内存 , 信号量 , 互斥量 , 条件变量 , 读写锁

三.匿名管道

1.管道

要理解匿名管道,首先需要明确什么是管道。

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

概念:我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个管道。

如图所示:

解释:who这个进程通过|这个管道,将信息发送到wc -l这个进程。

2.匿名管道

<1>.概念:由操作系统在内核中创建的一块用于具有亲缘关系进程之间的数据传输的管道。

如图所示:

解释:我们通过fork创建子进程,然后关闭进程的读端/写端,由另外一个进程写入内容到管道里

面,再由另外一个进程读到这份内容,完成命名管道间的通信。

<2>.背景:基于已有的技术,直接进行"通信"

如图所示:

解释:

1.父进程 拥有自己的独立虚拟地址空间,还拥有一个文件描述符数组 (fd_array[]),数组中的每

个条目(如 0, 1, 2, 3)在用户态是一个整数,指向内核空间中的具体结构。

2.父进程通过系统调用 fork() 克隆出一个子进程,子进程拥有父进程的副本。关键在于:子进程

会把父进程的文件描述符表也原样复制一份, 这意味着,此时父子进程各自拥有独立的文件描述符

表结构,但表中的条目内容是完全一致的。

3.子进程复制了父进程的文件描述符表,父进程的某个文件描述符指向内核中的一个资源,子进程

对应的文件描述符也会指向该资源。在内核中,存储着一个个 struct file 对象。当多个文件描

述符(来自同一进程或不同进程)指向同一个 struct file 对象 时,该对象的引用计数就会增加。

4.在这个路径中,父子进程的文件描述符3指向内核中的同一个 struct file A 。这个 struct file

A 又指向一个 inode 属性 对象。inode 属性最终指向磁盘(持久化)上的物理文件 log.txt。

进程可以读写 log.txt 来通信,但是这种共享只是两个进程指向同一个磁盘文件,而读写往往涉

磁盘刷新,这并不满足管道的特点,管道并不是用来长期存储资源的,数据写入后会暂存于管道

中,而资源一旦被读取,便会从管道缓冲区中移除。

<3>.原理:从文件描述符角度深度理解管道

解释:

<1>.父进程创建管道

  • 动作: 父进程通过系统调用在内核中创建了一个管道。

  • 状态: 操作系统分配了两个新的文件描述符(fd)给父进程。

  • 图中细节: * 0, 1, 2 分别是标准输入、标准输出和标准错误,操作系统默认打开的

    • 操作系统分配了空闲的 fd[0] = 3 和 fd[1] = 4。

    • 重点: fd[0] 默认固定绑定到管道的读端 ,fd[1] 默认固定绑定到管道的写端。此时,父进程自己既能读也能写这个管道。

<2>. 父进程 fork 出子进程

  • 动作: 父进程调用 fork() 创建了一个子进程。

  • 状态: 子进程会拷贝父进程的拷贝文件描述符表。

  • 图中细节: 子进程的文件描述符表与父进程一模一样。

    • 此时,父进程和子进程都拥有 fd[0](指向读端)和 fd[1](指向写端)。它们现在共同连接到了内核中的同一个管道。

<3>. 父进程关闭 fd0,子进程关闭 fd1

  • 动作: 为了形成单向的数据流,双方需要关闭自己不需要的那一端。

  • 状态: 父进程负责写,子进程负责读。

    • 父进程 不需要读取数据,所以它主动关闭了自己的读端 fd[0]。

    • 子进程 不需要写入数据,所以它主动关闭了自己的写端 fd[1]。

  • 最终结果: 此时,父子进程之间建立起了一条单向通道。父进程往 fd[1] 写入数据,数据流经内核管道,子进程从 fd[0] 读取数据。

总结:匿名管道通信的本质就是依靠 fork() 时子进程继承父进程打开的文件描述符,从而让两个

独立的进程看到并操作同一块资源,但是管道是单向的,所以最后一步各自关闭不用的读写端是必

须要做的标准操作。

3.操作

我们想要创建一个管道,需要先进行系统调用,操作也比较简单。

系统调用接口:pipe()( 终端处man 2 pipe即可搜索到),如图所示:

解释:pipe中的pipefd其实是输出型参数 ,内核里的 pipe 函数拿到这个指针后,会在内核空间把

管道建立好,然后顺着这个指针找回用户空间的内存 ,把新生成的两个文件描述符写进去:把读端

的文件描述符写到指针指向的第一个位置(即 pipefd[0]),把写端的文件描述符写到指针指向的

第二个位置(即 pipefd[1])。

其实从这我们也可以看出这个系统调用不需要文件路径,也没有文件名,因此才叫匿名管道。

其实这里要发出一个疑问:没有文件名,我们要如何保证两个进程打开的是同一个管道?

其实前面已经提到过了,子进程复制了父进程的文件描述符表,子进程文件描述符表里面也有关于

这个管道的读写端描述符,自然父子进程打开的就是同一个管道。

四.管道样例与特性
1.代码演示

我们先在当前目录下,创建一个make_pipe目录,然后在创建一个Pipe.cpp文件和makefile问

件,makefile内容如图所示:

cpp 复制代码
Pipe:Pipe.cpp
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf Pipe

然后在Pipe.cpp里面写创建管道的代码,如图:

cpp 复制代码
#include <iostream>
#include <unistd.h>

int main()
{
    //先创建一个匿名管道
    int pipefd[2];
    int n=pipe(pipefd);
    if(n<0)
    {
        std::cerr<<"make pipefd fail"<<std::endl;
        exit(1);
    }
    std::cout<<"pipefd[0]:"<<pipefd[0]<<std::endl;
    std::cout<<"pipefd[1]:"<<pipefd[1]<<std::endl;
    
    return 0;
}

注意返回值:

然后创建子进程并关闭父子进程不需要的文件描述符,然后由子进程写入内容,父进程读内容,创建两个函数,如图:

cpp 复制代码
void ChildWrite(int wfd)
{
    char c = 0;
    int cnt = 0;
    char buffer[1024];
    while (true)
    {
        snprintf(buffer, sizeof(buffer), "I am child, pid: %d, cnt: %d", getpid(), cnt++);
        write(wfd, buffer, sizeof(buffer));
        printf("child: %d\n", cnt++);
        // sleep(2);
        //break;
        sleep(3);
    }
}

void FatherRead(int rfd)
{
    char buffer[1024];
    while (true)
    {
        //sleep(100);
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "child say: " << buffer << std::endl;
            // sleep(2);
        }
        else if(n == 0)
        {
            std::cout << "n : " << n << std::endl;
            std::cout << "child 退出,我也退出";
            break;
        }
        else
        {
            break;
        }
    }
}
int main()
{
    pid_t id=fork();
    //子进程
    if(id==0)
    {
        //关闭读端
        close(pipefd[0]);
        ChildWrite(pipefd[1]);
        close(pipefd[1]);
        exit(0);
    }
    //父进程
    //关闭写端
    close(pipefd[1]);
    FatherRead(pipefd[0]);
    close(pipefd[0]);
}

完整代码如图:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <cstdio>

void ChildWrite(int wfd)
{
    char c = 0;
    int cnt = 0;
    char buffer[1024];
    while (true)
    {
        snprintf(buffer, sizeof(buffer), "I am child, pid: %d, cnt: %d", getpid(), cnt++);
        write(wfd, buffer, sizeof(buffer));
        printf("child: %d", cnt++);
        sleep(2);
        //break;
        //sleep(10);
    }
}

void FatherRead(int rfd)
{
    char buffer[1024];
    while (true)
    {
        //sleep(100);
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "child say: " << buffer << std::endl;
            // sleep(2);
        }
        else if(n == 0)
        {
            std::cout << "n : " << n << std::endl;
            std::cout << "child 退出,我也退出"<<std::endl;
            break;
        }
        else
        {
            break;
        }
        break;
    }
}
int main()
{
    //先创建一个匿名管道
    int pipefd[2];
    int n=pipe(pipefd);
    if(n<0)
    {
        std::cerr<<"make pipefd fail"<<std::endl;
        exit(1);
    }
    std::cout<<"pipefd[0]:"<<pipefd[0]<<std::endl;
    std::cout<<"pipefd[1]:"<<pipefd[1]<<std::endl;

    pid_t id=fork();
    //子进程
    if(id==0)
    {
        //关闭读端
        close(pipefd[0]);
        ChildWrite(pipefd[1]);
        close(pipefd[1]);
        exit(0);
    }
    //父进程
    //关闭写端
    close(pipefd[1]);
    FatherRead(pipefd[0]);
    close(pipefd[0]);

    int status = 0;
    int ret = waitpid(id, &status, 0); // 获取到子进程的退出信息吗!!!
    if(ret > 0)
    {
        printf("exit code: %d, exit signal: %d\n", (status>>8)&0xFF, status&0x7F);
    }
    return 0;
}

make后有了Pipe这个可执行程序后,结果如图所示:

从pipefd0和pipefd1的打印结果可知管道的读写端使用的就是空闲的文件描述符。

解释:从这张图,我们可以知道,

<1>.当一个进程写的慢,而另一个进程读的快,读端就要阻塞掉,

其实当我们将代码中的子进程函数中的sleep(10)注释删去就可以得到验证。

<2>.一个进程写的快,而另一个进程读的慢,写端就要阻塞掉

其实我们将子进程中的sleep都注释掉,再将父进程中的sleep去掉注释,在make,执行程序,即

可看到终端瞬间刷出几十行 child: 0, child: 1, child: 2 ... 然后突然卡住不动了,这是因为管道内容是有上限的,比如说我的管道大小为1024*128字节,为128KB。

<3>.当我们写端关闭时,继续读,read就会读到返回值为0,即此时读到了文件的结尾,

此时我们将父子进程的sleep都关闭,再将子进程的break注释删去,结果如图:

<4>.当我们将读端关闭,写端继续继续时,那么此时写端在继续写是没有意义的,因为操作系统不

会做没有意义的事,此时写端会杀死写端进程,此时我们将父子进程的sleep都关闭,再将子进程

的break注释删去,再将父进程里面的循环结尾加上break,此时结果如图:

这个13就是读端关闭后继续写管道触发的异常。

2.5种特性

<1>.匿名管道只能用于具有"血缘关系"的进程间通信(通常用于父子进程、兄弟进程(由同一个父

进程 fork 出来的两个子进程))。

<2>.管道是单向通信的,也就是数据只能在一个方向上流动。一个进程负责写(关闭读端),另一

个进程负责读(关闭写端)。

<3>.管道的生命周期随进程 ,管道是内核开辟的一块内存缓冲区。这块内存依靠引用计数来维持,

当所有指向这个管道的文件描述符(无论读端还是写端)都被关闭,或者持有这些描述符的进程都

退出了,操作系统就会自动回收这块内核内存。

<4>.管道自带同步机制 ,读阻塞: 管道空了,读端就会乖乖阻塞等数据;写阻塞: 管道满了(如默认 64KB/128KB 被塞满),写端就会乖乖卡住等对方读;写端关了,读端会读到 0;读端关了,写端会收到 13 号信号(SIGPIPE)被击毙。

<5>.管道是面向字节流的。

总结:

进程间通信听起来高深,但剥开外衣,它的本质其实就是一句话:让不同的进程看到同一份系统资源。

今天我们学习的匿名管道 ,就是操作系统在内核里悄悄挖的一条"单向地道"。父进程建好地道,通过 fork() 把地道的钥匙原样复制给子进程,父子俩各自关掉不需要的一端,就能顺畅地传递数据了。

匿名管道有着极强的同步机制:写满了它会等,读空了它也等;如果读端跑路了,它就拉响警报(SIGPIPE)强制终止写端;如果写端跑路了,它会通知读端read结束(读到0)。虽然它有只能沾亲带故和只能单行道的局限,但这恰恰成就了它作为 Unix 最古老通信方式的简单与高效。

希望本篇文章能帮你彻底拿下匿名管道!如果觉得有收获,欢迎点赞收藏;如果有疑问,也欢迎在评论区一起探讨交流,我们下期见!

相关推荐
且从容.27 分钟前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道1 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
讯捷蓝达3 小时前
戴尔R720xd服务器卡死在 “Configuring Memory...Done“,换了内存还是卡?问题根本不在内存
运维·服务器
且从容.4 小时前
ubuntu安装flink
linux·ubuntu·flink
数字新视界5 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落5 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨5 小时前
WSL2的安装与使用
linux·wsl
微小的鱼5 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng6 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮7 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频