高并发应用程序的设计原则和模式

2025/03/14

1. 概述

在本教程中,我们将讨论一些随着时间的推移而建立的用于构建高度并发应用程序的设计原则和模式。

然而,值得注意的是,设计并发应用程序是一个广泛而复杂的主题,因此没有任何教程可以声称其处理是详尽无遗的。我们将在这里介绍的是一些经常使用的流行技巧!

2. 并发基础

在继续之前,让我们花一些时间了解基础知识。首先,我们必须澄清我们对并发程序的理解。如果多个计算同时发生,我们称程序为并发

现在,请注意我们提到了同时发生的计算-也就是说,它们同时进行。但是,它们可能会或可能不会同时执行。理解其中的区别很重要,因为同时执行的计算称为并行

2.1 如何创建并发模块?

了解我们如何创建并发模块很重要。有很多选择,但我们将在这里重点介绍两个流行的选择:

  • 进程:进程是正在运行的程序的一个实例,它与同一台机器上的其他进程隔离开来。机器上的每个进程都有自己独立的时间和空间,因此,通常不可能在进程之间共享内存,它们必须通过传递消息进行通信。
  • 线程:另一方面,线程只是进程的一部分,一个程序中可以有多个线程共享同一个内存空间。但是,每个线程都有唯一的堆栈和优先级。线程可以是本地的(由操作系统本地调度)或绿色的(由运行时库调度)。

2.2 并发模块如何交互?

如果并发模块不必通信是非常理想的,但通常情况并非如此。这产生了两种并发编程模型:

  • 共享内存:在该模型中,并发模块通过读写内存中的共享对象进行交互。这通常会导致并发计算的交错,从而导致竞争条件。因此,它可能不确定地导致不正确的状态。

  • 消息传递:在此模型中,并发模块通过通信通道相互传递消息进行交互。在这里,每个模块按顺序处理传入的消息。由于没有共享状态,因此编程相对容易,但这仍然无法避免竞争条件!

2.3 并发模块如何执行?

摩尔定律在处理器时钟速度方面遇到瓶颈已经有一段时间了,相反,由于我们必须进步,因此已经开始将多个处理器封装到同一个芯片上,通常称为多核处理器。但是,仍然很少听说有超过32个核心的处理器。

现在,我们知道单个核心一次只能执行一个线程或一组指令。但是,进程和线程的数量可能分别为数百和数千。那么,它到底是如何工作的呢?这是操作系统为我们模拟并发的地方,操作系统通过时间片来实现这一点-这实际上意味着处理器在线程之间频繁、不可预测和不确定地切换。

3. 并发编程中的问题

当我们着手讨论设计并发应用程序的原则和模式时,首先了解典型问题是什么是明智的。

在很大程度上,我们的并发编程经验涉及使用具有共享内存的本机线程。因此,我们将重点关注由此产生的一些常见问题:

  • 互斥(同步原语)交错线程需要对共享状态或内存有独占访问权,以保证程序的正确性。共享资源的同步是一种流行的实现互斥的方法,有几种同步原语可供使用-例如锁、监视器、信号量或互斥锁。但是,互斥编程容易出错,并且经常会导致性能瓶颈。有几个与此相关的经过充分讨论的问题,如死锁和活锁
  • 上下文切换(重量级线程):每个操作系统都有对并发模块(如进程和线程)的原生支持,尽管有所不同。如前所述,操作系统提供的一项基本服务是通过时间分片调度线程在有限数量的处理器上执行。现在,这实际上意味着线程在不同状态之间频繁切换。在此过程中,需要保存和恢复它们当前的状态,这是一项直接影响整体吞吐量的耗时活动。

4. 高并发设计模式

现在,我们了解了并发编程的基础知识和其中的常见问题,是时候了解一些避免这些问题的常见模式了。我们必须重申,并发编程是一项需要大量经验的艰巨任务。因此,遵循一些既定模式可以使任务更容易。

4.1 基于Actor的并发

我们将讨论的关于并发编程的第一个设计称为Actor模型,这是一个并发计算的数学模型,基本上将所有事物都视为一个Actor。Actor可以相互传递消息,并且可以响应消息做出本地决策。这是由Carl Hewitt首次提出的,并启发了许多编程语言。

Scala的并发编程的主要构造是Actor,Actor是Scala中的普通对象,我们可以通过实例化Actor类来创建它们。此外,Scala Actors库提供了许多有用的Actor操作:

class myActor extends Actor {
    def act() {
        while(true) {
            receive {
                // Perform some action
            }
        }
    }
}

在上面的示例中,在无限循环中调用receive方法会暂停Actor,直到消息到达。到达后,消息从Actor的邮箱中删除,并采取必要的行动。

Actor模型消除了并发编程的一个基本问题-共享内存,Actor通过消息进行通信,每个Actor依次处理来自其专属邮箱的消息。但是,我们通过线程池执行Actor。我们已经看到本机线程可能是重量级的,因此数量有限。

4.2.基于事件的并发

基于事件的设计明确解决了本机线程的生成和操作成本高昂的问题。基于事件的设计之一是事件循环,事件循环与事件提供者和一组事件处理程序一起工作。在此设置中,事件循环在事件提供者上阻塞,并在到达时将事件分派给事件处理程序

基本上,事件循环不过是一个事件调度器!事件循环本身可以只在一个本地线程上运行。那么,事件循环中到底发生了什么?让我们以一个非常简单的事件循环的伪代码为例:

while(true) {
    events = getEvents();
    for(e in events)
        processEvent(e);
}

基本上,我们所有的事件循环所做的就是不断地寻找事件,并在找到事件时处理它们。该方法非常简单,但它获得了事件驱动设计的好处。

使用此设计构建并发应用程序可为应用程序提供更多控制。此外,它还消除了多线程应用程序的一些典型问题,例如死锁。

JavaScript实现事件循环以提供异步编程,它维护一个调用堆栈来跟踪所有要执行的函数。它还维护一个事件队列,用于发送新函数进行处理。事件循环不断检查调用堆栈并从事件队列中添加新函数,所有异步调用都被分派到Web API,通常由浏览器提供。

事件循环本身可以在单个线程上运行,但Web API提供单独的线程。

4.3 非阻塞算法

在非阻塞算法中,一个线程的暂停不会导致其他线程的暂停。我们已经看到,我们的应用程序中只能有有限数量的本机线程。现在,阻塞线程的算法显然会显着降低吞吐量并阻止我们构建高度并发的应用程序

非阻塞算法总是利用底层硬件提供的CAS原子原语,这意味着硬件会将内存位置的内容与给定值进行比较,只有当它们相同时才会将值更新为新的给定值。这可能看起来很简单,但它有效地为我们提供了一个原子操作,否则将需要同步。

这意味着我们必须编写新的数据结构和库来利用这个原子操作,这为我们提供了多种语言的大量无等待和无锁实现。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中的协程

Go编程语言中的Goroutines是轻量级线程,它们提供可以与其他函数或方法同时运行的函数或方法。Goroutines非常便宜,因为它们只占用几千字节的堆栈大小

最重要的是,Goroutines与较少数量的本机线程复用。此外,Goroutines使用通道相互通信,从而避免访问共享内存。

5.2 Erlang中的进程

Erlang中,每个执行线程称为一个进程。但是,这与我们目前讨论的过程不太一样!Erlang进程重量轻,内存占用小,创建和处理速度快,调度开销低

在幕后,Erlang进程只不过是运行时为其处理调度的函数。此外,Erlang进程不共享任何数据,它们通过消息传递相互通信。这就是为什么我们首先称这些为“进程”的原因!

5.3 Java中的纤程(提案)

Java并发的故事一直在不断发展。Java确实支持绿色线程,至少对于Solaris操作系统,一开始是这样。但是,由于超出本教程范围的障碍,这已经停止。

从那时起,Java中的并发就是关于本机线程以及如何巧妙地使用它们!但出于明显的原因,我们可能很快就会在Java中拥有一个新的并发抽象,称为纤程。Project Loom提议引入Continuations和Fibers,这可能会改变我们用Java编写并发应用程序的方式!

这只是对不同编程语言可用内容的初步了解,其他编程语言尝试使用更有趣的方式来处理并发。

此外,值得注意的是,在设计高度并发的应用程序时,上一节中讨论的设计模式组合以及对类似绿色线程的抽象的编程语言支持可能非常强大。

6. 高并发应用

真实世界的应用程序通常有多个组件通过网络相互交互。我们通常通过互联网访问它,它由代理服务、网关、Web服务、数据库、目录服务和文件系统等多种服务组成。

在这种情况下我们如何保证高并发呢?让我们探索其中的一些层以及我们用于构建高度并发应用程序的选项。

正如我们在上一节中看到的,构建高并发应用程序的关键是使用那里讨论的一些设计概念。我们需要为工作选择合适的软件-那些已经包含其中一些实践的软件。

6.1 网络层

Web通常是用户请求到达的第一层,这里不可避免地要提供高并发性,让我们看看有哪些选项:

  • Node是基于Chrome的V8 JavaScript引擎构建的开源跨平台JavaScript运行时,Node在处理异步I/O操作方面做得很好,Node之所以做得这么好,是因为它在单个线程上实现了事件循环。在回调的帮助下,事件循环异步处理所有阻塞操作,如I/O。
  • Nginx是一种开源Web服务器,我们通常将其用作其他用途中的反向代理。Nginx提供高并发的原因是它使用了异步、事件驱动的方法。Nginx在单个线程中与主进程一起运行,主进程维护执行实际处理的工作进程。因此,工作进程同时处理每个请求。

6.2 应用层

在设计应用程序时,有几种工具可以帮助我们构建高并发。让我们检查一些可供我们使用的库和框架:

  • Akka是一个用Scala编写的工具包,用于在JVM上构建高度并发和分布式的应用程序。Akka处理并发的方法基于我们之前讨论的Actor模型。Akka在Actor和底层系统之间创建了一个层,该框架处理创建和调度线程、接收和分发消息的复杂性。
  • Project Reactor是在JVM上构建非阻塞应用程序的响应式库,它基于Reactive Streams规范,专注于高效的消息传递和需求管理(背压)。Reactor运算符和调度程序可以维持消息的高吞吐率,几个流行的框架提供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是一种内存数据结构存储,我们主要用作缓存。它提供了一个内存中的键值数据库,具有可选的持久性。支持的数据结构包括String、Hash、List和Set。Redis具有内置并提供高可用性和自动分区,如果我们不需要持久化,Redis可以为我们提供功能丰富、网络化、性能卓越的内存缓存。

当然,在我们追求构建高度并发的应用程序的过程中,我们仅仅触及了可用资源的皮毛。重要的是要注意,除了可用的软件之外,我们的需求应该指导我们创建合适的设计。其中一些选项可能合适,而另一些可能不合适。

而且,我们不要忘记还有更多可用选项可能更适合我们的要求。

7. 总结

在本文中,我们讨论了并发编程的基础知识。我们了解并发的一些基本方面及其可能导致的问题。此外,我们还介绍了一些可以帮助我们避免并发编程中的典型问题的设计模式。

最后,我们了解了一些可用于构建高度并发的端到端应用程序的框架、库和软件。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章