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

Linux C语言文件描述符的跨进程使用

2022-03-243.6k 阅读

Linux C 语言文件描述符跨进程使用基础概念

文件描述符在 Linux 中的本质

在 Linux 系统中,一切皆文件。文件描述符(File Descriptor)是一个非负整数,它是内核为了高效管理已被打开的文件所创建的索引。当我们在程序中打开一个文件、管道、套接字等 I/O 资源时,内核会为其分配一个文件描述符。从内核的角度来看,文件描述符是进程与内核交互的一种方式,它指向内核中代表特定文件或 I/O 资源的结构体。

例如,标准输入(stdin)、标准输出(stdout)和标准错误(stderr)在程序启动时默认打开,对应的文件描述符分别为 0、1 和 2。当我们使用 open 函数打开一个普通文件时,内核会在进程的文件描述符表中寻找一个空闲的位置,并返回这个位置对应的文件描述符值。

进程的文件描述符表

每个进程都有一个独立的文件描述符表,该表记录了进程打开的所有文件描述符及其对应的内核文件表项。文件描述符表是一个数组,其索引就是文件描述符的值,数组元素则指向内核文件表中的某个表项。内核文件表维护着关于文件的各种状态信息,如文件当前的读写位置、文件权限等。

这种结构设计使得每个进程可以独立地管理自己打开的文件,即使不同进程打开同一个物理文件,它们在各自的文件描述符表中也有不同的记录,并且对文件的操作相互独立,除非通过特定的机制进行协同。

跨进程使用文件描述符的意义

在一些复杂的应用场景中,需要多个进程之间共享文件描述符。比如,在父子进程通过管道进行通信时,父进程创建管道后,需要将管道的文件描述符传递给子进程,以便子进程能够通过该管道与父进程进行数据交互。又比如,在服务器 - 客户端模型中,服务器进程可能需要将某个套接字文件描述符传递给子进程来处理客户端连接,实现并发处理。通过跨进程使用文件描述符,可以实现进程间高效的 I/O 资源共享,避免重复打开文件带来的资源浪费和不一致问题。

父子进程间文件描述符的传递

fork 函数的特性

fork 函数是 Linux 系统中创建子进程的重要方式。当一个进程调用 fork 时,内核会创建一个与父进程几乎完全相同的子进程。子进程会继承父进程的地址空间、打开的文件描述符等资源。

以下是一个简单的 fork 示例代码:

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

int main() {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        // 子进程
        printf("I am the child process, my pid is %d\n", getpid());
    } else {
        // 父进程
        printf("I am the parent process, my pid is %d\n", getpid());
    }
    return 0;
}

在这个示例中,fork 函数返回两次,一次在父进程中返回子进程的 PID,一次在子进程中返回 0。通过判断返回值,我们可以区分父进程和子进程。

父子进程共享文件描述符的原理

由于子进程继承了父进程的文件描述符表,所以父进程打开的文件描述符在子进程中同样有效。这意味着父子进程可以通过这些共享的文件描述符对同一个文件或 I/O 资源进行操作。例如,父进程打开一个文件进行写操作,子进程可以接着从父进程写入的位置继续写入或者进行读取操作。

假设父进程打开一个文件:

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

int main() {
    int fd;
    pid_t pid;
    fd = open("test.txt", O_WRONLY | O_CREAT, 0644);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        close(fd);
        exit(1);
    } else if (pid == 0) {
        // 子进程
        const char *child_msg = "This is from child process\n";
        write(fd, child_msg, strlen(child_msg));
        close(fd);
    } else {
        // 父进程
        const char *parent_msg = "This is from parent process\n";
        write(fd, parent_msg, strlen(parent_msg));
        close(fd);
    }
    return 0;
}

在这个代码中,父进程先打开 test.txt 文件获得文件描述符 fd,然后调用 fork 创建子进程。父子进程都可以通过 fd 对文件进行写操作。注意,这里需要合理地关闭文件描述符,避免资源泄漏。

管道通信中的文件描述符传递

管道是 Linux 中进程间通信(IPC)的一种常用机制,分为匿名管道和命名管道。匿名管道只能用于具有亲缘关系(如父子进程)的进程之间通信,而命名管道可以用于任意两个进程之间通信。

匿名管道

匿名管道通过 pipe 函数创建,该函数接受一个包含两个整数的数组作为参数,这两个整数分别是管道的读端和写端的文件描述符。以下是一个父子进程通过匿名管道通信的示例:

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

#define BUFFER_SIZE 1024

int main() {
    int pipe_fd[2];
    pid_t pid;
    char buffer[BUFFER_SIZE];
    if (pipe(pipe_fd) < 0) {
        perror("pipe error");
        exit(1);
    }
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        close(pipe_fd[0]);
        close(pipe_fd[1]);
        exit(1);
    } else if (pid == 0) {
        // 子进程
        close(pipe_fd[1]); // 关闭写端
        ssize_t read_bytes = read(pipe_fd[0], buffer, sizeof(buffer));
        if (read_bytes > 0) {
            buffer[read_bytes] = '\0';
            printf("Child process received: %s\n", buffer);
        }
        close(pipe_fd[0]);
    } else {
        // 父进程
        close(pipe_fd[0]); // 关闭读端
        const char *msg = "Hello from parent process";
        write(pipe_fd[1], msg, strlen(msg));
        close(pipe_fd[1]);
    }
    return 0;
}

在这个示例中,父进程创建管道后,将管道的写端文件描述符传递给子进程(通过 fork 的继承机制),然后父进程向管道写端写入数据,子进程从管道读端读取数据。

命名管道

命名管道(FIFO)通过 mkfifo 函数创建。以下是一个使用命名管道进行进程间通信的示例,假设有两个独立的进程 writer.creader.c

writer.c 代码如下:

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

#define FIFO_NAME "myfifo"
#define BUFFER_SIZE 1024

int main() {
    int fd;
    char buffer[BUFFER_SIZE];
    if (mkfifo(FIFO_NAME, 0666) < 0 && errno != EEXIST) {
        perror("mkfifo error");
        exit(1);
    }
    fd = open(FIFO_NAME, O_WRONLY);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    const char *msg = "Hello from writer process";
    write(fd, msg, strlen(msg));
    close(fd);
    return 0;
}

reader.c 代码如下:

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

#define FIFO_NAME "myfifo"
#define BUFFER_SIZE 1024

int main() {
    int fd;
    char buffer[BUFFER_SIZE];
    fd = open(FIFO_NAME, O_RDONLY);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    ssize_t read_bytes = read(fd, buffer, sizeof(buffer));
    if (read_bytes > 0) {
        buffer[read_bytes] = '\0';
        printf("Reader process received: %s\n", buffer);
    }
    close(fd);
    return 0;
}

在这个示例中,writer 进程创建命名管道并向其写入数据,reader 进程打开命名管道并从中读取数据。虽然这两个进程没有亲缘关系,但通过命名管道实现了文件描述符的“传递”(实际上是通过打开同一个命名管道文件获取对应的文件描述符),从而进行通信。

跨进程文件描述符传递的高级技术

使用 Unix 域套接字传递文件描述符

Unix 域套接字(Unix Domain Socket)不仅可以用于进程间通信,还可以用于在不同进程之间传递文件描述符。这种方式相比其他方式更加灵活,适用于更复杂的进程间关系。

Unix 域套接字的基本原理

Unix 域套接字基于文件系统,通过创建一个特殊的文件(套接字文件)来标识通信端点。进程可以通过 socket 函数创建 Unix 域套接字,使用 bind 函数将套接字绑定到一个路径名,然后通过 connectlisten/accept 等函数进行连接或监听。

传递文件描述符的实现

要在 Unix 域套接字中传递文件描述符,需要使用辅助数据(ancillary data)。辅助数据是一种特殊的数据包,用于携带额外的信息,如文件描述符。以下是一个简单的示例,展示如何在 Unix 域套接字中传递文件描述符:

发送方代码(sender.c):

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

#define SOCKET_PATH "mysocket"
#define BUFFER_SIZE 1024

int main() {
    int sockfd, fd_to_send;
    struct sockaddr_un servaddr;
    char buffer[BUFFER_SIZE];
    sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket error");
        exit(1);
    }
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sun_family = AF_UNIX;
    strcpy(servaddr.sun_path, SOCKET_PATH);
    if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("connect error");
        close(sockfd);
        exit(1);
    }
    fd_to_send = open("test.txt", O_RDONLY);
    if (fd_to_send < 0) {
        perror("open error");
        close(sockfd);
        exit(1);
    }
    struct msghdr msg;
    struct iovec iov;
    struct cmsghdr *cmsg;
    char control[CMSG_SPACE(sizeof(int))];
    memset(&msg, 0, sizeof(msg));
    memset(&iov, 0, sizeof(iov));
    memset(control, 0, sizeof(control));
    iov.iov_base = buffer;
    iov.iov_len = sizeof(buffer);
    msg.msg_name = NULL;
    msg.msg_namelen = 0;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = control;
    msg.msg_controllen = sizeof(control);
    cmsg = CMSG_FIRSTHDR(&msg);
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;
    cmsg->cmsg_len = CMSG_LEN(sizeof(int));
    *((int *)CMSG_DATA(cmsg)) = fd_to_send;
    msg.msg_controllen = cmsg->cmsg_len;
    if (sendmsg(sockfd, &msg, 0) < 0) {
        perror("sendmsg error");
        close(sockfd);
        close(fd_to_send);
        exit(1);
    }
    close(sockfd);
    close(fd_to_send);
    return 0;
}

接收方代码(receiver.c):

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

#define SOCKET_PATH "mysocket"
#define BUFFER_SIZE 1024

int main() {
    int sockfd, fd_received;
    struct sockaddr_un servaddr, cliaddr;
    char buffer[BUFFER_SIZE];
    sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket error");
        exit(1);
    }
    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));
    servaddr.sun_family = AF_UNIX;
    strcpy(servaddr.sun_path, SOCKET_PATH);
    if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind error");
        close(sockfd);
        exit(1);
    }
    if (listen(sockfd, 5) < 0) {
        perror("listen error");
        close(sockfd);
        exit(1);
    }
    socklen_t len = sizeof(cliaddr);
    int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
    if (connfd < 0) {
        perror("accept error");
        close(sockfd);
        exit(1);
    }
    struct msghdr msg;
    struct iovec iov;
    struct cmsghdr *cmsg;
    char control[CMSG_SPACE(sizeof(int))];
    memset(&msg, 0, sizeof(msg));
    memset(&iov, 0, sizeof(iov));
    memset(control, 0, sizeof(control));
    iov.iov_base = buffer;
    iov.iov_len = sizeof(buffer);
    msg.msg_name = NULL;
    msg.msg_namelen = 0;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = control;
    msg.msg_controllen = sizeof(control);
    if (recvmsg(connfd, &msg, 0) < 0) {
        perror("recvmsg error");
        close(sockfd);
        close(connfd);
        exit(1);
    }
    cmsg = CMSG_FIRSTHDR(&msg);
    if (cmsg && cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
        fd_received = *((int *)CMSG_DATA(cmsg));
        char data[BUFFER_SIZE];
        ssize_t read_bytes = read(fd_received, data, sizeof(data));
        if (read_bytes > 0) {
            data[read_bytes] = '\0';
            printf("Received data from file: %s\n", data);
        }
        close(fd_received);
    }
    close(sockfd);
    close(connfd);
    unlink(SOCKET_PATH);
    return 0;
}

在这个示例中,sender.c 打开一个文件并通过 Unix 域套接字将文件描述符发送给 receiver.creceiver.c 接收文件描述符并通过该描述符读取文件内容。

基于共享内存的文件描述符传递

共享内存是一种高效的进程间通信方式,它允许不同进程访问同一块物理内存区域。虽然共享内存本身并不直接支持文件描述符传递,但可以通过结合其他机制来实现。

共享内存的基本操作

在 Linux 中,共享内存通过 shmgetshmatshmdtshmctl 等函数进行操作。shmget 函数用于创建或获取共享内存段,shmat 函数用于将共享内存段附加到进程的地址空间,shmdt 函数用于分离共享内存段,shmctl 函数用于控制共享内存段的各种属性。

结合共享内存传递文件描述符

一种实现方式是在共享内存中存储文件描述符的值,然后通过信号量等同步机制确保不同进程对共享内存的访问顺序。以下是一个简化的示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <sys/ipc.h>
#include <fcntl.h>

#define SHM_KEY 1234
#define SEM_KEY 5678

union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
};

void semaphore_p(int semid) {
    struct sembuf sem_op;
    sem_op.sem_num = 0;
    sem_op.sem_op = -1;
    sem_op.sem_flg = 0;
    semop(semid, &sem_op, 1);
}

void semaphore_v(int semid) {
    struct sembuf sem_op;
    sem_op.sem_num = 0;
    sem_op.sem_op = 1;
    sem_op.sem_flg = 0;
    semop(semid, &sem_op, 1);
}

int main() {
    int shmid, semid, fd;
    int *shared_fd;
    // 创建共享内存
    shmid = shmget(SHM_KEY, sizeof(int), IPC_CREAT | 0666);
    if (shmid < 0) {
        perror("shmget error");
        exit(1);
    }
    // 附加共享内存
    shared_fd = (int *)shmat(shmid, NULL, 0);
    if (shared_fd == (void *)-1) {
        perror("shmat error");
        shmctl(shmid, IPC_RMID, NULL);
        exit(1);
    }
    // 创建信号量
    semid = semget(SEM_KEY, 1, IPC_CREAT | 0666);
    if (semid < 0) {
        perror("semget error");
        shmdt(shared_fd);
        shmctl(shmid, IPC_RMID, NULL);
        exit(1);
    }
    union semun sem_set;
    sem_set.val = 1;
    if (semctl(semid, 0, SETVAL, sem_set) < 0) {
        perror("semctl error");
        shmdt(shared_fd);
        shmctl(shmid, IPC_RMID, NULL);
        semctl(semid, 0, IPC_RMID, NULL);
        exit(1);
    }
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork error");
        shmdt(shared_fd);
        shmctl(shmid, IPC_RMID, NULL);
        semctl(semid, 0, IPC_RMID, NULL);
        exit(1);
    } else if (pid == 0) {
        // 子进程
        semaphore_p(semid);
        fd = *shared_fd;
        char data[1024];
        ssize_t read_bytes = read(fd, data, sizeof(data));
        if (read_bytes > 0) {
            data[read_bytes] = '\0';
            printf("Child process read from file: %s\n", data);
        }
        semaphore_v(semid);
        shmdt(shared_fd);
    } else {
        // 父进程
        fd = open("test.txt", O_RDONLY);
        if (fd < 0) {
            perror("open error");
            shmdt(shared_fd);
            shmctl(shmid, IPC_RMID, NULL);
            semctl(semid, 0, IPC_RMID, NULL);
            exit(1);
        }
        semaphore_p(semid);
        *shared_fd = fd;
        semaphore_v(semid);
        wait(NULL);
        shmdt(shared_fd);
        shmctl(shmid, IPC_RMID, NULL);
        semctl(semid, 0, IPC_RMID, NULL);
        close(fd);
    }
    return 0;
}

在这个示例中,父进程打开一个文件,将文件描述符存储在共享内存中,通过信号量控制子进程对共享内存的访问,子进程从共享内存中获取文件描述符并读取文件内容。

文件描述符跨进程使用的注意事项

文件描述符的生命周期管理

在跨进程使用文件描述符时,必须小心管理文件描述符的生命周期。如果一个进程关闭了文件描述符,而其他进程还在使用它,可能会导致未定义行为。例如,在父子进程通过管道通信时,父进程在子进程完成读取之前关闭管道的写端,可能会导致子进程读取到错误的数据或产生异常。

为了避免这种情况,需要在进程间进行明确的同步。可以使用信号量、互斥锁等同步机制来确保文件描述符在不再需要时才被关闭。例如,在上述共享内存传递文件描述符的示例中,通过信号量控制了对共享内存中文件描述符的访问,同时也间接保证了文件描述符在合适的时机被处理。

错误处理与可靠性

在进行文件描述符跨进程传递时,错误处理至关重要。例如,在使用 Unix 域套接字传递文件描述符时,如果 sendmsgrecvmsg 函数调用失败,需要及时处理错误,关闭相关的文件描述符和套接字,避免资源泄漏。

同时,要考虑到进程间通信的可靠性。例如,在网络环境下(即使是 Unix 域套接字也可能在网络命名空间中使用),数据可能会丢失或损坏。可以通过增加校验和、重传机制等方式来提高通信的可靠性。

安全性问题

文件描述符跨进程使用可能带来一些安全风险。例如,如果一个不可信的进程获得了敏感文件的文件描述符,可能会对该文件进行非法操作。为了提高安全性,需要对进程进行严格的权限控制。在 Linux 系统中,可以使用 setuidsetgid 等机制来限制进程的权限,确保只有授权的进程能够访问特定的文件描述符。

此外,在传递文件描述符时,要避免信息泄露。例如,在共享内存传递文件描述符的场景中,要确保共享内存的访问权限设置合理,防止其他未授权进程读取共享内存中的文件描述符信息。

通过深入理解文件描述符跨进程使用的原理、掌握各种实现技术,并注意相关的注意事项,可以在 Linux C 语言编程中有效地实现进程间的 I/O 资源共享,开发出高效、可靠和安全的应用程序。