文章目录
- 1. 进程间通信的概念
- 2. 进程间通信的7种方式
- 2.1 管道/匿名管道(pipe)
- 2.2 有名管道(FIFO)
- 2.3 信号(Signal)
- 2.4 消息(Message)队列
- 2.5 共享内存(share memory)
- 2.6 信号量(semaphore)
- 2.7 套接字(socket)
1. 进程间通信的概念
每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)
2. 进程间通信的7种方式
2.1 管道/匿名管道(pipe)
管道是一种半双工的通信机制,只允许数据在一个方向上流动。当需要双向通信时,需要建立两个独立的管道。它主要用于具有亲缘关系的进程之间进行通信,例如父子进程或者兄弟进程。
管道在操作系统中单独构成一种独立的文件系统,但与普通文件不同,它只存在于内存中,不属于任何文件系统。管道两端的进程将其视为一个文件,通过写入和读取数据来进行通信。
数据在管道中的读取和写入是按照先进先出的原则进行的。一个进程向管道中写入的内容被另一个进程从管道的另一端读取。写入的内容被添加在管道缓冲区的末尾,并且从缓冲区的头部逐个读取。
管道的实质:
- 管道的实质是一个由内核管理的缓冲区,进程通过管道以先进先出的方式进行数据传输。一端的进程按顺序将数据写入缓冲区,而另一端的进程则按顺序读取数据。
- 这个缓冲区可以被视为一个循环队列,读写操作在缓冲区中自动增长,无法随意改变。每个数据只能被读取一次,一旦读取,数据就从缓冲区中删除。
- 当缓冲区为空或者已满时,系统会根据一定的规则将对应的读取或写入进程置于等待队列中。当有新数据写入空缓冲区或者已满的缓冲区中的数据被读取时,等待队列中的进程会被唤醒,以便继续进行读写操作。
管道的局限主要体现在其特点上:
-
只支持单向数据流:管道只能支持单向数据传输,即数据只能从一端流向另一端,无法实现双向通信。
-
只能用于具有亲缘关系的进程之间:管道通常只能在具有亲缘关系的进程之间进行通信,例如父子进程或兄弟进程。
-
没有名字:管道是匿名的,没有名称标识,因此无法在不相关的进程之间共享和使用。
-
管道的缓冲区是有限的:管道的缓冲区大小是有限的,通常在创建管道时会为其分配一个固定大小的内存空间。
-
管道传输的是无格式字节流:管道所传输的数据是无格式的字节流,读写进程需要事先约定好数据的格式和解析规则,以确保正确的数据传输和解析。
2.2 有名管道(FIFO)
有名管道(FIFO)与匿名管道的主要区别在于有名管道具有路径名与之关联,以文件形式存在于文件系统中。因此,即使创建有名管道的进程不存在亲缘关系,其他进程只要可以访问该路径,就能够通过有名管道相互通信。有名管道严格遵循先进先出(FIFO)原则,读操作总是从管道的起始处返回数据,写操作则将数据添加到管道的末尾。需要注意的是,有名管道不支持诸如lseek()等文件定位操作,因为其数据流是线性的,不能随意定位。有名管道的名字存储在文件系统中,而其内容存放在内存中。
匿名管道和有名管道的主要区别和总结如下:
- 管道特性:
管道是一种特殊类型的文件,在满足先入先出(FIFO)的原则下,用于进程间的通信,但不能进行定位读写。 - 匿名管道:
匿名管道是单向的,只能在有亲缘关系的进程间通信。
在使用匿名管道时,写入的进程必须确定读取的进程存在,否则写入操作会阻塞,反之读取操作也会阻塞。
如果管道中没有数据可读,读取操作也会阻塞。
当管道的另一端断开时,写入操作会自动退出。 - 有名管道:
有名管道以磁盘文件的形式存在,可以实现本机任意两个进程之间的通信。
在打开有名管道时,需要确保对方进程的存在,否则会阻塞。
可以以读写(O_RDWR)模式打开有名管道,即当前进程读取数据,同时也可以向管道中写入数据,这种情况不会发生阻塞。
这些管道在使用时需要注意阻塞的问题,特别是在确定对方进程存在的情况下,保证写入和读取操作的顺利进行,避免出现阻塞的情况。
2.3 信号(Signal)
信号是 Linux 系统中用于进程间通信或操作的一种机制。信号可以在任何时候发送给某个进程,而无需知道该进程的状态。如果目标进程当前未处于执行状态,该信号将由内核保存,直到该进程恢复执行并将信号传递给它为止。如果进程将某个信号设置为阻塞状态,那么该信号的传递将被延迟,直到取消阻塞后才会传递给进程。
信号可以用于各种目的,如通知进程发生了某个事件、中断进程的执行、终止进程等。在 Linux 中,每个信号都有一个唯一的编号,以及一个对应的名称。一些常见的信号包括 SIGINT(终端中断信号,通常由 Ctrl+C 发送)、SIGKILL(用于强制终止进程)、SIGTERM(终止进程信号)等。
进程可以通过调用系统调用 signal() 或 sigaction() 来注册对信号的处理程序,从而在收到信号时执行特定的操作。信号处理程序可以是预定义的行为(如终止进程),也可以是用户定义的函数。
Linux系统中常用信号:
(1)SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
(2)SIGINT:程序终止信号。程序运行过程中,按Ctrl+C键将产生该信号。
(3)SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\键将产生该信号。
(4)SIGBUS和SIGSEGV:进程访问非法地址。
(5)SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。
(6)SIGKILL:用户终止进程执行信号。shell下执行kill -9发送该信号。
(7)SIGTERM:结束进程信号。shell下执行kill 进程pid发送该信号。
(8)SIGALRM:定时器信号。
(9)SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程。
信号来源
信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,,信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件主要有两个来源:
- 硬件来源:这些信号是由硬件事件触发的,比如用户按下 Ctrl+C 退出程序时,硬件会发送一个信号给操作系统,通知它中断当前进程的执行。另一个例子是硬件异常,比如无效的存储访问,也会触发信号。
- 软件终止:这些信号是由软件事件触发的,例如进程终止信号,当一个进程执行完成或者发生错误时,它会向操作系统发送一个信号来终止自己的执行。其他进程也可以通过调用系统函数 kill 来发送信号给目标进程,从而触发相应的操作。另外,软件异常也会产生信号,比如除零操作或者数据溢出。
信号生命周期和处理流程
-
信号的产生:某个进程产生信号,并设置要传递给哪个进程(通常是对应进程的 PID),然后将信号传递给操作系统。
-
操作系统的处理:操作系统根据接收进程对信号的设置来决定是否将信号发送给接收者。如果接收者阻塞了该信号(且该信号是可以阻塞的),操作系统会暂时保留该信号,直到接收者解除对该信号的阻塞。如果接收者已经退出,则丢弃该信号。如果接收者没有阻塞该信号,操作系统将传递该信号给接收者。
-
接收进程的处理:接收进程接收到信号后,根据当前进程对信号设置的预处理方式,暂时终止当前代码的执行,并保护当前上下文(主要包括临时寄存器数据、当前程序位置以及当前 CPU 的状态)。然后,执行与该信号相关的中断服务程序。执行完成后,恢复到中断发生之前的位置。对于抢占式内核,中断返回时可能会触发新的调度,切换到其他进程的执行。
2.4 消息(Message)队列
消息队列是一种存放在内核中的消息链表,每个消息队列都由唯一的消息队列标识符来表示。与管道不同的是,消息队列存放在内核中,只有在内核重启或者显式地删除一个消息队列时,该消息队列才会被真正删除。
另一个与管道不同的地方是,消息队列不需要等待消息到达的进程。在某个进程向队列写入消息之前,并不需要另一个进程在该队列上等待消息的到达。这意味着消息队列可以实现进程间的异步通信,发送方可以向消息队列发送消息,而不必关心接收方是否正在等待消息。
消息队列的这些特性使其成为一种非常灵活和高效的进程间通信机制,特别适用于需要解耦发送方和接收方的场景,以及需要在消息到达时进行异步处理的情况。
消息队列特点总结:
- 消息队列是一种消息链表结构,具有特定的格式,存放在内存中,并由消息队列标识符标识。
- 允许一个或多个进程向消息队列写入消息,以及从消息队列读取消息。
- 消息队列与管道类似,通信数据都遵循先进先出的原则。
- 消息队列可以实现消息的随机查询,不一定要按照先进先出的顺序读取,也可以根据消息的类型进行读取,这是与管道(FIFO)相比的优势之一。
- 消息队列克服了信号通信承载信息量少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
- 目前主要有两种类型的消息队列:POSIX消息队列和System V消息队列,其中System V消息队列目前被广泛使用。System V消息队列随内核持续存在,只有在内核重启或者手动删除时,该消息队列才会被删除。
2.5 共享内存(share memory)
共享内存是一种最快的可用IPC形式,它允许多个进程直接读写同一块内存空间,是为了解决其他通信机制效率较低而设计的。
在多个进程间交换信息时,内核会留出一块内存区域,允许需要访问的进程将其映射到自己的私有地址空间。这样,进程就可以直接读写这块内存,而不需要进行数据的拷贝,从而大大提高了效率。
由于多个进程共享同一段内存,因此需要依靠某种同步机制(例如信号量)来实现进程间的同步和互斥。这样可以确保在多个进程同时访问共享内存时不会出现数据混乱或竞争条件的问题,从而保证数据的一致性和正确性。
2.6 信号量(semaphore)
信号量是一个计数器,用于控制多个进程对共享资源的访问,其主要目的是实现进程间的同步。
使用信号量的基本流程如下:
- 创建一个信号量:调用者需要指定初始值,通常为1或0,用于控制共享资源的访问权限。
- 等待信号量:该操作会测试信号量的值,如果其值小于等于0,进程将被阻塞,直到信号量的值大于0。这个操作也被称为P操作。
- 发送信号量:该操作将信号量的值加1,以允许其他等待进程继续执行。这个操作也被称为V操作。
- 为了确保信号量操作的原子性,信号量通常在内核中实现。在Linux环境中,有三种主要类型的信号量:Posix信号量(Portable Operating System Interface for Unix),有名信号量(使用Posix IPC命名标识),以及基于内存的Posix信号量(存储在共享内存区中)。此外,还有System V信号量,它也常用于进程间或线程间的同步。
举例来说:
- 两个进程可以共享一个二值信号量,以控制对某个共享资源的互斥访问。
- 两个进程可以使用一个Posix有名二值信号量来实现跨进程的同步。
- 一个进程的两个线程可以共享基于内存的信号量,用于线程间的同步。
信号量与普通整型变量的区别:
- 操作方式:
信号量只能通过两个标准原子操作进行访问:等待操作(wait或P操作)和发送操作(signal或V操作)。这两个操作被称为PV原语。
普通整型变量可以在任何语句块中直接进行读取和修改,没有限制。 - 用途:
信号量主要用于实现进程间的同步和互斥,以及控制对共享资源的访问。
普通整型变量通常用于存储数据或计数,而不用于实现进程间的同步或互斥。 - 原子性:
信号量的操作是原子性的,即它们在执行时不会被中断。这确保了在多线程或多进程环境中对共享资源的正确访问。
普通整型变量的操作不是原子性的,可能会在多线程或多进程环境中导致竞态条件或数据不一致性的问题。
信号量与互斥量之间的区别:
- 互斥和同步的区别:
互斥是指同一时间只允许一个线程对资源进行访问,具有唯一性和排它性,但不限制访问顺序。
同步是在互斥的基础上,通过其他机制实现对资源的有序访问。 - 值的类型:
互斥量的值通常是二进制的,只能为0或1,表示锁的状态,即资源是否被占用。
信号量的值可以是非负整数,用来表示某个资源的可用数量或者是某个操作的计数器。 - 适用范围:
互斥量主要用于实现对单个资源的互斥访问,即同一时间只允许一个线程访问该资源。
信号量可以用于实现对多个资源的互斥访问,以及多线程之间的同步。它可以控制对多个同类资源的访问,或者实现多个线程之间的协作。 - 加锁和解锁:
对于互斥量,加锁和解锁必须由同一个线程来匹配使用,即同一个线程负责对资源的加锁和解锁。
信号量的操作没有这种限制,一个线程可以对信号量进行等待操作(减少),而另一个线程可以对信号量进行释放操作(增加),它们不需要由同一个线程来匹配使用。
2.7 套接字(socket)
套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。
Socket是应用层和传输层之间的桥梁
套接字是支持TCP/IP的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
套接字的特性由3个属性确定,它们分别是:域、端口号、协议类型。
- 套接字的域:
套接字的域决定了通信中所使用的网络介质。最常见的套接字域是AF_INET,用于Internet网络通信。另一个常见的域是AF_UNIX,用于本地进程间通信,它通过文件系统实现。 - 套接字的端口号:
端口号是套接字的标识符,用于区分主机上的不同程序(进程)。端口号是一个16位无符号整数,范围是0-65535。低于1024的端口号通常是系统保留的,用于一些标准服务(如HTTP的80端口)。 - 套接字的协议类型:
套接字可以基于不同的协议类型进行通信。常见的协议类型有:
流套接字(SOCK_STREAM):通过TCP/IP连接实现的套接字,提供可靠的、有序的、双向的字节流传输,适用于需要可靠性和顺序性的应用。
数据报套接字(SOCK_DGRAM):通过UDP/IP协议实现的套接字,不需要建立连接,适用于需要快速传输但可以容忍丢失的应用。
原始套接字(SOCK_RAW):允许对较低层次的协议直接访问,适用于需要对网络数据包进行底层操作和控制的应用,如网络监听和协议测试。
原始套接字与标准套接字的区别在于:
- 原始套接字:
原始套接字允许应用程序直接读写网络层(IP)和传输层(TCP、UDP)数据包,而不经过操作系统的处理。它可以用于访问和操作网络层和传输层协议的数据包,包括发送和接收特定协议的数据包。因此,它提供了更高级别的网络控制和自定义协议实现的能力。 - 标准套接字:
标准套接字(流套接字和数据报套接字)只能读取特定协议(TCP 或 UDP)的数据包。流套接字(SOCK_STREAM)提供面向连接的、可靠的、基于字节流的通信,适用于需要可靠性和顺序性的应用,如HTTP、FTP。数据报套接字(SOCK_DGRAM)提供无连接的、不可靠的、基于数据包的通信,适用于需要快速传输但可以容忍丢失的应用,如DNS。
套接字通信的建立
Socket通信基本流程
服务器端:
- 使用 socket() 系统调用创建一个套接字,获得一个套接字描述符,该套接字描述符用于后续通信。
- 使用 bind() 系统调用将套接字与服务器的地址和端口号绑定。
- 使用 listen() 系统调用监听连接请求,设置连接队列的最大长度。
- 使用 accept() 系统调用接受客户端的连接请求,并创建一个新的套接字用于与客户端通信,同时返回一个新的套接字描述符。
客户端:
- 使用 socket() 系统调用创建一个套接字,获得一个套接字描述符。
- 使用 connect() 系统调用连接到服务器的地址和端口号。
- 连接建立后,就可以像使用文件描述符一样,使用套接字进行双向通信,发送和接收数据。