进程作为独立的执行单元,各自拥有独立的内存空间和系统资源。然而,在实际的软件开发中,进程间往往需要相互协作、共享数据以及传递信息,这就引出了进程间通信(Inter - Process Communication,IPC)的概念。
进程间通信目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
如何通信呢?
进程具有独立性
前提:先得让不同的进程,看到同一份资源。
- 同一份资源:某种形式的内存空间。
- 提供资源的人只能是操作系统!(任何进程自己提供,其他进程都无法看见,要维护进程独立性)
- 进程想要访问OS的资源,少不了系统调用!!!
- 设计通信方案(进程间通信的系统调用),需要一套标准 ---- SystemIPC...
通信分类:
本地通信 --- 同一台主机,同一个OS,不同的进程之间通信 ---
网络通信 --- TODO
本地通信
进程间通信发展
管道
SystemV 进程间通信
posix进程间通信
管道
我们把从一个进程连接到另一个进程的一个数据流称为一个管道。
管道只能单向通信,一边读,另一边写。
如果打开了管道,但是不关闭,会造成文件描述符fd泄露。(不关,也可以,但是会造成误操作)
匿名管道pipe:
- 进程看到了同一份资源,并且有各自分别的读写位置,可以分别在文件内核级缓冲区进行读写操作(但是不是完全共享,会引发其他问题) --- 这个缓冲区是OS提供的,不是进程自己创建的。
- 匿名管道(无名管道)通常需要先创建管道,再创建进程来使用。
- 匿名管道用于具有亲缘关系(如父子进程)的进程间通信。创建匿名管道的函数通常是
pipe()
,它会创建一个管道,并返回两个文件描述符,分别用于管道的读端和写端。在创建管道后,通常会使用fork()
函数创建子进程,这样父进程和子进程就可以通过之前创建的管道进行通信。因为匿名管道没有名字,只能通过文件描述符来访问,所以必须先创建管道得到文件描述符,然后在创建的子进程中使用这些文件描述符来实现进程间的通信。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
进程管道读写的过程
创建管道
使用pipe()
函数创建一个匿名管道,该函数会在内核中创建一个管道对象,并返回两个文件描述符,通常记为fd[0]
和fd[1]
,fd[0]
用于从管道读取数据,fd[1]
用于向管道写入数据。
pipe()
函数的特性:它规定了第一个元素(下标为 0)代表读端,第二个元素(下标为 1)代表写端。
#include <stdio.h>
#include <unistd.h>
int main() {
int fd[2];
if (pipe(fd) == -1) {
perror("pipe");
return 1;
}
// 这里可以继续进行后续操作,如创建进程等
return 0;
}
创建进程(可选)
通常在创建管道后,会使用fork()
函数创建子进程。子进程会继承父进程的文件描述符,这样父子进程就可以通过管道进行通信。(子进程读,关闭写段fd[1]...)
#include <stdio.h>
#include <unistd.h>
int main() {
int fd[2];
if (pipe(fd) == -1) {
perror("pipe");
return 1;
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
} else if (pid == 0) {
// 子进程代码
close(fd[1]); // 子进程关闭写端,因为子进程只用于读数据
// 这里可以从fd[0]读取数据
} else {
// 父进程代码
close(fd[0]); // 父进程关闭读端,因为父进程只用于写数据
// 这里可以向fd[1]写入数据
}
return 0;
}
读写文件并通过管道传递数据
- 写入端:在拥有写端文件描述符的进程中,可以使用
write()
函数将文件数据写入管道。首先需要打开文件,然后将文件内容读取到缓冲区,再通过write()
函数将缓冲区中的数据写入管道。
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#define BUFFER_SIZE 1024
int main() {
int fd[2];
if (pipe(fd) == -1) {
perror("pipe");
return 1;
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
} else if (pid == 0) {
close(fd[1]);
// 子进程从管道读数据并输出到标准输出
//【 】->标准输出buffer
char buffer[BUFFER_SIZE];
ssize_t n;
while ((n = read(fd[0], buffer, BUFFER_SIZE)) > 0) {
write(1, buffer, n);//写入缓冲区
}
close(fd[0]);
} else {
close(fd[0]);
// 父进程打开文件,读取文件内容并写入管道
int file_fd = open("test.txt", O_RDONLY);//打开文件
if (file_fd == -1) {
perror("open");
return 1;
}
char buffer[BUFFER_SIZE];
ssize_t n;
while ((n = read(file_fd, buffer, BUFFER_SIZE)) > 0) { //读到了
write(fd[1], buffer, n); //就写入管道
// buffer ->【 】
}
close(file_fd);
close(fd[1]);
}
return 0;
}
父进程打开一个名为test.txt
的文件,将文件内容逐块读取到缓冲区,然后通过管道的写端fd[1]
将数据写入管道。
- 读取端:在拥有读端文件描述符的进程中,使用
read()
函数从管道读取数据。可以将读取到的数据进行处理,例如输出到标准输出或进行其他操作。如上述代码中的子进程,通过read()
函数从管道的读端fd[0]
读取数据,并将数据输出到标准输出。
关闭文件描述符和管道
在使用完管道和文件后,需要及时关闭相应的文件描述符,以释放资源并避免文件描述符泄露。可以使用close()
函数来关闭文件描述符。
注意事项:
- 管道为空&&管道正常,read会阻塞
- 管道为满&&管道正常,write会阻塞
- 管道有上限:ubuntu:64kb(65536)
- 管道写端关闭&&读端继续,读端读到0,表示文件结尾
- 管道写端正常&&读端继续,OS会直接杀掉( 向目标进程发送13号信号sigpipe )写入的那个进程!!
匿名管道特性:面向字节流,常用于父子进程,文件的生命周期随进程,单向数据通信,管道自带同步互斥等保护机制。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
管道通信的场景---进程池
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
命名管道
- 对于命名管道(FIFO),虽然也可以先创建管道再创建进程,但它也支持在进程创建之后再创建命名管道进行通信,因为命名管道有一个唯一的路径名与之关联,进程可以通过该路径名来打开和访问命名管道,不依赖于创建管道时的文件描述符在进程间的传递。
- 为什么叫命名管道?:真正存在的文件,路径+文件名:具有唯一性。
- 它是如何让不同的进程看到同一份资源的呢? 让不同的进程,用同一个文件系统路径,标志同一个资源。
mkfifo
命令
用于创建命名管道(也称为 FIFO,First In First Out)。
命名管道是一种特殊的文件类型,它允许不相关的进程进行通信,与匿名管道不同,命名管道可以在文件系统中以文件的形式存在,不同进程可以通过该文件进行数据的读写操作。
基本语法
$ mkfifo 文件名
常用选项
-m, --mode=模式
:指定创建的命名管道的权限模式,模式可以使用八进制数表示,例如0666
表示所有用户都有读写权限。--help
:显示帮助信息。--version
:显示版本信息。
读写操作示例
下面是一个简单的示例,展示如何使用命名管道进行进程间通信:
- 写入数据:
echo "Hello, named pipe!" > myfifo
- 读取数据:
cat myfifo
需要注意的是,在写入数据时,如果没有进程同时从命名管道中读取数据,写入操作会被阻塞,直到有进程开始读取数据;反之,读取操作也会被阻塞,直到有进程开始写入数据。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
进程值
在 Linux 中,与命名管道有关的进程值主要涉及以下几个方面:
进程 ID(PID)
每个使用命名管道的进程都有一个唯一的进程 ID。PID 是系统用于标识进程的数字,通过它可以对进程进行各种操作,如发送信号、查询进程状态等。
文件描述符
当进程打开命名管道时,会获得一个文件描述符。文件描述符是一个非负整数,它是进程访问命名管道的句柄。进程通过文件描述符来执行对命名管道的读写等操作。
管道缓冲区相关的值
- 缓冲区大小:命名管道有一个缓冲区,用于临时存储读写的数据。不同系统上命名管道缓冲区的默认大小可能不同,可以通过一些系统调用来获取或设置缓冲区大小。
- 缓冲区使用状态:进程在读写命名管道时,需要了解缓冲区的使用状态,例如缓冲区中当前有多少数据可读,以及还有多少空间可用于写入数据。
进程状态标志
进程在与命名管道交互时,会有一些状态标志来表示其当前的操作状态。例如,当进程正在从命名管道读取数据时,可能会设置一个标志表示其处于 “读阻塞” 状态,直到有数据可读或者遇到管道关闭等情况。
信号相关的值
进程在使用命名管道时,可能会接收到各种信号,这些信号会影响进程的行为。例如,当命名管道的另一端关闭时,写进程可能会接收到SIGPIPE
信号,默认情况下,进程收到该信号后会终止。进程可以通过设置信号处理函数来捕获和处理这些信号,以便在接收到信号后执行特定的操作,如进行错误处理、资源释放等。另外,一些信号还可以用于控制进程对命名管道的操作,如SIGSTOP
信号可以暂停进程对命名管道的读写操作,而SIGCONT
信号可以恢复进程的操作。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
IPC:(在OS层面IPC是同类资源)
不同进程可能需要共享数据、传递信息或协调工作流程,例如多个进程可能需要共同访问和修改同一个数据库,或者一个进程需要将处理结果传递给另一个进程。IPC 提供了一种标准化的方式来实现这些交互,确保进程之间能够高效、安全地进行通信和协作。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
System V IPC (IPC:进程间通信)
System V 共享内存是 Unix 系统中一种进程间通信(IPC)的机制:
概念
- 它允许不同的进程访问同一块物理内存区域,使得多个进程可以直接通过该共享区域进行数据交换和共享,而无需进行频繁的数据复制,从而提高了进程间通信的效率。
特点
- 高效性:由于数据直接在共享内存中进行读写,避免了数据在不同进程地址空间之间的复制,大大提高了数据传输的速度,尤其适用于大量数据的共享和交互。
- 数据共享性:多个进程可以同时访问和修改共享内存中的数据,实现了数据的共享和同步。
相关函数
shmget()
:用于创建一个新的共享内存段或获取一个已存在的共享内存段的标识符。- 函数原型:
int shmget(key_t key, size_t size, int shmflg);
- 参数说明:
key
是共享内存的键值,用于唯一标识共享内存段;size
是共享内存段的大小;shmflg
是标志位,用于指定共享内存的访问权限和创建方式等。
- 函数原型:
shmat()
:用于将共享内存段连接到进程的地址空间中,使进程能够访问共享内存。- 函数原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 参数说明:
shmid
是共享内存段的标识符;shmaddr
是指定连接共享内存的地址,通常设为NULL
,表示由系统自动选择合适的地址;
- 函数原型:
shmdt()
:用于将共享内存段从进程的地址空间中分离。- 函数原型:
int shmdt(const void *shmaddr);
- 参数说明:
shmaddr
是之前通过shmat()
连接到进程地址空间的共享内存地址。
- 函数原型:
shmctl()
:用于对共享内存段进行控制操作,如设置共享内存的属性、删除共享内存段等。- 函数原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 参数说明:
shmid
是共享内存段的标识符;cmd
是指定要执行的控制命令;buf
是一个指向shmid_ds
结构体的指针,用于存储或设置共享内存的相关属性。
- 函数原型:
工作原理
- 系统为共享内存分配一段物理内存,并在内存管理中维护相关的数据结构来记录共享内存的信息,如所有者、大小、访问权限等。
- 当进程通过
shmget()
创建或获取共享内存段后,内核会为该共享内存段分配一个唯一的标识符shmid
。 - 进程使用
shmat()
将共享内存段连接到自己的地址空间,此时进程可以像访问普通内存一样对共享内存进行读写操作。 - 多个进程通过相同的
key
值获取到同一个共享内存段的标识符,从而都能连接到该共享内存段,实现了数据的共享。
使用场景
- 数据库系统:数据库管理系统可以使用共享内存来缓存数据和索引,多个数据库进程可以同时访问共享内存中的数据,提高数据的访问效率和并发处理能力。
- 分布式系统:在分布式系统中,不同节点上的进程可能需要共享一些公共的配置信息、状态信息等,通过共享内存可以实现这些信息的快速共享和同步。
- 图形界面系统:图形界面系统中的多个进程,如窗口管理器、应用程序等,可能需要共享一些图形数据和显示信息,共享内存可以提供一种高效的方式来实现这种共享。
应用场景较少
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
System V 消息队列
- 概念:消息队列是一种进程间通信机制,它允许进程以消息的形式进行数据交换。消息队列提供了一种异步的通信方式,进程可以将消息发送到队列中,而不必等待接收方立即处理。
- 特点
- 消息类型:每个消息都有一个类型字段,接收进程可以根据消息类型有选择地接收消息,这使得消息队列可以用于不同类型数据的通信。
- 异步通信:发送进程可以随时将消息发送到消息队列,而接收进程可以在合适的时候从队列中获取消息,发送和接收操作不需要同步进行,提高了进程间通信的灵活性。
- 数据可靠性:消息队列中的消息会一直存在,直到被接收进程取走或队列被删除,即使发送进程或接收进程在消息传递过程中崩溃,消息也不会丢失(前提是系统没有崩溃)。
- 应用场景:适用于需要异步处理消息的场景,如日志记录系统,多个进程可以将日志消息发送到消息队列,而日志处理进程可以从队列中读取消息并进行处理,不会影响其他进程的运行。
System V 共享内存
- 概念:共享内存是一种高性能的进程间通信机制,它允许多个进程共享同一块物理内存区域,使得进程可以直接读写共享内存中的数据,从而实现高效的数据共享和通信。
- 特点
- 高效性:由于多个进程直接访问同一块内存,避免了数据在不同进程地址空间之间的复制,大大提高了数据传输的效率,适用于大量数据的共享和频繁的进程间通信。
- 灵活性:共享内存可以被多个进程同时读写,进程可以根据需要在共享内存中定义各种数据结构,如数组、结构体等,以满足不同的通信需求。
- 需要同步机制:因为多个进程可以同时访问共享内存,所以需要使用同步机制(如信号量、互斥锁等)来保证数据的一致性和完整性,防止多个进程同时对共享内存进行读写操作而导致数据混乱。
- 应用场景:常用于需要频繁进行数据共享和交换的场景,如数据库系统中的缓冲区管理,多个数据库进程可以共享一块内存作为缓冲区,用于缓存数据页,提高数据库的访问效率。
System V 信号量
- 概念:信号量是一种用于实现进程同步和互斥的机制,它本质上是一个计数器,用于控制多个进程对共享资源的访问。
- 特点
- 计数功能:信号量的值表示当前可用的共享资源数量。当进程访问共享资源时,需要先获取信号量,如果信号量的值大于 0,则进程可以访问资源,并将信号量的值减 1;如果信号量的值为 0,则进程需要等待,直到其他进程释放资源并增加信号量的值。
- 互斥与同步:通过对信号量的操作,可以实现进程之间的互斥访问,即同一时刻只有一个进程能够访问共享资源,也可以用于进程之间的同步,协调多个进程的执行顺序。
- 多种操作原语:通常有 P 操作(也称为 wait 操作)和 V 操作(也称为 signal 操作),P 操作用于申请资源,V 操作用于释放资源,通过这两个操作的组合来实现对信号量的控制。
相关函数:
semget/semctl
semget
函数用于创建一个新的信号量集或者获取一个已经存在的信号量集。信号量集是一组信号量的集合,每个信号量都有一个整数值,用于控制对共享资源的访问。
int semget(key_t key, int nsems, int semflg);
参数解释
key
:一个用于标识信号量集的键值。可以使用ftok
函数生成一个唯一的键值,也可以使用特殊的键值IPC_PRIVATE
来创建一个私有的信号量集。nsems
:指定信号量集中信号量的数量。如果是创建新的信号量集,需要指定具体的数量;如果是获取已存在的信号量集,这个参数的值应该和创建时指定的数量一致。semflg
:标志位,用于指定创建或获取信号量集的方式。可以使用IPC_CREAT
标志来创建新的信号量集,如果信号量集已经存在,则返回其标识符;还可以使用IPC_EXCL
标志与IPC_CREAT
一起使用,确保只有在信号量集不存在时才创建,否则返回错误。
semctl
函数用于对信号量集进行控制操作,例如初始化信号量的值、删除信号量集、获取信号量的当前值等。
int semctl(int semid, int semnum, int cmd, ...);
参数解释
semid
:信号量集的标识符,由semget
函数返回。semnum
:指定要操作的信号量在信号量集中的索引,从 0 开始。cmd
:指定要执行的控制命令,常见的命令有:SETVAL
:设置指定信号量的值。GETVAL
:获取指定信号量的当前值。IPC_RMID
:删除信号量集。
...
:可变参数,根据不同的命令需要传递不同的参数。例如,当使用SETVAL
命令时,需要传递一个int
类型的值作为信号量的初始值。
semget
用于创建或获取信号量集,而 semctl
用于对信号量集进行各种控制操作。
应用场景:在多进程并发访问共享资源的场景中,信号量用于确保资源的正确使用和保护,例如多个进程同时访问一个文件或数据库记录时,信号量可以防止数据冲突和不一致性。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
信号量
在 Linux 的多进程和多线程编程里,信号量是一种至关重要的同步原语,用于协调对共享资源的访问,防止多个进程或线程同时访问共享资源而引发数据不一致或其他并发问题。
概念
信号量本质上是一个具有非负整数值的变量,并且有两个原子操作:
P
操作(也称为 wait
操作)和 V
操作(也称为 signal
操作)。
P
操作会让信号量的值减 1,如果信号量的值在操作前为 0,则进程或线程会被阻塞
;V
操作会使信号量的值加 1,若有进程或线程因等待该信号量而被阻塞,其中一个会被唤醒。
类型
在 Linux 中,信号量主要有以下两种类型:
- System V 信号量:这是一种传统的信号量实现,它以信号量集的形式存在,也就是一组信号量。System V 信号量使用
semget
、semctl
和semop
等系统调用来进行创建、控制和操作。 - POSIX 信号量:这是 POSIX 标准定义的信号量,它有两种变体:命名信号量和无名信号量。命名信号量可以在不同的进程间共享,通过文件系统中的名称来标识;无名信号量通常用于线程间同步,存于共享内存区域。POSIX 信号量使用
sem_open
、sem_wait
、sem_post
等函数来操作。
工作原理
信号量的工作基于 P
操作和 V
操作:
P(S):①将信号量S的值减1,即S=S-1; ②如果S>=0,则该进程继续执行;否则该进程置为等待状态,排入等待队列。
V(S):①将信号量S的值加1,即S=S+1; ②如果S>0,则该进程继续执行;否则释放队列中第一个等待信号量的进程。
PV操作的原理(举个例子):
- 信号量的初始值通常被设置为系统中某种资源的数量。例如,有一个共享资源池,初始时有 5 个可用资源,那么对应的信号量初始值就设为 5。
- 当进程想要访问该共享资源时,会执行 P 操作,将信号量的值减 1。这相当于申请一个资源,若减 1 后信号量的值仍然大于等于 0,说明还有可用资源,该进程可以继续执行,即可以获取资源进行相应操作。
- 但若减 1 后信号量的值小于 0,说明资源已被其他进程全部占用,没有可用资源了。此时,该进程就不具备继续执行的条件,只能被阻塞,放入等待队列,直到其他进程释放资源(执行 V 操作)使信号量的值大于 0,才有可能被唤醒并获得资源继续执行。
使用场景
信号量在以下场景中应用广泛:
- 互斥访问:确保同一时间只有一个进程或线程能够访问共享资源,避免数据竞争。
- 同步执行:控制多个进程或线程的执行顺序,例如一个线程需要等待另一个线程完成某个任务后才能继续执行。
- 资源计数:用于记录可用资源的数量,当资源可用时,进程或线程可以获取资源;当资源使用完毕后,释放资源。
注意事项:
IPC资源必须删除,否则不会自动清除,除非重启,所以System V IPC 资源的生命周期随内核。