Intel64及IA-32架构优化指南第8章多核与超线程技术——8.4 线程同步
8.4 线程同步
为了确保正确的操作,具有多个线程的应用程序使用同步技术。然而,非适当实现的线程同步会严重地减少性能。
减少线程同步负荷的最佳实践是通过减少应用程序对同步的需求开始。Intel Thread Profiler可以用来剖析每个线程的时间轴并探测性能受同步负荷的频繁发生所影响的位置。
有几个编程技术和操作系统(OS)调用常用于线程同步。这些包括旋转等待循环、旋锁、临界区等。为特定情景选择最佳的OS调用并在大多情况下用并行来实现同步代码,对于最小化线程同步的处理成本而言是很重要的。
SSE3提供了两条指令(MONITOR/MWAIT)来帮助多线程软件在多个代理之间提升同步性能。在MONITOR和MWAIT的第一种实现中,这些指令对操作系统可用,这样操作系统可以在不同领域优化线程同步。比如,操作系统可以在其系统闲置循环(被称为C0循环)中使用MONITOR和MWAIT来减少电能消耗。操作系统也可以使用MONITOR和MWAIT来实现其C1循环来提升C1循环的快速响应性能。
8.4.1 同步原语的选择
线程同步常常涉及修改某些共享的数据,而同时使用同步原语保护来保护此操作。有许多原语可选。当选择同步原语时,有用的准则有:
● 赞成使用编译器内建函数或一个OS提供的互锁API用于单个数据操作的原子更新,诸如递增以及比较/交换。这将会比其它更复杂的,具有更高负荷的同步原语来得更高效。
● 当在不同的原语之间进行选择来实现一个同步构造时,使用Intel Thread Checker以及Thread Profiler在处理多线程函数正确性问题以及在多线程执行下的性能影响会非常有用。关于Intel Thread Checker以及Thread profiler的性能的额外信息在附录A中描述。
8.4.2 短周期的同步
一个线程与其它线程同步所需要的频率和时间依赖于应用特征。当一个同步循环需要非常快的响应时,应用程序可以使用一个旋转等待循环。
当一个线程需要为另一个线程到达一个同步点而等待一个比较短的时间时,一般使用旋转等待循环。一个旋转等待循环由一个循环组成,该循环中用某个预先定义的值与一个同步变量进行比较。见例8-4(a)。
在具有一个超标量投机执行引擎的一个现代化微处理器中,像这样的一个循环导致了多个同时从旋转线程读请求的问题。这些请求往往无序执行正分配一个缓存资源的每个读请求。当一个工作者线程探测到一次写要写到正在处理的加载的位置时,处理器必须保证不发生违背存储器次序的情况。维护未解决的存储器操作次序的必要性不可避免地会花费处理器一个严重的处罚,影响所有的线程。
这个处罚在奔腾M处理器、Intel Core Solo以及Intel Core Duo处理器上发生。然而,在这些处理器上的处罚与奔腾4和Intel Xeon处理器比起来要小。在奔腾4和Intel Xeon上退出循环的性能处罚要严重大约25倍。
在支持HT技术的一个处理器上,旋转等待循环会消耗处理器相当一部分执行带宽。执行一个旋转等待循环的一个逻辑处理器会严重影响另一逻辑处理器的性能。
例8-4:旋转等待循环以及PAUSE指令
// a)一个未被优化的旋转等待循环,在退出该循环时会遭遇性能处罚。它光光消耗执行资源而不会贡献计算工作 do { // 这个循环比起存储器访问的速度运行更快, // 其它工作者线程不能结束对sync_var的修改,直到从旋转循环的未决加载被解决 }while(sync_var != constant_value); // b)在快速旋转等待循环中插入PAUSE指令来防止对旋转线程和工作者线程的性能处罚 do { _asm pause // 确保这个循环是解流水化的,即防止有超过1个的对sync_var的加载请求未解决, // 避免当工作者线程更新sync_var以及旋转线程退出循环时的性能处罚 } while(sync_var != constant_value);
; c)使用一个"test, test-and-set"技术来确定同步变量的可用性的一个旋转等待循环。 ; 这个技术在写运行在Intel 64以及IA-32架构处理器上的旋转等待循环时推荐使用。 Spin_Lock: CMP lockvar, 0 ; 检查lock是否空闲 JE Get_lock PAUSE ; 短延迟 JMP Spin_Lock Get_Lock: MOV EAX, 1 XCHG EAX, lockvar ; 设法获得锁 CMP EAX, 0; ; 测试是否成功 JNE Spin_Lock Critical_Section: <临界区代码> MOV lockvar, 0 ; 释放锁
用户/源代码编写规则20:在快速旋转循环中插入PAUSE指令,并保持循环重复的次数为最小,以提升整体的系统性能。
在使用Intel NetBurst微架构核心的处理器上,从一个旋转等待循环退出的处罚可以在循环中插入一条PAUSE指令来避免。尽管这个名字叫“暂停”,不过PAUSE指令通过在循环中引入轻微的延迟来提升性能并且有效地使得存储器读请求以一个频率来发布,这允许了立即探测任一对同步变量的存储。
在一个简化的旋转等待循环中插入PAUSE指令的一个例子在例8-4(b)中展示。该PAUSE指令与Intel 64和IA-32处理器相兼容。在Intel NetBurst微架构之前的IA-32处理器上,PAUSE指令本质上就是一个NOP指令。使用PAUSE指令来优化旋转等待循环的其它例子在应用注解AP-949中可用。
插入PAUSE指令对于在旋转等待期间减少电能消耗具有额外的利益,因为更少的系统资源被使用。
8.4.3 对旋锁优化
当若干线程需要修改一个同步变量,以及该同步变量必须受一个锁来保护以防止无意的写覆盖时,一般使用旋锁。然而,当该锁被释放时,若干线程可能会立即竞争来获取它。这样的线程竞争严重降低了由频率、独立处理器的个数以及HT技术所带来的性能增益。
为了减少性能处罚,一种方法是减少许多线程竞争获取同一个锁的可能性。应用一个软件流水化的技术来处理必须在多个线程之间共享的数据。
不要允许多个线程来竞争一个给定的锁,而是应该让不超过两个线程对一个给定的锁有写访问。如果一个应用程序必须使用旋锁,那么在等待循环中包含PAUSE指令。例8-4(c)展示了一个“测试,测试并设置”技术来判定一个旋转等待循环中锁的可用性。
用户/源代码编写规则21:用流水化的锁来代替可能需要被多个线程获取的旋锁,使得让不超过两个线程对一个锁具有写访问。如果仅有一个线程需要对一个由两个线程共享的变量进行写,那么就不需要使用一个锁。
8.4.4 更长周期的同步
当使用一个不想被很快释放的旋转等待循环时,一个应用程序应该遵守下列准则:
● 将旋转等待循环的周期保持为一个最小的重复次数。
● 应用程序应该使用一个OS服务来阻塞等待线程;这可以释放处理器使得其它可运行的线程可以利用处理器或可用的执行资源。
在支持HT技术的处理器上,操作系统应该使用HLT指令,如果逻辑处理器是活动的并且另一个逻辑处理器不处于活动状态。HLT将允许一个闲置的逻辑处理器切换到一个暂停状态;这允许活动的逻辑处理器使用物理包内的所有硬件资源。不使用这种技术的操作系统必须仍然在闲置的逻辑处理器上执行指令,重复地检查作业。这种“闲置循环”消耗了执行资源,而这些资源可以被另一活动的逻辑处理器来继续使用。
如果一个应用程序线程必须很长时间处于闲置状态,那么这个应用程序应该使用一个线程阻塞API或其它方法来释放此闲置的处理器。在这里所讨论的技术应用于传统的MP系统,但它们对支持HT技术的处理器具有更大的影响。
一般,一个操作系统提供了分时服务,比如Sleep(dwMilliseconds);[注:Sleep() API不是线程阻塞的,因为它并不保证处理器将被释放。例8-5(a)展示了使用Sleep(0)的一个例子,这对另一个线程而言并不总是意识到该处理器。]这种函数可以被用于防止频繁地检查一个同步变量。
另一种在工作线程与一个控制循环之间同步的技术是使用一个由OS提供的线程阻塞API。使用一个线程阻塞API允许控制线程使用更少的处理器周期来旋转和等待。这给了OS更多时间来调度这些工作线程到可用的处理器上。此外,使用一个线程阻塞的API也可以从系统闲置循环优化上获益,OS使用HLT指令来实现此API。
用户/源代码编写规则22:在一个长的闲置循环中使用一个线程阻塞的API来释放处理器。
在一个传统的MP系统中使用一个旋转等待循环,当可运行的线程的个数少于系统中处理器个数时将可能不太会是个问题。如果一个应用程序中的线程个数被指望比处理器的个数更多(要么一个处理器或是多个处理器)时,那么使用一个线程阻塞API来释放处理器资源。采用一个控制线程来同步多个工作者线程的一个多线程应用程序可以考虑将工作者线程的个数限制到一个系统中处理器的个数并在控制线程中使用线程阻塞API。
8.4.4.1 避免线程同步中的代码陷阱
在多个线程之间的同步必须小心设计和实现来实现相对于独立处理器个数以及每个物理处理器的逻辑处理器个数而言良好的性能提升。对于每种同步情景没有一个通用的解决方案。
在例8-5(a)中的伪代码例子描述了一个控制线程的轮训循环实现。如果只有一个可运行的工作者线程,那么企图调用一个分时服务API,诸如Sleep(0),可能会在最小化线程同步上是无效果的。因为控制线程仍然在表现上像是一个快速旋转的循环,唯一可运行的工作者线程必须与旋转等待循环共享执行资源,如果两者都运行在同一支持HT技术的物理处理器上的话。如果有多个可运行的工作者线程,那么调用一个线程阻塞API,诸如Sleep(0),可以平静地释放运行旋转等待循环的处理器,允许该处理器被另一个工作者线程所使用而不是该旋转循环。
一个等待工作者线程完成的控制线程往往可以使用一个线程阻塞API或一个分时服务来实现线程同步,如果工作者线程需要很长时间来完成。例8-5(b)展示了一个例子,减少了控制线程在其线程同步中的负荷。
例8-5:使用旋转等待循环的代码陷阱
// a) 一个旋转等待企图不正确地释放处理器。它遭受了一个性能处罚,如果仅有的工作者线程与控制线程运行在同一物理处理器包上的话 // 只有一个工作者线程在运行,控制循环等待工作者线程完成。 ResumeWorkThread(thread_handle); while(!task_not_done){ Sleep(0); // 立即返回到旋转循环 ... } // b) 一个轮询循环正确地释放了处理器 // 让一个工作者线程运行并等待完成 ResumeWorkThread(thread_handle); while(!task_not_done){ Sleep(FIVE_MILLISEC); // 该处理器释放一段时间,该处理器可以被其它线程所用 ... }
通常,当同步线程时,OS函数调用应该被小心使用。当使用OS支持的线程同步对象(临界区、互斥体或信号量)时,应该给OS服务最少同步负荷的优先权,诸如一个临界区。
8.4.5 防止被修改的数据的共享和错误共享
在一个Intel Core Duo处理器或一个基于Intel Core微架构的处理器上,当运行在一个核心上的线程试图读或写在其它核心的第一级Cache中当前处于被修改状态的数据时,被修改数据的共享遭致了性能处罚。这将导致被修改的Cache行逐出回存储器,并且将它读入另一个核心的L1 Cache。这样的Cache行传输的延迟比起立即在L1或L2 Cache中使用数据具有更大的延迟。
错误共享应用于由一个线程所使用的数据正巧驻留在与另一个线程所使用的不同的数据的同一个Cache行。这些情景也会遭致性能延迟,依赖于平台的逻辑处理器/核心的拓扑。
使用基于Intel NetBurst微架构处理器的多线程环境的错误共享的例子是,当线程私有的数据和一个线程同步变量在行大小边界(64个字节)或段边界(128字节)内的位置上时。当一个线程修改同步变量时,“脏的”Cache行必须被写出到存储器并对每个共享总线的物理处理器进行更新。随后,将数据取到每个目标处理器,128字节一次,致使先前被cache的数据从每个目标处理器上的其各自的Cache里逐出。
当线程运行在驻留在不同物理处理器上的逻辑处理器上时,错误共享会遭致性能处罚。对于支持HT技术的处理器而言,当两个线程运行在不同核心上、不同物理处理器上,或在同一物理处理器包中的两个逻辑处理器上时,错误共享遭致性能处罚。在前两种情况下,性能处罚是由于Cache逐出而维持Cache一致性。而在后一种情况下,性能处罚由于存储器次序而机器清除这些条件。
在一单个Intel Core处理器中,错误共享不会有性能影响。
用户/源代码编写规则23:小心在一个Cache行以及一个段内的错误共享。
当从一个父线程将一个公共的参数块传递到几个工作者线程时,对于每个工作者线程创建一份在参数块中频繁访问的数据的拷贝是值得的。
8.4.6 共享的同步变量的放置
在基于Intel NetBurst微架构的处理器上,总线读一般获取128字节到一个Cache中,最小化被cache数据逐出的最优空间是128字节。为了防止错误共享,同步变量与系统对象(诸如一个临界区)应该以一个128字节区域单独驻留的空间来分配,并且对一个128字节边界对齐。
例8-6展示了一种方法,最小化维护MP系统中的Cache一致性所需要的总线交通。这个技术也可应用到使用带有或不带HT技术的处理器的MP系统。
例8-6:同步和常规变量的放置
int regVar; int padding[32]; int SynVar[32 * NUM_SYNC_VARS]; int AnotherVar;
在奔腾M、Intel Core Solo、Intel Core Duo处理器以及基于Intel Core微架构的处理器上,一个同步变量应该被单独放置并且放在的独立的Cache行中,以避免错误共享。软件不能允许一个同步变量跨越页边界。
用户/源代码编写规则24:将每个同步变量单独放置,以128字节分隔,或在一个独立的Cache行内。
用户/源代码编写规则25:不要将任一旋锁变量放置在跨一个Cache行边界处。
在代码层,错误共享在以下情况需要特别关注:
● 被放置在同一Cache行的全局数据变量与静态数据变量,并且被不同线程所写。
● 被不同线程所动态分配的对象可以共享Cache行。确定被一个线程本地所使用的变量以防止与其它线程共享Cache行的方式来分配。
迫使同步变量的对齐并避免一个Cache行被共享的另一种技术是,在声明数据结构的时候使用编译器指示符。见例8-7。
例8-7:声明同步变量而不共享一条Cache行
__declspec(align(64)) unsigned __int64 sum; struct sync_struct { ... }; __declspec(align(64)) struct sync_struct sync_var;
其它防止错误共享的技术包括:
● 将不同类型的变量组织在数据结构中(因为编译器所给数据变量的布局可能与源代码中的它们的位置有所不同)。
● 当每个线程需要使用一组变量的其自己的拷贝时,那么用以下方式来声明变量:
——当使用OpenMP时,使用threadprivate指示符
——当使用微软编译器时,使用__declspec(thread)修饰符
● 在提供自动对象分配的托管环境中,对象分配器和垃圾收集器负责存储器中对象的布局,使得贯穿两个对象的错误共享不会发生。
● 提供类,使得仅有一个线程写到每个对象域并关闭对象域,为了避免错误共享。
不应该将本小节所讨论的建议与偏好一个稀疏构成的数据布局视为等价。当必要时,数据布局建议应该被采用,并且避免不必要的工作集大小的膨胀。