Unix/Linux fork函数的问题 并不适合共享

起因

一位朋友问我一个关于 socket 通信的相关问题,其需要解决的问题如下:

需要存在一个服务器进程,服务器进程会进行监听,负责建立与客户端的 socket 连接。 同时可以存在多个客户端进程,客户端进程之间可以进行通信,不过客户端之间并不会建立 socket 连接。 通信是通过将信息发送给服务端进程,服务端进程查找与目标客户端建立的 socket 标志来进行信息的发送。

为了解决上面的问题,这位朋友在服务端进程中自定义了一个链表用于保存与服务器建立的 socket 连接的客户端名称以及对应的 socket 标识符,当成功建立连接的时候,则在链表中插入一个元素。 当一个客户端发送退出指令的时候,则将对应的 socket 关闭并将信息从链表中删除。 每次有客户端需要发送数据的时候,则需要遍历链表,然后找到目标 socket 进行通信。

同时为了实现通信的要求,则必须要使用多进程或者多线程, 这位朋友在服务端的代码中每次监听成均通过 fork 来建立一个子进程来处理与某个客户端之间的通信。 同时这位朋友在代码中申请了一块共享内存,用于保存链表头。

问题

上述的描述似乎是很合理的,但是他在运行后出现了一个奇怪的现象: 当服务端进程启动后,同时启动两个客户端进程与服务端进程建立 socket 连接, 当client1client2 发送消息的时候,代码出现了死循环。

哪里出现了死循环呢?根据朋友的调试,发现死循环出现在在链表中查询目的 socket 的过程。 他发现,链表变成了一个环,同时这个链表上只有一个结点, 也就是只有自己的结点信息,导致一直在链表上进行循环。

原因

为什么会出现死循环呢?出现问题的关键就在于他使用 fork 这个函数。 为了更加简单的进行描述我们先进性一个简单的实验:

编写一个程序,同时创建一个变量,对其进行赋值,然后调用 fork 查看是否父子进程均能获取变量的正确值,同时尝试在子进程中对变量的值进行修改, 父进程等待子进程完成后查看变量的值,看一下值是否成功被修改。

注:上面的问题实际上来自于 《操作系统导论》,非常好的一本书,推荐大家阅读。

可以很容易的写出上面实验的代码:

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

int main() {
    int x = 137;
    int ret = fork();
    if (ret < 0) {
        printf("One error occurs when fork(), the ret: %d.\n", ret);
        exit(ret);
    } else if (ret == 0) {
        printf("Child process (%d), the value of x is: %d.\n", getpid(), x);
        x *= 2;
    } else {
        printf("Parent process (%d), the value of x is: %d.\n", getpid(), x);
        ret = wait(NULL);
        printf("After child process write the variable, parent process (%d), the value of x is: %d.\n", getpid(), x);
    }
    return 0;
}

运行结果如下:

Parent process (110), the value of x is: 137. Child process (111), the value of x is: 137. After child process write the variable, parent process (110), the value of x is: 137.

可以看到子进程对于变量的修改父进程并不知道。 这就是问题所在:通过 fork 创建的进程会复制父进程的所有信息(注意是复制而不是共享), 一个通过 fork 创建的进程对变量的修改对于另一个进程不可见。

上面说的复制,指的是将进程完整的拷贝一遍,放到另一块内存区域中进行执行,这会有一个问题也就是, 初始两个进程所有的值都是一样的,同时虚拟地址也是一样的 (这一点体现在如果两个进程紧接着进行堆内存的申请, 那么会在会获得同样的地址,这一点也很好理解,由于是复制,那么自然堆的状态也是一样的, 获取到同样的虚拟空间也是合理的,但是实际的物理空间却不相同)。

有了上面的基础,我们再来看思考一下为什么会出现死循环?

问题的关键在于同时使用了共享内存和 fork,使用的共享内存能够保证在子进程中进行修改, 其他进程能够看见,我们来实际进行模拟一下:

  • 首先,服务端进程进行启动,申请共享内存,地址我们记为 0, 用 headp_address 变量保存该地址(即 headp_address=0)由于此时的链表是空的, 表头初始为空,即执行 *headp_address=NULL
  • client1 启动,此时向链表中插入一个新的结点,新结点通过 malloc 进行申请内存, 假设此时申请的地址为 4,那么我们将该节点插入到链表头即执行 *headp_address=new_node, 此时new_node=4
  • client2 启动,此时同样进行新结点内存的申请(通过 malloc),根据之前的结论, 此时会申请到相同的内存即还是会申请到 4(这里是虚拟内存),但是由于对于共享内存上的修改, 两个进程能够发现,此时发现链表中已经有一个元素了,于是使用头插法将当前的结点插入到链表的开头, 也就是执行:
new_node->next = *headp_address;
*headp_address=new_node;

根据前面的信息我们知道 *headp_address=4,在执行上面的语句之后, 我们发现 *headp_address=4, *headp_address->next=4; 也就是说这个时候链表变成了一个环,这也就是为什么后面通过某个客户端进行信息的发送的时候会出现死循环。

解决

这里提供几种解决方案:

  • 使用 pthread 替换fork()pthread 可以实现子线程进行修改各个进程能够看到, 也就是说其更偏向与实现共享的功能,而 fork() 则是偏向于复制。 (实际上这两个函数都调用了系统调用 clone, 但是 pthread_create 在调用的时候增加了 CLONE_VM 标志,使得能够实现共享)。
  • 如果一定要使用 fork() 则应该通过共享内存实现共享,也就是链表的申请与释放也应该在共享内存上进行, 这个时候则应该使用静态链表进行管理(也就是固定空间大小的链表)。



    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • 文学摘抄
  • Go 学习笔记
  • 客制化 Neovim
  • 鞋带公式
  • 背包问题