使用 Clone() 系统调用
底层 C 语言编程一直被认为是 “大师” 的领域。在我的高年级操作系统课程中,教授认为这种想法应该从我们的脑海中消除。为此,我们有很多编程作业,将我们推向了全新的领域。其中一项作业是研究和使用 Linux 提供的 clone() 系统调用。找到关于 Linux 提供的底层系统调用的信息并非易事。希望本文能够为其他有抱负的技术高手节省一些时间和麻烦。
UNIX 世界中的 C 程序员长期以来一直使用 fork() 系统调用。这个调用是多进程处理的基础,因为这是创建新进程的方式。实际上,对于大多数系统来说,这是创建新进程的唯一方法。Linux 引入了一个新的调用 clone(),它允许更高级别的进程上下文创建。
进程主要有两种类型:重量级进程和轻量级进程。重量级进程是您通常在运行程序时想到的那种进程。它们包含自己的地址空间和程序执行上下文(参见 图 1)。轻量级进程通常被认为是线程。这些轻量级进程共享父进程的地址空间,并且仅包含上下文元素的子集(参见 图 2)。
在查看 clone() 系统调用之前,我将首先快速回顾一下 fork。这个系统调用,以及 exec 系统调用类(即,execl(), execlp(), execv(), execvp())实际上是您的 shell 执行命令行上给定程序的方式。清单 1 给出了使用 fork() 的一个简单示例。父进程打开一个文件,给一个变量赋值,并 fork 一个子进程。然后,子进程尝试更改变量并关闭文件。退出后,父进程唤醒并检查变量是什么以及文件是否打开。
正如您所看到的,子进程位于一个全新的地址空间中。在这个地址空间内,子进程接收父进程在 fork 时拥有的所有内容的副本。(这在 Linux 中并不完全正确。只有当子进程尝试更改内存页中的内容时,它才会接收该内存页的新副本。在此之前,它只是使用父进程的副本。这称为写时复制内存管理。)对此副本的更改不会影响父进程。当子进程尝试更改变量的值时,父进程看不到此更改。
您将如何使用 clone() 系统调用编写类似的程序?清单 2 给出了一个非常基本的程序,它看起来复制了使用 fork() 的程序的行为。在这个阶段最明显的区别是 clone() 系统调用将所有内存管理都留给了程序员。首先要做的是使用 malloc() 为新子线程的堆栈分配空间。完成此操作后,发出 clone() 调用以开始新的执行上下文,从给定函数的开头开始。此系统调用的通用签名是
int __clone(int (*fn) (void *arg), void *child_stack, int flags, void *args)
正如您所看到的,有几个参数需要准备。第一个参数是指向返回整数的函数的指针。在 C 语言中,函数名是指向函数的指针,因此这很容易处理。第二个参数是指向为子进程设置的堆栈空间的指针。您将必须决定子进程将执行的操作需要多少空间。最后一个参数是指向将传递给子进程将执行的函数的参数的指针。在这种情况下,这是一个空指针,因为我们没有向该函数传递任何参数。
第三个参数值得特别关注。您可以在此处指定标志,这些标志将定义子进程和父进程之间共享多少进程上下文。与大多数 C 函数一样,多个标志只是简单地一起读取。clone 调用可用的标志有
CLONE_VM - 共享内存CLONE_FILES - 共享文件描述符CLONE_SIGHAND - 共享信号处理程序CLONE_VFORK - 允许子进程在退出时向父进程发出信号CLONE_PID - 共享 PID(尚未实现)CLONE_FS - 共享文件系统
您可以看到我们已经使用 CLONE_VM 和 CLONE_FILES 设置了我们的示例程序。查看程序的输出,您可以看到子进程将与其父进程共享地址空间和文件描述符表。一个进程所做的任何更改现在对另一个进程都是可见的。如果需要 fork() 的确切行为,则只需更改设置的标志即可。这样,您可以选择程序将使用的确切共享级别。
在使用此系统调用时,我应该提到一些注意事项。一些函数,例如 printf(),在与 clone() 一起使用时不是线程安全的。这些函数假定在使用线程库(如 linuxthreads)时发生的包装类型。如果您正在试验程序并遇到 段错误 或 核心转储,这将是开始进行故障排除的可能位置。可能的解决方案是使用较低级别的系统调用,例如 write() 而不是 printf()。此外,您应该使用 -static 开关编译程序。
我感谢 Red Hat 的好心人指出我在摸索本文时遇到的一些陷阱。本文中的任何错误完全是作者的过错,我将感谢您认为应该进行的任何更正。
电子邮件: ljeditors@ssc.com