本文档简要概述了如何使用 SMP Linux 系统进行并行处理。关于 SMP Linux 的最新信息可能通过 SMP Linux 项目邮件列表获得;发送电子邮件至 majordomo@vger.rutgers.edu,内容为 subscribe linux-smp
以加入列表。
SMP Linux 真的有效吗?1996 年 6 月,我购买了一台全新的(好吧,非名牌的 ;-) 双处理器 100MHz 奔腾系统。这套完全组装的系统,包括两个处理器、华硕主板、256K 缓存、32M 内存、1.6G 硬盘、6 倍速 CD-ROM、Stealth 64 和 15 英寸 Acer 显示器,总共花费 1,800 美元。这仅仅比同等的单处理器系统贵几百美元。让 SMP Linux 运行起来非常简单,只需安装“标准”单处理器 Linux,重新编译内核,并在 makefile 中取消注释 SMP=1
行(尽管我发现将 SMP
设置为 1
有点讽刺 ;-),并告知 lilo
关于新内核的信息。该系统性能良好,并且足够稳定,自那时以来一直作为我的主要工作站。总之,SMP Linux 确实有效。
下一个问题是在 SMP Linux 下编写和执行共享内存并行程序有多少高级支持可用。到 1996 年初,还不多。情况已经改变。例如,现在有一个非常完整的 POSIX 线程库。
尽管性能可能低于原生共享内存机制,但 SMP Linux 系统也可以使用大多数最初为使用套接字通信的工作站集群开发的并行处理软件。套接字(参见第 3.3 节)在 SMP Linux 系统内工作,甚至对于联网为集群的多个 SMP 也是如此。然而,套接字对于 SMP 来说意味着很多不必要的开销。大部分开销都在内核或中断处理程序中;这使问题更加严重,因为 SMP Linux 通常只允许一个处理器一次进入内核,并且中断控制器被设置为只有引导处理器才能处理中断。尽管如此,典型的 SMP 通信硬件比大多数集群网络好得多,以至于集群软件通常在 SMP 上比在其设计的集群上运行得更好。
本节的其余部分讨论了 SMP 硬件,回顾了用于在并行程序的进程之间共享内存的基本 Linux 机制,对原子性、易失性、锁和缓存行进行了一些观察,最后给出了一些指向其他共享内存并行处理资源的指针。
尽管 SMP 系统已经存在多年,但直到最近,每台这样的机器往往以足够不同的方式实现基本功能,以至于操作系统支持不具有可移植性。改变这种情况的是英特尔的多处理器规范,通常简称为 MPS。MPS 1.4 规范目前以 PDF 文件形式提供,网址为 http://www.intel.com/design/pro/datashts/242016.htm,并且在 http://support.intel.com/oem_developer/ial/support/9300.HTM 上有 MPS 1.1 的简要概述,但请注意,英特尔经常重新安排其 WWW 站点。 广泛的 供应商 正在构建符合 MPS 的系统,支持最多四个处理器,但 MPS 理论上允许更多处理器。
SMP Linux 支持的唯一非 MPS、非 IA32 系统是 Sun4m 多处理器 SPARC 机器。 SMP Linux 支持大多数符合 Intel MPS 版本 1.1 或 1.4 的机器,最多可支持 16 个 486DX、奔腾、奔腾 MMX、奔腾 Pro 或奔腾 II 处理器。 不受支持的 IA32 处理器包括 Intel 386、Intel 486SX/SLC 处理器(缺少浮点硬件会干扰 SMP 机制)以及 AMD 和 Cyrix 处理器(它们需要不同的 SMP 支持芯片,但在撰写本文时似乎不可用)。
重要的是要理解,符合 MPS 的系统的性能可能差异很大。正如预期的那样,性能差异的一个原因是处理器速度:更快的时钟速度往往会产生更快的系统,而奔腾 Pro 处理器比奔腾处理器更快。但是,MPS 实际上并未指定硬件如何实现共享内存,而只是指定了从软件角度来看该实现必须如何工作;这意味着性能还取决于共享内存实现如何与 SMP Linux 和您的特定程序的特性交互。
符合 MPS 的系统之间的主要区别在于它们如何实现对物理共享内存的访问。
一些 MPS 奔腾系统以及所有 MPS 奔腾 Pro 和奔腾 II 系统都具有独立的 L2 缓存。(L2 缓存封装在奔腾 Pro 或奔腾 II 模块内。)独立的 L2 缓存通常被认为可以最大限度地提高计算性能,但在 Linux 下情况并非如此明显。 主要的复杂之处在于,当前的 SMP Linux 调度程序不尝试将每个进程保留在同一处理器上,这称为 处理器亲和性。 这可能很快会改变;最近在 SMP Linux 开发社区中就此进行了一些讨论,标题为“处理器绑定”。 在没有处理器亲和性的情况下,当进程在与上次执行它的处理器不同的处理器上获得时间片时,拥有单独的 L2 缓存可能会引入显著的开销。
许多相对便宜的系统组织为两个奔腾处理器共享单个 L2 缓存。坏消息是,这会导致缓存争用,严重降低运行多个独立顺序程序时的性能。好消息是,许多并行程序实际上可能会从共享缓存中受益,因为如果两个处理器都想要访问共享内存中的同一行,则只有一个处理器需要将其提取到缓存中,并且避免了总线争用。 缺少处理器亲和性对于共享 L2 缓存造成的损害也较小。 因此,对于并行程序,共享 L2 缓存是否像人们预期的那样有害,实际上并不清楚。
我们在双奔腾共享 256K 缓存系统上的经验表明,性能范围很广,具体取决于所需的内核活动级别。 最坏的情况下,我们仅看到约 1.2 倍的加速。 但是,我们也看到了高达 2.1 倍的加速,这表明计算密集型 SPMD 风格的代码确实从“共享提取”效应中获益。
首先要说的是,大多数现代系统将处理器连接到一个或多个 PCI 总线,这些总线又“桥接”到一个或多个 ISA/EISA 总线。 这些桥接增加了延迟,并且 EISA 和 ISA 通常提供比 PCI 更低的带宽(ISA 最低),因此硬盘驱动器、视频卡和其他高性能设备通常应通过 PCI 总线接口连接。
尽管 MPS 系统即使只有一个 PCI 总线也可以为许多计算密集型并行程序实现良好的加速,但 I/O 操作的性能不会比单处理器性能更好……并且由于处理器的总线争用,可能会稍差一些。 因此,如果您希望加速 I/O,请确保您获得具有多个独立 PCI 总线和 I/O 控制器(例如,多个 SCSI 链)的 MPS 系统。 您需要小心确保 SMP Linux 支持您获得的内容。 还要记住,当前的 SMP Linux 本质上只允许一个处理器在任何时候进入内核,因此您应该仔细选择您的 I/O 控制器,以选择那些可以最大限度地减少每个 I/O 操作所需的内核时间的控制器。 为了获得真正的高性能,您甚至可以考虑直接从用户进程执行原始设备 I/O,而无需系统调用……这不一定像听起来那么难,并且不必损害安全性(有关基本技术的描述,请参见第 3.3 节)。
重要的是要注意,在过去几年中,总线速度和处理器时钟频率之间的关系变得非常模糊。 尽管大多数系统现在都使用相同的 PCI 时钟频率,但将更快的处理器时钟与更慢的总线时钟配对的情况并不少见。 典型的例子是奔腾 133 通常使用比奔腾 150 更快的总线,在各种基准测试中表现出奇怪的性能。 这些效应在 SMP 系统中被放大;拥有更快的总线时钟甚至更为重要。
内存交错实际上与 MPS 没有任何关系,但您经常会在 MPS 系统中看到它被提及,因为这些系统通常对内存带宽的要求更高。 基本上,双向或四向交错组织 RAM,以便使用多个 RAM 组而不是仅使用一个 RAM 组来完成块访问。 这提供了更高的内存访问带宽,特别是对于缓存行加载和存储。
然而,关于这一点的情况有点混乱,因为 EDO DRAM 和各种其他内存技术往往会改进类似的运算类型。 http://www.pcguide.com/ref/ram/tech.htm 中给出了 DRAM 技术的出色概述。
那么,例如,拥有 2 路交错 EDO DRAM 还是非交错 SDRAM 更好? 这是一个非常好的问题,没有简单的答案,因为交错和异构 DRAM 技术往往都很昂贵。 在更普通的内存配置上投入相同的美元通常会为您提供更大的主内存。 即使是最慢的 DRAM 仍然比使用基于磁盘的虚拟内存快得多……
好的,您已经决定在 SMP 上进行并行处理是一件很棒的事情……您如何开始? 好吧,第一步是了解一点关于共享内存通信的真正工作原理。
听起来您只是让一个处理器将一个值存储到内存中,另一个处理器加载它;不幸的是,事情并没有那么简单。 例如,进程和处理器之间的关系非常模糊;但是,如果我们活动的进程数量不超过处理器的数量,则这些术语大致可以互换。 本节的其余部分简要总结了如果您不了解它们可能会导致严重问题的关键问题:用于确定共享内容的两种不同模型、原子性问题、易失性的概念、硬件锁定指令、缓存行效应和 Linux 调度程序问题。
共享内存编程通常使用两种根本不同的模型:共享一切 和 共享某些内容。 这两种模型都允许处理器通过从/向共享内存加载和存储来进行通信;区别在于,共享一切将所有数据结构都放在共享内存中,而共享某些内容则要求用户显式指示哪些数据结构可能被共享,哪些数据结构是单个处理器的 私有 数据结构。
您应该使用哪种共享内存模型? 这主要是一个信仰问题。 很多人喜欢共享一切模型,因为他们实际上不需要在声明时确定哪些数据结构应该共享……您只需在可能冲突的共享对象访问周围放置锁,以确保任何时刻只有一个进程(或处理器)可以访问。 然而,再说一次,这真的没有那么简单……所以很多人更喜欢共享某些内容的相对安全性。
共享一切的好处在于,您可以轻松地获取现有的顺序程序并逐步将其转换为共享一切的并行程序。 您不必首先确定哪些数据需要被其他处理器访问。
简而言之,共享一切的主要问题是,一个处理器采取的任何操作都可能影响其他处理器。 这个问题以两种方式出现
errno
的变量中返回错误代码;如果两个共享一切的进程执行各种调用,它们会相互干扰,因为它们共享相同的 errno
。 尽管现在有一个库版本修复了 errno
问题,但在大多数库中仍然存在类似的问题。 例如,除非采取特殊预防措施,否则如果从多个共享一切的进程进行调用,则 X 库将无法工作。core
文件,让您了解发生了什么。 在共享一切并行处理中,极有可能的是,错误的访问将导致非故障进程的消亡,从而几乎不可能定位和纠正错误。当使用共享某些内容时,这两种类型的问题都不常见,因为只有显式标记的数据结构才会被共享。 同样很明显,共享一切仅在所有处理器都执行完全相同的内存映像时才有效;您不能跨多个不同的代码映像使用共享一切(即,只能使用 SPMD,而不能使用通用 MIMD)。
最常见的共享一切编程支持类型是 线程库。 线程 本质上是“轻量级”进程,它们可能不会以与常规 UNIX 进程相同的方式进行调度,最重要的是,它们共享对单个内存映射的访问。 POSIX Pthreads 包一直是许多移植工作的重点;最大的问题是这些端口中是否有任何一个实际上在 SMP Linux 下并行运行程序的线程(理想情况下,每个线程都有一个处理器)。 POSIX API 没有要求这样做,并且像 http://www.aa.net/~mtp/PCthreads.html 这样的版本显然没有实现并行线程执行 - 程序的所有线程都保留在单个 Linux 进程中。
第一个支持 SMP Linux 并行性的线程库是现在已有些过时的 bb_threads 库,ftp://caliban.physics.utoronto.ca/pub/linux/,这是一个非常小的库,它使用 Linux clone()
调用来派生新的、独立调度的 Linux 进程,所有进程都共享单个地址空间。 SMP Linux 机器可以并行运行多个这些“线程”,因为每个“线程”都是一个完整的 Linux 进程;权衡是您无法获得某些线程库在其他操作系统下提供的相同的“轻量级”调度控制。 该库使用了一点 C 包装的汇编代码,将新的内存块安装为每个线程的堆栈,并为锁数组(互斥对象)提供原子访问函数。 文档包括一个 README
和一个简短的示例程序。
最近,已经开发了使用 clone()
的 POSIX 线程版本。 这个库 LinuxThreads 显然是 SMP Linux 下使用的首选共享一切库。 POSIX 线程文档齐全,并且 LinuxThreads README 和 LinuxThreads FAQ 都做得非常好。 现在主要的问题仅仅是 POSIX 线程有很多细节需要正确处理,而 LinuxThreads 仍然是一个正在进行的工作。 还有一个问题是 POSIX 线程标准已经通过标准化过程发展而来,因此您需要小心,不要为过时的早期版本的标准编程。
共享某些内容实际上是“仅共享需要共享的内容”。 只要小心确保共享对象在每个处理器的内存映射中的相同位置分配,这种方法就可以用于通用 MIMD(而不仅仅是 SPMD)。 更重要的是,共享某些内容可以更轻松地预测和调整性能、调试代码等。 唯一的问题是
目前,有两种非常相似的机制允许 Linux 进程组拥有独立的内存空间,所有进程仅共享相对较小的内存段。 假设您在配置 Linux 系统时没有愚蠢地排除“System V IPC”,Linux 支持一种非常可移植的机制,通常称为“System V 共享内存”。 另一种选择是内存映射工具,其实现因不同的 UNIX 系统而异:mmap()
系统调用。 您可以并且应该从手册页中了解这些调用……但在第 2.5 节和第 2.6 节中给出了每个调用的简要概述,以帮助您入门。
无论您使用上述两种模型中的哪一种,结果都几乎相同:您获得一个指向块读/写内存的指针,该内存可由并行程序中的所有进程访问。 这是否意味着我可以让我的并行程序像访问普通本地内存中的共享内存对象一样访问它们? 好吧,不完全是……
原子性 指的是对对象的操作作为不可分割、不可中断的序列完成的概念。 不幸的是,共享内存访问并不意味着对共享内存中数据的所有操作都是原子发生的。 除非采取特殊预防措施,否则只有在单个总线事务中发生的简单加载或存储操作(即,对齐的 8 位、16 位或 32 位操作,但不是未对齐或 64 位操作)才是原子的。 更糟糕的是,像 GCC 这样的“智能”编译器通常会执行优化,从而消除确保其他处理器可以看到此处理器所做操作所需的内存操作。 幸运的是,这两个问题都可以解决……只剩下访问效率和缓存行大小之间的关系需要我们担心。
但是,在讨论这些问题之前,有必要指出,所有这些都假设每个处理器的内存引用都按照它们编码的顺序发生。 奔腾处理器是这样做的,但也指出未来的英特尔处理器可能不会这样做。 因此,对于未来的处理器,请记住,可能需要用一些指令包围一些共享内存访问,这些指令会导致所有挂起的内存访问完成,从而提供内存访问排序。 CPUID
指令显然被保留具有这种副作用。
为了防止 GCC 的优化器将共享内存对象的值缓存在寄存器中,共享内存中的所有对象都应声明为具有 volatile
属性的类型。 如果这样做,则所有仅需要一次字访问的共享对象读写都将原子发生。 例如,假设 p 是指向整数的指针,其中指针及其将指向的整数都在共享内存中; ANSI C 声明可能是
volatile int * volatile p;
在此代码中,第一个 volatile
指的是 p
最终将指向的 int
;第二个 volatile
指的是指针本身。 是的,这很烦人,但这是为使 GCC 能够执行一些非常强大的优化而付出的代价。 至少在理论上,GCC 的 -traditional
选项可能足以生成正确的代码,但会牺牲一些优化,因为前 ANSI K&R C 本质上声称所有变量都是易失性的,除非显式声明为 register
。 尽管如此,如果您的典型 GCC 编译看起来像 cc -O6 ...
,您确实会希望仅在必要时才显式地将事物标记为易失性。
有传言称,使用标记为修改所有处理器寄存器的汇编语言锁将导致 GCC 适当地刷新所有变量,从而避免与声明为 volatile
的事物相关的“低效”编译代码。 此技巧似乎适用于使用 GCC 2.7.0 版本的静态分配全局变量……但是,ANSI C 标准不要求这种行为。 更糟糕的是,其他仅进行读取访问的进程可以将值永远缓存在寄存器中,因此永远不会注意到共享内存值实际上已更改。 总之,做你想做的,但只有通过 volatile
访问的变量才能保证正常工作。
请注意,您可以通过使用强制 volatile
属性的类型转换来导致对普通变量的易失性访问。 例如,普通的 int i;
可以通过 *((volatile int *) &i)
作为易失性变量引用;因此,您可以仅在关键位置显式调用易失性的“开销”。
如果您认为 ++i;
始终可以工作以将 1 加到共享内存中的变量 i
,那么您会感到非常惊讶:即使编码为单个指令,结果的加载和存储也是单独的内存事务,并且其他处理器可以在这两个事务之间访问 i
。 例如,让两个进程都执行 ++i;
可能只会将 i
递增 1,而不是递增 2。 根据英特尔奔腾“架构和编程手册”,LOCK
前缀可用于确保以下任何指令相对于其访问的数据内存位置是原子的
BTS, BTR, BTC mem, reg/imm XCHG reg, mem XCHG mem, reg ADD, OR, ADC, SBB, AND, SUB, XOR mem, reg/imm NOT, NEG, INC, DEC mem CMPXCHG, XADD
但是,使用所有这些操作可能不是一个好主意。 例如,XADD
甚至在 386 中都不存在,因此对其进行编码可能会导致可移植性问题。
XCHG
指令始终断言一个锁,即使没有 LOCK
前缀,因此显然是从中构建更高级别原子构造(如信号量和共享队列)的首选原子操作。 当然,您无法仅通过编写 C 代码来让 GCC 生成此指令……相反,您必须使用一点内联汇编代码。 给定一个字大小的易失性对象 obj 和一个字大小的寄存器值 reg,GCC 内联汇编代码为
__asm__ __volatile__ ("xchgl %1,%0" :"=r" (reg), "=m" (obj) :"r" (reg), "m" (obj));
使用位操作进行锁定的 GCC 内联汇编代码示例在 bb_threads 库 的源代码中给出。
但是,重要的是要记住,使内存事务原子化是有成本的。 锁定操作会带来相当大的开销,并可能延迟来自其他处理器的内存活动,而普通引用可能会使用本地缓存。 当尽可能少地使用锁定操作时,性能最佳。 此外,这些 IA32 原子指令显然不可移植到其他系统。
有许多替代方法允许使用普通指令来实现各种同步,包括 互斥 - 确保在任何时刻最多只有一个处理器正在更新给定的共享对象。 大多数操作系统教科书至少讨论了这些技术中的一种。 在 Abraham Silberschatz 和 Peter B. Galvin 的《操作系统概念》第四版(ISBN 0-201-50480-4)中有一个相当好的讨论。
还有一个基本的原子性问题可能会对 SMP 性能产生巨大影响:缓存行大小。 尽管 MPS 标准要求引用是连贯的,无论使用何种缓存,但事实是,当一个处理器写入内存的特定行时,旧行的每个缓存副本都必须失效或更新。 这意味着,如果两个或多个处理器都将数据写入同一行的不同部分,则可能会导致大量的缓存和总线流量,实际上是将该行从缓存传递到缓存。 这个问题被称为 假共享。 解决方案很简单,就是尽量组织数据,以便并行访问的内容对于每个进程倾向于来自不同的缓存行。
您可能会认为,在使用共享 L2 缓存的系统时,假共享不是问题,但请记住,仍然存在单独的 L1 缓存。 缓存组织和单独级别的数量都可能有所不同,但奔腾 L1 缓存行大小为 32 字节,典型的外部缓存行大小约为 256 字节。 假设两个项目的地址(物理地址或虚拟地址)为 a 和 b,并且每个处理器的最大缓存行大小为 c,我们假设 c 是 2 的幂。 为了非常精确,如果 ((int) a) & ~(c - 1)
等于 ((int) b) & ~(c - 1)
,则两个引用都在同一缓存行中。 一个更简单的规则是,如果并行引用的共享对象至少相隔 c 个字节,则它们应映射到不同的缓存行。
尽管使用共享内存进行并行处理的全部意义在于避免操作系统开销,但操作系统开销可能来自通信本身以外的其他方面。 我们已经说过,应该构建的进程数小于或等于机器中的处理器数。 但是,您如何准确地决定要创建多少进程?
为了获得最佳性能,并行程序中的进程数应等于您的程序进程预期可以同时在不同处理器上运行的进程数。 例如,如果一台四处理器 SMP 通常有一个进程正在为其他目的(例如,WWW 服务器)积极运行,那么您的并行程序应仅使用三个进程。 您可以通过查看 uptime
命令引用的“负载平均值”来大致了解系统上有多少其他进程处于活动状态。
或者,您可以使用例如 renice
命令或 nice()
系统调用来提高并行程序中进程的优先级。 您必须具有特权才能提高优先级。 其想法很简单,就是将其他进程强制退出处理器,以便您的程序可以在所有处理器上同时运行。 使用 http://luz.cs.nmt.edu/~rtlinux/ 上的 SMP Linux 原型版本可以更明确地实现这一点,该版本提供实时调度程序。
如果您不是唯一将您的 SMP 系统视为并行机器的用户,那么您可能还会遇到尝试同时执行的两个或多个并行程序之间的冲突。 这种标准解决方案是 组调度 - 即,操纵调度优先级,以便在任何给定时刻,只有单个并行程序的进程正在运行。 然而,值得回顾的是,使用更多的并行性往往具有递减的回报,并且调度程序活动会增加开销。 因此,例如,对于四处理器机器来说,运行两个每个有两个进程的程序可能比在两个每个有四个进程的程序之间进行组调度更好。
这还有一个曲折。 假设您正在一台整天都在大量使用的机器上开发程序,但在晚上将完全可用于并行执行。 您需要使用完整数量的进程编写和测试代码的正确性,即使您知道白天的测试运行速度会很慢。 好吧,如果您有进程 忙等待 共享内存值被当前未运行(在其他处理器上)的其他进程更改,那么它们将非常慢。 如果您在单处理器系统上开发和测试代码,也会发生同样的问题。
解决方案是在您的代码中嵌入调用,无论它可能循环等待来自另一个处理器的操作,以便 Linux 将给另一个进程一个运行的机会。 我使用一个 C 宏,称之为 IDLE_ME
来做到这一点:对于测试运行,使用 cc -DIDLE_ME=usleep(1); ...
编译;对于“生产”运行,使用 cc -DIDLE_ME={} ...
编译。 usleep(1)
调用请求 1 微秒的睡眠,这具有允许 Linux 调度程序选择在处理器上运行不同进程的效果。 如果进程数是可用处理器数的两倍以上,则带有 usleep(1)
调用的代码通常比没有它们的代码运行速度快十倍。
bb_threads(“Bare Bones”线程)库,ftp://caliban.physics.utoronto.ca/pub/linux/,是一个非常简单的库,演示了 Linux clone()
调用的使用。 gzip tar
文件只有 7K 字节! 尽管此库基本上已被第 2.4 节中讨论的 LinuxThreads 库淘汰,但 bb_threads 仍然可用,并且它足够小巧和简单,可以很好地用作 Linux 线程支持的介绍。 当然,阅读此源代码比浏览 LinuxThreads 的源代码要容易得多。 总之,bb_threads 库是一个很好的起点,但实际上不适合编码大型项目。
使用 bb_threads 库的基本程序结构是
bb_threads_stacksize(b)
设置为 b 字节。MAX_MUTEXES
编号,并通过 bb_threads_mutexcreate(i)
初始化锁 i。void
的函数 f,并将单个参数 arg 传递给它,您可以执行类似 bb_threads_newthread(f, &arg)
的操作,其中 f 应声明为类似于 void f(void *arg, size_t dummy)
。 如果您需要传递多个参数,请传递指向结构的指针,该结构初始化为保存参数值。bb_threads_lock(n)
和 bb_threads_unlock(n)
。 请注意,此库中的锁定和解锁操作是非常基本的自旋锁,使用原子总线锁定指令,这可能会导致过多的内存引用干扰,并且不会尝试确保公平性。 与 bb_threads 打包在一起的演示程序没有正确使用锁来防止 printf()
从函数 fnn
和 main
中同时执行……因此,演示并非总是有效。 我不是在贬低演示,而是要强调,这东西非常棘手;此外,使用 LinuxThreads 只会稍微容易一些。return
时,它实际上会销毁进程……但本地堆栈内存不会自动释放。 准确地说,Linux 不支持释放,但内存空间不会自动添加回 malloc()
空闲列表。 因此,父进程应通过 bb_threads_cleanup(wait(NULL))
回收每个已死子进程的空间。
以下 C 程序使用第 1.3 节中讨论的算法,使用两个 bb_threads 线程计算 Pi 的近似值。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include "bb_threads.h" volatile double pi = 0.0; volatile int intervals; volatile int pids[2]; /* Unix PIDs of threads */ void do_pi(void *data, size_t len) { register double width, localsum; register int i; register int iproc = (getpid() != pids[0]); /* set width */ width = 1.0 / intervals; /* do the local computations */ localsum = 0; for (i=iproc; i<intervals; i+=2) { register double x = (i + 0.5) * width; localsum += 4.0 / (1.0 + x * x); } localsum *= width; /* get permission, update pi, and unlock */ bb_threads_lock(0); pi += localsum; bb_threads_unlock(0); } int main(int argc, char **argv) { /* get the number of intervals */ intervals = atoi(argv[1]); /* set stack size and create lock... */ bb_threads_stacksize(65536); bb_threads_mutexcreate(0); /* make two threads... */ pids[0] = bb_threads_newthread(do_pi, NULL); pids[1] = bb_threads_newthread(do_pi, NULL); /* cleanup after two threads (really a barrier sync) */ bb_threads_cleanup(wait(NULL)); bb_threads_cleanup(wait(NULL)); /* print the result */ printf("Estimation of pi is %f\n", pi); /* check-out */ exit(0); }
LinuxThreads http://pauillac.inria.fr/~xleroy/linuxthreads/ 是一个相当完整且可靠的“共享所有”实现,符合 POSIX 1003.1c 线程标准。与其他 POSIX 线程端口不同,LinuxThreads 使用与 bb_threads 相同的 Linux 内核线程工具 (clone()
)。POSIX 兼容性意味着可以相对容易地将许多线程应用程序从其他系统移植过来,并且可以找到各种教程材料。简而言之,这绝对是在 Linux 下开发大型多线程程序时应该使用的线程包。
使用 LinuxThreads 库的基本程序结构是
pthread_mutex_t lock
类型的变量。使用 pthread_mutex_init(&lock,val)
初始化您需要使用的每个锁。pthread_t
类型的变量来标识每个线程。要创建运行 f()
的线程 pthread_t thread
,请调用 pthread_create(&thread,NULL,f,&arg)
。pthread_mutex_lock(&lock)
和 pthread_mutex_unlock(&lock)
。pthread_join(thread,&retval)
在每个线程之后进行清理。-D_REENTRANT
。下面是一个使用 LinuxThreads 并行计算 Pi 的示例。使用了第 1.3 节的算法,并且与 bb_threads 示例一样,两个线程并行执行。
#include <stdio.h> #include <stdlib.h> #include "pthread.h" volatile double pi = 0.0; /* Approximation to pi (shared) */ pthread_mutex_t pi_lock; /* Lock for above */ volatile double intervals; /* How many intervals? */ void * process(void *arg) { register double width, localsum; register int i; register int iproc = (*((char *) arg) - '0'); /* Set width */ width = 1.0 / intervals; /* Do the local computations */ localsum = 0; for (i=iproc; i<intervals; i+=2) { register double x = (i + 0.5) * width; localsum += 4.0 / (1.0 + x * x); } localsum *= width; /* Lock pi for update, update it, and unlock */ pthread_mutex_lock(&pi_lock); pi += localsum; pthread_mutex_unlock(&pi_lock); return(NULL); } int main(int argc, char **argv) { pthread_t thread0, thread1; void * retval; /* Get the number of intervals */ intervals = atoi(argv[1]); /* Initialize the lock on pi */ pthread_mutex_init(&pi_lock, NULL); /* Make the two threads */ if (pthread_create(&thread0, NULL, process, "0") || pthread_create(&thread1, NULL, process, "1")) { fprintf(stderr, "%s: cannot make thread\n", argv[0]); exit(1); } /* Join (collapse) the two threads */ if (pthread_join(thread0, &retval) || pthread_join(thread1, &retval)) { fprintf(stderr, "%s: thread join failed\n", argv[0]); exit(1); } /* Print the result */ printf("Estimation of pi is %f\n", pi); /* Check-out */ exit(0); }
System V IPC(进程间通信)支持由许多系统调用组成,这些系统调用提供消息队列、信号量和共享内存机制。当然,这些机制最初旨在用于多个进程在单处理器系统内进行通信。但是,这也意味着它也应该适用于在 SMP Linux 下的进程之间进行通信,无论它们在哪个处理器上运行。
在深入了解如何使用这些调用之前,重要的是要理解,尽管 System V IPC 调用确实存在用于信号量和消息传输等功能,但您可能不应该使用它们。为什么不呢?这些功能通常在 SMP Linux 下速度慢且串行化。无需多言。
创建一组进程共享访问共享内存段的基本过程是
shmget()
来创建一个所需大小的新段。或者,此调用可用于获取预先存在的共享内存段的 ID。在任何一种情况下,返回值都是共享内存段 ID 或 -1(表示错误)。例如,要创建 b 字节的共享内存段,调用可能是 shmid = shmget(IPC_PRIVATE, b, (IPC_CREAT | 0666))
。shmat()
调用允许程序员指定段应出现的虚拟地址,但所选地址必须在页边界上对齐(即,是 getpagesize()
返回的页面大小的倍数,通常为 4096 字节),并将覆盖先前位于该地址的任何内存的映射。因此,我们更倾向于让系统选择地址。在任何一种情况下,返回值都是指向刚刚映射的段的基虚拟地址的指针。代码是 shmptr = shmat(shmid, 0, 0)
。请注意,您可以通过简单地将所有共享变量声明为 struct
类型的成员,并将 shmptr 声明为指向该类型的指针,从而将所有静态共享变量分配到此共享内存段中。使用此技术,共享变量 x 将被访问为 shmptr->
x。shmctl()
来设置此默认操作。代码类似于 shmctl(shmid, IPC_RMID, 0)
。fork()
调用来创建所需数量的进程... 每个进程都将继承共享内存段。shmdt(shmptr)
完成。
尽管上述设置确实需要一些系统调用,但一旦建立了共享内存段,一个处理器对该内存中的值所做的任何更改都将自动对所有进程可见。最重要的是,每次通信操作都将在没有系统调用开销的情况下发生。
下面是一个使用 System V 共享内存段的 C 程序示例。它使用第 1.3 节中给出的相同算法计算 Pi。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <sys/ipc.h> #include <sys/shm.h> volatile struct shared { double pi; int lock; } *shared; inline extern int xchg(register int reg, volatile int * volatile obj) { /* Atomic exchange instruction */ __asm__ __volatile__ ("xchgl %1,%0" :"=r" (reg), "=m" (*obj) :"r" (reg), "m" (*obj)); return(reg); } main(int argc, char **argv) { register double width, localsum; register int intervals, i; register int shmid; register int iproc = 0;; /* Allocate System V shared memory */ shmid = shmget(IPC_PRIVATE, sizeof(struct shared), (IPC_CREAT | 0600)); shared = ((volatile struct shared *) shmat(shmid, 0, 0)); shmctl(shmid, IPC_RMID, 0); /* Initialize... */ shared->pi = 0.0; shared->lock = 0; /* Fork a child */ if (!fork()) ++iproc; /* get the number of intervals */ intervals = atoi(argv[1]); width = 1.0 / intervals; /* do the local computations */ localsum = 0; for (i=iproc; i<intervals; i+=2) { register double x = (i + 0.5) * width; localsum += 4.0 / (1.0 + x * x); } localsum *= width; /* Atomic spin lock, add, unlock... */ while (xchg((iproc + 1), &(shared->lock))) ; shared->pi += localsum; shared->lock = 0; /* Terminate child (barrier sync) */ if (iproc == 0) { wait(NULL); printf("Estimation of pi is %f\n", shared->pi); } /* Check out */ return(0); }
在此示例中,我使用了 IA32 原子交换指令来实现锁定。为了获得更好的性能和可移植性,请替换一种避免原子总线锁定指令的同步技术(在第 2.2 节中讨论)。
调试代码时,记住 ipcs
命令将报告当前正在使用的 System V IPC 设施的状态,这很有用。
使用系统调用进行文件 I/O 可能非常昂贵;事实上,这就是为什么存在用户缓冲文件 I/O 库(getchar()
、fwrite()
等)的原因。但是,如果多个进程正在访问同一个可写文件,则用户缓冲区不起作用,并且用户缓冲区管理开销很大。BSD UNIX 对此的修复是添加了一个系统调用,该调用允许将文件的一部分映射到用户内存中,本质上是使用虚拟内存分页机制来引起更新。多年来,Sequent 系统也使用了相同的机制作为其共享内存并行处理支持的基础。尽管在(相当旧的)手册页中存在一些非常负面的评论,但 Linux 似乎正确地执行了至少一些基本功能,并且它支持此系统调用的退化使用,以映射可以跨多个进程共享的匿名内存段。
本质上,Linux 中 mmap()
的实现是 System V 共享内存方案(在第 2.5 节中概述)中步骤 2、3 和 4 的插件替代品。要创建匿名共享内存段
shmptr = mmap(0, /* system assigns address */ b, /* size of shared memory segment */ (PROT_READ | PROT_WRITE), /* access rights, can be rwx */ (MAP_ANON | MAP_SHARED), /* anonymous, shared */ 0, /* file descriptor (not used) */ 0); /* file offset (not used) */
与 System V 共享内存 shmdt()
调用等效的是 munmap()
munmap(shmptr, b);
在我看来,使用 mmap()
而不是 System V 共享内存支持没有任何实际好处。