进程间的通信
1.进程的简单通信
既然是进程间的通信,那至少得具备两方,根据信息的流动,可以分为发送方和接收方,在网络通信中也有客户端,服务端之称,我们只需要笼统地理解这种设计观念就行
- 通信的方式有什么??
- 单向
- 双向(还有半双工)
- 同步(会阻塞,有前置条件,一问一答)
- 异步(发送者和接收者可以独立执行,无需相互等待)
可见,无论是小到进程的通信还是大到计算机之间的通信,通信的设计理念都没有改变,因为通信二字很重要
-
进程间如何通信,进程不是互相隔离的吗?
是的,他们身处不同的地址空间,看到的系统资源都是互相隔离的,除非让他们两共享一片内存区域(像不像线程之间的通信?)
-
共享内存作用是什么?
传递数据,是的,无论是计算机内部还是计算机间的通信,数据的传递必不可少,因此进程由于粒度不小在当今的计算机中,因此需要数据的交流,就需要共享内存.
-
共享内存能否实现?
由于不同进程的虚拟地址对于的物理地址空间是可能不同的,这意味着指针级相关的数据都无法正常使用,因此多进程之间的隔离性其实与共享内存之间存在冲突.
操作系统辅助的信息传递
指内核提供给用户态的通信接口,比如Send和Recev等,进程之间可以通过接口的调用,而不需要去建立共享内存.因此,这也是一种通信方式.
2.通信的连接管理
前面了解过通信的方式和通过什么建立通信的方式,这里来理解如何建立一个通信
这种设计概念
连接本身也存着双向的,意味着接受进程也可以发送给发送进程,发送进程同样也可以接受接收进程的消息.例如管道
就属于间接通信,也就是通过信箱的传输方式.通信的双方并不知道对方是哪一个进程,他们共享这一个管道
3.进程间的通信需要安全
权限
:为保障进程间通信的安全性,必须设以相对的安全性
在Linux系统中,通常会复用其有效用户/用户组的文件权限,来表示此进程对于某个连接的权限.
在 System V 进程间通信(IPC)机制中,存在权限管理来控制进程对通信资源的访问权限。System V IPC 包括共享内存(Shared Memory)、消息队列(Message Queue)和信号量(Semaphore)三种通信方式.
- 权限的实现得以于用户与文件的组和:例如进程在某个用户下运行,操作系统也会将用户分为不同的用户/用户组,因此进程的用户可以分类为(所有者用户/用户组用户/其他用户),而文件同样存在着读写的权利比如(可读/可写/可执行)来判断,而访问模式就是用户组与文件的这种组和,当一个进程去访问一个文件时,就能知道是否合法了,而访问模式同样有着对应的访问模式控制表(ACL)来保存
例如:
-
查看文件的当前权限:
ls -l example.txt
输出可能是这样的:
-rw-r--r-- 1 user1 group1 1024 Jun 5 10:00 example.txt
-
修改文件的所有者用户和所有者组:
chown user2:group2 example.txt
-
修改文件的权限:
chmod 640 example.txt
这将设置文件的权限为
rw-r-----
,即所有者用户具有读写权限,所有者组具有读权限,其他用户没有任何权限.
权限的分发:继承
是的,继承同样可以用于文件的权限继承,也就是允许子目录或文件继承其父目录的权限
例如:创建一个名为parent的目录和child的子目录和一个file.txt的文件
-
创建父目录和子目录:
mkdir parent mkdir parent/child
-
设置父目录的权限:
chmod 755 parent
这将设置父目录
parent
的权限为rwxr-xr-x
。 -
创建文件并设置权限:
touch parent/file.txt chmod 644 parent/file.txt
这将设置文件
file.txt
的权限为rw-r--r--
。 -
验证权限设置:
ls -l parent ls -l parent/child ls -l parent/file.txt
输出结果:
4.管道
管道
也是进程间通信的一种方式,通过这种方式,可以使两个进程进行正常的通信.
例如:
ps aus | grep target
这里有两个命令ps和grep
- "ps":用于列出当前系统中正在运行的进程的相关信息。
- "grep":是一个文本搜索工具,用于从输入中匹配包含指定关键字的行。
意思就是查看当前是否有target关键字相关的进程在允许,然后通过|
运输出去,也就是第一个命令输出到管道中,管道对应的出口则是命令的输入.
所以管道的这种传输方式是单向的,也就是半双工模式
而这种先入先出的设计理念通常就和队列的数据结构特别像.(FIFO,别说,创建管道的命令还就长这样,外国人真的很直接)
-
所以管道被分为命名管道和匿名管道
-
匿名管道:就是
|
这种,这种管道..用完了就销毁,所以很伟大,不需要名字(奉献主打就是) -
命名管道:有名字的管道呗.所以不能随便销毁了,具有持久性(有身份的很牛逼)
-
创建命名管道:可以使用
mkfifo
命令或mknod
系统调用在文件系统中创建一个命名管道。例如,可以执行以下命令创建一个名为"mypipe"的命名管道:mkfifo mypipe
-
然后,查看一下
ls -l
-
输出:
prw-r--r-- 1 root root 0 Jun 1 19:25 mypipe
-
-
管道的实现机理:通过内核的缓冲区实现
- 缓冲区受限:当管道的写端写入数据速度过快,而读端读取数据速度较慢时,内核缓冲区可能会被填满,导致写入进程被阻塞,直到缓冲区有足够的空间。这种阻塞可能会导致管道的效率降低。
- 上下文切换:在管道的使用过程中,数据需要从一个进程的地址空间复制到另一个进程的地址空间,这涉及到进程间的上下文切换。上下文切换会引入一定的开销,包括保存和恢复进程的状态信息,可能会降低管道的效率。
5.消息队列
消息队列同样是进程间通信的一种方式,而进程的对象模型则为多对多;
-
为什么需要消息队列
前面说过管道其实效率是比较低的,两个进程通过管道进行传输,如果另一端没有读出,则会导致另一边被阻塞,因此消息队列可以解决这个问题
例如:A需要发送消息给B,但现在只需要放到信箱里,便不管了,因此这种方式是异步通信的方式,而消息队列的基本实现是通过内核中的消息链表实现
- 但同时消息队列的大小往往也是受限的,并且双方通信并不是同步的,所以是一种不及时的状态
- 而且在消息队列某个节点数据生成的时候,通常伴随着用户态进程要将数据写入内核态,发送拷贝的情况,同时取出也伴随着拷贝的情况,因此这个过程也比较耗时.
6.信号量
信号量其实是进程间通信用来控制资源同步的一种手段,上面的两种进程间通信其实并不具备状态的记录(也就是没保证同步性),因为某些进程在一些环境下通信是需要记录状态的,也就是顺序很重要,因此信号量就用来提供一种实现同步的机制方法.
具体的实现:一个共享的整形计数器,通常还是由内核维护,对信号量的操作也依然需要内核系统调用.
-
其中最重要最基本的两个操作就是P,V操作,我翻阅了一些书籍,对P,V这两个名词进行了解析,原意是这个含义:
- P:来自荷兰语Probeer(尝试),表示尝试一个操作,(计数器中就是-1的操作),操作失败则会导致进程阻塞.
- V:来自荷兰语Verhoog(增加),在信号量中是将计数器+1的操作,操作失败也一样会阻塞,但是+1可能会唤醒P操作(如果被阻塞了).
以下是一个java实例:
import java.util.concurrent.Semaphore; public class SharedMemoryExample { private static Semaphore semaphore = new Semaphore(1); private static int sharedMemory = 0; private static class ProcessA extends Thread { @Override public void run() { try { semaphore.acquire(); // P 操作 System.out.println("Process A acquires semaphore and enters critical section"); // 访问共享内存 sharedMemory = 42; System.out.println("Process A writes to shared memory: " + sharedMemory); semaphore.release(); // V 操作 System.out.println("Process A releases semaphore"); } catch (InterruptedException e) { e.printStackTrace(); } } } private static class ProcessB extends Thread { @Override public void run() { try { semaphore.acquire(); // P 操作 System.out.println("Process B acquires semaphore and enters critical section"); // 访问共享内存 System.out.println("Process B reads from shared memory: " + sharedMemory); semaphore.release(); // V 操作 System.out.println("Process B releases semaphore"); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { Thread processA = new ProcessA(); Thread processB = new ProcessB(); processA.start(); processB.start(); try { processA.join(); processB.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
- 创建了一个信号量对象,他的初始值为1,且是私有共享的(private static)意味着只有类内部能访问的变量,且属于类对象,可以被共享,而不属于实例,这样保证了数据是一份且私有安全,且只会被一个线程P操作,之后在P没用.
- 紧跟着创建了五个线程,去启动线程,执行他们的run方法.
- run方法中 semaphore.acquire();会去获得信号量(许可也叫),如果信号量为0,则会抛出相应的异常
- 因此执行完P操作后,会写入共享内存区域(可以不执行也没事)
- 之后semaphore.release(); 会去释放信号量
因此输出结果为:
Process A acquires semaphore and enters critical section
Process A writes to shared memory: 42
Process A releases semaphore
Process B acquires semaphore and enters critical section
Process B reads from shared memory: 42
Process B releases semaphore
这意味着即使A进程在获得了许可之后,即使B进程也想获得,他也会被阻塞,等待A进程释放许可后获得,这就通过信号量去控制生产者消费者问题的临界资源问题
7.共享内存
-
为什么需要共享内存
无论是消息队列还是管道,信号量,他们都设计用户态与内核态之间消息拷贝的过程,如果能在两个进程之间有一块可以同样映射的物理内存,那么就不需要再通过内核,用户间进程也可以正常通信
共享内存的实现机理:如何去解决虚拟地址映射在同一块物理内存的问题?
- 首先,内核会为所有内存维护一个全局的队列结构,这个队列的每一项可以把他看成一个结构体,他会和进程间通信的一个
key
绑定,每个进程就是通过这个key
来找到与之对应的物理内存来进行通信,因此这个key
必须保证全局唯一(不重复).但这一个节点的内存是否共享能够被使用,则需要相应的权限
,因此进程间的安全性必须得以保障,只要进程有对应的权限,就能够通过内核接口一段共享内存区域映射到虚拟地址中去,而在Linux系统中,共享内存的机制封装在了文件系统中,也就是inode
结构体,这些结构体依旧指向了我们物理内存的页.
当然,现在操作系统共享内存的实现不一定基于队列的实现,这只是实现共享内对的一种机理实现,并不代表所有的共享内存都这么实现.
8.信号
信号是用来通知进程的事件的,也就是说具备通知事件的能力,那信号量也具有通知能力,为什么还要说信号
-
信号量需要进程主动去查询!!
-
而信号则是一个进程可以随时发送给一个进程或者线程,甚至一个进程组.
所以通常来说信号也算是一种实现机制,他用来进程间传递信息的一种方式,传递的信息很短,其实这种信号在shell控制里面常出现,用过linux命令的基本都会很清楚,例如:
- SIGINT(2):由Ctrl+C产生,用于终止前台进程。
- SIGTERM(15):用于正常终止进程。
- SIGKILL(9):无条件终止进程。
- SIGSTOP(19):暂停进程的执行。
- SIGCONT(18):恢复被暂停的进程的执行。
早期Linux有32个(0~31)号,现在估计到64了,这种信号被称为常规信号,信号本身也具备性质,根据传递的意义来进行判断,有一些信号在接收到了之后就可以丢弃,而实时信号则不允许.
8.1信号的发送
在Linux中,可以使用系统调用函数kill()
来发送信号给指定的进程或进程组。kill()
函数的原型如下:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数说明:
- pid:要发送信号的目标进程的进程ID。可以是一个具体的进程ID,也可以是以下特殊值之一:
- 正数:发送信号给具有该进程ID的进程。
- 0:发送信号给与调用进程属于同一进程组的所有进程。
- -1:发送信号给除了进程ID为1的所有进程。
- 负数:发送信号给进程组ID等于该值的所有进程。
sig
:要发送的信号的编号。可以是标准的信号编号,如SIGINT
、SIGTERM
等,也可以是用户自定义的信号编号。
例如:
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
int main() {
pid_t pid = 1234; // 目标进程的进程ID
int result = kill(pid, SIGINT); // 发送SIGINT信号
if (result == 0) {
printf("Signal sent successfully.\n");
} else {
perror("Failed to send signal");
}
return 0;
}
信号的发送其实也需要相应的权级,权级不够,另一个进程都收不到,你明白就行.
8.2信号屏蔽
信号的设计很人性化,很符合现实世界的信号概念,因为信号在计算机内部进程之间交流也是可以屏蔽或者阻塞的.其实计算机内部世界的设计我一直感觉很像现实世界的抽象化(说歪了).
这里我仅作了解,查阅了相关资料:
常用的相关系统调用函数包括:
sigprocmask()
:用于修改进程的信号屏蔽字。它接受一个sigset_t
类型的参数,用于指定新的信号屏蔽集合。sigemptyset()
:用于清空信号集合。sigfillset()
:用于将所有信号添加到信号集合中。sigaddset()
:用于将指定的信号添加到信号集合中。sigdelset()
:用于从信号集合中删除指定的信号。
所以其实可以看出来信号屏蔽的实现还是发送都是通过程序实现也就是软件的实现,而在这里我其实还想到了硬中断,也就是我们说的中断机制,中断机制同样存在中断屏蔽,利用中断屏蔽为了确保关键代码的执行不被中断而禁用中断请求,本质上也是一种信号通知,因此进程间存在信号屏蔽,而硬件资源与程序的调控之间也存在硬件中断,触发源各有不同,但是设计思想不得不说是一样的.
8.3信号的响应和处理
首先,信号的接受是异步的,也就是你什么时候都有可能受到一个信号,没错,因此你处理信号的态度也有两种,忽略,处理
-
信号的处理函数分为两种:
为什么分为两种,因为看信号发送给谁了,处理的态度就要有所不同,联系现实就能明白
因此信号的处理函数分为两种:
- 用户处理函数:用户可以自定义信号的处理逻辑。通过注册自定义的信号处理函数,用户可以对信号做出特定的响应,例如记录日志、恢复状态、关闭资源等。
- 内核默认处理函数:操作系统为每个信号提供的默认处理行为。内核默认处理函数的目的是保障系统的正常运行和安全性,对于某些关键性的信号,例如SIGKILL和SIGSTOP,内核默认处理函数会采取强制终止和暂停进程的措施,用户无法修改其行为。
因此还可以明白的一点是内核默认处理函数比较老顽固
,因此常见的信号他都会处理根据默认规则,一旦遇到自身没见过的信号请求则会丢弃,而用户进程的请求信号(千奇百怪没有问题吧?),因此用户进程需要去注册他们的信号,因此存在信号注册的用户态处理函数,像不像你现实世界办理认证需要去某个机构.所以说计算机的设计思想非常重要,明白了设计思想就能理解他存在的意义,就能够理解他为什么这么执行.
在用户进程进行信号的注册之后(此函数是用户态的代码,因此在用户态执行),所以内核态在执行信号注册函数时还需要切换到用户态,为了保存内核的上下文,一般这个过程是需要通过栈,寄存器,程序寄存器的实现.
- 可重入函数:也就是允许多个任务(线程)并发使用,而不必担心数据的错误.
- 为什么要提起多并发的问题,因为中断的存在,信号处理函数的在执行的过程中,如果其他进程又发送了一个信号了,恰巧当前进程因为中断已经等待陷入内核,那么当前进程下次在执行此函数就得回头执行,这种嵌套就是可重入的.因此信号处理函数一般情况下都是可重入函数。
9.套接字(Socket)
如果说上述的途径都是为了实现计算机内部的通信,那么计算机和计算机之间的通信则通过Socket进行通信,在TCP中我展示过套接字的一个虚拟实体,实际上套接字是一种抽象概念,它并不止计算机和计算机之间的通信,其本身内部也是可以通过Socket通信的。
-
创建Socket:
socket
:创建一个新的Socket,并返回Socket文件描述符。#include <sys/types.h> #include <sys/socket.h> int socket(int domain, int type, int protocol);
-
domain
:指定Socket的协议域(Protocol Family),决定了Socket的通信范围。常见的协议域包括:AF_INET
:IPv4协议。AF_INET6
:IPv6协议。AF_UNIX
:UNIX域(本地套接字)。- 其他协议域如
AF_NETLINK
、AF_PACKET
等。
-
type
:指定Socket的类型,决定了Socket的通信方式。常见的Socket类型包括:SOCK_STREAM
:面向连接的可靠字节流,用于TCP协议。SOCK_DGRAM
:无连接的不可靠数据报,用于UDP协议。SOCK_RAW
:原始套接字,用于访问网络协议的底层。
-
protocol
:指定Socket使用的具体协议。通常为0,表示自动选择与domain
和type
相匹配的默认协议。如果返回值为-1则表示创建套接字失败.
-
-
绑定Socket到地址:
bind
:将Socket绑定到指定的地址和端口。 -
监听连接请求:
listen()
:将Socket设置为监听状态,等待客户端连接请求。 -
接受连接请求:
accept()
:接受客户端的连接请求,并返回一个新的Socket文件描述符,用于与客户端进行通信。 -
发起连接请求:
connect()
:发起连接到指定服务器的请求。 -
数据发送和接收:
send()
、sendto()
:发送数据到已连接的Socket或指定的目标地址。recv()
、recvfrom()
:从Socket接收数据。 -
关闭Socket:
close()
:关闭一个Socket连接。
因此,在这里回顾以下TCP的建立,在HTTP报文发送给TCP协议栈之前,操作系统调用了Socket库并执行了什么操作,这部分是之前没有说的
这里的通信,我画出来的是单向的,实际服务端也会去读写数据返回给客户端,意味着客户端和服务端在读写的操作是类似的.另外,用来收发数据在网络通信编程中往往write
和read
也是可以的,这是TCP三次握手的Socket库实际的调用情况,至于UDP通信,由于UDP不需要去考虑通信双方的状态,因此listen
则不需要,因此接下来处了读写,其余函数都不调用,而UDP进行收发操作调用的库函数则是sendto
和recvfrom
,仅仅这些点不一样.
总结:因此整个计算机架构中的通信其实是密不可分的,还是需要不断地深入学习才能对整个计算机领域更加理解,当然,也需多多拥抱生活,感受生活的美好,或许能够更加理解计算机系统.
标签:Socket,通信,管道,信号,进程,共享内存 From: https://www.cnblogs.com/looktheworld/p/17462612.html