进程是如何完成通信的呢?上面说了一共有俩类,但是都用的是一个思想:进程通信,必须使得进程间可以读写同一块内存。这块内存用于进程间通信,这是好理解的。
目的:
什么是管道?简单说,就是一个数据流,它用于两进程之间的数据交互,称做管道。
管道的本质:是内核中的缓冲区,用于完成进程间的通信,它的生命周期随进程。
举个例子:我有一个文件test.txt,内部有1~20个数字,有序的排列。现在我想要查看中间的5个数据,该怎么办?
(1) 先来看一下这个文件:
cat test.txt | head -12 | tail -5
首先 cat 命令会将test.txt的内容输出到屏幕上,但是有了这个 |
,是将test.txt的内容输入到管道中,就类似这样:
那么head将从管道中读取数据,加上选项 -12 就是看前12行,本来是要输出到屏幕上的,但是又加了一个管道,使得发生类似上述情况,tail又会读取此管道的内容,取出后5行。也就是中间的5个数据。
(4) 总结
上述的过程就是就是利用管道进行通信,cat,head,tail都是不同的进程,但是它们都能够读写同一区域(管道)。
但是一个进程进行写,另一个进程进行读这种方式。下面我们来具体的讲讲,我们创建的进程,该如何利用进程进行通信。
什么是匿名管道呢?它只用于父子进程中,所以不需要给它命名,只用于父子进程进行通信。
子进程会继承父进程的代码和数据,但是进程控制块是独立,这保证了进程间的独立性;进程控制块中有一个*file
,它指向了管理文件的结构体,这个结构体中有文件指针数组。进程默认打开了三个文件:标准输入,标准输出,标准错误输出,它们的文件描述符分别是0,1,2。文件描述符就是文件指针数组的下标。那么匿名管道它也是个文件,创建匿名管道后自然会有文件指针指向它,但是需要注意指向它的文件指针有俩个,一个是进行读操作的标识,一个是进行写操作的标识。
图解:
那么父进程创建好管道,子进程继承后,也会向父进程那样看待管道:
如何实现:父子进程进行通信?假如实现子进程读,父进程写,那么就关闭子进程的写,关闭父进程的读。
如果创建了一个子进程,那么情况会变成这样:
用的函数是int pipe(int fd[2])
以及·int pipe2(int pipefd[2], int flags)
.
这两个函数的差别在于:参数不同,pipe2的第二个参数,如果pipe2的第二个参数设了0,那么和pipe毫无差别。关键加这么一个参数是为了,对创建出的管理进一步限制(管理)。
函数的返回值:int整型,返回为0 表示管道创建成功;返回为 -1 表示管道设置失败,并设置errno。
函数的参数: 函数的参数是一个数组,输出型参数,创建管道成功后,pipefd[0] ,pipefd[1] 存的都是文件描述符,pipefd[0] 是读管道的描述符,pipefd[1]是向管道写的描述符。
单独讲讲pipe2()的第二个参数:
先简单的实现匿名管道,顺便验证一下匿名管道的文件描述符:
这就创建好了匿名管道,运行看看情况:
没问题,匿名管道的文件描述符是3 ,4 。0,1,2的话已经被占用。
图解很清楚:
两种情况:
为什么要这样做?其实也是为了保护原子性,防止进程又写又读导致非常混乱的情况。
假如我要实现:子进程读,父进程写
#include
#include
#include
int main()
{
int ret[2]={0};
pipe(ret);
if(fork() == 0)
{
close(ret[1]);
// 子进程读
while(1)
{
//更新一下
char buffer[64] = {0};
// //zero indicates end of file,如果read的返回值是0,意味子进程关闭文件描述符了
// //只要有数据,就可以一直读取
ssize_t s = read(ret[0], buffer, sizeof(buffer)-1);
if(s == 0){
printf("father quit...\n");
break;
}
else if(s > 0){
buffer[s] = 0;
printf("father say to child# %s\n", buffer);
}
else{
printf("read error...\n");
break;
}
}
}
close(ret[0]);
// 父进程写
while(1)
{
const char* str = "hollow everyone";
write(ret[1],str,strlen(str));
}
return 0;
}
这就完成了父子进程间的通信,运行的时候,不是很人性,因为写和读都很快,导致满屏飞。不过没关系,我们接下来会详细的讲解匿名管道的读写规则以及特点。
(1) 读写规则
验证一下,就上面的程序,因为可以让子进程(读端),sleep(2),看看情况。
因为写端快于读端,所以看到写段向管道写入了不少内容,所以读端每次都读的满满的。
这是好理解的,都没人去管道读数据了,何必还要往里面写呢?用什么终止,->信号
验证一下,我让子进程读一次,立马就退出,看看父进程还会写吗?父进程如果不写了,那么我们获取一下它的退出信息:
可以看到,运行直接终止,退出码是141,哎呀,退出码总共就130多个,他直接退出码141,说明父进程是被信号干掉得,这个信号就是13。
这次我们让写端父进程,等待2s,看看具体情况:
这个就不验证了,留个大家。
(2) 匿名管道的特点
命名管道是有名字的管道,它可以使得不同的进程,使用此管道。创建命名管道可以在命令行上创建,也就是创建一个文件,当然也能在程序中创建。命名管道也是内核的缓冲区,不过它是有一个命名管道文件的,进程通过命名管道文件,来找到内核中的缓冲区,所以命名管道文件是命名管道缓冲区的一个标识,那么如果删除命名管道文件,会不会导致进程间无法通信?不会,之前已经在通信的进程,是不需要命名管道文件的,它们依旧可以通信。
实现进程间通信,必须使得进程看到同一块内存,并进行读写操作。但是该如何找到这块内存呢?那就给管道命名,这是具有唯一性的,利用算法生成的管道名,这后面会讲到的。
命名管道文件的内容,一般不会刷到磁盘中,这是为了程序的效率:
注意
:
建好命名管道后,程序在进行通信时,其实用的是文件操作,就是向文件写,向文件读,这种基操。
命令行创建一个命名管道:
用的命令:mkfifo filename
操作一下吧,方便理解:
可以看到我创建的新文件,fifo的开头是 p
说明是管道文件。
这是两个不同的进程:
现在我利用fifo(命名管道),进行通信:
首先认识一个函数:
int mkfifo(const char *pathname,mode_t mode);
然后,我们创建俩个程序,ly.c,ybw.c。要实现它俩之间的通信,为了让它们看到同一份资源,那么我必须要创建一个命名管道,但是俩程序如何看到这个命名管道呢?可以包一个头文件,有点巧妙,看完就懂了。
ybw.c 发出信息,ly.c 接受消息:
#pragma once
#include
#include
#include
#include
#include
#include
#define MY_FIFO "./fifo"
#include"communi.h"
int main()
{
umask(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 str[64]={0};
read(fd,str,sizeof(str)-1);
printf("%s\n",str);
}
close(fd);
}
umask(0),表示将权限掩码设置为0;mkfino()创建管道;open()以只读的方式打开管道(操作文件);read()将管道的内容输入到str数组中;printf打印str内容;每次循环开始,都要将str置空。
#include"communi.h"
int main()
{
int fd= open(MY_FIFO,O_WRONLY);
while(1)
{
char str[64]={0};
printf("请输入: ");
fflush(stdout);
int s= read(0,str,sizeof(str)-1);
str[s-1]=0;
printf("%s\n",str);
write(fd,str,strlen(str));
}
close(fd);
return 0;
}
这里就不需要创建管道了,直接open()以只写的方式打开管道;然后用read()从键盘(文件描述符0)读取数据到str,注意不能读到‘\n’;将末尾设置为‘\0’;printf()打印一下str;最后用write()向管道写入str。
我们来看看现在的效果:
这就是简单的,一个进程传过去消息,然后另一个进程读消息;可不可以扩展一下,感觉有点low;我们可以畅想一下:如果再创建一个命名管道,是不是就可以完成聊天了?这个大家感兴趣可以去做着玩。但是如果是基础功能我到是还可以添加进去的,这涉及到了进程替换,比如ybw输出一个look,ly接受到后,比较一下,发现传过来的消息是look,那么就进程替换成 打印当下目录:ls -a -l,就是这个。
我们来试一下:
#include"communi.h"
int main()
{
umask(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 str[64]={0};
read(fd,str,sizeof(str)-1);
if(strcmp(str,"look")==0)
{
if(fork() == 0)
{
execl("/usr/bin/ls", "ls", "-l", NULL);
_exit(1);
}
}
else
{
printf("ybw say:%s\n",str);
}
}
close(fd);
}
运行一下:
上面我们是通过管道进行的进程通信,难道就没有更简单的方式,操作系统不能给个接口使唤一下吗?答案是有的。操作系统提供了system V标准的进程通信,当然这是科学家兼程序员设计的;进程通信的本质是让不同的进程使用到同一块内存,这是我多次强调的。
共享内存真的是相当的快捷。共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。
简单说就是,搞一块内存,映射到进程的页表中,这就表明进程和此内存挂接上来;如果要完成进程间通信,只需要使另一个进程也挂接上这个内存;通过这个内存就完成了进程间通信。这需要理解虚拟地址空间,不懂的童鞋需要补一下这块知识。
共享内存的删除的本质:是一种引用计数删除,也就是说只要有进程链接着共享内存,共享内存就不会被删除,进程要删除共享内存,只不过是共享内存中的链接计数减一,直到共享内存中的链接数为0,这时候进程对共享内存进行删除操作,才是真正的释放共享内存所占空间。
图解:
现在,我开辟一个共享内存,并使得A,B进程都挂接上此内存:
我们是需要管理共享内存的,应该都知道,进程管理。进程加载到内存需要管理,同样搞一个共享内存也是需要进行管理的。而且是有多个共享内存块的,如何区分?必然有个标号来标识共享内存,就是shmid。
使用指令 ipcs -m
来查看共享内存段的使用情况:
是由一个结构体来管理的:
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 */
}
功能:用来创建共享内存
原型: int shmget(key_t key, size_t size, int shmflg);
参数:
- key: 这个共享内存段名字
- size: 共享内存大小
- shmflg: 由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
返回值:成功返回一个非负整数,即该共享内存段的
标识码
(shmid) ; 失败返回 -1
第一个参数key,是用一个函数ftok()来设置的,这个共享内存的名字也是有说法的,用算法来形成的,防止共享内存段名字重复:
第二参数size:表示创建的共享内存段的大小,这里我建议创建的4KB的整数倍
第三个参数shmflg:有九个权限,也可以用 |
的方式,组合使用。
IPC_CREAT : 如果共享内存不存在,则创建一个新的共享内存;如果共享内存已经存在,则返回当前已经存在共享内存的shmid。
IPC_EXCL:这个必须和IPC_CREAT 配合使用,如果共享内存不存在,则创建新的共享内存;如果共享内存已经存在,则报错。这就保证拿到的共享内存必须是全新的。
功能:将共享内存段连接到进程地址空间
原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
- shmid: 共享内存标识
- shmaddr:指定连接的地址
- shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:成功返回一个指针,指向共享内存第一个字节;失败返回-1
第一个参数shmid是由shmget获取的;第二个参数shmaddr是指定连接的地址,也就是指定共享内存链接到进程内存地址的具体位置,如果设置为null,那么内核会自己决定在进程地址空间中找一个合适的位置;第三个参数是设置权限,SHM_RDONLY是只附加读权限,如果设置为0,那么就是可读可写;至于设置SHM_RND权限,要配合着第二个参数shmaddr使用,使用规则如下:
返回值是共享内存段的地址,挂接失败的话返回 -1。
功能:将共享内存段与当前进程脱离
原型:
int shmdt(const void *shmaddr);
参数:
- shmaddr: 由shmat所返回的指针
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段
函数的参数是shmat()的返回值,就是解除进程与共享内存段的挂接。
功能:用于控制共享内存
原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);参数:
- shmid:由shmget返回的共享内存标识码
- cmd:将要采取的动作(有三个可取值)
- buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0;失败返回-1
第二个参数:cmd有三个可取值:
总结一下:shmget用来创建共享内存,返回值是shmid(标识符),这有点像open()打开文件,返回文件的描述符fd;shmat是用于程序和共享内存的挂接;shmdt是用于解除程序和共享内存的挂接;shmctl是用于控制创建的共享内存,可以删除等操作。
有么有人,感觉有点疑问: key和shmid有必要同时存在吗?
答案是有必要:
首先还是得有共同的头文件,这个头文件中我定义几个宏,为的是两个程序,拿到同一个key。
头文件名为communite.h:
#pragma once
#include
#include
#include
#include
#include
#define PATH_name "./"
#define my_ID 0x6666
#define size 4097
我定义了三个宏:PATH_name,my_ID为的是得到的key相同。
我只要给ftok()传参,都传这两个宏,那么两个程序得到的key就是相同,key是ftok()的返回值。
size是我规定的,创建共享内存的大小。
然后我开始写 读取数据的程序,我要求读取数据的程序先开辟共享内存,然后读取数据完毕后,删除共享内存。
此程序我命名为ybw.c。
#include"communite.h"
int main()
{
key_t key = ftok(PATH_name,my_ID);
//可以看到shmid的第二个参数我设置中加上了IPC_EXCL,保证了共享内存是新开辟的,后面的0666是共享内存设置的权限
int shmid = shmget(key,size,IPC_CREAT|IPC_EXCL|0666);
if(shmid<0)
{
perror("no shmid\n");
}
// 用mem接收shmat返回的共享内存地址,第二参数设置为null,要求操作系统自己找合适的地址空间进行挂接,最后一个参数是可读可写,不设限
char *mem =(char*)shmat(shmid,NULL,0);
printf("shmat success\n");
//开始通信
//读出共享内存中的数据
while(1)
{
sleep(1);
printf("%s\n",mem);
//写不到24个字母就结束
if(strlen(mem)>24)
break;
}
//解除挂机
shmdt(mem);
printf("shmde success\n");
// 删除共享内存,必须手动释放,否则就是内存泄漏
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
最后我需要完成向共享内存中写入数据,但是需要注意,还需要开辟共享内存吗?不需要,但是还得用shmget()函数,拿到共享内存的shmid,因为有了shmid才能完成挂接。挂接成功后,我们就能够向共享内存写入数据。
我将此程序命名为ly.c:
#include"communite.h"
int main()
{
//拿到同一个key
key_t key = ftok(PATH_name,my_ID);
if(key<0)
{
perror("no key");
return 1;
}
//注意shmget的第二个参数是IPC_CREAT,说明共享内存创建好了的话,会直接返回
int shimid = shmget(key,size,IPC_CREAT);
if(shimid<0)
{
perror("no shmget");
return 1;
}
// 用mem指针接收shmat返回的共享内存指针
char *mem = (char *)shmat(shimid,NULL,0);
printf("shmat success\n");
//开始通信
char c = 'A';
while(c<'Z')
{
mem[c-'A']=c;
c++;
//字符串末尾加 \0 没毛病
mem[c-'A']=0;
sleep(2);
}
//解除挂机
shmdt(mem);
printf("shmdt success\n");
//不需要删除共享内存了,让ybw去删吧
return 0;
}
以上其实就完成了,利用共享内存完成进程通信:
看看效果:
注意
:程序应该要手动的删除共享内存,否则会造成内存泄漏,假如上面的进程,我用信号 ctrl+c 干掉它,那么它的共享内存就没有释放,该怎么办呢?可以通过命令行进行删除:ipcrm -m
+shmid ,不知道shimid可以用指令
ipcs -m
查看一下。
比如:
所以只能用命令行,来释放共享内存了:
大家可能觉得很神奇:向共享内存中写,读;都没提供接口函数。是直接往里写,从里面读。就类似于malloc开辟的空间一样。所以共享内存,没有什么互斥,同步,全靠程序员自己去控制。但是共享内存是真的快。
这个选学内容,我们不要求用它来实现进程通信,有点落伍。但是我们还是简单的介绍一下它的原理和函数接口。
(1) 创建或打开消息队列
关于第二个参数 msgflg:
msgctl()的参数:第一个参数 msqid是msgget()的返回值,也就是消息对立的标识符;第二个参数 cmd是对消息队列要进行的控制操作;最后一个参数 buf是指向msgid_ds结构的指针,删除时设为NULL。
msgctl()的返回值:至于返回值,是根据cmd来变化的。但是控制失败返回的是 -1。
关于第二个参数cmd:
IPC_STAT:把msgid_ds结构中的数据设置为消息队列的当前关联值,即用消息队列的当前关联值覆盖msgid_ds的值。
IPC_SET:如果进程有足够的权限,就把消息列队的当前关联值设置为msgid_ds结构中给出的值
IPC_RMID:删除消息队列
(3) 向消息队列中写入
这两个接口,都可以向消息队列中写入。
不过向消息队列写入,必须要定义一个结构体,结构体中必须定义long 类型整型(消息类型)。
然后介绍一下msgsnd()函数:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg)
(4) 读取消息队列的内容
函数参数和返回值都一样,同样需要有一个结构体来接收消息。
之前讲的进程间通信,通信的内容都是数据;但是信号量的通信目的不是为了通信数据,而是通过共享资源,使得进程间通信达到同步和互斥的目的。
在认识信号量之前,我们先搞懂以下的几个概念:
信号量的本质:是一个计数器 count,用于衡量临界资源的资源数目,这是一种保护临界资源的原子性的手段。
我举个例子:坐出租车。
出租车上的座位(临界资源),可以被不同的人坐上去(对应不同的进程查看临界资源)。总共还有多少座位,用一个标号来控制(信号量)。如果标号大于0,证明还有座位,可以拉人,拉上人后标识符减一;如果标识==0,那么表示没有座位,只能等待;如果有人要离开,那么标识符+1。
对此,信号量就对临界资源做到了管理,有个疑问,座位必须有人座,才算临界资源被占用吗?不是,也可以预约,只要有人预定座位,那么信号量也要减一。
信号量通过管理临界资源的数目,确实是做到了保护临界资源的原子性,如果有个进程想对某个临界资源横插一脚,是做不到的,这个进程只能去等待,或者去瞅瞅别的空余的临界资源。
但是还有一个问题:信号量本身也是临界资源,它的原子性怎么保护?那就是它的count+1和count-1都是原子性的,这就是传说中的v() -> 信号量+1,p() -> 信号量-1操作,所以就是保证v()和p()的原子性,就保证了信号量的原子性。
其实管理共享内存,消息队列,信号量用的是一个数组。这不扯吗?管理它们的结构体都不一样,怎么能用一个数组统一管理呢?我们先来依次的看看管理它们的结构体。
消息队列:
注意到了吗?我标红的地方,也就是结构体的一个内容,都是一样的,包的都是同一个结构体
struct ipc_perm 。
se
查看一下:
通过这个东西,我就可以完成对上面三个结构体的统一管理,其实了解到这里,我不得感叹一下,设计出此内容的人,真是个小天才。
我可以搞一个结构体指针数组,每个数组元素都是ipc_perm的指针。那么我就可以通过强转的方式拿到共享内存,消息队列,信号量的结构体指针中的头部数据,这是一种切片技术。
如果想要通过这个结构体指针数组,看到具体的共享内存,消息队列,信号量的结构体,只需要再强转回去就可以了。这里有点难理解,我画个图。
我通过强转的方式,确实是可以让结构体指针指向下面三个结构体:
比如 -> ipc_perm[0] =(ipc_perm *) semid_ds。
那么如果我想要看到semid_ds的具体内容,怎么办?
只需要 -> (semid_ds*) ipc_perm[0] ,秒呀,再强转回去,真是骚操作。
上面也就解释了我们创建时,那些标识符0,1,2,3……;其实都是 ipc_perm 指针数组的下标罢了。
结尾语: 以上就是本篇内容,带大家了解进程间的通信,有问题的朋友可以私信或评论。觉得有帮助的,可以点个赞支持一下哦 !!!