Linux 进程通信深剖

22 篇文章 11 订阅

传统艺能😎

小编是双非本科大二菜鸟不赘述,欢迎米娜桑来指点江山哦
在这里插入图片描述
1319365055

🎉🎉非科班转码社区诚邀您入驻🎉🎉
小伙伴们,满怀希望,所向披靡,打码一路向北
一个人的单打独斗不如一群人的砥砺前行
这是和梦想合伙人组建的社区,诚邀各位有志之士的加入!!
社区用户好文均加精(“标兵”文章字数2000+加精,“达人”文章字数1500+加精)
直达: 社区链接点我


在这里插入图片描述

进程间通信🤔

进程间通信即 IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息,请务必牢记进程间通信的本质 让 不 同 的 进 程 看 到 同 一 份 资 源 \color{red} {让不同的进程看到同一份资源}

进程间通信的目的4 个:

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

而进程间通信实际上实现是比较困难的,因为进程具有独立性,而且代码逻辑上可以公有和私有,比如父子进程,既然自己没办法做到那就借助外力:第三方资源

我们此时就需要一个第三方资源来提供一段公有区域来 “交流” 两方进程,并且支持读写操作。
在这里插入图片描述

通信方式🤔

管 道 : \color{red} {管道:}

匿名管道
命名管道

S y s t e m V I P C : \color{red} {System V IPC:} SystemVIPC

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

P O S I X I P C : \color{red} {POSIX IPC:} POSIXIPC

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

管道🤔

管道是 Unix 中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个 “管道”。比如统计我们当前使用云服务器上的登录用户个数:

who | wc -l

who 用于查看当前服务器的登录用户(一行一个小朋友),wc -l 用于统计当前的行数
在这里插入图片描述
这里 | 就是一个管道标志,who 和 wc 是两个命令,who 将数据通过标准输出输出到管道中,wc 再通过标准输入流读取数据:

在这里插入图片描述

匿名管道🤔

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

记得我们进程通信的本质是让不同进程看到同一份资源,于是匿名管道的原理就是先让父子进程看到一个已经打开的文件资源,然后就可以进行读写操作完成通信。
在这里插入图片描述
这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝

管道虽然用的是文件的方案,但操作系统一定不会把通信的数据刷新到磁盘中,因为这样存在 IO 参与会降低效率,而且也没必要。这种文件是不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在

pipe🤔

pipe 函数是用于创建匿名管道:

int pipe(int pipefd[2]);

pipe 调用成功时返回 0,调用失败时返回 -1。pipe 的参数是一个输出型参数,数组 pipefd 用于返回两个指向管道读端和写端的文件描述符:
在这里插入图片描述

匿名管道使用🤔

创建匿名管道实现父子进程间通信时需要pipe函数和fork函数搭配使用

1、父进程调用pipe函数创建管道:
在这里插入图片描述
2、父进程创建子进程:
在这里插入图片描述
3、父进程关闭写端,子进程关闭读端:
在这里插入图片描述

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

站在文件描述符角度再来看看这三个步骤

1、父进程调用pipe函数创建管道:
在这里插入图片描述
2、父进程创建子进程:
在这里插入图片描述
3、父进程关闭写端,子进程关闭读端:
在这里插入图片描述
比如我们向子进程向匿名管道当中写入10行数据,父进程从匿名管道当中将数据读出:

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.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]); 
		//子进程向管道写入数据
		const char* msg = "hello father, I am child...";
		int count = 10;
		while (count--){
			write(fd[1], msg, strlen(msg));
			sleep(1);
		}
		close(fd[1]); //子进程写入完毕
		exit(0);
	}
	//父进程关闭写端
	close(fd[1]); 
	//父进程从管道读取数据
	char buff[64];
	while (1){
		ssize_t s = read(fd[0], buff, sizeof(buff));
		if (s > 0){
			buff[s] = '\0';
			printf("child send to father:%s\n", buff);
		}
		else if (s == 0){
			printf("read file end\n");//父进程读取完毕
			break;
		}
		else{
			printf("read error\n");
			break;
		}
	}
	close(fd[0]); 
	waitpid(id, NULL, 0);
	return 0;
}

效果如下:
在这里插入图片描述

读写规则🤔

pipe2 函数与 pipe 函数类似,也是用于创建匿名管道,其函数原型如下:

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

flags 参数用于设置选项:

当管道空的时候

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

当管道满的时候:

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

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

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

管道特点🤔

同步与互斥😋

首先管道内部自带同步与互斥机制,管道一次只允许一个进程使用的资源,称为临界资源。在同一时刻也只允许一个进程对其进行读写操作,因此管道也就是一种临界资源

临界资源是需要被保护的,若不对管道进行保护,就可能出现同一时刻多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写或读取到数据不一样等问题,为了避免这些问题,内核会对管道操作进行同步与互斥

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

本质上同步是更为复杂的互斥,而互斥是一种特殊的同步。互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系

管道生命周期😋

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

流式服务😋

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

流式服务: 数据没有明确的分割,不分一定的报文段
数据报服务: 数据有明确的分割,拿数据按报文段拿

半双工通信😋

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

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

在这里插入图片描述

4种特殊情况🤔

  1. 写端进程不写,读端进程一直读,此时会因为没有数据可读,读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒

  2. 读端进程不读,写端进程一直写,写端进程写满后会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒

  3. 写端进程数据写完后将写端关闭,那么读端将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起

  4. 读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会主动将写端进程杀掉

前两种情况就能够很好的说明管道是自带同步与互斥机制的。我们再来看看四种情况子进程退出时对应收到的不同的信号:

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.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]); //子进程关闭读端
		const char* msg = "hello father, I am child...";
		int count = 10;
		while (count--){
			write(fd[1], msg, strlen(msg));
			sleep(1);
		}
		close(fd[1]); //子进程写入完毕
		exit(0);
	}
	//father
	close(fd[1]); //父进程关闭写端
	close(fd[0]); //父进程直接关闭读端(子进程被操作系统杀掉)
	int status = 0;
	waitpid(id, &status, 0);
	printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号(低7位)
	return 0;
}

子进程退出时收到的是13号信号:

在这里插入图片描述

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

kill -l

在这里插入图片描述
此时将子进程终止的的就是SIGPIPE信号

管道大小🤔

管道的容量是有限的,所以管道的最大容量是多少呢?此时我就可以使用 man 命令进行查看:

在这里插入图片描述
根据内容在 2.6.11 之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11 往后,管道的最大容量是 65536 字节。所以我们 uname -r 查看当前 Linux 版本

uname -r

在这里插入图片描述
可以看出当前是Linux 2.6.11之后的版本,所以最大容量是 65536 字节

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

在这里插入图片描述
所以管道的最大容量是 512 × 8 = 4096 字节

命名管道🤔

匿名管道只能用于具有共同祖先的进程,即一个管道由一个进程创建,然后该进程调用 fork,此后父子进程之间就可应用该管道。

如果要实现两个毫不相关进程通信,就要使用命名管道。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了

注意:

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

命名管道创建😋

使用mkfifo命令创建一个命名管道:

mkfifo fifo

在这里插入图片描述
这里创建出来的文件类型为 p,代表该文件是命名管道文件类型。

我们在进程 A 中每秒向命名管道写入一个字符串,在进程 B 中用cat命令从命名管道当中进行读取。现象是当进程 A 启动后,进程 B 会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据通信
在这里插入图片描述
这里可以很好的验证当管道的读端进程退出后,写端进程会被操作系统杀掉:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器 bash 执行的,所以此时 bash 就会被操作系统杀掉,服务器也就退出了

在这里插入图片描述

mkfifo 函数的函数原型如下:

int mkfifo(const char *pathname, mode_t mode);

p a t h n a m e : \color{red} {pathname:} pathname:

pathname 表示要创建的命名管道文件
pathname 以路径的方式给出,则将命名管道文件创建在该路径下
pathname 以文件名的方式给出,则将命名管道文件默认创建在当前路径下(注意当前路径的含义)

m o d e : \color{red} {mode :} mode:

mode 表示创建命名管道文件的默认权限
比如将 mode 设置为 0666,则命名管道文件创建出来的权限应该是 prw -rw -rw-,但不要忘了考虑 umask 掩码,实际权限为:mode&(~umask)。umask的默认值一般为 0002,所以实际权限为 0664,即 prw -rw -r- -

umask(0); //文件默认掩码置0

函数命名管道创建成功,返回 0;命名管道创建失败,返回 -1。现在就可以在当前路径下创建一个 myfifo 的命名管道:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#define FILE_NAME "myfifo"
int main()
{
	umask(0);
	if (mkfifo(FILE_NAME, 0666) < 0)//mkfifo创建命名管道文件
	{ 
		perror("mkfifo");
		return 1;
	}
	return 0;
}

在这里插入图片描述

打开规则🤔

  1. 打开 FIFO 进行读操作

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

  1. 打开 FIFO 进行写操作

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

serve&client 通信🤔

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

服 务 端 : \color{red} {服务端:}

#include "head.h"
int main()
{
	umask(0); //将文件默认掩码设置为0
	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;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		ssize_t s = read(fd, msg, sizeof(msg)-1);//从命名管道当中读取信息
		if (s > 0){
			msg[s] = '\0'; //手动设置'\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;
}

客户端只需以写的方式打开该命名管道文件,之后将通信信息写入到命名管道文件当中即可

客 户 端 : \color{red} {客户端:}

#include "head.h"
int main()
{
	int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 1;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		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;
}

我们让客户端和服务端包含同一个头文件,该头文件当中提供这个共用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了

头 文 件 : \color{red} {头文件:}

#pragma once
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.h>
#define FILE_NAME "myfifo" //包含共用的管道文件

代码完成后先将服务端进程运行起来,之后我们就能在客户端看到这个已经被创建的命名管道文件:

在这里插入图片描述
接着再运行客户端,此时我们从客户端写入的信息被客户端写入命名管道中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,说明命名管道通信成功!
在这里插入图片描述

退出关系😋

可以看到我们正常退出会有一个提示:

在这里插入图片描述
但是服务端中断退出后,客户端写入数据就不会被读取,也没有意义了,那么客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了

通信进行🤔

通信是在内存当中进行的,那如果我们只让客户端写入数据,而服务端不读取数据,那么这个管道文件大小会不会发生变化呢?

答案是 不 会 \color{red} {不会} ,尽管不读取数据,但数据并没有被刷新到磁盘,此时命名管道文件的大小依旧为 0,也就说明了双方进程之间的通信和匿名管道是一样的,都在内存当中进行

派发计算任务🤔

两个进程之间的通信,并不是简单的发送字符串而已,服务端是会对客户端发送过来的信息进行某些处理的。

以客户端向服务端派发计算任务为例,服务端会在接收到客户端的信息后计算出相应结果。这里我们无需更改客户端的代码,只需改变服务端处理通信信息的逻辑即可

服 务 端 : \color{red} {服务端:}

#include "comm.h"

int main()
{
	umask(0); //将文件默认掩码设置为0
	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;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		ssize_t s = read(fd, msg, sizeof(msg)-1);
		if (s > 0){
			msg[s] = '\0'; //手动设置'\0',便于输出
			printf("client# %s\n", msg);
			//服务端进行计算任务
		    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;
				}
				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); 
		}
		else if (s == 0){
			printf("client quit!\n");
			break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd);
	return 0;
}

效果如下:

在这里插入图片描述

进程遥控🤔

我们还可以通过一个进程来控制另一个进程的行为,比如我们从客户端输入命令到管道当中,再让服务端将管道当中的命令读取出来并执行

以不带选项的命令为例,如果想让服务端执行带选项的命令,只需让服务端从管道当中读取命令后创建子进程,然后再进行进程程序替换即可

服 务 端 : \color{red} {服务端:}

#include "head.h"

int main()
{
	umask(0); 
	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;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		ssize_t s = read(fd, msg, sizeof(msg)-1);
		if (s > 0){
			msg[s] = '\0'; //手动设置'\0',便于输出
			printf("client# %s\n", msg);
			if (fork() == 0){
				//child
				execlp(msg, msg, NULL); //程序替换
				exit(1);
			}
			waitpid(-1, NULL, 0); //等待子进程
		}
		else if (s == 0){
			printf("client quit!\n");
			break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd);
	return 0;
}

此时服务端接收到客户端的信息后,便进行进程程序替换,进而执行客户端发送过来的命令,效果如下:
在这里插入图片描述

管道实现文件拷贝🤔

首先我们创建一个 file.txt 的文件,内容如下:

在这里插入图片描述

我们要做的是让客户端将 file.txt 文件通过管道发给服务端,服务端就会创建一个 file-bat.txt 文件,并将从管道获取到的数据写入file-bat.txt文件当中,这就实现了file.txt的拷贝。服务端要做的就是创建命名管道并以读的方式打开该命名管道,再创建一个名为file-bat.txt的文件,之后需要做的就是将读取到的数据写入到file-bat.txt当中即可

在这里插入图片描述

服 务 端 : \color{red} {服务端:}

#include "head.h"

int main()
{
	umask(0); 
	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'; //每次读之前将msg清空
		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); //关闭file-bat.txt文件
	return 0;
}

而客户端要做的就是以写的方式打开这个已经存在的命名管道文件,再以读的方式打开file.txt文件之后读取出来并写入管道当中:

客 户 端 : \color{red} {客户端:}

#include "head.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); //数据读取完毕,关闭file.txt文件
	return 0;
}

头文件不变就可以了,完成代码后先运行服务端,再运行客户端,很快啊这俩都相继扑街了,此时使用ll命令就可以看到 file.txt 文件完成了拷贝。

在这里插入图片描述
我们 cat 打印拷贝文件file-bat.txt文件内容,发现和file.txt内容相同,拷贝成功!

在这里插入图片描述

文件拷贝的意义😋

我们现在是在本地视角实现的,所以并没有什么卵用,但是我们不妨将客户端想象成 Windows Xshell ,服务端想象成 centos 服务器,那此时就是实现了文件上传,反过来就是文件下载

在这里插入图片描述

命名管道和匿名管道区别😋

匿名管道由 pipe 函数创建并打开;命名管道由 mkfifo 函数创建,由 open 函数打开

所以 FIFO(命名管道)与 pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,除开这点它们就具有相同的语义

命令行中的管道🤔

现在就以 file.txt 为例:
在这里插入图片描述
我用管道 “|” + cat + grep 命令,实现一个关键词查找

cat file.txt | grep 结婚

在这里插入图片描述
ok ,那么问题来了,这里的管道 | 是匿名管道还是命名管道呢?

前文说过匿名管道只能连接有亲缘关系的进程,那我们测测亲缘关系就行了。首先我们用管道线连接 3 个简单的进程:

在这里插入图片描述
再用 ps查看 3 个进程信息:

在这里插入图片描述
你会发现他们PPID是一样的,说明他们的父进程是同一个,而实际上它们父进程就是命令行解释器 bash

在这里插入图片描述
也就是说管道连接的各个进程是有亲缘关系的,它们互为兄弟进程因此命令行的管道实际上是匿名管道

在这里插入图片描述

system V🤔

system V IPC 是操作系统特地设计的一种通信方,他和管道的本质是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源

通信方式😋

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

共享内存和消息队列主要用于输送数据,信号量是为了保证进程间的同步与互斥的,虽然 sysytem V 信号量和通信没有什么直接关系,但是他仍然属于通信范畴。所以共享内存和消息队列其实就是想手机一,用于沟通信息,信息量就类似于打比赛是的计时器,他可以维护两个进程的同步和互斥。

system V共享内存😋

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

在这里插入图片描述

这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成

共享内存数据结构😋

在系统中可能有大量正在通信的进程,因此系统中可能存在大量的进程,那么操作系统必须对他进行管理,所以共享内存除了在内存真正开辟的空间外,系统一定还会建立用于维护的数据结构:

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结构体的定义如下:

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

共享内存的数据结构shmid_dsipc_perm结构体分别在**/usr/include/linux/shm.h/usr/include/linux/ipc.h**中定义

初始化共享内存😋

共享内存的初始化大致包括两个过程:

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

所以共享内存的释放大致包括两个过程:

  1. 将共享内存与地址空间去关联,即取消映射关系
  2. 释放共享内存空间,即将物理内存归还给系统

建立共享内存😋

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

int shmget(key_t key, size_t size, int shmflg);

key 表示待创建共享内存在系统当中的唯一标识
size 表示待创建共享内存的大小
shmflg 表示创建共享内存的方式

shmget 调用成功返回一个有效的共享内存标识符,即用户层标识符;调用失败则返回 -1。

我们将具有标定某种资源的能力的东西叫做 句 柄 \color{red} {句柄} ,shmget 的返回值其实本质上就是共享内存的句柄,这个句柄可以在用户层进行标识共享内存,当共享内存创建后,在后续使用共享内存的接口时都需要通过这个句柄才可以对共享内存的资源进行操作

首先需要 ftok 函数来获取 shmget 的第一个参数:

key_t ftok(const char *pathname, int proj_id);

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

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

shmflg 常用的组合方式有两种:
在这里插入图片描述
使用组合 IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
使用组合 IPC_CREAT | IPC_EXCL,只有共享内存是新创建时,且共享内存创建成功时才会得到共享内存的句柄

现在就可以利用 shmget 和 ftok 来创建一个共享内存,创建后打印出共享内存的 key 值和句柄以便观察,代码大致如下:

#include <stdio.h>
#include <sys/types.h> 
#include <sys/ipc.h> 
#include <sys/shm.h> 
#include <unistd.h>
		
#define PATHNAME "/home/Asoul/dir/share/server.c" //路径名

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	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); //打印key值
	printf("shm: %d\n", shm); //打印句柄
	return 0;
}

在这里插入图片描述
Linux当中,我们可以使用ipcs命令查看有关进程间通信设施的信息:

在这里插入图片描述
ipcs 会默认列出消息队列、共享内存以及信号量的相关信息,若只想查看它们之间某一个的相关信息,可以携带选项:

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

比如想单独查看消息队列:

在这里插入图片描述
key 是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证唯一性的方式,keyshmid 之间的关系类似于fdFILE*之间的的关系

释放共享内存🤔

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

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

此时我们如果要释放创建的共享内存,那么有两个方法:一是命令行释放,二是进程通信完毕后调用释放共享内存的函数

方法一就是使用 ipcrm -m shmid

ipcrm -m 8

ipcs -m 查看当前服务器有多少共享内存,然后再使用 ipcrm 删除即可,删除时使用共享内存的用户层 id,即列表当中的 shmid

在这里插入图片描述
方法二使用程序释放:

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

shmid :所控制共享内存的用户级标识符
cmd :具体的控制动作
buf :用于获取或设置所控制共享内存的数据结构

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

其中 cmd 的常用选项有三个:

在这里插入图片描述
在以下代码当中,共享内存创建后两秒程序自动移除共享内存,再过两秒程序就会自动退出:

#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#define PATHNAME "/home/Asoul/dir/share/server.c" //路径

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	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); //打印key值
	printf("shm: %d\n", shm); //打印句柄

	sleep(2);
	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	sleep(2);
	return 0;
}

在另开一个 SSH 渠道进行监控,监控语句如下:

while :; do ipcs -m;echo "###################################";sleep 1;done

结果如下:

在这里插入图片描述

关联共享内存🤔

将共享内存连接到进程地址空间需要使用shmat函数:

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

shmid:待关联共享内存的用户级标识符
shmaddr:指定映射的某一地址,通常设置NULL,表示让内核自己决定一个合适的地址位置
shmflg:关联共享内存时设置的某些属性

shmat 调用成功返回最终映射的起始地址,调用失败返回 (void) -1*

其中 shmat 的 shmflg 有三个选项:

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

现在就可以使用 shmat 和共享内存进行关联:

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

#define PATHNAME "/home/Asoul/dir/share/server.c" //路径

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	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); //打印key值
	printf("shm: %d\n", shm); //打印句柄

	printf("attach begin!\n");
	sleep(2);
	char* mem = shmat(shm, NULL, 0); //关联共享内存
	if (mem == (void*)-1){
		perror("shmat");
		return 1;
	}
	printf("attach end!\n");
	sleep(2);
	
	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	return 0;
}

此时我们运行会关联失败,报错是权限问题这是因为 shmget 函数创建共享内存时并没有创建出设置权限,会给出一个默认权值为 0,也就是没有权限

在这里插入图片描述
这时候 shmget 的第三个参数就有价值了, shmflg 设置权限的规则与设置文件权限的规则相同,将原来 server.c 里的 shmget 语句替换成下面的指令:

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

效果如下:

在这里插入图片描述

去关联共享内存🤔

我们使用 shmdt 函数来进行去关联操作:

int shmdt(const void *shmaddr);

参数 shmaddr 即上面调用 shmat 得到的起始地址,shmdt 调用成功返回0,调用失败返回 -1:

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

#define PATHNAME "/home/Asoul/dir/share/server.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 | 0666); //创建新的共享内存
	if (shm < 0){
		perror("shmget");
		return 2;
	}
	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印句柄

	printf("attach begin!\n");
	sleep(2);
	char* mem = shmat(shm, NULL, 0); //关联共享内存
	if (mem == (void*)-1){
		perror("shmat");
		return 1;
	}
	printf("attach end!\n");
	sleep(2);
	
	printf("detach begin!\n");
	sleep(2);
	shmdt(mem); //共享内存去关联
	printf("detach end!\n");
	sleep(2);

	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	return 0;
}

效果如下:

在这里插入图片描述
nattch 是挂接的进程数,很明显 nattch 的数目由 1 变成了 0,说明我们去关联操作手成功的。注意我们只是去关联,让共享内存与当前进程脱离,并不是直接删除当前共享内存

共享内存下的 server&client 通信😋

我们此时也可以通过共享内存实现一个 server& client 通信模型,和管道其实大差不差,这里服务端负责创建共享内存,创建好后将共享内存和服务端进行关联,之后进入死循环,便于观察服务端是否挂接成功:

服 务 端 : \color{red} {服务端:}

#include "head.h"

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	if (key < 0){
		perror("ftok");
		return 1;
	}

	int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建新的共享内存
	if (shm < 0){
		perror("shmget");
		return 2;
	}
	
	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印共享内存用户层id

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

	while (1){
		//不进行操作
	}

	shmdt(mem); //共享内存去关联

	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	return 0;
}

客户端只需要和服务端建立共享内存即可,在死循环后即可观察是否挂接成功:

客 户 端 : \color{red} {客户端:}

#include "head.h"

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取与server进程相同的key值
	if (key < 0){
		perror("ftok");
		return 1;
	}
	int shm = shmget(key, SIZE, IPC_CREAT); //获取server进程创建的共享内存的用户层id
	if (shm < 0){
		perror("shmget");
		return 2;
	}

	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印共享内存用户层id

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

	int i = 0;
	while (1){
		//不进行操作
	}

	shmdt(mem); //共享内存去关联
	return 0;
}

同样的,为了在客户端和服务端可以使用 ftok 函数获取 key 值,那么 ftok 传入的路径名和正数标识符必须相同,我们需要引入一个单独的头文件 head.h 来支持

头 文 件 : \color{red} {头文件:}

#include <stdio.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#define PATHNAME "/home/Asoul/dir/share/server.c" 

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

效果如下:
在这里插入图片描述
这里在监控进程里面,执行服务端 server 后关联进程数为 1,在执行 client 客户端后这里关联进程数变成了 2 ,说明我们的关联操作是成功的,至此我们就可以向客户端和服务端死循环里面写入数据通信操作:
客 户 端 : \color{red} {客户端:}
客 户 端 : \color{red} {客户端:}

//不断向共享内存写入数据
int i = 0;
while (1){
	mem[i] = 'A' + i;
	i++;
	mem[i] = '\0';
	sleep(1);
}

服 务 端 : \color{red} {服务端:}

//不断读取共享内存当中的数据并输出
while (1){
	printf("client# %s\n", mem);
	sleep(1);
}

效果如下:

在这里插入图片描述

共享内存与管道🤔

实际上共享内存是所有通信方式里面最快的一种通信方式,管道相比共享内存稍逊一筹,因为管道启动后需要调用一堆接口 read,write 等,而共享内存就不需要这些接口

在这里插入图片描述
这张图就很形象的阐述了管道通信必须经历的四次拷贝操作

  1. 输入文件拷贝来的信息放入服务器的缓冲区
  2. 服务器将信息拷贝放入管道
  3. 客户端将管道信息拷贝到自己的缓冲区
  4. 客户端将接受到的信息放入输出文件

再来看看共享内存
在这里插入图片描述
很明显这里共享内存只需要两次拷贝即可完成通信过程,虽然但是吧,共享内存还是有缺陷的,他缺乏某些保护机制,即管道自带的同步互斥机制,共享内存是没有的。

systemV 消息队列😋

消息队列的底层原理就是在系统当中创建一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块

数据块的类型就决定了该信息到底是谁发给谁:

在这里插入图片描述

消息队列提供了一个发送数据块的方法,每个数据块都被认为是有一个类型的,接收者接收的数据块可以有不同类型值。和共享内存一样,消息队列资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。

消息队列数据结构😋

既然有消息队列,那也同理会有相应的数据结构对他进行维护;

struct msqid_ds {
struct ipc_perm msg_perm;
struct msg msg_first; / first message on queue,unused */
struct msg msg_last; / last message in queue,unused /
__kernel_time_t msg_stime; /
last msgsnd time /
__kernel_time_t msg_rtime; /
last msgrcv time /
__kernel_time_t msg_ctime; /
last change time /
unsigned long msg_lcbytes; /
Reuse junk fields for 32 bit /
unsigned long msg_lqbytes; /
ditto /
unsigned short msg_cbytes; /
current number of bytes on queue /
unsigned short msg_qnum; /
number of messages in queue /
unsigned short msg_qbytes; /
max number of bytes on queue /
__kernel_ipc_pid_t msg_lspid; /
pid of last msgsnd /
__kernel_ipc_pid_t msg_lrpid; /
last receive pid */
};

该结构的第一个成员是msg_perm,它和 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;
};

创建消息队列🤔

我们使用 msgget 函数来创建:

int msgget(key_t key, int msgflg);

这里依然需要 ftok 函数来获取 msgget 的第一个参数 key,msgflg 和 shmget 的第三个参数 shmflg 是一样的,在调用成功后会返回一个有效的消息队列标识符。

释放消息队列🤔

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

shmctl 的三个参数是一样的,不再赘述。

数据传输🤔

向消息队列发送数据我们需要用msgsnd函数

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

msqid:消息队列的用户级标识符
msgp:待发送的数据块
msgsz:所发送数据块的大小
msgflg:发送数据块的方式,一般默认为0即可

函数调用成功返回 0,调用失败返回 -1

获取消息队列数据🤔

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

msqid:消息队列的用户级标识符
msgp:获取到的数据块,是一个输出型参数
msgsz:要获取数据块的大小
msgtyp:要接收数据块的类型

函数调用成功返回实际获取到mtext数组中的字节数,调用失败返回 -1

system V信号量😋

由于进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系叫做进程互斥

系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源互斥资源。在进程中涉及到临界资源的程序段叫临界区。IPC 资源必须删除,否则不会自动删除,因为system V IPC 生命周期随内核

数据结构🤔

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

改结构的第一个成员是 sem_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;
};

创建信号量🤔

int semget(key_t key, int nsems, int semflg);

key :依然是使用 ftok 函数进行获取
nsems :创建信号量的个数 semflg:与创建共享内存时使用的 shmget
函数的第三个参数相同

信号量集创建成功时返回一个有效的信号量集标识符

删除信号量🤔

int semctl(int semid, int semnum, int cmd, ...);

操作信号量🤔

int semop(int semid, struct sembuf *sops, unsigned nsops);

进程互斥😋

进程间共享资源虽然实现了进程通信,但是同时也因为共用临界资源,在没有保护机制的情况下,会造成各个进程在临界区取得数据不一致的问题

保护的源头就是保护临界区,这就牵扯到信号量了,信号量分为两种:二元信号量和多元信号量

比如有一块大小为 100 字节的资源,25 字节为一份,那么可以分为 4 份,此时这块资源可以由 4 个信号量进行标识:

在这里插入图片描述
信号量本质就是一个计数器,在二元信号量里面将临界区资源看成一个整体所以信号量为 1 ,二元信号量本质解决了互斥问题,参考下面伪代码:
在这里插入图片描述
当进程A申请访问共享内存资源时,如果此时sem为1(sem代表当前信号量个数),则进程A申请资源成功,此时需要将sem减减,然后进程A就可以对共享内存进行一系列操作,但是在进程A在访问共享内存时,若是进程B申请访问该共享内存资源,此时sem就为0了,那么这时进程B会被挂起,直到进程A访问共享内存结束后将sem加加,此时才会将进程B唤起,然后进程B再对该共享内存进行访问操作。

在这种情况下,无论什么时候都只会有一个进程在对同一份共享内存进行访问操作,也就解决了临界资源的互斥问题。

实际上,代码中计数器sem减减的操作就叫做P操作,而计数器加加的操作就叫做V操作,P操作就是申请信号量,而V操作就是释放信号量。

在这里插入图片描述

system V IPC😋

虽然各个通信方式的内部属性差别很大,但是维护他们的数据结构的第一个成员都能和 ipc _perm 扯上关系,这样有一个优点就是可以以统一的视角进行管理:

我们只要涉及到 ipc_perm 就可以以数组的形式组织起来进行统一管理,后用切片的方式获取到该 IPC 资源的起始地址,然后就可以访问该 IPC 资源的每一个成员了,这或许就是就是多态的前身思想吧

在这里插入图片描述

  • 110
    点赞
  • 126
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

乔乔家的龙龙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值