目录
1.概述
每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的, 所以进程之间要通信必须通过内核来完成俩个进程或者多个进程之间的资源和信息的传递。
2.进程间通信方式
进程间通信方式总共有6种(管道通信、消息队列、共享内存、信号量、信号、套接字)。按照它们原理的不同可以分为三大类即:传统通信方式、IPC通信方式和网络通信三大类。
a、管道通信
管道是最简单,效率最差的一种通信方式。管道又可以分为无名管道和有名管道。
管道本质上就是内核中的一个缓存,当进程创建一个管道后,Linux会返回两个文件描述符,一个是写入端的描述符,一个是输出端的描述符,可以通过这两个描述符往管道写入或者读取数据。
如果想要实现两个进程通过管道来通信,则需要让创建管道的进程fork子进程,这样子进程们就拥有了父进程的文件描述符,这样子进程之间也就有了对同一管道的操作。
缺点:
半双工通信,一条管道只能一个进程写,一个进程读。
一个进程写完后,另一个进程才能读,反之同理。
无名管道:无名管道是实现亲缘间通信,属于半双工通信方式。类似于一个水管,只有两端,一个是数据流入段(写段),数据流出段(读段)。这两个段都是固定的端口,遵循数据的先进先出,数据拿出来后就消失。管道是有有限长度的64*1024(64K)个字节,无名管道,不在文件系统上体现,数据存在内存之上,进程结束后,数据就会丢失,管道文件不能使用lseek读写指针偏移。
函数接口:
头文件:#include <unistd.h>
原型:int pipe(int pipefd[2]);
功能:创建一个无名管道,会将读写端两个文件描述符分别封装到fd[0]和fd[1]
参数: fd[0] -----r
fd[1] -----w
返回值: 成功返回0; 失败返回-1;
特点:对于无名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork来复制父进程fd文件描述符,来达到通信的目的。
有名管道:克服了无名管道只能再亲缘间通信的限制。FIFO不同于管道指出在于它提供了一个路径名与之关联,以FIFO的文件形式存在于文件系统中。只要通过访问该路径,就能够彼此通过FIFO相互通信,即不相关的进程也能交换数据。
第一种方式:linux命令
mkfifo + 有名管道名字
第二种方式:c语言函数接口
头文件:#include <sys/types.h>
#include <sys/stat.h>
原型:int mkfifo(const char *pathname, mode_t mode);
功能:创建一个有名管道
参数:pathname:目标路径及名称
mode: 权限 例如:0666
返回值:
成功返回 0
失败返回 -1
特点:任意俩个进程通信。
管道的注意点:
1.如果管道中没有数据,read读取时会阻塞等待数据的到来。
2.管道符和先进先出的原则,数据读走后就会消失。
3.管道的大小是64K,管道写满以后再次进行写入会阻塞等待写入,防止有效数据丢失。
4.如果关闭了写入端口,读发生什么情况?
1>管道中有数据时,将里面的数据读出来
2>管道中无数据时,管道机制会认为写端关闭,不会再有数据到来,read在做读取时阻塞 没有任何用处,read将不会阻塞等待了,便不会影响进程运行。
5. 如果读端关闭,在进行写入会发生“管道破裂”
如果读端关闭,写入将没有任何意义了,并且每次调用write函数写入数据都被称为有效数据。如果写入会造成有效数据的丢失,所以在写入时会出现管道破裂的问题,结束进程。
b、消息队列
管道的通信方式效率是低下的,不适合进程间频繁的交换数据。消息队列的通信方式就解决了这个文图。A进程往消息队列写入数据后就可以正常返回,B进程需要时再去读取就可以了,效率比较高。
消息队列的本质就是由内核创建的用于存放消息的链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体 (数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
创建:
函数原型:
int msgget(key_t key, int msgflg);
key:消息队列的唯一id
生成key的方式有三种:
1)指定为IPC_PRIVATE宏
2)指定一个整数型
3)使用ftok函数生成key
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
返回值:
成功:返回消息队列标识符(msgid)
失败:返回-1,errno被设置
ftok函数原型:
key_t ftok(const char *pathname, int proj_id)
pathname:文件名
proj_id:ASCII码
ftok函数头文件:
#include<sys/types.h>
#include<sys/ipc.h>
通过msgctl函数删除
函数原型:
int msgctl(int msgid, int cmd, srtuct msgid_ds *buf);
msgid:消息队列标识符
cmd:控制选项
IPC_STAT:将消息队列读到buf里
IPC_SET:使用buf中新设置修改消息队列属性
IPC_RMID:删除消息队列(不用为NULL)
buf:存放属性信息(是否存在取决于cmd)
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
发送:
函数原型:
int msgsnd(int msqid,const void *msgp,size_t msgsz,int msgflg);
msgid:消息队列的标识符
msgp:存放消息的缓存地址类型struct msgbuf类型
msgsz:消息正文大小
nsgflg:-0:阻塞发消息
-IPC_NOWAIT:非阻塞方式发送消息
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
返回值:
成功:返回0
失败:返回-1,errno被设置
接受:
函数原型:
size_t msgrcv(int msqid,void *msgp,size_t msgsz,long msgtyp,int msgflg);
msgid:消息队列的标识符
msgp:存放消息的缓存地址类型struct msgbuf类型
msgsz:消息正文大小
msgtyp:接受信息的编号
nsgflg:-0:阻塞发消息
-IPC_NOWAIT:非阻塞方式发送消息
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
返回值:
成功:返回消息正文的字节数
失败:返回-1,errno被设置
消息队列特点:
1)传送有格式的信息流
2)多进程网状交叉通信时,消息队列是上上之选
3)能实现大规模数据的通信
缺点:
1)一是通信不及时
2)二是传输数据也有大小限制
3)消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列包含的全部消息体的总长度也是有上限。
4)消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一个进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
c、共享内存
消息队列存在着用户态与内核态之间消息拷贝的开销,那么共享内存就解决了这一问题。
共享内存原理:让同一块物理内存被映射到进程A、B各自的进程地址空间,进程A可以及时看到进程B对共享内存中数据的更新
共享内存的使用步骤:
1)进程调用shmget函数创建新的或获取已有共享内存
2)进程调用shmat函数,将物理内存映射到自己的进程空间
3)shmdt函数,取消映射
4)调用shmctl函数释放开辟的那片物理内存空间
1)创建:
函数原型:
int shmget(key_t key, size_t size, int shmflg);
参数1:用于生成共享内存的标识符(三种)
IPC_PRIVATE:指定这个后,每次调用shmget时都会创建一个新共享内存
自己指定一个长整型数
使用ftok函数,通过路径名和一个8位的整形数来生成key值
参数2:指定共享内存的大小,一般要求size是虚拟页大小的整数倍
参数3:指定原始权限和IPC_CREAT
函数功能:
创建新的,或者获取已有的共享内(与消息队列一样)
只有在创建一个新的共享内存时才会用到,否者不会用到
返回值:
成功:返回共享内存的标识符,以后续操作
失败:返回-1,errno被设置
2)删除:
a)重启OS
b)使用ipcrm命令删除:
ipcrm -M shmkey:移除用shmkey创建的共享内存段
ipcrm -m shmid:移除用shmid标识的共享内存段
c)int shmctl(int shmid, int cmd, struct shmid_ds *buf);
功能:根据cmd的要求,对共享内存进行相应控制。
参数1:标识符
参数2:控制选项
IPC_STAT:从内核获取共享内存属性信息到第三个参数,应用缓存
IPC_SET:修改共享内存的属性
IPC_RMID:删除共享内存,不过前提是只有当所有的映射取消后,才能删除共享内存。
参数3:一般为NULL
3)映射:
函数原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数1:共享内存标识符
参数2:定映射的起始地址(2种)
自己指定
NULL:内核决定
参数3:指定映射条件
0:可读可写
1SHM_RDONLY:以只读方式映射共享内存
函数功能:将shmid所指向的共享内存空间映射到进程空间(虚拟内存空间),并返回影射后的起始,地址(虚拟地址)。有了这个地址后,就可以通过这个地址对共享内存进行读写操作。
返回值:
成功:则返回映射地址
失败:返回(void *)-1,并且errno被设置
4)取消映射:
函数原型:
int shmdt(const void *shmaddr);
参数1:映射的起始地址(虚拟地址)
函数功能:取消建立的映射
返回值:
成功:返回0
失败:返回-1,errno被设置
共享内存特点:
1)减少了进入内核空间的次数
2)直接使用地址来读写缓存时,效率会更高,适用于大数据量的通信
d、信号量
上面的共享内存带来的问题,那就是如果多个进程同时修改同一个共享内存,很有可能将一个进程刚写的内容被另一个进程覆盖掉,也就是多进程竞争共享资源,从而造成数据错乱的问题。为了解决这个问题,我们就引入了信号量这个保护机制,让在共享的资源只能被一个进程访问。
信号量其实是一个整型的计数器的一个共享变量,进程在进行操作之前,会先检查这个变量的值,这变量的值就是一个标记,通过这个标记就可以知道可不可以操作,以实现互斥。主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作(不可被打断的操作称为原子操作):
- 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
- 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
信号量使用步骤:
1)进程调用semget函数创建新的信号量集合,或者获取已有的信号量集合
2)调用semctl函数给集合中的每个信号量设置初始值
3)调用semop函数,对集合中的信号量进行pv操作(加锁解锁)
P操作(加锁):对信号量的值进行-1,如果信号量的值为0,p操作就会阻塞
V操作(解锁):对信号量的值进行+1,V操作不存在阻塞的问题
4)调用semctl删除信号量集合
1)创建:
函数原型:
int semget(key_t key, int nsems, int semflg);
(sem就是semaphore的缩写)
参数1:设置同消息队列和共享内存(一般都使用ftok获取key值)
参数2:指定集合中信号量的个数(用于互斥时,数量都指定为1)
参数3:设置同消息队列和共享内存( 默认:0664|IPC_CREAT)
头文件:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
函数功能:根据key值创建新的、或者获取已有的信号量集合,并返回其标识符
实现互斥时:集合中只需要一个信号量
实现同步时:集合中需要多个信号量
返回值:
成功:返回0
失败:返回-1,errno被设置
2)删除:
a)重启OS
b)使用ipcrm命令删除:
ipcrm -S semkey:移除用semkey创建的信号
ipcrm -s semid:移除用semid标识的信号
c)int semctl(int semid, int semnum, int cmd, ...);
参数1:信号量标识符
参数2:集合中某个信号量的编号
参数3:控制选项
IPC_STAT:将信号量的属性信息从内核读到第四个参数所以指定的struct semid_ds缓存中
IPC_SET:修改属性信息,此时也会用到struct semid_ds结构体变量
IPC_RMID:删除信号量,当集合中所有的信号量都被删除后,信号量集合也就被删除了
SETVAL:通过第四个参数,给集合中semnu编号的信号量设置一个int初始值
返回值:
成功:返回非-1值
失败:返回-1,errno被设置
3)P/V操作:
函数原型:
int semop(int semid, struct sembuf *sops, unsigned nsops);
参数1:信号量集合的标识符
参数2:这个参数更好理解的写法是struct sembuf sops[]
参数3:指定数组元素个数的
e、信号
对于异常情况下的工作模式,就需要用信号的方式来通知进程。
在进程创建初期,会为进程创建一个信号函数表:
信号的处理方式:
1.忽略:指的是信号到来了,不采取热呢措施,如:SIGCHLD SIGCHLD:子进程结束后给父进程发送一个信号 SIGKILL和SIGSTOP不能被忽略
2.捕捉:指的是信号到来之前,将信号函数表中信号所对应的默认函数指针修改成指向自己定义的函数----为我所用 SIGKILL和SIGSTOP不能被捕捉
3.默认:指定的是信号到来之后,去执行进程创建初期信号函数表中的默认操作
原型:typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
头文件:#include <signal.h>
功能:注册一个信号函数,一般在进程刚开始的时候
参数:
signum:信号号
handler:信号的处理方式
1.忽略:SIG_IGN
2.默认:SIG_DFL
3.捕捉:指向自定义函数的指针,函数指针,指向一个返回值:void,参数:int类型的函数指针
signal函数会将该signum信号号和函数指针相绑定
返回值:
成功:返回一个函数指针指向上一次所执行的函数,保留下一个
失败:返回 SIG_ERR
原型:unsigned int alarm(unsigned int seconds);
头文件:#include <unistd.h>
功能:给自己发送一个闹钟信号 ----》SIGALRM 默认终止进程
参数:
定时seconds秒后发送信号
unsigned int ret = alarm(5); //5秒之后发送一个信号,代码正常向下执行
返回值:
成功返回上一次alarm剩余的秒数
0代表定时器时间到
注意:
如果调用alarm后再次调用alarm函数会刷新定时器的事件,打断了上一次alarm的定时,上一次的alarm不会再发送闹钟信号,会将上一次alarm剩余的描述返回回来。
f、套接字
管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
头文件:#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
原型:int socket(int domain, int type, int protocol);
功能:创建套接字,返回一个文件描述符
参数:
domain:通信域
AF_UNIX, AF_LOCAL Local communication unix(7) //本地通信
AF_INET IPv4 Internet protocols ip(7) //ipv4网络协议
AF_INET6 IPv6 Internet protocols ipv6(7) //ipv6网络协议
AF_IPX IPX - Novell protocols
AF_NETLINK Kernel user interface device netlink(7)
AF_X25 ITU-T X.25 / ISO-8208 protocol x25(7)
AF_AX25 Amateur radio AX.25 protocol
AF_ATMPVC Access to raw ATM PVCs
AF_APPLETALK AppleTalk ddp(7)
AF_PACKET Low level packet interface packet(7) //底层协议通信
AF_ALG Interface to kernel crypto API
type:套接字类型
SOCK_STREAM :流式套接字 --->tcp
SOCK_DGRAM : 数据报套接字 --->udp
SOCK_RAW : 原始套接字
protocol:附加协议,传0表示不需要其他协议
返回值:
成功:文件描述符
失败: -1
关于套接字,后面会在网络编程中具体去讲,这里只是简单提一下。有需要的小伙伴可以关注后续更新内容。
总结
由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间的。
Linux内核提供了不少进程间通信的方式,其中最简单的方式就时管道,管道分为无名管道和有名管道。
无名管道只能存在于亲缘关系的进程间通信,它随进程的创建而建立,随着进程的终止而消失。
为了突破亲缘关系这个限制就有了有名管道。有名管道除了可以在任意俩个进程间进行通信外,其他都和无名管道相同。它们都是进程写入的数据都是缓存在内核中,另一个进程读取数据时再从内核中去读取,遵循先进先出的原则,不支持lseek之类的文件定位操作。这样也就带来了弊端,管道通信的效率是低下的,不适合进程间频繁的交换数据,并且管道通信它的数据时无格式的字节流。
而为了解决这些问题,就有了消息队列,消息队列本质上就是保存在内核上的消息链表。消息队列的消息体时用户自定义的数据类型,在发送数据时,会被分成一个一个独立的消息体,这样接收到的消息和发送的消息保持数据类型一致,保证了读取的数据的正确性。而且因为时链表式存储,不存在先进先出的原则,可以做到随机读取。但是消息队列每次的数据的写入和读取都需要经过用户态和内核态之间的拷贝过程,其造成的开销非常大。
那么为了解决消息队列带来的开销问题,我们就引出了共享内存,共享内存就是将不同进程的虚拟地址空间映射到同一片物理地址空间上,每个进程都可以直接访问,省去了内核态和用户态之间的开销,大大提高了通信的速度。但是,它也带来了新的问题,就是多进程竞争同个共享资源会造成数据的错乱。
当然为了解决共享内存带来的问题,我们就引出了信号量。信号量的保护机制(互斥访问)来保护共享资源。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
信号与信号量名字很相似,它俩名字虽然相似,但机制却完全不一样。信号是异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL
和 SIGSTOP
,这是为了方便我们能在任何时候结束或停止某个进程。
如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。