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

Linux C语言进程创建的深入探究

2022-09-183.3k 阅读

进程的基本概念

什么是进程

在Linux系统中,进程是程序的一次执行过程。它是一个动态的概念,与静态的程序有着本质的区别。程序是存储在磁盘上的可执行文件,而进程是程序在执行时所占用的系统资源的集合,包括内存空间、打开的文件描述符、信号处理机制等。每个进程都有一个唯一的标识符,称为进程ID(PID),系统通过PID来管理和调度进程。

进程的状态

进程在其生命周期中会处于不同的状态,主要包括以下几种:

  1. 运行态(Running):进程正在CPU上执行。在多任务系统中,由于CPU时间片的轮转,一个进程可能会在运行态和就绪态之间频繁切换。
  2. 就绪态(Ready):进程已经准备好运行,等待CPU调度。处于就绪态的进程在就绪队列中等待被分配CPU资源。
  3. 阻塞态(Blocked):进程因为等待某些事件(如I/O操作完成、信号等)而暂时无法运行。例如,当进程进行磁盘I/O操作时,它会进入阻塞态,直到I/O操作完成后才会转换到就绪态。
  4. 停止态(Stopped):进程被暂停执行,通常是由于接收到特定的信号(如SIGSTOP)。进程处于停止态时,它不再参与CPU调度,但它的资源仍然被系统保留。
  5. 僵死态(Zombie):当一个进程终止时,它的父进程没有调用wait()waitpid()函数来获取其终止状态,该进程就会进入僵死态。僵死进程虽然已经不再运行,但它的进程控制块(PCB)仍然保留在系统中,直到父进程调用相应的等待函数来清理它。

进程控制块(PCB)

进程控制块是操作系统用于管理进程的数据结构,它包含了进程的所有相关信息,如进程ID、进程状态、程序计数器、内存指针、打开的文件描述符表、信号处理函数指针等。操作系统通过PCB来实现对进程的调度、资源分配和管理。在Linux内核中,PCB是一个名为task_struct的结构体,其定义在<linux/sched.h>头文件中。虽然应用程序通常不会直接访问task_struct结构体,但了解其存在对于理解进程管理的原理是非常重要的。

Linux C语言中进程创建的函数

fork()函数

fork()是Linux C语言中用于创建新进程的基本函数。它的函数原型如下:

#include <unistd.h>
pid_t fork(void);

fork()函数会创建一个与调用进程(父进程)几乎完全相同的新进程(子进程)。子进程是父进程的副本,它继承了父进程的大部分资源,包括内存空间(通过写时复制技术,COW)、打开的文件描述符、当前工作目录等。fork()函数的返回值有两种情况:

  1. 在父进程中,fork()返回子进程的PID。
  2. 在子进程中,fork()返回0。
  3. 如果fork()调用失败,返回-1,并设置errno以指示错误原因。

下面是一个简单的fork()函数示例:

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

int main() {
    pid_t pid;

    pid = fork();

    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        printf("This is the child process. PID: %d, PPID: %d\n", getpid(), getppid());
    } else {
        printf("This is the parent process. PID: %d, Child PID: %d\n", getpid(), pid);
    }

    return 0;
}

在这个示例中,fork()函数被调用后,父进程和子进程都会继续执行后续的代码。通过判断fork()的返回值,我们可以区分父进程和子进程,并在不同的进程中执行不同的操作。

vfork()函数

vfork()函数也是用于创建新进程的函数,它的函数原型与fork()相同:

#include <unistd.h>
pid_t vfork(void);

vfork()fork()的主要区别在于:

  1. 内存共享方式vfork()创建的子进程与父进程共享地址空间,而fork()创建的子进程采用写时复制技术,最初与父进程共享内存,但当任何一个进程试图修改内存时,会为修改的部分分配新的内存页。
  2. 执行顺序vfork()保证子进程先执行,直到子进程调用exec()系列函数或exit()函数后,父进程才会继续执行。而fork()之后,父进程和子进程的执行顺序是不确定的,由内核调度决定。

下面是一个vfork()函数的示例:

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

int main() {
    pid_t pid;
    int data = 10;

    pid = vfork();

    if (pid < 0) {
        perror("vfork error");
        exit(1);
    } else if (pid == 0) {
        data = 20;
        printf("Child process: data = %d\n", data);
        _exit(0);
    } else {
        printf("Parent process: data = %d\n", data);
    }

    return 0;
}

在这个示例中,由于子进程和父进程共享地址空间,子进程对data变量的修改会影响到父进程。同时,子进程通过调用_exit(0)函数来结束自己,确保父进程能够继续执行。

clone()函数

clone()函数是一个更底层的进程创建函数,它提供了更细粒度的控制,可以选择继承父进程的哪些资源。其函数原型如下:

#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
          /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

clone()函数的参数说明:

  1. fn:子进程开始执行的函数指针。
  2. stack:为子进程分配的栈空间指针。
  3. flags:标志位,用于指定子进程继承父进程的哪些资源,如CLONE_VM(共享内存)、CLONE_FS(共享文件系统相关信息)等。
  4. arg:传递给fn函数的参数。
  5. 后面的可选参数ptidtlsctid用于获取子进程的PID、设置线程局部存储和获取子进程的线程组ID等。

下面是一个简单的clone()函数示例:

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define STACK_SIZE (1024 * 1024)

static int child_func(void *arg) {
    printf("Child process: PID = %d\n", getpid());
    return 0;
}

int main() {
    void *stack = malloc(STACK_SIZE);
    if (!stack) {
        perror("malloc");
        return 1;
    }

    pid_t pid = clone(child_func, stack + STACK_SIZE, SIGCHLD, NULL);
    if (pid < 0) {
        perror("clone");
        free(stack);
        return 1;
    }

    printf("Parent process: Child PID = %d\n", pid);
    waitpid(pid, NULL, 0);
    free(stack);

    return 0;
}

在这个示例中,我们使用clone()函数创建了一个新的进程(实际上,由于clone()的灵活性,它也可以用于创建线程)。通过为子进程分配独立的栈空间,并指定子进程开始执行的函数child_func,我们实现了一个简单的进程创建操作。

进程创建过程中的资源管理

内存管理

当使用fork()函数创建新进程时,子进程最初与父进程共享内存空间,采用写时复制(COW)技术。这意味着在子进程或父进程没有对共享内存进行写操作之前,它们共享相同的物理内存页。当其中一个进程试图修改共享内存时,系统会为修改的部分分配新的物理内存页,从而使父进程和子进程的内存空间分离。

vfork()函数创建的子进程与父进程共享地址空间,这使得子进程对内存的修改会直接影响到父进程。因此,在使用vfork()时需要特别小心,确保子进程在修改共享内存后尽快调用exec()系列函数或exit()函数,以避免对父进程造成意外影响。

clone()函数可以通过设置CLONE_VM标志位来决定是否与父进程共享内存空间。如果设置了CLONE_VM,子进程和父进程将共享相同的内存地址空间;否则,子进程将拥有自己独立的内存空间。

文件描述符管理

进程打开的文件描述符在进程创建时也会被继承。当使用fork()vfork()clone()创建新进程时,子进程会继承父进程的文件描述符表,这意味着子进程可以访问父进程打开的所有文件。

例如,假设父进程打开了一个文件并获得了文件描述符fd,在fork()之后,子进程也可以通过fd来访问该文件。但是,需要注意的是,文件偏移量是进程私有的。也就是说,父进程和子进程对文件的读写位置是独立的。

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

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

    fd = open("test.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        exit(1);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        close(fd);
        exit(1);
    } else if (pid == 0) {
        read(fd, buffer, 5);
        buffer[5] = '\0';
        printf("Child read: %s\n", buffer);
        close(fd);
    } else {
        read(fd, buffer, 3);
        buffer[3] = '\0';
        printf("Parent read: %s\n", buffer);
        wait(NULL);
        close(fd);
    }

    return 0;
}

在这个示例中,父进程打开了一个文件test.txt,然后通过fork()创建了子进程。父进程和子进程分别从文件中读取不同长度的数据,由于文件偏移量是独立的,它们读取的数据不会相互干扰。

信号处理管理

信号是一种异步通知机制,用于在进程间传递事件。在进程创建时,子进程会继承父进程的信号处理设置。这意味着子进程会采用与父进程相同的信号处理函数来处理信号。

例如,如果父进程设置了对SIGINT信号(通常由用户按下Ctrl+C产生)的自定义处理函数,在fork()之后,子进程也会使用相同的处理函数来处理SIGINT信号。

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

void sigint_handler(int signum) {
    printf("Received SIGINT\n");
}

int main() {
    pid_t pid;

    signal(SIGINT, sigint_handler);

    pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(1);
    } else if (pid == 0) {
        printf("Child process. PID: %d\n", getpid());
        while (1);
    } else {
        printf("Parent process. PID: %d\n", getpid());
        while (1);
    }

    return 0;
}

在这个示例中,父进程设置了SIGINT信号的处理函数sigint_handler。当用户在终端中按下Ctrl+C时,无论是父进程还是子进程,都会调用sigint_handler函数来处理该信号。

进程创建中的常见问题与解决方法

僵死进程问题

如前文所述,当一个进程终止时,如果它的父进程没有调用wait()waitpid()函数来获取其终止状态,该进程就会进入僵死态。僵死进程虽然不再占用CPU资源,但它的PCB仍然保留在系统中,会浪费系统资源。

解决僵死进程问题的方法是在父进程中调用wait()waitpid()函数来等待子进程结束,并获取其终止状态。wait()函数会阻塞父进程,直到任意一个子进程终止;waitpid()函数则可以指定等待某个特定的子进程,并且可以设置非阻塞模式。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main() {
    pid_t pid;
    int status;

    pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(1);
    } else if (pid == 0) {
        printf("Child process. PID: %d\n", getpid());
        exit(0);
    } else {
        printf("Parent process. PID: %d\n", getpid());
        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) {
            printf("Child exited with status %d\n", WEXITSTATUS(status));
        }
    }

    return 0;
}

在这个示例中,父进程通过waitpid()函数等待子进程结束,并获取子进程的终止状态。WIFEXITED(status)宏用于判断子进程是否正常退出,WEXITSTATUS(status)宏用于获取子进程的退出状态。

内存泄漏问题

在进程创建过程中,如果不注意内存管理,可能会导致内存泄漏。例如,在使用clone()函数时,如果为子进程分配的栈空间没有在合适的时候释放,就会造成内存泄漏。

为了避免内存泄漏,需要确保在进程结束时,所有动态分配的内存都被正确释放。在前面的clone()示例中,我们在父进程中使用free(stack)来释放为子进程分配的栈空间,以防止内存泄漏。

资源竞争问题

当多个进程共享某些资源(如文件、共享内存等)时,可能会出现资源竞争问题。例如,多个进程同时对一个文件进行写操作,可能会导致文件内容混乱。

为了解决资源竞争问题,可以使用同步机制,如信号量、互斥锁等。在Linux系统中,semaphore是一种常用的同步工具,用于控制对共享资源的访问。通过使用信号量,进程可以在访问共享资源前获取信号量,访问结束后释放信号量,从而避免多个进程同时访问共享资源。

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

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

void semaphore_p(int semid) {
    struct sembuf sem_op;
    sem_op.sem_num = 0;
    sem_op.sem_op = -1;
    sem_op.sem_flg = SEM_UNDO;
    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 = SEM_UNDO;
    semop(semid, &sem_op, 1);
}

int main() {
    key_t key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        exit(1);
    }

    int semid = semget(key, 1, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        exit(1);
    }

    union semun sem_set;
    sem_set.val = 1;
    if (semctl(semid, 0, SETVAL, sem_set) == -1) {
        perror("semctl");
        semctl(semid, 0, IPC_RMID);
        exit(1);
    }

    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        semctl(semid, 0, IPC_RMID);
        exit(1);
    } else if (pid == 0) {
        semaphore_p(semid);
        printf("Child process is accessing the shared resource.\n");
        sleep(2);
        printf("Child process has finished accessing the shared resource.\n");
        semaphore_v(semid);
        exit(0);
    } else {
        semaphore_p(semid);
        printf("Parent process is accessing the shared resource.\n");
        sleep(2);
        printf("Parent process has finished accessing the shared resource.\n");
        semaphore_v(semid);
        wait(NULL);
        semctl(semid, 0, IPC_RMID);
    }

    return 0;
}

在这个示例中,我们使用信号量来控制父进程和子进程对共享资源的访问。通过semaphore_p函数获取信号量,semaphore_v函数释放信号量,确保同一时间只有一个进程可以访问共享资源。

总结

通过深入探究Linux C语言中进程创建的相关知识,我们了解了进程的基本概念、进程创建的函数(fork()vfork()clone())以及进程创建过程中的资源管理和常见问题。在实际编程中,合理使用这些知识可以创建高效、稳定的多进程程序。同时,注意避免僵死进程、内存泄漏和资源竞争等问题,以确保程序的健壮性和可靠性。掌握进程创建的技术对于开发高性能、并发处理的Linux应用程序至关重要,希望本文能为读者在这方面的学习和实践提供有价值的参考。