CPU 亲和性
Linux 中将一个或多个进程绑定到一个或多个处理器的能力,称为 CPU 亲和性,是一项长期请求的功能。其想法是说“始终在处理器一上运行此进程”或“在除处理器零之外的所有处理器上运行这些进程”。然后调度器服从该命令,并且该进程仅在允许的处理器上运行。
其他操作系统(例如 Windows NT)长期以来一直提供一个系统调用来设置进程的 CPU 亲和性。因此,对 Linux 中此类系统调用的需求一直很高。最后,2.5 内核引入了一组系统调用,用于设置和检索进程的 CPU 亲和性。
在本文中,我将探讨在 Linux 中引入 CPU 亲和性接口的原因。然后,我将介绍如何在程序中使用该接口。如果您不是程序员,或者您有一个无法修改的现有程序,我将介绍一个简单的实用程序,用于使用给定进程的 PID 更改其亲和性。最后,我们将研究系统调用的实际实现。
CPU 亲和性有两种类型。第一种是软亲和性,也称为自然亲和性,是调度器尽量将进程保留在同一 CPU 上的倾向。这仅仅是一种尝试;如果不可行,进程肯定会迁移到另一个处理器。2.5 中的新 O(1) 调度器表现出出色的自然亲和性。然而,与之相反的是 2.4 调度器,它的 CPU 亲和性很差。这种行为会导致乒乓效应。每次调度和重新调度进程时,调度器都会在多个处理器之间来回弹跳进程。表 1 是自然亲和性差的示例;表 2 显示了良好的自然亲和性。
另一方面,硬亲和性是 CPU 亲和性系统调用提供的。这是一种要求,进程必须遵守指定的硬亲和性。例如,如果一个处理器绑定到 CPU 零,那么它只能在 CPU 零上运行。
在我们介绍新的系统调用之前,让我们讨论一下为什么有人需要这样的功能。CPU 亲和性的第一个好处是优化缓存性能。我说 O(1) 调度器努力将任务保留在同一处理器上,它也确实做到了。但在某些对性能至关重要的场景中——可能是一个大型数据库或一个高度线程化的 Java 服务器——强制执行亲和性作为硬性要求是有意义的。多处理计算机花费大量精力来保持处理器缓存的有效性。数据一次只能保存在一个处理器的缓存中。否则,处理器的缓存可能会失去同步,从而导致一个问题:谁拥有最新的主内存副本数据?因此,每当处理器向其本地缓存添加一行数据时,系统中缓存该数据的所有其他处理器也必须使该数据无效。这种无效化是昂贵且令人不快的。但真正的问题出现在进程在处理器之间来回弹跳时:它们不断导致缓存无效,并且当它们需要数据时,数据永远不在缓存中。因此,缓存未命中率变得非常高。CPU 亲和性可以防止这种情况并提高缓存性能。
CPU 亲和性的第二个好处是第一个好处的推论。如果多个线程正在访问相同的数据,则将它们全部绑定到同一处理器可能是有意义的。这样做可以保证线程不会争用数据并导致缓存未命中。这确实会削弱从 SMP 上的多线程获得的性能。但是,如果线程本质上是串行化的,那么提高的缓存命中率可能是值得的。
第三个也是最后一个好处是在实时或对时间敏感的应用程序中发现的。在这种方法中,所有系统进程都绑定到系统上一部分处理器。然后,专门的应用程序绑定到剩余的处理器。通常,在双处理器系统中,专门的应用程序绑定到一个处理器,而所有其他进程都绑定到另一个处理器。这确保了专门的应用程序获得处理器的全部关注。
系统调用是新的,因此并非在所有系统中都可用。您至少需要 kernel 2.5.8-pre3 和 glibc 2.3.1;glibc 2.3.0 支持系统调用,但它有一个错误。系统调用尚未在 2.4 中,但在 www.kernel.org/pub/linux/kernel/people/rml/cpu-affinity 上提供了补丁。
许多发行版内核也支持新的系统调用。特别是,Red Hat 9 在其内核和 glibc 中都支持新的调用。实时解决方案,例如 MontaVista Linux,也完全支持新的接口。
在大多数系统(包括 Linux)上,用于设置 CPU 亲和性的接口都使用位掩码。位掩码是一系列 n 位,其中每个位分别对应于某些其他对象的状态。例如,CPU 亲和性(在 32 位机器上)由 32 位位掩码表示。每个位表示给定任务是否绑定到相应的处理器。从右到左计数位,位 0 到位 31,因此,处理器零到处理器 31。例如
11111111111111111111111111111111 = 4,294,967,295
是所有进程的默认 CPU 亲和性掩码。因为所有位都已设置,所以该进程可以在任何处理器上运行。相反
00000000000000000000000000000001 = 1则更具限制性。仅设置了位 0,因此该进程只能在处理器零上运行。也就是说,此亲和性掩码将进程绑定到处理器零。
明白了吗?接下来的两个掩码等于十进制的多少?将它们用作进程的亲和性掩码的结果是什么?
10000000000000000000000000000000 00000000000000000000000000000011
第一个等于 2,147,483,648,并且由于设置了位 31,因此将进程绑定到处理器编号 31。第二个等于 3,它将相关进程绑定到处理器零和处理器一。
Linux CPU 亲和性接口使用如上所示的位掩码。不幸的是,C 不支持二进制常量,因此您始终必须使用十进制或十六进制等效项。对于设置位 31 的非常大的十进制常量,您可能会收到编译器警告,但它们可以正常工作。
有了正确的内核和 glibc,使用系统调用非常容易
#define _GNU_SOURCE #include <sched.h> long sched_setaffinity(pid_t pid, unsigned int len, unsigned long *user_mask_ptr); long sched_getaffinity(pid_t pid, unsigned int len, unsigned long *user_mask_ptr);
第一个系统调用用于设置进程的亲和性,第二个系统调用用于检索它。
在任一系统调用中,PID 参数都是您要设置或检索其掩码的进程的 PID。如果 PID 设置为零,则使用当前任务的 PID。
第二个参数是 CPU 亲和性位掩码的长度(以字节为单位),当前为四个字节(32 位)。包含此数字是为了防止内核更改 CPU 亲和性掩码的大小并允许系统调用向前兼容任何更改;毕竟,破坏 syscalls 不是好的形式。第三个参数是指向位掩码本身的指针。
让我们看一下检索任务的 CPU 亲和性
unsigned long mask; unsigned int len = sizeof(mask); if (sched_getaffinity(0, len, &mask) < 0) { perror("sched_getaffinity"); return -1; } printf("my affinity mask is: %08lx\n", mask);
为方便起见,返回的掩码与系统中所有处理器的掩码进行二进制与运算。因此,系统中未上线的处理器具有相应未设置的位。例如,单处理器系统始终为此调用返回 1(设置了位 0,而没有其他位)。
设置掩码同样容易
unsigned long mask = 7; /* processors 0, 1, and 2 */ unsigned int len = sizeof(mask); if (sched_setaffinity(0, len, &mask) < 0) { perror("sched_setaffinity"); }
此示例将当前进程绑定到系统中的前三个处理器。
然后,您可以调用 sched_getaffinity() 以确保更改生效。如果您只有两个处理器,则上述设置的 sched_getaffinity() 返回什么?如果您只有一个处理器呢?除非位掩码中至少存在一个处理器,否则系统调用会失败。使用零掩码总是会失败。同样,如果您没有处理器七,则绑定到处理器七也会失败。
可以检索系统上任何进程的 CPU 亲和性掩码。但是,您只能设置您拥有的进程的亲和性。当然,root 用户可以设置任何进程的亲和性。
如果您不是程序员,或者由于任何原因无法修改源代码,您仍然可以绑定进程。列表 1 是一个简单的命令行实用程序的源代码,用于设置任何进程的 CPU 亲和性掩码(给定其 PID)。正如我们上面讨论的,您必须拥有该进程或成为 root 用户才能执行此操作。
用法很简单;一旦您了解了 CPU 掩码的十进制等效项,您就需要
usage: bind pid cpu_mask
例如,假设我们有一台双计算机,并且想要将我们的 Quake 进程(PID 为 1600)绑定到处理器二。我们将输入以下内容
bind 1600 2
在前面的示例中,我们将 Quake 绑定到我们系统中两个处理器之一。为了确保一流的帧速率,我们需要将系统上的所有其他进程绑定到另一个处理器。您可以手动或编写巧妙的脚本来执行此操作,但这两种方法效率都不高。相反,利用 CPU 亲和性在 fork() 中继承的事实。进程的所有子进程都接收与其父进程相同的 CPU 亲和性掩码。
然后,我们需要做的就是让 init 将其自身绑定到一个处理器。所有其他进程,由于 init 是进程树的根,因此是所有进程的超级父进程,因此也绑定到一个处理器。
执行此类绑定的最干净方法是将此功能破解到 init 本身中,并使用内核命令行传入所需的 CPU 亲和性掩码。但是,我们可以使用更简单的解决方案来实现我们的目标,而无需修改和重新编译 init。相反,我们可以编辑系统启动脚本。在大多数系统上,这是 /etc/rc.d/rc.sysinit 或 /etc/rc.sysinit,init 运行的第一个脚本。将示例 bind 程序放在 /bin 中,并将以下行添加到 rc.sysinit 的开头
/bin/bind 1 1 /bin/bind $$ 1
这些行将 init(其 PID 为 1)和当前进程绑定到处理器零。所有未来的进程都将从这两个进程之一 fork,因此继承 CPU 亲和性掩码。然后,您可以将您的进程(无论是实时核控制系统还是 Quake)绑定到处理器一。除我们的特殊进程(和任何子进程)外,所有进程都将在处理器零上运行,而特殊进程(和任何子进程)将在处理器一上运行。这确保了整个处理器可用于我们的特殊进程。
早在 Linus 合并 CPU 亲和性系统调用之前,内核就支持并尊重 CPU 亲和性掩码。用户空间没有接口可以设置掩码。
每个进程的掩码都以无符号长整型 cpus_allowed 形式存储在其 task_struct 中。task_struct 结构称为进程描述符。它存储有关进程的所有信息。CPU 亲和性接口仅读取和写入 cpus_allowed。
每当内核尝试将进程从一个处理器迁移到另一个处理器时,它首先检查目标处理器的位是否在 cpus_allowed 中设置。如果未设置该位,则内核不会迁移该进程。此外,每当 CPU 亲和性掩码更改时,如果进程不再位于允许的处理器上,则会将其迁移到允许的处理器。这确保了进程在合法处理器上启动,并且只能迁移到合法处理器。当然,如果它仅绑定到一个处理器,则它不会迁移到任何地方。
在 2.5 中引入并在其他地方向后移植的 CPU 亲和性接口提供了一种简单而强大的机制,用于控制哪些进程被调度到哪些处理器上。拥有多个处理器的用户可能会发现系统调用在从系统中榨取更多性能或确保处理器时间可用于即使是最苛刻的实时任务时非常有用。当然,只有一台处理器的用户不必感到被排除在外。他们也可以使用系统调用,但它们不会太有用。

Robert Love 是一位内核黑客,参与了各种项目,包括抢占式内核和调度器。他是佛罗里达大学的数学和计算机科学专业的学生,也是 MontaVista Software 的内核工程师。他喜欢摄影。