并发编程关键模型及语言实现
2021-08-06高永强
高永强
(武警工程大学信息工程学院,西安710086)
0 引言
传统并发编程一般采用多进程或者多线程的方式,需要考虑数据竞争、同步互斥、死锁等等问题,使开发并发程序尤其是大型服务器程序的难度大大加大。大量的进程或线程创建不仅会占用大量内存,而且随着线程数量的增多,多线程之间切换的开销是不容忽视的,会浪费大量CPU时间在调度上[1]。在当今互联网高并发场景下,迫切需要新的并发编程方式来满足高并发的需要,同时降低并发程序的开发难度。
并发编程最大的困难就在与对共享资源的竞争,CSP(Communicating Sequential Process,通讯顺序进程)和Actor模型都是基于消息传递的并发编程模型,它们的并发体之间不共享内存,由此可以在业务代码层面实现无锁并发。同时,CSP模型、Actor模型和协程模型中并发体的调度和切换发生在用户态,大幅降低了切换开销。
1 CSP模型
1.1 CSP模型基本概念
CSP是贝尔实验室的Tony Hoare在1978年提出的一种并发模型。CSP有着精确的数学模型,并实际应用在了Hoare参与设计的T9000通用计算机上。CSP模型中最重要的两个概念是Process(进程)和Channel(通道)。这里的进程和传统多进程编程中的进程有所区别,CSP中的进程是实际并发执行的实体,是一种运行在用户态的用户线程,其调度不是由操作系统来完成的,而是由编程语言的运行时进行调度。CSP中通道是第一类对象,两个独立的并发实体通过共享的通道进行通信。在Java、C++、或者Python等语言的多线程编程中,线程间通信都是通过共享内存的方式来进行的,在访问共享数据时,必须通过互斥锁或信号量等机制确保数据的一致性。不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。在CSP模型中,程序就是一组无共享状态进程的并行组合,进程间的通信和同步是通过Channel完成的。Process和Channel之间的关系如图1所示。
图1 Process和Channel之间的关系
1.2 CSP模型在Go语言中的实现
Go语言是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言。Go语言强大的并发编程能力,使其在微服务架构和云原生技术领域大放异彩,Docker、Kubernetes和etcd等软件均是采用Go语言进行开发的[2]。Go语言中通过Goroutine(Go协程)与Channel实现了CSP模型中的核心概念Process和Channel。Process在go语言上的表现就是Gorou⁃tine,它是实际并发执行的实体,每个实体之间是通过Channel通讯来实现数据共享。
(1)Goroutine的特点
不同于Python基于进程的并发模型,以及C++、Java等基于线程的并发模型,Go语言采用轻量级的Goroutine来实现并发。Goroutine是Go语言中并发的执行单位,通过Go关键字可以简单而快速地创建一个Goroutine。与线程相比,Goroutine具有以下特点:
●用户态。Goroutine处于用户态,由Go语言调度器进行调度,避免了内核态和用户态的切换导致的成本。Goroutine之间切换的开销要比线程切换的开销小得多,一个Go语言程序可以轻而易举地创建成千上万个Goroutine,而操作系统能够创建的线程数量要少得多。
●轻量级。在一般的操作系统中,线程栈的大小是固定的且在运行过程中线程栈的大小不能伸缩,例如Linux系统默认线程栈大小是2MB。Goroutine默认栈要比线程栈小很多,一个Goroutine只占几KB,并且Goroutine的栈是可伸缩的。
●通信灵活。Goroutine既可以通过共享内存进行通信,也可以通过Channel进行通信,给并发编程带来了较大的灵活性。
(2)Goroutine的调度
Goroutine处于用户态,最终要在操作系统线程上执行,Go语言调度器负责将多个Goroutine复用到线程上。调度器是线程和Goroutine的中间层,通过调度器的调度,每一个内核线程都能够执行多个Goroutine,并且在Goroutine进行一些I/O操作时及时切换,提高线程的利用率。Go目前使用的调度器是基于GMP模型重新设计的,其中G表示Goroutine,它是一个待执行的任务;M表示操作系统的线程,它由操作系统的调度器调度和管理;P表示处理器,它可以被看做运行在线程上的本地调度器。
(3)Go Channel
(1)给出输入信号x(t),设置迭代次数,通常情况下,迭代次数越高,分解越精确,但是同时所花时间也越长。将重建信号初始化置零。
作为Go核心的数据结构和Goroutine之间的通信方式,Channel是支撑Go语言高性能并发编程模型的重要结构。在很多主流的编程语言中,多个线程一般通过共享内存方式进行通信和传递数据。虽然在Go语言中也能使用共享内存加互斥锁进行通信,但推荐的方式是通过Channel进行Goroutine之间的通信。“不要通过共享内存来通信,要通过通信来共享内存”是Go语言重要的设计哲学。
Go语言中的Channel是一种队列式的数据结构,遵循先入先出的规则。Go中Channel的容量可以为0,容量为0的Channel被称为无缓冲的Channel,容量大于0的Channel被称为有缓冲的Channel。对于无缓冲Channel,如果向Channel发送数据的Goroutine先被调用,则该Goroutine将被挂起直到接收数据的Gorou⁃tine被调用。同样,如果接收数据的Goroutine先被调用,它将被挂起直到发送数据的Goroutine被调用。有缓冲的Channel和阻塞队列非常类似,如果Channel已满,那么向Channel发送数据的Goroutine将被挂起。反之,如果Channel为空,从Channel接收数据的Gor⁃outine将被挂起。Channel支持创建、接收、发送和关闭四个操作。与BlockingQueue不同的是,Channel可以被关闭,发送者关闭通道来表明没有更多的元素将会进入通道。Go从语言层面保证同一个时间只有一个Goroutine能够访问Channel里面的数据。基于这些特性,使用Channel可以轻松实现Goroutine之间的同步和互斥。
2 Actor模型
2.1 Actor模型基本概念
Actor模型是一种并发计算模型,其中的Actor是计算的基本单位,1973年Carl Hewitt在论文A Universal Modular Actor Formalism for Artificial Intelligence中首次提出Actor模型[3]。Actor模型由一个个称为Actor的执行体和Mailbox(邮箱)组成。在Actor理论中,一切都被认为是Actor,一个Actor实例是执行计算的最小单元,拥有自己的状态和行为,它能接收一个消息并且基于消息内容执行计算任务。
Actor与Actor之间只能通过消息进行通信,一个Actor可以发送消息给其他Actor,也可以从其他Actor接收消息,如图2所示。Actor模型内部的状态由自己的行为维护,外部线程不能直接调用对象的行为,保证了Actor内部数据只有被自己修改。Actor的一大重要特征在于Actor之间相互隔离,它们并不互相共享内存,一个Actor能维持一个私有的状态。由于Actor之间没有共享数据,所以可以轻松实现无锁并发。
图2 Actor之间的通信方式
每个Actor都有一个属于自己的信箱。Actor的信箱类似一个队列,发送到Actor的消息依次存入目标Actor的信箱中等待处理。每个Actor是串行处理信箱中的消息的,这样在Actor内部保证了不会出现并发安全问题。当一个Actor接收到消息后,它能做如下三件事中的一件:创建其他Actor;向其他Actor发送消息;指定当前的Actor如何处理下一个消息。这样的设计解耦了Actor之间的关系,且发送消息时不会被阻塞。虽然所有Actor可以同时运行,但它们都按照信箱接收消息的顺序来依次处理消息,且仅在当前消息处理完成后才会处理下一个消息。
2.2 Actor模型在Scala语言中的实现
Scala语言的Akka库实现了Actor模型,其借鉴了Erlang的Actor模型实现,同时又引入了许多新的特性,为并发编程提供了强大的工具[4]。Akka是一款优秀的分布式并发框架,由Scala语言开发,运行于Java虚拟机之上,同时支持使用Scala、Java和Kotlin等基于JVM的语言进行编程。在Scala语言中仍然可以使用Java线程,但是使用Akka中的Actor模型是更好的选择。Akka中的Actor是一个比线程更高层的抽象,最终是跑在Java的线程中的,多个Actor在底层可以共享一个线程。使用Akka进行并发编程可以不用担心底层线程、锁和共享数据冲突等传统多线程编程面临的问题。Akka提供了丰富的组件,比如邮箱、路由组件、持久化组件等,在底层对分布式和并行模式进行了高度且统一的抽象,使用很少的代码就可以实现一个完整的高并发分布式应用[5]。
(1)Akka中Mailbox的并发安全机制
对于Mailbox存在两个操作,一个是向Mailbox中写入消息,一个是从Mailbox中读取消息,因此可能会出现一条消息在被写入Mailbox中还没结束的时候,就被Actor读取走的情况,这就会引发并发安全问题。所以Mailbox必须保证消息完整地写入后才能被接收处理,即Mailbox必须是并发访问安全的。Mailbox的底层数据结构是一个线程安全的存储消息的队列,Scala标准库中的LinkedBlockingDeque和ConcurrentLinked⁃Queue等线程安全队列均可以作为Mailbox的底层基础结构。但Akka没有采用这种方案,而是实现了一个AbstractNodeQueue数据结构,这种结构是一个功能更加明确的队列,专门为Mailbox的需求所设计,在兼顾较高性能的同时,保证了上层Mailbox的并发访问安全。
(2)Actor之间的消息传递
在Akka中,可以使用tell和ask两种方式向Actor发送消息,它们都以异步的方式发送消息,不同的是,前者发完后立即返回,而后者会返回一个Future对象,假如在设置的时间内没有得到返回结果,消息的发送方会收到一个超时异常。
(3)Akka中Actor的层级关系
如图3所示,Actor系统从上到下有严格的层级关系。与父进程可以创建子进程类似,一个Actor也可以创建多个子Actor,最终形成一种树形结构。当子Ac⁃tor在处理消息时发生了异常,父Actor可以通过预先设定的动作进行处理,处理方式有:恢复子Actor、重启子Actor、停止子Actor、向上级Actor报告,这样的处理方式被称为“父监督”模式。
图3 Actor系统的树形结构
3 协程
3.1 协程基本概念
协程并不是一个新概念,早在1963年协程这一概念就被提出[6-7],并在古老的Simula和Modula-2语言中得到了实现。但长期以来,协程这一概念并没有引起足够的重视,主流编程语言也鲜见对于协程的支持。随着云计算、大数据时代的到来,如何提高软件的并发能力以充分利用硬件性能成为重要的研究课题。在高并发条件下,协程具有上下文切换开销小和资源利用率高的特性而重新得到开发人员的重视。随着Lua、Golang、Kotlin等主流语言对于协程的支持越来越完善,协程重新登上历史舞台。
协程又称纤程,是一种用户级线程,操作系统不知道协程的存在。协程与进程或线程最直接的区别表现在,协程是编译器层面的概念,而进程与线程则是操作系统层面的概念。协程能够被挂起,稍后再在挂起的位置恢复执行,其挂起和恢复是由开发者的程序逻辑自己控制的,通过主动挂起让出运行权可以实现协程之间的协作。和进程与线程相比,协程具有以下几个优势。其一,创建协程消耗的系统资源更小。协程的栈空间可以根据需要进行扩容和缩容,最小为内存页长,而线程的栈空间大小一般为MB级别。其二,协程之间的切换代价更小。协程的调度发生在用户态,避免了线程上下文切换带来的开销。其三,协程可以实现无锁编程。
2004年Lua语言之父Roberto Ierusalimschy在其发表的论文Revisiting Coroutines中,根据协程的实现方式的差异对协程进行了分类[8]。
按是否开辟调用栈,将协程分为有栈协程和无栈协程。有栈协程具有自己的调用栈,协程挂起时其中断状态会保存在调用栈中。其优点是可以在任意函数调用层级的任意位置挂起,并转移调度权。Lua语言中的协程是这种实现方式的典型代表。与之相对应,无栈协程没有自己的调用栈,挂起点的状态则是通过状态机或者闭包等语法来实现。其优点是内存开销较小,Python语言中的Generator是这种协程的典型代表。
按调度方式的不同,将协程分为对称协程和非对称协程。对称协程中每一个协程都是地位平等且相互独立的,调度权可以在任意协程之间转移。上文中提到的Go语言中的Goroutine是这种实现方式的典型代表,Goroutine可以通过对Channel的读写实现控制权的自由转移。而非对称协程在挂起后,只能将调度权出让给它的调用者,即协程之间存在调用与被调用的关系。Lua语言中的协程是典型的非对称协程,当前协程调用yield后总是将调度权让给之前调用它的协程。
协程是用户态的概念,其最终还是要运行在操作系统线程上,根据协程和线程的对应关系,可以分为多对一、一对一和多对多三类。多对一是指多个协程对应一个底层线程,只要内存足够,一个线程中可以有任意多个协程,多个协程共享该线程分配到的计算机资源。其优点是协程切换时不会进入内核态,从而减少切换开销。一对一是指协程和底层线程是一一对应的关系,这种方式可以充分利用多核性能,但是协程切换会进入内核态,开销较大。多对多是指协程和底层线程没有特定对应关系。某一协程在某时刻可以在线程A执行,一段时间之后又可能在线程B上执行。这种方式融合了前两种方式的优点,但是实现较为困难。
3.2 协程在Kotlin语言中的实现
Kotlin是一种运行在Java虚拟机上的静态类型语言,由JetBrain公司设计开发。Kotlin目前是JVM语言家族中的重要成员之一,它的出现充分弥补了Java缺乏现代化编程语言特性的缺憾[9]。在Google I/O 2017大会上,Google宣布Kotlin成为Android官方开放语言。
Kotlin是少有的几门从语法和标准库两个层面对协程提供支持的编程语言。在语法层面,被suspend关键字修饰的函数称为挂起函数,Kotlin协程的挂起和恢复本质上就是挂起函数的挂起和恢复[10]。挂起函数只能在协程体内或其他挂起函数内调用,不能被普通函数调用。同时,除了标准库以外,Kotlin官方还推出了面向生产环境的kotlin.coroutines框架,该框架提供了丰富的编程接口和组件来支撑生产环境中异步高并发程序的设计和实现,例如热数据通道Channel、冷数据流Flow等高级数据结构。
Kotlin语言除了可以编译为字节码运行在Java虚拟机上以外,还可以通过LLVM编译链工具最终编译成机器码,这一多平台特性大大扩展了协程的应用场景。目前Kotlin对于协程的支持正在不断完善,快速迭代,相信不久的将来,必将给开发者带来开发体验和开发效率上的全方位提升。
4 模型之间的比较
如表1所示,协程、CSP模型和Actor模型与传统的多进程/多线程相比,具有通信方式灵活、并发度高、调用栈较小和开销低的特点。
表1 并发编程模型之间的比较
进程、线程和协程的异同点表现在以下几个方面:每个进程拥有独立的堆和栈,进程之间既不共享堆,也不共享栈,其调度由操作系统负责。每个线程拥有独立的栈和共享的堆,线程之间不共享栈,但同一进程内的线程共享堆空间,其调度亦由操作系统负责。协程,也是共享堆,不共享栈,但协程不是操作系统调度单元,而是由用户调度。
CSP模式和Actor模式有许多共同点,两者都通过消息传递的方式来避免共享内存引发的数据竞争问题,Actor模型中的Mailbox与CSP模型中的Channel都满足先进先出的特性。但CSP与Actor有以下几点比较大的区别:
(1)Actor模型中的Mailbox对程序员是透明的,Mailbox明确归属于某一个特定的Actor,是Actor模型的内部机制。CSP模型中的Channel对于程序员来说是可见的,必须由程序员手动创建。
(2)Actor模型中发送消息是非阻塞的。相反,CSP中的Channel是一个阻塞队列,当Channel已满时,继续向Channel发送数据,会导致发送消息的Goroutine被挂起。当Channel为空时,继续从Channel读取数据,会导致接收消息的Goroutine被挂起。
(3)Actor模型理论上不保证消息百分比送达,而Go实现的CSP模型中,能保证消息百分百送达。
(4)Actor模型中,Actor与Mailbox是一对一的关系,每个Actor有且只有一个Mailbox,而在CSP中Channel和Process之间没有从属关系,两者之间是多对多的关系,Process可以订阅任意个Channel,一个Channel也可以被多个Process操作。
5 结语
随着多核CPU的高速发展和大规模分布式应用的逐渐普及,提高软件系统的并发能力是当前亟待解决的问题,传统的多进程和多线程编程方式无法较好地满足快速构建高可用、高性能和高并发的分布式应用的要求。在当今互联网高并发场景下,迫切需要新的并发编程方式来满足高并发的需要,同时降低并发程序的开发难度。使用传统并发编程方式写出一个高性能并且可扩展的并发程序是相当困难的。高并发程序设计既是一个重点,也是一个难点。
提高系统的并发能力主要有垂直扩展和水平扩展两种方式,垂直扩展通过提升单机性能来提高系统的并发性能,水平扩展则通过增加服务器数量提升并发性。未来分布式高并发应用场景的不断多样化,提高系统的并发性必将朝着垂直扩展和水平扩展相结合的方向发展。CSP、Actor和协程等并发计算模型是进行垂直扩展,提高系统并发能力的重要手段,随着编程语言对并发计算模型的支持不断完善,新的编程方式必将大大提升应用开发效率。