【Linux】进程间通信

进程间通信

  • [1. 进程间通信介绍](#1. 进程间通信介绍)
    • [1.1 进程间通信目的](#1.1 进程间通信目的)
    • [1.2 进程间通信发展](#1.2 进程间通信发展)
    • [1.3 进程间通信分类](#1.3 进程间通信分类)
    • [1.4 进程间通信的本质理解](#1.4 进程间通信的本质理解)
  • [2. 管道](#2. 管道)
  • [3. 匿名管道](#3. 匿名管道)
    • [3.1 pipe()函数](#3.1 pipe()函数)
    • [3.2 站在文件描述符角度-深度理解管道](#3.2 站在文件描述符角度-深度理解管道)
    • [3.3 站在内核角度-管道本质](#3.3 站在内核角度-管道本质)
    • [3.4 匿名管道使用步骤](#3.4 匿名管道使用步骤)
    • [3.4 管道读写规则](#3.4 管道读写规则)
    • [3.5 管道的读与写的五种特殊情况](#3.5 管道的读与写的五种特殊情况)
    • [3.6 管道特点](#3.6 管道特点)
    • [3.7 管道大小](#3.7 管道大小)
  • [4. 命名管道](#4. 命名管道)
    • [4.1 命名管道原理](#4.1 命名管道原理)
    • [4.2 创建命名管道](#4.2 创建命名管道)
    • [4.3 命名管道的打开规则](#4.3 命名管道的打开规则)
    • [4.4 匿名管道与命名管道的区别](#4.4 匿名管道与命名管道的区别)
    • [4.5 命名管道的应用](#4.5 命名管道的应用)
    • [4.6 命令行上的管道`|`](#4.6 命令行上的管道|)
  • [5. system V共享内存](#5. system V共享内存)
    • [5.1 共享内存原理](#5.1 共享内存原理)
    • [5.2 共享内存数据结构](#5.2 共享内存数据结构)
    • [5.3 共享内存的创建](#5.3 共享内存的创建)
    • [5.4 共享内存的释放](#5.4 共享内存的释放)
    • [5.5 共享内存的关联](#5.5 共享内存的关联)
    • [5.6 共享内存的去关联](#5.6 共享内存的去关联)
    • [5.7 使用共享内存实现serve&client通信](#5.7 使用共享内存实现serve&client通信)
    • [5.8 共享内存与管道进行对比](#5.8 共享内存与管道进行对比)

1. 进程间通信介绍

1.1 进程间通信目的

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

1.2 进程间通信发展

  • 管道
  • System V进程间通信(聚焦在本地通信)
  • POSIX进程间通信(让通信过程可以跨主机)

1.3 进程间通信分类

管道

  • 匿名管道pipe
  • 命名管道

System V IPC

  • System V 消息队列
  • System V 共享内存
  • System V 信号量

POSIX IPC

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

1.4 进程间通信的本质理解

进程是具有独立性的!现在需要进行进程间通信,成本一定不低!

  1. OS需要直接或者间接给通信双方的进程提供"内存空间";
  2. 要通信的进程,必须要看到一份公共的资源。不同的通信种类,本质就是OS的不同模块提供的资源。

2. 管道

  • 什么是管道
  • 管道是Unix中最古老的进程间通信的形式。
  • 我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"
  • 管道是半双工方式的通信,只能进行单向通信
  • 管道也是文件

例如,统计我们当前使用云服务器上的登录用户个数

其中,who命令和wc命令都是可执行程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到"管道"当中,wc进程再通过标准输入从"管道"当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。

注明

  • who命令用于查看当前云服务器的登录用户(一行显示一个用户)
  • wc -l用于统计当前的行数

3. 匿名管道

3.1 pipe()函数

pipe函数用于创建匿名管道,pip函数的函数原型如下:

bash 复制代码
int pipe(int pipefd[2]);

pipe()函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:

pipefd数组 含义
pipefd[0] 管道的读端
pipefd[1] 管道的写端

头文件

bash 复制代码
#include <unistd.h>

返回值

创建管道成功返回0,失败返回-1,并设置错误码errno


匿名管道只用于创建管道的进程的子孙进程间(含创建管道的进程)的通信

小提示

  • fd[0]表示管道的读端,可以这样记忆:0表示嘴巴,用来读
  • fd[1]表示管道的写端,可以这样记忆:1表示笔,用来写

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

进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程分别以写或者读方式打开这个文件(因为管道只能用来进行单项数据通信),进而实现父子进程间通信。


注意

  • 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
  • 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。

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

所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了"Linux一切皆文件思想"。

实例代码

c 复制代码
// 从键盘读取数据,写入管道,读取管道,写到屏幕
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define READ 0
#define WRITE 1
#define SIZE 100

int main()
{
    int fd[2];
    char buf[SIZE];
    int len;

    if(pipe(fd) == -1)
    {
        perror("make pipe");
        exit(1);
    }

    // read from stdi
    while(fgets(buf, SIZE, stdin))
    {
        len = strlen(buf);
        // write into pipe
        if(write(fd[WRITE], buf, len) != len)
        {
            perror("write to pipe");
            break;
        }

        memset(buf, 0x00, SIZE);

        // read from pipe
        if((len=read(fd[READ], buf, SIZE)) == -1)
        {
            perror("read from pipe");
            break;
        }

        // write to stdout
        if(write(1, buf, len) != len)
        {
            perror("write to stdout");
            break;
        }
    }
    exit(0);
}

3.4 匿名管道使用步骤

在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:

  1. 父进程调用pipe函数创建管道

  2. 父进程创建子进程。

    然而,这将发生问题:

    ◼ 父or子进程都可通过fd[1]写入数据

    ◼ 父or子进程都可从fd[0]读数据

父子进程无差别读/写管道,难以区分特定信息的接收者:父?子?

  • 确定读者/写者角色,关闭不再需要写/读端。这里可以将父进程关闭写端,子进程关闭读端。

    注意
  • 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
  • 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。

3.4 管道读写规则

1.当没有数据可读时

  • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
  • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。

2.当管道满的时候

  • O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
  • O_NONBLOCK enable:调用返回-1,errno值为EAGAIN

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

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

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

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

3.5 管道的读与写的五种特殊情况

  1. 场景1:写者进程打开了管道,还没写,此时,读者进程读管道,会发生什么?
c 复制代码
// 写者进程打开管道,还没写,读者进程读管道
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>

#define MSGSIZE 16

char *msg1 = "Buenos Dias! #1";
char *msg2 = "Buenos Dias! #2";
char *msg3 = "Buenos Dias! #3";

int main()
{
    char inbuf[MSGSIZE];
    int pipefd[2], i = 0, rsize = 0;
    pid_t pid;

    time_t t;

    // 创建管道
    if(pipe(pipefd) == -1)
    {
        perror("pipe call");
        exit(1);
    }

    switch(pid = fork())
    {
    case -1:
        perror("fork call");
        exit(2);
    case 0:
        // if child then write
        close(pipefd[0]); // 关闭读端
        sleep(5);
        write(pipefd[1], msg1, MSGSIZE);
        sleep(5);
        write(pipefd[1], msg2, MSGSIZE);
        sleep(5);
        write(pipefd[1], msg3, MSGSIZE);
        break;
    default:
        // if parent then read
        close(pipefd[1]);
        for(i = 0; i < 3; i++)
        {
            printf("tring to read at time: %ld\n", (long)time(&t));
            rsize = read(pipefd[0], inbuf, MSGSIZE);
            printf("read data %.*s at time %ld\n", rsize, inbuf, (long)time(&t));
        }
        wait(NULL);
    }
    exit(0);
}

运行结果:

写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒。

  1. 场景2:写者进程已退出、尚未启动或尚未打开管道,此时,读者进程打开空管道进行读操作,会发生什么?
c 复制代码
// 写者进程已退出、尚未启动或尚未打开管道,此时,读者进程打开空管道进行读操作
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>

#define MSGSIZE 16

char *msg1 = "Buenos Dias! #1";
char *msg2 = "Buenos Dias! #2";
char *msg3 = "Buenos Dias! #3";

int main()
{
    char inbuf[MSGSIZE];
    int pipefd[2], i = 0, rsize = 0;
    pid_t pid;

    time_t t;

    // 创建管道
    if(pipe(pipefd) == -1)
    {
        perror("pipe call");
        exit(1);
    }

    switch(pid = fork())
    {
    case -1:
        perror("fork call");
        exit(2);
    case 0:
        // if child then exit
        break;
    default:
        // if parent then read
        close(pipefd[1]);
        for(i = 0; i < 3; i++)
        {
            printf("tring to read at time: %ld\n", (long)time(&t));
            rsize = read(pipefd[0], inbuf, MSGSIZE);
            printf("read data %.*s at time %ld\n", rsize, inbuf, (long)time(&t));
        }
        close(pipefd[0]);
        wait(NULL);
    }
    exit(0);
}

运行结果:

写端进程已退出、尚未启动或尚未打开管道,读端进程一直读,那么此时会因为管道里面没有数据可读,read会立即返回。

  1. 场景3:写者进程已退出,此时,读者进程打开非空管道进行读操作,会发生什么?
c 复制代码
// 写者进程已退出,此时,读者进程打开非空管道进行读操作
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>

#define MSGSIZE 16

char *msg1 = "Buenos Dias! #1";
char *msg2 = "Buenos Dias! #2";
char *msg3 = "Buenos Dias! #3";

int main()
{
    char inbuf[MSGSIZE];
    int pipefd[2], i = 0, rsize = 0;
    pid_t pid;

    time_t t;

    // 创建管道
    if(pipe(pipefd) == -1)
    {
        perror("pipe call");
        exit(1);
    }

    switch(pid = fork())
    {
    case -1:
        perror("fork call");
        exit(2);
    case 0:
        // if child then write
        close(pipefd[0]); // 关闭读端
        write(pipefd[1], msg1, MSGSIZE);
        break;
    default:
        // if parent then read
        close(pipefd[1]);
        for(i = 0; i < 3; i++)
        {
            printf("tring to read at time: %ld\n", (long)time(&t));
            rsize = read(pipefd[0], inbuf, MSGSIZE);
            printf("read data %.*s at time %ld\n", rsize, inbuf, (long)time(&t));
        }
        wait(NULL);
    }
    exit(0);
}

运行结果:

写端进程已退出,此时,读端进程打开非空管道进行读操作,读端进程会先读取管道中的数据,然后剩下的read的调用会立即返回。

  1. 场景4:读者进程打开管道,忙,未进行读操作。写者进程持续写管道,将会发生什么?
c 复制代码
// 读者进程打开管道,忙,未进行读操作。写者进程持续写管道
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>

#define MSGSIZE 16

char *msg1 = "Buenos Dias! #1";
char *msg2 = "Buenos Dias! #2";
char *msg3 = "Buenos Dias! #3";

int main()
{
    char inbuf[MSGSIZE];
    int pipefd[2], i = 0, rsize = 0;
    pid_t pid;

    time_t t;

    // 创建管道
    if(pipe(pipefd) == -1)
    {
        perror("pipe call");
        exit(1);
    }

    switch(pid = fork())
    {
    case -1:
        perror("fork call");
        exit(2);
    case 0:
        // if child then write
        close(pipefd[0]); // 关闭读端
        while(1)
        {
            write(pipefd[1], msg1, MSGSIZE);
        }
        break;
    default:
        // if parent then read
        close(pipefd[1]);
        while(1);
        wait(NULL);
    }
    exit(0);
}

运行结果:

读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。

  1. 场景5:读者进程已退出,此时,写者进程打开管道进行写操作,会发生什么?
c 复制代码
// 读者进程已退出,此时,写者进程打开管道进行写操作
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>

#define MSGSIZE 16

char *msg1 = "Buenos Dias! #1";
char *msg2 = "Buenos Dias! #2";
char *msg3 = "Buenos Dias! #3";

int main()
{
    char inbuf[MSGSIZE];
    int pipefd[2], i = 0, rsize = 0;
    pid_t pid;

    time_t t;

    // 创建管道
    if(pipe(pipefd) == -1)
    {
        perror("pipe call");
        exit(1);
    }

    switch(pid = fork())
    {
    case -1:
        perror("fork call");
        exit(2);
    case 0:
        // if child then write
        close(pipefd[0]); // 关闭读端
        while(1)
        {
            write(pipefd[1], msg1, MSGSIZE);
        }
        break;
    default:
        close(pipefd[1]);
        close(pipefd[0]);
        int status = 0;
	    waitpid(pid, &status, 0);
	    printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号
    }
    exit(0);
}

运行结果:

运行结果显示,子进程退出时收到的是13号信号。

读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程发信号,终止写端进程。

通过kill -l命令可以查看13对应的具体信号。

由此可知,当发生情况四时,操作系统向子进程发送的是SIGPIPE信号将子进程终止的。

管道的一端关闭时:
写端关闭,读管道

  • 管道非空,读取数据,并返回读取数据的长度
  • 管道为空,返回0

读端关闭,写管道

  • 引发信号SIGPIPE

3.6 管道特点

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

我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。

临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。

为了避免这些问题,内核会对管道操作进行同步与互斥:

  • 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
  • 互斥:一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。

实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。

也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。

  • 2、只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信

通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。

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

管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。

  • 4、管道提供流式服务

对于进程A写入管道当中的数据,进程B每次从管道读取的数据的多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:

  • 流式服务: 数据没有明确的分割,不分一定的报文段。
  • 数据报服务: 数据有明确的分割,拿数据按报文段拿。
  • 5、管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道

在数据通信中,数据在线路上的传送方式可以分为以下三种:

  • 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
  • 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
  • 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。

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

3.7 管道大小

管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,那么管道的最大容量是多少呢?

  • 1、使用man手册
bash 复制代码
man 7 pipe

根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。

使用uname -r命令,查看自己使用的Linux版本

  • 2、使用ulimit命令

其次,我们还可以使用ulimit -a命令,查看当前资源限制的设定。

根据显示,管道的最大容量是 512 × 8 = 4096 512\times8=4096512×8=4096 字节。

  • 3、编写代码进行测试

这里发现,根据man手册得到的管道容量与使用ulimit命令得到的管道容量不同,那么此时我们可以自行进行测试。

前面说到,若是读端进程一直不读取管道当中的数据,写端进程一直向管道写入数据,当管道被写满后,写端进程就会被挂起。据此,我们可以写出以下代码来测试管道的最大容量。

c 复制代码
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
	int fd[2] = { 0 };
	if (pipe(fd) < 0){ //使用pipe创建匿名管道
		perror("pipe");
		return 1;
	}

	pid_t id = fork(); //使用fork创建子进程
	if (id == 0)
    {
		//child 
		close(fd[0]); //子进程关闭读端
		char c = 'h';
		int count = 0;
		//子进程一直进行写入,一次写入一个字节
		while (1)
        {
			write(fd[1], &c, 1);
			count++;
			printf("%d\n", count); //打印当前写入的字节数
		}
		close(fd[1]);
		exit(0);
	}
	//parent
	close(fd[1]); //父进程关闭写端

	//父进程不进行读取
	waitpid(id, NULL, 0);
	close(fd[0]);

	return 0;
}

可以看到,在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起了,也就是说,我当前Linux版本中管道的最大容量是65536字节。

4. 命名管道

4.1 命名管道原理

管道的局限性

  1. 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
  2. 非持久化。管道的生命周期随进程,一旦进程被终止,管道也就被销毁了。

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

命名管道是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。

通过命名管道通信

  • 通信数据存在内核,先进先出。不支持诸如lseek()等文件定位操作
  • 在文件系统中有路径与之相关联
  • 可设置权限( Owner, Group, Other) (rwx) (rwx) (rwx)
  • 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。

4.2 创建命名管道

1. 在命令行上创建

命名管道可以从命令行上创建,命令行方法是使用下面这个命令:

c 复制代码
$ mkfifo filename

可以看到,创建出来的文件的类型是p,代表该文件是命名管道文件。

使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。

现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。

当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。

2. 在程序里创建

命名管道也可以从程序里创建,相关函数有:

c 复制代码
int mkfifo(const char *filename,mode_t mode);

头文件:

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>

pathname :文件路径名。如果只是一个文件名,默认是在当前路径下创建。
mode :命名管道创建时设定的权限。还受到文件掩码 umask的影响。
返回值:成功返回0,失败返回-1。

命名管道创建实例

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>

#define MY_FIFO "MY_FIFO"

int main()
{
    umask(0); // 将文件掩码设置为0
    if(mkfifo(MY_FIFO, 0666) == -1)
    {
        perror("mkfifo");
        exit(1);
    }

    // 创建成功,使用命名管道
    //......

    return 0;
}

运行代码后,当前路径下就会创建一个命名管道MY_FIFO

4.3 命名管道的打开规则

命名管道打开函数open原型:

c 复制代码
int open(const char *pathname, int flags);

头文件

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

flags参数:

使用open函数打开命名管道时,可以选择阻塞(blocking)或非阻塞(non-blocking)模式,这两种模式对程序的行为有显著的影响:

阻塞模式(Blocking Mode)

  1. 行为 : 在阻塞模式下,如果一个进程试图从空的管道读取数据,或者向满的管道写入数据,open调用将阻塞,即进程挂起直到可以执行操作为止。
  2. 读取: 如果没有数据可读,读取操作将阻塞,直到有数据写入管道。
  3. 写入: 如果管道满了,写入操作将阻塞,直到管道中有足够的空间。
  4. 场景:当进程的交互严格依赖于管道中的数据时,使用阻塞模式是合理的。

非阻塞模式(Non-blocking Mode)

  1. 行为 : 在非阻塞模式下,open调用和随后的读/写操作不会阻塞进程。如果操作不能立即完成,调用将返回一个错误(通常是EAGAINEWOULDBLOCK)。
  2. 读取 : 如果没有数据可读,读取操作立即返回EAGAINEWOULDBLOCK
  3. 写入 :如果管道满了,写入操作立即返回EAGAINEWOULDBLOCK
  4. 场景: 非阻塞模式适用于需要同时处理多个I/O操作或不希望程序在管道操作上挂起的情况。

flags参数使用:

  • O_RDONLY: 进程阻塞,直到有进程以写方式打开管道
  • O_WRONLY:进程阻塞,直到有进程以读方式打开管道
  • O_RDWR:进程不阻塞 ,读写都不阻塞。
  • O_RDONLY | O_NONBLOCK:进程不阻塞,读不阻塞。
  • O_WRONLY | O_NONBLOCK: 进程不阻塞,写不阻塞。

如果打开命名管道时flags参数不加上O_NONBLOCK,则默认是阻塞模式。

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

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

4.5 命名管道的应用

客户端-服务器架构是常用的网络编程架构。使用命名管道构建一个如图所示的一个简单的客户端服务器:

其中,服务器(Server)端的功能描述如下

  • 从命名管道中读取客户端的请求,请求是一个计算表达式,服务器接收到表达式后进行运算
  • 计算结果并将其写到客户端的命名管道

客户端(Client)的功能描述如下

  • 通过服务器命名管道发送请求到服务器
  • 通过自己的命名管道读取服务器消息然后退出

头文件clientinfo.h

c 复制代码
/* clientinfo.h */
#ifndef _CLIENTINFO_H
#define _CLIENTINFO_H

typedef struct {
    char myfifo[50];    /* client's FIFO name */
    int leftarg;        /* left argument of calculation */ 
    int rightarg;       /* right argument of calculation */
    char op;            /* operation: + - "*" / */
}CLIENTINFO, *CLIENTINFOPTR;

#endif

客户端代码

c 复制代码
/* client.c */
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <fcntl.h>
#include <signal.h>
#include "clientinfo.h"

#define FIFO_NAME "/tmp/server_fifo"
#define BUFF_SZ 100

char mypipename[BUFF_SZ];

void handler(int sig) {     /* remove pipe if signaled */
    unlink(mypipename);     //删除客户端的私有管道
    exit(1);
}

int main(int argc, char*argv[]) 
{
    int res;
    int fifo_fd, my_fifo;
    int fd;
    CLIENTINFO info;
    char buffer[BUFF_SZ];

    /* handle some signals */
    signal(SIGKILL, handler);
    signal(SIGINT, handler);
    signal(SIGTERM, handler);// kill -15

    /* check for proper command line */
    if(argc != 4) {
        printf("Usage: %s op1 operation op2\n", argv[0]);
        exit(1);
    }

    /* check if server fifo exists */
    if (access(FIFO_NAME, F_OK) == -1) {
        printf("Could not open FIFO %s\n", FIFO_NAME);
        exit(EXIT_FAILURE);
    }

    /* open server fifo for write */
    fifo_fd = open(FIFO_NAME, O_WRONLY);
    if (fifo_fd == -1) {
        printf("Could not open %s for write access\n", FIFO_NAME);
        exit(EXIT_FAILURE);
    }

    /*create my own FIFO */
    sprintf(mypipename, "/tmp/client%d_fifo", getpid());
    res = mkfifo(mypipename, 0777);
    if (res != 0) {
        printf("FIFO %s was not created\n", buffer);
        exit(EXIT_FAILURE);
    }

    /* open my own FIFO for reading */
    my_fifo = open(mypipename, O_RDONLY | O_NONBLOCK);
    if (my_fifo == -1) {
        printf("Could not open %s for read only access\n", FIFO_NAME);
        exit(EXIT_FAILURE);
    }

    /* construct client info */
    strcpy(info.myfifo, mypipename);
    info.leftarg = atoi(argv[1]);
    info.op = argv[2][0];
    info.rightarg = atoi(argv[3]);
    
    /* write client info to server fifo */
    write(fifo_fd, &info, sizeof(CLIENTINFO));
    close(fifo_fd);

    /* get result from server */
    memset(buffer, '\0',  BUFF_SZ);
    while(1) {
        res = read(my_fifo,  buffer, BUFF_SZ);
        if (res > 0) {
            printf("Received from server: %s\n", buffer);
            break;
        }
    }
    
    printf("client %d is terminating\n", getpid());

    /* delete fifo from system */
    close(my_fifo);
    (void)unlink(mypipename);

    exit(0);
}

服务器端代码

c 复制代码
/* calcserver.c */
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <fcntl.h>
#include <signal.h>
#include "clientinfo.h"

#define FIFO_NAME "/tmp/server_fifo"

void handler(int sig) {
    unlink(FIFO_NAME);
    exit(1);
}

int calc(CLIENTINFOPTR info) 
{
    switch(info->op) 
    {
        case '+': return info->leftarg + info->rightarg;
        case '-': return info->leftarg - info->rightarg;
        case '*': return info->leftarg * info->rightarg;
        case '/': return info->leftarg / info->rightarg;
    }
    return 0;
}

int main() 
{
    int res;
    int i;
    int fifo_fd, fd1;
    CLIENTINFO info;
    char buffer[100];

    signal(SIGKILL, handler);
    signal(SIGINT, handler);
    signal(SIGTERM, handler);
    
    /* create FIFO, if necessary */
    if(access(FIFO_NAME, F_OK) == -1) 
    {
        res = mkfifo(FIFO_NAME, 0777);
        if (res != 0) 
        {
            printf("FIFO %s was not created\n", FIFO_NAME);
            exit(EXIT_FAILURE);
        }
    }
    
    /* open FIFO for reading */
    fifo_fd = open(FIFO_NAME, O_RDONLY);
    if(fifo_fd == -1) 
    {
        printf("Could not open %s for read only access\n", FIFO_NAME);
        exit(EXIT_FAILURE);
    }
    printf("\nServer is rarin' to go!\n");

    while(1) 
    {
        res = read(fifo_fd, &info, sizeof(CLIENTINFO));
        if(res != 0) 
        {
            printf("client arrived!!\n");
            sprintf(buffer, "The result is %d", calc(&info));
            fd1 = open(info.myfifo, O_WRONLY | O_NONBLOCK);
            write(fd1, buffer, strlen(buffer) + 1);
            close(fd1);
        }
    }
    exit(0);
}

运行结果:

4.6 命令行上的管道|

在命令行上使用的管道通常是匿名管道(Anonymous Pipes)。匿名管道是一种在进程之间传递数据的简单方式。它是由操作系统创建的无名管道,用于在相关的进程之间进行通信。匿名管道通常通过 "|" 符号在命令行上创建,将一个命令的输出连接到另一个命令的输入。

例如,下面的命令使用匿名管道将一个命令的输出传递给另一个命令:

bash 复制代码
command1 | command2

这里,command1的输出会成为command2的输入,而它们之间的通信是通过匿名管道完成的。

5. system V共享内存

5.1 共享内存原理

System V共享内存(System V Shared Memory)是一种进程间通信(IPC)机制,允许两个或多个进程共享一个给定的存储区。这种共享内存是UNIX和类UNIX操作系统(如Linux)中最快的IPC形式。以下是其基本原理和特点:

  1. 内存段的创建和附加:一个进程创建一个共享内存段,并将其附加到自己的地址空间中。创建后,其他进程也可以将这个内存段附加到它们的地址空间,实现内存共享。

  2. 数据共享:一旦内存段被附加,进程可以像访问常规内存一样访问共享内存。这意味着进程可以读取和写入这段内存,而对这段内存的更改对所有附加了该内存段的进程可见。

  3. 同步机制:由于多个进程可以同时访问共享内存,因此需要某种形式的同步(如信号量)来防止数据竞争和确保数据一致性。

  4. 性能优势:与其他IPC机制(如管道和消息队列)相比,共享内存提供了更高的性能,因为它允许直接的内存访问,避免了数据复制的开销。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。

  5. 安全和管理:共享内存的使用需要考虑安全性和资源管理,确保只有授权的进程能访问特定的内存段,并且在不再需要时适当地释放资源。

System V共享内存是一种高效但需要谨慎使用的IPC机制,特别是在涉及数据共享和同步时。

共享内存示意图

共享内存的本质就是:通过让不同的进程,看到同一个内存块的方式。

当我们需要使用共享内存时,我们要做的准备工作是:

复制代码
            --- 通过某种调用,在内存中开辟一块空间(shmget)

            --- 通过某种调用,让两个进程挂接到这个新开辟的空间上(shmat)

当我们不需要使用共享内存时,我们需要做的收尾工作是:

复制代码
            --- 断开进程和共享内存之间的关联(shmdt)

            --- 释放共享内存(shmctl)

5.2 共享内存数据结构

在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。

共享内存的数据结构如下:

c 复制代码
struct shmid_ds {
	struct ipc_perm shm_perm; /* operation perms */
	int shm_segsz; /* size of segment (bytes) */
	__kernel_time_t shm_atime; /* last attach time */
	__kernel_time_t shm_dtime; /* last detach time */
	__kernel_time_t shm_ctime; /* last change time */
	__kernel_ipc_pid_t shm_cpid; /* pid of creator */
	__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
	unsigned short shm_nattch; /* no. of current attaches */
	unsigned short shm_unused; /* compatibility */
	void *shm_unused2; /* ditto - used by DIPC */
	void *shm_unused3; /* unused */
};

当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个key值,这个key值用于标识系统中共享内存的唯一性。

可以看到上面共享内存数据结构的第一个成员是shm_permshm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:

c 复制代码
struct ipc_perm{
	__kernel_key_t  key;
	__kernel_uid_t  uid;
	__kernel_gid_t  gid;
	__kernel_uid_t  cuid;
	__kernel_gid_t  cgid;
	__kernel_mode_t mode;
	unsigned short  seq;
};

5.3 共享内存的创建

shmget函数

功能 :用来创建共享内存
原型

c 复制代码
int shmget(key_t key, size_t size, int shmflg);

参数

  • key:表示待创建共享内存在系统当中的唯一标识
  • size:共享内存大小
  • shmflg:创建共享内存的方式以及设置权限。由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的

返回值:成功返回一个非负整数,即该共享内存段的标识码(用户层标识符);失败返回-1

注意: 我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。

传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取

ftok函数的函数原型如下:

c 复制代码
key_t ftok(const char *pathname, int proj_id);

头文件

c 复制代码
#include <sys/types.h>
#include <sys/ipc.h>

ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。

注意:

  1. 使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
  2. 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。

传入shmget函数的第三个参数shmflg,常用的组合方式有以下两种:

组合方式 作用
IPC_CREAT 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄
**`IPC_CREAT IPC_EXCL`**

换句话说:

  • 使用组合IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
  • 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。

至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:

cpp 复制代码
#include <iostream>
#include <cstdlib>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define PATHNAME "."
#define PROJ_ID 0x66
#define MAX_SIZE 4097

int main()
{
    // 获取key值
    key_t key = ftok(PATHNAME, PROJ_ID);
    if(key == -1)
    {
        std::cerr << "ftok: " << strerror(errno) << std::endl;
        exit(1);
    }
    std::cout << "key: " << key << std:: endl;
    
    // 创建新的共享内存
    int shm_id = shmget(key, MAX_SIZE, IPC_CREAT | IPC_EXCL);
    if(shm_id == -1)
    {
        std::cerr << "shmget:" << strror(errno) << std::endl;
        exit(2);
    }
    std::cout << "shm_id: " << shm_id << std::endl;

	return 0;
}

该代码编写完毕运行后,我们可以看到输出的key值和句柄值:

在Linux当中,我们可以使用ipcs命令查看有关进程间通信设施的信息。

单独使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择加上以下选项

  • -q:列出消息队列相关信息。
  • -m:列出共享内存相关信息。
  • -s:列出信号量相关信息。

例如,携带-m选项查看共享内存相关信息

此时,根据ipcs命令的查看结果和我们的输出结果可以确认,共享内存已经创建成功了。

ipcs命令输出的每列信息的含义如下:

标题 含义
key 系统区别各个共享内存的唯一标识
shmid 共享内存的用户层id(句柄)
owner 共享内存的拥有者
perms 共享内存的权限
bytes 共享内存的大小
nattch 关联共享内存的进程数
status 共享内存的状态

注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性。

在操作系统和进程间通信(IPC)的上下文中,keyshm_id(共享内存ID)之间的关系可以类比为银行账户号和银行账户之间的关系。这里是一个简单的解释:

  1. key(键): 类似于一个银行账户号。它是一个用于识别资源(在这种情况下是共享内存段)的唯一标识符。就像多个人可以知道一个银行账户号,多个进程可以知道同一个key,用它来请求访问同一个共享资源。

  2. shm_id(共享内存ID): 类似于银行账户本身。一旦使用key创建或访问了一个共享内存段,系统会分配一个唯一的ID(即shm_id),就像银行为特定账户号分配的实际账户。shm_id用于直接访问和操作共享内存。

在这个比喻中,key是用来查找或创建共享资源的手段,而shm_id是一旦资源被创建或找到后用于实际交互的标识符。

5.4 共享内存的释放

通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。

这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。

当我们再次使用相同的key值去创建新的共享内存时,操作系统会提示我们该共享内存已存在,无法创建。此时需要将之前用该key值创建的共享内存删除,才能再次用该key值去创建新的共享内存。

此时我们若是要将创建的共享内存释放,有两个方法:

  1. 使用命令释放共享内存;
  2. 在进程通信完毕后调用释放共享内存的函数进行释放。

1. 使用命令释放共享内存资源

可以使用ipcrm -m shmid命令释放指定id的共享内存资源。

注意: 指定删除时使用的是共享内存的用户层id,即列表当中的shmid

2. 在程序中释放共享内存资源

控制共享内存我们需要用shmctl函数:

c 复制代码
NAME
       shmctl - System V shared memory control

SYNOPSIS
       #include <sys/ipc.h>
       #include <sys/shm.h>

       int shmctl(int shmid, int cmd, struct shmid_ds *buf);

shmctl函数的参数说明:

  • 第一个参数shmid,表示所控制共享内存的用户级标识符。
  • 第二个参数cmd,表示具体的控制动作。
  • 第三个参数buf,用于获取或设置所控制共享内存的数据结构。

shmctl函数的返回值说明:

  • shmctl调用成功,返回0。
  • shmctl调用失败,返回-1,并设置错误码errno

其中,作为shmctl函数的第二个参数传入的常用的选项有以下三个:

选项 作用
IPC_STAT 获取共享内存的当前关联值,此时参数buf作为输出型参数
IPC_SET 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值
IPC_RMID 删除共享内存段

例如,在以下代码当中,共享内存被创建,2秒后删除除共享内存,再过2秒程序退出。

cpp 复制代码
#include <iostream>
#include <cstdlib>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "."
#define PROJ_ID 0x66
#define MAX_SIZE 4097

int main()
{
    // 获取key值
    key_t key = ftok(PATHNAME, PROJ_ID);
    if(key == -1)
    {
        std::cerr << "ftok: " << strerror(errno) << std::endl;
        exit(1);
    }
    std::cout << "key: " << key << std:: endl;
    
    // 创建新的共享内存
    int shm_id = shmget(key, MAX_SIZE, IPC_CREAT | IPC_EXCL);
    if(shm_id == -1)
    {
        std::cerr << "shmget:" << strror(errno) << std::endl;
        exit(2);
    }
    std::cout << "shm_id: " << shm_id << std::endl;
	
	sleep(2);

    // 删除共享内存
    int ret = shmctl(shm_id, IPC_RMID, NULL);
	if(ret == -1)
	{
		std::cerr << "shmctl:" << strror(errno) << std::endl;
        exit(3);
	}
	sleep(2);
	
    return 0;
}

我们可以在程序运行时,使用以下监控脚本时刻关注共享内存的资源分配情况:

bash 复制代码
while :; do ipcs -m; echo "################"; sleep 1; done

通过监控脚本可以确定共享内存确实创建并且成功释放了

5.5 共享内存的关联

共享内存创建完成后,还需要将共享内存连接到进程的地址空间,这需要使用shmat函数:

c 复制代码
NAME
       shmat, shmdt - System V shared memory operations

SYNOPSIS
       #include <sys/types.h>
       #include <sys/shm.h>

       void *shmat(int shmid, const void *shmaddr, int shmflg);

       int shmdt(const void *shmaddr);

shmat函数的参数说明:

  • 第一个参数shmid,表示待关联共享内存的用户级标识符。
  • 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
  • 第三个参数shmflg,表示关联共享内存时设置的某些属性。

shmat函数的返回值说明:

  • shmat调用成功,返回一个制作,指向共享内存映射到进程地址空间中的起始地址。
  • shmat调用失败,返回(void*)-1

其中,作为shmat函数的第三个参数传入的常用的选项有以下三个:

选项 作用
SHM_RDONLY 关联共享内存后只进行读取操作
SHM_RND 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA)
0 默认为读写权限

使用shmat函数对共享内存进行关联:

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "."
#define PROJ_ID 0x66
#define MAX_SIZE 4097

int main()
{
    // 获取key值
    key_t key = ftok(PATHNAME, PROJ_ID);
    if(key == -1)
    {
        std::cerr << "ftok: " << strerror(errno) << std::endl;
        exit(1);
    }
    std::cout << "key: " << key << std:: endl;
    
    // 创建新的共享内存
    int shm_id = shmget(key, MAX_SIZE, IPC_CREAT | IPC_EXCL);
    if(shm_id == -1)
    {
        std::cerr << "shmget:" << strror(errno) << std::endl;
        exit(2);
    }
    std::cout << "shm_id: " << shm_id << std::endl;
	
	sleep(2);

	// 关联共享内存
    char *start = (char*)shmat(shm_id, NULL, 0);
    if(start == (void*)-1)
    {
        std::cerr << "attShm: " << strerror(errno) << std::endl;
        exit(3);
    }
    printf("shm start address: %p\n", start);
	sleep(2);
	
    // 删除共享内存
    int ret = shmctl(shm_id, IPC_RMID, NULL);
	if(ret == -1)
	{
		std::cerr << "shmctl:" << strror(errno) << std::endl;
        exit(4);
	}
	sleep(2);
	
    return 0;
}

代码运行后发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,所以创建出来的共享内存的默认权限为0,即什么权限都没有,因此server进程没有权限关联该共享内存。

我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同。

cpp 复制代码
int shm_id = shmget(key, MAX_SIZE, IPC_CREAT | IPC_EXCL | 0666);

此时再运行程序,即可发现关联该共享内存的进程数由0变成了1,而共享内存的权限显示也不再是0,而是我们设置的666权限。

5.6 共享内存的去关联

取消共享内存与进程地址空间之间的关联我们需要用shmdt函数:

c 复制代码
NAME
       shmat, shmdt - System V shared memory operations

SYNOPSIS
       #include <sys/types.h>
       #include <sys/shm.h>

       void *shmat(int shmid, const void *shmaddr, int shmflg);

       int shmdt(const void *shmaddr);

shmdt函数的参数说明:

  • 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。

shmdt函数的返回值说明:

  • shmdt调用成功,返回0。
  • shmdt调用失败,返回-1,并设置错误码errno

现在我们就能够取消共享内存与进程之间的关联了

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "."
#define PROJ_ID 0x66
#define MAX_SIZE 4097

int main()
{
    // 获取key值
    key_t key = ftok(PATHNAME, PROJ_ID);
    if(key == -1)
    {
        std::cerr << "ftok: " << strerror(errno) << std::endl;
        exit(1);
    }
    std::cout << "key: " << key << std:: endl;
    
    // 创建新的共享内存
    int shm_id = shmget(key, MAX_SIZE, IPC_CREAT | IPC_EXCL);
    if(shm_id == -1)
    {
        std::cerr << "shmget:" << strror(errno) << std::endl;
        exit(2);
    }
    std::cout << "shm_id: " << shm_id << std::endl;
	
	sleep(2);

	// 关联共享内存
    char *start = (char*)shmat(shm_id, NULL, 0);
    if(start == (void*)-1)
    {
        std::cerr << "attShm: " << strerror(errno) << std::endl;
        exit(3);
    }
    printf("shm start address: %p\n", start);
	sleep(2);

	int ret = shmdt(shmaddr);
    if(ret == -1)
    {   
        std::cerr << "shmdt: " << strerror(errno) << std::endl;
        exit(3);
    }
    sleep(2);
	
    // 删除共享内存
    ret = shmctl(shm_id, IPC_RMID, NULL);
	if(ret == -1)
	{
		std::cerr << "shmctl:" << strror(errno) << std::endl;
        exit(4);
	}
	sleep(2);
	
    return 0;
}

运行程序,通过监控即可发现该共享内存的关联数由1变为0的过程,即取消了共享内存与该进程之间的关联。

注意: 将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。

5.7 使用共享内存实现serve&client通信

在知道了共享内存的创建、关联、去关联以及释放后,现在可以尝试让两个进程通过共享内存进行通信了。在让两个进程进行通信之前,我们可以先测试一下这两个进程能否成功挂接到同一个共享内存上。

服务端负责创建共享内存,创建好后将共享内存和服务端进行关联,之后进入死循环,便于观察服务端是否挂接成功。

公共头文件comm.hpp

cpp 复制代码
#ifndef _COMM_HPP_
#define _COMM_HPP_

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "."
#define PROJ_ID 0x66
#define MAX_SIZE 4097

key_t getKey()
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    if(key == -1)
    {
        std::cerr << "getKey: " << strerror(errno) << std::endl;
    }

    return key;
}

int getShmHelper(key_t key, int shmflg)
{
    int shm_id = shmget(key, MAX_SIZE, shmflg);
    if(shm_id == -1)
    {
        std::cerr << "creatShm:" << strerror(errno) << std::endl;
        exit(1);
    }

    return shm_id;
}

int creatShm(key_t key)
{
    return getShmHelper(key, IPC_CREAT | IPC_EXCL | 0666);
}

int getShm(key_t key)
{
    return getShmHelper(key, IPC_CREAT | 0666);
}

void *attachShm(int shm_id)
{
    void *start = shmat(shm_id, NULL, 0);

    if((long long)start == -1L)
    {
        std::cerr << "attShm: " << strerror(errno) << std::endl;
        exit(2);
    }

    return start;
}

void detachShm(const void *shmaddr)
{
    int ret = shmdt(shmaddr);
    if(ret == -1)
    {   
        std::cerr << "detachShm: " << strerror(errno) << std::endl;
        exit(3);
    }
}


void delShm(int shm_id)
{
    int ret = shmctl(shm_id, IPC_RMID, NULL);
    if(ret == -1)
    {
        std::cerr << "delShm: " << strerror(errno) << std::endl;
        exit(4);
    }
}

#endif

服务端代码如下:

cpp 复制代码
#include "comm.hpp"

int main()
{
    // 获取key值
    key_t key = getKey();
    std::cout << "key: " << key << std:: endl;
    
    // 创建共享内存
    int shm_id = creatShm(key);
    std::cout << "shm_id: " << shm_id << std::endl;
    sleep(2);

    // 关联共享内存
    char *start = (char*)attachShm(shm_id);
    //std::cout << "shm start address: " << start << std::endl;
    printf("shm start address: %p\n", start);
    sleep(2);

    // 共享内存的使用
    while(true)
    {
        std::cout << "client say: " << start << std::endl;
        sleep(1);
    }

    // 去关联
    detachShm(start);
    sleep(2);

    // 删除共享内存
    delShm(shm_id);
    sleep(2);

    return 0;
}

客户端代码如下:

cpp 复制代码
#include "comm.hpp"

int main()
{
    // 获取key值
    key_t key = ftok(PATHNAME, PROJ_ID);
    std::cout << "key: " << key << std:: endl;
    
    // 获取共享内存
    int shm_id = getShm(key);
    std::cout << "shm_id: " << shm_id << std::endl;
    sleep(2);

    // 关联共享内存
    char *start = (char*)attachShm(shm_id);
    //std::cout << "shm start address: " << start << std::endl;
    printf("shm start address: %p\n", start);
    sleep(2);

    // 共享内存的使用
    const char *msg = "hello server, I am client";
    int cnt = 1;
    while(true)
    {
        snprintf(start, MAX_SIZE, "%s[pid:%ld][%d]", msg, getpid(), cnt++);
        sleep(1);
        if(cnt == 10)
        {
            break;
        }
    }

    return 0;
}

先后运行服务端和客户端后,通过监控脚本可以看到服务端和客户端所关联的是同一个共享内存,共享内存关联的进程数也是2,表示服务端和客户端挂接共享内存成功。同时服务器不断接收客户端发送的消息。

5.8 共享内存与管道进行对比

共享内存(Shared Memory)和管道(Pipes)是两种在操作系统中常用来实现进程间通信(IPC, Inter-Process Communication)的机制。以下是它们的一些关键区别:

共享内存

  • 速度:共享内存是一种非常快速的IPC机制,因为它允许多个进程直接访问同一内存区域。
  • 同步问题:使用共享内存时,多个进程可能会同时尝试读写同一内存区域,因此需要额外的同步机制(如互斥锁、信号量等)来防止竞争条件和数据不一致。
  • 资源利用:共享内存不需要内核介入来传输数据,减少了数据复制的需要,因此可以更有效地使用系统资源。
  • 复杂性:通常来说,使用共享内存的程序比使用管道的程序更复杂,因为需要处理同步和协调多个进程对内存的访问。

管道

  • 简易性:管道是一种相对简单的IPC机制,容易实现和使用。它们在UNIX和类UNIX系统中非常常见。
  • 单向通信:管道通常是单向的,即数据只能在一个方向上流动,从写入端流向读取端。双向通信需要两个管道。
  • 缓冲区限制:管道使用内核中的缓冲区来传输数据,这意味着它们可能会受到缓冲区大小的限制。
  • 数据流动性:数据通过管道从一个进程流向另一个进程,这就像在两个进程之间建立了一个数据流。
  • 阻塞和同步:管道在读写操作时可能会导致阻塞,直到另一端的进程执行相应的读写操作。

对比

  • 性能:共享内存的性能通常优于管道,因为它避免了数据在内核空间和用户空间之间的复制。
  • 用途:管道适合于简单的、顺序的、少量数据的通信,而共享内存适合于需要高速、大量数据交换的应用。
  • 复杂度:共享内存需要更多的编程工作来维护多个进程间的同步,而管道则因其简单性而容易集成到应用程序中。

在选择使用哪种IPC机制时,需要考虑应用程序的具体需求,包括数据传输的速度、数据量、同步与复杂度等因素。

当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。

我们先来看看管道通信:

从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作

  1. 服务端将信息从输入文件复制到服务端的临时缓冲区中。
  2. 将服务端临时缓冲区的信息复制到管道中。
  3. 客户端将信息从管道复制到客户端的缓冲区中。
  4. 将客户端临时缓冲区的信息复制到输出文件中。

我们再来看看共享内存通信:

从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作

  1. 从输入文件到共享内存。
  2. 从共享内存到输出文件。

所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。

但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。

相关推荐
鱼香rose__几秒前
tmux和vim
linux·编辑器·vim
EverydayJoy^v^4 分钟前
RH134学习进程——五.调优系统性能
linux·运维·服务器
小李广5 分钟前
修改MS源码—开发新接口:查询所有项目下面的模块
java·linux·服务器
shhpeng6 分钟前
Debian packages 的签名与验证
运维·debian
猫猫的小茶馆8 分钟前
【Linux 驱动开发】嵌入式 Linux 开发概念
linux·服务器·arm开发·stm32·单片机·嵌入式硬件·mcu
还不秃顶的计科生8 分钟前
查看linux服务器中某文件夹占用空间大小
服务器·python
旺仔Sec8 分钟前
2026年湖北省职业院校技能大赛5G组网与运维(高职学生组)任务书
运维·5g
小五传输9 分钟前
探秘主流的内外网文件传输方式,解锁高效安全共享新途径
大数据·运维·安全
“αβ”9 分钟前
UDP与TCP的区别
linux·运维·服务器·网络·网络协议·tcp/ip·udp
天上飞的粉红小猪11 分钟前
应用层自定义协议与序列化
运维·服务器·网络