技术开发 频道

JCSP 的高级主题

  【IT168 技术文章】

         AOP 和 CSP

  面向方面编程,即 AOP,是现在大多数 Java 开发人员都知道的一个术语。AOP 把系统当成方面或者关注点的的组合,而不是单纯地将系统看成是对象的组合。它试图通过把切入点组合成独立的模块,由模块代码解决一个特定关注,从而提高系统的模块性。这些模块可以用静态或动态编织技术编织在一起。

  这个系列文章中已经介绍了很多内容,所以对您来说,应当很容易就可以看出 AOP 的概念 把模块编织在一起 与 CSP 的概念 组合进程网络 之间的相似性。两个技术之间的相似(绝对不是故意的!)主要是由于后者的复合性质。

  一个基于安全性检测的示例被频繁地用作 AOP 的示例。在这个示例中,包含解决应用程序安全问题的代码的模块是以方面的形式编写和打包的;然后,这个方面将应用于整个应用程序中所有适当的业务对象。为了用 CSP 解决同样的问题,可以先从编写进程开始,编写一个包含业务对象的功能的进程,再编写另一个包含应用程序的安全性检测功能的进程。然后可以用 One2One 通道把两个进程连接起来。CSP 技术的优势与 AOP 技术类似:都可以将关注点分隔开。

  高级 JCSP 同步

  通道不是进程间同步的惟一可用选择,也不总是最合适的选择。通道总是在以下两个进程间同步:阅读器和写入器。是否在运行多个阅读器/写入器并不重要,因为在 Any2One、One2Any 和 Any2Any 模型中,每种类型的进程都只有一个进程能够积极地参与到通道两边进行的会话中。

  为了获得更高级的同步,即在多个进程之间的同步,JCSP 提供了 Barrier、Bucket 和 CREW 构造,还提供了一个叫作 ProcessManager 的类负责管理 CSProcess 实例。

  Barrier 构造

  barrier 是一个 JCSP 事件,可以充当多个进程之间的同步机制。每个 barrier 都能与多个进程相关联,任何在这个 barrier 上同步的进程都将被阻塞,直到其他进程已经同步为止。

  必须用需要在 Barrier 类上同步的进程的数量来创建它,其中每个进程都运行在独立的线程中。在完成当前一轮工作之后,每个进程都调用 Barrier 实例的 sync() 方法,然后等候其他进程完成。

  Barrier 内部

  从内部来看,Barrier 包含一个成员变量,在创建 Barrier 对象时,这个成员变量被初始化为指定的进程总数。每当进程调用 sync() 方法时,这个变量就减一,并发出 wait() 调用。(您会想起已经介绍过的内容:Java 线程在能够调用 wait() 方法之前必须一直持有恰当的锁。在这里,由于将 Barrier 的 sync() 方法标记为同步的,所以 Barrier 实例本身充当着锁的作用。) 检测是在最后一个调用 sync() 的进程所在的线程上进行的。如果成员变量变成 0,则清楚地表明,所有进程已经完成了对 sync() 的调用 ,可以安全的发出 notifyAll() 了。这时,内部的计数器被重新设置回最初提供的进程总数,从而使得 Barrier 实例可以在下一轮同步中重复使用。

  注意,Barrier 并没有把进程的身份和自身关联起来。所以,只有能得到 Barrier 对象,任何进程(除了要在 Barrier 上同步的进程之外)都能调用对象上的sync,从而将一个或多个合法进程排除在外。要想防止这个问题,可以对 Barrier 对象的可见性进行严格控制,只允许那些已经在 Barrier 上同步的进程集看到它。

  似曾相识!

  如果您认为 Barrier 工作方式的描述听起来很熟悉,那就对了。Parallel 构造自行控制运行多个 CSProcess 实例的基础就是 Barrier 构造的底层机制。

  您会想起前面介绍过,在您调用 Parallel 实例上的 run 方法时,它创建了 (n-1) 个线程来运行前面 (n-1) 个 CSProcess 实例,然后在自己的线程中运行最后一个 CSProcess 实例。所有这些进程都在一个公共的 Barrier 实例上进行 sync,这个实例是在 Parallel 类的构造函数中创建的(而且通过负责运行 CSProcess 的每个 n-1 线程的构造函数,可以将实例传递给每个线程)。

  Barrier 是一个比 Parallel 级别低的构造,因此,相应地也就带来了更多的复杂性,进程可以在任意时间从 Barrier 对象 enroll(登记)或 resign(退出)。例如,在一个工作进程运行时,它可能想与时钟同步(实际上是模拟时钟滴答) ,然后停下来休息一段时间(在此期间,它应当从 barrier 退出),接着再登记到 barrier,重新开始工作。

  Bucket 构造

  bucket 也是一个用于多进程的同步 barrier。bucket 构造和 barrier 构造之间的区别是:在前者中,所有进程都被阻塞,直到另外一个(外部)进程决定释放它们,而释放是通过“清空 bucket”进行的。

  使用 Bucket 的方法只是创建一个 bucket 构造;然后,每个需要在该构造上面同步的进程都可以调用这个 bucket 的 fallInto() 方法。fallInto() 是一个同步的方法,它维护一个内部变量,用该变量来跟踪当前(等候)的进程数量(也就是说,每有一个调用 fallInto() 方法的进程,计数器变量就加 1。)在计数增加之后,调用进程执行 wait(),这导致调用进程受阻塞。当外部进程调用 Bucket 的(也是同步的) flush() 方法时,就发送一个 notifyAll() ,唤醒所有受阻塞的线程。阻塞进程的最后计数将从这个调用中输出。

  Barrier 是确定性的,而 Bucket 是非确定性的。可以保证同步到 Barrier 的进程得到重新安排,从而得以执行(在一个时间周期后,受阻时间长短等于组中最慢的进程的执行时间),同步到 Bucket 的线程受阻塞的时间是不确定的,时间长短取决于清除进程何时决定发出释放这些进程的调用。

  在必须按照先后顺序处理一组进程时,Barrier 很有用。这就是说,每个进行到步骤 n 的进程都不能进行步骤 n+1,直到同组中的其他所有进程都已经完成它们的步骤 n。在每个进程都要完成自己分配的任务并且停在 bucket 中,以表明自己可以进行下一步,这种情况下,Barrier 很有用。清除进程可以周期性地清除这个 bucket (可能用某些工作调度算法),从而释放目前在 bucket 中的所有进程,开始下一组任务。

  CREW 构造

  并发读/排它写(Concurrent-Read-Exclusive-Write)构造,即 CREW 锁,允许多个并行阅读器访问共享资源,条件是:(1)“读”访问不会修改资源;(2)之前没有写入器正在访问资源。当写入器正在访问资源时,它对资源拥有排它权限:不管是阅读器还是写入器,都不允许访问资源。

  CREW 锁的使用是为了消除争用风险(因为竞争的阅读器/写入器进程之间任意的交叉),并且由于允许多个阅读器进程并行执行而提高了性能。是否真的会有性能提升则取决于以下因素:以读模式访问资源的频率与以写模式访问资源的频率的比较、读操作和写操作的时间,以及在同一时间在冲突模式下试图访问共享资源的进程数量。

  管理 CSProcess 实例

  在 第 2 部分 中,我介绍了如何用 Parallel 构造把较低级别的“构造块”进程组合成更高级别的进程网络。虽然对于许多编程场景,这些描述都是合适的,但在这篇文章的示例中,我假设已经知道了需要创建的所有进程的全部细节。但是对于某些应用程序来说,可能会发现有必要根据只能在运行时才能计算的某些条件,动态地创建一个或多个进程,而且还应当能够管理它们。

  JCSP 为这类场景提供了一个叫作 ProcessManager 的类。这个类接受 CSProcess 实例作为输入参数,允许用以下两种不同的方法之一启动进程。如果在ProcessManager 上调用 run() 方法,那么 ProcessManager 将启动托管的进程,然后等候进程终止(被管理的进程在 ProcessManager 的线程上运行,所以,后者的运行被阻挡,直到托管的进程完成为止)。另一个选项是调用 ProcessManager 上的 start() 方法,这使托管的进程在独立的线程中启动,在这种情况下,ProcessManager 自己仍然继续运行。

  管理并行优先级

  我在 第 2 部分 中提到:Parallel 构造并行地运行构造函数中传递给它的数组中包含的所有 CSProcess 实例。但是在某些情况下,可能必须要为这些进程赋予不同的优先级。JCSP 提供了一个叫作 PriParallel 的类,可以为传递给它的进程加上优先级。

  受控制的进程的优先级是由进程在数组中的位置索引决定的。在数组中,放在前面的进程拥有更高的优先级。注意,JCSP 以底层线程的优先级机制实现进程的优先级;所以优先级实际的工作方式取决于底层的 JVM 实现。

  越来越多的构造!

  迄今为止讨论的构造(从第 2 部分开始到现在这里)都是 JCSP 库的核心构造。可以用它们来解决普通的和高级的并发性问题,现在应当可以让您开始编写所喜爱(也没有错误)的多线程程序了。当然,在 JCSP 库中,这些问题只是冰山之一角。请考虑以下有趣的构造的应用,您也可以自己对它们进行研究:

  BlackHoleChannel 实现 ChannelOutput 接口,包含一个空的 write() 方法。可以把任意数量的数据写入这个通道。如果想把现有进程用在不同的环境中,而 不 再使用某些输出,那么 BlackHoleChannel 类是最有用的。因为不能一直留着某个输出通道不处理(害怕造成死锁),所以最好的选择就是让进程在 BlackHoleChannel 的实例上产生输出,而 BlackHoleChannel 则有效地充当存放所生成数据的透明无底洞。

  Paraplex 是一个进程,它将其输入通道集上的多个对象流转化到单一输出通道。等到每个输入通道上都有一些可用输入之后(在输入到达的时候就接受,没有要求或规定特定的顺序),就可以将这些输入打包成数组(数据的尺寸与输入通道的数量相同),然后把数据通过输出通道发送为单一输出。如果需要用表格的形式表现某些数据,那么 Paraplex 可能会很方便。例如,假设有一个三列的表,每列均用数字填充。前两列用前面已经介绍过的 JCSP 进程生成的数字填充:NumbersInt 为第一列生成从 0 开始的自然数序列,SquaresInt 为第二列生成对应的平方序列。一个即插即用的叫作 FibonacciInt 的 JCSP 进程(随 JCSP 库一道立即可用)可以用来为第三列生成斐波纳契数系列。

  可以容易地用一个组合了三个输入通道的 ParaplexInt 实例来处理这三个列。每个输入通道都要附着在前面提到过的三个进程中的一个的实例上。然后,ParaplexInt 的单一输出通道向 CSProcess 传送数据,后者则接着读取带有三个元素的数组,并在适当的格式化表格中输出它们。

  Deparaplex 是一个类,它和 Paraplex 类相反, 它把数据从单一输入通道 分离 到一组输出通道。所以,Deparaplex 类可能读取一个数组对象(尺寸为 n),把数组中的每个元素逐个输出到它的 n 个输出通道上。然后,Deparaplex 进程会一直等候,直到它生成的每个元素都被写入的通道接受为止。

  CSP 的好处

  CSP 是基于成熟的数学理论的并发编程范式。因此,CSP 提供了丰富的设计模式和工具集,可以防范常见的多线程缺陷,例如争用风险、死锁、活动锁和资源耗尽。因为所有这些数学上的完善性都构建到了 JCSP 库中,所以可以直接用它根据规定好的指导原则来编写应用程序。 (也就是说,不必非得理解理论才能利用它;虽然清楚的了解会更有优势!)。因为存在正式的针对 Java 的 CSP 模型,所以可以分析并正式地验证用 JCSP 构造构建的任何多线程 Java 应用程序。

  正如前面所指出的,AOP 的许多好处也适应于基于 CSP 的程序。基于 CSP 的程序的主要好处是关注点的分享。可以使用 CSP 作为程序的基础(例如通过 JCSP 库),还可以确保进程之间干净的解耦,因为它们只能通过通道进行交互,不能通过直接的方法调用进行交互。它还确保可以完美地把数据和应用程序逻辑封装在通道接口之后的进程之中。所有的副作用都被限制在进程的边界之内,编程实体之间的交互都是显式公开的。在基于 JCSP 的程序中,没有隐藏的互交 —— 在网络的每一级上,所有的管道都是可见的。

  CSP 的第二个好处是它的复合性质。进程可以组合,从而构成更复杂的进程(复杂的进程还可以再组合起来构成更加复杂的进程),很容易地随时间推移进行修改和扩展。所以,基于 CSP 的应用程序设计特别简单并且易于理解,并且在进行维护的时候也非常健壮。CSP 的复合性质也促进了更好的重用;正如在第 2 部分的编程示例中看到的,可以用大量不同的设置使用一个 JCSP 进程,如果需要的话,可以将不希望的输出重定向到黑洞中。

  用 CSP 进行并发编程的最后一个好处对于构建分布式系统的开发人员来说特别明显。在本文中,我描述了实现并发性的不同选项(运行在单一进程中的多线程、运行在一个器上的多进程和运行在多个处理器上的多进程)。通常,这些并发机制中的每一个都要求使用完全不同的执行机制、编程接口和设计范式。而一旦用 CSP 开发应用程序,那么可以在不影响(至少不非常显著)应用程序设计或代码的情况下决定在哪个平台上运行应用程序,比如,是在多线程平台上,还是单一处理器上的多进程平台上,亦或是在分布处理器上运行的多进程平台上。

  JCSP 和 java.util.concurrent

  大多数 Java 程序员都知道,java.util.concurrent 包是作为 J2SE 1.5 类库的标准组成部分引入的。因为JCSP 库出现的时间比这个包加入 Java 平台的时间早,所以您可能想知道是否需要两者都用,或者说您想知道,既然已经适应了 java.util.concurrent,为什么还要费力地学习 JCSP。

  java.util.concurrent 包实际上是 Doug Lea 的一个很不错的流行的 util.concurrent 库的重新整理。这个包的设计目的是提供一个强壮的、高性能的标准化工具集,简化 Java 平台上的并发 —— 而这个目的已经实现了!毫无疑问! 如果 java.util.concurrent 包中的工具适合您的应用程序,那么请使用它们,您将得到回报。

  但是,正如我希望的,本文中的讨论已经显示出:基于 CSP 的技术(通过 JCSP 库) 表现出的机制超出了 java.util.concurrent 的范围。可以把系统构建成通信进程的网络,并用它们组合成网中之网,实现这一点的范式既自然又简单。这不仅提高了编写多线程应用程序的体验,而且提高了产品 品质。用 JCSP 构建系统会产生干净的接口,很少有会造成产品不好维护和技术变化这类隐藏的副作用。正式的验证(即正式地推断应用程序的安全性和活动属性)对于安全敏感和高价值的财务应用程序来说也是强大的资产。

  在许多情况下,这两个包不是互相排斥的:有些 JCSP 的核心要素非常有望会在 java.util.concurrent 提供的原子的低级机制顶部重新实现,新实现的开支可能要比目前使用的标准监视器机制的开支更少。

  结束语

  在这篇介绍适用于 Java 程序员的 CSP 的文章中,介绍了许多基础知识。包括Java 语言目前支持的多线程编程的构造。还解释了这些构造的起源,讨论了它们与 Java 平台多线程编程的 4 个常见缺陷(争用风险、死锁、活动锁和资源耗尽)之间的关系。在总结时,我解释了为什么在任意、大型、复杂的多线程应用程序中,很难应用正式的理论消除编程 bug 或证明它们不存在。我推荐使用 CSP 作为这个困境的备选项。

0
相关文章