MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言进程间通信机制对比

2023-07-094.3k 阅读

管道(Pipe)

匿名管道(Anonymous Pipe)

  1. 原理
    • 匿名管道是一种半双工的通信方式,数据只能单向流动,且只能在具有亲缘关系(通常是父子进程)的进程间使用。它在内核中创建了一个缓冲区,就像一个先进先出(FIFO)的队列。一个进程写入管道的数据,会被另一个进程从管道中读出。
    • 匿名管道基于文件描述符来实现通信。当创建匿名管道时,内核会创建两个文件描述符,一个用于读(通常是 fd[0]),另一个用于写(通常是 fd[1])。
  2. 代码示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

#define BUFFER_SIZE 256

int main() {
    int pipe_fds[2];
    pid_t child_pid;
    char buffer[BUFFER_SIZE];

    // 创建管道
    if (pipe(pipe_fds) == -1) {
        perror("pipe creation failed");
        return 1;
    }

    // 创建子进程
    child_pid = fork();
    if (child_pid == -1) {
        perror("fork failed");
        return 1;
    } else if (child_pid == 0) {
        // 子进程关闭写端
        close(pipe_fds[1]);

        // 从管道读数据
        ssize_t bytes_read = read(pipe_fds[0], buffer, sizeof(buffer));
        if (bytes_read == -1) {
            perror("read failed");
            return 1;
        }
        buffer[bytes_read] = '\0';
        printf("Child process received: %s\n", buffer);

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

        // 向管道写数据
        const char* message = "Hello from parent!";
        ssize_t bytes_written = write(pipe_fds[1], message, strlen(message));
        if (bytes_written == -1) {
            perror("write failed");
            return 1;
        }

        // 关闭写端
        close(pipe_fds[1]);

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

    return 0;
}
  1. 特点
    • 简单易用:对于具有亲缘关系的进程间通信,匿名管道提供了一种简单直接的方式。
    • 半双工限制:数据只能单向流动,如果需要双向通信,需要创建两个管道。
    • 生命周期短:管道的生命周期与创建它的进程相关,当所有使用管道的进程关闭时,管道自动消失。

命名管道(Named Pipe,FIFO)

  1. 原理
    • 命名管道是一种特殊类型的文件(FIFO 文件),它在文件系统中有一个路径名,因此可以在不相关的进程间进行通信。与匿名管道不同,命名管道不依赖于进程的亲缘关系。
    • 命名管道同样基于内核缓冲区实现 FIFO 机制,不同进程通过打开同一个命名管道文件进行读写操作来实现通信。
  2. 代码示例 写端(writer.c)
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>

#define FIFO_PATH "/tmp/my_fifo"
#define BUFFER_SIZE 256

int main() {
    int fd;
    char buffer[BUFFER_SIZE];

    // 创建命名管道
    if (mkfifo(FIFO_PATH, 0666) == -1 && errno != EEXIST) {
        perror("mkfifo failed");
        return 1;
    }

    // 打开命名管道用于写
    fd = open(FIFO_PATH, O_WRONLY);
    if (fd == -1) {
        perror("open for write failed");
        return 1;
    }

    // 向命名管道写数据
    const char* message = "Hello from writer!";
    ssize_t bytes_written = write(fd, message, strlen(message));
    if (bytes_written == -1) {
        perror("write failed");
        return 1;
    }

    // 关闭文件描述符
    close(fd);

    return 0;
}

读端(reader.c)

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

#define FIFO_PATH "/tmp/my_fifo"
#define BUFFER_SIZE 256

int main() {
    int fd;
    char buffer[BUFFER_SIZE];

    // 打开命名管道用于读
    fd = open(FIFO_PATH, O_RDONLY);
    if (fd == -1) {
        perror("open for read failed");
        return 1;
    }

    // 从命名管道读数据
    ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
    if (bytes_read == -1) {
        perror("read failed");
        return 1;
    }
    buffer[bytes_read] = '\0';
    printf("Reader received: %s\n", buffer);

    // 关闭文件描述符
    close(fd);

    // 删除命名管道
    unlink(FIFO_PATH);

    return 0;
}
  1. 特点
    • 跨进程通信:能在无亲缘关系的进程间通信,扩展了通信的范围。
    • 文件系统关联:依赖文件系统,创建和管理相对复杂,需要处理文件路径和权限等问题。
    • 数据持久化:只要命名管道文件存在,数据就可以被不同进程按顺序读取,有一定的数据持久化特性。

信号(Signal)

原理

  1. 信号的概念
    • 信号是一种软中断,用于通知进程发生了某种特定事件。它是一种异步通信机制,内核可以向进程发送信号,进程也可以向其他进程发送信号。每个信号都有一个唯一的编号(例如 SIGINT 对应编号 2,SIGTERM 对应编号 15 等)和一个默认的处理动作,如终止进程、忽略信号、暂停进程等。
  2. 信号的处理
    • 进程可以通过 signal() 函数或 sigaction() 函数来设置对特定信号的处理方式。signal() 函数相对简单,但在一些系统中存在可移植性问题,sigaction() 函数则提供了更丰富的功能和更好的可移植性。
    • 当进程接收到一个信号时,会暂停当前执行的任务,转而执行信号处理函数(如果设置了的话),处理完成后再返回原来被中断的地方继续执行。

代码示例

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

void signal_handler(int signum) {
    printf("Received signal %d\n", signum);
    // 处理信号的逻辑,这里简单打印信息
    exit(0);
}

int main() {
    // 设置信号处理函数
    if (signal(SIGINT, signal_handler) == SIG_ERR) {
        perror("signal() error");
        return 1;
    }

    printf("Waiting for signal...\n");
    while (1) {
        sleep(1);
    }

    return 0;
}

在这个示例中,程序通过 signal() 函数设置了对 SIGINT 信号(通常由用户按下 Ctrl + C 产生)的处理函数 signal_handler。当接收到 SIGINT 信号时,程序会打印接收到信号的信息并退出。

特点

  1. 异步通知:信号是异步的,进程不需要一直等待信号的到来,这对于处理一些紧急事件(如用户终止程序请求)非常有用。
  2. 简单但功能有限:信号机制简单,主要用于通知进程事件的发生,但信号携带的信息有限,一般只能传递信号编号,难以传递复杂的数据。
  3. 不可靠性:在某些情况下,信号可能会丢失或被延迟处理,特别是在进程处于忙碌状态或系统负载较高时。

消息队列(Message Queue)

原理

  1. 消息队列的概念
    • 消息队列是内核中一个存放消息的链表,每个消息都有一个特定的类型。进程可以向消息队列中发送消息,也可以从消息队列中接收消息。消息队列提供了一种有格式的、异步的通信方式。
  2. 消息队列的操作
    • 创建或获取消息队列使用 msgget() 函数,它返回一个消息队列标识符。发送消息使用 msgsnd() 函数,接收消息使用 msgrcv() 函数。进程可以根据消息的类型来接收特定类型的消息,这使得消息队列在通信的灵活性上优于管道。

代码示例

发送端(sender.c)

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

#define MSG_TYPE 1
#define BUFFER_SIZE 256

// 消息结构
typedef struct {
    long mtype;
    char mtext[BUFFER_SIZE];
} message_t;

int main() {
    key_t key;
    int msgid;
    message_t msg;

    // 生成唯一的键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok failed");
        return 1;
    }

    // 创建消息队列
    msgid = msgget(key, IPC_CREAT | 0666);
    if (msgid == -1) {
        perror("msgget failed");
        return 1;
    }

    // 设置消息类型和内容
    msg.mtype = MSG_TYPE;
    strcpy(msg.mtext, "Hello from sender!");

    // 发送消息
    if (msgsnd(msgid, &msg, strlen(msg.mtext) + 1, 0) == -1) {
        perror("msgsnd failed");
        return 1;
    }

    printf("Message sent successfully.\n");

    return 0;
}

接收端(receiver.c)

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

#define MSG_TYPE 1
#define BUFFER_SIZE 256

// 消息结构
typedef struct {
    long mtype;
    char mtext[BUFFER_SIZE];
} message_t;

int main() {
    key_t key;
    int msgid;
    message_t msg;

    // 生成唯一的键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok failed");
        return 1;
    }

    // 获取消息队列
    msgid = msgget(key, 0666);
    if (msgid == -1) {
        perror("msgget failed");
        return 1;
    }

    // 接收消息
    if (msgrcv(msgid, &msg, sizeof(msg.mtext), MSG_TYPE, 0) == -1) {
        perror("msgrcv failed");
        return 1;
    }

    printf("Received message: %s\n", msg.mtext);

    // 删除消息队列
    if (msgctl(msgid, IPC_RMID, NULL) == -1) {
        perror("msgctl failed");
        return 1;
    }

    return 0;
}

特点

  1. 有格式通信:消息队列可以传递有格式的消息,每个消息都有类型,进程可以根据类型有选择地接收消息,提高了通信的灵活性。
  2. 异步通信:进程发送消息后不需要等待接收方立即处理,消息会在队列中等待,实现了异步通信。
  3. 内核维护:消息队列由内核维护,提供了一定的可靠性,但需要注意消息队列的大小限制,超过限制可能导致发送失败。

共享内存(Shared Memory)

原理

  1. 共享内存的概念
    • 共享内存是最快的一种进程间通信机制。它允许不同的进程访问同一块物理内存区域,这样进程之间可以直接读写共享内存中的数据,而不需要像管道或消息队列那样进行数据的拷贝。
  2. 共享内存的操作
    • 创建或获取共享内存使用 shmget() 函数,它返回一个共享内存标识符。将共享内存附加到进程地址空间使用 shmat() 函数,这样进程就可以像访问普通内存一样访问共享内存。使用完共享内存后,通过 shmdt() 函数将其从进程地址空间分离,最后可以使用 shmctl() 函数删除共享内存。

代码示例

写入端(writer.c)

#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>

#define SHM_SIZE 256

int main() {
    key_t key;
    int shmid;
    char* shm_ptr;

    // 生成唯一的键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok failed");
        return 1;
    }

    // 创建共享内存
    shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget failed");
        return 1;
    }

    // 附加共享内存到进程地址空间
    shm_ptr = (char*)shmat(shmid, NULL, 0);
    if (shm_ptr == (void*)-1) {
        perror("shmat failed");
        return 1;
    }

    // 向共享内存写入数据
    const char* message = "Hello from writer!";
    strcpy(shm_ptr, message);

    // 分离共享内存
    if (shmdt(shm_ptr) == -1) {
        perror("shmdt failed");
        return 1;
    }

    return 0;
}

读取端(reader.c)

#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>

#define SHM_SIZE 256

int main() {
    key_t key;
    int shmid;
    char* shm_ptr;

    // 生成唯一的键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok failed");
        return 1;
    }

    // 获取共享内存
    shmid = shmget(key, SHM_SIZE, 0666);
    if (shmid == -1) {
        perror("shmget failed");
        return 1;
    }

    // 附加共享内存到进程地址空间
    shm_ptr = (char*)shmat(shmid, NULL, 0);
    if (shm_ptr == (void*)-1) {
        perror("shmat failed");
        return 1;
    }

    // 从共享内存读取数据
    printf("Received message: %s\n", shm_ptr);

    // 分离共享内存
    if (shmdt(shm_ptr) == -1) {
        perror("shmdt failed");
        return 1;
    }

    // 删除共享内存
    if (shmctl(shmid, IPC_RMID, NULL) == -1) {
        perror("shmctl failed");
        return 1;
    }

    return 0;
}

特点

  1. 速度快:由于直接在共享内存中读写数据,避免了数据拷贝,因此共享内存是进程间通信中速度最快的方式。
  2. 复杂的同步问题:因为多个进程可以同时访问共享内存,所以需要额外的同步机制(如信号量)来保证数据的一致性和避免竞争条件。
  3. 内存管理:共享内存的大小需要提前确定,并且对共享内存的操作需要谨慎,不正确的内存访问可能导致段错误等问题。

信号量(Semaphore)

原理

  1. 信号量的概念
    • 信号量是一个整型变量,它用于控制对共享资源的访问。信号量的值表示当前可用的共享资源数量。当一个进程想要访问共享资源时,它需要先获取信号量(将信号量的值减 1),如果信号量的值为 0,表示没有可用资源,进程会被阻塞,直到有其他进程释放信号量(将信号量的值加 1)。
  2. 信号量的操作
    • 创建或获取信号量使用 semget() 函数,它返回一个信号量标识符。对信号量进行操作使用 semop() 函数,该函数可以进行 P 操作(获取信号量,将信号量值减 1)和 V 操作(释放信号量,将信号量值加 1)。semctl() 函数用于控制信号量,如初始化信号量的值等。

代码示例

#include <stdio.h>
#include <stdlib.h>
#include <sys/sem.h>
#include <sys/types.h>
#include <unistd.h>

// 信号量操作结构
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
};

// P操作
void semaphore_P(int semid) {
    struct sembuf sem_op;
    sem_op.sem_num = 0;
    sem_op.sem_op = -1;
    sem_op.sem_flg = 0;

    if (semop(semid, &sem_op, 1) == -1) {
        perror("semaphore P operation failed");
        exit(1);
    }
}

// V操作
void semaphore_V(int semid) {
    struct sembuf sem_op;
    sem_op.sem_num = 0;
    sem_op.sem_op = 1;
    sem_op.sem_flg = 0;

    if (semop(semid, &sem_op, 1) == -1) {
        perror("semaphore V operation failed");
        exit(1);
    }
}

int main() {
    key_t key;
    int semid;
    union semun sem_union;

    // 生成唯一的键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok failed");
        return 1;
    }

    // 创建信号量
    semid = semget(key, 1, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget failed");
        return 1;
    }

    // 初始化信号量值为 1
    sem_union.val = 1;
    if (semctl(semid, 0, SETVAL, sem_union) == -1) {
        perror("semctl SETVAL failed");
        return 1;
    }

    // 模拟进程使用共享资源
    semaphore_P(semid);
    printf("Process is using the shared resource.\n");
    sleep(2);
    printf("Process finished using the shared resource.\n");
    semaphore_V(semid);

    // 删除信号量
    if (semctl(semid, 0, IPC_RMID) == -1) {
        perror("semctl IPC_RMID failed");
        return 1;
    }

    return 0;
}

特点

  1. 同步控制:主要用于进程间的同步,保证对共享资源的有序访问,避免竞争条件。
  2. 计数功能:信号量的值可以表示可用资源的数量,这使得它在管理多个共享资源时非常方便。
  3. 复杂操作:信号量的操作相对复杂,需要仔细处理 P、V 操作的顺序和时机,否则容易导致死锁等问题。

对比总结

  1. 通信方式和数据传递
    • 管道:匿名管道用于亲缘关系进程间半双工通信,数据单向流动;命名管道可用于无亲缘关系进程间,数据按 FIFO 顺序传递。它们都以字节流形式传递数据。
    • 信号:主要用于异步通知,传递的信息有限,一般只有信号编号。
    • 消息队列:传递有格式的消息,进程可按消息类型接收,实现异步通信。
    • 共享内存:直接共享物理内存区域,数据传递最快,但需要额外同步机制。
  2. 同步机制
    • 管道和消息队列:本身有一定的同步机制,如管道的 FIFO 特性,消息队列按顺序接收消息。但对于复杂同步需求,可能需要额外同步手段。
    • 信号量:专门用于同步控制,通过 P、V 操作管理共享资源访问。
    • 共享内存:必须配合同步机制(如信号量)使用,否则易出现数据不一致问题。
  3. 适用场景
    • 管道:适合简单的、具有亲缘关系进程间的数据流传输,如 shell 命令的管道。命名管道适用于无亲缘关系进程间简单的数据交换。
    • 信号:处理异步事件,如程序终止、暂停等信号的处理。
    • 消息队列:适用于需要传递有格式消息且异步处理的场景,如分布式系统中的消息传递。
    • 共享内存:适用于对速度要求极高、数据量较大且能妥善处理同步问题的场景,如数据库系统中的共享缓冲区。

在实际应用中,需要根据具体的需求和场景来选择合适的进程间通信机制,以实现高效、稳定的进程间通信。