Linux 进程间通信(IPC)详解
进程间通信(IPC)深入解析
一、进程间通信概述
在操作系统里,不同进程间常常需要进行数据交换、同步协调等操作,进程间通信(Inter - Process Communication,IPC)机制应运而生。在Linux系统中,常见的IPC机制有管道、信号量、共享内存、消息队列和套接字。这些机制各自具备独特的特性,适用于不同的应用场景,并且在各类系统和应用程序开发中得到广泛应用,也是技术面试中的重点考查内容。
思考问题1:为什么需要进程间通信?
在多进程的环境下,各个进程通常是独立运行的,但有些情况下,它们需要协同工作。比如,一个进程负责收集用户输入,另一个进程负责对这些输入进行处理,这就需要两个进程之间进行数据传递。另外,当多个进程需要访问同一资源时,为了避免冲突,就需要通过进程间通信来进行同步和协调。
思考问题2:不同的IPC机制适用于哪些场景?
不同的IPC机制有不同的特点和适用场景。例如,管道适合简单的父子进程间的数据传递;信号量主要用于进程间的同步和互斥;共享内存适合大量数据的快速传输;消息队列适用于需要按消息类型分类处理数据的场景;套接字则常用于网络环境下的进程间通信。
二、管道
2.1 管道分类
管道分为有名(命名)管道和无名管道。
有名管道
有名管道也叫命名管道,它以文件的形式存在于文件系统中,不同进程可以通过这个文件进行通信,即便这些进程没有亲缘关系。
mkfifo fifo # 创建一个叫做fifo的管道
创建完成后,使用ls -l
命令查看文件类型,会看到文件类型为p
,这代表该文件是一个管道文件。
无名管道
无名管道是通过系统调用pipe
创建的,它没有对应的文件系统实体,只能用于具有亲缘关系的进程(如父子进程)之间的通信。
2.2 管道的特点和阻塞行为
数据存储
管道大小在文件系统层面显示为零,但数据实际上是存储在内存中的。管道本质上是内核中的一块缓冲区,用于临时存储要传输的数据。
打开条件
读打开和写打开的进程必须同时打开管道。若只有读进程打开管道,读操作会阻塞,直到有写进程打开并写入数据;若只有写进程打开管道,写操作也会阻塞,直到有读进程打开管道读取数据。
读阻塞
读打开的进程在管道没有数据时会阻塞,直到有数据被写入管道。当所有写端关闭且管道中的数据都被读完后,读操作会返回0,表示已到达文件末尾。
写关闭处理
写打开的进程关闭管道时,读打开的进程会返回零。此时读进程知道写进程已经停止写入数据,可以结束读取操作。
2.3 如何使用管道在两个进程之间传递数据?
第一步,创建一个管道文件
使用mkfifo
命令创建一个命名管道文件,例如:
mkfifo fifo
第二步,创建两个进程,a.c
和b.c
//a.c用来往管道里面写数据:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>int main()
{int fd = open("./fifo", O_WRONLY);if (fd == -1){perror("open");exit(1);}printf("fd=%d\n", fd);while (1){printf("input:\n");char buff[128] = {0};fgets(buff, 128, stdin);if (strncmp(buff, "end", 3) == 0){break;}write(fd, buff, strlen(buff));}close(fd);exit(0);
}
//b.c用来在管道里面收数据:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>int main()
{int fd = open("./fifo", O_RDONLY);if (fd == -1){perror("open");exit(1);}printf("fd=%d\n", fd);while (1){char buff[128] = {0};int n = read(fd, buff, 127);if (n == 0){break;}printf("buff = %s\n", buff);}close(fd);exit(0);
}
第三步,同时打开两个文件,进行通信
当使用管道进行数据传输时,必须有两个进程同时打开这个管道文件,一个负责读,一个负责写,否则不能正常打开。在打开文件后,当写进程没有进行写操作前,读进程将会阻塞。
管道的特点是读打开和写打开的进程可以循环读写数据。当管道文件被关闭后,读操作返回值为0,可以作为读进程结束的条件。
2.4 无名管道
无名管道通过pipe
来创建。其实现原理是需要提供一个整型数组,数组的两个元素分别作为读端和写端的文件描述符。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>int main()
{int fd[2];if (pipe(fd) == -1){perror("pipe");exit(1);}// 父进程写,子进程读pid_t pid = fork();if (pid == -1){perror("fork");exit(1);}if (pid == 0){close(fd[1]);while (1){char buff[128] = {0};if (read(fd[0], buff, 127) == 0){break;}printf("child read:%s\n", buff);}close(fd[0]);}else{close(fd[0]);while (1){printf("input:\n");char buff[128] = {0};fgets(buff, 128, stdin);if (strncmp(buff, "end", 3) == 0){break;}write(fd[1], buff, strlen(buff));}close(fd[1]);}exit(0);
}
2.5 有名管道和无名管道的区别
- 通信范围:无名管道只能在父子进程之间通信,而有名管道可以在任意两个进程之间通信。
- 存在形式:无名管道没有对应的文件系统实体,而有名管道以文件的形式存在于文件系统中。
2.6 管道的通信方式
管道的通信方式是半双工,即能发送和接收数据,但不能同时进行发送和接收操作。
2.7 写入管道的数据位置
写入管道的数据存储在内存中。不使用文件进行数据传递是因为使用文件进行传递涉及到I/O操作,效率较低,而管道直接在内存中操作,数据传输速度更快。
2.8 管道的实现原理
假设管道有一个分配的内存空间,将其划分为一个字节一个字节的单元,有两个操作这块空间的指针。用size
来表示管道的总大小,设一个头指针和一个尾指针指向管道的起始位置。写入数据时,头指针往后移动,指向待写入的下一个位置;读数据时,读掉尾指针所在位置的数据,然后尾指针往后移动。只要尾指针赶上头指针,说明管道中的数据已读完。等到头指针指到内存最末尾时,会循环到起始地址。管道的内存是有限的,在没读掉的位置不能写入数据,就像一个循环队列一样。
思考问题3:管道的缓冲区大小有限制吗?如果数据量超过缓冲区大小会怎样?
管道的缓冲区大小是有限制的,不同的系统可能有不同的默认值,通常为几KB到几十KB不等。当数据量超过缓冲区大小时,写操作会阻塞,直到有足够的空间可以继续写入数据。这是为了防止数据溢出,保证数据的有序传输。
思考问题4:管道在多进程环境下可能会出现哪些问题?如何解决?
在多进程环境下,管道可能会出现数据竞争、死锁等问题。例如,多个写进程同时向管道写入数据可能会导致数据混乱;如果读进程和写进程的操作不协调,可能会出现死锁。解决这些问题可以使用同步机制,如信号量、互斥锁等,来协调进程对管道的访问。
2.9 写端关闭和读端关闭的处理
当写端关闭时,读端read()
会返回0;当读关闭时,写端write()
会异常终止(触发SIGPIPE
信号)。
三、信号量
3.1 PV操作
信号量通常是一个正数值,一般代表可用资源的数目。对信号量的操作主要有PV操作。
- P操作:获取资源,对信号量的值减一。如果减一后信号量的值小于0,进程会进入阻塞状态,等待其他进程释放资源。
- V操作:释放资源,对信号量的值加一。如果加一后信号量的值小于等于0,说明有进程在等待该资源,会唤醒一个等待的进程。
3.2 相关概念
- 临界资源:一次仅允许一个进程使用的共享资源,如打印机、共享内存区域等。
- 临界区:访问临界资源的代码段。为了保证临界资源的正确使用,需要对临界区进行保护,防止多个进程同时访问。
3.3 信号量的操作步骤
- 创建 初始化:使用
semget
函数创建信号量集,并使用semctl
函数进行初始化。 - P操作,获取资源:使用
semop
函数执行P操作,获取对临界资源的访问权限。 - V操作,释放资源:使用
semop
函数执行V操作,释放对临界资源的访问权限。PV操作没有严格的先后顺序,要根据当时的使用需求来决定。 - 删除信号量:使用
semctl
函数删除信号量集。
3.4 信号量的操作和接口
信号量的主要操作函数有semget
(创建)、semctl
(控制,初始化)和semop
(PV操作)。
//sem.h
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/sem.h>union semun
{int val;
};void sem_init();
void sem_p();
void sem_v();
void sem_destroy();
//sem.c
#include "sem.h"
static int semid = -1;void sem_init()
{semid = semget((key_t)1234, 1, IPC_CREAT | IPC_EXCL | 0600);if (semid == -1){semid = semget((key_t)1234, 1, IPC_CREAT | 0600);if (semid == -1){perror("semget");return;}}else{union semun a;a.val = 1;if (semctl(semid, 0, SETVAL, a) == -1) // SETVAL表示初始化值{perror("semctl setval");}}
}void sem_p()
{struct sembuf buf;buf.sem_num = 0; // 信号量的下标buf.sem_op = -1;buf.sem_flg = SEM_UNDO; // 这个标志位表示着当操作发生异常时由内核释放资源,避免资源一直占用if (semop(semid, &buf, 1) == -1){perror("semop p");}
}void sem_v()
{struct sembuf buf;buf.sem_num = 0;buf.sem_op = 1;buf.sem_flg = SEM_UNDO; // 这个标志位表示着当操作发生异常时由内核释放资源,避免资源一直占用if (semop(semid, &buf, 1) == -1){perror("semop v");}
}void sem_destroy()
{if (semctl(semid, 0, IPC_RMID) == -1) // IPC_RMID表示删除{perror("semctl destroy");}
}
3.5 创建两个进程使用信号量进行资源调用
//a.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "sem.h"int main()
{sem_init();for (int i = 0; i < 5; i++){sem_p();printf("A");fflush(stdout);int n = rand() % 3;sleep(n);printf("A");fflush(stdout);n = rand() % 3;sleep(n);sem_v();}return 0;
}
//b.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "sem.h"int main()
{sem_init();for (int i = 0; i < 5; i++){sem_p();printf("B");fflush(stdout);int n = rand() % 3;sleep(n);printf("B");fflush(stdout);n = rand() % 3;sleep(n);sem_v();}sleep(10);sem_destroy();return 0;
}
思考问题5:信号量的值可以为负数吗?负数代表什么含义?
信号量的值可以为负数。当信号量的值为负数时,其绝对值表示正在等待该资源的进程数量。例如,信号量的值为 -2,表示有两个进程正在等待该资源的释放。
思考问题6:如果在使用信号量时忘记释放资源(即没有执行V操作)会怎样?
如果忘记执行V操作,信号量的值不会增加,其他等待该资源的进程将一直处于阻塞状态,无法获取资源,从而导致死锁或资源饥饿问题。因此,在使用信号量时,必须确保在适当的时候执行V操作,释放资源。
四、共享内存
4.1 共享内存的原理和优势
共享内存是一种高效的进程间通信方式,它允许不同进程直接访问同一块物理内存区域,避免了数据的多次拷贝,从而提高了数据传输的效率。
//a.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/shm.h>int main()
{// 创建共享内存int shmid = shmget((key_t)1234, 128, IPC_CREAT | 0600);if (shmid == -1){perror("shmget");exit(1);}// 获取共享内存char* s = (char*)shmat(shmid, NULL, 0);if (s == (char*)-1){perror("shmat");exit(1);}while (1){char buff[128] = {0};fgets(buff, 128, stdin);strcpy(s, buff);if (strncmp(buff, "end", 3) == 0){break;}}// 分离共享内存shmdt(s);exit(0);
}
//b.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/shm.h>int main()
{// 创建共享内存int shmid = shmget((key_t)1234, 128, IPC_CREAT | 0600);if (shmid == -1){perror("shmget");exit(1);}// 获取共享内存char* s = (char*)shmat(shmid, NULL, 0);if (s == (char*)-1){perror("shmat");exit(1);}while (1){if (strncmp(s, "end", 3) == 0){break;}printf("s=%s", s);sleep(1);}// 分离共享内存shmdt(s);// 销毁共享内存shmctl(shmid, IPC_RMID, NULL);exit(0);
}
4.2 存在问题及改进方案
上述代码存在问题:当没有输入值的时候,b.c
会循环打印当时的共享内存中的值。改进方案是使用信号量,让两个程序不能同时访问临界资源。
4.3 共享内存和信号量的使用
需要使用信号量的两个文件sem.c
和sem.h
,跟之前不同的是之前使用了一个信号量,这次需要使用两个。
//sem.h
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/sem.h>union semun
{int val;
};void sem_init();
void sem_p(int index);
void sem_v(int index);
void sem_destroy();
//sem.c
#include "sem.h"
static int semid = -1;void sem_init()
{semid = semget((key_t)1234, 2, IPC_CREAT | IPC_EXCL | 0600);if (semid == -1){semid = semget((key_t)1234, 2, IPC_CREAT | 0600);if (semid == -1){perror("semget");return;}}else{union semun a;int arr[2] = {1, 0};for (int i = 0; i < 2; i++){a.val = arr[i];if (semctl(semid, i, SETVAL, a) == -1) // SETVAL表示初始化值{perror("semctl setval");} } }
}void sem_p(int index)
{struct sembuf buf;buf.sem_num = index; // 信号量的下标buf.sem_op = -1;buf.sem_flg = SEM_UNDO; // 这个标志位表示着当操作发生异常时由内核释放资源,避免资源一直占用if (semop(semid, &buf, 1) == -1){perror("semop p");}
}void sem_v(int index)
{struct sembuf buf;buf.sem_num = index;buf.sem_op = 1;buf.sem_flg = SEM_UNDO; // 这个标志位表示着当操作发生异常时由内核释放资源,避免资源一直占用if (semop(semid, &buf, 1) == -1){perror("semop v");}
}void sem_destroy()
{if (semctl(semid, 0, IPC_RMID) == -1) // IPC_RMID表示删除{perror("semctl destroy");}
}
//a.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/shm.h>
#include "sem.h"int main()
{// 创建共享内存int shmid = shmget((key_t)1234, 128, IPC_CREAT | 0600);if (shmid == -1){perror("shmget");exit(1);}// 映射共享内存char* s = (char*)shmat(shmid, NULL, 0);if (s == (char*)-1){perror("shmat");exit(1);}sem_init();while (1){printf("input:\n");char buff[128] = {0};fgets(buff, 128, stdin);sem_p(0); // s1,第一个信号量,初始值为1strcpy(s, buff);sem_v(1); // s2,第二个信号量,初始值为0if (strncmp(buff, "end", 3) == 0){break;}}// 分离共享内存shmdt(s);exit(0);
}
//b.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/shm.h>
#include "sem.h"int main()
{// 创建共享内存int shmid = shmget((key_t)1234, 128, IPC_CREAT | 0600);if (shmid == -1){perror("shmget");exit(1);}// 获取共享内存char* s = (char*)shmat(shmid, NULL, 0);if (s == (char*)-1){perror("shmat");exit(1);}sem_init();while (1){sem_p(1);if (strncmp(s, "end", 3) == 0){break;}printf("s=%s", s);sem_v(0);}sem_destroy();// 断开共享内存映射shmdt(s);// 销毁shmctl(shmid, IPC_RMID, NULL);exit(0);
}
4.4 共享内存和管道的区别
共享内存对物理内存进行操作,管道对内核缓冲区进行操作,二者存在以下不同:
内存位置与管理方式
- 共享内存:共享内存是在物理内存中开辟一块特定的区域,通过操作系统的内存管理机制,将其映射到不同进程的虚拟地址空间中。这样,多个进程可以直接访问同一块物理内存,实现数据共享。操作系统负责管理共享内存的分配与回收,进程通过系统调用如
shmget
、shmat
等来请求和使用共享内存。例如,在多个进程需要频繁共享大量数据,如数据库系统中多个查询进程可能需要共享数据缓存时,就可以使用共享内存。 - 管道:管道的内核缓冲区是由操作系统在内核空间中分配的一块内存区域,用于暂存管道两端进程间传输的数据。它的大小通常有一定限制,并且由操作系统自动管理其数据的进出和空间利用。当进程向管道写入数据时,数据被复制到内核缓冲区;读进程从管道读取数据时,再从内核缓冲区复制到用户空间。例如,在
ls | grep
这样的命令组合中,ls
命令的输出通过管道传输到grep
命令,中间的数据就是暂存在管道的内核缓冲区中。
数据访问特性
- 共享内存:进程可以直接对共享内存进行读写操作,就像访问自己的内存一样,速度非常快,因为不需要进行数据在用户空间和内核空间之间的复制。但是,由于多个进程可以同时访问共享内存,为了保证数据的一致性和完整性,需要使用同步机制,如信号量、互斥锁等,来协调进程对共享内存的访问。否则,可能会出现数据竞争等问题。
- 管道:管道的读写是有方向的,数据只能从写端流向读端。写进程将数据写入内核缓冲区,读进程从内核缓冲区读取数据。当管道满时,写进程会被阻塞,直到有数据被读走,腾出空间;当管道空时,读进程会被阻塞,直到有数据写入。这种机制保证了数据的有序传输,但也意味着数据的读写是顺序进行的,不能像共享内存那样随机访问。
数据可见性与持续性
- 共享内存:一旦数据被写入共享内存,只要其他进程有访问权限,就可以立即看到更新后的数据,数据在共享内存中的存在是持续性的,直到被显式修改或删除。即使所有使用共享内存的进程都暂时退出,共享内存中的数据仍然存在于物理内存中,只要没有被操作系统回收或其他进程修改,下次进程再访问时,数据依然保持原来的状态。
- 管道:管道中的数据具有临时性和一次性的特点。当数据被读进程从内核缓冲区读取后,数据就从管道中消失了,其他进程无法再次读取到相同的数据。而且,当所有与管道相关的文件描述符都被关闭后,管道所占用的内核缓冲区资源会被操作系统自动释放,其中的数据也会被清除。
思考问题7:共享内存可能会带来哪些安全隐患?如何防范?
共享内存可能带来的安全隐患包括数据泄露、数据被恶意篡改等。由于多个进程可以直接访问共享内存,若没有适当的权限控制和加密机制,敏感数据可能会被其他进程获取或修改。防范措施包括设置合理的访问权限,对共享内存中的数据进行加密处理,以及使用同步机制确保数据的一致性和完整性。
思考问题8:如果多个进程同时对共享内存进行写操作会怎样?
如果多个进程同时对共享内存进行写操作,会导致数据竞争问题,可能会使共享内存中的数据变得混乱,出现数据不一致的情况。为了避免这种情况,需要使用同步机制,如信号量、互斥锁等,来保证同一时间只有一个进程可以对共享内存进行写操作。
五、消息队列
5.1 消息队列的特点
添加消息和读取消息是消息队列的基本操作。消息是一个结构体,结构体名字由自己定义,特殊的地方是第一个成员是长整型(代表消息的类型),且该类型的值至少为1。
为什么消息类型必须大于零?
长整型如果是0号,就是不区分消息类型,在函数调用中,要是传入0的话,无论是什么消息类型都能读到。这样可以根据不同的消息类型对消息进行分类处理,提高消息处理的灵活性和效率。
5.2 消息队列的接口函数
msgget()
:创建或获取消息队列。
int msgget(key_t key, int msgflg);
key
是消息队列的键值,用于唯一标识一个消息队列;msgflg
是标志位,用于指定创建方式和权限等。
msgrcv()
:读取消息。
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msqid
是消息队列的标识符;msgp
是用于存储接收到的消息的缓冲区;msgsz
是缓冲区的大小;msgtyp
是期望接收的消息类型;msgflg
是标志位,用于指定操作方式。
msgsnd()
:发送消息。
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
参数含义与msgrcv
类似。
5.3 示例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/msg.h>struct mess
{long type;char buff[128];
};int main()
{int msgid = msgget((key_t)1234, IPC_CREAT | 0600);if (msgid == -1){perror("msgget");exit(1);}struct mess m;m.type = 1;strcpy(m.buff, "hello");if (msgsnd(msgid, &m, sizeof(m.buff), 0) == -1){perror("msgsnd");exit(1);}exit(0);
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/msg.h>struct mess
{long type;char buff[128];
};int main()
{int msgid = msgget((key_t)1234, IPC_CREAT | 0600);if (msgid == -1){perror("msgget");exit(1);}struct mess m;if (msgrcv(msgid, &m, sizeof(m.buff), 1, 0) == -1){perror("msgrcv");exit(1);}printf("read:%s\n", m.buff);exit(0);
}
思考问题9:消息队列的大小有限制吗?如果消息队列满了会怎样?
消息队列的大小是有限制的,不同的系统可能有不同的默认值。当消息队列满了时,后续的msgsnd
操作会阻塞,直到队列中有空间可以容纳新的消息。这是为了防止消息队列溢出,保证消息的有序存储和处理。
思考问题10:消息队列在分布式系统中有哪些应用场景?
在分布式系统中,消息队列可以用于异步通信、任务调度、解耦服务等场景。例如,在一个电商系统中,订单服务在处理订单时可以将订单信息发送到消息队列中,库存服务、物流服务等可以从消息队列中获取订单信息并进行相应的处理,这样可以提高系统的并发处理能力和可扩展性。
六、IPC管理命令
6.1 ipcs
ipcs
命令可以查看消息队列、共享内存、信号量的使用情况。例如:
ipcs -m
:查看共享内存的使用信息。ipcs -q
:查看消息队列的使用信息。ipcs -s
:查看信号量的使用信息。
6.2 ipcrm
使用ipcrm
命令可以进行删除操作。手动移除的命令格式为ipcrm -s/m/q +id
,分别用于删除信号量、共享内存、消息队列。例如:
ipcrm -m 1234
:删除ID为1234的共享内存段。ipcrm -q 5678
:删除ID为5678的消息队列。ipcrm -s 9012
:删除ID为9012的信号量集。
通过这些命令,系统管理员可以方便地管理系统中的IPC资源,确保系统的稳定运行。
思考问题11:在使用ipcrm
命令删除IPC资源时需要注意什么?
在使用ipcrm
命令删除IPC资源时,需要确保该资源不再被其他进程使用。如果在其他进程还在使用该资源时删除,可能会导致这些进程出现异常,甚至崩溃。因此,在删除之前,需要先确认相关进程已经停止使用该资源,或者采取适当的同步机制来确保安全删除。
思考问题12:如何定期清理系统中的IPC资源,以避免资源浪费?
可以编写脚本,结合ipcs
命令查看IPC资源的使用情况,根据一定的规则(如资源的使用时间、是否有进程关联等)筛选出不再使用的资源,然后使用ipcrm
命令进行删除。还可以设置定时任务,定期执行该脚本,以确保系统中的IPC资源得到及时清理。