【万字详解Linux系列】进程间通信(IPC)


一、进程间通信

1.目的

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

2.如何通信

  1. 进程运行时具有独立性(数据层面具有独立性,即使是父子进程操作数据时也要写时拷贝)。
  2. 进程间的通信一般要借助第三方资源(OS)
  3. 通信的本质是数据的“拷贝”(进程A将数据“拷贝”给操作系统,操作系统再将数据“拷贝”给进程B,所以操作系统一定要提供一段内存区域且两个进程都能看到这段区域)。

进程间通信的本质是让不同的进程能看到同一份资源(内存、文件缓冲等等)。这一个资源由不同的部分提供,就有了不同的进程间通信方式。


3.分类

管道:匿名管道、命名管道。

System V:System V消息队列、System V共享内存、System V 信号量。

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

本文重点介绍管道和System V共享内存。


二、管道

1.概念

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

管道通信本质是文件,操作系统没有做过多的工作。

管道只能进行单向通信。


2.匿名管道

(1)实现父子进程间通信

首先大致了解一下pipe函数:

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

下面简单使用一下pipe函数。

#include <stdio.h>
#include <unistd.h>

int main()
{
    
    
    //child->write, father->read

    int fd[2] = {
    
     0,0 };
    if (pipe(fd) < 0)
    {
    
    
    	//调用失败
        perror("pipe!");
        return 1;
    }

	//根据文件描述符的分配规则,这里一定是3、4
	//如果在这之前关闭了某个文件描述符,则按照分配规则依次分配
    printf("fd[0]: %d\n", fd[0]);//3
    printf("fd[1]: %d\n", fd[1]);//4
    return 0;
}

然后用代码实现子进程向父进程发送字符串。

#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main()
{
    
    
    int fd[2] = {
    
     0,0 };
    if (pipe(fd) < 0)
    {
    
    
        perror("pipe!");
        return 1;
    }

    //fd[0]表示读端, fd[1]表示写端
    //子进程写,父进程读
    pid_t id = fork();
    if (id == 0)
    {
    
    
        //child
        close(fd[0]);//关闭读
        int count = 5;
        const char* msg = "hello father!\n";
        while (count--)
        {
    
    
            write(fd[1], msg, strlen(msg));
            sleep(1);
        }
        close(fd[1]);
        exit(2);
    }

    //father
    close(fd[1]);//关闭写
    char buffer[64];
    while (1)
    {
    
    
        ssize_t ret = read(fd[0], buffer, sizeof(buffer));
        if (ret > 0)//如果没有读到结尾,ret就是字符个数
        {
    
    
            buffer[ret] = '\0';//由于文件的读写没有'\0',所以需要手动添加
            printf("child send to father : %s\n", buffer);
        }
        else if (ret == 0)//读到结尾
        {
    
    
            printf("read file end!\n");
            break;
        }
        else if (ret < 0)
        {
    
    
            printf("read error!\n");
            break;
        }
    }
    close(fd[0]);
    waitpid(id, NULL, 0);
    
    return 0;
}

结果如下,子进程成功向父进程发送了消息。
在这里插入图片描述


几个小问题

  • 可不可以定义一个全局的数据区,子进程向其中写入内容,然后父进程再从中读取。
    不可以,因为进程间具有独立性,不能相互干扰,子进程在写入内容时会写时拷贝,这样代码中看起来是同一块数据区,但映射到物理内存后是不同的两块内容,互相不可访问。

  • 既然会有写时拷贝,那为什么上面代码中的写入没有写时拷贝呢?
    因为上面在write时将内容写进了操作系统提供的文件区内,而不是写入到了子进程自己的数据区内,写入的内容不属于父进程或子进程,不需要写时拷贝。

  • sleep(1)在子进程中才有,但父进程打印时却也是每隔一秒打印一次,为什么?
    在多执行流(比如这里同时有父子进程)下,访问同一份资源,这个资源叫临界资源。
    在上面的代码中,可能会发生子进程写入到一半,父进程就来读取,这显然是不被允许的,所以就需要同步与互斥来解决。
    这里主要是互斥,即任何时刻都只能有一个进程正在使用某种资源,管道内部自动提供了同步与互斥机制。当子进程写入并sleep时,父进程阻塞地等待,由于子进程sleep,导致父进程也看起来sleep。

  • 如果写端关闭,那么读端read就会返回0,代表读取结束。


(2)fork角度的理解

在这里插入图片描述


(3)文件描述符角度的理解

下面从父子进程来理解匿名管道。

在这里插入图片描述
既然最后只能留一个读写端,那么父进程最开始为什么要把读写端都打开呢?
父进程打开读写端是为了创建子进程时子进程得到的读写端也都打开,而最后各自只剩一个读写端是因为进程间的通信是单向的。

通常规定fd[0]是读文件描述符,而fd[1]是写文件描述符。


(4)匿名管道特点

  • 只能用于具有共同祖先的进程之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
  • 管道提供流式服务(在网络部分中会详谈)。
  • 一般而言,进程退出,管道释放,所以管道的生命周期随进程变化而变化。
  • 一般而言,内核会对管道操作进行同步与互斥。
  • 管道的数据只能向一个方向流动(半双工);需要双方通信时(全双工),需要建立起两个管道。

(5)四种情况

  • 如果管道空间被写满,负责write的进程就会被挂起。
  • 如果管道空间什么都没有,负责read的进程就会被挂起。
  • 如果负责write的进程写完内容后关闭,负责read的进程的read返回值为0。
  • 如果负责read的进程关闭,而负责write的进程一直写,那么负责write的进程就会被系统kill掉,因为继续写入也没有进程会来读,是没有意义的行为,所以直接kill掉,由于这是不正常退出,所以一定有信号参与。

下面用代码验证最后一种情况:

#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main()
{
    
    
    int fd[2] = {
    
     0,0 };
    if (pipe(fd) < 0)
    {
    
    
        perror("pipe!");
        return 1;
    }

    //fd[0]表示读端, fd[1]表示写端
    //子进程写,父进程读
    pid_t id = fork();
    if (id == 0)
    {
    
    
        //child
        close(fd[0]);//关闭读
        int count = 5;
        const char* msg = "hello father!\n";
        while (conut--)//共写5次
        {
    
    
            write(fd[1], msg, strlen(msg));
            sleep(1);
        }
        close(fd[1]);
        exit(0);
    }

    //father
    close(fd[1]);//关闭写
    char buffer[64];
    while (1)
    {
    
    
        ssize_t ret = read(fd[0], buffer, sizeof(buffer));
        if (ret > 0)//如果没有读到结尾,ret就是字符个数
        {
    
    
            buffer[ret] = '\0';//由于文件的读写没有'\0',所以需要手动添加
            printf("child send to father : %s\n", buffer);
        }
        else if (ret == 0)//读到结尾
        {
    
    
            printf("read file end!\n");
            break;
        }
        else if (ret < 0)
        {
    
    
            printf("read error!\n");
            break;
        }

        //读一次后关闭读端,然后break
        //也就是只读了一次
        close(fd[0]);
        break;
    }

    sleep(2);
    int status = 0;
    waitpid(id, &status, 0);
    printf("child quit, signal : %d\n", status & 0x7F);//打印信号值

    return 0;
}

在这里插入图片描述


(6)管道的大小

上面四种情况中的第一种情况说明管道是可能被写满的,那么管道具体是多大呢?

通过man 7 pipe可以查看到管道的大小在Linux下的要求。

在这里插入图片描述
将上面子进程内的代码如下改动,并在父进程的代码下将打印内容去掉。

if (id == 0)
{
    
    
    //child
    close(fd[0]);//关闭读
    int count = 0;
    char c = 'a';
    //const char* msg = "hello father!\n";
    while (1)
    {
    
    
        write(fd[1], &c, 1);//每次写入一个字节
        count++;//计数++
        printf("count : %d\n", count);//打印,当管道满后不再写入
    }
    close(fd[1]);
    exit(0);
}

count打印到65536后停止,说明这里我的Linux系统管道的大小是65536字节。
在这里插入图片描述


3.命名管道

匿名管道使用的限制就是只能在具有共同祖先的进程间通信。如果想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。

命名管道本质是一种特殊类型的文件。

(1)命令行使用命名管道

用mkfifo可以创建一个命名管道。

在这里插入图片描述
创建了命令管道之后,如下写一个每秒输出一次hello的脚本并重定向。
在这里插入图片描述

现象如下,上面的进程不断向fifo中写入(write)内容,而下面的进程不断通过cat将写入的内容读出并打印在显示器上。这样上下两个进程间就实现了通信。
在这里插入图片描述
当结束负责read的进程时还可以验证前面四种情况中的最后一种情况:负责write的进程立即停止。
在这里插入图片描述


如果觉得上面的脚本不容易读懂或是过程太复杂,下面再举一个简单的例子:
在这里插入图片描述


(2)代码使用命名管道

代码创建命名管道使用的仍是mkfifo,只不过这时它是个函数。
在这里插入图片描述

创建命名管道文件很简单,代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>

#define FILE_NAME "myfifo"

int main()
{
    
    
	umask(0);
	if (mkfifo(FILE_NAME, 0644) < 0)
	{
    
    
		perror("mkfifo");
		return 1;
	}

	return 0;
}

这样就在当前目录下创建了一个命名管道,可以用它来实现进程间通信。
在这里插入图片描述


下面实现简单的server和client之间的通信,重点只有创建命名管道的部分,剩下的对文件进行读写都是前面写过很多次的。

server.c的代码:

//server.c代码如下
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define FILE_NAME "myfifo"

int main()
{
    
    
	//命名管道已经创建
	//下面从文件中读取内容之前已经写过很多次了,不是重点
	int fd = open(FILE_NAME, O_RDONLY);
	if (fd < 0)//打开文件失败
	{
    
    
		perror("open");
		return 2;
	}

	char msg[128];
	while (1)
	{
    
    
		msg[0] = '\0';//清空字符串
		ssize_t s = read(fd, msg, sizeof(msg));
		if (s > 0)//读取成功
		{
    
    
			msg[s] = '\0';
			printf("client send : %s\n", msg);
		}
		else if (s == 0)
		{
    
    
			printf("client quit\n");
			break;
		}
		else
		{
    
    
			printf("read error\n");
			break;
		}
	}

	close(fd);
	return 0;
}
//server.c

client.c的代码:

//client.c的代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define FILE_NAME "myfifo"

int main()
{
    
    
	//重点是这里的创建命名管道
	if (mkfifo(FILE_NAME, 0644) < 0)//创建命名管道失败
	{
    
    
		perror("mkfifo");
		return 1;
	}

	int fd = open(FILE_NAME, O_WRONLY);
	if (fd < 0)
	{
    
    
		perror("open");
		return 1;
	}

	char msg[128];
	while (1)
	{
    
    
		msg[0] = 0;
		printf("please Enter:\n");
		ssize_t s = read(0, msg, sizeof(msg));//从键盘读入
		if (s > 0)
		{
    
    
			msg[s] = '\0';
			write(fd, msg, strlen(msg));//写入管道中
		}
	}

	close(fd);
	return 0;
}
//client.c

效果如下:

在这里插入图片描述


4.命令行中的管道|

命令行中连接两条命令(运行起来也是进程)的管道是匿名管道。

运行三条命令并用管道连接,查看它们的信息。
在这里插入图片描述
上面只能证明具备匿名管道的条件,但并不能证明就是匿名管道。但事实上确实是匿名管道,可以用这个例子来帮助记忆。


三、System V

1.介绍

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

共享内存和消息队列以传送数据为目的,而信号量是为了保证进程的同步与互斥而设计的,但也属于通信范畴。

共享内存本质就是修改页表的映射关系,在不同进程的虚拟地址空间中开辟空间,和同一块物理内存对应。完成开辟空间、建立映射、开辟虚拟空间、返回给用户信息等等一系列操作都有系统接口可用,是操作系统完成的。


2.基本原理

共享内存的基本原理如下:
在这里插入图片描述


共享内存建立过程:

  1. 申请共享内存(假设物理内存已经开辟好了)。
  2. 将共享内存挂接到进程地址空间(建立映射关系)。
  3. 去关联共享内存(修改页表,取消映射关系)。
  4. 释放共享内存,将内存归还给系统。

3.shmget函数

申请共享内存需要用到shmget函数:
在这里插入图片描述
这个函数的三个参数都多少有一些坑,下面一一说明。

①key

显然系统中可能会同时存在多个共享内存,为了管理这些共享内存,操作系统就需要维护与其相关的内核数据结构。其次,保证共享内存的唯一性,这就需要上面shmget函数中的第一个参数key传入一个系统中独一无二的值。

那么这个独一无二的key如何获得呢?需要再调用函数ftok。
在这里插入图片描述


②SIZE

第二个参数size是需要申请共享内存的大小(单位是字节)。具体有什么坑在后面写代码时说明。

在这里插入图片描述
PAGE_SIZE的大小是4096字节,对应一页数据。在操作系统层面,进行内存申请和释放(尤其是和外设进行IO时),一般是按页来访问的。

而申请共享内存时是按页对齐的,比如调用函数时申请4097个字节的空间,那么操作系统底层会分配出两页,即4096*2的空间(但查看时看到的仍是4097),这显然浪费了许多空间,所以在调用该函数时,申请的大小最好是页大小的整数倍。


③shmflg

该参数有下面两个常用的宏:

  • IPC_CREAT:如果共享内存存在直接返回共享内存;如果不存在则创建。问题是,只要传入该参数且调用成功,那么一定会返回一片共享内存,但无法确定是不是新创建的。
  • IPC_EXCL:单独使用无意义,常和IPC_CREAT一起使用。

这两个都是宏,而且也都是只有一位为1的宏,上面两个宏按位或后的语义是如果共享内存不存在则创建,若已存在则函数调用出错返回。

返回值叫做共享内存句柄,其实就是一个可以在用户层标识共享内存的值。


4.创建共享内存

在server.c中写入如下代码,先用ftok和shmget创建一段共享内存。

#include <sys/ipc.h>
#include <sys/types.h>
#include <sys/shm.h>
#include <stdio.h>

//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666

#define SIZE 4096//设置为4096

int main()
{
    
    
	key_t k = ftok(PATH_NAME, PROJ_ID);
	if (k < 0)
	{
    
    
		printf("ftok error!\n");
		return 1;
	}
	printf("%x\n", k);

	int shm = shmget(k, SIZE, IPC_CREAT | IPC_EXCL);
	if (shm < 0)
	{
    
    
		perror("shm");
		return 2;
	}

	return 0;
}

现象如下,成功创建了共享内存,且能够用ipcs命令查看到。
在这里插入图片描述

但是注意,上面ipcs命令是在程序运行结束后才使用的,也就是说进程已经结束了,但共享内存仍然存在。事实上,共享内存的生命周期是跟随内核的,也就是说除非进程主动删除或在命令行中用命令删除,否则共享内存就一直存在直到关机。

再进一步,System V包括的共享内存、消息队列、信号量的生命周期都是跟随内核的。所以这部分内存一定是操作系统提供并维护的。

上面的列表中key是在内核层面上,多个进程之前区分共享内存的方式,而shmid是在进程内部区分某个共享内存资源的方式。


5.删除共享内存

上面提到除非进程主动删除或在命令行中用命令删除,否则共享内存就一直存在直到关机,下面就说一下如何删除共享内存。

  • 在命令行中用ipcrm -m shmid即可删除对应的共享内存。
    在这里插入图片描述

  • 在代码中可以用shmctl来删除共享内存。
    在这里插入图片描述
    serve.c的代码如下,在共享内存创建成功后倒计时五秒后删除。

#include <sys/ipc.h>
#include <sys/types.h>
#include <sys/shm.h>
#include <stdio.h>
#include <unistd.h>

//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666

#define SIZE 4096//设置为4096

int main()
{
    
    
	key_t k = ftok(PATH_NAME, PROJ_ID);
	if (k < 0)
	{
    
    
		printf("ftok error!\n");
		return 1;
	}
	printf("%x\n", k);

	int shm = shmget(k, SIZE, IPC_CREAT | IPC_EXCL);
	if (shm < 0)
	{
    
    
		perror("shm");
		return 2;
	}

	printf("create success\nsleeping\n");
	
	int count = 5;
	while(count--)
	{
    
    
		printf("%d\n", count);
		sleep(1);
	}

	//	选项设置IPC_RMID
	shmctl(shm, IPC_RMID, NULL);
	printf("remove success\n");

	return 0;
}

从下面动图中可以看到进程成功创建并删除了共享内存。

在这里插入图片描述


6.关联、去关联共享内存

上面讲了创建和删除共享内存,下面介绍如何将申请的共享内存和进程关联起来,实现进程间通信。

在这里插入图片描述
shmaddr为NULL,操作系统会自动选择一个地址。
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)。
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存。

上面的选项具体使用时细节很多,这里只举最简单常用的例子,将shmaddr设置为NULL,shmflg设置为0。
在这里插入图片描述
返回值返回的是共享内存映射到进程地址空间的虚拟地址的起始地址。(请认真理解,返回值很重要,可类比malloc的返回值)。


7.用共享内存实现进程间通信

下面两部分代码将上面几个过程结合起来,并简单地在进程间用字符串通信。

server.c代码如下:

#include <sys/ipc.h>
#include <sys/types.h>
#include <sys/shm.h>
#include <stdio.h>
#include <unistd.h>

//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666

#define SIZE 4096//设置为4096

int main()
{
    
    
	key_t k = ftok(PATH_NAME, PROJ_ID);
	if (k < 0)
	{
    
    
		printf("ftok error!\n");
		return 1;
	}
	printf("%x\n", k);

	//创建共享内存									设置权限
	int shm = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0644);
	if (shm < 0)
	{
    
    
		perror("shm");
		return 2;
	}

	char* mem = shmat(shmid, NULL, 0);//关联共享内存

	//使用共享内存
	while (1)
	{
    
    
		printf("client send : %s\n", mem);
	}
	shmdt(mem);//去关联

	shmctl(shm, IPC_RMID, NULL);//删除共享内存

	return 0;
}

client.c的代码如下:

#include <sys/ipc.h>
#include <sys/types.h>
#include <sys/shm.h>
#include <stdio.h>
#include <unistd.h>

//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666

#define SIZE 4096//设置为4096

int main()
{
    
    
	key_t k = ftok(PATH_NAME, PROJ_ID);
	if (k < 0)
	{
    
    
		printf("ftok error!\n");
		return 1;
	}
	printf("%x\n", k);

	int shmid = shmget(k, SIZE, IPC | CREAT);
	if (shm < 0)
	{
    
    
		perror("shm");
		return 2;
	}

	char* mem = shmat(shmid, NULL, 0);//关联共享内存

	//使用共享内存
	int i = 0;
	while (i < 26)
	{
    
    
		mem[i] = 'A' + i;
		sleep(1);
		i++;
		mem[i] = '\0';
	}

	shmdt(mem);//去关联

	//client中不需要删除共享内存
	return 0;
}

在这里插入图片描述
注意上面向共享内存中读写时,并没有像使用管道时通过系统调用接口(read、write)实现,而是直接使用(像malloc申请的堆空间一样)。因此拷贝次数少、不提供同步与互斥,所以这也是进程间通信最快的方式。


感谢阅读,如有错误请批评指正

猜你喜欢

转载自blog.csdn.net/weixin_51983604/article/details/123306293
今日推荐