内容


多核 CPU 和它们带来的并发性改变

为什么基于线程的编程模型不是多核时代的最佳选择

Comments

摩尔定律(戈登·摩尔 1965 年预测,每个集成电路的元件数量每 18 到 24 个月就会翻一倍)仍然适用,它的适用性预计会持续到 2015-2020 年(参阅 参考资料)。2005 年之前,CPU 时钟速率也在同步提升,这本身已足以改进在这些 CPU 上执行的所有应用程序的性能。应用程序开发社区只需对算法改进进行少量投资或者甚至无需投资,就可以坐享这一性能改进。

然而,从 2005 年开始,时钟速率的增长和晶体管数量的增长已不再同步。由于处理器材料的物理性质限制,时钟速率已停止增长(甚至下降),处理器制造商开始将更多执行单元(核心)封装到单个芯片(插槽)中。这一趋势(似乎能够在可预见的未来继续保持)已开始给应用程序开发和编程语言开发社区带来越来越大的压力,这大体表现在两个方面:

  • 简单地升级到更强大的 CPU 无法再获得 2005 年之前单线程应用程序的性能增长速率。单线程应用程序的性能没有变化,无论 CPU 中有多少个核心。也就是说,每核心吞吐量几乎是相同的,无论 CPU 拥有多少个核心(假设编译器、虚拟机或操作系统级别的自动并行化技术没有突破)。
  • 升级到多核 CPU 仅能使系统上的增量负载受益,而不是让现有负载受益。

高效地利用可用 CPU 核心的惟一方法就是使用并行性。目前为止,主要由操作系统在进程级别上使用并行性来提供无缝的多任务、多处理体验。在应用程序开发方面,基于线程的并发编程是实现并行性的主导机制。

基于线程的并行性拥有以下优势:

  • 它是一种受到广泛认可的编程模型。
  • 应用程序开发社区对线程的创建、调度、执行和管理方式有深入的了解。
  • 开发人员已被培养为以序列方式思考算法开发。线程模型只是相同的方法在并行性方面的简单扩展。

但是,基于线程的应用程序并行性的问题大于优势。本文解释了显式的基于线程的应用程序并行性可能不是利用 CPU 核心的最佳方式和我们需要一种不同的编程模式的一些原因。

调用栈深度

调用栈是操作系统或虚拟机所维护的一种处理所有方法调用的内部结构。线程执行中的每次方法调用都会推进一个栈帧(由并行方法调用的详细信息组成,比如参数、返回地址和局部变量)。

图 1 显示了方法调用的内部原理:

图 1. 调用栈内部结构和增长
该图显示了调用栈内部结构和增长
该图显示了调用栈内部结构和增长

无论您如何将一个应用程序模块化为多个逻辑层(比如控制器层、外观层、组件层和数据访问对象 [DAO] 层),在运行时,线程是终极织入器 (weaver),而且它只有一个堆栈。调用栈是在运行时处理源代码模块化方面的一项绝妙发明。但是,随着应用程序的复杂性和系统上的负载的增长,目前的调用栈结构模型限制了应用程序可伸缩性,而且它还存在与内存大小和对象可达性相关的内部问题。

内存大小

只要操作系统创建一个线程,它就会为调用栈分配一个特定的内存块。这意味着系统中可并行化的任务(线程)的数量受到系统中可用内存的限制。(例如,在 Linux 64 位系统中,JVM 为每个线程分配了 1MB 的调用栈。假设 JVM 实例获得了 2GB 的内存,那么它创建的可并行执行的任务不得超过 2,000 个。实际上,由于其他可选的线程控制结构(比如线程本地存储)的限制,该数量甚至会更低。基本而言,这种模型提前为线程分配了一个预定义大小的空间,这间接地限制了应用程序的可并行化程度。

从历史角度讲,JVM 上的默认堆栈大小在不断增长,这表明随着应用程序的模块化和复杂性增加,此模型需要更多的调用栈内存。例如,32 位 JDK 1.1.3.4 上的默认堆栈大小为 128K;在 JDK 1.2 和 1.3 上,默认堆栈大小为 256 K;在 JDK 1.5 和更高版本上,默认堆栈大小为 512K。

对象可达性

深度调用栈的另一个问题是,调用栈中可能持有但从不使用对象引用。例如,在 图 1 中,当线程正在执行流中执行最深的方法时,调用栈中所有方法的所有局部变量和参数不可能都派上用场。(例如,当一个线程执行 DAO 层代码时,应用程序不见得会调用栈中由 servlet 层、控制器层、外观层和其他层方法调用所推送的所有局部参数和变量)。但是,这些内容不会被释放或垃圾收集,因为它们包含有效的引用。

Java™ 调用栈实现旨在方法调用返回时自动释放它的所有引用。这在 JVM 不再具有高负载时可能是可以接受的。但在 JVM 处理着大量活动线程时,这可能存在问题。例如,如果每个线程持有调用栈中高达 5MB 的未用的活动引用,而且有 100 个线程处于活动状态,JVM 无法对 500MB 的堆空间进行垃圾收集,因为调用栈变量和参数仍在引用该空间。在 32 位机器上,这可能至少耗用了该 JVM 的所有可用内存的 25%,这个大小非常可观。

共享对象

基于线程的并行性的另一个关键问题是,由于多个线程共享的对象的易变性而导致的同步工作,如图 2 所示:

图 2. 共享内存
该图显示了对象在线程之间的共享方式
该图显示了对象在线程之间的共享方式

尽管同步不是一个新概念,而且已经被广泛采用,但它需要牺牲应用程序的性能,因为锁获取顺序可能强制线程在锁释放之前处于等待或休眠状态,释放锁将在内部触发一个线程上下文开关。上下文开关一般会减缓线程执行。它还会驱赶出核心中的所有管道指令和缓存。在具有大量并行线程的 JVM 中,同步可能导致由同步或锁引起的频繁的线程上下文开关。

顺序编程

顺序编程对线程本身而言未必一个问题,但它与应用程序使用它们的方式密切相关。操作系统进程的逻辑概念实在早期的计算时代为(在一个用户提交的作业中)顺序执行指令而发明的。但顺序编程思想仍在盛行,即使一些进程的复杂性自那时起已增长了许多倍。随着复杂性的增长,各种系统层(后端、中间层和前端)逐渐建立起来。但在一个层内,应用程序用例仍然是按顺序执行的,使用一个线程作为各种不同组件之间的所有逻辑的编织者。

您可将此与亨利·福特的装配线诞生之前的制造流程进行类比。当时,一个员工或员工小组将创建一个完整的产品。装配线使员工能够集中精力执行总体操作流程中的具体的子任务,节省了员工在各个产品制造阶段往返移动的时间,将生产力提高了许多倍。

现在的一种与装配线类似的流程是快餐店的客户订单处理。由既定数量的员工(专门负责一组子任务)处理订单,每位员工仅执行整体工作的一部分。这个人的工作完成后,半成品就会交给生产线上的下一位员工,直到完成最终的成品。相比之下,考虑这样一个系统,其中每个员工从头到尾负责一位客户。两种方法都可有效地处理订单,但快餐系统的效率更高。处理整个订单的一位员工将花费许多时间在不同位置来回移动,而不是实际制造产品。员工的移动还带来了其他问题,比如空间争用和时间延迟。

现在来考虑一个现代 JEE 应用服务器执行用户请求的方式。它为一个用户请求分配一个专门的线程。如图 3 中所示,该线程执行记录、数据库交互、Web 服务调用、网络交互和逻辑等所有指令:

图 3. 线程流
在该图中一个线程执行所有不同逻辑
在该图中一个线程执行所有不同逻辑

无论源代码在控制器、模型、视图、外观和其他层上的模块化程度多高,它始终由单个线程来执行。这种类型的执行会在内部导致大量硬件资源争用问题,比如上下文开关。

结束语

多线程是尽可能高效地利用底层 CPU 资源的一个好方法。但由于各种系统在演化,开发和操作系统社区已经扩展了多线程在应用程序级并行性中的使用。应用程序开发社区开始使用基于线程的编程,按某种顺序执行所有应用程序逻辑。自多核 CPU 开始发展以来,核心数量逐步增多,顺序、显式、基于线程的编程已变得不那么高效。

在多核硬件上运行的可伸缩、高性能的应用程序需要一种并行化方法,将应用程序逻辑分解为多个相互依赖的工作单元,并透明地将它们链接在一起(而不是通过使用单个线程明确地将它们捆绑在一起),这样每个工作单元都可以高效地执行。

正如装配线引起了制造流程的革命并提高了每一层的效率,良好的未来编程模型也将改变我们设计应用程序软件的方式。基于角色的编程就是这样一种抽象模型(参阅 参考资料),它将整个应用程序划分为多个部分,以便可以将底层的核心分配给这些部分,并以某种高效的方式并行执行它们。

免责声明

本文中的所有观点和看法仅为一己之见,不代表我的上司的观点和看法。

致谢

感谢我的同事 Jesus Bello 和 Olga Raskin 提供的宝贵建议。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=861734
ArticleTitle=多核 CPU 和它们带来的并发性改变
publish-date=03182013