父进程和子进程

如果想创建一个新的进程,使用函数 fork 就可以。

  1. pid_t fork(void)
  2. 返回:在子进程中为0,在父进程中为子进程ID,若出错则为-1

fork 函数实现的时候,实际上会把当前父进程的所有相关值都克隆一份,包括地址空间、打开的文件描述符、程序计数器等,就连执行代码也会拷贝一份,新派生的进程的表现行为和父进程近乎一样,就好像是派生进程调用过 fork 函数一样。为了区别两个不同的进程,实现者可以通过改变 fork 函数的栈空间值来判断,对应到程序中就是返回值的不同。

  1. if(fork() == 0){
  2. do_child_process(); //子进程执行代码
  3. }else{
  4. do_parent_process(); //父进程执行代码
  5. }

当一个子进程退出时,系统内核还保留了该进程的若干信息,比如退出状态。这样的进程如果不回收,就会变成僵尸进程。在 Linux 下,这样的“僵尸”进程会被挂到进程号为 1 的 init 进程上。所以,由父进程派生出来的子进程,也必须由父进程负责回收,否则子进程就会变成僵尸进程。僵尸进程会占用不必要的内存空间,如果量多到了一定数量级,就会耗尽我们的系统资源。

有两种方式可以在子进程退出后回收资源,分别是调用 wait 和 waitpid 函数:

  1. pid_t wait(int *statloc);
  2. pid_t waitpid(pid_t pid, int *statloc, int options);
  • pid_t: 已终止子进程的进程 ID 号
  • statloc: 子进程终止的实际状态
    • 正常终止
    • 被信号杀死
    • 作业控制停止

如果没有已终止的子进程,而是有一个或多个子进程在正常运行,那么 wait 将阻塞,直到第一个子进程终止。

  • pid: 允许我们指定任意想等待终止的进程 ID
    • -1: 等待第一个终止的子进程
  • options: 更多的控制选项

处理子进程退出的方式一般是注册一个信号处理函数,捕捉信号 SIGCHILD 信号,然后再在信号处理函数里调用 waitpid 函数来完成子进程资源的回收。SIGCHLD 是子进程退出或者中断时由内核向父进程发出的信号,默认这个信号是忽略的。所以,如果想在子进程退出时能回收它,需要像下面一样,注册一个 SIGCHOLD 函数。

  1. signal(SIGCHLD, sigchld_handler);

阻塞 I/O 的进程模型

image.png

假设父进程之后又接收了新的连接请求,从 accept 调用返回新的已连接套接字,父进程又派生出另一个子进程,这个子进程用第二个已连接套接字为客户端服务。

image.png

程序讲解

  1. #include "lib/common.h"
  2. #define MAX_LINE 4096
  3. char rot13_char(char c) {
  4. if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
  5. return c + 13;
  6. else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
  7. return c - 13;
  8. else
  9. return c;
  10. }
  11. void child_run(int fd) {
  12. char outbuf[MAX_LINE + 1];
  13. size_t outbuf_used = 0;
  14. ssize_t result;
  15. while (1) {
  16. char ch;
  17. result = recv(fd, &ch, 1, 0);
  18. if (result == 0) {
  19. break;
  20. } else if (result == -1) {
  21. perror("read");
  22. break;
  23. }
  24. if (outbuf_used < sizeof(outbuf)) {
  25. outbuf[outbuf_used++] = rot13_char(ch);
  26. }
  27. if (ch == '\n') {
  28. send(fd, outbuf, outbuf_used, 0);
  29. outbuf_used = 0;
  30. continue;
  31. }
  32. }
  33. }
  34. void sigchld_handler(int sig) {
  35. while (waitpid(-1, 0, WNOHANG) > 0);
  36. return;
  37. }
  38. int main(int c, char **v) {
  39. int listener_fd = tcp_server_listen(SERV_PORT);
  40. signal(SIGCHLD, sigchld_handler);
  41. while (1) {
  42. struct sockaddr_storage ss;
  43. socklen_t slen = sizeof(ss);
  44. int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
  45. if (fd < 0) {
  46. error(1, errno, "accept failed");
  47. exit(1);
  48. }
  49. if (fork() == 0) {
  50. close(listener_fd); // 子进程关闭监听套接字
  51. child_run(fd); // 子进程读写连接套接字
  52. exit(0);
  53. } else {
  54. close(fd); // 父进程关闭连接套接字
  55. }
  56. }
  57. return 0;
  58. }

WNOHANG:

用来告诉内核,即使还有未终止的子进程也不要阻塞在 waitpid 上。注意这里不可以使用 wait,因为 wait 函数在有未终止子进程的情况下,没有办法不阻塞。

从父进程派生出的子进程,同时也会复制一份描述字,也就是说,连接套接字和监听套接字的引用计数都会被加 1,而调用 close 函数则会对引用计数进行减 1 操作,这样在套接字引用计数到 0 时,才可以将套接字资源回收。所以,这里的 close 函数非常重要,缺少了它们,就会引起服务器端资源的泄露。

close 不关心的 fd 来避免资源泄漏.

实验

启动该服务器,监听在对应的端口 43211 上。

  1. ./fork01

再启动两个 telnet 客户端,连接到 43211 端口,每次通过标准输入和服务器端传输一些数据,我们看到,服务器和客户端的交互正常。

  1. $telnet 127.0.0.1 43211
  2. Trying 127.0.0.1...
  3. Connected to localhost.
  4. Escape character is '^]'.
  5. afasfa
  6. nsnfsn
  7. ]
  8. telnet> quit
  9. Connection closed.
  1. $telnet 127.0.0.1 43211
  2. Trying 127.0.0.1...
  3. Connected to localhost.
  4. Escape character is '^]'.
  5. agasgasg
  6. ntnftnft
  7. ]
  8. telnet> quit
  9. Connection closed.

客户端退出,服务器端也在正常工作,此时如果再通过 telnet 建立新的连接,客户端和服务器端的数据传输也会正常进行。

总结

在实现这样的程序时,我们需要注意两点:

  • 要注意对套接字的关闭梳理;
  • 要注意对子进程进行回收,避免产生不必要的僵尸进程。