Linux进程间通信

53 阅读34分钟

[TOC]

Linux进程间通信

一、进程间通信的介绍

1.进程间通信的概念

进程通信(Interprocess communication),简称:IPC; 本来进程之间是相互独立的。但是由于不同的进程之间可能要共享某些信息,所以就必须要有通讯来实现进程间的互斥和同步。比如说共享同一块内存、管道、消息队列、信号量等等就是实现这一过程的手段,相当于移动公司在打电话的作用。

2.进程间通信的目的

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

3.进程间通信的前提

​ 进程间通信的前提本质:由操作系统参与,提供一份所有通信进程都能看到的公共资源;两个或多个进程相互通信,必须先看到一份公共的资源,这里所谓的资源是属于操作系统的,就是一段内存(可能以文件的 方式提供、可能以队列的方法提供,也有可能提供的就是原始内存块),这也就是通信方式有很多种的原因;

4.进程间通信的分类

管道

  • 匿名管道pipe
  • 命名管道

System V IPC

  • System V 消息队列
  • System V 共享内存(重点介绍)
  • System V 信号量

POSIX IPC(本次不做介绍)

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

二、管道

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

image-20240207160630677

​ 通过管道我们查看test.c文件写了多少行代码。其中cat和wc是两个命令,运行起来也就是进程,cat test.c 进程将查看内容通过管道交给了下一个进程 wc -l 来计算代码的行数;

image-20240207160853343

三、匿名管道

1.基本原理

​ 匿名管道用于进程间通信,且仅限于父子进程之间的通信。

image-20240207161042662

​ 我们知道进程的PCB中包含了一个指针数组 struct file_struct,它是用来描述并组织文件的。父进程和子进程均有这个指针数组,因为子进程是父进程的模板,其代码和数据是一样的; ​ 打开一个文件时,其实是将文件加载到内核中,内核将会以结构体(struct file)的形式将文件的相关属性、文件操作的指针集合(即对应的底层IO设备的调用方法)等; ​ 当父进程进行数据写入时(例如:写入“hello Linux”),数据是先被写入到用户级缓冲区,经由系统调用函数,又写入到了内核缓冲区,在进程结束或其他的操作下才被写到了对应的设备中; ​ 如果数据在写入设备之前,“hello Linux”是在内核缓冲区的,因为子进程和父进程是同时指向这个文件的,所以子进程是能够看到这个数据的,并且可以对其操作; ​ 简单来说,父进程向文件写入数据时,不直接写入对应的设备中,而是将数据暂存到内核缓冲区中,交给子进程来处理; ​ 所以这种基于文件的方法就叫管道;

2.管道的创建步骤

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

​ 匿名管道属于单向通信,意味着父子进程只有一个端是打开的,实现父子进程通信的时候就需要根据自己的想要实现的情况,关闭对应的文件描述符;

1.pipe函数

#include <unistd.h>
int pipe(int pipefd[2]);

函数的参数是两个文件描述符,是输出型参数:

  • pipefd[0]:读管道——对应的文件描述符是3
  • pipefd[1]:写管道——对应的文件描述符是4

返回值:成功返回0,失败返回-1;

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main() {
    int pipefd[2] = {0};
    if(pipe(pipefd) != 0){
        perror("pipe error!");
        return 1;
    }
    //pipefd[0]:读取段  pipefd[1]:写入端
    printf("pipefd[0]:%d\n",pipefd[0]);//3
    printf("pipefd[1]:%d\n",pipefd[1]);//4
    return 0;
}

image-20240207163824709

2.代码实战

接下来我们来实现子进程写入数据,父进程读取数据;那么我们就需要针对父子进程关闭对应的文件描述符fd,子进程关闭读端,父进程关闭写端;

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>                                   
#include <string.h>
//让子进程sleep
int main() {
    int pipefd[2] = {0};
    if(pipe(pipefd) != 0){ //创建匿名管道
        perror("pipe error!");
        return 1;
    }
    //pipefd[0]:读取端  pipefd[1]:写入端
    printf("pipefd[0]:%d\n",pipefd[0]);//3
    printf("pipefd[1]:%d\n",pipefd[1]);//4
    if(fork() == 0){
        //子进程---写入
        close(pipefd[0]); //关闭子进程的读取端
        const char* msg = "hello-linux!";
        while(1){
            write(pipefd[1], msg, strlen(msg)); //子进程不断的写数据
            sleep(1);
        }
    exit(0);
    }
    //父进程---读取
    close(pipefd[1]); //关闭父进程的写入端
    char buffer[64] = {0};
    while(1){
        //如果read返回值是0,就意味着子进程关闭文件描述符了
        ssize_t s = read(pipefd[0], buffer, sizeof(buffer)); //父进程不断的读数据
        if(s == 0){
            break;
        }
        else if(s > 0){
            buffer[s] = 0;
            printf("child say to father:%s\n",buffer);
        }
        else{
            break;
        }
    }
return 0;
}

image-20240207164458022

3.管道的五个特点和四种情况

五个特点:

  1. 管道是一个只能单向通信的通信信道,仅限于父子间通信
  2. 管道提供流式服务
  3. 管道操作自带同步与互斥机制
  4. 进程退出,管道释放,所以管道的生命周期随进程
  5. 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道

四种情况:

  1. 读端不读或者读的慢,写端要等待读端;
  2. 读端关闭,写端收到SIGPIPE信号直接终止;
  3. 写端不写或者写的慢,读端要等待写端;
  4. 写端关闭,读端读完pipe内部的数据然后再读,会读到0为止,表明读到文件结尾;

接下来我们通过下面的程序进行验证:==管道是单向通信和面向字节流==

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>                                   
#include <string.h>
int main() {
    int pipefd[2] = {0};
    if(pipe(pipefd) != 0){ //创建匿名管道
        perror("pipe error!");
        return 1;
    }
    //pipefd[0]:读取端  pipefd[1]:写入端
    printf("pipefd[0]:%d\n",pipefd[0]);//3
    printf("pipefd[1]:%d\n",pipefd[1]);//4
    if(fork() == 0){
        //子进程---写入
        close(pipefd[0]); //关闭子进程的读取端
        const char* msg = "hello-linux!";
        while(1){
            write(pipefd[1], msg, strlen(msg)); //子进程写数据
            sleep(1);
        }
    exit(0);
    }
    //父进程---读取
    close(pipefd[1]); //关闭父进程的写入端
    char buffer[64] = {0};
    while(1){
        sleep(1);
        ssize_t s = read(pipefd[0], buffer, sizeof(buffer)); //父进程读数据
        if(s == 0){
            break;
        }
        else if(s > 0){
            buffer[s] = 0;
            printf("child say to father:%s\n",buffer);
        }
        else{
            break;
        }
    }
return 0;
}

​ 上述代码中,在父子进程中都有sleep函数:(我们切换使用)

  1. 当子进程sleep时,父进程没有sleep,运行结果如下:

    image-20240207173253720

    我们可以发现,子进程在写入数据后经由管道交给父进程处理,这就验证了管道是单向通信的信道;

  2. 当父进程sleep时,子进程没有sleep,运行结果如下:

    image-20240207173559431

    我们发现打印出来的数据并不像刚才那样一条一条的打印,这是因为子进程在写入数据时,只要pipe内部有缓冲区,就不断的写入;当父进程在读取的时候;只要管道内有数据就会一直读;这就是所谓的字节流;即管道是面向字节流的(提供流式服务)

通过下面的程序来验证:同步机制

int main() {
     int pipefd[2] = {0};
     if(pipe(pipefd) != 0){
         perror("pipe error!");
         return 1;           
     }
     //pipefd[0]:读取端  pipefd[1]:写入端
     printf("pipefd[0]:%d\n",pipefd[0]);//3
     printf("pipefd[1]:%d\n",pipefd[1]);//4
     if(fork() == 0){
         //子进程---写入
         close(pipefd[0]);
         int count = 0;
         while(1){
             write(pipefd[1], "a", 1);
             count++;
             printf("count: %d\n",count);
         }
         exit(0);
     }
     //父进程---读取
     close(pipefd[1]);
     while(1){
         sleep(1);
     }
     return 0;
}

​ 上面的代码中,子进程在不断的写入数据,而父进程一直不读取数据,运行结构如下:

image-20240207174453817

​ 我们运行起来后,就会一直刷屏,知道count为65536的时候停下来。这里为什么子进程不继续写了呢?这首先说明管道是有大小的,在我的服务器下Linux的管道容量是65536(64Kb),其次子进程不继续写了,表明写端写满后要等待读端读取,才可以继续写入; ​ 我们对上面的代码进行修改,让父进程一次读取一个字符,检验一下子进程会不会继续写入。

//这里简写了,其他内容和上面的代码一样 父进程---读取
close(pipefd[1]);
while(1){
    sleep(10);
    char c = 0;
    read(pipefd[0], &c, 1);
    printf("father taken:%c\n", c);         
}

image-20240207175153133

​ 我们发现父进程没过10秒读取一个字符,但是子进程并没有写入,我们试着读取字符大小调整到4096个字节时,会发现读端读走数据后,写端就进行写入了;这表明管道自带同步机制(当然管道肯定也是有互斥机制的 ,这里不做讲解) image-20240207175619948

​ 通过下面的程序验证:写端不写或者写的慢,读端会等待写端;(读端不读同理)

int main() {
     int pipefd[2] = {0};              
     if(pipe(pipefd) != 0){
         perror("pipe error!");
         return 1;
     }
     //pipefd[0]:读取端  pipefd[1]:写入端
     printf("pipefd[0]:%d\n",pipefd[0]);//3           
     printf("pipefd[1]:%d\n",pipefd[1]);//4
     //子进程写的慢
     if(fork() == 0){ //子进程---写入
         close(pipefd[0]);
         const char* msg = "hello-linux!";
         while(1){
             write(pipefd[1], msg, strlen(msg));
             sleep(10);    
         }
         exit(0);
     }
     //父进程---读取
     close(pipefd[1]);
     while(1){
         sleep(10);
         char c[64] = {0};
         ssize_t s = read(pipefd[0], &c, sizeof(c)-1);
         c[s] = 0;
         printf("father taken:%s\n", c);
     }
     return 0;
}

运行结果如下:

image-20240207180159690

​ 从运行结果可以看出,读端是在等待写端的,这也就是所谓的同步机制,当我们对写端不再进行写入时,读端也会一直在等待写端的数据写入

​ 通过下面的程序验证:写端关闭,读端读完pipe内部的数据然后再读,会读到0为止,表明读到文件结尾

int main() {
     int pipefd[2] = {0};       
     if(pipe(pipefd) != 0){
         perror("pipe error!");
         return 1;
     }
     //pipefd[0]:读取端  pipefd[1]:写入端
     printf("pipefd[0]:%d\n",pipefd[0]);//3           
     printf("pipefd[1]:%d\n",pipefd[1]);//4
     //子进程写的慢
     if(fork() == 0){ //子进程---写入
        close(pipefd[0]);
        const char* msg = "hello-linux!";
        while(1){
            write(pipefd[1], msg, strlen(msg));
            sleep(10); 
            break;   
        }
        close(pipefd[1]);
        exit(0);
     } //父进程---读取
     close(pipefd[1]);
     while(1){
        sleep(10);
        char c[64] = {0};
        ssize_t s = read(pipefd[0], c, sizeof(c)-1);
        if(s > 0){
            c[s] = 0;
            printf("father taken:%s\n", c);
        }
        else if(s ==0){
            printf("write quit...\n");
            break;
        }
        else{ break; }
     }
     return 0;
}

​ 在上面的程序中,我们让写端写入一条数据后,10秒直接退出,然后关闭写进程的写端口,运行结果如下:

image-20240207203706700

​ 当写端口写入数据后关闭了写端口,读端会从管道内读到文件的末尾,接收到写端关闭后,就自行退出了。

​ 通过下面的程序验证:读端关闭,写端收到SIGPIPE信号直接终止

int main() {
     int pipefd[2] = {0};       
     if(pipe(pipefd) != 0){
         perror("pipe error!");
         return 1;
     }
     //pipefd[0]:读取端  pipefd[1]:写入端
     printf("pipefd[0]:%d\n",pipefd[0]);//3           
     printf("pipefd[1]:%d\n",pipefd[1]);//4
     //子进程写的慢
     if(fork() == 0){
        //子进程---写入
        close(pipefd[0]);
        const char* msg = "hello-linux!";
        while(1){
            write(pipefd[1], msg, strlen(msg));  
        }
        exit(0);
     }
     //父进程---读取
     close(pipefd[1]);
     while(1){
        sleep(10);
        char c[64] = {0};
        ssize_t s = read(pipefd[0], c, sizeof(c)-1);
        if(s > 0){
            c[s] = 0;
            printf("father taken:%s\n", c);
        }
        else if(s ==0){
            printf("write quit...\n");
            break;
        }
        else{
            break;
        }
        break;
     }
     close(pipefd[0]);  
	//在源程序的基础上加上,用来获取子进程退出信号
	 int status = 0;
	 waitpid(-1, &status, 0);
	 printf("exit code: %d\n",(status >> 8)& 0xFF);
	 printf("exit signal: %d\n",status& 0x7F);
     return 0;
}

image-20240207205014451

​ 首先我们对程序进行分析,子进程处于一直写的状态,父进程读取一次数据后就break了,然后将读端关闭了(文件描述符0);当我们的读端关闭,写端还在写入,在操作系统的层面上,严重不合理;这本质上就是在浪费操作系统的资源,所以操作系统在遇到这样的情况下,会将子进程杀掉(发送13号信号——SIGPIPE);image-20240207205253737

4.管道的读写规则

int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

img

当没有数据可读时

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

当管道满的时候

  • O_NONBLOCK disable:write调用阻塞,知道有进程读走程序
  • O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
  • 如果所有管道写端对应的文件描述符被关闭,则read返回0
  • 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
  • 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。

img

四、命名管道

​ 匿名管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。如果我们想在不相关的进程间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件;

1.命名管道的创建

1.命令行创建

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

image-20240207212726300

​ 我们创建好命令管道后,就可以实现两个进程间通信了;(左图的进程进行循环的数据写入,右图的进程进行读取)当我们关闭读端的时候,写端也会被操作系统关闭,当我们关闭写端时,读端会一直在等写端; image-20240207213618182

image-20240207213752865

​ 当然也可以让读端不断的读取数据,写端只要写就行了() image-20240207214241093

2.程序创建(mkfifo函数)

​ 在程序中创建命名管道使用mkfifo函数,mkfifo函数的函数原型如下:int mkfifo(const char *pathname, mode_t mode);

pathname:表示你要创建的命名管道文件

  • 如果pathname是以文件的方式给出,默认在当前的路径下创建;
  • 如果pathname是以某个路径的方式给出,将会在这个路径下创建;

mode:表示给创建的命名管道设置权限

​ 我们在设置权限时,例如0666权限,它会受到系统的umask(文件默认掩码)的影响,实际创建出来是(mode &~ umask)0644;所以想要正确的得到自己设置的权限(0666),我们需要将文件默认掩码设置为0;

返回值:命名管道创建成功返回0,失败返回-1

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#define MY_FIFO "myfifo" //默认是在当前路径下创建
//#define MY_FIFO "../xxx/myfifo"//指定在上级目录下的xxx目录下创建
int main() {
    umask(0);        
    if(mkfifo(MY_FIFO, 0666) < 0) {
         perror("mkfifo");
         return 1;
    }
    return 0;
}

image-20240207215333880

2.命名管道的打开规则

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

  • O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
  • O_NONBLOCK enable:立刻返回成功、

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

  • O_NONBLOCK disable:阻塞直到有相应进程为了读而打开该FIFO
  • O_NONBLOCK enable:立即返回失败,错误码为ENXIO

3.用命名管道实现server&client通信

​ 实现server(服务端)和client(客户端)之间的通信,我们让server创建命名管道,用来读取命名管道内的数据;client获取管道,用来向命名管道内写数据;server(服务端)和client(客户端)想要使用同一个管道,这里我们可以让客户端和服务端包含同一个头文件comm.h,该头文件提供这个公用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了。 image-20240207225118708

comm.h:

#pragma once
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>                                     
#define MY_FIFO "./fifo" 

server.c:

#include "comm.h"
int main() {
    umask(0); //将文件掩码设置为0,确保得到我们设置的权限
    if(mkfifo(MY_FIFO, 0666) < 0){ //服务端用来创建命名管道文件
        perror("mkfifo");
        return 1;
    }
    int fd = open(MY_FIFO, O_RDONLY); //以只读的方式打开命名管道文件
    if(fd < 0){
        perror("open");
        return 2;
    }
    while(1){
        char buffer[64] = {0};
        ssize_t s = read(fd, buffer, sizeof(buffer) - 1); //从fd(命名管道)中读数据到buffer中
        if(s > 0){                
            buffer[s] = 0;
            printf("client: %s\n", buffer); //打印客户端发来的数据
        }
        else if(s == 0){
            printf("client qiut...\n");
            break;
        }
        else{
            perror("open");
            break;
        }
    }
    close(fd); //通信结束,关闭命名管道文件
    return 0;
 } 

client.c

#include "comm.h"                                   
int main() {
    //这里不需要创建fifo,只需要获取就行
    int fd = open(MY_FIFO, O_WRONLY); //以写的方式打开命名管道文件
    if(fd < 0){ 
        perror("open");
        return 1;
    }
    //业务逻辑
    while(1){
        printf("请输入:");
        fflush(stdout);
        char buffer[64] = {0};
        //先把数据从标准输入拿到我们的client进程内部
        ssize_t s = read(0, buffer, sizeof(buffer) - 1);
        if(s > 0){
            buffer[s-1] = 0;
            printf("%s\n",buffer);
            //拿到了数据,将数据写入命名管道
            write(fd, buffer, strlen(buffer));
        }
    }
    close(fd); //通信完毕,关闭命名管道文件
    return 0;
}

编写Makefile:

.PHONY:all
all:client server
//all 依赖这两个文件,但是没有依赖方法,所有编译后只会生成两个.c文件 想要同时编译这两个,就需要加上它
client:client.c
	gcc -o $@ $^
server:server.c
	gcc -o $@ $^
.PHONY:clean
clean:
	rm -f client server fifo

​ 接下来使用Makefile进行编译,然后我们需要先将服务器端运行起来,再运行客户端,因为服务器端是用来创建命名管道文件的,先运行客户端的话,是不可以打开一个不存在的文件的;

image-20240208022037164

4.用命名管道实现client控制server执行某种任务

​ 两个进程间的通信,不是只能发送一些字符串,还可以实现一个进程控制另一个进程去实现某种任务;比如:client(客户端)向让server(服务端)执行“显示当前目录下的所有文件信息”的任务和执行“小火车命名sl”

#include "comm.h"
int main() {
	umask(0); //将文件掩码设置为0,确保得到我们设置的权限
	if (mkfifo(MY_FIFO, 0666) < 0) { //服务端用来创建命名管道文件
		perror("mkfifo");
		return 1;
	}
	int fd = open(MY_FIFO, O_RDONLY); //以只读的方式打开命名管道文件
	if (fd < 0) {
		perror("open");
		return 2;
	}
	while (1) {
		char buffer[64] = { 0 };
		ssize_t s = read(fd, buffer, sizeof(buffer) - 1); //从fd(命名管道)中读数据到buffer中
		if (s > 0) {
			buffer[s] = 0;
            //client控制server完成某种动作/任务
			if (strcmp(buffer, "show") == 0) {
				if (fork() == 0) {
					execl("/usr/bin/ls", "ls", "-l", NULL);
					exit(1);
				}
				waitpid(-1, NULL, 0);
			}
			else if (strcmp(buffer, "run") == 0) {
				if (fork() == 0) {
					execl("/usr/bin/sl", "sl", NULL);
                    exit(1);
				}
                waitpid(-1, NULL, 0);
			}
			else {
				printf("client: %s\n", buffer);
			}
		}
		else if (s == 0) {
			printf("client qiut...\n");
			break;
		}
		else {
			perror("open");
			break;
		}
	}
	close(fd); //通信结束,关闭命名管道文件
	return 0;
}

​ 客户端输入show之后,服务端就显示出当前目录下的所有文件

image-20240208134955156

​ 客户端输入run之后,服务端就让小火车跑起来了

image-20240208135357530

5.管道的总结

管道:

  • 管道分为匿名管道和命名管道;
  • 管道通信方法的中间介质的文件,通常称这种文件为管道文件;
  • 匿名管道:管道是半双工的,数据只能单向通信;需要双方通信时,需要建立起两个管道;只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程)。
  • 命名管道:不同于匿名管道之处在于它提供了一个路径名与之关联,以FIFO的文件形式存在于文件系统中。这样,即使与FIFO的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过FIFO相互通信
  • 利用系统调用pipe()创建一个无名管道文件,通常称为无名管道或PIPE;利用系统调用mkfifo()创建一个命名管道文件,通常称为有名管道或FIFO。
  • PIPE是一种非永久性的管道通信机构,当它访问的进程全部终止时,它也会随之被撤销。
  • FIFO是一种永久性的管道通信机构,它可以弥补PIPE的不足。管道文件被创建后,使用open()将文件进行打开,然后便可对它进行读写操作,通过系统调用write()和read()来实现。通信完毕后,可使用close()将管道文件关闭。
  • 匿名管道的文件是内存中的特殊文件,而且是不可见的,命名管道的文件是硬盘上的设备文件,是可见的。

五、system V进程间通信

它是操作系统层面上专门为进程间通信设计的一个方案,其通信方式包括如下三种:

  1. system V共享内存
  2. system V消息队列
  3. system V信号量

​ 其中共享内存和消息队列是以传输数据为目的的,信号量是为了保证进程间的同步和互斥而设计的;本篇主要针对共享内存进行介绍

1.system V共享内存

1.共享内存的基本原理(示意图)

​ 不同的进程想要看到同一份资源,在操作系统内部,一定是通过某种调用,在物理内存当中申请一块内存空间,然后通过某种调用,让参与通信进程“挂接”到这份新开辟的内存空间上;其本质:将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些参与通信进程便可以看到了同一份物理内存,这块物理内存就叫做共享内存。

image-20240208154723069

2.共享内存的数据结构

​ 我们知道在操作系统中是存在大量的进程的,如果两两进程进程进行通信,就需要多个共享内存。既然共享内存在系统中存在多份,就一定要将这些不同的共享内存管理起来,即先描述,再组织;为了保证两个或多个进程能够看到它们的同一份共享内存,那么共享内存一定要有能够唯一标识性的ID,方便让不同的进程识别它们的同一份共享内存;这个所谓的ID一定是在共享内存的数据结构中;

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 */
};/*shm_perm   成员储存了共享内存对象的存取权限及其它一些信息。
    shm_segsz  成员定义了共享的内存大小(以字节为单位) 。
    shm_atime  成员保存了最近一次进程连接共享内存的时间。
    shm_dtime  成员保存了最近一次进程断开与共享内存的连接的时间。
    shm_ctime  成员保存了最近一次 shmid_ds 结构内容改变的时间。
    shm_cpid   成员保存了创建共享内存的进程的 pid 。
    shm_lpid   成员保存了最近一次连接共享内存的进程的 pid。
    shm_nattch 成员保存了与共享内存连接的进程数目 */

​ 对于每个IPC对象,系统共同一个struct ipc_perm的数据结构来存放权限信息,以确定一个ipc操作是否可以访问该IPC对象。

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

3.共享内存相关函数总览

函数原型头文件功能
int shmget(key_t key,size_t size,int shmflg)#include<sys/ipc.h>
#include<sys/shm.h>
创建共享内存
key_t ftok(const char* pathname,int proj_id);#include<sys/types.h
#include<sys/ipc.h>
获取key
int shmctl(int shmid,int cmd,struct shmid_ds *buf);#include<sys/ipc.h>
#include<sys/shm.h>
控制共享内存
void * shmat(int shmid,const void *shmaddr, int shmflg);#include<sys/types.h
#include<sys/shm.h>
共享内存关联
int shmdt(const void * shmaddr);#include<sys/types.h
#include<sys/shm.h>
共享内存去关联

4.共享内存的创建

​ 创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

函数说明:

  • 得到一个共享内存标识符或创建一个共享内存内存对象并返回共享内存标识符

参数说明:

参数key:表示标识共享内存的键值

  • 需要ftok函数获取

参数size:表示待创建共享内存的大小

  • size是要建立共享内存的长度。所有的内存分配操作都是以页为单位的。所以如果一段进程只申请一块只要一个字节的内存,内存也会分配整整一页(在32位下一页的缺省大小PACE_SIZE=4096字节);这样,新创建的共享内存的大小实际上是从size这个参数调整而来的页面大小。即如果size为1至4096,则实际申请到的共享内存大小为4K(一页);4097到8192,则实际申请到的共享内存大小为8K(两页),依次类推。

参数shmflg:表示创建共享内存的方式 image-20240208170338396

shmflg主要和一些标志有关。
其中有效的包括IPC_CREAT和IPC_EXCL,它们的功能与open()的O_CREAT和O_EXCL相当。 
IPC_CREAT 如果共享内存不存在,则创建一个共享内存,否则打开操作。 
IPC_EXCL 只有在共享内存不存在的时候,新的共享内存才建立,否则就产生错误。
如果单独使用IPC_CREAT:
shmget()函数要么返回一个已经存在的共享内存的标识符 ,要么返回一个新建的共享内存的标识符。
如果将 IPC_CREAT和IPC_EXCL标志一起使用:
shmget()将返回一个新建的共享内存的标识符;如果该共享内存已存在,或者返回-1。
IPC_EXEL标志本身并没有太大的意义,但是和IPC_CREAT标志一起使用可以用来保证所得的对象是新建的,而不是打开已有的对象。

返回值:

  • 调用成功,返回一个有效的共享内存标识符。
  • 调用失败,返回-1,错误原因存于errno中。

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

#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
//把从pathname导出的信息与proj_id的低序8位组合成一个整数IPC键,传给shmget函数的key

​ ftok函数的作用就是,将一个已存在的路径名pathname(此文件必须存在且可存取)和一个整数标识符proj_id转换成一个key值。在使用shmget函数创建共享内存时,首先要调用ftok函数获取这个key值,这个key值会被填充进维护共享内存的数据结构当中,作为共享内存的唯一标识。

​ 结合上面的知识,我们就可以来创建共享内存了,代码如下:

#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#define PATH_NAME "./" //路径名
#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096      //共享内存的大小
int main() {
    key_t key = ftok(PATH_NAME, PROJ_ID);//获取key值
    if(key < 0){
        perror("ftok");
        return 1;
    }
    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL);//创建共享内存
    if(shmid < 0){
        perror("shmget");
        return 2;
    }                                      
    printf("key: %u  shmid: %d\n", key, shmid);
    return 0;
}

image-20240208190534572

​ 我们可以使用ipcs命令查看有关进程间通信设施的信息 image-20240208190846596

这里的key和上面打印出来的key是一样的,我们是以 无符号数10进制打印的;

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

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

其中:

  • key:共享内存的唯一键值
  • shmid:共享内存的编号
  • owner:创建的用户
  • perms:共享内存的权限
  • bytes:共享内存的大小
  • nattach:连接到共享内存的进程数
  • status:共享内存的状态

key vs shmid

key:只是用来在系统层面上进行标识唯一性的,不能用来管理共享内存; shmid:是操作系统给用户返回的id,用来在用户层上进行管理共享内存; ==key和shmid之间的关系类似于 fd 和FILE* 之间的关系==。

5.共享内存的释放

​ 刚刚我们已经创建好了共享内存,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道的生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。 ​ 这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。 ​ 此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。

1.使用命令释放
ipcrm -m 0
//指定删除时使用的是共享内存的用户层id,即列表当中的shmid

image-20240208192926948

2.使用函数释放

控制共享内存我们需要用shmctl函数,shmctl函数的函数原型如下:

#include <sys/types.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

函数说明:完成对共享内存的控制

参数说明:

shmctl函数的参数说明:

  • shmid:共享内存标识符
  • cmd:表示具体的控制动作
  • buf:共享内存管理结构体(参考上文的共享内存的数据结构)

返回值:

  • shmctl调用成功,返回0
  • shmctl调用失败,返回-1

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

选项说明
IPC_STAT将信息从与shmid相关联的内核数据结构复制到buf指向的shmid_ds结构中。调用者必须具有共享内存段的读权限
IPC_SET改变共享内存的状态,把Buf所指的shmid_ds结构中的uid、gid、mode复制到共享内存的shmid_ds结构中
IPC_RMID删除这片共享内存
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
#define PATH_NAME "./" //路径名
#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096      //共享内存的大小
int main() {
    key_t key = ftok(PATH_NAME, PROJ_ID);//获取key值
    if(key < 0){
        perror("ftok");
        return 1;
    }
    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL);//创建共享内存
    if(shmid < 0){
        perror("shmget");
        return 2;
    }                                                     
    printf("key: %u  shmid: %d\n", key, shmid);
    sleep(10);
    shmctl(shmid, IPC_RMID, NULL);//释放共享内存
    sleep(10);
    printf("key: 0x%x, shmid: %d -> shm delete success\n", key, shmid);
    return 0;
}

通过shell脚本查看共享内存的状态: while : ; do ipcs -m; echo “######################”; sleep 1; done

image-20240208195018420

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

上文我们提到ipcs是查看进程间通信设施的信息的,这里的perms是共享内存的权限,此时为0,表示没有任何权限,所以我们在创建共享内存的时候,想要获得权限可以如下操作:image-20240208195227589

int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建权限为0666的共享内存

6.共享内存的关联(挂接)

​ 将共享内存连接到进程地址空间需要用shmat函数,shmat函数的函数原型如下:

#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);

函数说明: 连接共享内存标识符为shmid的共享内存,连接成功后把共享内存区对象映射到调用进程的地址空间,随后可像本地空间一样访问; 参数说明:

参数说明
shmid共享内存标识符
shmaddr指定共享内存出现在进程内存地址的什么位置,一般直接指定为NULL让内核自己决定一个合适的地址位置
shmflgSHM_RDONLY:为只读模式,其他为读写模式

返回值:

  • shmat调用成功,返回共享内存映射到进程地址空间中的起始地址
  • shmat调用失败,返回(void*)-1
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
#define PATH_NAME "./" //路径名
#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096      //共享内存的大小
int main() {   
    key_t key = ftok(PATH_NAME, PROJ_ID); //获取key    
    if(key < 0){    
        perror("ftok");    
        return 1;    
    }    
    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建共享内存并设置权限   
    if(shmid < 0){    
        perror("shmget");    
        return 2;    
    }    
    printf("key: %u , shmid: %d\n", key, shmid);    
    sleep(10);    
    char* mem = (char*)shmat(shmid, NULL, 0);  //休眠10s后,关联共享内存
    printf("attaches shm success\n");    
    sleep(5);    
    shmdt(mem);    //5秒后,共享内存去关联
    printf("detaches shm success\n");    
    sleep(5);    
    shmctl(shmid, IPC_RMID, NULL);    //释放共享内存
    printf("key: 0x%x, shmid: %d -> shm delete success\n", key, shmid);
    sleep(10);    
    return 0;    
}

image-20240208200846735

7.共享内存的去关联

取消共享内存与进程地址空间之间的关联需要用shmdt函数,shmdt函数的函数原型如下:

#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);

函数说明:

​ 与shmat函数相反,shmdt函数是用来断开与共享内存附加点的地址,禁止本进程访问此片共享内存;(并不是释放共享内存)

参数说明:

​ shmaddr:连接的共享内存的起始地址

返回值:

  • shmdt调用成功,返回0
  • shmdt调用失败,返回-1

代码、运行结果同上

8.用共享内存实现server&client通信

​ 刚刚我们是同一个进程和共享内存关联的,接下来我们让两个进程通过共享内存进行通信;在线之前我们先测试一下这两个进程能否成功挂接到同一个共享内存上。

comm.h

#pragma once    
#include <stdio.h>    
#include <sys/ipc.h>    
#include <sys/shm.h>    
#include <unistd.h>                                                     
#define PATH_NAME "./"    
#define PROJ_ID 0x6666    
#define SIZE 4097

server.c

#include "comm.h"    
int main(){   
    key_t key = ftok(PATH_NAME, PROJ_ID); //获取key    
    if(key < 0){    
        perror("ftok");    
        return 1;    
    }    
    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建共享内存并设置权限   
    if(shmid < 0){    
        perror("shmget");    
        return 2;    
    }    
    printf("key: %u , shmid: %d\n", key, shmid);    
    sleep(5);    
    char* mem = (char*)shmat(shmid, NULL, 0);  //休眠10s后,关联共享内存
    printf("attaches shm success\n");    
    sleep(5);   
    /*
        通信内容(暂时不写):先测试两个进行能不能同时挂接到同一个共享内存上
    */
    shmdt(mem);    //5秒后,共享内存去关联
    printf("detaches shm success\n");    
    sleep(5);    
    shmctl(shmid, IPC_RMID, NULL);    //释放共享内存
    printf("key: 0x%x, shmid: %d -> shm delete success\n", key, shmid);
    sleep(5);    
    return 0;    
}

client.c

#include "comm.h"    
int main(){      
    key_t key = ftok(PATH_NAME, PROJ_ID);    
    if(key < 0){    
        perror("ftok");    
        return 1;    
    }//client只需要获取即可,不需要创建
    int shmid = shmget(key, SIZE, IPC_CREAT);//单独使用IPC_CREAT,共享内存存在就获取,反之创建                                
    if(shmid < 0){                   
        perror("shmid");                                  
        return 1;                            
    }                                   
    printf("key: %u , shmid: %d\n", key, shmid);
    sleep(5);                                      
    char* mem = (char*)shmat(shmid, NULL, 0);
    sleep(5);       
    printf("client process attaches success\n"); 
    /*
        通信内容(暂时不写):先测试两个进行能不能同时挂接到同一个共享内存上
    */                                               
    shmdt(mem);                                              
    sleep(5);                                     
    printf("client process detaches success\n");
    return 0;       
}

​ 从运行结果来看,两个进程确实都挂接到了共享内存;

image-20240208232050332

接下来我们来实现通信内容:

//server.c
while(1){
    sleep(1);                                                   
    printf("%s\n", mem);                                       
}

服务端不断的从共享内存中读数据;

//client.c
char c = 'A';
while(c < 'Z'){                                                 
    mem[c - 'A'] = c;
    c++;
    mem[c - 'A'] = 0;
    sleep(2);
}

客户端不断的向共享内存写数据;

​ 此时先运行服务端创建共享内存,当我们运行客户端时服务端就开始不断输出数据,说明服务端和客户端是能够正常通信的。

image-20240208232645680

9.共享内存的总结

共享内存:

  • 要使用一块共享内存,进程必须首先分配它。随后需要访问这个共享内存块的每一个进程都必须将这个共享内存绑定到自己的地址空间中。
  • 在Linux系统中,每个进程的虚拟内存都是被分为许多页面的。这些内存页面中包含了实际的数据。每个进程都会维护一个从内存地址到虚拟内存页面之间的映射关系。尽管每个进程都有自己的内存地址,不同的进程可以同时将同一个内存页面映射到自己的地址空间中,从而达到共享内存的目的。
  • 分配一个新的共享内存块会创建新的内存页面。因为所有进程都希望共享对同一块内存的访问,只应由一个进程创建一块新的共享内存。再次分配一块已经存在的内存块不会创建新的页面,而只是会返回一个标识该内存块的标识符。
  • 一个进程如需使用这个共享内存块,则首先需要将它绑定到自己的地址空间中。这样会创建一个从进程本身虚拟地址到共享页面的映射关系。当对共享内存的使用结束之后,这个映射关系将被删除。当再也没有进程需要使用这个共享内存块的时候,必须有一个(且只能是一个)进程负责释放这个被共享的内存页面。
  • 所有共享内存块的大小都必须是系统页面大小的整数倍。系统页面大小指的是系统中单个内存页面包含的字节数。在Linux系统中,内存页面大小是4KB,不过您仍然应该通过调用getpagesize 获取这个值(通过man 2 getpagesize 查看)。
  • 共享内存的生命周期是随内核的,而管道是随进程的。
  • 共享内存不提供任何的同步和互斥机制,需要程序员自行保证数据安全。
  • 共享内存在各种进程间通信方法中具有最高的效率。访问共享内存区域和访问进程独有的内存区域一样块,并不需要通过系统调用或者其他需要切入内核的过程来完成。同时它也避免了对数据的各种不必要的复制。