进程是具有独立性的;早在我们之前对进程的学习中,我们就知道,每个进程都有自己独立的虚拟空间,通过虚拟空间映射到物理内存上完成指令;也正是因为这样的原因我们的进程之间想要相互交流将要付出一些代价;因为进程之间是相会独立的无法看到对方的数据,自然也就没办法接收对方的数据,或者发送数据给对方,使得无法进行数据交流;就算是父子进程都没办法之间进行数据交互(写时拷贝的存在);
所以进程间通信就是为了解决独立进程之间的数据交互问题;如何进行进程间通信呢?让不同进程看见同一块内存空间;
如何使得不同进程看见同一块内存空间呢?就得通过进程间通信的标准来操作了;
进程间通信标准
技术的水平是不同的,当一个技术的水平和效率达到其他技术无法超越的程度之后,用户们在使用其他技术的时候自然会感受到技术之间的差距,所以用户们如果像创造出更好的产品就不得不使用更好的技术,这个时候大家都会使用这个好的技术,此时这个技术就成为了标准;
而我们进程间通信的标准有:管道,System V IPC , POSIX IPC 这三个标准;下面的标准所包含的通信技术:
管道:命名管道,匿名管道;(内核的通信)
System V IPC:消息队列, 共享内存 ,信号量;(本地通信)
POSIX IPC:消息队列 ,共享内存,信号量 ,互斥量 ,条件变量 ,读写锁;(包含了网络通信)
有了这些标准下面我们来一个个讲解这些标准:
管道
管道一开始在我的印象里面就是一个 | 符号,可以通过它来传输我们的数据,而这个指令其实也是一个程序,是调用了系统接口形成的;我们先来看看管道中的匿名管道吧;
匿名管道pipe
什么是匿名管道
在我们的命令行上输入的 | 符号就是匿名管道,通过它可以实现我们进程之间的数据交流;首先我们需要明白管道的存在是为了进行进程间通信,而想要进程间通信,我们首先需要进程可以有数据交流,而想要让进程有数据交流,就得让进程看到同一块内存空间;而管道就是那块被双方进程都可以看到的内存空间;
所以叫管道的进程间通信我们首先要创建出我们的管道;上图中的管道就是创建出来供进程交流的内存,这个内存的生命随着交流双方的关闭会自动消失;其实我们可以把这个内存就看作为文件,因为我们的不同进程向分别向同一个文件进行读写,也可以实现这样的数据交流;不同的是管道的数据交流是进行封装过的,有着访问控制;
现在我们知道了管道的概念;我们下面通过实操(写代码)来创建管道并进行进程间通信;
创建匿名管道
我们在命令行上可以通过 | 来创建管道并传输数据:
我们运行ps指令(指令也是进程)可以将ps进程中的数据输入到head进程中通过head进程读取第一行的数据;这是命令行上的管道;命令行上的管道 | 也是一个进程;
接下来我们看进程 | 的底层:
创建匿名管道的系统接口:
pipe函数接收一个int pipefd[2]类型的数组,我们的进程向这个数组中的0号为填入读方式打开的管道文件描述符,1号位填写以写方式打开的文件描述符;
代码实现匿名管道pipe
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <assert.h>
#include <fcntl.h>
#include <cstring>
using namespace std;
int main()
{
int pipefd[2] = {0};
int pipe_ret = pipe(pipefd);
assert(pipe_ret != -1);
#ifdef DEBUG
// 展示读写所代表的文件描述符
open("tmp.txt", O_WRONLY | O_CREAT); // 随意打开一个文件可以查看pipefd中存储的文件描述符是否改变
cout << "pipefd[0]:" << pipefd[0] << " 读" << endl;
cout << "pipefd[1]:" << pipefd[1] << " 写" << endl;
#endif
pid_t pid = fork();
assert(pid >= 0);
if (pid == 0)
{
// 关闭管道写
close(pipefd[1]);
// 子进程读取管道中的数据
// 开始接收信息
while (true)
{
char recept_massage[1024]; // 接收到的信息
int recept_size = read(pipefd[0], recept_massage, sizeof(recept_massage) - 1);
if (recept_size > 0)
{
recept_massage[recept_size] = '\0'; // 处理接收到的信息为结尾赋0
// 打印一下读取到的信息;
// debug
// cout <<"["<<getpid()<<"] recept massage:"<<recept_massage<<flush;
cout << "[" << getpid() << "] recept massage:" << recept_massage << endl;
}
else if (recept_size == 0)
{
// 如果读取到了结尾就代表读完了
cout << "send over;quit!" << endl;
break;
}
}
// 关闭管道读并退出
close(pipefd[0]);
exit(0);
}
// 关闭管道读
close(pipefd[0]);
// 写入数据到管道中
const char *massage = "hello ! I am parent progress!"; // 待编辑的信息
char send_massage[1024] = {0}; // 要发送的信息
int num = 0;
while (true)
{
// 编辑信息到send_massage发送信息中
snprintf(send_massage, sizeof(send_massage), "%s %d", massage, num++);
// 开始发送信息
cout << "send massage :" << num - 1 << endl;
write(pipefd[1], send_massage, strlen(send_massage));
// 每隔1秒发送一次
sleep(1);
// 发送到第5条时停止发送
if (num == 5)
{
close(pipefd[1]);
break;
}
}
// 等待子进程退出
pid_t wait_ret = waitpid(pid, nullptr, 0);
assert(wait_ret != -1);
cout << "I am parent ;I get my son ;I quit!" << endl;
return 0;
}
讲解:由于我们的匿名管道是匿名的原因,所以一般是给父子进程进程进行交流的;上面我们先创建了管道,然后再创建子进程,之后父进程关闭读端,子进程关闭写端(因为管道是半双工的单向的);然后由父进程向管道中写数据,子进程通过管道从管道中读取数据;
现象:
就这样我们的子进程不断读取我们父进程的数据;
理解管道为什么能安全传输数据
我们可以发现我们的父进程是每隔一秒发送一次数据的,而子进程在不断的读数据,但是子进程读取的数据总是一行一行的没有发生读取过快使得数据不正确的问题;
这是因为:
1.我们父进程写入的数据是原子的(通俗的说就是数据量小,如果数据量大就得分批发送,一次发送不了);所以数据不会被分批读取,产生错误;
2.我们的管道对读写进行了访问控制;
当读端速度快,写端速度慢时;读端会阻塞住,等待写端写入成功后,读取数据解除阻塞(原因)
当读端速度慢,写端速度快时;写端写满管道后也会阻塞住,等待读端读取,读端读取之后写端才会接触阻塞,继续写入;
当写端关闭时,读端会获得返回值0,读端可以通过这个特性设置退出条件;
当读端关闭时,写端会直接关闭进程;
现象可以通过我的代码来模拟出来:
process_communication2024.4.2/pipe · future/Linux - 码云 - 开源中国 (gitee.com)
用文件来理解管道
我们如果想更好的理解管道我们可以看下面的图:
所以我们将管道看作文件来处理它,就可以啦!
我还是实现了一个进程池的代码,我们可以通过创建多个进程,让赴京城向多个子进程发送数据从而实现,多个进程执行多个命令的功能;
process_communication2024.4.2/pipe · future/Linux - 码云 - 开源中国 (gitee.com)
命名管道fifo
我们在复杂多变的场景中一定是不只有父子进程进行通信的,如果两个毫不相干的进程甚至没有父子关系的进程想要通信,这个时候就可以借助我们的命令管道来完成通信了;
命名管道和管道的性质都是一样的,只不过是命名管道,我们有他的名字,仅此而已;
创建命名管道
我们可以通过mkfifo()接口来创建命名管道;
pathname就是我们要创建的命名管道的路径名,mode就是创建的命名管道文件的权限码;
代码实现命名管道fifo
我们可以看下面的代码来理解:
process_communication2024.4.2/fifo · future/Linux - 码云 - 开源中国 (gitee.com)
全部代码可以通过上面链接观看
我们的client是客户端将会向server服务端发送命令,而server端会接收指令(现实中还会根据指令来实现一些功能);
client端需要创建管道并发送打开写端向server端发送信息;
int main()
{
int ret = mkfifo(pathName.c_str(), MODE);
assert(ret != -1);
log(DEBUG, "server管道创建成功 | step1");
// 打开以写方式打开管道
int fd = open(pathName.c_str(), O_WRONLY);
assert(fd != -1);
log(DEBUG, "server管道打开成功 | step2");
// 发送数据
char buffer[1024];
while (true)
{
cout << "plase sent massage" << endl;
int size = read(0, buffer, sizeof(buffer - 1));
buffer[size - 1] = '\0';
if (strcmp("quit", buffer) == 0)
break;
write(fd, buffer, strlen(buffer));
}
// 关闭管道并删除
close(fd);
unlink(pathName.c_str());
log(DEBUG, "server管道删除成功");
return 0;
}
server端也需要打开管道的读端读取数据
void getmessage(int fd, char *buffer)
{
int size = read(fd, buffer, sizeof(buffer) - 1);
assert(size != -1);
if (size == 0)
{
cout <<"["<<getpid()<< "]quit " << endl;
exit(0);
}
else if (size > 0)
{
buffer[size] = '\0';
cout << "[" << getpid() << "] get message : " << buffer << endl;
}
}
int main()
{
// 打开管道
int fd = open(pathName.c_str(), O_RDONLY);
assert(fd != -1);
log(DEBUG, "client管道打开成功 | step1");
// 接收数据由进程池接收数据
for (int i=0; i < P_NUM; i++)
{
pid_t pid = fork();
if (pid == 0) // 子进程
{
char buffer[1024] = {0};
while (true)
{
getmessage(fd, buffer);
}
}
}
for (int i=0; i < P_NUM; i++)
{
waitpid(-1, nullptr, 0);
}
// 关闭管道
close(fd);
log(DEBUG, "client管道关闭成功");
}
这上面我们通过访问控制如果写端退出,那么读端也就是server端也会自动退出;我们这里的server端也是创建了进程中来抢夺命令,从而实现多个进程来执行指令的;
命名管道创建时的发现
我发现我们创建好了管道后,我们的读端和写端都打开的时候才能继续向下运行我们的代码,否则会阻塞在管道打开的位置,等待对方端打开;
只有当双方都准备打开管道的时候,管道接收到了双方同时发出的信号才能成功打开
log日志信息
这是一个可以打印日志信息的代码文件;
enum log_num{
DEBUG,
NOTICE ,
WARNING ,
ERROR
};
string logMassage[]={
"debug",
"notice",
"warning",
"error"
};
void log(int num,const string& massage)
{
time_t curtime=time(nullptr);
cout<<asctime(localtime(&curtime))<<logMassage[num]<<" | "<<massage<<endl;
}
实现很简单,就是通过time函数的接口打印出当前进程在接收参数打印相应的日志类型和日志信息;
值得注意的是:
这里我在使用time函数的时候发现localtime函数不能直接接收time()的返回值因time(nullptr) 返回的是一个临时值,而临时值是不可取地址的,因为它们没有实际的内存地址;之后我们再把localtime返回值传给asctime使得我们的tm结构体中的数据转换字符串数据从而打印出工整的当前时间;
最后的现象:
除开管道这以内核级别的进程间通信方式,我们还有System V标准的ipc与POSIX标准的ipc;我们接下来说一说System V标准下的共享内存通信方式;
共享内存shm(System V标准)
理解共享内存
共享内存是ipc中速度最快的一种方式;为什么这么说呢?这是因为shm它的底层是在内存中开辟了一片空间,并将这片内存空间映射到了进程的虚拟地址空间上的堆栈之间的数据共享区,也就是说,这片空间就和我们malloc函数一般,我们申请到的随时可以看见并修改的空间,真正属于进程的空间;这个时候的数据交互已经不需要通过操作系统完成了,两个进程一个进程写入数据另一个读即可;所以速度会非常快;我们可以通过下面的图辅助理解
此时的数据交流是不通过内核的是完全在进程自己的用户空间中操作的;
接下来我们就来看看如何操作共享内存:
共享内存函数
1.shmget():get
2.shmat():attach
3.shmdt():detach
4.shmctl():control
额外.ftok() :File to Key
这几个接口就是完成内存共享的关键函数;
我们下面来解释一下这几个接口;
shmget
key是创建我们共享内存的一个键值用来唯一标识我们的共享内存空间;这个key值我们一般使用ftok来获得;
ftok
用一个路径和项目id创建创建出一个唯一标识的key值,proj_id可以是随机值我们的操作系统会自动截断第八位数据也就是一个字节;
size是共享内存的大小;
shmfg是创建内存空间的选项一般有IPC_CREAT, IPC_EXCL 以及创建文件的权限例如:0666;IPC_CREAT单独出现代表创建一块共享内存空间如果以及有了这片共享内存空间,就直接使用这片创建好的共享内存空间;但IPC_EXEC和IPC_CREAT一起出现的时候代表创建一片内存空间如果空间存在则返回-1,不存在就返回正确值;
shmget的返回值是共享内存的标识码shmid(让用户看的);
shmat
这个函数是用来进来当前进程和共享内存空间的联系的;
返回值是共享内存空间的首地址的指针;
shmid是shmget的返回值;shmaddr是指定连接的共享内存空间一般是设置为nullptr的;shmflg是对指定连接的共享内存空间进行的设置,我们一般设置为0;
shmaddr是shmat的返回值;我们可以像malloc函数一样接收
如char*shmaddr=(char *)shamat();
shmdt
shmaddr就是上面所说的shmat的返回值;用来删除共享内存空间与进程的联系;
shmctl
用来控制shmid的共享内存空间的函数;其中cmd是对shmid标识的内存空间的操作选项,选项有:
IPC_STAT
:获取共享内存的状态信息,并将其存储在 buf
指向的结构体中。
IPC_SET
:设置共享内存的状态信息,通过 buf
指向的结构体中提供的值进行设置。
IPC_RMID
:删除共享内存段,释放其资源。(这是最常用的选项)
其他命令,如 SHM_LOCK
、SHM_UNLOCK
等,用于共享内存的锁定和解锁。
buf:则是一个指向保存着内存空间访问权限和模式状态的数据结构的指针(不关心则设置为nullptr);
接下来我们使用代码来实现上面接口的使用:
// 1.创建我们的key值给shm
key_t key = ftok(PATH_NAME, PROJ_ID);
assert(key != -1);
log(DEBUG, "server成功获得共享内存的key值 | step1");
// 2.利用shmget创建出共享内存
umask(0);
int shmid = shmget(key, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);
assert(shmid != -1);
log(DEBUG, "server成功创建共享内存 | step2");
cout << endl;
// 3.使用shmat链接共享内存和进程地址空间
char *shmaddr = (char *)shmat(shmid, nullptr, 0);
assert(shmaddr);
log(DEBUG, "server成功链接共享内存与进程 | step3");
cout << endl;
// 4.服务端进行操作
log(DEBUG, "server服务端接收信息 | step4");
// 操作
。。。。。。
cout << endl;
// 5.使用shamdt将当前共享内存脱离进程地址空间
int ret_dt = shmdt(shmaddr);
assert(ret_dt != -1);
log(DEBUG, "server成功使进程脱离共享内存 | step5");
cout << endl;
// 6.使用shmctl删除创建的共享内存
int ret_ctl = shmctl(shmid, IPC_RMID, nullptr);
assert(ret_ctl != -1);
log(DEBUG, "server成功删除共享内存 | step6");
上面就是这些接口的使用了;
我还是用这些接口完成了像上面的管道一样的client端和server端的数据交流,并使用管道进行访问控制(因为我们的共享内存是没有访问控制的);
如何通过管道实现访问控制:
先创建一个管道;打开管道两端,当我们像内存空间中写入数据后,我们还需要向管道发送信号;读端的管道会在读取内存空间之前先接收管道中的信号,如果管道中没有信号,则代表写端还没有写入数据,就会阻塞在管道的读代码处,当写端写入完成后,发送信号给读端,读端管道解除阻塞,继续读取共享内存中的数据;就这样通过管道完成了访问控制;但是这是借别人之手完成的控制,我相信在之后的学习中,肯定会有底层实现来自己进行控制的手段;
共享内存的代码实现:
process_communication2024.4.2/shm · future/Linux - 码云 - 开源中国 (gitee.com)
以上就是共享内存的内容;
消息队列的了解(仅仅只是了解)
消息队列由于用法陈旧大部分的进程间通信都不使用这个通信方式了,所以我们也在暂时不做过多了解;消息队列也和共享内存一样有它自己的函数接口:也是由于这些接口和我们linux下一切皆文件的接口有着冲突,所以我们大部分的操作都将其淘汰了;msgget,msgctl,msgrcv这些接口;
ipcs指令:
ipcs -m查看共享内存
上面的nattch是挂接数,指的是有多少个进程与当前共享内存挂接上了;
status一般不显示,因为信息包含太多为了简洁一般不显示;
ipcs -q查看消息队列
ipcs -s查看信号量
初步了解信号量
在前面我们看到了管道是具有访问控制的,我们的共享内存不具有访问控制,所以以管道为媒介实现了访问控制;而管道的底层是如何实现访问控制的呢?我认为就是和信号量有关;访问控制一个要借助信号量来完成;(这段话是我从前面的学习对于信号量的猜测)
下面我们来介绍一下信号量;信号量用来进行同步与互斥;我们先介绍一些同步与互斥的名词;
1.临界资源:也叫互斥资源和公共资源;当一些资源每次只允许一个进程使用的时候,这个资源就叫临界资源;
2.临界区:在进程中使用了临界资源的代码区,就叫临界区;
3.进程之间抢夺临界资源的关系叫做互斥;
有了这些名词,下面我们来理解信号量;
1.临界资源的访问就和电影院的座位一样,我们需要先买票,同理我们想要访问临界资源我们也需要先申请信号量;
2.信号量的本质就是对临界资源的预定;
我们也可以把信号量看作一个计数器;当我们的进程挂接上临界资源的时候计数器++(申请成功),解除挂接的时候计数器 -- ;
通过上面的特性,信号量就可以用来保护临界资源,当计数器减为0时就没有临界资源,那进程就无法申请成功;
由于信号量在这个时候也能被多个进程看到,所以其实信号量也是临界资源,如果信号量也是临界资源那就没有办法很好的保护我们的临界资源;所以为了信号量可以保护临界资源,信号量的++与--操作需要是原子的,就是只需要一次指令就可以完成的操作,使得上下文在交换的时候不会影响到信号量,这个时候信号量的++操作叫做V操作,--操作叫做P操作;
猜测:
上面说到了互斥,我猜测同步应该就是要两个进程同时访问信号量才会发生变化才允许进程访问临界资源;就像是我们上面命名管道创建时的发现一样,当两个进程同时打开的时候,才能访问管道,否则一个进程会阻塞在管道打开处;我认为这里就是信号量同步的影响;(这仅仅是我的猜测仅供参考2024.4.17)
以上就是本章节的全部内容;