高度并发应用的设计原理和模式
1. 概述
在本教程中,我们将讨论一些随着时间的推移而建立的用于构建高度并发应用程序的设计原则和模式。
然而,值得注意的是,设计并发应用程序是一个广泛而复杂的主题,因此没有任何教程可以声称其处理详尽无遗。我们将在这里介绍的是一些经常使用的流行技巧!
2. 并发基础
在我们继续之前,让我们花一些时间了解基础知识。首先,我们必须澄清我们对并发程序的理解。**如果多个计算同时发生,**我们称程序为并发。
现在,请注意我们提到了同时发生的计算——也就是说,它们同时进行。但是,它们可能会或可能不会同时执行。理解其中的区别很重要,因为同时执行的计算称为并行。
2.1. 如何创建并发模块?
了解我们如何创建并发模块很重要。有很多选择,但我们将在这里重点介绍两个流行的选择:
- 进程:进程是正在运行的程序的一个实例,它与同一台机器上的其他进程隔离开来。机器上的每个进程都有自己独立的时间和空间。因此,通常不可能在进程之间共享内存,它们必须通过传递消息进行通信。
- 线程:另一方面,线程只是进程的一部分。一个程序中可以有多个线程共享同一个内存空间。但是,每个线程都有唯一的堆栈和优先级。线程可以是本地的(由操作系统本地调度)或绿色的(由运行时库调度)。
2.2. 并发模块如何交互?
如果并发模块不必通信是非常理想的,但通常情况并非如此。这产生了两种并发编程模型:
- 共享内存:在该模型中,并发模块通过读写内存中的共享对象进行交互。这通常会导致并发计算的交错,从而导致竞争条件。因此,它可能不确定地导致不正确的状态。
- 消息传递:在此模型中,并发模块通过通信通道相互传递消息进行交互。在这里,每个模块按顺序处理传入的消息。由于没有共享状态,因此编程相对容易,但这仍然无法避免竞争条件!
2.3. 并发模块如何执行?
摩尔定律在处理器时钟速度方面遇到瓶颈已经有一段时间了。相反,由于我们必须成长,我们已经开始将多个处理器封装到同一个芯片上,通常称为多核处理器。但是,仍然很少听说有超过 32 个内核的处理器。
现在,我们知道单个内核一次只能执行一个线程或一组指令。但是,进程和线程的数量可能分别为数百和数千。那么,它到底是如何工作的呢?这是操作系统为我们模拟并发的地方。操作系统通过时间片来实现这一点——这实际上意味着处理器在线程之间频繁、不可预测和不确定地切换。
3. 并发编程中的问题
当我们着手讨论设计并发应用程序的原则和模式时,首先了解典型问题是什么是明智的。
在很大程度上,我们的并发编程经验涉及使用具有共享内存的本机线程。因此,我们将重点关注由此产生的一些常见问题:
- Mutual Exclusion (Synchronization Primitives):交错线程需要对共享状态或内存有独占访问权,以保证程序的正确性。共享资源的同步是一种流行的实现互斥的方法。有几种同步原语可供使用——例如,锁、监视器、信号量或互斥体。但是,互斥编程容易出错,并且经常会导致性能瓶颈。有几个与此相关的经过充分讨论的问题,如deadlock 和 livelock 。
- 上下文切换(重量级线程):每个操作系统都有对并发模块(如进程和线程)的原生支持,尽管有所不同。如前所述,操作系统提供的一项基本服务是通过时间分片调度线程在有限数量的处理器上执行。现在,这实际上意味着线程在不同状态之间频繁切换。在此过程中,需要保存和恢复它们当前的状态。这是一项直接影响整体吞吐量的耗时活动。
4. 高并发的设计模式
现在,我们了解了并发编程的基础知识和其中的常见问题,是时候了解一些避免这些问题的常见模式了。我们必须重申,并发编程是一项需要大量经验的艰巨任务。因此,遵循一些既定模式可以使任务更容易。
4.1. 基于参与者的并发
我们将讨论的关于并发编程的第一个设计称为 Actor 模型。这是一个并发计算的数学模型,基本上将所有事物都视为一个参与者。Actor 可以相互传递消息,并且可以响应消息做出本地决策。这是由 Carl Hewitt 首次提出的 ,并启发了许多编程语言。
Scala 的并发编程的主要构造是参与者。Actor 是 Scala 中的普通对象,我们可以通过实例化Actor类来创建它们。此外,Scala Actors 库 提供了许多有用的 actor 操作:
class myActor extends Actor {
def act() {
while(true) {
receive {
// Perform some action
}
}
}
}
在上面的示例中,在无限循环中调用receive方法会暂停 actor,直到消息到达。到达后,消息从演员的邮箱中删除,并采取必要的行动。
参与者模型消除了并发编程的一个基本问题——共享内存。Actor 通过消息进行通信,每个 Actor 依次处理来自其专属邮箱的消息。但是,我们通过线程池执行 actor。我们已经看到本机线程可以是重量级的,因此数量有限。
当然,还有其他模式可以帮助我们——我们稍后会介绍这些模式!
4.2. 基于事件的并发
基于事件的设计明确解决了本机线程的生成和操作成本高昂的问题。基于事件的设计之一是事件循环。事件循环与事件提供者和一组事件处理程序一起工作。在此设置中,事件循环在事件提供者上阻塞,并在到达时将事件分派给事件处理程序。
基本上,事件循环不过是一个事件调度器!事件循环本身可以只在一个本地线程上运行。那么,事件循环中到底发生了什么?让我们以一个非常简单的事件循环的伪代码为例:
while(true) {
events = getEvents();
for(e in events)
processEvent(e);
}
基本上,我们所有的事件循环所做的就是不断地寻找事件,并在找到事件时处理它们。该方法非常简单,但它获得了事件驱动设计的好处。
使用此设计构建并发应用程序可为应用程序提供更多控制。此外,它还消除了多线程应用程序的一些典型问题,例如死锁。
JavaScript 实现事件循环以提供异步编程。它维护一个调用堆栈来跟踪所有要执行的函数。它还维护一个事件队列 ,用于发送新函数进行处理。事件循环不断检查调用堆栈并从事件队列中添加新函数。所有异步调用都被分派到 Web API,通常由浏览器提供。
事件循环本身可以在单个线程上运行,但 Web API 提供单独的线程。
4.3. 非阻塞算法
在非阻塞算法中,一个线程的暂停不会导致其他线程的暂停。我们已经看到,我们的应用程序中只能有有限数量的本机线程。现在,阻塞线程的算法显然会显着降低吞吐量 并阻止我们构建高度并发的应用程序。
非阻塞算法总是利用底层硬件提供的比较和交换原子原语。这意味着硬件会将内存位置的内容与给定值进行比较,只有当它们相同时才会将值更新为新的给定值。这可能看起来很简单,但它有效地为我们提供了一个原子操作,否则将需要同步。
这意味着我们必须编写新的数据结构和库来利用这个原子操作。这为我们提供了多种语言的大量无等待和无锁实现。Java 有几种非阻塞数据结构 ,如AtomicBoolean、AtomicInteger、AtomicLong和AtomicReference。
考虑一个应用程序,其中多个线程试图访问相同的代码:
boolean open = false;
if(!open) {
// Do Something
open=false;
}
显然,上面的代码不是线程安全的,它在多线程环境中的行为是不可预测的。我们在这里的选择是将这段代码与锁同步或使用原子操作:
AtomicBoolean open = new AtomicBoolean(false);
if(open.compareAndSet(false, true) {
// Do Something
}
正如我们所看到的,使用像AtomicBoolean这样的非阻塞数据结构可以帮助我们编写线程安全的代码,而不会沉迷于锁的弊端!
5. 编程语言支持
我们已经看到有多种方法可以构建并发模块。虽然编程语言确实有所不同,但主要是底层操作系统如何支持这个概念。然而,由于本地线程支持的基于线程的并发性在可伸缩性方面遇到了新障碍,我们总是需要新的选择。
实施我们在上一节中讨论的一些设计实践确实被证明是有效的。但是,我们必须记住,它确实使编程本身变得复杂。我们真正需要的是能够提供基于线程的并发能力而又不会带来不良影响的东西。
我们可用的一种解决方案是绿色线程。**绿色线程是由运行时库调度的线程,**而不是由底层操作系统本地调度的线程。虽然这并不能消除基于线程的并发中的所有问题,但在某些情况下它肯定可以为我们提供更好的性能。
现在,使用绿色线程并非易事,除非我们选择使用的编程语言支持它。并非每种编程语言都有这种内置支持。此外,我们粗略地称为绿色线程的东西可以由不同的编程语言以非常独特的方式实现。让我们看看其中一些可供我们使用的选项。
5.1. Go 中的 Goroutines
Go 编程语言 中的 Goroutines是轻量级线程。它们提供可以与其他函数或方法同时运行的函数或方法。Goroutines非常轻,因为它们只占用几千字节的堆栈大小。
最重要的是,goroutines 与较少数量的本机线程复用。此外,goroutines 使用通道相互通信,从而避免访问共享内存。我们得到了几乎所有我们需要的东西,猜猜是什么——什么都不做!
5.2. Erlang 中的进程
在Erlang 中,每个执行线程称为一个进程。但是,这与我们目前讨论的过程不太一样!Erlang 进程**轻,内存占用小,创建和处理速度快,**调度开销低。
在幕后,Erlang 进程只不过是运行时为其处理调度的函数。此外,Erlang 进程不共享任何数据,它们通过消息传递相互通信。这就是为什么我们首先称这些为“过程”的原因!
5.3. Java 中的Fibers 提案)
Java 并发的故事一直在不断发展。Java 确实支持绿色线程,至少对于 Solaris 操作系统,一开始是这样。但是,由于超出本教程范围的障碍,这已经停止。
从那时起,Java 中的并发就是关于本机线程以及如何巧妙地使用它们!但出于明显的原因,我们可能很快就会在 Java 中拥有一个新的并发抽象,称为纤程。Project Loom 提议引入 continuations 和 fibers,这可能会改变我们用 Java 编写并发应用程序的方式!
这只是对不同编程语言可用内容的初步了解。其他编程语言尝试使用更有趣的方式来处理并发。
此外,值得注意的是,在设计高度并发的应用程序时,上一节中讨论的设计模式组合以及对类似绿色线程的抽象的编程语言支持可能非常强大。
6. 高并发应用
真实世界的应用程序通常有多个组件通过网络相互交互。我们通常通过 Internet 访问它,它由代理服务、网关、Web 服务、数据库、目录服务和文件系统等多种服务组成。
在这种情况下我们如何保证高并发呢?让我们探索其中的一些层以及我们用于构建高度并发应用程序的选项。
正如我们在上一节中看到的,构建高并发应用程序的关键是使用那里讨论的一些设计概念。我们需要为工作选择合适的软件——那些已经包含其中一些实践的软件。
6.1. 网络层
Web 通常是用户请求到达的第一层,这里不可避免地要提供高并发性。让我们看看有哪些选项:
- Node (也称为 NodeJS 或 Node.js)是基于 Chrome 的 V8 JavaScript 引擎构建的**开源跨平台 JavaScript 运行时。**Node 在处理异步 I/O 操作方面做得很好。Node 之所以做得这么好,是因为它在单个线程上实现了事件循环。在回调的帮助下,事件循环异步处理所有阻塞操作,如 I/O。
- nginx 是一种开源 Web 服务器,我们通常将其用作其他用途中的反向代理。nginx 提供高并发的原因是它使用了异步的、事件驱动的方法。nginx 在单个线程中与主进程一起运行。主进程维护执行实际处理的工作进程。因此,工作进程同时处理每个请求。
6.2. 应用层
在设计应用程序时,有几种工具可以帮助我们构建高并发。让我们检查一些可供我们使用的库和框架:
- Akka 是在 JVM 上**构建高度并发和分布式的应用程序。**Akka 处理并发的方法基于我们之前讨论的角色模型 。Akka 在参与者和底层系统之间创建了一个层。该框架处理创建和调度线程、接收和分发消息的复杂性。
- Reactor 是在 JVM 上**构建非阻塞应用程序。**它基于 Reactive Streams 规范,专注于高效的消息传递和需求管理 。Reactor 操作员和调度程序可以维持消息的高吞吐率。几个流行的框架提供反应器实现,包括 Spring WebFlux 和 RSocket。
- Netty 是一个异步的、事件驱动的网络应用程序框架。我们可以使用 Netty 来开发高并发的协议服务器和客户端 。Netty 利用NIO ,它是 Java API 的集合,可通过缓冲区和通道提供异步数据传输。它为我们提供了几个优势,例如更高的吞吐量、更低的延迟、更少的资源消耗以及最小化不必要的内存复制。
6.3. 数据层
最后,没有数据的应用程序是不完整的,数据来自持久存储。当我们讨论与数据库有关的高并发时,大部分的焦点都集中在 NoSQL 系列上。这主要是由于 NoSQL 数据库可以提供线性可扩展性,但在关系变体中很难实现。让我们看看两个流行的数据层工具:
- Cassandra 是一种免费的开源 NoSQL 分布式数据库,可在商用硬件上提供高可用性、高可扩展性和容错能力。但是,Cassandra 不提供跨多个表的 ACID 事务。所以如果我们的应用程序不需要强一致性和事务,我们可以受益于 Cassandra 的低延迟操作。
- Kafka 是一个分布式流媒体平台。Kafka 将记录流存储在称为主题的类别中。它可以为记录的生产者和消费者提供线性水平可扩展性,同时提供高可靠性和持久性。分区、副本和代理是它提供大规模分布式并发 的一些基本概念。
6.4. 缓存层
好吧,现代世界中没有任何以高并发为目标的 Web 应用程序能够承受每次都访问数据库。这让我们选择一个缓存 - 最好是可以支持我们的高并发应用程序的内存缓存:
- Hazelcast 是一种分布式、云友好的内存中对象存储和计算引擎,支持多种数据结构 ,例如Map、 Set、 List、 MultiMap、 RingBuffer和HyperLogLog。它具有内置复制并提供高可用性和自动分区。
- Redis 是一种内存数据结构存储,我们主要用作缓存。它提供了一个内存中的键值数据库,具有可选的持久性。支持的数据结构包括字符串、散列、列表和集合。Redis 具有内置复制并提供高可用性和自动分区。如果我们不需要持久化,Redis性能卓越的内存缓存 。
当然,在我们追求构建高度并发的应用程序的过程中,我们仅仅触及了可用资源的皮毛。重要的是要注意,除了可用的软件之外,我们的需求应该指导我们创建合适的设计。其中一些选项可能合适,而另一些可能不合适。
而且,我们不要忘记还有更多可用选项可能更适合我们的要求。