进程间通信(Inter-Process Communication, IPC)

进程间通信有哪些方式

  • 共享内存
  • 管道
  • 消息队列
  • 信号量
  • 套接字

进程间通信(IPC)的连接可以通过多种机制建立,每种机制都有其特定的用途和实现方式。以下是几种常见的 IPC 机制及其连接建立方式:

1. 管道(Pipe)

  • 匿名管道

    • 只能在父子进程之间使用
    • 通过pipe()系统调用创建
    • 创建后返回两个文件描述符:一个用于读,一个用于写
    1
    2
    3
    4
    5
    int pipefd[2];
    if (pipe(pipefd) == -1) {
    perror("pipe");
    exit(EXIT_FAILURE);
    }
  • 命名管道(FIFO)

    • 可以在无亲缘关系的进程间使用
    • 通过mkfifo()创建一个特殊文件
    • 进程通过打开这个文件进行读写
    1
    mkfifo("/tmp/myfifo", 0666);

2. 消息队列

  • 通过msgget()创建或获取一个消息队列标识符

  • 使用msgsnd()msgrcv()进行消息发送和接收

  • 使用msgctl()进行消息队列控制,如删除消息队列或修改消息队列的权限

  • 消息队列是基于消息的,消息队列是保存在内核中的链表,消息队列中每个消息都有一个类型和优先级

  • 支持多进程并发访问

    1
    2
    key_t key = ftok("progfile", 65);
    int msgid = msgget(key, 0666 | IPC_CREAT);

3. 共享内存

  • 使用共享内存的很重要原因是性能,内核为需要通信的进程分配了一块内存,进程可以直接访问这块内存,不需要内核介入

  • 当进程不再希望共享内存时,通过shmdt()将共享内存段从进程的地址空间分离,取消共享内存段与进程的地址空间的映射,只影响当前进程,其他正在使用共享内存的进程不受影响

  • 通过shmget()创建或获取一个共享内存段

  • 使用shmat()将共享内存段附加到进程的地址空间

    1
    2
    3
    key_t key = ftok("shmfile", 65);
    int shmid = shmget(key, 1024, 0666 | IPC_CREAT);
    char *str = (char*) shmat(shmid, (void*)0, 0);

4. 信号量

  • 信号量是用于进程间同步的机制

  • 信号量的主要操作是两个原语:P操作和V操作,计数器值只能在 0 和 1 之间变化

    • P操作:计数器减一,如果计数器为 0,无法减一,则阻塞,直至计数器大于 0,减一成功
    • V操作:计数器加一,如果计数器超过 1,则无法加一,忽略本次操作
  • 通过semget()创建或获取一个信号量集

  • 使用semop()进行信号量操作

    1
    2
    key_t key = ftok("semfile", 65);
    int semid = semget(key, 1, 0666 | IPC_CREAT);

5. 套接字(Sockets)

  • 本地套接字(UNIX 域套接字)

    • 通过socket()创建
    • 使用bind()listen()accept()建立连接
    1
    2
    3
    4
    5
    6
    int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    struct sockaddr_un addr;
    addr.sun_family = AF_UNIX;
    strcpy(addr.sun_path, "/tmp/mysocket");
    bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
    listen(sockfd, 5);
  • 网络套接字

    • 用于不同主机间的通信
    • 通过socket()创建
    • 使用connect()bind()listen()accept()建立连接
    1
    2
    3
    4
    5
    6
    7
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
    listen(sockfd, 5);

6. 信号

  • 通过kill()发送信号

  • 进程通过signal()sigaction()设置信号处理函数

  • Linux 常规信号有 32 个,信号值范围为 1-31,信号值为 0 的信号通常用于进程间同步;POSIX 引入了 32 个信号(实时信号),信号值范围为 32-64

  • 使用信号,一个进程可以随时通知另一个进程某个事件的发生,并且接收者不需要阻塞等待该事件,内核会帮助其切换到对应的处理函数中响应信号事件

    1
    2
    signal(SIGUSR1, handler_function);
    kill(pid, SIGUSR1);

信号表

在终端,可通过kill -l查看所有的 signal 信号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
:~$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX

宏内核进程间通信机制对比

请给出一个管道的设计,使其支持双向通信

实现双向通信的管道可以通过创建两个单向管道来完成,一个用于从 A 到 B 的通信,另一个用于从 B 到 A 的通信。以下是实现双向管道通信的设计方案:


1. 管道结构设计

  • 管道 A->B: 一个单向管道,负责从进程 A 向进程 B 发送数据。
  • 管道 B->A: 另一个单向管道,负责从进程 B 向进程 A 发送数据。

2. 关键步骤

假设使用 C 语言和 POSIX 标准管道 (pipe):

(1) 创建管道

每个管道由一对文件描述符表示:fd[0] 用于读,fd[1] 用于写。

1
2
3
4
5
6
7
8
int pipeAB[2]; // A->B
int pipeBA[2]; // B->A

// 创建两个单向管道
if (pipe(pipeAB) == -1 || pipe(pipeBA) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}

(2) 在父子进程间通信

  • 父进程 A: 关闭 pipeAB[0]pipeBA[1],仅用于写入到 pipeAB 和读取 pipeBA
  • 子进程 B: 关闭 pipeAB[1]pipeBA[0],仅用于读取 pipeAB 和写入到 pipeBA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
pid_t pid = fork();
if (pid == 0) {
// 子进程B
close(pipeAB[1]); // 关闭写端
close(pipeBA[0]); // 关闭读端

char buffer[256];
// 从 A->B 读取数据
read(pipeAB[0], buffer, sizeof(buffer));
printf("B received: %s\n", buffer);

// 向 B->A 写入数据
write(pipeBA[1], "Hello A", 7);
close(pipeAB[0]);
close(pipeBA[1]);
exit(EXIT_SUCCESS);
} else if (pid > 0) {
// 父进程A
close(pipeAB[0]); // 关闭读端
close(pipeBA[1]); // 关闭写端

// 向 A->B 写入数据
write(pipeAB[1], "Hello B", 7);

char buffer[256];
// 从 B->A 读取数据
read(pipeBA[0], buffer, sizeof(buffer));
printf("A received: %s\n", buffer);

close(pipeAB[1]);
close(pipeBA[0]);
}

3. 使用 selectpoll 支持异步通信

为了避免阻塞,可使用 selectpoll 对管道的读写操作进行多路复用,检查管道是否有数据可读。


4. 其他方案

除了 POSIX 管道,还可以使用以下方法实现双向通信:

  1. Socket: 使用 socketpair 创建全双工通信。
  2. 消息队列: 使用 System V 或 POSIX 消息队列。
  3. 共享内存: 配合信号量实现双向通信。
  4. FIFO(命名管道): 通过创建两个命名管道实现双向通信。

选择哪种方式取决于具体的应用场景和需求。如果是本地进程间通信,pipesocketpair 是推荐的简单方案。

选择合适的 IPC 机制

  • 管道适合简单的父子进程通信
  • 消息队列适合需要消息排序和优先级的场景
  • 共享内存适合需要高效大数据量传输的场景
  • 信号量常用于进程间同步
  • 套接字适合网络通信或复杂的本地通信

每种 IPC 机制都有其特定的应用场景和优缺点,选择时应根据具体需求和系统环境进行权衡。

在进程间通信过程中, 数据和控制流分别是如何传输和转移的

一种常见的通信数据的抽象是消息。消息一般包含消息头和消息体。消息头中包含消息类型、消息长度、消息优先级等控制信息,消息体中包含实际需要传输的数据。

进程间通信的连接是如何建立的

通信过程一般指通信的进程间具体的通信发起、回复、结束的过程。

发送者将要传输的数据内容拷贝到发送者消息上,然后一次设置头部的状态(设置为“准备就绪”等)。

接收者不停地轮询发送者消息的状态信息,当发现消息头部的状态变为“准备就绪”时,就表示发送者发送了一个消息。

发送者一发送完消息,就开始轮询接收者消息的状态信息,当发现接收者消息头部的状态变为“准备就绪”时,就表示接收者接收到了一个消息。

接收者在读取发送者的消息后,处理请求,并在接受者消息上准备返回结果。

发送者不停地轮询接收者消息的状态信息,当发现接收者消息头部的状态变为“返回结果”时,就表示接收者处理完了消息,并准备返回结果。

发送者读取接收者消息中的返回结果,并从消息中删除返回结果。

直接通信和间接通信

直接通信:发送者和接收者都知道彼此的标识符。比如进程号。

间接通信:发送者和接收者不知道彼此的标识符,需要一个中间实体(如消息队列、共享内存、管道等)来传递消息。

什么是超时机制, 为什么需要超时机制

超时机制是进程间通信中的一种机制,用于处理通信过程中可能出现的延迟或失败情况。允许发送者/接收者设置一个超时时间,如果在这个时间内没有收到回复,则认为通信失败。由操作系统内核结束此次 IPC 调用,返回一个超时的错误。

一个进程如何找到另一个进程提供的服务

一个进程找到另一个进程提供的服务,可以通过以下几种常见的通信机制实现,具体选择取决于操作系统环境和应用场景:


1. 使用命名管道 (FIFO)

  • 适用场景: 本地进程间通信,简单高效。
  • 实现步骤:
    1. 服务端创建一个命名管道(FIFO)。
    2. 客户端打开该命名管道进行读写。
    3. 使用文件路径标识管道。
1
2
3
4
5
6
7
// 服务端
mkfifo("/tmp/myservice", 0666); // 创建命名管道
int fd = open("/tmp/myservice", O_RDONLY); // 打开管道用于读取

// 客户端
int fd = open("/tmp/myservice", O_WRONLY); // 打开管道用于写入
write(fd, "Hello Service", strlen("Hello Service"));

2. 使用套接字(Socket)

  • 适用场景: 本地或分布式场景,通过网络通信。
  • 实现步骤:
    1. 服务端监听一个固定的地址和端口。
    2. 客户端通过地址和端口连接到服务端。
1
2
3
4
5
6
7
8
9
10
11
// 服务端
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in address = { ... }; // 设置地址和端口
bind(server_fd, (struct sockaddr*)&address, sizeof(address));
listen(server_fd, 3);
int client_fd = accept(server_fd, NULL, NULL); // 等待客户端连接

// 客户端
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
connect(client_fd, (struct sockaddr*)&address, sizeof(address));
write(client_fd, "Hello Service", strlen("Hello Service"));

3. 使用共享内存

  • 适用场景: 本地进程间通信,数据量大且需要高效。
  • 实现步骤:
    1. 服务端创建一个共享内存段并设置标识符。
    2. 客户端通过标识符访问共享内存。
    3. 使用信号量或互斥锁同步访问。
1
2
3
4
5
6
7
// 服务端
int shm_id = shmget(1234, 1024, IPC_CREAT | 0666); // 创建共享内存
char* shm_ptr = (char*)shmat(shm_id, NULL, 0); // 映射到地址空间

// 客户端
int shm_id = shmget(1234, 1024, 0666); // 获取共享内存
char* shm_ptr = (char*)shmat(shm_id, NULL, 0); // 映射到地址空间

4. 使用系统总线 (如 D-Bus 或 ZeroMQ)

  • 适用场景: 复杂系统,支持服务发现和消息路由。
  • 实现步骤:
    1. 服务端注册服务到消息总线。
    2. 客户端通过总线查找服务。

5. 使用文件描述符或信号

  • 适用场景: 简单标识服务状态。
  • 实现方式:
    • 服务端创建特定文件(如 Unix 域套接字文件)。
    • 客户端通过文件路径定位服务。

6. 使用注册表或配置文件

  • 适用场景: 服务动态发布或客户端动态查找。
  • 实现方式:
    • 服务端将自身信息(如 PID、套接字地址)写入文件或注册表。
    • 客户端读取该文件查找服务。
1
2
3
4
5
6
7
8
9
// 服务端
FILE* f = fopen("/tmp/service_info", "w");
fprintf(f, "127.0.0.1:8080\n"); // 写入服务信息
fclose(f);

// 客户端
FILE* f = fopen("/tmp/service_info", "r");
char service_address[256];
fgets(service_address, sizeof(service_address), f);

7. 服务发现协议

  • 适用场景: 动态服务发现,分布式系统。
  • 实现方式:
    • 使用诸如 mDNSConsul 等服务发现工具。
    • 服务端注册到发现工具,客户端通过工具查找服务。

综合建议

  • 本地简单通信: 命名管道、共享内存。
  • 本地或远程灵活通信: Socket。
  • 动态服务发现: 服务总线或专用工具(如 Consul)。