进程间通信(IPC) Pipe / SharedMemory / Message Queue / 本地Socket

320 阅读16分钟

背景

最近在看 <<凤凰架构>>,里面提到的IPC的一些方式,在这里做一些总结和延伸

什么是IPC

在计算机科学的世界里,进程间通信(IPC,Inter-Process Communication)这里的进程间特指同一个主机和同一个操作系统,用于区分函数调用(同一个进程)和远程服务调用(RPC)

所有IPC的本质都是: 在进程之间找一块公共的区域进行数据交换,如果找到的是磁盘:Pipe管道,如果找到的是内存:共享内存

image.png 如果共享的是磁盘,是文件,则是pipe

image.png 如果共享的是内存,则是ShareMemory

什么时候需要使用到IPC

使用IPC的场景其实是比较尴尬的,进一步我们可以通过RPC的方式去实现还可以实现分布式,退一步我们可以用一个进程的方式去实现 下面是初略的总结了下使用的场景:

  • master/worker模式: 在处理多个客户端请求时,我通过开子进程的方式来对应执行客户端的任务,等执行结束后需要告诉我父进程的直接结果
  • 资源的共享: 为了让响应速度更快,我们的每一个进程都需要将众多的配置文件加载到内存中去,这时候我们可以创建一个进程专门来加载和管理配置,别的进程通过IPC的方式访问这个进程即可

下面是常见的IPC的方式

匿名管道

管道就是创建在磁盘上的一个文件,我们在使用fork创建子进程时会把这个文件的描述符一起copy到子进程中,这样父进程和子进程都持有一个文件的描述符,一个进程向这个文件写数据,另外一个进程从这个文件读取数据,就实现了通信,但管道是有一些限制的:

  • 大小限制: 一般管道文件的大小是固定的4kb,如果我们写满了就不能在写了,会等待读取
  • 半双工: 只能一个进程写,另外一个进程读取,如果想要实现全双工,则需要创建两个管道来进行通信

image.png

实现和例子:

最常见的例子是|

tail -f | grep "Hello"

上面的例子就是, 把tail -f的标准输出通过管道|变成了 grep的标准输入

使用C语言创建一个管道进行通信

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main() {
    int pipefd[2];
    pid_t pid;
    char buffer[100];

    // 创建管道
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    // 创建子进程
    // 注意: 在创建子进程时会把父进程的资源进行拷贝,就比如我们刚创建的pipefd,这样父进程和子进程都能访问到这个两个fd了
    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // 子进程
        close(pipefd[1]);  // 关闭写端

        // 从管道读取数据
        // 使用read读取数据
        ssize_t numBytes = read(pipefd[0], buffer, sizeof(buffer) - 1);
        if (numBytes >= 0) {
            buffer[numBytes] = '\0';  // 确保字符串以空字符结尾
            printf("子进程读取到: %s\n", buffer);
        } else {
            perror("read");
        }

        close(pipefd[0]);  // 关闭读端
        exit(EXIT_SUCCESS);
    } else {
        // 父进程
        close(pipefd[0]);  // 关闭读端

        // 向管道写入数据
        // 使用write写入数据
        const char *msg = "Hello from parent process!";
        write(pipefd[1], msg, strlen(msg));

        close(pipefd[1]);  // 关闭写端
        wait(NULL); // 等待子进程结束
        exit(EXIT_SUCCESS);
    }

    return 0;
}

下面是一个golang代码的例子 演示了 父进程和子进程的

package main

import (
    "fmt"
    "os/exec"
)

func main() {
    // 创建一个 *Cmd,表示要执行的命令,可以加入参数
    cmd := exec.Command("ls", "-l")

    // 获得子进程的输出
    output, err := cmd.Output()
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    // 打印命令输出
    fmt.Println(string(output))
}

使用匿名管道的优点

  • 使用简单
  • 实现成本低

使用匿名管道的缺点

  • 只能单向,临时使用下可以,比如要执行下命令获得命令的结果,但如果需要持续的两个进程需要交互的话不太好用
  • 容量有限,如果写入速度过快而读取速度跟不上,可能会导致管道阻塞
  • 匿名管道不能在两个不相关的非父子进程或兄弟进程之间直接使用。匿名管道的特性决定了它的生命周期和作用域受限于创建它的进程及其子进程,这是因为匿名管道依赖于文件描述符,这些描述符仅在相关的进程间可以共享。

为什么 linux的 "|"可以使用呢

在Linux中,管道符号 | 被用于将一个命令的输出直接作为下一个命令的输入进行处理,这被称为管道。管道由shell(例如,bash、zsh等)管理,并在幕后使用匿名管道来实现。这是如何做到的:

  • 创建管道: 当shell解析到管道符号|时,它会在内部创建一个匿名管道。
  • 生成进程: Shell会fork出两个子进程,一个用于执行管道前的命令,另一个用于执行管道后的命令。
  • 重定向文件描述符: - Shell会重定向第一个命令的标准输出到管道的写入端。对第二个命令,Shell会重定向标准输入到管道的读取端。
  • 数据传输: 第一个命令的输出通过匿名管道传输,作为第二个命令的输入。
  • 进程同步: Shell会管理这些子进程的生命周期,确保数据正确传输和处理。

通过上面的流程我们可以看出 在 "|" 的两端他们是 shell分别fork出来的两个进程,也就是兄弟进程的关系,所以可以使用匿名管道

命名管道 FIFO

我们上面讲了 匿名管道,也说了创建它的过程(在磁盘上创建一个文件并持有这个文件的文件描述符fd,并进行fork,fork时会把父进程的资源拷贝到子进程中过去,所以在父进程和子进程中都能访问到这个文件,这时候父进程和子进程都能读或写这个文件,从而实现了跨进程通信),通过这个过程我们注意到,只要亲属关系的进程才能访问匿名文件,但如果是两个没关系的进程呢? 如果想用管道应该怎么通信呢? 答案就是: 命名管道,也很好理解,无法通过fork的形式都持有一个匿名文件的引用,那么我们创建一个有名称的文件,这样两个没关系的进程都能打开和读写这个文件了

下面是一个通过命令行创建管道和读写文件的例子:

# 创建命名pipe
mkfifo mypipe
ls -lah mypipe
# prw-r--r--  1 daiyunchao  staff     0B 12 19 14:09 mypipe
# 我们把这个文件列出来说明我们能实实在在的看到这个文件
# 我们注意到这个文件的类型是 "p"是pipe类型

#写内容
echo "Hello This is Some String" > mypipe
#我们可以观察到,这里会阻塞住, 因为他在等另外一个进程读取数据

#读取数据
# 我们打开另外一个终端或是命令行工具
cat mypipe
# 输出: Hello This is Some String
# 并且在写入的进程也停止阻塞了

下面是使用C创建一个命名管道并通信的例子


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>

#define FIFO_NAME "/tmp/myfifo"

int main() {
    pid_t pid;
    char buffer[100];

    // 创建命名管道
    if (mkfifo(FIFO_NAME, 0666) == -1) {
        perror("mkfifo");
        exit(EXIT_FAILURE);
    }

    // 创建子进程
    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // 子进程:用于写操作
        int fd;
        const char *msg = "Hello from child process!";
        
        // 打开命名管道进行写操作
        fd = open(FIFO_NAME, O_WRONLY);
        if (fd == -1) {
            perror("open for write");
            exit(EXIT_FAILURE);
        }

        // 将消息写入管道
        write(fd, msg, strlen(msg) + 1);

        close(fd);
        exit(EXIT_SUCCESS);
    } else {
        // 父进程:用于读操作
        int fd;

        // 打开命名管道进行读操作
        fd = open(FIFO_NAME, O_RDONLY);
        if (fd == -1) {
            perror("open for read");
            exit(EXIT_FAILURE);
        }

        // 从管道读取数据
        ssize_t numBytes = read(fd, buffer, sizeof(buffer));
        if (numBytes >= 0) {
            buffer[numBytes] = '\0';  // 确保字符串以空字符结尾
            printf("父进程读取到: %s\n", buffer);
        } else {
            perror("read");
        }

        close(fd);
        wait(NULL);  // 等待子进程结束

        // 删除命名管道文件
        unlink(FIFO_NAME);
        exit(EXIT_SUCCESS);
    }

    return 0;
}

我们会发现操作这个命名管道和操作文件几乎是完全相同的

关于Pipe和FIFO是否在磁盘上

其实 pipe和fifo这是用了文件系统的接口,比如我们可以像对文件一样的读写它,但实际上数据是不在磁盘上的 Use the filesysttem interface for a nontraditional file

  • A fifo special file (a named pipe) is similar to a pipe, except that it is accessed as part of the file system.
  • It can be opened by multiple processes for reading or writing.
  • When processes are exchangeing data via the fifo, the kernel passes all data internally without writing it to the file system.
  • Thus, the fifo special file has no contents on the file system, the file system entry merely serves as a reference point so that processes can access the pipe using a name in the file system.

共享内存 Shared Memory

在管道中,我们把pipe当成是多个进程外的一块公共的区域来进行数据交互,那么SharedMemory就是把内存当成是多个进程外的公共区域来交互数据

执行步骤

  1. 创建一个特殊的文件,并获取这个文件的fd
  2. 为这个文件设置大小
  3. 通过mmap的方式将文件映射到内存中
  4. 通过memcpy或是strcpy方式对内存进行读写
  5. 通过close关闭这个文件的fd

下面是使用C去创建和使用的过程

写入程序

// write_to_shared_memory.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define SHARED_MEM_NAME "/my_shared_memory"
#define SHARED_MEM_SIZE 4096  // 4KB

int main() {
    // 创建共享内存对象
    int shm_fd = shm_open(SHARED_MEM_NAME, O_CREAT | O_RDWR, 0666);
    if (shm_fd == -1) {
        perror("shm_open");
        return 1;
    }

    // 调整共享内存大小
    if (ftruncate(shm_fd, SHARED_MEM_SIZE) == -1) {
        perror("ftruncate");
        return 1;
    }

    // 将共享内存映射到进程空间
    char *shared_mem = mmap(0, SHARED_MEM_SIZE, PROT_WRITE, MAP_SHARED, shm_fd, 0);
    if (shared_mem == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    // 向共享内存写入数据
    const char *message = "Hello, Shared Memory!";
    snprintf(shared_mem, SHARED_MEM_SIZE, "%s", message);

    // 解除映射
    if (munmap(shared_mem, SHARED_MEM_SIZE) == -1) {
        perror("munmap");
        return 1;
    }
    
    // 关闭文件描述符
    close(shm_fd);

    return 0;
}

读取程序

// read_from_shared_memory.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define SHARED_MEM_NAME "/my_shared_memory"
#define SHARED_MEM_SIZE 4096  // 4KB

int main() {
    // 打开共享内存对象 这里文件名一定要相同
    int shm_fd = shm_open(SHARED_MEM_NAME, O_RDONLY, 0666);
    if (shm_fd == -1) {
        perror("shm_open");
        return 1;
    }

    // 将共享内存映射到进程空间
    char *shared_mem = mmap(0, SHARED_MEM_SIZE, PROT_READ, MAP_SHARED, shm_fd, 0);
    if (shared_mem == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    // 从共享内存读取数据
    printf("Read from shared memory: %s\n", shared_mem);

    // 解除映射
    if (munmap(shared_mem, SHARED_MEM_SIZE) == -1) {
        perror("munmap");
        return 1;
    }

    close(shm_fd);

    return 0;
}

整体通过看代码后发现和使用pipe的方式其实是差不多的,无非使用的函数不同,但从接口层面来看的话还是对文件的读写

为什么SharedMemory创建的文件我们看不到

在上文中我们说通过shm_open函数打开了一个文件,但这个文件不像fifo一样能看到,那这个文件在哪呢? 其实创建的这个共享文件不在普通定义的区域里,而在swap storage 中,通过函数mmap的方式实现将swap storage映射到进程内(进程外的内存是不能访问的)

image.png

swap storage是交换区,在内存不够用时操作系统会将部分数据放到磁盘中,这种行为就叫swap,这区域就是 swap storage

使用Shared Memory的优点

  • 高效: 直接操作内存,效率高
  • 全双工: 和pipe,fifo半双工不同,参与共享的进程既可以读取数据,也可以写入数据,这个我们可以看到上面的代码在shm_open时添加的不同的权限
  • 可以交互的数据量大了很多,具体可以通过命令cat /proc/sys/kernel/shmmax进行查看,我电脑的返回值是:68719476736 (64GB), 一般的pipe和fifo的大小为4kb

消息队列 Message Queue

这个消息队列并不是RabbitMQ类似的队列,而是操作系统层面的的一个MQ,我们通过这个MQ可以实现向队列中写入数据和从队列中读取数据,从而实现了进程间通信

执行步骤

  1. 创建一个MQ
  2. MQ中写入数据
  3. MQ中读取数据

image.png

下面是C使用MQ实现通信的过程

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/msg.h>

#define QUEUE_KEY 1234

// 定义消息结构体
struct message {
    long msg_type;
    char msg_text[100];
};

//发送端,发送消息
void send_message() {
    int msgid;
    struct message msg;
    
    // 创建消息队列
    msgid = msgget(QUEUE_KEY, IPC_CREAT | 0666);
    if (msgid < 0) {
        perror("msgget");
        exit(1);
    }

    // 准备发送的消息
    msg.msg_type = 1; // 消息类型通常设置为正整数
    strcpy(msg.msg_text, "Hello from sender!");

    // 发送消息
    if (msgsnd(msgid, &msg, sizeof(msg.msg_text), 0) < 0) {
        perror("msgsnd");
        exit(1);
    }

    printf("Message sent: %s\n", msg.msg_text);
}

//接收端 接收消息
void receive_message() {
    int msgid;
    struct message msg;
    
    // 访问消息队列
    msgid = msgget(QUEUE_KEY, IPC_CREAT | 0666);
    if (msgid < 0) {
        perror("msgget");
        exit(1);
    }

    // 接收消息
    if (msgrcv(msgid, &msg, sizeof(msg.msg_text), 1, 0) < 0) {
        perror("msgrcv");
        exit(1);
    }

    printf("Message received: %s\n", msg.msg_text);

    // 删除消息队列
    if (msgctl(msgid, IPC_RMID, NULL) < 0) {
        perror("msgctl");
        exit(1);
    }
}

本地Socket (Unix Domain Socket 和 Loopback Socket)

socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。需要注意的是: 本地Socket是依赖于文件系统的

下面是使用golang的一个示例

package main

import (
	"fmt"
	"net"
	"os"
)

const socketPath = "/tmp/unix_socket_demo"

func main() {
	// 删除已存在的Socket文件
	if err := os.RemoveAll(socketPath); err != nil {
		fmt.Println("Failed to remove old socket file:", err)
		return
	}

	// 监听Unix域套接字
	listener, err := net.Listen("unix", socketPath)
	if err != nil {
		fmt.Println("Failed to listen on socket:", err)
		return
	}
	defer listener.Close()

	fmt.Println("Server is listening on", socketPath)

	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Failed to accept connection:", err)
			continue
		}

		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()

	buf := make([]byte, 1024)
	n, err := conn.Read(buf)
	if err != nil {
		fmt.Println("Failed to read from connection:", err)
		return
	}

	fmt.Println("Received message:", string(buf[:n]))
}


package main

import (
	"fmt"
	"net"
)

const socketPath = "/tmp/unix_socket_demo"

func main() {
	// 连接到Unix域套接字
	conn, err := net.Dial("unix", socketPath)
	if err != nil {
		fmt.Println("Failed to connect to socket:", err)
		return
	}
	defer conn.Close()

	message := "Hello from client!"

	// 发送消息
	_, err = conn.Write([]byte(message))
	if err != nil {
		fmt.Println("Failed to send message:", err)
		return
	}

	fmt.Println("Message sent:", message)
}

本地socket其实可以理解为对文件的操作

  1. 文件描述符: 在UNIX和类UNIX系统上,套接字和普通文件、管道、设备一样,都是通过文件描述符来进行读写操作的。一个套接字在打开时,会分配一个文件描述符,这就像打开一个文件一样。
  2. 读写接口: 可以使用与文件读写类似的系统调用来操作套接字,如read()write()recv()send()等,这些操作对于流式套接字(SOCK_STREAM)特别适用。实际上,对于流式Unix域Socket,read()write()可以直接用于套接字的数据传输。
  3. 文件系统路径: UNIX域Socket使用文件系统路径命名,这意味着套接字在某种意义上表现为文件。您可以在文件系统中看到套接字的实际存在(通常是一个特别的"socket"类型文件)。
  4. 生命周期管理: 就像文件需要在使用完毕后关闭一样,套接字也需要关闭以释放相关资源。在UNIX系统中,关闭一个文件描述符(通过close()调用)也适用于套接字。

但它和文件系统也有区别:

  1. 套接字支持双向、全双工通信,而文件通常是单向的。套接字提供的是一种网络通信模型,而文件提供的是持久化数据存储。
  2. 套接字支持一些文件所没有的操作和属性,比如监听、连接、绑定、接受连接等,这些操作是专门为网络和IPC通信模型设计的。

环路Socket

说起本地Socket,其实还有一种本地Socket的,环路Socket(Loopback Socket) 这种方式和网络socket的写法是相同的,只是IP是localhost 或是 127.0.0.1, 下面是一个golang的例子

// server.go
package main

import (
	"bufio"
	"fmt"
	"net"
)

func main() {
	// 监听本地主机的端口
	listener, err := net.Listen("tcp", "127.0.0.1:12345")
	if err != nil {
		fmt.Println("Error starting server:", err)
		return
	}
	defer listener.Close()
	fmt.Println("Server is listening on 127.0.0.1:12345")

	for {
		// 接受客户端的连接
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Error accepting connection:", err)
			continue
		}
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()
	fmt.Println("Accepted connection from", conn.RemoteAddr())

	scanner := bufio.NewScanner(conn)
	// 读取客户端发送的数据
	for scanner.Scan() {
		fmt.Println("Received:", scanner.Text())
		conn.Write([]byte("Message received\n"))
	}
	if scanner.Err() != nil {
		fmt.Println("Error reading from connection:", scanner.Err())
	}
}

我们可以发现环路socket和我们的网络socket在写法上并没有什么区别,那本质上和网络socket是否是一致的呢? 在计算机网络中,环路 socket 的实现机制实际上不经过物理网卡,而是在操作系统内核中进行数据传输。这一机制依赖于环回接口(loopback interface)的特殊性。有以下一些特点:

  • 环回接口是一种虚拟网络接口,用于主机内部的进程间通信。它不依赖于任何物理网络设备,比如网卡
  • 环回接口的 IP 地址通常为 127.0.0.1,而域名 localhost 通常解析为这个地址
  • 当一个应用程序通过环路 socket 发起连接(如连接到 127.0.0.1),数据包并不会经过物理网络层。
  • 网络数据包到达环回接口时,操作系统在内核中将数据直接传递给目的进程的接收队列。这一过程仅限于内核态内存复制,避免了数据传输的物理开销。
  • 尽管环路通信仍然使用标准的 TCP/IP 协议栈,但许多优化措施使之更高效。这包括减少协议栈的某些处理步骤,比如跳过校验和计算等(因为没有实际网络传输风险)。
  • 操作系统内核可能会进一步优化数据包的路径,在内存中快速复制数据,而不是经历完整的网络层处理。

Unix Domain Socket 和 Loopback Socket 对比

  • Loopback Socket代码更通用,如果需要改成网络socket实现分布式几乎不用修改代码
  • Unix Domain Socket 性能更高 (Unix Domain Socket 是基于文件的,Loopback Socket是基于TCP或是UDP)

参考

讲的很不错的一个视频: www.bilibili.com/video/BV113…