进程间通信
不要把自己太当回事~
前言
我们要了解 进程间是如何通信的,那么就需要了解进程是什么。
进程:其实进程就是运行起来的程序,程序运行起来需要被加载到内存中。进程和可执行文件很像(文件名.exe)->这就是可执行文件.但是他们又有所不同.可执行文件就像是静态的,躺在我们的硬盘中,但是,我们在任务管理器中可以明显的看到我们的进程是动态的,是在内存中不断被加载的.
进程他是一个运行的程序,是在内存中不断被加载的。那么这些进程是如何通信的?
进程间通信方式
1.为什么需要进程间通信?
每一个进程都拥有自己的独立的进程虚拟地址空间,造成了进程独立性,从而进程间通信技术就是为了各个进程之间可以很好的交换数据或者进程控制等应运而生的。
2.进程通信的几种方式
进程间的通信有 :管道 消息队列 共享内存 信号 套接字。
总结:
-
管道:包括无名管道和命名管道,无名管道半双工,只能用于具有亲缘关系的进程直接的通信(父子进程或者兄弟进程),可以看作一种特殊的文件;命名管道可以允许无亲缘关系进程间的通信。
-
系统IPC
-
消息队列:消息的链接表,放在内核中。消息队列独立于发送与接收进程,进程终止时,消息队列及其内容并不会被删除;消息队列可以实现消息的随机查询,可以按照消息的类型读取。
-
信号量semaphore:是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步。
-
信号:用于通知接收进程某个事件的发生。
-
内存共享:使多个进程访问同一块内存空间。
- 套接字socket:用于不同主机直接的通信。
3.进程间通信的目的
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止 时要通知父进程)
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另 一个进程的所有陷入和异常,并能够及时知道它的状态改变
管道
匿名管道
1 | ps auxf | grep mysql |
了解linux的朋友肯定熟悉 【|】这个符号 其实竖线就是一个管道,管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行,这种管道是没有名字,所以【|】表示的管道称为匿名管道,用完了就销毁。
匿名管道的创建:
1 | int pipe(int fd[2]) |
这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0]
,另一个是管道的写入端描述符 fd[1]
。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
管道就是一端写入数据,另一端读取。 所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取。
看到这,你可能会有疑问,两个进程都在一个进程里,怎么实现通信的。
我们可以使用 fork
创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]
与 fd[1]
」
这样就实现了不同进程之间的通信,但是问题又来了,因为管道是只能一端写一段读,如果两个进程同时写的话,那岂不是会混乱。
所以我们一般会这样:
- 父进程关闭读取的 fd[0],只保留写入的 fd[1];
- 子进程关闭写入的 fd[1],只保留读取的 fd[0];
通过以上我们知道:匿名管道是一个半双工的通信方式,就是一个发 另一个读。并且只能在具有亲缘关系的进程之间通信。很不方便。于是我们有了命名管道
命名管道
命名管道:需要通过 mkfifo 命令来创建并指定好名字。相当提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
1 | mkfifo Pipe |
Pipe就是这个管道的名字。
接下来,我们往 Pipe 这个管道写入数据:
1 | $ echo "hello" > Pipe // 将数据写进管道 |
你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。
于是,我们执行另外一个命令来读取这个管道里的数据:
1 | $ cat < Pipe // 读取管道里的数据 |
可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。
总结
我们发现,不管是匿名管道还是命名管道,都是半双工的通信方式,并且只能一方写,另一方读。通信效率非常低下,所以我们又引出了消息队列。
消息队列
我们说到,管道的通信效率低下,不适合进程间频繁的交流,于是消息队列很好的解决了这个问题。
消息队列,就是一个消息的链表,是一系列保存在内核中消息的列表。当一个进程需要通信的时候,只需要将数据写入这个消息列表当中,就可以正常退出干其他事情了,另一个进程需要数据的时候只需去读取数据就行了。
补充:
消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
- 队列的特性是先进先出,消息队列也是满足先进先出的特性的,内核当中实现消息队列的时候,是采用链表这个结构体。
- 消息队列当中的元素是有类型的,每一种类型是有优先级概念的。
1 | 同一类型保证先进先出的特性; |
管道 VS 消息队列
管道是最基本的 IPC 方法之一,它允许一个进程向另一个进程发送数据。然而,管道是半双工的,这意味着数据只能在一个方向上流动,如果两个进程需要相互通信,就需要建立两个管道。另外,管道的信息传递是无格式的,接收进程需要知道数据的格式才能正确解析。
消息队列是另一种 IPC 方法,它解决了管道的一些限制。消息队列是全双工的,这意味着数据可以在两个方向上流动,只需要一个消息队列就可以实现双向通信。此外,消息队列发送的是格式化的消息,每个消息都有一个类型,这使得接收进程可以根据消息类型来选择处理的消息,提高了通信的灵活性。
全双工(Full Duplex)和半双工(Half Duplex)
全双工(Full Duplex)通信允许数据在两个方向上同时进行传输。就像在电话通话中,你可以在同一时间既能听到对方的声音,也能向对方讲话,这就是全双工通信。
而半双工(Half Duplex)通信则是数据只能在一个方向上流动,同一时间只允许一个方向的通信,不能同时进行接收和发送。例如,对讲机就是典型的半双工通信设备,你在讲话的时候,对方只能听,不能同时与你交谈。
综上说所,消息队列很好的解决了管道不能频繁交流的问题,但是也存在了不足,就是交流不及时,还有大小受限制。并且在通信的过程中,会存在大量频繁的用户态和内核态的不断转换,进程写入消息时,会发生用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
共享内存
共享内存的原理
首先在物理内存当中创建了一块内存
不同的进程通过页表映射, 将同一块物理内存映射到自己的虚拟地址空间
不同的进程, 操作进程虚拟地址, 通过页表的映射, 就相当于操作同一块内存,从而完成了数据交换
共享内存的接口
创建共享内存
1 |
|
将共享内存附加到进程
1 | void *shmat (int shmid, const void *shmaddr, int shmflg); |
将共享内存和进程分离
1 | int shmdt (const void *shmaddr) ; |
操作共享内存
1 | int shmctl (int shmid, int emd, struct shmid ds *buf) ; |
删除共享内存
- 当使用shmct1或者使用ipcrm,删除共享内存之后, 共享内存就实际被释
放掉了 - 当共享内存被群放掉之后, 共享内存的标识符会被设置成为0x00000000.表示其他进程不能通过之前的标识符找到该共享内存, 并且共享内存的状态会被设置成为dest (destroy)
- 当共享内存被群放掉之后了, 但是还是有进程在附加着共享内存,当前描述共享内存的结构体并没有被释放, 直到当前共享内存的附加进程数量为0的时候才会被释放掉
总结
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。但是也引入了一个问题,就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。为了解决这一问题我们又引入了信号量的概念。
**来个考题:**两个进程通过共享内存往同一个地址写内容,内存中是否是同一个地址?
当两个进程通过共享内存向同一个地址写内容时,虽然它们使用的是各自进程地址空间中的某个地址,但内存中实际映射到的物理地址是相同的。这意味着它们共享的是同一块物理内存区域。
信号量
为了防止多个进程同时访问公共资源,造成数据混乱,必须想一个保护机制,使得共享的资源,在任意时刻只能被一个进程访问。于是便提出了信号量。
信号量(semaphore)它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
控制信号量的方式有两种原子操作:
- 一个是 P 操作,这个操作会把信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
- 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
进程互斥访问资源 初始化信号量为 1
假如有A B两个进程 信号量初始化为 1, 现在A 要访问共享内存 进行了p操作,信号量变为 0,说明还有可用资源,A就可以顺利访问共享资源了。
结果这会B进程又要来访问资源,进行了p操作,信号量变成了-1,说明资源已经有进程占用了,那么B进程就会阻塞等待。
A进程这会访问完了,出来了,进行了V操作,信号量变成了 0 ,看见了B在哪等待,于是唤醒了B,说你可以进去了,于是B就可以访问了,访问完之后,进行V操作,信号量又变回了1。
进程同步访问资源 初始化信号量为 0
我们都知道,进程是抢占式占用资源,但是我们有时想让多个进程相互合作,实现同一个任务,比如先让A进程生产数据,B进程才能读取数据。但是我们不知道到底那个进程先抢占了资源。假如A还没有生产数据呢,但是B进程又要读取,我们该如何做?
于是我们边有了进程同步:
我们可以初始化信号量为 0:
如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0
,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
总结:信号量不是用来通信的,信号量是和共享内存结合,来限制多进程同时访问共享资源的。防止冲突的一种保护机制
信号
我们上述说到的都是正常情况下的进程通信,那么如果进程出现异常了呢,这个时候我们就需要用信号通知的方式,实现通信。
1 | (base) sv@sv-NF5280M5:/home/sv$ kill -l |
总共定义了62个信号。
非实时信号: 非可靠信号
特点: 信号可能会丢失
1~31信号
实时信号:可靠信号
特点: 信号不会丢失
33~64
信号的操作原理:
- 信号产生:信号可以由操作系统、进程或用户生成。如硬件错误、软件中断、操作系统异常等情况,操作系统会产生信号。进程也可以使用系统调用,如 kill,给其他进程发送信号。用户通过终端(如按下 ctrl+c)也可以产生信号。
- 信号发送:一旦信号产生,它就会被发送给目标进程。信号在发送时会指定信号类型,例如 SIGINT 表示键盘中断信号,SIGKILL 表示终止进程信号等。
- 信号接收与处理:进程接收到信号后,有以下几种处理方式:
- 忽略信号
- 捕获信号:设置一个函数,当信号发生时,执行该函数
- 使用默认操作:每种信号都有一个默认操作,如终止进程、停止进程或忽略信号等。
- 信号阻塞:进程可以选择阻塞某些信号。被阻塞的信号不会立即被处理,而是被挂起,直到进程解除阻塞。
举个例子说明下我们经常使用通过终端(如按下 ctrl+c)杀死进程的原理
- 用户在终端按 ctrl+c,终端驱动程序会捕获这个键盘事件,并向前台进程组发送 SIGINT 信号。
- 收到 SIGINT 信号的进程会查找对应的信号处理函数**。在进程的控制块(PCB,Process Control Block)中,有一个信号处理表,用于存储进程对每种信号的处理方式。如果进程设置了捕获该信号的处理函数,那么就会执行该函数**;如果没有设置,那么执行该信号的默认操作。对于 SIGINT 信号,如果没有捕获,那么默认操作就是终止进程。
- 如果进程被终止,操作系统会回收该进程的资源,包括内存资源、文件描述符、环境变量等,并将该进程的状态设置为退出(Exit)。
- 进程的父进程会收到 SIGCHLD 信号,通知其子进程已经终止。父进程可以通过 wait() 或 waitpid() 函数获取子进程的退出状态,完成对子进程的回收。
kill -9 pid
1. 用户态下的 kill
命令
当用户在终端中输入 kill -9 <pid>
时,kill
命令的用户空间程序会执行以下操作:
- 解析参数:
kill
命令的实现程序会解析命令行参数,提取信号编号(在这种情况下是9
,即SIGKILL
)和目标进程的 PID。 - 调用系统调用:
kill
命令之后会调用kill()
或tkill()
系统调用,向指定的进程发送信号。
1 | int kill(pid_t pid, int sig); |
- 参数说明
pid
:目标进程的 PID。sig
:要发送的信号编号,在这里是SIGKILL
。
2. 系统调用 kill()
和 tkill()
当用户态程序调用 kill()
系统调用后,控制权会转移到内核态。kill()
系统调用的主要职责是向指定的进程发送信号。
kill()
系统调用:kill()
系统调用内部会调用do_kill()
, 它会根据传入的pid
和sig
,确定信号需要发送到哪个进程(或进程组)。- 实际的信号发送操作由
send_signal()
或group_send_sig_info()
函数完成。
tkill()
和tgkill()
:- 对于特定线程发送信号,系统使用
tkill()
或tgkill()
。这些系统调用允许向特定线程发送信号,而不仅是整个进程。
- 对于特定线程发送信号,系统使用
3. 内核中的信号处理
信号的处理机制在内核中是通过 struct task_struct
结构体完成的。task_struct
是 Linux 内核中用来表示进程的主要数据结构。
- 查找目标进程:
- 内核通过 PID 查找目标进程的
task_struct
结构体。这个结构体包含了进程的所有信息,包括信号队列、进程状态等。
- 内核通过 PID 查找目标进程的
- 发送信号:
send_signal()
函数将SIGKILL
信号添加到目标进程的信号队列中。这是通过修改task_struct
中的sigpending
字段来完成的。
- 处理信号:
- 当目标进程在内核态或用户态执行时,下次进入内核(例如通过系统调用或中断)时,内核会检查该进程的信号队列。
- 如果信号队列中存在
SIGKILL
信号,内核会立即终止该进程。 - 与其他信号不同,
SIGKILL
信号是无法被捕获、阻塞或忽略的。内核会直接调用do_exit()
函数,执行进程退出的操作。
4. 进程的终止 (do_exit()
)
当内核决定要终止一个进程时,do_exit()
函数被调用,这个函数负责清理进程的资源并将其从系统中移除。
- 资源释放:
do_exit()
函数会逐步释放进程所占用的资源,包括文件描述符、内存空间、等待队列等。- 它还会处理进程的子进程,将孤儿进程重新分配给
init
进程(PID 为 1 的进程)。
- 进程状态更新:
- 进程的状态会被设置为
TASK_DEAD
,表示进程已经终止。 - 终止的进程会被放入
zombie
状态,直到其父进程调用wait()
系统调用收集其终止状态。
- 进程的状态会被设置为
- 调度器的参与:
- 在进程的最后,内核调用调度器选择新的进程来运行。被杀死的进程的
task_struct
结构会被从调度器队列中移除。
- 在进程的最后,内核调用调度器选择新的进程来运行。被杀死的进程的
5. 进程变为僵尸态
- 在
do_exit()
完成后,进程进入僵尸状态(TASK_ZOMBIE
),此时进程的资源已被释放,但其task_struct
仍然保留在内核中,以便父进程可以获取其终止状态。 - 父进程会通过
wait()
或waitpid()
系统调用来收集子进程的退出状态;一旦收集完,僵尸进程的task_struct
也会被内核释放。
说到这里,我们发现上面的所有进程间通信方式都只是局限在本机,那我我们自然而然的就引出了网络Socket 通信,实现不同电脑中的进程通信。
kill和kill 9的区别
在Unix和类Unix操作系统(如Linux)中,kill
命令用于向进程发送信号,以通知它们执行某些操作或终止。kill
命令默认发送的是 TERM
(终止)信号,而 kill -9
发送的是 KILL
信号。这两种信号的主要区别在于它们如何处理和终止进程。
kill
命令(默认信号为 TERM
)
- 信号编号:默认发送的是
TERM
信号,信号编号为15
。 - 行为:
TERM
信号是请求进程正常终止。进程收到TERM
信号后,有机会进行清理工作,如关闭文件、释放资源等。 - 适用场景:当希望进程有机会进行清理工作时,应使用
TERM
信号。
kill -9
命令(发送 KILL
信号)
- 信号编号:发送的是
KILL
信号,信号编号为9
。 - 行为:
KILL
信号是强制终止进程。进程收到KILL
信号后,无法进行任何清理工作,操作系统会直接终止进程。 - 适用场景:当进程无法响应
TERM
信号或需要立即终止进程时,应使用KILL
信号。
主要区别
- 清理工作:
TERM
信号允许进程进行清理工作。KILL
信号不允许进程进行清理工作。
- 响应性:
TERM
信号可能被进程忽略或处理,如果进程陷入死循环或无响应状态,可能无法终止。KILL
信号是强制性的,进程无法忽略或处理,操作系统会直接终止进程。
- 使用场景:
- 通常先尝试使用
TERM
信号,如果进程无响应或无法终止,再使用KILL
信号。
- 通常先尝试使用
socket通信
这个可以看我的多路复用那篇文章
编程流程:
服务端:创建套接字,绑定地址信息
客户端:创建套接字,不推荐绑定地址信息 (可以绑定 )
创建套接字的含义:
将进程和网卡进行绑定,进程可以从网卡当中接收数据,也可以通过网卡发送数据。
绑定地址信息的含义:
绑定ip,绑定端口,是为了在网络当中可以标识出来一台主机和一个进程。
对于接收方而言:发送数据的人就知道接收方在那台机器那个进程了
对于发送方而言:能标识网络数据从那台机器那个进程发送出去的
总结
如果面试官问你说说,进程间是如何通信的,你该做一下回答。
首先 进程间的通信有 管道 消息队列 共享内存 信号 套接字。
管道分为 匿名管道和命名管道。
**匿名管道 :他是一个半双工的通信方式,就是一个发 另一个读。**并且只能在具有亲缘关系的进程之间通信。很不方便,于是便有了命名管道: 同理命名管道也是一个半双工的通信方式,一个发 另一个读。但是可以实现不同进程之间的通信了。
这又产生了问题,就是这个通信不迅速,效率低下又成了问题,于是又产生出了消息队列。
消息队列:消息队列是保存在内核中的消息链表,比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。这样就是像写信一样,一来一封,我回一封,而且消息队列在内核当中,我们去读取的时候,还会有用户态和内核态之间的转换,效率虽然说有所改变,但是还是不够及时。所以共享内存又出来了。
共享内存:就是两个进程,各拿出一块虚拟内存空间,映射到相同的物理空间。这样一个进程在进行读写操作的时候,另外一个进程立马就可以看到。通信的效率大大提高,但是又带来了新的问题。就是如果遇到两个或者多个进程同时给这个空间写东西,就会产生冲突。
那么为了防止多个进程同时共享资源,就提出了信号量,使得在任意时刻资源只能被一个进程访问。信号量说白了就相当于一个计数器,有两个操作:(他不能通信只能配合共享内存)
-
P操作:相当于每来一个进程要访问的时候,先给信号量减1,减1之后 如果信号量还>>=0 说明这会这个资源还没有占用可以访问,如果减一之后<0,说明有其他进程正占用着资源,需要等待。
-
V操作:每个进程要走的时候,先给信号量+1 如果说+1了之后,还<<=0,说明前面还有排队的进程,这个时候就会把前面排队的进程唤醒,说我走了,你可以去访问资源了。如果+1>0 说明前面没有排队的进程,也就是没有阻塞的进程,如果后面有进程要访问的话,直接就可以访问。
说了这些都是在一台主机上的进程间的通信,那么也产生出了Socket,也就是套接字。这实现了不同电脑上的进程通信。
最后还有个 信号: 信号就是可以给进程发送一个命令,进程就会做相应的工作。
摘录自:面试常问【进程间通信】最详细解说
通信方式 | 进程通信 (IPC) | 线程通信 |
---|---|---|
共享内存 | - 通过映射内存区域到多个进程的地址空间,实现进程间共享数据。 - 需要进程间同步机制(如信号量)。 - 高效,但需要小心并发问题。 |
- 所有线程共享同一进程的内存空间。 - 线程间可以直接访问共享数据,无需额外的通信机制。 - 需要同步机制(如互斥锁、条件变量等)来避免竞争条件。 |
管道 (Pipe) | - 单向通信渠道,通常用于父子进程间通信。 - 数据通过管道传输,支持字节流传输。 - 需要操作系统的支持,效率较低。 |
- 不常用于线程间通信,因为线程可以直接共享内存。 |
消息队列 | - 通过操作系统管理的队列传递消息,进程间可以异步通信。 - 提供消息的顺序性和缓冲能力。 - 操作系统管理消息队列,较为复杂。 |
- 线程间较少使用消息队列,通常使用条件变量来实现类似功能。 |
信号量 (Semaphore) | - 用于进程间同步,控制进程对共享资源的访问。 - 可以是计数信号量,用于多进程同步。 |
- 用于线程间同步,控制线程对共享资源的访问。 - 可以是计数信号量,用于多线程同步。 |
套接字 (Socket) | - 主要用于网络通信,但本地进程间也可以通过本地套接字通信。 - 支持跨计算机通信。 |
- 通常用于跨进程或网络通信,而线程间可直接访问共享内存,无需通过套接字。 |
信号 (Signal) | - 一种异步通信方式,进程通过发送信号来通知其他进程。 - 信号处理函数在接收到信号时执行。 - 信号简单,但功能有限,通常用于通知或中断。 |
- 线程可以发送信号给同一进程中的其他线程。 - 信号处理函数是进程共享的,所有线程都可以响应。 |
文件或文件锁 | - 进程间可以通过读写文件来交换数据。 - 使用文件锁可以确保进程间的同步。 - 简单但效率低下,通常用于日志或简单数据交换。 |
- 线程可以通过文件共享数据,但通常不推荐。 - 文件锁可以用于线程同步,但效率低,通常使用其他同步机制。 |
条件变量 (Condition Variables) | - 进程间不常用条件变量,通常使用信号量或其他机制。 | - 线程间常用的同步机制,线程可以等待某个条件满足或发出通知。 - 与互斥锁配合使用。 |
消息传递 (Message Passing) | - 通过 msgget 、msgsnd 、msgrcv 等系统调用实现进程间消息传递。- 适用于复杂的数据交换。 |
- 线程间通常不使用这种方式,消息传递在进程间更常用。 |
内存映射 (Memory Mapping) | - 通过 mmap 共享内存区域,允许多个进程访问同一块内存。- 需要同步机制来避免并发问题。 |
- 线程间共享同一进程的内存空间,因此不需要内存映射。 |