多处理器多核环境对软件设计决策的影响
1.在芯片多线程、多核、多处理器架构上软件可伸缩性的阻碍
- 效率低下的并行化
需要将应用程序组织成并行任务。线程太多可能会和线程太少一样,都不会产生好的结果。 - 串行瓶颈
在多个线程或进程之间共享数据结构的应用程序可能会有串行瓶颈。为了保持数据完整性,可能必须使用锁定和串行化技术(例如,读取锁、读写锁、写入锁、自旋锁、互斥等)将这些共享数据结构的访问串行化。设计得效率低下的锁可能会由于多个线程或进程之间的高度锁争用而导致串行瓶颈。 - 对操作系统 (OS) 或运行时环境的过度依赖
不能依赖操作系统、运行时环境或编译器来完成伸缩应用程序或软件所需的一切操作。但是,编译器和运行时环境可以帮助提供一定的优化,您不能依赖它们解决所有可伸缩性问题。 - 工作负载的不平衡可能是一个瓶颈
- I/O 瓶颈
由于阻止磁盘输入/输出 (I/O) 或高网络延迟而导致的瓶颈可能会严重抑制应用程序的可伸缩性。 - 无效的内存管理
内存带宽一直是一个瓶颈,因为所有处理器核心都贡献了一个通用的总线。无效的内存管理可能导致一些难以检测到的性能问题,比如伪共享。
2.芯片多处理器、多核、多线程系统
所有核和处理器都共享系统总线,并通过系统总线访问主要内存或 RAM。对于应用程序和操作系统,该系统看起来就像是 8 个逻辑处理器。
2.1 缓存一致性
缓存一致性是表示处理器缓存中的数据项目值与系统内存中的数据项目值相同的一种状态。该状态对于软件来说是透明的。但是,系统为了保持缓存一致性而执行的操作可能会影响软件的性能。
如果这两个线程都正在读取和写入相同的数据项,那么该系统必须执行额外的操作,以确保在进行每个读取和写入操作时,线程都看到相同的数据值。如果每个线程都进行了一系列的写入操作,那么这可能会严重影响系统的性能,因为所有时间都花费在等待更新系统内存中的数据值上。这种情况被称为“乒乓效应”,当在多处理器和多核系统上运行时,应避免发生这种情况,这是一项重要的软件设计注意事项。
2.2 嗅探 - Snooping
这是一个跟踪每个缓存行的状态的缓存子系统。该系统使用一个称为 “总线动态监视” 或者称为“总线嗅探” 的技术来监视在系统总线上发生的所有事务,以检测缓存中的某个地址上何时发生了读取或写入操作。
缓存行状态MESI:
- 如果数据由它自己的 CPU 进行了更新,那么这个缓存子系统会将缓存行的状态从 “exclusive” 更改为 “modified”。
如果该缓存子系统检测到另一个处理器对该地址的读取,它会阻止访问,更新系统内存中的数据,然后允许该处理的访问继续进行。 - 该缓存子系统想知道,当该系统在监视系统总线时,系统是否在其缓存中包含数据的惟一副本。如果数据只存于本cache,即是唯一副本,则状态为 “exclusive”
- 当这个缓存子系统在系统总线上检测到对缓存中加载的内存区域进行的读取操作时,它会将该缓存行的状态更改为 “shared”。
- 如果它检测到对该地址的写入操作时,会将缓存行的状态更改为 “invalid”。
缓存行状态MESI:
- M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
- E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
- S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
- I(无效,Invalid):缓存行失效, 不能使用。
3.多处理器多核环境对软件设计决策的影响
3.1 避免内存争用
在内存和缓存中,各种不同的核共享一个通用的数据区域,这需要在它们之间进行同步。当不同的核同时访问同一个数据区域时,会发生内存争用。在不同的核之间同步数据会因总线通信、锁定成本以及缓存缺失而有很大的性能损失。
如果应用程序有多个线程,并且所有线程都更新或修改同一个内存地址,那么正如前面部分所讨论的那样,为了保持缓存一致性,可能会产生一次重大的乒乓效应。这会导致性能降低。
避免内存争用的技巧:
-
不要在核之间共享可写入的状态
1)为了最大程度地减少内存总线通信,可以通过最小化共享位置/数据尽可能地减少核心交互,即使共享数据没有锁保护,而有一些硬件级别原子指令
2)减少线程之间的内存争用的一个方法是从多个线程中消除对共享内存区域的更新。例如,即便是在多个线程需要更新全局计数器或累计总数(如统计数据)时,各个线程也可以保持线程本地总数,并让全局总数仅在需要时通过一个通用的线程进行更新。因此,在共享内存区域上的争用会大大减少。
3)趋向于减少锁争用的模式会减少内存通信,因为它是一个共享的可写入状态,该状态需要使用锁并产生争用。 -
避免由核缓存造成的伪共享
3.2 避免伪共享
如果两个或多个处理器正在向同一缓存行的不同部分中写入数据,那么很多缓存和总线通信可能会导致其他处理器上的旧行的每个缓存副本失效或进行更新。这称为 “伪共享” 或者也称为 “CPU 缓存行干扰”。
和两个或多个线程共享同一数据(因此需要程序化的同步机制来确保按顺序访问)的真正共享不同,当两个或多个线程访问位于同一缓存行上的无关数据时,就会产生伪共享。
即使线程正在修改内存中的不同区域(红色和蓝色箭头),为了保持缓存一致性,在加载该缓存行的所有处理器上,该缓存行会失效,从而强制进行更新。
避免伪共享的技巧:
- 可以使用可用于对某个特定处理器进行有条件地编译的编译器对齐指令,通过将数据结构与缓存行边界对齐来避免伪共享。
- 另一个技巧涉及对经常访问的数据结构领域进行分组,以便让它们成为单个的缓存行,因此可以通过单个内存访问来加载它们。这会减少内存延迟。但是如果数据结构非常巨大,则可能会增加缓存占用,并且可能需要牺牲一些打包效率来减少或消除伪共享。
- 为了防止数组中发生伪共享,应该将数组与缓存的大小匹配。数组元素的大小必须是处理器缓存行大小的整数倍。
- 确保不同线程所拥有或修改的数据位于不同的缓存行上。
处理伪共享的两种方式:
- 增大数组元素的间隔使得不同线程存取的元素位于不同的cache line上。典型的空间换时间。(Linux cache机制与之相关)
- 在每个线程中创建全局数组各个元素的本地拷贝,然后结束后再写回全局数组。
在Oracle JDK 8 / OpenJDK 8里有一个新功能,叫做 @Contended ,可以用来减少false sharing的情况。本质上来说就是用户在源码上使用@Contended注解来标注哪些字段要单独处理,避免与其它字段放得太近导致false sharing,然后JVM的实现在计算对象布局的时候就会自动把那些字段拿出来并且插入合适的大小padding。
它的JEP在这里:JEP 142: Reduce Cache Contention on Specified Fields
Aleksey Shipilёv大大写过几篇文章都提到了它,这里有其中一篇:What Heap Dumps Are Lying To You About
要在用户代码(非bootstrap class loader或extension class loader所加载的类)中使用@Contended注解的话,需要使用 -XX:-RestrictContended 参数。
3.3 消除或减少锁争用
软件设计者的主要目标应该是消除共享,以便线程或进程之间不会发生资源争用。
避免锁争用的技巧:
- 采用并发数据结构设计和无锁算法,这会消除锁以及传统的同步技巧(比如互斥)。
- 在 Linux 内核中,广泛使用了每处理器变量,系统上的每个处理器都获得了自己的一个给定变量的副本。访问每处理器变量不需要使用锁,此外,因为在不同的处理器上,这些变量未在线程之间共享,因此没有伪共享或内存争用。这种技巧非常适合收集统计信息。
减少锁争用的技巧:
- 当使用传统锁或同步技巧(如自旋锁)时,必须注意的是,不要使用单片锁或全局锁,而是将这些锁分成更细小的部分。因此,锁会保护数据结构中的某个特定区域以及较小的区域。这样多个线程就能在同一数据结构的不同成员上并发进行操作。这种方法可以实现更多并发。
- 可能会由于伪共享而导致发生性能问题。两个线程在两个不同的处理器上运行,每个线程都锁定哈希中的不同哈希桶,那么当它们所需的自旋锁位于同一个缓存行上时,可能会发生伪共享。因此,在设计此类算法时需要考虑采用避免发生伪共享的通用技巧。
3.4 避免堆争用
是全局资源,在某个进程中的线程之间共享并争用。堆争用是内存密集型多线程应用程序的瓶颈之一。
避免堆争用的技巧:
- 使用线程本地/专用堆进行内存管理,从而消除了资源争用。在 Windows 平台上,可以使用 HeapCreate() 为每个线程创建一个专用堆,并将返回的堆句柄传递给 HeapAlloc()/HeapFree() 函数。
3.5 提高处理器关联
处理器关联是一个线程或进程属性,该属性告诉操作系统可以在哪些核或逻辑处理器上运行进程。这更适合嵌入式软件设计。
在该配置中,在同一处理器上的两个核之间以及不同处理器上的两个核之间,缓存子系统的行为将会有所不同。如果两个相关的进程或某个进程的两个相关线程被分配给同一处理器上的两个核,那么它们可以更好地利用共享的 L2 缓存,而且可以减少保持缓存一致性的开销。
- 嵌入式系统的软件设计者可以利用这个保持缓存一致性相对较低的开销,通过编程控制向核分配线程。
- 在 Linux 和 Windows 操作系统上的以下系统调用可以告诉应用程序,对于特定的进程来说,处理器是如何关联的,并为进程设置处理器关联掩码
4.编程模型
当为应用程序中的线程分配工作时,软件设计者可以考虑两个不同的编程模型。
- 功能分解
该模型的目标是了解应用程序或软件必须执行的操作,并将每个操作分配给不同的线程。
可以将多个操作组合在一个线程中,但功能分解的结果是每个操作都由特定的线程来执行。 - 域分解或称为数据分解
该模型的目标是分析软件或应用程序所需的数据集,以便对它们进行管理或处理,并将这些数据集分解成可以单独处理的较小组件。