进程间通信的各种方法以及优缺点
进程间通讯概念
- 进程是一个独立的资源分配单位,不同的进程(主要指用户进程)之间的资源是独立的,没有关联,无法在一个进程中直接访问另一个进程的资源
- 但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等等,因此需要进程间通信(IPC,Inter-Process Communication),
- 进程间通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 通知事件:一个进程需要向另外一个或者一组进程发送信息,通知它(它们)发生了某种事件
- 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供
互斥
和同步
机制 - 进程控制:有些进程希望能完全控制另外一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程
陷入异常
,并能够即使知道它的状态改变
- 进程间虽然用户地址空间是独立的,但是它们共享一个内核空间,所以进程想要通信就只能通过内核
Linux下进程间的通信方式
同一主机下的进程间通信方式
- Unix进程间通信方式
- 管道
- 信号
- System V进程间通信方式 和 POSTX进程间通信方式
- 消息队列
- 共享内存
- 信号量
不同主机(网络)进程间通信方式
Socket(套接字)
管道
管道又分为匿名管道和有名管道,管道是UNIX系统中IPC(进程通信)的最古老的形式,所有UNIX都支持这种通信机制
1 | ls | wc -l |
管道的特点
- 管道是一个在内核内存中维护的缓冲区,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同
- 在Linux下,可以说一切皆是文件,其实内核内存中的这个缓冲区也是一个文件,那么可以操作文件的API,同样也能操控缓冲区
- 一个管道是一个字节流,使用管理时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小为多少
- 通过管道传递的数据时顺序的,从管道中读取出来的字节的顺序和写入的顺序时完全一样的,就像是队列一样,先进先出
- 从管道读取数据是一次性操作,只要读取完,那么读取的数据就会被抛弃而不会留在缓冲区以便有更多的空间来写数据,所以无法使用lseek()函数来随机访问数据
- 匿名管道只能在具有公共祖先进程的时候(例如:父进程与子进程通信,两个兄弟进程通信)才能使用,有名管道用于无亲缘关系的进程
管道的数据结构一般都是:环形队列或者循环队列
匿名管道
匿名管道也叫无名管道,大部分情况下,我们说的管道都是指匿名管道
为什么匿名管道能够通信
当我们对一个进程使用fork()创建一个子进程的时候,子进程会复制一份父进程的内核区,不过其中的进程号是不一样的,同时复制的还有文件描述符表
,那么子进程的文件描述符表上也就会有关于管道读写端的记录,这样就能操作同一个管道来进行通信了,这也就意味着,必须要采用fork()函数之前先创建好匿名管道
匿名管道的使用
- 创建管道
1
2
3
4
5
6
7
int pipefd[2];
int pipe(int pipefd[2]);
参数:
pipefd[2]:传出参数,函数调用成功后,pipefd[0]代表读端,pipefd[1]代表写端,都是文件描述符
返回值:
成功返回0,失败返回-1 - 查看管道缓冲大小命令
1
2ulimit -p;
ulimit -p 想要设置的大小(Kbytes) - 查看管道缓冲区大小函数
1
2
3
4
5
long fpathconf(int fd,int name)
参数:
int fd:管道的文件描述符
int name:宏值,通过在终端上输入"man 2 fpathconf"查看 - `如果管道是空的,read函数会阻塞;如果管道是满的,write函数会阻塞
匿名管道的读写特点和如何将管道设置为非阻塞
- 使用管道时,需要注意的点:
- 当多有指向管道写端的文件描述符都关闭了(管道写端引用计数为0),有进程从管道的读端读取数据,那么当该进程把管道里面剩余的数据读取完后,再次 read就会返回0,就像如到文件末尾一样
- 如果有指向管道写段的文件描述符没关闭(管道写段引用计数不为0),而持有管道写端的进程也而没有往里面写数据,当管道的数据被另外一个进程读取完后,再次read时,管道就会变成阻塞状态,直到有进程往管道里面写数据才可以再次返回读取数据
- 如果有指向管道读端的文件描述符都关闭了(管道读端引用计数为0),有进程从管道的写端写入数据时,那么该进程就会收到一个SIGPIPE信号,通常会导致进程终止
- 如果有指向管道读端的文件描述符没有关闭(管道读端引用计数不为0),而持有管道读端的进程也没有进行读数据操作,当有别的进程往管道里写入数据时,若管道已经被写满的话,此时管道就会变为阻塞状态直到管道读端读取数据,若没写满的话就还是能继续写入的
- 如何将管道设置为非阻塞
1
int fcntl(int fd,int cmd,...)
例如:将管道的读端设置为非阻塞
- 先获取原本管道读端的flags
int flags=fcntl(fd[0],F_GETFL) - 修改flags
flags|=NONBLOCK(非阻塞) - 设置修改后的flags
fcntl(fd[0],F_SETFL,flags)
- 先获取原本管道读端的flags
有名管道
- 有名管道也叫做命名管道、FIFO文件
- 命名管道(FIFO)不同于匿名之处在于提供了一个路径与之关联,以FIFO的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与FIFO的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能彼此通过FIFO相互通信,因此,通过FIFO不想管的进程也能交换数据
- 一旦打开了FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的I/O系统调用API(如read,write,close)。与管道一样,FIFO也有一个写入端和读取端,并且从管道读取数据的顺序与写入顺序是一样的,FIFO(先进先出)名称也由此而来
与匿名管道的不同
- 有名管道能用于无亲缘关系的进程,匿名管道不行
- FIFO在文件系统中作为一个特殊文件存在,但FIFO中的内容却存放在内存中
- 当使用FIFO的进程退出后,FIFO文件将继续保存在文件系统中以便以后调用
- FIFO有名字,所以不相关的进程可以通过名字打开有名管道进行通信
有名管道的使用
- 通过命令创建有名管道
1
mkfifo 名字
- 通过函数创建有名管道
1
2
3
4
5
6
int mkfifo(const char *pathname,mode_t mode)
参数:
*pathname:管道名称的路径,可以是绝对路径也可以是相对路径
mode:文件权限,和open()函数里面的mode是一样的 - 常见的I/O函数都可以用于FIFO
有名管道的注意事项
- 一个为只读而打开一个管道的进程会阻塞,直到另外一个进程为只写打开管道
- 读管道读数据:
- 管道写端全部关闭:
- 管道无数据,read会返回0(相当于读到文件末尾)
- 管道有数据,先将管道剩余数据读取完,再次read会返回0(相当于读到文件末尾)
- 管道写端没有全部关闭
- 管道无数据,写入数据将管道塞满
- 管道有数据,写入数据将管道塞满
- 管道写端全部关闭:
- 写管道写数据:
- 读端全部关闭,收到一个SIGPIPE信号,进程异常终止
- 读端没有全部关闭:
- 管道已经满了,write()会阻塞
- 管道没有满,write()将数据写入
- 一个进程只能以一种方式打开有名管道(即要么以只读的方式打开FIFO,要么以只写的方式打开FIFO),因为如果以又能读又能写的方式打开FIFO的话,该进程就会出现自己读取到自己写入的数据,并且因为被读取完的数据会被丢弃,那样想要通信的另外一个进程就无法获得消息
管道的优势和劣势
优势
简单,我们容易得知管道里的数据已经被另外一个进程读取
劣势
通信效率低下
消息队列
消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息这种模型,就好像两个进程发邮件一样,你来一封,我来一封,可以频繁沟通
消息队列的优势和劣势
优势
相对于管道,消息队列交换数据的效率明显提高了
劣势
- 通信不及时
- 附件大小也有限制
内存映射
内存映射(Memory-mapped I/O)是将磁盘文件的数据映射到内存,用于通过修改内存就能修改磁盘文件
内存映射的一些API
- 映射一个文件到内存中,实现文件物理地址和进程虚拟地址的一一对应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void *mmap(void *addr,size_t length,int prot,int flags,int fd,off_t offset)
参数:
*addr: NULL,由内核指定
length: 要映射的数据的长度,这个长度不能为0,建议使用文件的长度(可以通过 stat()/lseek() 函数获取)
prot:对申请内存映射区的操作权限
PROT_EXEC:可执行权限 PROT_READ:可读权限 PROT_WRITE:写权限 PORT_NONE:没有权限
注意:要操作内存,就必须要有读权限
flags:
MAP_SHARED:映射区的数据会自动和磁盘文件进行同步,若要进程间通信,必须要设置这个选项
MAP_PRIVATE:不同步,会重新创建一个新的文件
fd:需要映射的文件的文件描述符
注意:文件大小不能为0,也就是length不能为0,open()指定的权限不能与prot有冲突
prot指定的权限必须小于open()指定的权限,并且两个都需要有读的权限
offset:偏移量,一般不用,要用的话必须是4K的整数倍
返回值:
成功返回创建的内存的首地址,失败则返回MAP_FAILED - 释放内存映射
1
2
3
4
5
6
7
int munmap(void *addr,size_t length)
参数:
*addr:要释放的内存的首地址,可以通过mmap函数获取
length:要释放的内存的大小,和mmap函数的length参数的值一样
返回值:
成功返回0,失败返回-1
信号
后面会专门写一篇文章来讲信号的,这里就先不多说了
共享内存
消息队列的读写都发生了用户态到内核态的转变,过多的转变其实也很占用CPU资源,为了减少这种转变,共享内存就很好的解决了这一问题
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
共享内存是效率最高的通信方式。
共享内存使用步骤
步骤
- 使用shmget()函数创建一个新的共享内存段或者区的一个已有的共享内存段的标识符(就是说有其他进程创建的共享内存段),这个调用将返回后续调用需要用到的共享内存标识符
- 使用shmat()函数来附上共享内存段,即使该段称为调用进程的虚拟内存的一部分
- 此时在程序中就可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由shmat()函数的返回值的addr值(一个指向进程的虚拟地址空间中该共享内存段的起点的指针)
- shmdt()函数用来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存
- 调用shmctl()来删除共享内存段。只有当所有附加内存段的进程都与之分离之后内存段可会销毁,只有一个进程需要执行这一步
共享内存相关API
- 创建一个新的共享内存(里面的数据都会被初始化为0),或者获取一个既有的共享内存的标识
1
2
3
4
5
6
7
8
9
10
11
12
int shmget(key_t key, size_t size, int shmflg)
参数:
key_t key:key_t类型是一个整形,通过这个找到或者创建一个共享内存,一般使用16进制并且不为0
size: 共享内存的大小
shmflg:属性
1.访问权限
2.附加权限 创建共享内存(IPC_CREAT)
判断共享内存是否存在,不存在就创建(IPC_EXCL,需要和IPC_CREAT一起使用)
返回值:
成功就返回共享内存的引用ID,后面操作共享内存都是通过这个值
失败就返回-1 - 和当前的进程进行关联
1
2
3
4
5
6
7
8
9
10void *shmat(int shmid,const void *shmaddr,int shmflg)
参数:
shmid:共享内存的标识(ID),由shmger返回值获取
shmaddr:申请的共享内存的其实地址,一般指定NULL,由内核来指定
shmflg:对共享内存的操作
读: SHM_REONLY,必须要有读权限
读写: 0
返回值:
成功返回共享内存的起始地址
失败返回-1 - 解除当前进程和共享内存的关联
1
2
3
4
5int shmdt(const void *shmaddr)
参数:
shmaddr:共享内存的首地址(起始地址),通过shmat函数获得
返回值:
成功返回0,失败返回-1 - 对共享内存进行操作,共享内存要删除才会消失,创建共享内存的进程被终止了对共享内存无影响5.根据指定的路径名和int值,生成一个共享内存的key
1
2
3
4
5
6
7
8
9
10
11
12
13int shmctl(int shmid,int cmd,struct shmid_ds *buf)
参数:
shmid:共享内存的标识号,通过shmget函数获得
cmd:要对共享内存做的操作
IPC_STAT:获取共享内存的当前状态
IPC_SET:设置共享内存的状态
IPC_RMID:标记共享内存被摧毁
struct shmid_ds *buf:需要设置或获取的共享内存的属性信息
若cmd=IPC_STAT,则buf存储数据
若cmd=IPC_SET,则buf中需要初始化数据,设置到内核中
若cmd=IPC_RMID,没用,设置为NULL
(struct shmid_ds:一个结构体,里面有一个成员shm_nattach,记录了该共享内存关联的进程的个数)1
2
3
4key_t ftok(const char *pathname,int proj_id)
参数:
pathname:指定一个存在的路径
proj_id:int类型的值,但是系统调用智慧使用其中的一个字节(范围:1-255,一般指定一个字符'a')
信号量
用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。
为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量
控制信号量的方式有两种原子操作
- 一个是 P 操作,这个操作会把信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
- 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1。
具体过程
- 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
- 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
- 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
可以发现,信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0。
具体过程
- 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
- 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
- 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
Socket
前面提到的管道、消息队列、内存映射、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要Socket通信了。
实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
针对TCP协议的Socket编程模型
- 服务端和客户端初始化 socket,得到文件描述符;
- 服务端调用 bind,将绑定在 IP 地址和端口;
- 服务端调用 listen,进行监听;
- 服务端调用 accept,等待客户端连接;
- 客户端调用 connect,向服务器端的地址和端口发起连接请求;
- 服务端 accept 返回用于传输的 socket 的文件描述符;
- 客户端调用 write 写入数据;服务端调用 read 读取数据;
- 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。
这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
针对UDP协议的Socket编程模型
UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。
对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。
针对本地进程间通信的Socket编程模型
本地 socket 被用于在同一台主机上进程间通信的场景:
- 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
- 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;
- 对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。
- 对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。
Socket相关的API
太多了,这里就不多说了,不懂得可以去看这篇文章:http://t.csdn.cn/EoIzG
结语
鼠鼠今天看到一个真实故事,大概讲的就就一个人的前女友脑出血成为了植物人,主人公和她已经和平分手1年了,突然有一天主人公接到了前女友的姐姐的电话,得知了前女友发生的事,然后展开的一个故事,里面的一句话让我特别难过”她听完我的录音后,并没有醒过来,那一刻,我多么希望电视剧里的憨憨剧情是真的”,不管是这个,里面还有着女主的哥哥写的一封信,令我特别特别难过,我多希望女主能像电视剧里面醒过来啊,但事与愿违,女主最后还是成为了天边的一朵云彩…
真的还蛮难过的,听主人公的描述,女主是一个善良天真的女孩,她才活了20几年啊,为什么这种事情会发生在她身上呢?世界上确实是有人该死的,但那一定不会是善良和好人啊,我们总之在担心以后的事情,但我们谁也没办法知道,意外和明天哪一个先到来,看完这个故事我又拾起了以前写遗书的想法,我想我大概会写一封遗书,我会一直去修改,如果有一天我的生命定格在了某一刻,那我希望有人能在我的博客上解开我的密码,替我完成我还未完成的心愿,只是现在的我还有太多事没做了,我还不能死
- 标题: 进程间通信的各种方法以及优缺点
- 作者: 这题超纲了
- 创建于: 2023-02-25 16:52:51
- 更新于: 2023-06-23 14:32:19
- 链接: https://qx-gg.github.io/2023/02/25/blog6/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。