【Linux】进程间通信

文章目录

  • 1. 认识进程间通信
    • 1.1 进程间通信的概念
    • 1.2 进程间通信的目的
    • 1.3 进程间通信的本质
  • 2. 管道
    • 2.1 匿名管道
      • 2.1.1 匿名管道的原理
      • 2.1.2 pipe函数
      • 2.1.3 匿名管道的使用
      • 2.1.4 匿名管道的特点
      • 2.1.5 管道的四种特殊情况
    • 2.2 命名管道
      • 2.2.1 命名管道的原理
      • 2.2.2 命令行下创建命名管道
      • 2.2.3 在程序中创建命名管道
      • 2.2.4 命名管道的使用
  • 3. system V 进程间通信
    • 3.1 共享内存
      • 3.1.1 共享内存的概念
      • 3.1.2 共享内存的基本原理
      • 3.1.3 共享内存的创建
      • 3.1.4 共享内存的释放
      • 3.1.5 共享内存的关联
      • 3.1.6 共享内存的去关联
      • 3.1.7 共享内存的应用
      • 3.1.8 共享内存与管道的对比
    • 3.2 信号量
      • 3.2.1 信号量相关概念
      • 3.2.2 信号量数据结构
      • 3.2.3 信号量相关函数
      • 3.2.4 进程互斥概念

1. 认识进程间通信

1.1 进程间通信的概念

进程间通信就是在不同进程之间交换或者传输信息。
在认识进程间通信之前,我们须明白进程是具有独立性的,而进程间通信能让进程之间交流信息,也就是说进程间通信在一定程度了打破了进程的独立性,那么进程间通信的通信成本一定不低!

1.2 进程间通信的目的

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

1.3 进程间通信的本质

进程间通信的本质:让不同的进程看到同一份资源。

由于各个进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面即可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信一定要付出代价的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。
【Linux】进程间通信_第1张图片
因此,进程间通信的本质就是让不同进程看到同一份资源。由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信的方式。

2. 管道

什么是管道?

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

例如:我们统计云服务器上的登录用户个数。
在这里插入图片描述
其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到"管道"当中,wc进程再通过标准输入从"管道"当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。
【Linux】进程间通信_第2张图片

2.1 匿名管道

2.1.1 匿名管道的原理

匿名管道用于进程间通信,且仅限于本地父子进程之间通信

进程间通信的本质就是,让不同进程看到同一份资源,使用匿名管道实现父子进程间通信的原理:让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或者是读取操作,进而实现父子间通信。
【Linux】进程间通信_第3张图片
这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区中的数据并不会进行写时拷贝。管道虽然用的是文件的方案,但管道是内存级文件,操作系统不会把进程进行通信的数据刷新到磁盘当中,因为这样做既没有必要,也会大大降低效率。也就是说,这种文件是一批不会把数据刷新到磁盘当中的文件,换句话说,磁盘文件和内存文件并不是一一对应的,有些文件只在内存中存在,而不会再磁盘中存在。

2.1.2 pipe函数

【Linux】进程间通信_第4张图片
pipe函数用来创建一个管道,它的参数为输出型参数,pipefd[0]中会保存读端的fd,pipefd[1]会保存写端的fd。
在pipe函数调用成功时返回0,调用失败时返回-1。

2.1.3 匿名管道的使用

创建匿名管道,实现父子进程之间通信。这里实现的结果是父进程是读端,子进程是写端。

第一步:父进程调用pipe函数创建管道

【Linux】进程间通信_第5张图片

第二步:父进程创建子进程,子进程继承父进程文件描述符表

【Linux】进程间通信_第6张图片

第三步:父进程关闭写端,子进程关闭读端

【Linux】进程间通信_第7张图片
管道只能进行单向通信,因此父进程创建完子进程之后,需要确认父进程进程谁读谁写,然后关闭相应的读写端。这里虽然不关也不会报错,但是不关不敢保证后续会不会有误操作。

在下面的代码中,子进程每隔1秒写入一次数据,然后父进程读取。

#include 
#include 
#include 
#include 
#include 
#include 

// 父进程为读端,子进程为写端
int main()
{
    int fds[2] = { 0 }; 
    // 1. 创建匿名管道,使用fds数组保存fd
    if (pipe(fds) < 0)
    {
        // 如果返回值小于0,说明创建管道失败
        perror("pipe");
        return 1;
    }
    // 2. 创建子进程,子进程继承父进程的文件描述符表
    pid_t id = fork();
    if (id == 0)
    {
        close(fds[0]); // 关闭子进程的读端
        // 子进程写入数据
        const char* msg = "hello father, I am child...";
        int cnt = 10;
        while (cnt--)
        {
            write(fds[1], msg, strlen(msg));
            sleep(1);
        }
        // 子进程写入完毕之后,关闭文件描述符并退出
        close(fds[1]);
        exit(0);
    }

    // 父进程读取数据
    char buffer[64];
    while (1)
    {
        ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1);
        if (s > 0) // 读取到了数据
        {
            buffer[s] = '\0';
            printf("child send to father:%s\n", buffer);
        }
        else if (s == 0) // 没有读取到数据,退出循环
        {
            printf("read file end\n");
            break;
        }
        else // 出现错误,退出循环
        {
            printf("read error\n");
            break;
        }
    }

    // 父进程执行完毕,关闭读端并回收子进程
    close(fds[0]);
    waitpid(id, NULL, 0);
    return 0;
}

运行结果如下【Linux】进程间通信_第8张图片

2.1.4 匿名管道的特点

管道内部自带同步与互斥机制

我们将一次只允许一次进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
临界资源是需要被保护的,若是我们不对管道这种临界资源进行保护,那么可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写,交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:

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

实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个进程不能同时对管道进程操作,但这两个进程必须按照某种次序来对管道进行操作。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务直接则有明确的顺序关系。

管道的生命周期很短

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

管道提供的是流式服务

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

管道是半双工通信的

半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。

2.1.5 管道的四种特殊情况

在使用管道时,可能会出现下面四种特殊情况:

  1. 写端进程不写,读端进程一直读,当管道内没有数据可读时,对应的读端进程会被挂起,直到管道内有数据了,读端进程才被唤醒。
  2. 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
  3. 写端进程将数据写完之后将写端关闭,读端在读取完数据之后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
  4. 读端进程将读端关闭,写端进程还是一直向管道写入数据,那么操作系统会发送13号信号从而将写端进程杀死。

前两种情况就可以说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道内还有空间,若是条件不满足,对应的进程就会被挂起,直到条件满足之后才会被再次唤醒。
第三种情况,读端进程已经将管道内所有数据都读取了,而且此后也不会有写端进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。
第四种情况,既然管道当中的数据已经没有进程可以读取了,那么写端的写入也就没有意义了,因此操作系统会直接将写端进程杀掉。

2.2 命名管道

2.2.1 命名管道的原理

匿名管道只能用于具有父子关系之间的进程的通信,如果要实现两个毫不相关的进程之间的通信,可以使用命名管道。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
注意:

  1. 普通文件是很难做到通信的,即使做到也无法解决一下安全问题
  2. 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,这个映像的大小永远为0,因为命名管道和匿名管道都不会将数据刷新到磁盘当中。

2.2.2 命令行下创建命名管道

使用mkfifo命令可以在命令行中创建命名管道
在这里插入图片描述
可以看到,文件类型为p,代表该文件为命名管道文件。
使用这个命名管道文件,就能实现两个进程之间的通信了。

2.2.3 在程序中创建命名管道

使用mkfifo函数,可以在程序中创建命名管道
【Linux】进程间通信_第9张图片
该函数的第一个参数为文件路径,若给出具体的路径,那么在具体的路径下创建命名管道文件。若只给出了文件名,默认在当前路径下创建命名文件。
函数的第二个函数表示创建命名管道文件的默认权限,文件的最终权限和我们平时在命令行创建文件一样,都为mode & (~mask)。
当管道文件创建成功时,返回0,当创建失败时,返回-1。
【Linux】进程间通信_第10张图片
运行代码之后,命名管道文件就被创建了。
【Linux】进程间通信_第11张图片

2.2.4 命名管道的使用

用命名管道实现server和client通信

实现server和client之间的通信之前,我们需要先让服务器运行起来,然后创建一个命名管道文件,以读的方式打开命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。

在编写服务端和客户端代码之前,我们要先保证服务端和客户端使用同一个命名管道文件,这里我们就可以先让客户端和服务端包含同一个头文件,该头文件中提供了这个公用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了。

#include "comm.hpp"

int main()
{
    if (mkfifo(FILE_NAME, 0666) < 0)
    {
        perror("mkfifo");
        return 1;
    }
    int fd = open(FILE_NAME, O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        return 2;
    }
    char msg[128];
    while (1)
    {
        msg[0] = '\0'; // 在每次读取之前,清空msg
        ssize_t s = read(fd, msg, sizeof(msg) - 1);
        if (s > 0)
        {
            msg[s] = '\0';
            printf("client# %s\n", msg);
        }
        else if (s == 0)
        {
            printf("client quit!\n");
            break;
        }
        else
        {
            printf("read error!\n");
            break;
        }
    }
    close(fd);
    return 0;
}

客户端以写的方式打开管道文件,代码如下:

#include "comm.hpp"

int main()
{
    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# "); // 提示用户输出
        fflush(stdout); 
        ssize_t s = read(0, msg, sizeof(msg) - 1);
        if (s > 0)
        {
            msg[s - 1] = '\0';
            write(fd, msg, strlen(msg));
        }
    }
    close(fd);
    return 0;
}

编译之后,先让服务端跑起来,再让客服端跑起来,看看运行结果是怎样的。
在这里插入图片描述
这样客户端和服务端就实现通信了。
如果此时退出客户端,也就是说退出写端,那么服务端不再进行读取,而是会执行其他代码。如果此时退出服务端,也就是说退出读端,那么在写端下一次向管道中写入数据时,它会被操作系统杀死。

管道的通信是在内存中进行的

如果客户端向管道中写入数据,而服务端不读取数据,那么管道文件的大小仍然会为0,也就说双方进行的管道通信是在内存中进行的,与磁盘无关。

用命名管道实现派发计算任务

两个进程之间的通信,并不是简单的发送字符串而已,服务端会对客户端发送过来的信息进行某些处理。
这里我们以客户端向服务端派发计算任务为例,客户端通过管道向服务端发送双操作数的计算请求,服务端收到客户端的信息后计算出相应的结果。
这里无需修改客户端代码,修改服务端代码即可。

#include "comm.hpp"

int main()
{
    if (mkfifo(FILE_NAME, 0666) < 0)
    {
        perror("mkfifo");
        return 1;
    }
    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) - 1);
        if (s > 0)
        {
            msg[s]  = 0;
            printf("client# %s\n", msg);
            const char *lable = "+-*/%";
            char *p = msg;
            int flag = 0;
            while (*p)
            {
                switch (*p)
                {
                    case '+':
                        flag = 0;
                        break;
                    case '-':
                        flag = 1;
                        break;
                    case '*':
                        flag = 2;
                        break;
                    case '/':
                        flag = 3;
                        break;
                    case '%':
                        flag = 4;
                        break;
                    default:
                        break;
                }
                p++;
            }
            char* data1 = strtok(msg, "+-*/%");
            char* data2 = strtok(NULL, "+-*/%");
            int num1 = atoi(data1);
            int num2 = atoi(data2);
            int ret = 0;
            switch (flag)
            {
                case 0:
                    ret = num1 + num2;
                    break;
                case 1:
                    ret = num1 - num2;
                    break;
                case 2:
                    ret = num1 * num2;
                    break;
                case 3:
                    ret = num1 / num2;
                    break;
                case 4:
                    ret = num1 % num2;
                    break;
            }
            printf("%d %c %d = %d\n", num1, lable[flag], num2, ret);
        }
        if (s == 0)
        {
            printf("client quit\n");
            break;
        }
        if (s < 0)
        {
            printf("read error");
            break;
        }
    }

    return 0;
}

运行效果如下:
【Linux】进程间通信_第12张图片

命名管道实现进程控制

我们可以通过一个进程来控制另外一个进程的行为,比如我们从客户端输出命令到管道当中,再让服务端将管道当中的命令读取出来并执行。
下面我们只实现了让服务端执行不带选项的命令,若是想让服务端执行带选项的命令,可以对管道当中获取命令进行解析处理。这里只需让服务端从管道当中获取命令后创建子进程,然后再对进程进行程序替换即可。

#include "comm.hpp"

int main()
{
    if (mkfifo(FILE_NAME, 0666) < 0)
    {
        perror("mkfifo");
        return 1;
    }
    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) - 1);
        if (s > 0)
        {
            msg[s] = 0;
            printf("Client# %s\n", msg);
            pid_t id = fork();
            if (id == 0)
            {
                execlp(msg, msg, NULL);
                exit(1);
            }
            waitpid(id, NULL, 0);
        }
        else if (s == 0)
        {
            printf("client quit\n");
            break;
        }
        else
        {
            printf("read fail\n");
            break;
        }
    }
    close(fd);
    return 0;
}

运行效果如下:
【Linux】进程间通信_第13张图片

用命名管道实现文件拷贝

需要拷贝的文件是file.txt,内容如下。

【Linux】进程间通信_第14张图片
我们要做的就是,让客户端将file.txt文件的内容通过管道发送给服务器,在服务器端创建一个file-bat.txt文件,并将从管道获取的数据写入到file-bat.txt文件中,这样便实现了file.txt文件的拷贝。
【Linux】进程间通信_第15张图片
其中服务端要做的就是,创建命令管道并以读的方式打开该命名管道,再创建一个名为file-bat.txt的文件,只有再将管道中读取的数据写入到file-bat.txt即可。
服务端代码如下:

//server.c
#include "comm.h"

int main()
{
	if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
		perror("mkfifo");
		return 1;
	}
	int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 2;
	}
	//创建文件file-bat.txt,并以写的方式打开该文件
	int fdout = open("file-bat.txt", O_CREAT | O_WRONLY, 0666);
	if (fdout < 0){
		perror("open");
		return 3;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; 
		ssize_t s = read(fd, msg, sizeof(msg)-1);
		if (s > 0){
			write(fdout, msg, s); 
		}
		else if (s == 0){
			printf("client quit!\n");
			break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd); 
	close(fdout); 
	return 0;
}

客户端代码如下:

//client.c
#include "comm.h"

int main()
{
	int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 1;
	}
	int fdin = open("file.txt", O_RDONLY); //以读的方式打开file.txt文件
	if (fdin < 0){
		perror("open");
		return 2;
	}
	char msg[128];
	while (1){
		//从file.txt文件当中读取数据
		ssize_t s = read(fdin, msg, sizeof(msg));
		if (s > 0){
			write(fd, msg, s); //将读取到的数据写入到命名管道当中
		}
		else if (s == 0){
			printf("read end of file!\n");
			 break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd); 
	close(fdin); 
	return 0;
}

【Linux】进程间通信_第16张图片
查看文件内容
在这里插入图片描述
这样就实现了文件的拷贝了。
其实,将文件从客户端拷贝到服务端的过程,我们可以称之为上传。而从服务端拷贝到客户端的过程,我们可以称之为下载!

注意:我们在命令行中打开的管道为匿名管道!

3. system V 进程间通信

3.1 共享内存

3.1.1 共享内存的概念

共享内存是一种在操作系统中用于实现进程间共享数据的机制。它允许多个进程访问同一块物理内存,从而实现高效的数据共享和通信。
在共享内存中,多个进程可以将同一段物理内存映射到它们各自的虚拟地址空间中,使得它们可以直接读写这块内存,而无需通过复制数据或进行进程间通信的开销。

3.1.2 共享内存的基本原理

共享内存让不同进程看到同一份资源的方式就是,在物理内存中申请一块内存空间,然后将这块内存空间分别与各个进程的页表之间建立映射,再在虚拟地址空间中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。

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

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_perm,它是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量中,其中ipc_perm结构体定义如下:

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.1.3 共享内存的创建

共享内存的创建大致分为以下两个步骤:

  1. 在物理内存当中申请共享内存空间
  2. 将申请到的内存挂接到地址空间,即建立映射关系

创建共享内存需要用到shmget函数

【Linux】进程间通信_第17张图片
第一个参数key,表示创建共享内存在系统中的唯一标识。
第二个参数size,表示创建共享内存的大小。
第三个参数shmflg,表示创建共享内存的方式。
返回值:若shmget调用成功,返回一个在用户层面的共享内存标识符;若shmget调用失败,返回-1。

关于传入shmget函数的第三个参数shmflg,常用的参数有IPC_CREATIPC_EXCL
当使用IPC_CREAT时,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
IPC_EXCL必须配合IPC_CREAT一起使用,这样的话共享内存一定是新建的共享内存。

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

在使用shmget函数时我们需要传入key值,key值可以从ftok函数获取。
【Linux】进程间通信_第18张图片
ftok函数的作用就是,将一个已经存在的路径名pathname和一个整数标识符proj_id转换为一个key值,又称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可取。

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

下面我们尝试用代码获取key值和用户级的句柄

#include 
#include 
#include 
#include 
#include 

#define PATHNAME "/home/ljk/shm/test.c"
#define PROJ_ID 0x6666
#define SIZE 4096

int main()
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    if (key < 0)
    {
        perror("ftok");
        return 1;
    }
    int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL);
    if (shm < 0)
    {
        perror("shmget");
        return 2;
    }
    printf("key: %x\n", key);
    printf("shm: %d\n", shm);
    return 0;
}

运行结果如下
在这里插入图片描述
在Linux中,我们可以使用ipcs命令查看有关进程间通信的信息
【Linux】进程间通信_第19张图片
单独使用ipcs选项时,会默认列出消息队列,共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带一下选项:

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

【Linux】进程间通信_第20张图片
下面解释上这个表中生僻的一些信息:

  • perims:表示共享内存的权限
  • nattch:表示关联共享内存的进程数
  • status:表示共享内存的状态

key是在内核层面上保证共享数据唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid直接的关系类似于fd和FILE*之间的关系。

3.1.4 共享内存的释放

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

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

使用ipcrm -m shmid释放指定shmid的共享内存资源

【Linux】进程间通信_第21张图片
删除时用的是用户层的id,即shmid

使用程序释放共享内存资源

shmctl函数可以用来控制共享内存,原型如下:
【Linux】进程间通信_第22张图片
第一个参数,表示用户级内存标识符
第二个参数,表示具体控制冬至
第三个参数,用户获取或设置所控制共享内存的数据结构
当调用成功时,返回0;调用失败时,返回-1。

第二个参数传入的选项有以下三个:

  • IPC_CREAT:获取共享内存的当前关联值,此时buf为输出型参数
  • IPC_SET:将共享内存的当前关联值设为buf所指的数据结构内的值
  • IPC_RMID:删除共享内存段

下面的一段代码,就是创建共享内存,然后5秒后删除共享内存。

#include 
#include 
#include 
#include 
#include 

#define PATHNAME "/home/ljk/shm/test.c"
#define PROJ_ID 0x6666
#define SIZE 4096

int main()
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    if (key < 0)
    {
        perror("ftok");
        return 1;
    }
    int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL);
    if (shm < 0)
    {
        perror("shmget");
        return 2;
    }
    printf("key: %x\n", key);
    printf("shm: %d\n", shm);

    sleep(5);
    shmctl(shm, IPC_RMID, NULL);
    return 0;
}

运行结果如下:
【Linux】进程间通信_第23张图片

3.1.5 共享内存的关联

将共享内存连接到进程地址空间我们需要用到shmat函数【Linux】进程间通信_第24张图片
第一个参数,为用户级的标识符
第二个参数,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
第三个参数,表示关联共享内存的某些属性。

在调用成功时,返回共享内存映射到进程地址空间中的起始地址。调用失败时,返回(void*)-1。

第三个参数shmflg常用的选项有以下三个:

  • SHM_RDONLY:关联贡献内存之后只进行读取操作
  • SHM_RND:若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍
  • 0:默认为读写权限

下面一段代码使用shmat对共享内存进行关联
在进行关联之前,我们在创建共享内存的时候要给共享内存一个权限,否则会出现权限不足的情况。
【Linux】进程间通信_第25张图片
整体代码如下:

#include 
#include 
#include 
#include 
#include 

#define PATHNAME "/home/ljk/shm/test.c"
#define PROJ_ID 0x6666
#define SIZE 4096

int main()
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    if (key < 0)
    {
        perror("ftok");
        return 1;
    }
    // 创建默认权限为0666的共享内存
    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666);
    if (shmid < 0)
    {
        perror("shmget");
        return 2;
    }
    printf("key: %x\n", key);
    printf("shmid: %d\n", shmid);

    printf("attach begin!\n");
    sleep(5);
    char* mem = shmat(shmid, NULL, 0);
    if (mem == (void*)-1)
    {
        perror("shmat");
        return 3;
    }
    printf("attatch end!\n");
    sleep(5);
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}

3.1.6 共享内存的去关联

取消共享内存与进程地址空间之间的练习我们需要用到shmdt函数
在这里插入图片描述
直接传入关联贡献内存的起始地址即可。
shmdt调用成功返回0,调用失败返回-1。
代码演示如下

#include 
#include 
#include 
#include 
#include 

#define PATHNAME "/home/ljk/shm/test.c"
#define PROJ_ID 0x6666
#define SIZE 4096

int main()
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    if (key < 0)
    {
        perror("ftok");
        return 1;
    }
    // 创建默认权限为0666的共享内存
    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666);
    if (shmid < 0)
    {
        perror("shmget");
        return 2;
    }
    printf("key: %x\n", key);
    printf("shmid: %d\n", shmid);

    printf("attach begin!\n");
    sleep(5);
    char* mem = shmat(shmid, NULL, 0);
    if (mem == (void*)-1)
    {
        perror("shmat");
        return 3;
    }
    printf("attatch end!\n");
    sleep(5);
    shmdt(mem); // 共享内存的去关联
    sleep(5);
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}

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

3.1.7 共享内存的应用

下面我们用共享内存实现server与client之间的通信
首先我们为了保证server和client关联的是同一块共享内存,我们需要先写一个头文件,然后让server和client去包含它。
【Linux】进程间通信_第26张图片
服务端代码如下:

#include "comm.h"

int main()
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    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: %x\n", key);
    printf("shmid: %d\n", shmid);

    char *mem = shmat(shmid, NULL, 0);

    while (1)
    {
        printf("client# %s\n", mem);
        sleep(1);
    }
    shmdt(mem);

    return 0;
}

客户端代码如下:

#include "comm.h"

int main()
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    if (key < 0)
    {
        perror("ftok");
        return 1;
    }
    // 获取server创建的共享内存的id
    int shmid = shmget(key, SIZE, IPC_CREAT);
    if (shmid < 0)
    {
        perror("shmget");
        return 2;
    }
    printf("key: %x\n", key);
    printf("shmid: %d\n", shmid);
    char *mem = shmat(shmid, NULL, 0);
    int i = 0;
    while (1)
    {
        mem[i] = 'A' + i;
        i++;
        mem[i] = '\0';
        sleep(1);
    }
    shmdt(mem);
    return 0;
}

运行效果如下:
【Linux】进程间通信_第27张图片
这也就可以看出来服务器和客户端正常通信了。

3.1.8 共享内存与管道的对比

管道创建好之后,进程通过管道通信的话,还需要read,write等函数接口。但是共享内存创建好之后就不需要再调用系统接口进行通信了,共享内存是所有进程间通信方式中最快的一种。
如果客户端和服务端用管道和共享内存交流同样的数据,那么它们在方式和效率上有什么差别呢?

管道

【Linux】进程间通信_第28张图片
从图中可以看出,使用管道通信,将数据从一个进程传输到另一个进程需要四次操作

  1. 客户端将数据从输入文件拷贝到客户端缓冲区中
  2. 客户端将数据从缓冲区拷贝到管道中
  3. 服务端将将数据从管道拷贝到服务端缓冲区中
  4. 服务端将数据重缓冲区拷贝到输出文件中

共享内存

【Linux】进程间通信_第29张图片
从图中可以看出,使用共享内存,将数据从一个进程传到另一个进程需要两次操作

  1. 将数据从输入文件拷贝到共享内存
  2. 将数据从共享内存拷贝到输出文件

相比管道通信少了两次拷贝的操作。
共享内存是所有进程通信中最快的一种通信方式。但是它也是有缺点的,管道通信是自带同步与互斥机制的,但是共享内存没有提供任何的保护机制。

3.2 信号量

3.2.1 信号量相关概念

  • 由于进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系叫做进程互斥
  • 系统中某些资源一次只允许一个进程使用,这样的资源叫做临界资源或者互斥资源
  • 在进程中设计到临界资源的程序段叫做临界区
  • IPC资源必须删除,否则不会自动删除,因为system V IPC的声明周期随内核

3.2.2 信号量数据结构

系统中为信号量维护了相关的数据结构

struct semid_ds {
	struct ipc_perm sem_perm;       /* permissions .. see ipc.h */
	__kernel_time_t sem_otime;      /* last semop time */
	__kernel_time_t sem_ctime;      /* last change time */
	struct sem  *sem_base;      /* ptr to first semaphore in array */
	struct sem_queue *sem_pending;      /* pending operations to be processed */
	struct sem_queue **sem_pending_last;    /* last pending operation */
	struct sem_undo *undo;          /* undo requests on this array */
	unsigned short  sem_nsems;      /* no. of semaphores in array */
};

信号量数据结构的第一个成员也是ipc_perm的结构体变量,ipc_perm结构体的定义如下:

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.2.3 信号量相关函数

信号量集的创建

创建信号量集我们需要用到semget函数
【Linux】进程间通信_第30张图片

  • 创建信号量集也需要使用ftok函数生成一个key值
  • semget函数的第二个参数,表示信号量的个数
  • semget函数的第三个参数,与创建共享内存使用的shmget函数的第三个参数相同
  • 信号量集创建成功时,semget函数返回一个用户级别的标识符

信号量集的删除

【Linux】进程间通信_第31张图片

信号量集的操作

【Linux】进程间通信_第32张图片

3.2.4 进程互斥概念

进程间通信通过共享资源来实现,这虽然解决了通信的问题,但是也引入了新的问题,那就是通信进程间公用的临界资源,若是不对临界资源进行保护,就可能产生各个进程从临界资源获取数据不一致的问题。
保护临界资源的本质是保护临界区,我们把进程代码中访问临界资源的代码称为临界区,信号量就是用来保护临界区的。
比如当前有一块大小为100字节的资源,我们若是25字节为一份,那么该资源可以分为4份,那么此时这块资源可以由4个信号量进行标识。
【Linux】进程间通信_第33张图片
信号量本质就是计数器,在二元信号量中,信号量的个数为1(相当于临界资源看出一整块),二元信号量本质解决了临界资源的互斥问题。
【Linux】进程间通信_第34张图片
根据以上代码,当进程A申请访问共享内存资源时,如果此时sem为1,则进程A申请资源成功,此时将sem减一,然后进程A就可以对共享内存进行一系列操作,但是在进程A访问共享内存时,若是进程B申请访问该共享内存资源,此时sem就为0了,那么此时进程B会被挂起,知道进程A访问共享内存结束后将sem加一,此时才会将进程B唤起,然后进程B再对该共享内存进行访问操作。
在这种情况下,无论什么时候都只会有一个进程在对同一份资源进行访问操作,也就解决了临界资源的互斥问题。
实际上,代码中计数器sem减一的操作叫做P操作,而计数器sem加一的操作叫做V操作,P操作就是申请信号量,而V操作就是释放信号量。

你可能感兴趣的:(Linux,linux)