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

Linux C语言等待子进程的超时设置

2021-10-317.1k 阅读

1. 进程相关基础知识回顾

在深入探讨等待子进程的超时设置之前,我们先来回顾一些进程相关的基础概念。

1.1 进程的概念

进程是程序在计算机上的一次执行活动。当你运行一个程序时,操作系统会为这个程序分配一定的资源,如内存、CPU 时间片等,这些资源的集合以及程序的执行状态就构成了一个进程。在 Linux 系统中,每个进程都有一个唯一的标识符,称为进程 ID(PID)。

1.2 父子进程关系

在 Linux 中,一个进程可以通过 fork() 系统调用创建一个新的进程。新创建的进程被称为子进程,而调用 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("I am the child process, my pid is %d\n", getpid());
        exit(0);
    } else {
        // 父进程代码
        printf("I am the parent process, my pid is %d, child pid is %d\n", getpid(), pid);
    }
    return 0;
}

在上述代码中,fork() 函数会返回两次,一次在父进程中返回子进程的 PID,另一次在子进程中返回 0。通过判断返回值,我们可以区分父进程和子进程,并在不同的分支中执行相应的代码。

1.3 进程的状态

进程在其生命周期中有多种状态,常见的状态包括:

  • 运行态(Running):进程正在 CPU 上执行。
  • 就绪态(Ready):进程已经准备好执行,等待 CPU 分配时间片。
  • 阻塞态(Blocked):进程由于等待某些事件(如 I/O 完成、信号等)而暂停执行。
  • 僵尸态(Zombie):子进程已经终止,但父进程尚未调用 wait()waitpid() 来获取其终止状态,此时子进程处于僵尸态。僵尸进程会占用系统资源,如果大量出现,可能会导致系统资源耗尽。

2. 等待子进程的常规方法

在 Linux C 语言编程中,父进程通常需要等待子进程完成任务后再继续执行,以确保数据的完整性和正确性。这就涉及到等待子进程的操作。

2.1 wait 函数

wait() 函数用于使父进程暂停执行,直到它的一个子进程终止。函数原型如下:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
  • 参数status 是一个指向整数的指针,用于存储子进程的终止状态。如果不关心子进程的终止状态,可以将其设置为 NULL
  • 返回值:成功时,返回已终止子进程的 PID;出错时,返回 -1。

以下是一个使用 wait() 函数的示例:

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

int main() {
    pid_t pid;
    int status;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        // 子进程代码
        sleep(2); // 模拟子进程执行任务
        printf("Child process is exiting\n");
        exit(0);
    } else {
        // 父进程代码
        printf("Parent process is waiting for child\n");
        pid_t child_pid = wait(&status);
        if (child_pid == -1) {
            perror("wait error");
            exit(1);
        }
        printf("Parent process: child with pid %d has exited\n", child_pid);
    }
    return 0;
}

在这个例子中,父进程调用 wait() 函数等待子进程结束。子进程通过 sleep(2) 模拟执行了一些任务,然后退出。父进程在子进程退出后,获取到子进程的 PID 并打印相关信息。

2.2 waitpid 函数

waitpid() 函数提供了更灵活的等待子进程的方式。它不仅可以等待特定的子进程,还可以设置一些选项来控制等待行为。函数原型如下:

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
  • 参数
    • pid:指定等待的子进程的 PID。如果 pid 为 -1,表示等待任意一个子进程;如果 pid 大于 0,表示等待指定 PID 的子进程;如果 pid 为 0,表示等待与调用进程在同一进程组中的任意子进程;如果 pid 小于 -1,表示等待其进程组 ID 等于 pid 的绝对值的任意子进程。
    • status:与 wait() 函数中的 status 作用相同,用于存储子进程的终止状态。
    • options:可以设置一些选项来控制等待行为。常见的选项有 WNOHANGWUNTRACEDWNOHANG 表示如果没有子进程退出,waitpid() 不阻塞,立即返回 0;WUNTRACED 表示如果子进程处于暂停状态,waitpid() 也会返回。

以下是使用 waitpid() 函数并设置 WNOHANG 选项的示例:

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

int main() {
    pid_t pid;
    int status;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        // 子进程代码
        sleep(2); // 模拟子进程执行任务
        printf("Child process is exiting\n");
        exit(0);
    } else {
        // 父进程代码
        printf("Parent process is waiting for child\n");
        int ret;
        while ((ret = waitpid(pid, &status, WNOHANG)) == 0) {
            printf("Child process is still running...\n");
            sleep(1);
        }
        if (ret == -1) {
            perror("waitpid error");
            exit(1);
        }
        printf("Parent process: child with pid %d has exited\n", ret);
    }
    return 0;
}

在这个示例中,父进程通过 waitpid() 函数并设置 WNOHANG 选项,在子进程未结束时,父进程不会阻塞,而是每隔一秒打印提示信息,直到子进程结束。

3. 等待子进程的超时设置需求

在实际应用中,仅仅等待子进程完成是不够的,有时我们需要设置一个时间限制。例如,在一些网络服务程序中,子进程可能负责处理客户端的请求,如果子进程长时间没有响应,父进程不能一直等待下去,否则可能会导致整个服务的性能下降甚至无响应。

3.1 可能出现的问题

  • 资源浪费:如果父进程无限期等待子进程,而子进程由于某种原因陷入死循环或长时间阻塞,那么父进程所占用的资源(如内存、文件描述符等)就无法得到释放和有效利用。
  • 影响系统性能:在多进程并发的系统中,大量父进程长时间等待无响应的子进程,会导致系统资源紧张,影响其他进程的正常运行,进而降低整个系统的性能。

3.2 应用场景举例

  • 网络服务器:如 HTTP 服务器,每个客户端请求可能由一个子进程处理。如果某个子进程因为网络故障或其他原因长时间不能完成任务,服务器不能一直等待,需要及时释放资源并向客户端返回错误信息。
  • 批处理任务:在一些批量处理数据的程序中,可能会创建多个子进程并行处理任务。如果某个子进程处理异常,长时间不结束,主进程需要在一定时间后放弃等待,继续执行后续的处理逻辑。

4. 实现等待子进程超时设置的方法

4.1 使用 alarm 函数和信号处理机制

alarm() 函数用于设置一个定时器,当定时器到期时,会向进程发送 SIGALRM 信号。我们可以结合信号处理函数来实现等待子进程的超时设置。

4.1.1 alarm 函数

alarm() 函数的原型如下:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
  • 参数seconds 表示设置的定时器时间,单位为秒。
  • 返回值:返回之前设置的定时器剩余的秒数,如果之前没有设置定时器,则返回 0。

4.1.2 信号处理函数

我们需要定义一个信号处理函数来处理 SIGALRM 信号。在信号处理函数中,我们可以采取相应的措施,如终止等待子进程的操作,并进行一些清理工作。

以下是使用 alarm() 函数和信号处理机制实现等待子进程超时设置的代码示例:

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

volatile sig_atomic_t alarm_fired = 0;

void sigalrm_handler(int signum) {
    alarm_fired = 1;
}

int main() {
    pid_t pid;
    int status;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        // 子进程代码
        sleep(5); // 模拟子进程执行任务
        printf("Child process is exiting\n");
        exit(0);
    } else {
        // 父进程代码
        struct sigaction sa;
        sa.sa_handler = sigalrm_handler;
        sigemptyset(&sa.sa_mask);
        sa.sa_flags = 0;
        if (sigaction(SIGALRM, &sa, NULL) == -1) {
            perror("sigaction error");
            exit(1);
        }
        alarm(3); // 设置 3 秒的定时器
        printf("Parent process is waiting for child\n");
        pid_t child_pid = waitpid(pid, &status, 0);
        alarm(0); // 取消定时器
        if (alarm_fired) {
            printf("Timeout: child process did not exit in time\n");
            // 这里可以进行一些清理操作,如终止子进程
        } else {
            if (child_pid == -1) {
                perror("waitpid error");
                exit(1);
            }
            printf("Parent process: child with pid %d has exited\n", child_pid);
        }
    }
    return 0;
}

在上述代码中,我们定义了一个全局变量 alarm_fired 来标记是否收到 SIGALRM 信号。在信号处理函数 sigalrm_handler 中,将 alarm_fired 设置为 1。父进程在调用 waitpid() 之前,通过 alarm(3) 设置了一个 3 秒的定时器。如果在 3 秒内子进程没有结束,SIGALRM 信号会被触发,alarm_fired 会被设置为 1,父进程会检测到超时并进行相应处理。

4.2 使用 select 函数实现超时等待

select() 函数最初是用于 I/O 多路复用,它可以监控多个文件描述符的状态变化。但我们也可以利用它的超时机制来实现等待子进程的超时设置。

4.2.1 select 函数

select() 函数的原型如下:

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • 参数
    • nfds:监控的文件描述符集合中最大文件描述符加 1。
    • readfds:指向 fd_set 结构体的指针,用于监控可读事件。
    • writefds:指向 fd_set 结构体的指针,用于监控可写事件。
    • exceptfds:指向 fd_set 结构体的指针,用于监控异常事件。
    • timeout:指向 struct timeval 结构体的指针,用于设置超时时间。struct timeval 结构体定义如下:
struct timeval {
    long tv_sec;  // 秒
    long tv_usec; // 微秒
};
  • 返回值:成功时,返回准备好的文件描述符的数量;超时返回 0;出错返回 -1。

4.2.2 利用 select 实现等待子进程超时

我们可以创建一个管道,父进程在等待子进程时,同时监控管道的读端和子进程的状态。当子进程结束时,向管道写入数据,父进程通过 select() 函数检测到管道可读,就知道子进程已结束。如果 select() 超时,说明子进程在规定时间内未结束。

以下是使用 select() 函数实现等待子进程超时设置的代码示例:

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

int main() {
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe error");
        exit(1);
    }
    pid_t pid;
    int status;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        // 子进程代码
        close(pipefd[0]); // 子进程关闭读端
        sleep(5); // 模拟子进程执行任务
        write(pipefd[1], "done", 4); // 子进程结束时向管道写入数据
        close(pipefd[1]);
        printf("Child process is exiting\n");
        exit(0);
    } else {
        // 父进程代码
        close(pipefd[1]); // 父进程关闭写端
        fd_set read_fds;
        FD_ZERO(&read_fds);
        FD_SET(pipefd[0], &read_fds);
        struct timeval timeout;
        timeout.tv_sec = 3;
        timeout.tv_usec = 0;
        printf("Parent process is waiting for child\n");
        int ret = select(pipefd[0] + 1, &read_fds, NULL, NULL, &timeout);
        if (ret == -1) {
            perror("select error");
            exit(1);
        } else if (ret == 0) {
            printf("Timeout: child process did not exit in time\n");
            // 这里可以进行一些清理操作,如终止子进程
        } else {
            char buf[5];
            read(pipefd[0], buf, sizeof(buf));
            pid_t child_pid = waitpid(pid, &status, 0);
            if (child_pid == -1) {
                perror("waitpid error");
                exit(1);
            }
            printf("Parent process: child with pid %d has exited\n", child_pid);
        }
        close(pipefd[0]);
    }
    return 0;
}

在这个示例中,父进程创建了一个管道,并在 select() 函数中监控管道的读端。子进程在结束时向管道写入数据,父进程通过 select() 函数检测到管道可读,就知道子进程已结束。如果 select() 超时,父进程会执行超时处理逻辑。

4.3 使用 poll 函数实现超时等待

poll() 函数与 select() 函数类似,也是用于 I/O 多路复用,但它在处理大量文件描述符时性能更好。我们同样可以利用 poll() 函数的超时机制来实现等待子进程的超时设置。

4.3.1 poll 函数

poll() 函数的原型如下:

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • 参数
    • fds:指向 struct pollfd 结构体数组的指针,struct pollfd 结构体定义如下:
struct pollfd {
    int fd;         // 文件描述符
    short events;   // 等待的事件
    short revents;  // 发生的事件
};
- `nfds`:`fds` 数组中元素的个数。
- `timeout`:超时时间,单位为毫秒。如果 `timeout` 为 -1,表示无限期等待;如果 `timeout` 为 0,表示立即返回。
  • 返回值:成功时,返回准备好的文件描述符的数量;超时返回 0;出错返回 -1。

4.3.2 利用 poll 实现等待子进程超时

与使用 select() 函数类似,我们可以创建一个管道,父进程通过 poll() 函数监控管道的读端和子进程的状态。

以下是使用 poll() 函数实现等待子进程超时设置的代码示例:

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

int main() {
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe error");
        exit(1);
    }
    pid_t pid;
    int status;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        // 子进程代码
        close(pipefd[0]); // 子进程关闭读端
        sleep(5); // 模拟子进程执行任务
        write(pipefd[1], "done", 4); // 子进程结束时向管道写入数据
        close(pipefd[1]);
        printf("Child process is exiting\n");
        exit(0);
    } else {
        // 父进程代码
        close(pipefd[1]); // 父进程关闭写端
        struct pollfd fds[1];
        fds[0].fd = pipefd[0];
        fds[0].events = POLLIN;
        printf("Parent process is waiting for child\n");
        int ret = poll(fds, 1, 3000); // 设置 3 秒超时
        if (ret == -1) {
            perror("poll error");
            exit(1);
        } else if (ret == 0) {
            printf("Timeout: child process did not exit in time\n");
            // 这里可以进行一些清理操作,如终止子进程
        } else {
            char buf[5];
            read(pipefd[0], buf, sizeof(buf));
            pid_t child_pid = waitpid(pid, &status, 0);
            if (child_pid == -1) {
                perror("waitpid error");
                exit(1);
            }
            printf("Parent process: child with pid %d has exited\n", child_pid);
        }
        close(pipefd[0]);
    }
    return 0;
}

在这个示例中,父进程通过 poll() 函数监控管道读端的 POLLIN 事件。如果在 3 秒内子进程结束并向管道写入数据,poll() 函数会返回大于 0 的值,父进程获取子进程状态;否则,poll() 函数超时返回 0,父进程执行超时处理逻辑。

5. 不同方法的比较与选择

5.1 信号处理机制(alarm 函数)

  • 优点
    • 实现相对简单,代码量较少。只需要设置定时器和定义信号处理函数,就能实现基本的超时等待功能。
    • 直接利用了系统提供的信号机制,对于熟悉信号处理的开发者来说,容易理解和掌握。
  • 缺点
    • 信号处理函数的执行环境比较特殊,在信号处理函数中不能调用一些不安全的函数,如 printf() 等。这可能会给调试和日志记录带来一定的困难。
    • 信号处理函数可能会打断正常的程序流程,导致一些未预期的问题。例如,如果在信号处理函数中修改了共享变量,可能会导致竞态条件。

5.2 select 函数

  • 优点
    • 标准的 I/O 多路复用函数,可移植性好,在不同的 Unix 系统上都有很好的支持。
    • 可以同时监控多个文件描述符的多种事件(可读、可写、异常),不仅仅局限于等待子进程超时。
  • 缺点
    • 每次调用 select() 函数时,都需要重新设置文件描述符集合,这在处理大量文件描述符时效率较低。
    • select() 函数对文件描述符的数量有限制,通常为 1024 个,在处理大量并发连接时可能不够用。

5.3 poll 函数

  • 优点
    • select() 函数类似,但在处理大量文件描述符时性能更好。poll() 函数通过 struct pollfd 结构体数组来管理文件描述符,不需要每次调用时重新设置整个集合。
    • 对文件描述符的数量没有限制(理论上),更适合处理大量并发连接的场景。
  • 缺点
    • 相对 select() 函数,poll() 函数的接口稍微复杂一些,需要更多的代码来初始化 struct pollfd 结构体数组。
    • 同样是基于轮询的方式,在高并发场景下,如果文件描述符状态变化频繁,性能会受到一定影响。

在实际应用中,选择哪种方法取决于具体的需求。如果对代码的简洁性要求较高,且对信号处理有较好的把握,可以选择使用 alarm 函数和信号处理机制;如果需要较好的可移植性,且处理的文件描述符数量不是特别大,可以选择 select() 函数;如果需要处理大量文件描述符且追求更好的性能,poll() 函数可能是更好的选择。

6. 注意事项与常见问题

6.1 资源清理

无论使用哪种方法实现等待子进程的超时设置,在子进程超时未结束时,都需要注意进行资源清理。例如,如果子进程占用了一些系统资源(如打开的文件、分配的内存等),父进程在发现超时后,需要采取措施终止子进程,并释放这些资源,以避免资源泄漏。

6.2 竞态条件

在使用信号处理机制时,要特别注意竞态条件的问题。由于信号处理函数是异步执行的,可能会在程序的任何地方打断正常的执行流程。因此,在信号处理函数中访问共享变量时,需要采取同步措施,如使用互斥锁等,以确保数据的一致性。

6.3 跨平台兼容性

虽然 Linux 系统提供了丰富的系统调用和函数来实现等待子进程的超时设置,但在编写跨平台的程序时,需要考虑不同操作系统的兼容性。例如,select() 函数在大多数 Unix 系统上都有支持,但在 Windows 系统上有不同的实现方式(如 select() 函数在 Windows 下主要用于网络 I/O)。因此,在编写跨平台代码时,可能需要根据不同的操作系统进行条件编译。

6.4 调试与日志记录

在实现超时等待功能的过程中,调试和日志记录非常重要。特别是在使用信号处理机制时,由于信号处理函数的执行环境特殊,一些常规的调试手段(如打印日志)可能会失效。此时,可以考虑使用更安全的调试方法,如使用 syslog 函数记录日志,或者使用调试工具(如 gdb)来跟踪程序的执行流程。

7. 总结

在 Linux C 语言编程中,等待子进程的超时设置是一个重要的功能,它可以有效地避免资源浪费和提高系统性能。我们介绍了三种常见的实现方法:使用 alarm 函数和信号处理机制、使用 select 函数以及使用 poll 函数。每种方法都有其优缺点,在实际应用中需要根据具体需求进行选择。同时,在实现过程中要注意资源清理、竞态条件、跨平台兼容性以及调试与日志记录等问题,以确保程序的稳定性和可靠性。通过合理地运用这些方法和注意相关事项,开发者可以编写出高效、健壮的多进程程序。