单线程并发是指在一个线程内,一次完成多个任务。传统上,我们会用多个线程一次处理多个任务,每个线程处理它自己的任务。如果采用传统的多线程并发,不同任务之间的切换是通过操作系统和 CPU 在不同线程之间切换来实现的。不过,如果使用单线程并发技术,一个线程实际上也可以通过在处理每个任务之间切换,来实现处理多个任务。在本教程中,我会解释单线程并发的工作原理,以及单线程并发设计带来的好处。

单线程并发仍然是一个新领域

我开始研究单线程并发设计,同时为单线程服务器设计寻找更好的线程模型,以便使用 Java NIO 的非阻塞 IO。高性能的 IO 工具包都使用单线程服务器设计,比如 Netty、Vert.x 和 Undertow。Node.js 也是用单线程设计。Vert.x 和 Undertow 在底层都是用 Netty(据我所知),因此都是遵从 Netty 的线程模型。

Netty 的线程模型是以事件循环为中心。事件循环是一个查找系统中发生的事件的线程,该线程运行在一个循环中。当一个事件发生时,调用代码,这样代码就可以响应该事件。

虽然事件循环线程设计对某些类型的系统和工作负载很有效,但是也有对其它类型的系统和工作负载效果不好的时候。因此,我决定在别的地方寻找符合我需求的单线程并发设计。顺便说一下,我会在本教程的后面更详细解释。

除了 Netty 和 Node.js 之外,我没有找到太多有关单线程并发设计的信息。也许它隐藏在付费专区后面的科学论文或者别的什么中。或者也许这只是一个研究不多的领域。

由于我没有发现太多有关单线程并发设计的文章,所以我不得不自己想出一些设计。我在这个教程中提出这些设计。不过,我怀疑,随着时间的推移,可能会想出更好的设计(如果不是这样的话,那就太奇怪了)。如果你有任何想法或者参考,我会很有兴趣了解!

经典的多线程并发

在经典的多线程并发架构中,通常会把每个任务分配给一个单独的线程来执行。每个线程一次只执行一个任务。在某些设计中,会为每个任务创建一个新线程,并且一旦任务完成,该线程就消亡了。在其它设计中,线程池保持活动状态,它从任务队列中一次取出一个任务,执行它,然后取出另一个任务,等等。更多信息,请参考线程池教程。

多线程架构的优点是,在多个线程和多个 CPU 之间分配工作负载相对容易。只需把任务交给一个线程,然后让操作系统和 CPU 把该线程调度给 CPU 即可。

不过,如果正在执行的任务需要共享数据,多线程架构可能会导致很多并发问题,比如竞态条件、死锁、饥饿、滑移条件、嵌套管程锁死等等。一般来说,共享相同数据和数据结构的多个线程越多,并发问题发生的概率就越高。也就是说,越需要仔细检查设计。

当多个线程试图同时访问相同的数据结构时,经典多线程架构有时候也会导致拥塞。根据给定数据结构实现得多好,在其它线程正在访问该数据结构时,有些线程在等待访问时可能被阻塞。

单线程或同线程并发

经典多线程并发架构的替代方案是单线程或者同线程并发架构。在单线程并发设计中,必须自己实现任务切换。

可以把单线程架构向上扩展为使用多个线程,其中每个线程表现得就好像它是一个分离的单独的单线程系统一样。在这种情况下,我将架构称为同线程。执行任务所需的所有数据在一个线程内依然是保持隔离的,也就是保持在同一个线程内。

单线程并发的好处

在我看来,单线程并发设计比多线程并发设计有一些好处。这里我会论证我认为这些好处是什么。

完全的线程可见性

首先,单线程并发避免了多线程并发可能出现的大多数并发问题。当同一个线程执行多个任务时,可以避免并发问题,比如线程可见性问题,即对共享数据结构的更新对其它线程不可见。

使用多线程并发,必须确保使用同步、volatile 变量以及并发数据结构的正确混合,以确保对被多个任务共享,因而被多个线程共享的数据结构,对其它线程是可见的。

更多有关 Java 线程可见性问题的信息,请参见Java 内存模型Java 发生前保证教程。

没有竞态条件

当只有一个线程访问多个任务共享的数据结构时(因为所有任务都是被同一个线程执行的),就避免了竞态条件问题。当多个线程访问相同的临界区,而没有任何保证的线程访问顺序时,竞态条件就会出现。更多有关这些问题的信息,请阅读竞态条件和临界区教程。

控制任务切换

当我们自己在任务之间切换时,我们自己控制切换什么时候发生。可以确保共享资源在切换之前处于合理的状态,并且可以决定在切换之前执行多大的工作增量(块)。

在切换到另一个任务之前,能决定要执行的工作块(增量)有多大,这就让我们可以更好地控制如何利用 CPU。工作增量小了,会导致更多的任务切换。工作增量越大,任何切换越少。任务切换是我们想最小化的开销。花在暂停一个任务,恢复另一个任务的 CPU 周期是浪费的。CPU 时间本身不会为应用程序产生任何有用的工作。也许我们根本不希望某个特定大小的任务被打断,以避免不必要的任务切换。

能够决定工作增量的大小还可以让我们可以决定任务的优先级。如果在两个任务之间切换,可以决定一个任务一次只能执行一个工作增量,而另一个任务一次能执行两到多个工作增量。因而第二个任务会比第一个任务赋予更多的 CPU 时间。我们自己可以控制这种任务的优先级。

控制任务优先级

可以通过某种方式实现单线程任务切换,这样我们就可以设置任务的优先级,也就是说给某些任务分配比其它任务更多的 CPU 时间。本教程的后面我会讲解这一点。

单线程并发的挑战

使用单线程并发设计也带来了一些挑战。下面我们会描述其中的一些挑战。

在不丧失单线程并发架构的简单性这个优点,以及在不让总体设计过于复杂的情况下,克服这些挑战的每一个,是有可能的。

需要实现

必须自己实现任务切换就需要我们学习如何实现这样的设计、以及如何真正实现它们。这是一个挑战。它还会代码库增加了一点开销(不过我认为不是太多)。好在可以创建跨应用程序重用的单线程任务切换设计,这样就可以实现的开销降到最低。

阻塞操作必须避免,或者在后台线程处理

如果一个任务需要执行阻塞 IO 操作,那么该任务和线程会保持阻塞状态,直到 IO 操作完成为止。等待阻塞 IO 操作完成时,线程不能切换到另一个任务。

由于这种阻塞 IO 操作的限制,所以就可能需要在这些任务的后台线程中执行这些任务,因为对这些任务来说,就回到了传统的多线程设计了。

一个线程只能利用一个 CPU

一个线程只能利用一个 CPU。如果应用程序是运行在有多个 CPU 或者多核 CPU 的计算机上,而我们想把它们都利用起来,就必须把单线程设计扩展为同线程设计。这样做是可能的,不过需要一些工作。

单线程并发设计

现在我们来看看一些前面我已经提到的单线程设计提供的功能,这样你就可以亲眼看到它们是如何工作的,并理解其优缺点。

线程循环

大多数长时间运行的应用程序是在某种循环中执行,其中主应用程序线程在等待来自应用程序外部的输入,处理该输入,然后返回等待状态。

7. 单线程并发 - 图1

这种线程循环在服务器应用程序(Web Services、服务等)和 GUI 应用程序中都被用到。有时这种线程循环对我们是隐藏的,有时不是。

暂停线程循环

你可能想知道,一个线程在一个紧凑循环中反复执行是否会浪费大量 CPU 时间。如果线程在运行时没有任何实际工作要做,那么是的,这可能会浪费大量 CPU 时间。不过,如果执行该循环的线程评估休眠几微秒是可以的,它就随时可以休眠。通过这种方式,CPU 时间的浪费可以减少。

代理

线程循环通常调用一些组件来执行应用程序的工作。我把这种组件称为代理(Agent)。代理是执行工作的类似作业的组件。

代理的寿命可能会有所不同。代理可以:

  • 在应用程序的整个生命周期内运行。
  • 运行一个最终会完成的长时间运行的作业。
  • 运行一个一次性任务(很快会完成的任务)。

因此,搭理可以执行应用程序的长时间运行逻辑、由很多较小任务组成的一个长时间运行的作业、或者几乎立即完成的一个一次性任务。因此,代理这个术语涵盖多种大小的任务和职责。

我更喜欢代理这个术语替代作业或者任务这些术语,代理的寿命、职责和能力可能超出我们通常认为的一个作业或者任务。我认为搭理是执行作业和任务的一个组件。它本身不是一个作业或者一个任务,即使在某些情况下它看起来可能是这样。

线程循环调用代理

线程循环通常会反复调用一个代理组件,也就是把应用程序的实际职责移交给代理。这种设计把线程循环和代理之间的职责划分为:

  • 线程循环负责循环(重复调用代理),并检测搭理何时终止,从而终止线程循环。
  • 代理负责执行应用程序逻辑本身,但是不负责循环。

使用这种设计,我们可以创建不同类型的线程循环,这些循环可以与不同类型的代理组合在一起。如下是这种设计的示意图:

7. 单线程并发 - 图2

代理可以调用其它代理

一个代理可以把它的工作分工给其它代理。因此,代理有不同的责任级别。如下的示意图阐述一个应用程序级的代理和多个作业级的代理之间的职责分工:

7. 单线程并发 - 图3

单线程任务切换

如果上图中由作业代理执行的某些工作需要很长时间才能完成该怎么办?如果作业代理在应用程序代理第一次调用它的时候,就是执行全部工作,那么在第一个作业代理完成整个工作时,整个系统(线程循环和代理)就都被阻塞,没法进行其它作业或者任务。

为了能看似同时进行多个任务,执行任务的线程必须能在任务之间切换。这也称为任务切换。当只有一个线程可用时,我们需要通过代码设计让任务切换成为可能。

为了让在单个线程内能实现任务切换,每个任务必须像如下图所示那样被拆分成一到多个增量:

7. 单线程并发 - 图4

这样,只要代理被调用,它就会执行一到多个工作增量。或迟或早,所有工作增量都会被执行完,整个任务也就完成了。

一次又一次循环调用代理,让它们每次执行一个工作增量,可以图示如下:

7. 单线程并发 - 图5

增量大小平衡

如果一个线程要能在多个任务之间切换,那么它必须将这些任务划分为不太大的增量。换言之,每个任务都有责任帮助确保任务之间公平分配执行时间。确切的大小通常取决于具体任务和我们的应用程序。

优先执行

优先执行某些任务是可能的。可以通过在调用代理时传递一个参数来实现这一点,该参数告诉代理要执行多少工作增量。因而,有些代理可能被指示为只执行一个工作增量,而有些代理可能被指示执行两到多个工作增量。这会导致有些任务比其它任务得到更多的 CPU 执行时间,它们因此会更快地完成。

代理暂止

如果一个代理在等待某些异步操作完成,比如来自远程服务器的答复,那么在它等待的异步操作完成之前,这个代理是没法继续进行的。在这种情况下,一次又一次地调用代理可能是没有意义的,因为代理会立即意识到它无法继续进行下去,并立即把控制权返回给调用线程。

在这种情况下,代理可以自己“暂止(Park)”,这样就不再被调用了。当异步操作完成时,代理就可以解除暂止,并重新插入到活动代理中,活动代理会被不断调用以继续进行。当然,为了能够对代理解除暂止,系统的其他部分必须检测到异步操作已经完成,并找出要解除暂止的代理。

扩展单线程并发

很显然,如果应用程序内只有一个线程在执行,就没法利用多个 CPU。解决方案是启动多个线程。通常,一个线程一个 CPU,取决于线程需要执行什么类型的任务。如果任何需要执行阻塞 IO 工作,比如读文件系统或者网络,那么可能每个 CPU 就需要多个线程。在等待阻塞 IO 操作完成时,每个线程都会被阻塞,做不了任何事情。

7. 单线程并发 - 图6

当把单线程架构扩展为多个单线程子系统时,从技术上讲,它就不再是单线程的了。不过,每个单线程子系统通常被设计和表现为单线程系统。我曾经把这样的多线程单线程系统称为同线程系统,不过我不确定这是否是最准备的术语。我们可能需要审视这些不同的设计,并在将来为它们提出更多的描述性术语。