目录
进程间通信概述
进程通信目的
进程间通信的发展
进程间通信分类
管道通信
无名管道
有名管道mkfifo()
信号
发送信号kill & raise
忽略信号signal()
发送信号alarm()
消息队列
消息队列使用的步骤
创建消息队列msgget()
读写消息队列msgrcv()/msgsnd()
删除消息队列msgctl()
共享内存
共享内存实现分为两个步骤
创建共享内存shmget()
映射共享内存shmat()
读写共享内存
删除共享内存shmctl()
信号量(semphore)/信号灯
信号量类型
信号量使用步骤
创建信号量semget()
初始化信号量semctl()
pv操作semop()/插拔钥匙
删除信号量semctl()
上节我们学习了进程控制,本节开始学习进程间的通信!
进程间通信概述
为什么要通信?
我们之前用fork产生了一个子进程后,子进程复制了父进程的地址空间,父子进程的地址空间彼此独立,是两个独立的地址空间,那如果父进程想要子进程传递数据怎么办?
这个时候我们就需要一种机制来完成进程间的通信。
进程通信目的
1.数据传输
一个进程需要将它的数据发送给另一个进程;
2.资源共享
多个进程之间共享同样的资源;
3.通知事件
一个进程需要向另一个或一组进程发送消息,通知它们发生了某种事件;
4.进程控制
有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有操作,并能够及时知道它的状态改变。(比如我们在代码中写了一个死循环,运行后我们想要结束这个进程,我们就用CTRL+C来结束进程,这就是通过信号来完成控制的)
进程间通信的发展
Linux进程间通信(IPC)由以下几部分发展而来:
1.UNIX进程间通信(Linux系统由UNIX发展而来)
2.基于System V进程间通信(System V是Linux的第四个版本,一直延续到今天还在用)
3.POSIX进程间通信(POSIX比较新的标准)
POSIX(Portable Operating System Interface)表示可移植操作系统接口。
电气和电子工程师协会(IEEE)最初开发 POSIX 标准,是为了提高 UNIX 环境下应用程序的可移植性。然而,POSIX 并不局限于 UNIX,许多其它的操作系统,例如 DEC OpenVMS 和 Microsoft Windows,都支持 POSIX 标准
进程间通信分类
现在Linux使用的进程间通信方式包括:
1、管道(pipe)和有名管道(FIFO)
2、信号(signal)
3、消息队列(message queue)
4、共享内存(share memory)
5、信号量(semphore)
6、套接字(socket)(这个是网络编程里面的东西,两台电脑之间的通信,之后再讲)
管道通信
管道是单向的、先进先出的,它把一个进程的输出和另一个进程的输入连接在一起。
一个进程(写进程)在管道的尾部写入数据,另一个进程(读进程)从管道的头部读出数据。
数据被一个进程读出后,将被从管道中删除,其它读进程将不能再读到这些数据(即写入的数据被读完后,数据不会遗留在管道中)。
管道提供了简单的流控制机制,进程试图读空管道时,进程将阻塞。同样,管道已经满时,进程再试图向管道写入数据,进程将阻塞。
管道包括无名管道和有名管道两种,前者用于父进程和子/孙进程间(有“血缘关系”的进程之间)的通信,后者可用于运行于同一系统中的任意两个进程间的通信。
无名管道
无名管道其实也是一个文件,只不过取的名字叫无名管道。
无名管道用ls这个命令是看不到的,有名管道用ls这个命令是可以显示出来的。
无名管道创建pipe()
int pipe(int filedis[2]);
当一个管道建立时,它会创建两个文件描述符,放在一个数组里面:
filedis[0] 用于读管道,
filedis[1] 用于写管道
到底是父进程读/写还是子进程读/写,这不是最重要的,反正读的那个进程必须是通过fd[0]去读出来的,写的那个进程必须是通过fd[1]写的。
无名管道用于不同进程间通信。通常先创建一个管道,再通过fork函数创建一个子进程,该子进程会继承父进程所创建的管道。
必须在系统调用fork( )前调用pipe( ),否则子进程将不会继承文件描述符。
写个无名管道的代码测试一下:
创建目录和文件
pipe的头文件和原型,参数是一个数组
返回值是成功返回0,失败返回-1
注:read是阻塞函数,如果管道为空,则程序停在这,直到有数据可读
write也是阻塞函数,如果管道已满,write阻塞,程序停在这。
因此我们不用担心这段测试的代码中父子进程的运行顺序。
有名管道mkfifo()
命名管道(有名管道)和无名管道基本相同,但也有不同点:
无名管道只能由父子进程使用;
但是通过命名管道,不相关的进程也能交换数据。
mkfifo的原型和头文件
注:一旦创建了一个FIFO(First In First Out ),就可用open打开它,一般的文件访问函数(close、read、write等)都可用于FIFO
mkfifo第一个头文件是文件名(路径),第二个参数和open的mode参数含义是一样的,就是指定文件的权限
返回值是成功返回0,失败返回-1
代码演示:
分为两个文件写,一个文件写,一个文件读,只需要一个进程中创建管道即可
当我们创建了有名管道之后,输入ls的确能到一个有名管道,并且还标记了文件属性是p表示管道的意思
两个进程运行后,写数据的进程写什么,读数据的进程就能读出来什么,这里的读和写这两个进程是没有“血缘关系”的两个进程
这个代码有个瑕疵就是我们结束两个进程后,那个有名管道还在,每次我们要启动这两个进程时,我们都要手动删除这个管道,这样太麻烦了,我们可以在创建有名管道的那个进程的代码中(读数据的那个进程),用unlink来删除管道
这样我们每次结束进程时,这个管道也就随之消失了。
Unlink这个函数的参数只要加上文件名就可以了,没有加路径就是默认删除当前目录下的某个文件。
下面再介绍一种机制,叫信号机制
信号
信号(signal)机制是Unix系统中最为古老的进程间通信机制,很多条件可以产生一个信号:
1、当用户按某些按键时,产生信号(比如CTRL+C结束进程这个就是一种信号);
2、硬件异常产生信号:除数为0、无效的存储访问等等。这些情况通常由硬件检测到,将其通知内核,然后内核产生适当的信号通知进程,例如,内核对正访问一个无效存储区的进程产生一个SIGSEGV信号;(常见为段错误)
3、进程用kill函数将信号发送给另一个进程;
4、用户可用kill命令将信号发送给其他进程。
补充命令25:kill -l
这行命令可以查看Linux中的信号的宏定义名称以及它的编号
以上前面的31个信号是我们经常使用的
几种常见的信号
下面是几种常见的信号:
SIGHUP: 从终端上发出的结束信号
SIGINT: 来自键盘的中断信号(Ctrl-C)
SIGKILL:该信号结束接收信号的进程
SIGTERM:kill 命令发出的信号
SIGCHLD:标识子进程停止或结束的信号
SIGSTOP:来自键盘(Ctrl-Z)或调试程序的停止执行信号
发送信号kill & raise
发送信号的主要函数有 kill和raise。
区别:
Kill既可以向自身发送信号,也可以向其他进程发送信号。
与kill函数不同的是,raise函数是向进程自身发送信号。
注:kill既是命令也是函数
Kill函数的原型和头文件:
kill的第一个参数是向谁发送信号(填进程号),第二个参数是发送什么信号(填信号的编号或者直接填信号的宏定义名字)
代码演示
按理这个程序中下面的while循环应该是跑不起来的,因为kill已经发送了结束进程的信号给改进程,所以while这句并没有执行
果然刚执行就结束了,因为它自己给自己发了一个CTRL+C
raise函数的原型和头文件
参数只有一个:发送什么信号
效果是一样的
一般CTRL+C这个信号可以结束很多进程,但是有个命令叫passwd(默认改变root用户的密码)
当我们敲下passwd这个命令后,就进入了输密码的状态,这个时候如果我们想结束这个进程的话,按下CTRL+C是不能结束的,或者用kill -2这个命令也不能结束掉它,我们只有用kill -9 进程号,这个命令才可以结束掉它了
所以很多进程是会忽略CTRL+C这个信号的
忽略信号signal()
比如我们写一段这样的代码,这段代码是可以忽略CTRL+C这个信号的
我们要用到signal这个函数
(注:在Linux中很多_t类型变量都可以看成是32位整数来看待,但是这里的sighandler_t不是32位整数)
在一开始这个函数就声明了sighandler_t是一个函数指针:*表示sighandler_t是一个指针,指针指向一个函数,这个函数有个int类型的参数,这个函数没有返回值。
也就是说signal这个函数的第二个参数要提供一个函数名才行,因为函数名是一个函数的地址。
Signal这个函数的作用就是告诉系统,当它收到signum信号的编号的时候会自动调用handler这个函数,handler可以是SIG_IGN(忽略的意思)或者SIG_DFL(默认的意思,默认“死掉”)
代码演示
这段代码忽略了SIGINT这个信号,进入了死循环,我们按CTRL+C是结束不了它的
同样,我们只有用kill -9 进程号,这个命令才可以结束掉它了
那我们能不能让它忽略kill -9 这个命令呢?
结果是它不能忽略kill -9这个信号
在Linux上有两个信号是不能被忽略的:SIGKILL和SIGSTOP
signal的第二个参数可以是调用一个函数,这个函数有个int类型的参数,这个函数没有返回值
代码演示
结果给这个进程发送编号为1的信号时,它就打印get 1
发送信号函数除了Kill和raise外,还有一个alarm
发送信号alarm()
Alarm函数的原型
它是一个闹钟函数,参数只给它传一个数字
运行后效果就是每2s就打印一次get 14
这个alarm我们以后可以拿来模拟定时器。
消息队列
消息队列解决了前几种通信机制的一些缺陷,之前无名管道和有名管道只能传送简单的字节流,不能加上格式。
假设现在有两个进程在读管道中的数据,那就只能是谁先读管道,就先被谁读去,这个控制不了。
如果A进程只想给其中一个指定的进程发,用管道的是不好操作的。
如果用信号那个机制做的话传递的数据太有限了,信号一般用于一些控制,如果想要通过信号传递数据的话,就不太好操作。
于是我们需要第四种通信机制:消息队列
消息队列是用队列来实现的,它的一个好处就是可以指定格式,假设现在有两个进程要读管道中的数据,A进程发送数据
比如A进程要发送Helloworld,那它不仅要发“Helloworld”这个字符串,还要在字符串的前面或者后面跟上数字,这个数字代表类型,比如“Helloworld 1”就是表示Helloworld的类型是1,这个时候B或者C进程去读,任意进程在读取消息队列的时候都要指定类型,例如B进程读取的类型只能是1,也就是说只有类型为1的数据B才能读,C读取的类型为2
有可能B和C同时去读,但是C一看这个数据的类型是1,就满足它的要求就不读了,B要求的类型是1就把它读出来了。
那我们需要一个结构体来存放数据和这个类型,结构体的定义在man手册中msgsnd函数的描述中已经定义好了,直接复制过来修改一下就行。
消息队列使用的步骤
1、创建(获取)消息队列,使用函数 msgget();(比如ABC三个进程只要有一个进程创建,其余进程获取就行)
2、读写消息队列,使用函数 msgrcv()/msgsnd();
3、删除消息队列,使用函数 msgctl()。
创建消息队列msgget()
msgget();的原型和头文件
第一个参数Key的作用是区别消息队列,内存可能会有很多消息队列,区别消息队列就需要key,这个key可以自己定义一个。
第二个参数是消息队列的属性,如果这个消息队列不存在就创建,如果存在就提示
返回值如果成功就返回消息队列的标识,如果失败就返回-1
读写消息队列msgrcv()/msgsnd()
msgsnd函数的原型和头文件
第一个参数是消息队列的标识,也就是msgget();的返回值,第二个参数是一个指针,指针要发送的数据(这些数据放在一个结构体里面)
我们直接把这个结构体复制进我们的代码中。
第三个参数是msgsz消息的大小,第四个参数是消息的属性,一般写成0不需要指定
返回值失败返回-1,
msgrcv函数的原型和头文件
前三个参数和第五个参数msgsnd的一样,第四个参数是接收的消息类型
代码演示:
运行结果
当我们再次运行的时候它提示这个文件已存在,但是ls后并没有看到这个文件,因为它是内存里面的东西,并不是以文件的形式存在的
我们可以通过这个命令看看
补充命令26:ipcs
这行命令能查到三种进程通信之间的方式:消息队列、共享内存、信号量(其中共享内存和信号量之后会讲)
我们可以看到这里的消息队列的KEY是0x000003e8 换成十进制就是我们刚刚宏定义的1000,如果某天我们内存中有很多消息队列,就是可以通过这个KEY来区分的。
补充命令27:ipcs -q/ipcs -m/ipcs -s
ipcs -q这行命令可以只查询消息队列,ipcs -m只查询共享内存,ipcs -s可以单独查询信号量
如果我们要删除这个消息队列,就可以使用这个命令:
补充命令28:ipcrm -q 消息队列的标识msqid
这行命令可以删除掉指定的消息队列
这样我们就删除掉了刚刚创建的消息队列
ipcrm -m 共享内存的标识shmid
这个可以删除共享内存
ipcrm -s信号量的标识semid
这个可以删除信号量
但是每次都要手动去删,我们可以修改一下代码,让它随进程的结束而消失
删除消息队列msgctl()
删除消息队列要用到这个函数msgctl
第一个参数是msgid,第二个参数是控制的属性,其中删除是IPC_RMID
第三个参数对于删除来说,直接写NULL就行
我们可以在发送消息的那份代码中加上这句
运行结束后就不需要我们手动删除消息队列了
共享内存
共享内存这种通信方式被称为是效率最高的一种,因为它是直接读取内存的,也就是内存到内存。之前我们讲的管道和消息队列都是通过访问内核中的队列或者管道来实现两个进程之间的通信的。
共享内存就是两个进程之间有块共享内存(物理层面),但是它们不能直接访问这个共享内存,所以就把共享内存映射到各自的虚拟内存里面的一块空间里面,之后这块虚拟内存空间里面有什么数据,共享内存中就有什么数据
结果就是这三块内存就是连通的,因此这种方式就是效率最高的进程之间通信的方式。
共享内存实现分为两个步骤
一、创建共享内存,使用shmget函数;
二、映射共享内存,将这段创建的共享内存映射到具体的进程空间去,使用shmat函数;
三、读写共享内存(当做指针使用);
四、解除映射,使用shmdt函数;
五、删除共享内存,使用shmctl函数。
创建共享内存shmget()
第一个参数和消息队列的KEY差不多,因为内存中也可能有很多共享内存,如果区分就是通过这个KEY,通过自己宏定义
第二个参数是要创建共享内存的大小,大小是以页为单位的,一页是4096字节,就算实际使用的不到一页,它也会分配一页的大小,大概就是4K
第三个参数和创建消息队列的msgget的第二个参数差不多,可以是IPC_CREAT and IPC_EXCL
返回值是shmid,相当于是一个文件描述符一样的东西,失败返回-1
映射共享内存shmat()
第一个参数就是shmget函数的返回值shmid,第二个参数是一个地址,就是可以自己选一块内存去映射,但是我们并不知道哪块内存是空闲的,所以一般直接写NULL,就是让系统自己去找一块空闲的内存去映射。第三个参数我们直接写成0就行了,不需要什么属性
返回值就是映射的这块内存空间的地址,出错返回void *类型的-1
读写共享内存
代码演示
运行之前先补充个命令
补充命令29:./编译好的二进制文件名1 & ./编译好的二进制文件名12
在命令里面&这个符号相当于是让前面这个进程1在后台运行,不占用终端,然后接着再运行后面的进程2,
但是有可能进程2先运行,所以我们最好在代码中让进程2先睡眠一会儿,比如
运行结果
......
如果我们想要再次运行,它会提示我们这个文件已存在,我们可以用ipcs -m找出内存中的共享内存
这个时候会出现很多共享内存,我们用过我们定义的KEY 1000来找到我们刚刚创建的共享内存,1000换算成十六进制就是0x000003e8这个
输入ipcrm -m shmid就把它删掉了
删除共享内存shmctl()
每次这样比较麻烦,我们可以在代码中加入这段
这样共享内存就会随着进程的结束而消失
下面我们把两份文件中的睡眠挪到这里
这样运行后现象就不一样了
......
可以发现两个进程打印的数字是一样的,每次数字都被打印了两遍,这和我们之前提到过进程同步是有关系的。
一开始是进程B先进去把num修改了后退出,A再进去,但是我们把代码改了后,就变成进程B先进去把num打印出来了,之后减1等于99,然后睡眠100ms,没来得及写回去,这时B已经进来了,此时B看到的num还是100,所以每个数字都被访问了两遍。
因此在多进程的程序里面必须要进程同步,当多个进程去访问同一个共享数据的时候,必须加上进程同步,接下来就会讲这个。
信号量(semphore)/信号灯
我们上面刚刚说了当多个进程去访问同一个共享数据的时候,必须加上进程同步。
怎么做呢?
我们可以在这个共享空间加一道门锁,当A进程的访问数据的时候就把钥匙先拔了,只要A 还没有出来,B进程就进不去,这样就能达到一个数据不被多次访问的效果。
进程同步有一种机制就叫信号量
信号量(又名:信号灯)与其他进程间通信方式不大相同,主要用途是保护临界资源。
进程可以根据它判定是否能够访问某些共享资源。除了用于访问控制外,还可用于进程同步。
信号量类型
二值信号灯:信号灯的值只能取0或1(要么有钥匙,要么没有钥匙,A进去之前有钥匙,状态是1,进去之后把钥匙拔了,状态就是0,B就进不去了),类似于互斥锁。 但两者有不同:信号灯强调共享资源,只要共享资源可用,其他进程同样可以修改信号灯的值;互斥锁更强调进程,占用资源的进程使用完资源后,必须由进程本身来解锁。
计数信号灯:信号灯的值可以取任意非负值。(有很多把钥匙,状态有很多种)
我们创建信号量的时候会有信号量集,因为有些时候有多个进程想要访问多个临界资源,于是我们需要多个信号量,也就是信号量集,在信号量集中有多个信号量,第一个信号量的下标是从0开始的。
信号量使用步骤
1、获取(创建)信号量,使用函数semget();只要一个进程创建就可以了
2、初始化信号量(二值信号量或者计数信号量),使用函数 semctl();只要一个进程初始化就可以了
3、pv操作,使用函数semop();对应的是拔钥匙P和插钥匙V的过程
4、删除信号量,使用函数semctl()。
代码演示:
创建信号量semget()
第一个参数KEY(区分信号量),第二个参数是要创建信号量的个数,第三个参数是属性和shmget的第三个参数一样
返回值是一个id,类似于文件描述符,出错返回-1
初始化信号量semctl()
第一个参数是semget()的返回值semid,第二个参数是要初始化第几个信号量,第三个参数可以选择这个设置值的意思
最后是可变参数,是一个联合体,可以直接复制这个联合体在代码中定义,然后把初始化的时候把初值放在这个联合体里面,这里面有很多成员,我们只需要用到第一个
返回值就是失败返回-1
pv操作semop()/插拔钥匙
第一个参数是semid,
第二个参数是一个结构体指针,指向的是一个叫sembuf的结构体,这个结构体里面有很多成员,我们只要使用到下面提出的这三个,我们只要在代码中定义一个结构体变量,然后访问这个三个成员即可,因为这个结构体已经在头文件中了,不需要我们定义了。第一个成员是第几个信号量,第二成员是加1/减1的那个操作,p操作是拔钥匙是-1,v操作是插钥匙是+1
第三个参数可以理解为集合里面有多少个信号量,直接写成1
返回值是失败返回-1
删除信号量semctl()
代码演示:
sem1.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>#define SHMKEY 1000 //区分共享内存的KEY
#define SHMSIZE 4096 //共享内存的大小
#define SEMKEY 1000 //区分信号量union semun
{int val; /* Value for SETVAL */struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */unsigned short *array; /* Array for GETALL, SETALL */struct seminfo *__buf; /* Buffer for IPC_INFO(Linux specific) */
};void sem_p(int id)
{struct sembuf s;//定义结构体变量s.sem_num=0;//表示第0个信号量s.sem_op=-1;//原始状态的1加上-1就变成了0s.sem_flg=SEM_UNDO;//如果进程异常退出,信号量会恢复初值if(semop(id,&s,1)==-1)//1表示多少个信号量{perror("semop");}
}void sem_v(int id)
{struct sembuf s;//定义结构体变量s.sem_num=0;//表示第0个信号量s.sem_op=1;//-1加上1就变成了0s.sem_flg=SEM_UNDO;//如果进程异常退出,信号量会恢复初值if(semop(id,&s,1)==-1)//1表示多少个信号量{perror("semop");}
}int main()
{//创建共享内存int shmid=shmget(SHMKEY,SHMSIZE,IPC_CREAT|IPC_EXCL);if(-1==shmid){perror("shmget");exit(1);}//映射void*addr=shmat(shmid,NULL,0);//NULL就是让系统去找一块空闲的内存if((void*)-1==addr){perror("shmat");exit(2);}//创建信号量int semid=semget(SEMKEY,1,IPC_CREAT|IPC_EXCL);//1表示创建1个信号量if(-1==semid){perror("semget");exit(3);}//初始化信号量union semun s;//定义联合体s.val=1;//二值信号量,锁的状态值if(semctl(semid,0,SETVAL,s)==-1)//0表示第0个变量{perror("semctl");exit(4);}//写入共享内存int num=100;*(int*)addr=num;//强转成int*类型再取值*while(1){ //减1操作,拔钥匙sem_p(semid);//操作数据num=*(int*)addr;//取出来放num里面if(num<=0)//直到num<=0才结束操作{sem_v(semid);//退出之前插回钥匙break;}printf("%d get %d\n",getpid(),num);num--;usleep(100000);//睡眠100ms//操作完后再写回去*(int*)addr=num;//加1操作,插钥匙sem_v(semid);}usleep(500000);//等另一个进程结束后再删除//删除共享内存shmctl(shmid,IPC_RMID,NULL);//删除信号量semctl(semid,0,IPC_RMID);//第0个信号量return 0;
}
sem2.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>#define SHMKEY 1000 //区分共享内存的KEY
#define SHMSIZE 4096 //共享内存的大小
#define SEMKEY 1000 //区分信号量union semun
{int val; /* Value for SETVAL */struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */unsigned short *array; /* Array for GETALL, SETALL */struct seminfo *__buf; /* Buffer for IPC_INFO(Linux specific) */
};void sem_p(int id)
{struct sembuf s;//定义结构体变量s.sem_num=0;//表示第0个信号量s.sem_op=-1;//原始状态的1加上-1就变成了0s.sem_flg=SEM_UNDO;//如果进程异常退出,信号量会恢复初值if(semop(id,&s,1)==-1)//1表示多少个信号量{perror("semop");}
}void sem_v(int id)
{struct sembuf s;//定义结构体变量s.sem_num=0;//表示第0个信号量s.sem_op=1;//-1加上1就变成了0s.sem_flg=SEM_UNDO;//如果进程异常退出,信号量会恢复初值if(semop(id,&s,1)==-1)//1表示多少个信号量{perror("semop");}
}int main()
{usleep(100000);//获取共享内存int shmid=shmget(SHMKEY,SHMSIZE,0);if(-1==shmid){perror("shmget");exit(1);}//映射void*addr=shmat(shmid,NULL,0);//NULL就是让系统去找一块空闲的内存if((void*)-1==addr){perror("shmat");exit(2);}//获取信号量int semid=semget(SEMKEY,1,0);//1表示1个信号量if(-1==semid){perror("semget");exit(3);}int num=0;while(1){ //减1操作,拔钥匙sem_p(semid);//操作数据num=*(int*)addr;//取出来放num里面if(num<=0)//直到num<=0才结束操作{sem_v(semid);//退出之前插回钥匙break;}printf("%d get %d\n",getpid(),num);num--;usleep(100000);//睡眠100ms//操作完后再写回去*(int*)addr=num;//加1操作,插钥匙sem_v(semid);}return 0;
}
运行结果
这样就不会是一个数字被访问两次了
......
因此进程同步要加上信号量,之后线程同步也会有自己的机制
下节开始学习多线程编程!
本篇就到这里,下篇继续!欢迎点击下方订阅本专栏↓↓↓