一、管道
管道是UNIX环境中历史最悠久的进程间通信方式,分为匿名管道和命名管道。
系统操作执行命令的时候,经常有需求要将一个程序的输出交给另一个程序进行处理,这种操作可以使用输入输出重定向加文件搞定,比如:
[zorro@zorro-pc pipe]$ ls -l /etc/ > etc.txt
[zorro@zorro-pc pipe]$ wc -l etc.txt
183 etc.txt
但是这样未免显得太麻烦了。所以,管道的概念应运而生。目前在任何一个shell中,都可以使用“|”连接两个命令,shell会将前后两个进程的输入输出用一个管道相连,以便达到进程间通信的目的:
[zorro@zorro-pc pipe]$ ls -l /etc/ | wc -l
183
对比以上两种方法,我们也可以理解为,管道本质上就是一个文件,前面的进程以写方式打开文件,后面的进程以读方式打开。这样前面写完后面读,于是就实现了通信。实际上管道的设计也是遵循UNIX的“一切皆文件”设计原则的,它本质上就是一个文件。Linux系统直接把管道实现成了一种文件系统,借助VFS给应用程序提供操作接口。
虽然实现形态上是文件,但是管道本身并不占用磁盘或者其他外部存储的空间。在Linux的实现上,它占用的是内存空间。所以,Linux上的管道就是一个操作方式为文件的内存缓冲区。
1.匿名管道(PIPE)
匿名管道最常见的形态就是我们在shell操作中最常用的”|”。它的特点是只能在父子进程中使用,父进程在产生子进程前必须打开一个管道文件,然后fork产生子进程,这样子进程通过拷贝父进程的进程地址空间获得同一个管道文件的描述符,以达到使用同一个管道通信的目的。此时除了父子进程外,没人知道这个管道文件的描述符,所以通过这个管道中的信息无法传递给其他进程。
使用pipe()系统调用可以创建一个匿名管道。
fildes[]是一个文件描述符数组,fildes[0]是读端,fildes[1]是写端。 通常在读的一端关闭写端的文件描述符,在写的一端关闭读的文件描述符。
使用示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main() {
int pipe_fd[2]; // 用于存储管道的文件描述符
char message[] = "Hello, World";
char buffer[20];
// 创建管道
if (pipe(pipe_fd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
// 创建子进程
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) { // 子进程
close(pipe_fd[1]); // 关闭写端
// 从管道读取数据
read(pipe_fd[0], buffer, sizeof(buffer));
printf("子进程接收到消息: %s\n", buffer);
close(pipe_fd[0]);
} else { // 父进程
close(pipe_fd[0]); // 关闭读端
// 向管道写入数据
write(pipe_fd[1], message, strlen(message) + 1);
printf("父进程发送消息: %s\n", message);
close(pipe_fd[1]);
}
return 0;
}
2.命名管道(FIFO)
我们可以使用mkfifo命令来创建一个命名管道,这跟创建一个文件没有什么区别:
[zorro@zorro-pc pipe]$ mkfifo pipe
[zorro@zorro-pc pipe]$ ls -l pipe
prw-r--r-- 1 zorro zorro 0 Jul 14 10:44 pipe
可以看到创建出来的文件类型比较特殊,是p类型。表示这是一个管道文件。有了这个管道文件,系统中就有了对一个管道的全局名称,于是任何两个不相关的进程都可以通过这个管道文件进行通信了。比如我们现在让一个进程写这个管道文件:
[zorro@zorro-pc pipe]$ echo xxxxxxxxxxxxxx > pipe
此时这个写操作会阻塞,因为管道另一端没有人读。这是内核对管道文件定义的默认行为。此时如果有进程读这个管道,那么这个写操作的阻塞才会解除:
[zorro@zorro-pc pipe]$ cat pipe
xxxxxxxxxxxxxx
大家可以观察到,当我们cat完这个文件之后,另一端的echo命令也返回了。这就是命名管道。
我们还可以在程序中调用mkfifio()函数来创建命名管道,效果和mkfifo命令一样。
使用示例:
首先,在一个终端中运行以下命令来创建命名管道:
mkfifo myfifo
接下来,创建两个不同的C程序,一个用于写入消息,另一个用于读取消息。
writer.c(用于写入消息到命名管道):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd;
char message[] = "Hello, World";
// 打开命名管道以写入数据
fd = open("myfifo", O_WRONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 写入数据到管道
write(fd, message, sizeof(message));
printf("写入消息到管道: %s\n", message);
// 关闭文件描述符
close(fd);
return 0;
}
reader.c(用于从命名管道中读取消息):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd;
char buffer[20];
// 打开命名管道以读取数据
fd = open("myfifo", O_RDONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 从管道读取数据
read(fd, buffer, sizeof(buffer));
printf("从管道中读取消息: %s\n", buffer);
// 关闭文件描述符
close(fd);
return 0;
}
然后先运行writer再运行reader就实现了通信。
二、消息队列
- 消息队列亦称报文队列,也叫做信箱。是Linux的一种通信机制,这种通信机制传递的数据具有某种结构,而不是简单的字节流。
- 消息队列的本质其实是一个内核提供的链表,内核基于这个链表,实现了一个数据结构。
- 向消息队列中写数据,实际上是向这个数据结构中插入一个新结点;从消息队列汇总读数据,实际上是从这个数据结构中删除一个结点。
- 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法。
- 进程发送的消息会顺序写入消息队列之中,且每个消息队列都有IPC标识符唯一地进行标识。简单理解就是,每个消息队列都有一个ID号,而这个号用来区分不同的消息队列,从而保证不同消息队列之间不冲突。而每个消息队列内部也维护了一个独立的链表。
- 消息队列也有管道一样的不足,就是每个数据块的最大长度是有上限的,系统上全体队列的最大总长度也有一个上限,其定义在Linux/msg.h文件中:#define MSGMAX 8192.
1.ftok()
前面说到了,每个消息队列都需要一个唯一的IPC作为标识符。而ftok()函数即实现将文件路径名和项目的表示符转变成一个系统IPC键值。这个键值将贯穿整个进程间通信使用。
其中的pathname必须是已经存在的目录,而项目的表示符是一个8位,1个字节的值,通常情况下用a,b等字母表示。
2.msgget()
如果我们想访问消息队列的信息或者向消息队列写入信息,首先便要使用msgget()函数,它会返回一个队列标识符。
它的作用就是创建一个新的消息队列,或者访问一个已经存在的消息队列。
该函数的第一个参数不难理解,就是刚才ftok()函数生成的唯一ipc键值,它用来定位消息队列。而第二个参数则是在定位到消息队列之后的一系列权限操作。其取值有IPC_CREAT与IPC_EXCL两种:
IPC_CREAT:若内核中不存在指定队列就创建它;
IPC_EXCL:当与IPC_CREAT一起使用时,若队列已存在则出错(函数返回-1)。
实际上,第二个参数还需要与文件权限一起使用,如IPC_CREAT|00666表示若内核中不存在指定队列则创建它,同时进程可以对队列消息进行读写操作。
成功返回int类型的qid,否则返回-1.
3.msgctl()
msgctl()函数用于对消息队列进行控制和管理,包括创建、删除、修改消息队列属性以及获取消息队列信息等操作。
msqid
:表示消息队列的标识符,是通过msgget
函数获得的。cmd
:表示要执行的操作,常用的有以下几个:IPC_STAT
:获取消息队列的状态信息,并将其存储在buf
中。IPC_SET
:设置消息队列的状态信息,通过buf
中的数据来设置消息队列的属性。IPC_RMID
:删除消息队列。
buf
:一个指向struct msqid_ds
结构的指针,用于传递消息队列的状态信息或设置消息队列的属性。
返回值与cmd相关,上面三个都是成功返回0,失败返回-1.
4.msgrop
当我们通过msgget()函数得到了队列标识符,我们就可以在对应的消息队列上来执行相关的读写操作了,msgrcv()和msgsnd()函数就是用来读写消息队列的。
这里读写双方的消息缓冲区使用一个结构体定义的,这个结构体通常定义如下:
其中第一个成员mtype表示消息类型,一般用正数来表示,其作用是为某个消息设定一个类型,从而保证自己在消息队列中正确地发送和接收自己的消息;
第二个成员即具体的数据,其大小可以由我们自行重新构建。
1)msgrcv()
如果我们要读取消息,则需要用到的就是msgrcv()函数:
size_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
第一个参数是message queue id,即用来告诉系统接受哪个消息队列的消息;
第二个参数是message pointer,是一个空型指针,指向消息缓冲区结构体;
第三个参数是message size,顾名思义,就是消息的长度,它是以字节为单位的,注意,这里的大小单纯指消息的大小,并不含消息类型的大小; 第四个参数则指定要从队列中获取的消息类型,若取0,则不管是什么类型都接收。 第五个参数是message flag,它通常取0,也就是忽略它,也可以设置成IPC_NOWAIT,如果设置成后者,也就是不等待,即消息队列空了的话就不等了,若不指定的话,则会阻塞等待,直到可以读为止。
成功返回读取的字节个数,否则返回-1.
2)msgsnd()
如果我们要发送消息,则需要用到的就是msgsnd()函数:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
它的参数和msgrcv()中的对应参数一样。
成功返回0,失败返回-1.
5.使用示例
写两个程序,在两个不同的终端窗口中分别编译和运行这两个程序。一个进程用于发送消息,另一个用于接收消息。将结构体和key的定义写在头文件中,二者包含该头文件。
msgkey.h:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#define MAX_MSG_SIZE 256
// 定义消息结构
struct msg_buffer {
long msg_type;
char msg_text[MAX_MSG_SIZE];
};
#define KEYPATH "/tmp"
#define KEYPROJ 'A'
rcver.c:
#include "msgkey.h"
int main() {
key_t msg_queue_key;
int msg_queue_id;
struct msg_buffer message;
// 创建消息队列
msg_queue_key = ftok(KEYPATH, KEYPROJ); // 使用相同的键来获取消息队列
msg_queue_id = msgget(msg_queue_key, 0666);
if (msg_queue_id == -1) {
perror("msgget");
exit(1);
}
// 从消息队列接收消息
if (msgrcv(msg_queue_id, &message, sizeof(message), 1, 0) == -1) {
perror("msgrcv");
exit(1);
}
printf("接收到的消息:%s", message.msg_text);
// 删除消息队列(可选)
// if (msgctl(msg_queue_id, IPC_RMID, NULL) == -1) {
// perror("msgctl");
// exit(1);
// }
return 0;
}
snder.c:
#include "msgkey.h"
int main() {
key_t msg_queue_key;
int msg_queue_id;
struct msg_buffer message;
// 创建消息队列
msg_queue_key = ftok(KEYPATH, KEYPROJ); // 生成一个唯一的键
msg_queue_id = msgget(msg_queue_key, 0666 | IPC_CREAT);
if (msg_queue_id == -1) {
perror("msgget");
exit(1);
}
// 准备要发送的消息
message.msg_type = 1;
printf("输入要发送的消息:");
fgets(message.msg_text, MAX_MSG_SIZE, stdin);
// 将消息发送到消息队列
if (msgsnd(msg_queue_id, &message, sizeof(message), 0) == -1) {
perror("msgsnd");
exit(1);
}
printf("消息已发送:%s", message.msg_text);
return 0;
}
三、信号量
信号量是一种进程间同步机制,用于协调多个进程对共享资源的访问。信号量本质上是一个计数器,用于多进程对共享数据对象的读取,它和管道有所不同,它不以传送数据为主要目的,它主要是用来保护共享资源(信号量也属于临界资源),使得资源在一个时刻只有一个进程独享。
1. 信号量的类型
1)二进制信号量(Binary Semaphore)
二进制信号量只有两个状态,即计数只能计0和1,通常用于互斥(mutex)和同步(synchronization)操作,常用于实现互斥锁。
2)计数信号量(Counting Semaphore)
计数信号量可以有多个状态值,通常用于计数资源的可用性,例如,一个资源池中有多个资源可用。
2.semget()
semget()函数用于创建或获取一个 System V 信号量(区别于POSIX信号量)集合的标识符。
-
key
:用于标识信号量集合的键值(key value)。不同的键值对应不同的信号量集合。通常使用ftok
函数来生成一个唯一的键值,也可以使用 IPC_PRIVATE 来创建一个私有的信号量集合。 -
nsems
:要创建或获取的信号量集合中的信号量数量。通常为正整数。如果是获取可填0,表示不关心。 -
semflg
:创建信号量集合时的标志位,包括以下选项的组合:IPC_CREAT
:如果指定的键值不存在,则创建一个新的信号量集合。IPC_EXCL
:与IPC_CREAT
一起使用,如果信号量集合已存在,则返回错误。- 权限位:使用八进制表示的权限位,例如
0666
表示读写权限。这些权限位用于控制哪些进程可以访问信号量集合。
成功返回信号量id,否则返回-1.
3.semctl()
和msgctl()类似,semctl()是一个用于控制和管理 System V 信号量集合的函数。它可以用于获取信号量集合的信息、设置信号量集合的属性、操作信号量以及删除信号量集合等操作。
-
semid
:信号量集合的标识符,由semget
函数返回。 -
semnum
:要操作的信号量在集合中的索引。通常为 0,表示第一个信号量。如果信号量集合中有多个信号量,可以指定其他索引来操作不同的信号量。 -
cmd
:表示要执行的操作,可以是以下之一:GETVAL
:获取信号量的当前值。SETVAL
:设置信号量的值。GETPID
:获取上次执行semop
操作的进程ID。GETNCNT
:获取等待信号量值增加的进程数量。GETZCNT
:获取等待信号量值变为零的进程数量。IPC_STAT
:获取信号量的状态信息。IPC_SET
:设置信号量的状态信息。IPC_RMID
:删除信号量集合。
-
可选参数:根据不同的
cmd
值,semctl
函数可能需要提供额外的参数,如信号量的新值、进程ID 等。这些参数的类型和数量取决于cmd
。
成功返回值取决于cmd,失败返回-1.
4.semop()
semop()函数是用于操作 POSIX 信号量集合或 System V 信号量集合的函数,它主要用于执行对信号量的操作,如等待(wait)和释放(post)。semop()函数通过操作信号量的值来实现进程或线程间的同步和互斥。
-
semid
:信号量集合的标识符,由semget
函数返回。 -
sops
:一个指向sembuf
结构数组的指针,每个结构描述了要执行的操作。sops
是一个数组,可以包含一个或多个sembuf
结构。 -
nsops
:sops
数组中结构的数量,表示要执行的操作个数。
sembuf 结构体的定义如下:
-
sem_num
:要操作的信号量在集合中的索引。通常为0,表示第一个信号量。如果信号量集合中有多个信号量,可以指定其他索引来操作不同的信号量。 -
sem_op
:操作类型,通常为以下之一:-1
:执行 P(等待)操作,如果信号量的值为0,则等待。0
:执行等待操作,如果信号量的值不为0,则等待。1
:执行 V(释放)操作,增加信号量的值。
-
sem_flg
:操作标志,通常为SEM_UNDO
,用于在进程退出时撤销未完成的操作。如果不需要撤销操作,可以设置为0。
成功返回0,否则返回-1.
5.使用示例
可以用信号量实现生产-消费者模型。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#define NUM_SEMS 2 // 两个信号量:一个用于互斥,一个用于计数
int main() {
int semid;
key_t key = ftok("/tmp/semaphore_example", 's'); // 生成键值
// 创建一个信号量集合,包括互斥信号量和计数信号量
semid = semget(key, NUM_SEMS, IPC_CREAT | 0666);
// 初始化互斥信号量为1,计数信号量为0
semctl(semid, 0, SETVAL, 1);
semctl(semid, 1, SETVAL, 0);
// 创建子进程
pid_t pid = fork();
if (pid < 0) {
perror("Fork failed");
exit(1);
}
if (pid == 0) {
// 子进程是消费者
for (int i = 0; i < 5; i++) {
struct sembuf sem_wait = {0, -1, 0}; // P(互斥)
semop(semid, &sem_wait, 1);
// 从缓冲区中取出数据(这里简化为打印)
printf("Consumer: Consumed item %d\n", i);
struct sembuf sem_post = {1, 1, 0}; // V(计数)
semop(semid, &sem_post, 1);
sleep(1); // 模拟消费过程
}
} else {
// 父进程是生产者
for (int i = 0; i < 5; i++) {
struct sembuf sem_wait = {1, -1, 0}; // P(计数)
semop(semid, &sem_wait, 1);
// 向缓冲区中生产数据(这里简化为打印)
printf("Producer: Produced item %d\n", i);
struct sembuf sem_post = {0, 1, 0}; // V(互斥)
semop(semid, &sem_post, 1);
sleep(1); // 模拟生产过程
}
}
// 等待子进程结束
if (pid != 0) {
wait(NULL);
}
// 删除信号量集合
semctl(semid, 0, IPC_RMID);
return 0;
}
四、共享内存
共享内存允许不同的进程在它们之间共享一块内存区域。这个共享的内存区域可以被多个进程读取和写入,从而实现进程间的数据共享。Linux 提供了一些系统调用和库函数来创建、附加、分离和删除共享内存段,以及进行数据的读写。
1.shmget()
shmget()函数用于创建或获取一个共享内存段,并返回共享内存标识符。如果共享内存段已存在,可以通过shmget()获取它,否则会创建一个新的。
-
key
:用于标识共享内存段的键值。不同的键值对应不同的共享内存段。通常可以使用ftok
函数生成一个唯一的键值。 -
size
:共享内存段的大小(以字节为单位)。这是需要分配的内存大小。 -
shmflg
:创建或获取共享内存段的标志,可以包括以下选项的组合:IPC_CREAT
:如果指定的键值不存在,则创建一个新的共享内存段。IPC_EXCL
:与IPC_CREAT
一起使用,如果共享内存段已存在,则返回错误。- 权限位:使用八进制表示的权限位,例如
0666
表示读写权限。这些权限位用于控制哪些进程可以访问共享内存段。
成功返回shmid,否则返回-1.
2.shmctl()
和前面的类似,shmctl()函数用于控制和管理共享内存段。通过不同的 cmd
参数,可以执行不同的操作,如删除共享内存段、获取状态信息、设置权限等。
-
shmid
:共享内存段的标识符,通常是由shmget
函数返回的值。 -
cmd
:执行的操作,可以是以下之一:IPC_RMID
:删除共享内存段。一旦删除,共享内存段的标识符将不再有效,且共享内存段将被销毁。IPC_SET
:设置共享内存段的属性,需要提供一个struct shmid_ds
结构体作为参数来指定新的属性值。IPC_STAT
:获取共享内存段的状态信息,将共享内存段的信息填充到提供的struct shmid_ds
结构体中。
-
buf
:一个指向struct shmid_ds
结构体的指针,用于传递和接收共享内存段的状态信息。
shmid_ds结构体定义如下:
成功返回值取决于cmd,失败返回-1.
3.shmop
1)shmat()
shmat()函数用于将进程附加到一个共享内存段,返回共享内存段的首地址。附加后,进程可以读取和写入共享内存中的数据。
注意:共享内存是可以同时被访问的,也就是说一个进程附加了以后不影响另一个进程附加。
-
shmid
:共享内存段的标识符,通常是由shmget
函数返回的值。 -
shmaddr
:通常设置为 NULL,表示让内核自动选择一个合适的地址来附加共享内存。如果指定了非 NULL 的地址,内核将尝试将共享内存段附加到指定的地址,但这并不是一种推荐的做法,因为可能会导致地址冲突。 -
shmflg
:附加共享内存段的标志,通常为 0。
成功返回共享内存的地址,否则返回(void*)-1.
2)shmdt()
shmdt()函数用于将进程从一个共享内存段上分离,即不再可以访问共享内存中的数据。
shmaddr
:要分离的共享内存段的首地址。
成功返回0,否则返回-1.
4.使用示例
父进程创建一个共享内存段,然后创建子进程。父进程写入数据到共享内存,而子进程从共享内存中读取数据。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
int main() {
int shmid;
key_t key = ftok("/tmp/shared_memory_ipc_example", 's'); // 生成一个键值
// 创建共享内存段
shmid = shmget(key, 1024, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget failed");
exit(1);
}
// 附加共享内存段
char *shared_memory = (char *)shmat(shmid, NULL, 0);
if (shared_memory == (char *)(-1)) {
perror("shmat failed");
exit(1);
}
// 创建子进程
pid_t pid = fork();
if (pid < 0) {
perror("Fork failed");
exit(1);
}
if (pid == 0) {
// 子进程从共享内存中读取数据
printf("Child process reads from shared memory: %s\n", shared_memory);
// 分离共享内存
shmdt(shared_memory);
} else {
// 父进程写入数据到共享内存
strcpy(shared_memory, "Hello from parent process!");
// 等待子进程
wait(NULL);
// 分离共享内存
shmdt(shared_memory);
// 删除共享内存段
shmctl(shmid, IPC_RMID, NULL);
}
return 0;
}