
2007年4月29日
如主流中央处理器(CPU)供应商所证明的那样,多核处理器随时准备着占领桌面和嵌入式市场。多处理带来了更高的性能,也同时带来了新的问题。本文将探讨多处理和开发使用 SMP 的 Linux® 应用程序背后的思想。
可以通过多种方法提高 Linux 系统的性能,而其中最流行的一种是提高处理器的性能。一个明显的解决方案是使用具有更快时钟频率的处理器,但是对于任何特定技术来讲都存在一个物理极限,时钟频率也有这样的极限。当达到那个极限时,可使用 “越多越好” 的方法应用多处理器。不幸的是,多处理器的性能并不与单个处理器性能的总和成线性比例。
在开始讨论 Linux 中的多处理应用程序之前,我们先来快速地回顾一下多处理的历史。
多处理的历史
 |
Flynn 的多 CPU 架构分类
Single Instruction, Single Data(SISD) 是典型的单处理器架构。Multiple Instruction, Multiple Data(MIMD) 多处理架构拥有一些独立的处理器,它们各自操作独立数据(控制并行)。最后,Single Instruction, Multiple Data(SIMD) 拥有操作不同数据的大量处理器(数据并行)。
请参阅下面的 参考资料 部分,获取 Flynn 的原始文件的详细信息。
|
|
多处理起源于 20 世纪 50 年代中期的一些公司,这些公司中有些您可能知道,而另一些您可能就不记得了(IBM、Digital Equipment Corporation、Control Data Corporation)。20 世纪 60 年代早期,Burroughs Corporation 引入了一种对称 MIMD 多处理器,它带有四个 CPU 并通过交叉开关可连接最多十六个内存模块(第一种 SMP 架构)。1964 年引入了 CDC 6600,它的使用比较成功并得到流行,它提供了一个带有十个子处理器(外围处理单元)的 CPU。20 世纪 60 年代末,Honeywell 发布了它的第一个 Multics 系统,这是带八个 CPU 的另一种对称多处理系统。
在开发多处理系统的同时,各种技术的使用也提高了缩小处理器体积和运行更快的时钟频率的能力。20 世纪 80 年代,Cray Research 等公司引入了多处理器系统和类似 UNIX® 的操作系统(CX-OS),以便利用这些能力。
20 世纪 80 年代末期,随着单处理器个人计算机系统(如 IBM PC)的流行,多处理系统的使用呈下降趋势。但是到了二十年后的现在,多处理利用对称多处理技术又回到了个人计算机系统中。
Amdahl 法则
Gene Amdahl 是一名计算机架构师、IBM 职员,在 IBM、Amdahl Corporation(以他的名字命名的企业)和其他一些公司从事计算机架构开发。但是最著名的是他的法则,该法则用于在改进系统的一部分后预测最大的预期系统改进。它主要用来计算使用多处理器后理论上的最大性能改进(参见图 1)。
图 1. 处理器并行化的 Amdahl 法则
使用图 1 所示的等式,可计算系统的最大性能改进,N 表示处理器的数目,而因数 F 指定不能并行化的系统部分(即本质上顺序的系统部分)。结果如图 2 所示。
图 2. 最多十个 CPU 的 Amdahl 法则
图 2 中最上面的一条线显示了处理器的数目。理想状态下,添加另外的处理器来解决问题时,希望看到这样的性能增长。不幸的是,并非所有的问题都可以并行化,而且还有管理处理器的开销,所以速度的提高并没有这么大。底部(紫色的线)是一个 90% 的处理属于顺序性的问题例子。在此图中,最佳的情况是棕色的线,它展示了一个 10% 顺序性(因此 90% 可并行化)的问题。即使在这种情况下,十个处理器的执行性能也只比五个稍好一点儿。
多处理和 PC
SMP 架构:两个或多个同样的处理器通过一块共享内存彼此连接。每个处理器可同等地访问共享内存(具有相同的内存空间访问延迟)。可将此架构与 Non-Uniform Memory Access(NUMA)架构进行对比。例如,每个处理器拥有自己的内存,访问共享内存时具有不同的访问延迟。
松耦合多处理
最早的 Linux SMP 系统是松耦合多处理器系统。这些系统是利用多个高速互连的单一系统构造的(如 10G 以太网、Fibre Channel 或 Infiniband)。此类架构也被称作集群(参见图 3),Linux Beowulf 项目是此类架构的一个流行的解决方案。Linux Beowulf 集群可利用普通硬件和典型的网络互连(如以太网)来构建。
图 3. 松耦合多处理架构
构建松耦合多处理器架构很容易(由于使用了 Beowulf 之类的项目),但是它们也有自身限制。构建大型的多处理器网络可能占用相当大的空间并消耗很多电量。因为它们通常是利用普通硬件来构建的,所以包含的有些硬件不相关却要耗费很多电量和空间。更大的缺点在于通信结构。即使使用高速网络(如 10G 以太网),也存在系统可伸缩性的限制。
紧密耦合多处理
紧密耦合多处理指芯片级多处理(CMP)。可将其看作松耦合架构按比例缩小至芯片级。这正是紧密耦合多处理(也称作多核计算)背后的思想。在一个集成电路中,多个芯片、共享内存以及互连形成了一个紧密集成的多处理核心(参见图 4)。
 |
处理器互连 另一种可选的互连(系统结构总线)是 AMD 的 HyperTransport。Intel® 也在规划称作 Common System Interface 的新互连,预计于 2008 年发布。 |
|
图 4. 紧密耦合多处理架构
在 CMP 中,多个 CPU 通过共享总线连接到共享内存(2 级缓存)。每个处理器也拥有自身的快速内存(1 级缓存)。CMP 紧密耦合的本质使处理器与内存之间的物理距离很短,因此可提供最小的内存访问延迟和更高的性能。此类架构在多线程应用程序中运行良好,该类应用程序中线程可能被分配给多个处理器以实现并行操作。这种方法被称作线程级并行(TLP)。
由于这种多处理器架构的流行,很多供应商都生产了 CMP 设备。表 1 列出了一些 Linux 支持的流行变种。
表 1. CMP 设备样例
| 供应商 |
设备 |
说明 |
| IBM |
POWER4 |
SMP,双 CPU |
| IBM |
POWER5 |
SMP,双 CPU,四个并发线程 |
| AMD |
AMD X2 |
SMP,双 CPU |
| Intel® |
Xeon |
SMP,双 CPU 或四 CPU |
| Intel |
Core2 Duo |
SMP,双 CPU |
| ARM |
MPCore |
SMP,最多四 CPU |
| IBM |
Xenon |
SMP,三个 Power PC CPU |
| IBM |
Cell Processor |
不对称多处理(Asymmetric multiprocessing,ASMP),九 CPU |
内核配置
为了在支持 SMP 的硬件上通过 Linux 使用 SMP,必须适当地配置内核。内核配置期间必须启用 CONFIG_SMP 选项,以使内核感知 SMP。通过在多 CPU 主机上运行感知 SMP 的内核,可使用 proc 文件系统了解处理器的数目及类型。
首先,使用 grep 在 /proc 下的 cpuinfo 文件中检索处理器的数目。如清单 1 所示,使用计数选项(-c)来统计以单词 processor 开头的行。然后显示 cpuinfo 文件的内容。显示的示例来自一个二芯片的 Xeon 主板。
清单 1. 使用 proc 文件系统检索 CPU 信息
mtj@camus:~$ grep -c ^processor /proc/cpuinfo
8
mtj@camus:~$ cat /proc/cpuinfo
processor : 0
vendor_id : GenuineIntel
cpu family : 15
model : 6
model name : Intel(R) Xeon(TM) CPU 3.73GHz
stepping : 4
cpu MHz : 3724.219
cache size : 2048 KB
physical id : 0
siblings : 4
core id : 0
cpu cores : 2
fdiv_bug : no
hlt_bug : no
f00f_bug : no
coma_bug : no
fpu : yes
fpu_exception : yes
cpuid level : 6
wp : yes
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr
pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm
pbe nx lm pni monitor ds_cpl est cid xtpr
bogomips : 7389.18
...
processor : 7
vendor_id : GenuineIntel
cpu family : 15
model : 6
model name : Intel(R) Xeon(TM) CPU 3.73GHz
stepping : 4
cpu MHz : 3724.219
cache size : 2048 KB
physical id : 1
siblings : 4
core id : 3
cpu cores : 2
fdiv_bug : no
hlt_bug : no
f00f_bug : no
coma_bug : no
fpu : yes
fpu_exception : yes
cpuid level : 6
wp : yes
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr
pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm
pbe nx lm pni monitor ds_cpl est cid xtpr
bogomips : 7438.33
mtj@camus:~$
|

 |

|
SMP 和 Linux 内核
在 Linux 2.0 的早期,SMP 支持由一个 “大锁” 组成,这个 “大锁” 对跨系统的访问进行串行化。对于 SMP 支持的改进缓慢地进行着,但是直到 2.6 内核才显示出 SMP 的威力。
2.6 内核引入了新的 O(1) 调度程序,它包含更好的 SMP 系统支持。关键在于能在可用 CPU 之间进行负载平衡,同时维持亲合性以提高缓存效率。回顾图 4,当任务与单个 CPU 相关联时,如果将其移到另一个 CPU,就需要为该任务清空缓存。这就增加了任务的内存访问延迟,这些时间用来将其数据移入新 CPU 的内存中。
 |
内核中的 SMP 为了理解如何为特定架构启用 SMP 功能,请在 ./linux/arch/<arch>/kernel/ 查看内核中的 smp.c 或 smpboot.c 文件(适于大多数架构和平台)。 |
|
2.6 内核为每个处理器维护两个 runqueue(过期的和活动的 runqueue)。每个 runqueue 支持 140 个优先级,前面的 100 个用于实时任务,而后面的 40 个用于用户任务。任务分时间片执行,在使用它们分配到的时间片时,这些任务被从活动的 runqueue 移至过期的 runqueue。这就为所有的任务提供了公平访问 CPU 的机会(仅根据每个 CPU 锁定)。
利用每个 CPU 的任务队列,可以根据系统中所有 CPU 的负载进行负载平衡。每 200 毫秒,调度程序就执行一次负载平衡调节,以便重新分配任务负载,维持处理器之间的平衡。有关 Linux 2.6 调度程序的更多信息,请参阅 参考资料 部分。
用户空间线程:使用 SMP 的功能
为使用 SMP,需要在 Linux 内核中执行大量工作,但仅靠操作系统本身还不够。回顾一下,SMP 的能力取决于 TLP。单个单片(非线程化的)程序不能使用 SMP,但是 SMP 可在由核心之间分布的多个线程组成的程序中使用。当一个线程由于等待完成 I/O 处理而被延迟时,另一个线程可执行一些有用的工作。这样,线程之间互相协作,隐藏了各自的延迟时间。
Portable Operating System Interface(POSIX)线程是构建可利用 SMP 的线程化应用程序的一种好方法。POSIX 线程提供了线程化机制和共享内存。当调用程序创建若干线程时,每个线程得到自身的堆栈(本地变量和状态),但共享父线程的数据空间。创建的所有线程共享这个相同的数据空间,但是问题就出在这里。
为支持多线程访问共享内存,需要具备协调机制。POSIX 提供了互斥函数来创建临界区,用于实施单线程对对象(一块内存)的独占访问。不这样做可能导致内存破坏(由于多个线程执行了不同步的操作)。清单 2 演示了如何使用 POSIX 互斥函数创建临界区。
清单 2. 使用 pthread_mutex_lock 和 pthread_mutex_unlock 创建临界区
pthread_mutex_t crit_section_mutex = PTHREAD_MUTEX_INITIALIZER;
...
pthread_mutex_lock( &crit_section_mutex );
/* Inside the critical section. Memory access is safe here
* for the memory protected by the crit_section_mutex.
*/
pthread_mutex_unlock( &crit_section_mutex );
|
如果多个线程在完成上面的初始调用后尝试锁定信号量,则这些线程会被阻塞,它们的请求进入队列,直到执行 pthread_mutex_unlock 调用。
SMP 的内核变量保护
如果处理器中的多个核心为内核并发运作,则希望避免共享那些特定于给定核心的数据。出于此原因,2.6 内核引入了 per-CPU 变量的概念,这些变量与单个 CPU 相关联。这允许将某个 CPU 通常访问的变量声明为该 CPU 专有的变量。使用此方法使锁定需求最小化并提高了性能。
per-CPU 变量由 DEFINE_PER_CPU 宏进行定义,需要为该宏提供类型和变量名称。由于宏的运作方式与 l-value 类似,因此也可在宏中进行变量的初始化。下面的示例(来自 ./arch/i386/kernel/smpboot.c)定义了一个变量,它用于表示系统中每个 CPU 的状态。
/* State of each CPU. */
DEFINE_PER_CPU(int, cpu_state) = { 0 };
|
该宏创建了一个变量数组(每个 CPU 一个变量)。要访问 per-CPU 变量,需将 per_cpu 宏和 smp_processor_id 结合使用,后者是一个函数,用于返回当前执行代码的当前 CPU 的标识符。
per_cpu( cpu_state, smp_processor_id() ) = CPU_ONLINE;
|
内核提供了用于 per-CPU 锁定和变量动态分配的其他函数。可在 ./include/linux/percpu.h 中查找这些函数。
结束语
当处理器频率达到其极限时,一种流行的提高性能的方法是添加更多的处理器。在早期,这就意味着将更多的处理器添加到主板上,或将多个独立计算机集群到一起。现在,芯片级多处理能够在单个芯片上提供更多的 CPU,由于减少了内存延迟,因而可获得更高的性能。
您会发现 SMP 系统不仅存在于服务器中,还存在于桌面上,特别是在引入虚拟化以后。跟大多数先进技术一样,Linux 提供 SMP 支持。内核负责完成可用 CPU 间的负载优化(从线程到虚拟化操作系统)。惟一要做的就是确保应用程序可被充分地多线程化以便使用 SMP 的能力。
posted @ 2007-04-29 19:46 彭帅 阅读(194) 评论(0)
编辑
请下载本文的代码: UnderTheHood0500.exe (264KB)
近年来,在我的专栏文章和技术讲座中,其中一个主要主题一直是优化可执行文件技术的优点。 通常,优化技术指的是确定基址和绑定,但可能还包括按序号导入函数或更改可执行文件页面对齐。 从直观的角度来说,这些策略应该使您的代码加载得更快。 不过,始终有一个关于这一主题的问题萦绕在我的心里 — 利用这些技术究竟能获得多大的改进? 这个月我一直在研究这个问题,并得出一些具体的数字。
在本期专栏文章中,我的目标是测量以下几个方案的加载时间速度效果:
| • |
使用具有冲突的加载地址的 DLL 的程序与在首选地址加载每个 DLL 的程序。 此方案测试 REBASE 程序的效果。
|
| • |
以正常方式导入数百个 API 的程序与将所有 API 绑定到其目标 DLL 的程序。 此方案测试 BIND 程序的效果。
|
| • |
按序号而不是按名称导入函数。 为此,您必须自己来完成某些工作,通常是创建和维护 .DEF 文件。
|
我将立刻解释为什么第二个方案取决于第一个方案。 原因在于,针对不在其首选地址加载的 DLL 绑定可执行文件是浪费时间。 绑定假定您有一个具有正确基址的系统,其中 DLL 根据其头规范进行加载。
本页内容
加载时间性能优化的快速回顾
在讨论性能改进的细节之前,迅速回顾一遍确定基址、绑定和按序号导入。 确定基址是一个很好的起点。 在创建 DLL 时,链接器假定 DLL 将在某个特定地址加载。 某些代码块和数据包含硬编码的地址,仅当 DLL 在首选地址加载时,这些地址才是正确的。 不过,运行时有可能发生这样的事情 — 操作系统可能不得不在另一个不同的内存位置加载 DLL。
为了处理操作系统不得不移动 DLL 的情况,链接器向 DLL 添加基重定位。 基重定位是需要修改的地址,这些地址在修改后就会包含 DLL 在内存中进行加载的正确地址。 DLL 拥有的基重定位越多,操作系统处理它们以加载 DLL 所需的时间也越多。 具有正确基址的 DLL 在其首选地址加载,并且可以跳过对基重定位记录的处理。
如果没有给定明确的指示,Microsoft 链接器会用首选加载地址 0x10000000 来创建每个 DLL。 如果您的程序使用了一个以上您自己设计的 DLL,则您将拥有多个具有相同首选加载地址的 DLL。 这种情况的结果是,除了第一个 DLL 之外的每个 DLL 都将由操作系统在加载时重定位。 有时,这种情况称为加载地址冲突。 不过,如果您进行干预,就能避免此类情况的发生。
随 Microsoft Visual Studio 和 Platform SDK 提供的 REBASE 程序是一个解决加载地址冲突问题的方便工具。 您为 REBASE 提供一个构成程序的所有模块(系统 DLL 不计算在内)的列表,该程序会为 DLL 挑选新的加载地址,然后再进行适当的修改。
绑定可执行文件建立在可将所有 DLL 调整为在其首选地址加载这一前提之上。 当您从 DLL 导入一个函数时,Windows 加载器为查找导入函数所必需的信息存储在您的可执行文件中。 通常,此信息是导入的 DLL 的名称和导入的函数的名称。 当加载器解析导入的函数时,实质上是在执行 GetProcAddress 使用的同一代码。
正常情况下,在启动时,加载器会搜索所有导入的函数,查找它们的地址。 不过,如果导入的 DLL 不随着运行而更改,则加载器找回的地址也不更改。 一个简单的优化方法是,将目标函数的地址写入进行导入的可执行文件,这正是 BIND 程序承担的工作。
普通的 Win32 可执行文件有两个完全相同的、查找导入函数所必需的信息副本。 一个称为导入地址表 (IAT),另一个称为导入名称表。 不过,只有一个副本 (IAT) 是 Win32 加载器所必需的。 BIND 程序利用此信息有两个副本这一事实,用导入的函数的实际地址覆盖 IAT 条目。
在加载时,加载器检查是否一切都符合规定。如果是,则使用 BIND 存储在 IAT 中的地址。 这样一来,就无需按名称查找函数。 如果链接可执行文件时出了差错,该怎么办呢? 例如,导入的 DLL 可能加载到其他位置。 在这种情况下,加载器使用导入名称表信息来进行正常的查找。
BIND.EXE 是绑定可执行文件的最著名的方法。 不过,它是根据您的系统 DLL 来优化可执行文件的。 如果您将自己的程序分发给用户,他们的系统 DLL 可能不同,因此您需要在这些用户的系统上绑定您的可执行文件。 Windows Installer 具有 BindImage 操作,非常易于使用(但我必须承认我从来没有编写过一个安装脚本)。 另一种方法是,您可以使用 API BindImageEx,它是 IMAGEHLP.DLL 的一部分。
本月专栏文章中最后一种性能优化是按序号导入。 一般来说,当您导入函数时,您的二进制文件包含导入的函数的名称。 当 Win32 加载器查找导入的名称时,它必须进行字符串比较,以将正在导入的名称与正在从其中导入的 DLL 所导出的名称进行匹配。
当一个可移植的二进制可执行文件导出函数时,它包含对导出的函数的偏移量数组。 当您按序号导入时,进行导入的二进制文件将数组索引(这里称为序号)包含在此数组中。 这种查找导入函数的地址的方法是一个简单的数组查找,因此非常快。 按名称导入则麻烦得多,因为 Win32 加载器先获得名称,然后执行搜索,以查找相应的序号值。 接着,加载器继续工作,就好像您已经先指定了序号一样。 按名称导入只是在按序号导入之上又添加了额外一个代码层。
那么,为什么 Win32 允许按名称导入和导出呢? 其中有好几个原因,但我只能立即想到两个原因。 第一个原因,如果其 DLL 随着时间变化(就像 Win32 系统 DLL 那样),则保留分配到给定的 API 的同一个导出序号非常困难。在一个 .DEF 文件中跟踪数百个函数会非常麻烦。 此外,KERNEL32.DLL 不只有一个;Windows 2000 有一个,Windows 98 有一个,等等。 第二个原因,按名称导出允许您使用函数的名称 GetProcAddress,而不是其序号值。 如果您从 Platform SDK 或 Visual Studio 转储随机选择的导入库,您会发现大多数 DLL 按名称导出,但有几个按序号导出。
您怎样按序号导入和导出呢? 导入部分实际上是由 Microsoft 链接器自动为您完成的。 不过,作为交换,您必须按序号导出 API。 当链接器生成与 DLL 相对应的导入库时,它会生成指定链接器应该如何导入 API 的导入记录。 按序号导出的最佳方法是,通过 .DEF 文件向链接器显式指定。 例如,在 .DEF 文件中可以包含:
EXPORTS
MyExportedAPI @1
如果您不使用 @1 修饰符,Microsoft 链接器会按名称导出 API。
按序号导入和导出除了具有加载速度快这一优点之外,还有另一个好处。 按序号导出 API 时,您可以告诉链接器不要在进行导出的 DLL 中存储导出的 API 名称。 这意味着导出部分更小,二进制文件也可能更小,请求调页的数据更少。要消除 API 名称,请在按序号导出时使用 NONAME 修饰符。 EXPORTS
MyExportedAPI @1 NONAME
如果查看一下 MFC42.DLL,您就会发现,几乎所有 6000 多个 API 都是按序号导出的,并使用了 NONAME 修饰符。 您可以想象,如果 MFC42.DLL 不得不在其导出中存储所有 6000 个打乱的 C++ 名称,它会有多么庞大!
创建优化测试
为了测试正确的确定基址、绑定和按序号导入的效果,我编写了一个名为 MakeLoadTimeTest.EXE 的程序,它会生成作为基准测试的程序。 利用我创建的这个程序,我可以很容易地调整导入的函数的数目、它调用多少个 DLL 以及每个 DLL 拥有的重定位的数目。 MakeLoadTimeTest 的源代码可在本月的下载文件中找到。
MakeLoadTimeTest.CPP 代码并非十分完美。 不过,您不必太仔细阅读该代码,因为您可能需要更改的内容已经在开头单独列出 — 尤其是这三行:
const unsigned nDLLs = 10;
const unsigned nExportedFunctions = 100;
const unsigned nGlobalVariablesPerFunction = 5;
根据这些常量,生成的程序 (LoadTimeTest) 将导入 10 个 DLL(KERNEL32.DLL 除外)。 其中每个 DLL 将导出 100 个函数,主可执行文件将导入所有 100 个函数。 最后,每个导出的函数引用五个全局变量。 为什么引用全局变量呢? 因为这是强制生成基重定位的一个简便方法。 基重定位越多,DLL 不在其首选地址加载时加载器需要完成的工作就越多。
图 1 显示了为一个导出的函数生成的代码。 代码开头是五个全局变量声明(例如,g_var_n2_0)。 接下来是 #ifdef 程序块,可用于在编译时确定函数按名称导出还是按序号导出。 最后,函数本身(在此示例中,即 LoadTimeDLL_10_func_2)只将一个值存储到前面声明的变量中。 为什么变量和函数名这样奇怪呢? 原因在于,这些名称结尾处的数字使它们成为唯一名称,从而避免了命名冲突。
至于 LoadTimeTest 可执行文件,它只需要一个简单的函数 main,它引用生成的 DLL 导出的所有函数。 整个函数的长度超过 1000 行,但下面只列出了相关的代码片段:
#include "LoadTimeTest.H"
#include <windows.h>
int main(int argc)
{
TerminateProcess( GetCurrentProcess(), 0 );
LoadTimeDLL_1_func_1();
LoadTimeDLL_1_func_2();
LoadTimeDLL_1_func_3();
LoadTimeDLL_1_func_4();
// ...
}
您可能想知道为什么我使用了 TerminateProcess 调用。 在理想情况下,我能够计算出加载我的目标进程所用的时间有多长(直到调用其入口点)。 不过,我无法提出一个完全按此过程执行的简单方法。
我最终采用的办法是,使 main 函数调用 TerminateProcess。 这种做法可立即停止该进程,而不必向 DLL 发送 DLL_PROCESS_DETACH 通知。 此外,因为我实际上不调用所有生成的 API(例如,LoadTimeDLL_1_func_1),就不会发生对代码请求调页的系统开销。不过,因为我在函数 main 中引用了所有生成的导出的函数,加载器被强制加载 DLL,并且有可能应用基重定位。
除了代码生成器 (MakeLoadTimeTest) 和生成的程序 (LoadTimeTest),这个基准测试程序包中还有一个程序。 LoadTimer 可执行文件运行 LoadTimeTest 程序,并计算出执行所用的时间。 因为 Windows 是一个按优先级执行的多任务处理系统,我花了相当大的工夫才得到比较可靠的计时信息。 下载文件中的源代码包含了所有细节,但这里还是要概括一下。
对于启动程序,LoadTimer 并不依靠 LoadTimeTest 的一次运行。 LoadTimeTest 第一次运行时,磁盘系统开销增加了它的加载时间。 后来的运行通常更快一些,因为操作系统已缓存了 EXE 和 DLL 中的页面。 我计算 30 次调用 LoadTimeTest 的时间,并使用最快的运行 — 由于线程切换和中断处理等因素导致外部系统开销最少。
为了最大限度地减少这些外部事件的系统开销,LoadTimer 将其优先级设置为 REALTIME_PRIORITY_CLASS。 此外,当它启动 LoadTimeTest.EXE 时,代码为该进程指定 REALTIME_PRIORITY_CLASS。 这种做法可确保 LoadTimer 进程中执行的 CreateProcess 代码以及 LoadTimeTest 进程中的代码都以可能设定的最高优先级运行。 因此,外部事件的作用应该被最大限度地降低。
为了进行计时,我使用了 QueryPerformanceCounter API。 我原来使用 x86 结构的 RDTSC 指令来凑合一下,该指令可以与一个 CPU 时钟周期一样准确。 不过,它需要知道 CPU 的速度,以便计算实际时间。 虽然您可以从 Windows NT 中的注册表 (HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\ System\CentralProcessor\0) 读取 CPU 的速度,但该数字并不准确。 例如,在我的 550Mhz 的计算机中,注册表报告的速度是 548Mhz。 (我该向谁投诉那缺少的 2Mhz 呢?) 最后,QueryPerformanceCounter 的粒度似乎足以满足我们研究的持续时间的要求。
为了让我的基准测试代码变得灵活,我使 LoadTimer 可在任何无需用户干预、自动退出的程序中使用。 LoadTimer 接受命令行参数,指定要运行的文件名。 在我的测试中,命令行参数是 LoadTimeTest.EXE。
LoadTimeTest 基准测试进程
下面讨论重新生成所下载的代码文件中的结果需要的步骤。 首先,从项目文件构建 MakeLoadTimeTest 和 LoadTimer。 接着,运行 MakeLoadTimeTest.EXE。结果是 11 个 .CPP 文件和 11 个 .H 文件。 运行 BuildLoadTimeTest.BAT 文件可将这些文件编译到 LoadTimeTest.EXE 和 10 个相关联的 DLL。 如果您指定 "ORDINAL" 作为 BuildLoadTimeTest.BAT 的参数,LoadTimeTest.EXE 将按序号导入 API;否则,API 将按名称导入。
如果查看一下 BuildLoadTimeTest.BAT,您就会发现它将链接器默认值用于这 10 个 DLL。 因此,所有生成的 DLL 都有相同的首选加载地址: 0x10000000。 这种做法是故意的,因为这样可使基准测试程序的开头是将在运行时重定位的九个 DLL。
现在,开始实际测试。 首先,运行命令
LoadTimer.EXE LoadTimeTest.EXE
若干次,并记录最短的时间。 这是一种最坏情况计时。
现在,我们来解决所有那些 DLL 需要重定位的问题。 运行 RebaseLoadTimeTest.BAT 文件,该文件对 EXE 和 10 个生成的 DLL 使用 REBASE.EXE,因此每个 DLL 都有一个唯一的加载地址。 重新运行计时序列,记录最短时间。 这样,您可以大致了解重新确定基址对加载时间有多大的影响。
既然 EXE 和所有 DLL 都在其首选加载地址加载,我们应该看看将它们进行绑定可以获得哪些额外的好处。 运行 BindLoadTimeTest.BAT,然后重新运行计时序列,再次记录最短时间。
此时,您应该有三个加载时间: 没有任何干预的默认时间、对可执行文件重新确定基址后的时间以及确定基址并进行绑定后的时间。 要查看按 ORDINAL 导入的效果,请重新运行前面的测试,但要进行一处更改: 运行 BuildLoadTimeTest.BAT 时,在开头指定 "ORDINAL" 作为参数。
LoadTimeTest 结果
在讨论实际数字之前,我首先要说的是,我对加载程序速度之快感到吃惊。 我故意创建了 LoadTimeTest.EXE,使 Win32 加载器不得不完成许多工作。 它包括许多 DLL、大量导出的函数和重定位。 即使在最慢的情况下,我的计算机在 Windows 2000 下仍然以低于每秒 1/50th 的速度加载该程序。 如果您的程序用了很长的时间才加载,不要责怪加载器。 我几乎可以肯定问题的原因是,某人的初始化代码用了太长时间。
图 2 显示了我获得的结果。 测试所使用的计算机是 Dell XPS T550,它具有以 550Mhz 速度运行的单个 Pentium III CPU。 唯一可见的运行进程(除了 Explorer 外壳程序)是命令提示。 测试从 FAT16 分区运行,这样我可以在 Windows 2000 和 Windows 9x 两种平台上测试。
您可以用各种方式来划分数据,但下面是我认为很有趣的几个数字。 在 Windows 2000 中,正确确定 DLL 的基址将加载时间缩短了大约 12%。 对 EXE 和 DLL 进行确定基址和绑定将加载时间缩短了大约 18%。 相比按名称导入而言,按序号导入使加载时间缩短了 4%。 在 Windows 98 第二版中,正确确定 DLL 的基址使加载时间缩短了大约 8%。 对 EXE 和 DLL 进行确定基址和绑定将加载时间缩短了大约 12%。 相比按名称导入而言,按序号导入使加载时间只缩短了 2%。
当程序正确地确定基址并绑定时,按名称导入(而不是按序号导入)对加载时间并没有太大的影响。 当一切都正确配置后,加载器可以直接从进行导入的 DLL 读取导入的函数的地址。 加载器不需要查找名称或在数组中索引来获得函数地址。
随机观测
在完成代码的同时,我考虑了在非首选地址加载的 DLL 的效果。 在我的测试代码中,其中每个 DLL 导出 100 个 API,可是那些 API 从未被调用。 由于请求调页,我们可以认为包含那些 API 的页面不会调入内存。 同样,可能不会应用使用基重定位的开销。 带着这个看法,我联系了一位从事 Microsoft 研究的性能专家。 他告诉我,在 Windows 9x 中,我关于不需要应用基重定位的假设是正确的。
不过,在 Windows NT 和 Windows 2000 中,页面被临时设置为读/写以进行修改,然后恢复到原来的权限。 因此,用这种方式修改的任何页面不再由各进程共享。 实质上,在 Windows NT 和 Windows 2000 中,不在其首选加载地址加载的任何可执行文件将在可执行文件第一次加载时将其所有代码和数据按请求调入页面。
另一个值得注意之处是,我对 QueryPerformanceFrequency 在 Windows 2000 中的结果感到吃惊。请看图 2,注意在 Windows 2000 中,计时器以 3.57Mhz 运行。 这个速度比 Windows 98 中使用的 1.19 Mhz 频率整整快了三倍。如果您是一个 PC 机的老用户,您可能会想起,传统上,母板使用的是驱动 8254 芯片的 1.19Mhz 示波器。
为了试验一下,我在运行 Windows 2000 的 Pentium Pro 200Mhz 上运行我的测试,并获得了 1.19Mhz 的频率。 然后,我在一些双处理器计算机上运行 LoadTimer,发现该频率与 CPU 速度相匹配。 我从中得出的结论是,Windows 2000 观察母板能够完成的工作,并将最佳可用计时器频率用于 QueryPerformanceXXX API。 我承认自己有些罗嗦,在这个很小的细节上占用了太多时间,不过我喜欢上面的实验,很高兴有机会以硬件级别深入探讨问题。
posted @ 2007-04-29 19:37 彭帅 阅读(480) 评论(0)
编辑
If multiple threads concurrently execute code that writes to or modifies a resource, then obviously the resource must be protected with a thread synchronization lock to ensure that the resource doesn't get corrupted. However, it is common to have a resource that is occasionally written to but frequently read from. If multiple threads are concurrently reading a resource, using a mutual exclusive lock hurts performance significantly because only one thread at a time is allowed to read from the resource. It's far more efficient to allow all the threads to read the resource simultaneously, and it's fine to do this if all of the threads treat the resource as read-only and do not attempt to write to or modify it.
A reader/writer synchronization lock can and should be used to improve performance and scalability. A reader/writer lock ensures that only one thread can write to a resource at any one time and it allows multiple threads to read from a resource simultaneously as long as no thread is writing at the same time.
The Microsoft® .NET Framework Class Library includes a ReaderWriterLock class in the System.Threading namespace that lets you obtain multiple-reader/single-writer semantics. While it is nice that a class like this exists, there are several problems with its implementation and I recommend you do not use it:
Performance Even when there is no contention for a ReaderWriterLock, its performance is very slow. For example, a call to its AcquireReaderLock method takes about five times longer to execute than a call to Monitor's Enter method.
Policy If a thread completes writing and there are both reader and writer threads waiting, the ReaderWriterLock releases the reader threads instead of the writer threads. But if you have a resource that is always being written to, you should use a mutually exclusive lock to guard access. And if you have a resource that's always being read from, you don't need a thread synchronization lock at all. So, the reason to use a reader/writer lock is if you expect a lot of reader threads and very few writer threads. Because the ReaderWriterLock releases any waiting readers when a writer releases the lock, writer threads may get queued up and take an unusually long time to get serviced. In fact, I know of some Web sites that were architected around this lock and had problems. Users would go to a Web page, submit a form to modify their profile data, then on the server the thread would call the ReaderWriterLock's AcquireWriterLock method to update the profile data. But other threads were reading from the data, so the writer thread wasn't allowed access in a reasonable amount of time and the client's browser would time out.
On the other hand, the policy of releasing reader threads when a writer thread releases the lock ensures that threads are always making progress and do eventually get serviced. Imagine a reader/writer lock that has a different policy: when a writer thread releases the lock, another waiting writer thread is allowed to own the lock. With this policy, if a lot of writer threads happen to show up, then reader threads are starved. I generally prefer a reader/writer lock that favors writers and if I expect a lot of writers, I'd use a mutually exclusive lock instead. In my opinion, the ReaderWriterLock that ships with the .NET Framework defeats the main purpose of using a reader/writer lock.
Recursion The ReaderWriterLock class supports recursion. This means it remembers which threads currently own the lock and if an owning thread attempts to acquire the lock recursively, it allows the thread to acquire the lock and increments a counter for the thread's ownership. The thread must then release the lock the same number of times so that the thread doesn't own the lock anymore. Although this seems like a nice feature, it comes at a very high cost. First, since multiple reader threads can own the lock simultaneously, the lock must keep the counter on a per-thread basis and this requires additional memory and time to update the counter. This feature contributes significantly to the ReaderWriterLock's poor performance. Second, it is sometimes useful to design an architecture where you need to acquire a lock in one thread and release the lock in another thread. Because of its recursion feature, the ReaderWriterLock prohibits this kind of application architecture.
Resource Leak Prior to version 2.0 of the .NET Framework, the ReaderWriterLock had a bug that caused it to leak some of the kernel objects it was using. These objects would be reclaimed only after process termination. Fortunately, this bug has been fixed in the .NET Framework 2.0.
For all these reasons, I never use the ReaderWriterLock that ships with the .NET Framework. Instead, I have created my own reader/writer lock that is super fast, favors writer threads over reader threads, and does not support recursion. My lock is called OneManyResourceLock because you can use it either to allow just one thread or to allow many threads access to a resource. The object model of my class looks like this:
public sealed class OneManyResourceLock : ResourceLock {
public IDisposable WaitToRead() { ... }
public void DoneReading() { ... }
public IDisposable WaitToWrite() { ... }
public void DoneWriting() { ... }
}
I've written many reader/writer locks and all of them exhibited the convoy problem I discussed in my March 2006 column. I decided to fix this in my new OneManyResourceLock. Implementing my new version turned out to be fairly complicated. To give you a sense of my new lock, I created some state diagrams showing how WaitToWrite (Figure 1), WaitToRead (Figure 2), DoneWriting (Figure 3), and DoneReading (Figure 4) work.
Figure 1 WaitToWrite
To understand the state diagrams, you first need to know that the OneManyResourceLock has an Int32 field that maintains the state of the lock. An instance of the lock also holds one semaphore that waiting reader threads wait on and another that waiting writer threads wait on. The bytes of the Int32 lock state field represent different parts of the lock's state and are always manipulated using the interlocked methods I described in my first Concurrent Affairs column. The first byte represents the number of writers waiting (WW), the second represents the number of readers waiting (RW), the third represents the number of readers reading (RR), and the last byte represents the current disposition of the lock, which can be any of the following:
- Free: no thread owns the lock.
- Owned by writer (OBW): one writer thread owns the lock.
- Owned by readers (OBR): one or more reader threads owns the lock.
- Owned by readers and a writer is pending (OBRAWP): one or more reader threads own the lock but a writer thread is waiting. In this state, a new reader thread cannot own the lock.
- Reserved for writer (RFW): the lock enters this state when a writer thread is waiting, when one leaves the lock with another writer thread waiting, or when the last reader thread leaves the lock and a writer thread is waiting.
Figure 2 WaitToRead
The OneManyResourceLock class is much more complex than the Optex class described in my previous column, but it is quite useful and can greatly improve the performance and scalability of your application code.
Figure 3 DoneWriting
Notice that the WaitToRead and WaitToWrite methods have a return type of IDisposable. I did this to offer the convenience of calling these methods with a C# using statement. In other words, you can write code like this:
public void ModifyResource() {
using (m_OneManyResourceLock.WaitToWrite()) {
// The code to modify the resource goes here
}
}
Figure 4 DoneReading
and the C# compiler will compile the code as if you had written code like this:
public void ModifyResource() {
IDisposable temp = m_OneManyResourceLock.WaitToWrite();
try {
// The code to modify the resource goes here
}
finally {
if (temp != null) temp.Dispose();
}
}
When WaitToWrite (or WaitToRead) is called, a reference to an object that implements IDisposable is returned. When Dispose is called on this object, the Dispose method internally calls DoneWriting (or DoneReading). This makes it very convenient to use any of the ResourceLock-derived types in your code. Note that I create just one Disposable object for any given lock instance; I don't create a new IDisposable object every time WaitToRead or WaitToWrite is called, thereby improving performance and decreasing memory consumption.
It would be pretty easy to add additional features to the OneManyResourceLock class. For example, you could add recursion so that an owning thread could acquire the lock multiple times. You could add TryWaitToRead/TryWaitToWrite methods that take timeout values rather than having the calling threads wait infinitely to gain access to the resource. You could change the policy of the lock to release any waiting readers when a writer releases the lock. In fact, you could record the time of the longest waiting reader and use this information to make the policy decision very intelligent. Of course, you could modify the WaitToRead/WaitToWrite methods so that they spin in user mode a few times before transitioning to kernel mode to wait on the semaphore.
In addition, if you know that the work performed by the threads once they own the lock is very short and of a finite duration, you could even create a reader/writer lock that doesn't need semaphores and never transitions into kernel mode. It spins entirely in user mode until the lock can be obtained by the calling thread. I have implemented a lock like this that I call the OneManySpinResourceLock. Be careful when you use it though, because calling threads never relinquish the CPU, so if there is lengthy contention, you can waste a lot of CPU time.
My ResourceLock Library
By now, you're aware of the various locks that ship with the .NET Framework that can be used to allow threads to access a shared resource in a thread-safe way: Monitor, ReaderWriterLock, Mutex, Semaphore, and EventWaitHandle. I've introduced a few others: SpinLock, Optex, OneManyResourceLock, and OneManySpinResourceLock. So now, the question is, with all these thread synchronization locks available, how do you know which is best for any given situation? The answer is that you probably don't know. It fact, it really depends on how your application is being used in the field. It could be that one customer typically writes to the data and only infrequently reads the data. Another customer might write to the data initially and subsequently considers the data read-only.
Is the resource generally accessed for a long or short period of time? Does the machine running your application have one CPU or more than one? Are the CPUs hyper-threaded? Are they multicore? All of these variables have some impact on your application and should influence the exact lock implementation you use in your code. But, how can you code your application so the thread synchronization locks are selectable at run time? Well, I have a proposal—and an implementation.
I have defined an abstract base class, called ResourceLock. This class has a bunch of virtual methods: WaitToRead, DoneReading, WaitToWrite, and DoneWriting. It offers Close and Dispose methods as well. ResourceLock is the base of many concrete classes I've also defined. The table in Figure 5 shows the set of classes (sorted alphabetically) I've derived from my ResourceLock base class.
Each type represents an existing thread synchronization lock you should already be familiar with. But, the class wraps the locks so they all offer the same reader/writer programming interface. You'll find the code for all of these classes in my PowerThreading library, which can be downloaded from my Web site (see my biographical information at the end of this column).
When your application starts up, you construct an instance of the ResourceLock-derived type you desire and assign the object's reference to a variable of type ResourceLock (the abstract base class). In your source code, always use reader/writer semantics and call the WaitToRead, DoneReading, WaitToWrite, DoneWriting methods using the ResourceLock variable. Note that the table shows the different capabilities of each lock.
- A lock with a
in the Exclusive column identifies a lock that allows only one thread at a time to access the resource. So, even if two threads call WaitToRead, the lock will only let one thread at a time access the resource.
- A lock with a
in the Recursion column identifies a lock that allows a thread to own the lock multiple times. The thread must release the lock the same number of times before it completely releases the lock.
- A lock with a
in the Spin column identifies a lock that spins the calling thread in user mode while it waits to own the lock. Calling threads never transition to kernel mode. Locks that spin should be used with great caution and only when the guarded work performed by the thread is known to be of very short duration. You also need to make sure that all waiting threads are of the same priority and that priority boosting is disabled for these threads by calling the Win32® SetProcessPriorityBoost or SetThreadPriorityBoost functions.
It's clear from the table that some permutations of Exclusive, Recursion, and Spin do not have corresponding locks. While it would be possible to create such locks, I haven't found them necessary in my own work so I haven't gone to the trouble. If you're doing any cross-AppDomain or cross-process synchronization, though, you really need a lock that is a wrapper around a kernel object such as a mutex or semaphore. My MutexResourceLock and SemaphoreResourceLock classes don't offer constructors that let you set a string name for the underlying mutex and semaphore objects, but modifying the code to support this should be trivial.
There's one more thing I should point out: the actual class you construct when your application starts can be determined dynamically. For example, when your application starts up, it could determine how many CPUs are in the machine and whether those CPUs are hyper-threaded or multicore and then use this information to decide which specific ResourceLock-derived class to construct. You could also have an application setting in an XML file or equivalent that you use to determine what class to create, which would let you try different locks without modifying and recompiling the source code. In addition, you could instrument your application's code and monitor the behavior the application exhibits when deployed in the field. Based on the results, the application could choose a specific lock for its next run, allowing your application to fine-tune itself to each customer!
Just for fun, I decided to run some performance tests on these different locks. My test application had just one thread that first called WaitToRead/DoneReading two million times and then called WaitToWrite/DoneWriting two million times. In these tests there was no contention for the lock because only one thread was accessing the lock object. Then I ran the test again, this time with four threads calling both sets of methods two million times. I used a 2.4GHz AMD Athlon 64 X2 dual-core CPU system for the tests. The results in Figure 6 should give you a feel for the performance variations of the locks. Obviously, the results will be different on differently configured machines.
The rows are sorted using the 1-Thread Reading time, from best performing (NullResourceLock) to worst performing (SemaphoreResourceLock). Notice that, for each lock, the 1-Thread times for reading and writing are about the same. For example, OptexResourceLock shows 0.105 seconds for reading and 0.102 seconds writing. This is expected because, without contention, there isn't much difference between a thread acquiring/releasing a reader lock versus acquiring/releasing a writer lock.
In the 4-Thread Reading and Writing columns, you'll notice that the time to read versus time to write is also about the same for all the exclusive locks. For example, the MonitorResourceLock shows 0.503 seconds for reading and 0.496 seconds for writing; the 0.007 second difference here is obviously just noise. The nearly identical times are also expected because these locks let only one thread at a time own the lock.
However, notice the disparity between reading and writing using four threads for a nonexclusive lock. For example, the ReaderWriterResourceLock (see Figure 7) shows 1.782 seconds for reading and 2.187 seconds for writing; writing took more than 20 percent longer than reading. That's not surprising. Since multiple threads can access the resource simultaneously for reading whereas only one thread at a time can access the resource for writing, we'd expect the reading time to be substantially less, proving that the lock is doing what it is supposed to be doing.
Finally, my code also includes an IResourceLock interface, which is implemented by my ResourceLock abstract base class. The interface lets you define a type and make that type follow the same reader/write lock programming pattern discussed here. Suppose, for example, you have a CustomerOrder class that implements my IResourceLock interface. Now you can use reader/writer semantics to access an instance of the CustomerOrder class. When you define your CustomerOrder class, most likely you would have a private ResourceLock field that will be initialized to refer to one of the ResourceLock-derived types. Of course, your CustomerOrder class would then have to implement the IResourceLock methods and, internally, you'd have each of the methods delegate to the corresponding method. Here is an example:
public sealed class CustomerOrder : IResourceLock {
private ResourceLock m_rl = new OneManyResourceLock();
public IDisposable WaitToRead() { return m_rl.WaitToRead(); }
public void DoneReading() { m_rl.DoneReading(); }
public IDisposable WaitToWrite() { return m_rl.WaitToWrite(); }
public void DoneWriting() { m_rl.DoneWriting(); }
...
}
I've been using my library now for several years with great results. I love the fact that it lets me separate the kind of locking I need to do from the exact lock I decide to use. Now, when I write code, I always think about reader/writer locking for a shared resource. After seeing how the code performs, I decide exactly which lock to use. In fact, I can even use the NullResourceLock to turn off locking altogether if I determine it's not necessary to have concurrent access to a resource.
Send your questions and comments for Jeffrey to mmsync@microsoft.com.
Jeffrey Richter is a cofounder of Wintellect (www.Wintellect.com), a training and consulting firm. He is the author of several books, including Applied Microsoft .NET Framework Programming (Microsoft Press, 2002). Jeffrey is also a contributing editor to MSDN Magazine and has been consulting with Microsoft since 1990.
posted @ 2007-04-29 19:32 彭帅 阅读(184) 评论(0)
编辑
下载本文中所用的代码:CLRInsideOut2006_09.exe (151KB)
本页内容
近来在并发操作问题上引发了许多讨论。主要原因是多数硬件供应商计划向客户端和服务器端的计算机上添加更多的处理器内核,还因为针对此类硬件,目前的软件尚未做好相关准备。许多文章关注的是如何在代码中保证并发操作的安全性,但并没有首先讨论如何将并发操作融入代码中。
这两项任务都很重要,但也可能由于各种原因而难以实现。随意创建新线程并在整个代码库中随意调用 ThreadPool.QueueUserWorkItem 并不会产生好的结果。您需要采取更具结构性的方法。首先,让我们简单估量一下情况。
在上世纪 90 年代,并行性已逐渐成为新一代处理器体系结构中软件可伸缩性的一个幕后支持。尽管我们多数人甚至不必意识到它的存在,或甚至以不同方式编写代码来利用它,但并行性仍然在发挥着它的用处。指令级并行性 (ILP) 技术是现有序列编程模型的基础,以单个指令流的粒度执行,并采用分支预测、推测和数据流乱序执行。例如,采用管道技术可使性能提高 25% 到 30%,具体视管道深度及工作负荷而定。如果将此类技术与提高的时钟速度相结合,可确保软件会随着每一代硬件持续提高运行速度,而同时将软件的额外工作量降至最低。
尽管芯片供应商仍期望摩尔定律继续有效(每 18 个月左右就使处理器中的晶体管数量翻倍),但这些晶体管对工程师的用处已开始发生转变。以过去的速率提高新芯片的时钟速度是完全不可能的,主要是因为发热量过高。但是,可以利用增加的晶体管在芯片上放置更多的低滞后时间内存和更多内核(如果软件支持多核)。请注意限定条件。当今多数软件都基于单线程设计开发,如果要利用这些额外内核,就需要改变这一点。
从某种意义上讲,要使软件在与下一代硬件结合时提高运行速度,很大一部分责任已从硬件方面转移到软件方面。这意味着在中长期内,如果要使代码自动提高运行速度,就必须开始考虑以不同方式构架和实现任务。本文将全面探讨这些体系结构问题,旨在引导您深入了解这个新领域。从长远看,很有希望出现新的编程模型,以解决您将遇到的许多难题。
硬件线程概述
能够运行 Windows® 的对称式多处理器 (SMP) 计算机已投放市场多年,尽管通常只有服务器端和高端工作站才使用此类计算机。这种计算机包含一个或多个主板,每个主板通常有多个插槽,每个插槽都插入一个完整的 CUP。在这方面,每个 CUP 都有其自己的 on-die 高速缓存、中断控制器、易失状态(寄存器)和一个带有其自己的执行单元的处理器内核。Windows 调度程序将各个软件线程映射到各个 CPU,在这种情况下,各 CPU 完全独立。从硬件线程的意义来说,我将这些 CPU 称为单线程 CPU。因为各单元相对隔离(共享内存体系结构除外,我马上就会就此进行讨论),如果提供了足够的软件线程以供执行,则可以提高添加到计算机的每个新 CPU 的执行吞吐量。
Intel 针对 Pentium 4 处理器系列采用了超线程 (HT) 技术。HT 将额外一组中断控制器和易失状态打包到某单个插槽中的单个物理 CPU 上,使多个软件线程可在各自的逻辑处理器上并行执行,尽管它们共享同一组执行单元。此方法类似于超型计算机公司(如 Tera)早期采用的方法。由于与访问内存相关的滞后时间以及其他因素,两个逻辑 CPU 线程之间的指令可频繁交错,从而导致并行加速。从这种意义上讲,启用 HT 的 CPU 是双线程的,因为 Windows 调度程序可将两个可运行线程同时映射到一个 HT 处理器上。事实上,HT 适合用于某些工作负荷,并因可使实际环境中的程序性能提高 15% 到 40% 而受到赞誉。
多核技术已随时可供客户端和服务器端计算机以同样方式使用,它在单个芯片上复制每个 CPU 的体系结构,从而使单个插槽可包含多个完整 CPU。双核芯片(一个芯片上两个内核)目前也成为现实,而且 4 核、8 核等更高级芯片也离我们不远了。不同于 HT,双核 CPU 具有单独的执行单元,因此通常可实现更明显的并行加速。与单个 CPU 很类似,除了共享的内存体系结构之外,每个内核在逻辑上都截然不同。这意味着两倍的内核数可以使吞吐量倍增。从这种意义上讲,内核数就是可同时运行的线程数。当然,这些技术并不互相排斥。一个 4 插槽、4 内核 的 HT 计算机相当于 32 个硬件线程。那是相当大的马力了。
内存体系结构
内存交互作用通常是决定现代软件性能的一个实质性因素。典型的计算机包含一个相当复杂的内存系统,由处理器与实际的 DRAM 存储体之间的多级高速缓存组成。SMP 计算机传统上使用一致的层次结构设计,但现已出现更特殊的体系结构,并会随着大规模并行计算机可用性的提高而更为普及。此类特殊结构的一个代表就是“非一致存储访问结构 (NUMA)”,在该结构中多个主内存专用于多个 CPU 的节点。尽管可以实现跨节点通信,但成本极其昂贵。Windows 的各个部分及 CLR 要改变策略才能实现 NUMA。通常智能并行代码也必须改变策略。
可缓存性并发软件通过智能方式有效使用内存,利用局部性减少某一特定计算所需的周期总数。局部性主要分为两大类。第一类为空间局部性:内存中紧靠一起的数据在程序的运算中也将紧靠一起使用。虽然更大的高速缓存线意味着最后可能会将不必要的数据放入高速缓存,但具有良好空间局部性的程序会通过接着访问已放入高速缓存的其他地址来充分利用这一点。例如,CLR 垃圾收集器通过连续进行分配来最大限度利用空间局部性。
时间局部性指的是存储内容由于某种原因而保留在高速缓存中的概念:如果此存储内容最近被访问,您可预期它可能很快会被再次访问。现代高速缓存使用的是清除策略,该策略充分利用了伪 LRU(最近最少使用)技术。
编写严谨的并发软件甚至可以观察到通过在高速缓存中保留更多数据并与其他线程共享更少数据而产生的超线性加速。也就是说,在装有 n 个 CPU 的计算机上,软件的运行速度可能比在装有单个 CPU 的计算机上运行时快 n 倍。从另一方面来讲,“高速缓存缺失”的代价相当昂贵。这一点可通过图 1 中对高速缓存访问费用的相对比较来进一步说明。根据所有的经验法则,可对这些数字持保留态度,而更关注一下差值阶。
图 1 访问成本比较对数图
并行软件特别需要注意局部性。在相交高速缓存线上不断更新数据的两个线程可引起高速缓存线的“ping-pong(乒乓)”效应,其中处理器要花费格外多的时间获取对某一高速缓存线的独占访问权限,这涉及到作废其他处理器的副本。一些高速缓存线交互作用是显而易见的,因为在应用程序级存在真正的数据共享。而另一些交互作用就不那么明显,它们由内存中紧靠一起的数据产生,很遗憾,仅仅通过检查算法很难以确定这种交互作用。
与此类似,线程迁移(以后将详细讨论)可能会使某线程移至另一个处理器上,并必须随后获得其在原始处理器上曾经拥有的所有高速缓存线并使它们失效。对于需要迁移的每一次缓存线访问,此类高速缓存迁移的成本可能为一次 on-die 高速缓存命中成本的 50 倍左右。在 NUMA 计算机上,这可能会由于节点间的通信成本而造成严重损失,尽管可通过明智地利用处理器相似性而在部分程度上避免迁移问题也是如此。在编写高度并行的代码时要对这些成本做到心中有数。例如,通过 Windows Vista™ 中新的 GetLogicalProcessorInformation API 可查询有关计算机体系结构的信息(包括高速缓存布局及 NUMA 信息),在调度期间可动态使用这些信息。
工作单元
要使您的软件并行执行,无疑您需要以某种方式将以您的算法编码的问题划分为子问题:也就是我将称为任务的更小的工作单元。任务将接受一些输入并生成一些输出,无论输出是一段数据还是一个操作。任务可以孤立执行,然而它可能对状态或排序有着微秒的依赖性,这种依赖性起初可能不是很明显。
您也许会说,函数几乎已经做到了这一点。但是不同于在您编写代码时静态定义的普通函数,要编写在给定了任意数量的 CPU 时可伸缩的软件,就必须经常动态发现任务的界限。或者,至少必须将任务呈现给智能体系结构,通过其确定并行执行任务是否有益。此外,要使任务并行执行,您的代码必须以某种方式排列以使任务被并行调用,而不只是仅仅在当前线程上按顺序调用。在 Windows 上,这通常意味着在一个单独的 OS 线程上运行。在 CLR 上,这意味着可能对要在 ThreadPool 上执行的工作进行排队。
Windows 上的物理执行单元是一个线程。每个进程都以一个单线程为开头,当然在该进程中运行的代码可随意引入其他线程,并随后在认为适当的时候终止它们。Windows 调度程序负责将线程分配到各硬件线程并允许代码执行。如果线程数多于现有的硬件线程数,则调度程序的处理方式要复杂一些;它选取具有最高优先级的可运行线程(根据智能化防资源不足算法),然后让其一直执行到某一时间片到期为止。时间片到期后,发生上下文切换,然后同一调度算法选择要执行的下一线程。时间片的长度因 OS 类型和配置而异,但通常对客户端平台为 20ms 左右,对服务器端平台则为 120ms 左右。线程可能会由于执行 I/O、尝试获取被争用的锁等原因而阻塞。在这种情况下,正如上下文切换一样,调度程序将选取一个新线程来执行。
如先前所述,将尽可能多的数据驻留在高速缓存中对传统 SMP 系统的性能是至关重要的。这种意义上的“数据”指的是要执行的代码、由线程算法操纵的堆积数据以及线程堆栈。当线程切换进和切换出 CPU 时,Windows 会自动利用所谓理想的处理器相似性来试图将高速缓存效率提至最高。例如,在 CPU 1 上运行的某线程在使上下文切换出之后,会优选再次在 CPU 1 上运行,目的是希望它的一些数据仍可以驻留在高速缓存中。但如果 CPU 1 已被占用而 CPU 2 却空闲,则可能会将该线程改为调度到 CPU 2 上,同时也会产生所有隐含的高速缓存负面效应。
了解成本
线程不是免费的午餐。它们会产生 CPU 及内存成本,这一点您应铭记在心。如果您的目标是利用并发操作来提高算法的可伸缩性,大概您还要花费同样多(甚至更多)的时间来进行传统的性能剖析工作。并行运行结构松散的算法不会产生任何结果,只会使其用尽更多的系统资源。要充分利用并行伸缩性,确保代码最重要的热数据部分在序列情况下尽可能高效是至关重要的。
要确定您可以承担的成本,有一些通用的经验法则可以遵循。创建一个 Windows 线程的成本约为 200,000 个周期,而终止一个 Windows 线程的成本约为 100,000 个周期。那么,此时您知道了如果要创建一个新线程以执行 100,000 个周期的工作,将要付出巨大的额外开销,而且,如果我必须猜测的话,您也不会观察到任何类型的加速。
内存额外开销因配置而异。但多数受管理线程将保留 1MB 的用户堆栈空间并将调拨全部数量的空间,这意味着必须在实际 RAM 或页面文件中物理备份内存中的数据。还需要一个小型的内核堆栈页面集,在 32 位系统上为三页,在 64 位系统上为六页。其他数据结构使用另外的 10-20KB 虚拟内存,但相对于堆栈所需的内存而言,这是微不足道的。GUI 线程的成本还要略高一些,因为它们必须建立额外的数据结构,如消息队列。
现在,如果您最终创建过多的优先级相等的线程,则必须经常进行上下文切换。进行一次上下文切换的成本是 2,000–8,000 个周期(具体视系统负荷和体系结构而定),其中还要涉及保存当前易失状态、选择要运行的下一线程以及恢复下一线程的易失状态。这听起来好像没有花费太多成本,尤其与时间片的持续时间和随后高速缓存缺失的成本相比更是如此,但它表示从应用程序代码执行过程中减去的纯额外开销。
假定您要使引入和终止新 OS 线程的成本以及意外引入“过多”工作而产生的负面后果降至最低,则应考虑使用 CLR 的线程池。它将智能化线程注入和引退算法隐藏在一个简单接口之下,在整个程序生命周期内分摊创建和终止线程的成本。使用 ThreadPool 类很简单。
尽管如此,使用 ThreadPool 仍要花费一些成本。调用 QueueUserWorkItem 会对调用方产生连续成本,并且从待处理工作项目队列中分派工作的基础设施也会为正在执行的并行工作带来额外开销。对于大粒度的并行性,这些成本微不足道,以至于您可能不会注意到它们。但对于粒度极其精细的并行性,这些成本可能会成为明显的可伸缩性瓶颈。您可能会考虑从无锁数据结构构建自己的轻型 ThreadPool,以避免由一般用途的 ThreadPool 导致的一些成本,如确保 AppDomains 间的公平性、捕捉和恢复安全性信息等等。但对多数使用情况来说,可在任务中使用普通的 ThreadPool。
定义界限
确定如何拆分工作并不是一项无足轻重的活动。当处理受 CPU 限制的工作负荷时,该工作更多集中于避免产生与并发执行相关的性能开销。但多数工作负荷不受 CPU 限制,它们结合了各种形式的 I/O 以及 CPU 工作间的同步,其中任意一项都可能导致不可预测的阻塞模式。因此,对于多数代码,与其说并发执行与低级性能相关,还不如说它涉及的是如何巧妙安排复杂的协调模式。
也许分割工作的最简单方法是使用并发操作的服务器模型。在 SQL Server™ 或 ASP.NET 之类的服务器中,每个外来请求都被视为一个独立任务并因此在其自己的线程上运行。宿主软件通常会限制使用的实际 OS 线程数,以免过多引入并发操作。多数此类工作负荷都由访问数据和资源的不相交集的完全独立的任务组成,从而产生高效率的并行加速。但对于客户端程序,很少有工作负荷会完全适合该模型。例如,可通过该模型完成对等通信的区段划分和响应,但除非预期有大量工作密集型的外来请求,否则此时可能达到的加速上限将受到很大限制。
另一个备选方法是使用更具逻辑性、更主观的“重要任务”定义在代码中划分出任意子任务,这往往更有利于客户端工作负荷。一次复杂的软件运算通常包含多个逻辑步骤,例如,这些步骤可能在程序中表示为独立的函数调用,而这些函数调用自身又包含多个步骤,以此类推。您可考虑将各函数调用表示为一个独立任务,至少对于那些足够重要的函数调用是如此。从必须考虑排序从属性的意义上来讲,这相当棘手,因为这为此想法增添了很多复杂性。多数现代命令式程序充满了无组织的循环、通过不透明指针进行的数据访问(这些不透明指针在内存中可能不紧靠一起)以及各种函数调用(其中没有任何函数能够清楚说明存在哪些从属性)。当然,还有您可能不知道的隐藏的线程相似性。因此该技术显然需要您深入了解您的代码试图解决的问题,并产生关于并行执行代码的最有效方式的一些思路,从而消除尽可能多的从属性。
一种常见的相关模式是派生/联结式并行性,其中一个主任务派生出多个子任务(这些子任务自身也可派生出子任务),接着每个主任务在一些定义明确的点与自己的子任务联结。例如,假定有一个称为派生/联结式 future 的任务级并行性模型,它作为任务单元基于函数调用来封装此模式。这可通过一些新类型 Future <T> 加以说明(可从 MSDN®Magazine 网站下载 Future <T> 的实现代码):
int a() { /* 一些工作 */ }
int b() { /* 一些工作 */ }
int c()
{
Future<int> fa = a();
Future<int> fb = b();
// 做一些工作
return fa.Value + fb.Value;
}
该代码的含义是 a 和 b 的调用可随 c 的主体并行执行,对此的决策由 Future<int> 引擎的实现来做出。当 c 需要这些调用的结果时,它会访问 future 的 Value 属性。这所产生的结果是:等待工作完成;或者,如果工作还未开始异步执行,则在调用线程上本地执行函数。该语法与现有 IAsyncResult 类很相似,但多出了一个优点,就是在有关将多少并发操作引入程序方面更加智能化。尽管很容易就可以设想出更多智能化的实现方法,但此代码的直接译文可能如下所示:
int a() { /* 一些工作 */ }
int b() { /* 一些工作 */ }
delegate int Del();
int c()
{
Del da = a; IAsyncResult fa = da.BeginInvoke(null, null);
Del db = b; IAsyncResult fb = db.BeginInvoke(null, null);
// 做一些工作
return da.EndInvoke(fa) + db.EndInvoke(fb);
}
还可以采用其他方法,如使用运行时间更长的子任务,而不是要求子任务的生命周期绝不能超过父任务。这通常需要更复杂的同步和会合模式。派生/联结模式很简单,因为单个工作单元的生命周期显而易见。
以上围绕代码对并行性进行了讨论。另一项技术通常更简单:数据并行性。该项技术通常适用于数据和计算密集型的问题和数据结构,或其各运算往往要频繁访问不相交数据的问题和数据结构。
一种常见的数据并行性技术称为分区。例如,基于循环的并行性使用此方法将计算分布于一系列元素。假定您有 16 个逻辑 CPU、一个包含 100,000 个元素的数组和一段以微乎其微的从属性执行并往往阻塞 20% 的时间的工作。您可以将数组分割为各有 5,000 个元素的 20 个连续块(稍后我将说明如何计算出该数字),派生出 19 个线程(重复使用一个分区的当前线程),并安排各线程并行执行各自的计算。数据库(如 SQL Server)中的并行查询处理使用的是类似方法。此技术在图 2 中进行说明。
图 2 基于分区的并行性
该例显示了一个分布于四个线程上的由 100,000 个元素组成的数组。您会留意到,为进行数组分割连续支付了一定的额外开销。在需要合并时,经常要为合并结果支付附加成本,包括连接待处理线程。
For-all 循环通常是以编程语言表示基于分区的并行性的一种传统方式。图 3 中显示了 ForAll<T> API 实现的示例。也可使用类似方法将循环并行化 — 例如,可以不采用 IList<T>,而改为采用 int from 和 int to 参数集,然后将循环迭代数馈入 Action<int> 委托。
此代码做出了一个可能具有灾难性的重大假定:预期传入的 Action<T> 委托会安全地并行执行。这意味着如果它指的是共享状态,则需要使用适当的同步来消除并发操作程序错误。如果不是,则可以预期我们程序的正确性和可靠性都相当差。
另一个数据并行性技术是管道操作,其中多个运算并行执行,使用一个快速的共享缓冲区来相互输送数据。这类似于装配线,其中流程中的每个步骤都有机会与一些数据交互,然后将其传递给装配线中的下一步骤。此技术需要巧妙的同步代码以尽量缩短花费在明显瓶颈处的时间:在瓶颈处,管道中的相邻阶段通过一个共享缓冲区进行通信。
多少任务?
选择要创建的任务数也是一个棘手的因素。如果吞吐量是唯一的优先考虑因素,则可以使用如下所示的一些理论目标,其中 BP 是任务将阻塞的时间百分比:
NumThreads = NumCPUs / (1 – BP)
也就是说,线程数最好等于 CPU 数与任务要花费在实际工作上的时间百分比的比率。这已在先前的 ForAll 示例中进行了说明。可惜的是,尽管理论上这是一个良好起点,但它不会带给您准确的答案。例如,它没有解释采用 HT 的原因(其中高内存滞后时间允许引发并行计算),但在其他方面它不应该是使用完整处理器的原因。而且它相当天真地假定您实际可以预测 BP 值,这一点我可以保证是相当困难的,特别是对于试图调度异类工作的组件,这非常像 CLR 的线程池。如果有疑虑,最好依靠线程池将任务调度给 OS 线程,并倾向于过度表示并发操作。
任何算法都有一个自带的加速曲线。关于这条曲线,有两点特别重要的问题要考虑。首先,可从计算并行化获益的最少任务数是多少?对于小型计算,情况可能会是这样:使用少量任务会导致过多的额外开销(线程创建和高速缓存缺失),但使用大量任务会使执行进度赶上相继的版本并超过它。其次,假定硬件线程的数量无穷大,则在开始发现性能下降而不是持续上升之前可以分配给某问题的最多任务数是多少?所有问题都会达到这一递减返回点。随着继续细分问题,最终将达到单个指令的粒度。
线性加速意味着使用 p 个处理器执行问题花费的时间是使用一个处理器执行问题所花费时间的 1/p。Amdahl 定律往往限制了实现这种加速的能力。它相当简单地指出最大加速受到采用并行性后保持的序列执行量的限制。更正式地说,此定律指出,如果 S 是必须保持有序的问题(无法并行化)的百分比,p 是所使用 CPU 的数量,则预期的近似加速可如下表示:
1/(S + ((1 – S)/p))
随着处理器数量的增加,此表达式接近于 1/S。因此,如果只能并行化问题的(例如)85%,则只能达到 1/.85(大约 6.6)的加速。与同步化和采用并发操作相关的任何额外开销往往都成为 S 的一个因素。但是,在现实中还是有一个好消息:在多个处理器之间分配工作也具备难以量化和测度的好处,例如,使并发线程可以保持其(各自的)高速缓存随时可用。
任何管理实际资源的算法还必须考虑跨计算机使用情况。完全进行本地决策以最大化并行性的软件(特别是在 ASP.NET 之类的服务器环境中)可能会(并且总会!)导致混乱并增加对资源(包括 CPU)的争用。例如,ForAll 式循环在动态决定最佳任务数之前可能会查询处理器使用情况。可考虑使用图 4 中所示的 GetFreeProcessors 函数,而取代图 3 中使用的依赖于 System.Environment.ProcessorCount 属性的算法。
该算法并非十全十美。它只是在其运行时的计算机状态的一个统计快照,而并未表明在其返回结果之后出现的任何情况。它可能过度乐观或过度悲观。并且它当然不会解释这样的事实,被查询的某一处理器就是执行 GetFreeProcessors 函数的处理器本身,这会是一项有帮助的改进。另一个要考虑的值得关注的统计度量标准是系统\处理器队列长度性能计数器,它可以告诉您在调度队列中有多少线程正在等待空闲处理器。如果结果是一个很大的数字,则表示引入的新工作只能等待队列清空(假定所有线程都具有同等优先级)。
存在一些重要理由来创建过多而不是过少的并发操作。如果正在考虑异类任务,则让每个任务在某一线程上一直执行到完成的模型会带来公平性问题。如果不释放另外的资源,则运行时间远多于任务 B 的任务 A 会导致任务 B 资源缺乏。如果 A 决定阻塞而您的算法没有考虑到这一点,则这种情况会更加糟糕。
有意过度并行化的另一个原因是针对异步 I/O。Windows 为实现高可伸缩性提供了 I/O 完成端口,在这种情况下,待处理的 I/O 请求甚至不需要使用 OS 线程。I/O 开始异步执行,一旦完成,Windows 即会向下层端口发布一个完成数据包。通常情况下,有效设定大小的线程池会被绑定到端口(在 CLR 上由该线程池负责此端口),等到完成数据包一旦可用就对它们进行处理。假定完成率不足,则尽可能快地并行创建大量 I/O 请求比起让每个任务排在其他任务后面等待轮流启动异步 I/O 会实现更好的可伸缩性。这适用于文件、网络和内存映射 I/O,但应始终认识到这一事实,计算机上共享资源的数量有限,过度争用这些资源只会降低而不是增强伸缩性。
共享状态
无论何时采用并发操作,都需要考虑保护共享状态。这一点至关重要。要想了解锁定为何如此重要,建议您阅读 MSDN®Magazine 2005 年 8 月刊中 Vance Morrison 的文章(英文)(msdn.microsoft.com/msdnmag/issues/05/08/Concurrency)。正确性应始终优先于性能,如果您使用并发操作而不考虑锁定,则您的代码很有可能不正确。我不打算重申 Vance 已经表述得很明确的内容,而是想把重点集中于这种技术的性能上。
最常见的同步技术是锁定和低位锁操作。锁定使用 Win32® Mutex 或 CRITICAL_SECTION 之类的原语,或 CLR Monitor、ReaderWriterLock,或相关的语言关键字(例如 C# 中的 lock 和 Visual Basic® 中的 SyncLock)来实现某种程度的相互排斥。要实现这种排斥,需对 API 进行调用;一些内部算法确保了两个使用同一个锁的代码段不可进入受保护的代码区。只要每个人都遵守此协议,代码就会一直保持安全。
低位锁操作可使用联锁原语生成,后者通过对“加载-比较-存储”原子指令的硬件支持来实现。它们确保了内存的单个更新为原子级更新,并可用于生成使用优化并发操作的高度可伸缩代码。这样的代码更难以编写,但其往往不会阻塞。(如果您对此感到疑惑,可以告诉您,锁就是使用此类原语编写而成。)
但进行这些调用会产生成本。图 5 说明了在不争用资源的情况下,获得各种类型锁的成本的微基准(以 CPU 周期为单位)。
图 5 比较各种锁的成本
尽管这样的度量对于理解锁定的性能本质非常重要(特别是在要做出关于并行执行的动态决策时,在这种情况下,“需要就绪”的代码量要多于将实际并行运行的代码量),但确保以正确的粒度进行同步有助于保证代码的执行不会受到此类成本的制约。还有一些我没有提到的成本,例如联锁操作和内存层级之间的交互。遗憾的是,受空间所限,不允许发生这种情况。然而,更重要的部分是对可伸缩性的影响。可惜的是,您经常需要在可伸缩性和序列直线执行性能之间进行权衡。应通过度量来获知这些权衡结果。
我们无法保证线程在持有锁时仍可运行,因此,如果其时间片到期,后续线程可能会运行并试图获取这同一个锁。此外,某一变为可运行状态的更高优先级的线程可以优先于在上锁情况下运行的线程。这会导致被称为优先级倒置的现象,如果某一被争用锁处的到达率特别高,则会导致锁保护。大多数锁通过在多 CPU 系统上以某种形式轻度旋转来回应争锁尝试,以期待持有锁的线程马上解锁。如果该方法失败(因为锁的持有方持锁时间超过预期时间,或也许因为在一次上下文切换后锁被交换出),则它会阻塞。对于高度并发的系统,阻塞量越多,则就需要越多的线程来保持 CPU 的占用状态,您的系统成功伸缩的可能性也就越低。
这样,一个时刻需要保持思考的重要问题就是:如何在持有锁的同时执行最少量的工作,以将所需的锁定量减至最低?读取方/写入方锁功能对此会有帮助,它允许多个线程读取数据,同时又仍确保写操作互斥。对于大多数系统,读取方与写入方的比率非常高,因此赢得可伸缩性的成功率极大。要了解更多信息,Jeffrey Richter 在 MSDN Magazine 的 2006 年 6 月刊上发表的“并发事务”专栏是一个很好的起点(参见 msdn.microsoft.com/msdnmag/issues/06/06/ConcurrentAffairs)(英文)。
就已提过的内容而言,如果能首先避免共享状态,则根本就不需要将访问同步化。提高操纵热数据结构(即多数线程都必须访问的数据)的算法的可伸缩性的一个常用技术是避免将各锁一起使用。这可采取三种实现难度逐级提高的重要形式:不变性、隔离性和锁自由性。
不变性意味着某一实例一旦创建就不再改变,或者至少在一段固定的已知时间段内不再改变。例如,CLR 字符串具备不变性,因此不需要根据对其各字符的访问权限上锁。如果状态不变动,则不需要上锁。当有多个位置包含应在原子级观察的状态指针时,这就难以实现。
隔离性通过维护各自的副本避免了对数据的任何并发访问。例如,malloc 操作和 free 操作的许多线程安全的 C 实现对每个线程都维护一个可用内存池,以在线程分配时不会争用该池(该池很可能是任一 C 程序中的一个热点)。同样,CLR 的服务器垃圾收集器 (GC) 使用每个线程一个分配上下文和每个 CPU 一个内存段的方式来提高内存分配的吞吐量。这通常需要与数据结构的中心副本定期会合,并且有时可能需要产生与复制和确保重要数据位不失时效所相关的成本。
锁自由性是一种极复杂的技术,我将只对其一带而过。如果您真正理解目标计算机的内存模型并且乐于编写和维护大量代码,则可以创建在被并行访问时可成功伸缩的智能化数据结构。时常会出现这种情况,最终所得代码如此难以测试正确性和维护,以至于不值得为其花费这样的精力。对于程序中已被测量到与使用锁相关的伸缩性或性能问题的那些方面,这些技术值得探究。
用于剖析并行性的工具
让我们看看如何可以测量和提高代码的可伸缩性。在这整个专栏中,我在技术、方法和成本方面一直不太明确。可惜的是,没有一个可适用于所有并行问题的魔法公式。同样,对于如何剖析问题和/或发现更好的方法以达到并行加速的问题,也没有一个简单的答案。完全可能发生这种情况,您将经历我在这里罗列的所有工作(以及其他一些工作 — 我还未讨论调试问题),但结果却不比坚持使用序列算法的结果好。还有一些所谓的令人困惑的并行问题,对于这些问题已编写了类似食谱的算法,可通过在线方式和在课本中获取这些算法。可惜的是,许多现实中的程序并不是这样简单易懂。
以下是对于剖析并行算法的一些提示。所有这些提示利用的都是新版 Visual Studio® 2005 剖析器。它内置于普通的 Visual Studio 界面中(在工具|性能工具|新性能会话菜单项下),它还有一个命令行版本,位于 \Team Tools\PerformanceTools\ Visual Studio 子目录中,名为 VSPerfCmd.exe(有关此工具的使用详细信息,请参见 msdn2.microsoft.com/ms182403.aspx)。此剖析器将创建 VSP 文件,这些 VSP 文件可通过 VSPerfReport.exe 命令进行管道输送以创建 CSV 或 XML 文件,以供进一步分析。以下是要查找的几个项目。
确保 CPU 被占用。 如果处理器的利用率很低,则很可能是发生了以下两种情况之一。您没有使用足够的处理器来保持问题的占用状态,或者线程被备份以互相等待(这极有可能是因代码中热点处的过度同步引起)。通常任务管理器足以应付此问题,尽管也可使用处理器\% 处理器时间性能计数器(通过 PerfMon.exe)。
确保程序不大量出错。特别是对于数据密集型的应用程序,需要确保物理内存不定期溢出。在这种情况下,一个充满线程的系统可能会在其不断输入和输出页面数据时频繁磨损磁盘。(请回想一下,前面图表所示的磁盘访问的成本是多么昂贵?)与 PerfMon.exe 一样,任务管理器可以为您提供此数据(您需要将其选为一列)。VSPerfCmd 也可使用此命令通过 ETW 事件报告此数据:
VSPerfCmd.exe /events:on,Pagefault /start:SAMPLE /output:<reportFile name> /launch:<exeFile name>
然后当程序完成时使用下列命令。
VSPerfCmd.exe /shutdown
您可能还想斟酌一下采样间隔。
确定程序在哪里花费的 CPU 时间最多。 如果在持有锁时发生此 CPU 时间,则这一点特别重要。也可能出现这种情况,即创建线程、执行同步以及与这两件事相关的任何操作所需的附加代码在支配执行时间。
检查系统\上下文切换/秒和系统\处理器队列长度性能计数器。 这有助于确定线程是否过多,以使时间浪费在上下文切换和线程迁移上。如果情况如此,则尝试调整用于确定使用多少任务的算法。
查找内存层级和高速缓存问题。 如果上述建议没有一个有效,并且似乎应看到更大的加速,则可能存在内存层级和高速缓存问题。在高速缓存缺失和失效上花费的大量时间会极大限制加速程序的能力。对数据分区可更易于高速缓存线操作,并且使用上述一些方法(例如隔离)会有助于解决此问题。每个 CPU 都提供一组性能计数器,可在 Visual Studio 的剖析器中对其查询,包括已引退指令和缺失的高速缓存之类的信息。如果已引退指令的计数很低,则表明有更多时间花费在高滞后时间操作上(例如高速缓存缺失),并且可使用高速缓存特定的计数器来确定发生缺失的位置以及缺失频率。
尽管确切的计数器特定于处理器,但 Visual Studio 界面为您提供了一个方便的选项来使用它们(参见图 6),也可以通过下列命令查询这些计数器:
VSPerfCmd.exe /QueryCounters
图 6 剖析器属性
结论
从历史角度说,通过并行性实现可伸缩性仅限于服务器端和高端工作站环境。但随着新硬件向线程级并行性(即多核体系结构)发展的趋势,主流客户端软件最终将不得不应付和有效使用可用资源。随之将产生一系列独特的难题。并行性无疑不会替代高效的序列代码,但它的确是提高已优化序列算法的运行速度的一项技术。
这是一个前卫、高层次的看法。您可能会走开,说这条路太难走,您并没有错。但随着时间推移,新一代的多核处理器将越来越普及,这些技术会帮助您构建可在这些多核处理器上保持良好伸缩性的代码。在 CLR 线程池之类的基础结构和 Visual Studio 之类的工具演化到可更好支持这种形式的编程时,您就可以期待许多困难可以逐渐迎刃而解。
posted @ 2007-04-29 17:21 彭帅 阅读(107) 评论(0)
编辑