TCP/IP 网络编程(十)---进程间通信

26 阅读7分钟

进程间通信的概念

进程间通信(Inter Process Communication, IPC) 意味着两个不同进程间可以交换数据。为了实现这一点,操作系统中应该提供两个进程可以同时访问的内存空间

进程具有完全独立的内存结构,连通过 fork 函数创建的子进程也不会与父进程共享内存空间。所以进程间通信只能通过其他特殊方法完成。

基于管道实现进程间通信

进程间通信是通过管道(PIPE) 实现的。管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是 fork 函数的复制对象)。所以两个进程通过操作系统提供的内存空间进行通信。

(一)创建管道的函数 pipe()

#include <unistd.h>
//成功时返回0,失败时返回-1
int pipe(int filedes[2]);
  • filedes[0] :通过管道接收数据时使用的文件描述符,即管道出口。
  • filedes[1] :通过管道传输数据时使用的文件描述符,即管道入口。

父进程调用该函数时将创建管道,同时获取对应于出入口的文件描述符,之后便可通过调用 fork 函数将入口或出口中的一个文件描述符传递给子进程。

(二)pipe() 函数示例

#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char* argv[])
{
    int fds[2];
    char str[] = "who are you?";
    char buf[BUF_SIZE];

    // 创建管道,调用该函数时,fds数组中将保存两个文件描述符,fds[0]表示管道出口,[1]表示管道入口
    pipe(fds);  
    pid_t pid = fork();     // 创建进程
    if(pid == 0)
    {
        write(fds[1], str, sizeof(str));
    }
    else
    {
        read(fds[0], buf, BUF_SIZE);
        puts(buf);
    }

    return 0;
}

上述示例的通信路径如下图所示:

image.png

(三)通过管道进行进程间双向通信

#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char* argv[])
{
    char str1[] = "who are you?";
    char str2[] = "Thank you for your message.";
    char buf[BUF_SIZE];

    int fds[2];
    pipe(fds);

    pid_t pid = fork();
    if(pid == 0)
    {
        write(fds[1], str1, sizeof(str1));

        // 向管道传递数据时,先读的进程会把数据取走, 所以延迟2秒
        // 否则子进程将读回自己向管道发送的数据,而父进程调用read函数后将无限期等待数据进入管道
        sleep(2);
        read(fds[0], buf, BUF_SIZE);
        printf("child proc output: %s \n", buf);
    }
    else
    {
        read(fds[0], buf, BUF_SIZE);
        printf("parent proc output: %s \n", buf);
        write(fds[1], str2, sizeof(str2));
        sleep(3);
    }
    return 0;
}
image.png

双向通信模型如下图:

image.png

从图中可以看出,通过1个管道可以进行双向通信,但采用这种模型时需格外注意,将第21行的 sleep 语句注释后将出错:

image.png

此时子进程将读回自己在17行向管道发送的数据,记过父进程调用 read 函数后将无限期等待数据进入管道。即:

“向管道传递数据时,先读的进程会把数据取走。”

综上,1个管道无法完成双向通信任务,因此需要创建2个管道,各自负责不同的数据流

#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char* argv[])
{
    int fds1[2], fds2[2];
    char str1[] = "who are you?";
    char str2[] = "Thank you for your message";
    char buf[BUF_SIZE];

    pipe(fds1);
    pipe(fds2);
    pid_t pid = fork();
    if(pid == 0)
    {
        write(fds1[1], str1, sizeof(str1));
        read(fds2[0], buf, BUF_SIZE);
        printf("Child proc output: %s \n", buf);
    }
    else
    {
        read(fds1[0], buf, BUF_SIZE);
        printf("Parent proc output: %s \n", buf);
        write(fds2[1], str2, sizeof(str2));
        sleep(3);   // 可不加
    }
    return 0;
}

运用进程间通信

拓展 “TCP/IP 网络编程(九)---多进程服务器端” 的 echo_mpserv.c,添加如下功能:

“将回声客户端传输的字符串按序保存到文件中。”

将该任务委托给另外的进程,即另行创建进程,从向客户端提供服务的进程读取字符串信息。

echo_storeserv.c

// 保存消息的回声服务器端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 100

void error_handling(const char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

void read_childproc(int sig)
{
    pid_t pid;
    int status;
    pid = waitpid(-1, &status, WNOHANG);    // waitpid函数防止僵尸进程
    printf("removed proc id: %d \n", pid);
}

int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        printf("Usage: %s <port>\n", argv[0]);
        exit(1);
    }

    struct sigaction act;
    act.sa_handler = read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    int state = sigaction(SIGCHLD, &act, 0);

    int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serv_adr, clnt_adr;
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");
    if(listen(serv_sock, 5) == -1)  // 5表示服务器可以等待接受的最大客户数量
        error_handling("listen() error");

    int fds[2];
    pipe(fds);
    pid_t pid = fork();
    if(pid == 0)
    {
        FILE* fp = fopen("echomsg.txt", "wt");
        char msgbuf[BUF_SIZE];
        for(int i = 0; i < 10; i++)
        {
            int len = read(fds[0], msgbuf, BUF_SIZE);
            // 参数:要写入数据的指针,每个数据项的大小,要写入的数据项的数量,文件流
            fwrite((void*)msgbuf, 1, len, fp);
        }
        fclose(fp);
        return 0;
    }

    while(1)
    {
        socklen_t adr_sz = sizeof(clnt_adr);
        int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
        if(clnt_sock == -1)
            continue;
        else
            puts("new client connected...");

        pid_t pid = fork();
        if(pid == 0)
        {
            close(serv_sock);
            int str_len;
            char buf[BUF_SIZE];
            while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
            {
                write(clnt_sock, buf, str_len);
                write(fds[1], buf, str_len);
            }

            close(clnt_sock);
            puts("client disconnected...");
            return 0;
        }
        else
            close(clnt_sock);
    }
    close(serv_sock);
    return 0;
}
  • 信号处理

    struct sigaction act;
    act.sa_handler = read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    int state = sigaction(SIGCHLD, &act, 0);
    

    这部分代码设置了信号处理程序,用于处理 SIGCHLD 信号,即子进程结束时产生的信号。通过 waitpid() 函数防止出现僵尸进程。

  • 创建管道

    int fds[2];
    pipe(fds);
    

    管道用于在进程之间传递数据。fds[0] 是管道的读端,fds[1] 是写端。

  • 创建日志子进程

    pid_t pid = fork();
    if(pid == 0)
    {
        FILE* fp = fopen("echomsg.txt", "wt");
        char msgbuf[BUF_SIZE];
        for(int i = 0; i < 10; i++)
        {
            int len = read(fds[0], msgbuf, BUF_SIZE);
            fwrite((void*)msgbuf, 1, len, fp);
        }
        fclose(fp);
        return 0;
    }
    

    这里创建了一个子进程,它从管道中读取数据并写入文件 echomsg.txt。每次从管道读取一条消息,并将其写入文件中。

  • 主循环处理客户端连接

    while(1)
    {
        socklen_t adr_sz = sizeof(clnt_adr);
        int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
        if(clnt_sock == -1)
            continue;
        else
            puts("new client connected...");
    
        pid_t pid = fork();
        if(pid == 0)
        {
            close(serv_sock);
            int str_len;
            char buf[BUF_SIZE];
            while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
            {
                write(clnt_sock, buf, str_len);
                write(fds[1], buf, str_len);
            }
            close(clnt_sock);
            puts("client disconnected...");
            return 0;
        }
        else
            close(clnt_sock);
    }
    
    • 接受连接accept() 函数用于接受客户端的连接。如果连接成功,服务器输出提示信息 “new client connected...”,并创建一个子进程来处理该客户端。
    • 子进程处理:子进程关闭服务器套接字 serv_sock(因为这个套接字在子进程中不再需要),读取客户端数据,将数据回显给客户端,并通过管道 fds[1] 传递数据给日志子进程。
    • 父进程关闭客户端套接字:父进程在创建子进程后关闭客户端套接字 clnt_sock,因为子进程已经接管了该连接。
image.png

文件中累计一定数量的字符串后(共10次的 fwrite 函数调用完成后),可以打开 echomsg.txt 验证保存的字符串。

问答

(一)什么是进程间通信,分别从概念上和内存的角度进行说明。

  • 概念上:IPC 是多进程环境中不同进程之间进行数据交换、同步操作、和事件通知的机制。由于进程之间的独立性和隔离性,IPC 成为进程间协作的必需手段。

  • 内存角度:IPC 涉及通过共享内存直接访问数据或通过内核缓冲区间接传递数据。这些机制确保进程在保持各自地址空间独立性的同时,能够有效地进行通信和协作。

(二)进程间通信需要特殊的 IPC 机制,这是由操作系统提供的。进程间通信时为何需要操作系统的帮助?

  • 操作系统隔离了进程的地址空间,确保它们彼此独立和安全。

  • 操作系统管理共享资源和同步机制,以避免冲突和保证数据一致性。

  • 操作系统扮演中介角色,在内核空间中管理数据传输和通信。

  • 操作系统提供标准接口和模型,简化了进程间通信的实现。

  • 操作系统确保安全性,通过权限控制和监控来保护进程间的通信。