Go并发程序设计

CSP模型下并发程序的设计模式

Posted by John Mactavish on January 16, 2020

导论

并发程序的设计问题,自从操作系统被提出以来就存在了。在操作系统的实现当中,对并发一般都是采用共享内存模型,使用的同步工具通常包括 锁、信号量等。在很多语言,比如 Java 中对操作系统提供的系统调用进行简单封装之后就提供给用户使用。而后来发展的一些并发模型,则在此基础上进行进一步抽象。其目的就是要在保证并发安全性基础上,提高开发效率,进一步解放开发者,让他们能专注于业务逻辑的设计上。 Go 语言原生实现了基于 CSP 模型的并发。这种并发模型提倡采用通信来代替内存共享。因为模型的不同,CSP 并发程序的设计与传统的并发程序设计有很多的不同。这篇文章,将以 Go 语言为例,从语言原生的特性出发,介绍并发的基本原则和小模块的实现方式,最后给出实际程序的设计模式。

基本原则和小模块的实现

资源独占原则

如果多个协程都有权访问同一个资源,就有可能出现读写不一致问题,加锁又可能导致死锁,活锁问题。所以我们的一个设计原则就是资源独占。我们通常会针对某一资源专门设计一个协程,来独占地管理资源。比如日志文件就可以看成是一个资源,我们可以让一个协程专门管理它。其他的协程无法直接访问这一资源,如果需要输出日志,就只能通过通信的方式来请求日志协程处理。因为对这个资源而言,自始至终都是单线程处理的,所以就不会出现并发安全问题。

状态隔离原则

我这里所说的是状态,而非数据,因为数据可能是并发安全的,如不可变数据。无论有多少个协程同时访问它,都无法对其进行改写,从而从根本上确保了其一致性。其不隔离所带来的问题,主要是封装的问题,也就是把不必要的信息暴露出去。而状态之所以称之为状态,就是因为其是可改变的。在 Java 等语言中,通常会把这些并发不安全的状态封装在一起,并为其读写过程加锁,从而提供原子性保证。同时要防止这些状态的引用发生泄露。

在 Go 语言中,我们不提倡公用占据同一块内存的状态。所以我们要关注的是状态的隔离问题。还是以上面的日志协程为例,为了管理资源,自然会在内存中维护一个对象,包括日志的各种属性,像下面这样。

type log struct {
    file     *os.File
    line     int
    version  string
    logLevel level
    // ......
}

那么我们就应当把这个对象限制在这个协程内部使用以在实际上保证资源独占原则。编写代码时应该会把日志协程这一部分专门放在一个 package 里,同时将类设为私有(即类名和构造函数名首字母小写)。那么在其他地方,我们既不能创建这个对象,更不能访问这个对象的方法。package 仅向外提供启动协程的 API。同时也要求我们在编写协程的程序时不对外传递内部对象的引用(因为 Go 语言不支持引用绑定,所以开发者要当心引用泄露)。

父协程保留控制权

协程之间的关系并不是完全平等的。我们在创建一个新协程时,把创建者称为父协程,被创建的是子协程。Go 语言中创建协程是以函数调用的形式启动。子协程可以通过函数参数或闭包这两种形式从父协程中继承信息。获得的信息是有限的,因此父协程通常控制着更多的上下文信息,简单来说就是它可访问与控制的对象更多。正是由于这种信息不对等的状态,它们之间的关系是主仆关系。因此,最好让信息更多的父协程来控制子协程的活动。

其中最主要的一点就是父协程要有权能杀死子协程。Go 语言其实没有真正意义上的杀死协程的方法。我们通常是通过发信号让子协程自然退出。这就要求有一个信号通道,并且子协程要按照约定的方式编写。我们通常像这样写,子协程会一直轮询一个信号通道。在正常情况下,父协程不会对这个通道进行任何操作;当需要关闭子协程时,它向其中发送任意值,或者采取更常用的方法,关闭这个通道。之后子协程立马就可以从通道中获得一个值,然后自然退出。

foo := func(done <-chan struct{}){ // 只读 chan,添加对通道引用的限制
    // 循环内多路复用,常见的写法
    for{
        select{
            case <-done:
                cleanUp()
                return
            default: // 这个分支用于防堵塞
        }
        // do something
    }
}

// 父协程里命名为 handle,是不是和句柄的行为很像呢
var handle = make(chan struct{})
go foo(handle)
time.Sleep(1*time.Second)
close(handle)

在通道内包装更多信息

我们的通道当然也可以传递自定义类型,这样我们就可以在里面提供更多附加信息。比如说,协程如果在操作过程中遇到了错误,它可以向返回通道传递下面这个结构体。父协程查看结果前首先会进行错误校验,这就和函数调用时返回值的设计思想不谋而合了。

type Result struct{
	result int
	err    error
}

对于我们之前提到的终止信号 done,在 Go 语言的 context 包中,基于这个提供了包装类。具体的内容可以查看《Go语言专家编程》等书籍。 它不仅提供了手动关闭子协程的方法,还提供了定时退出功能,做超时管理;还可以保存每一层协程的一些信息。与前一种方法相比,这种包装类更加适用于协程树形调用等多级的、复杂的并发模式;而前一种在简单的协程控制中更加简洁。

通道是无主的,持有引用的协程就能对其进行操作。假如我们让一个守护进程式的协程为特定的请求者服务,可以让请求者把结果输出通道包装在请求中给出。

// 伪代码演示,实际情况要考虑更周到一点
type Request struct{
    request RequestMode
    result  chan Result
}

协程函数的编写形式

Go 语言中使用类似函数调用的方式启动协程。我们可以把协程的函数抽象成一种控制流,数据流在控制流中流动便是整个处理过程。数据流应该通过通道传递,而不应该在启动协程时作为参数输入。参数应该是通讯用的通道。协程函数没有返回值,我们可以把结果输出通道通过输入参数传入,但是这样一来,父协程就获得了结果输出通道的双向引用,一旦父协程关闭通道而子协程继续写入结果,就会触发 panic 。所以我们可以给它做一层包装以达到结果输出通道做返回值的效果,并且返回值应该是通道的单向(接收方)引用;语言语法保证接收方无法关闭通道。

var handle = make(chan struct{})
var data = make([]byte, 1024)
go foo1(handle, data) // data 属于数据流,传递引用不安全,因为父协程可以修改 data

var result = make(chan byte, 64) // 子协程输出返回值用的通道
go foo2(handle, result) // 返回通道做函数输入值,不好

result:=startFooRoutine(handle) // 父协程无法 close(result)
func startFooRoutine(done <-chan struct{}) <-chan byte {
    var result = make(chan byte, 64)
    go foo(){
        defer close(result)
        // process something
    }()

    return result
}

心跳包

我们可以让子协程周期性地返回心跳包从而让父协程知晓,协程工作正常。这可以用于监控的情形下,这里不详细讨论,具体可以参考 Concurrency In Go (特指英文版,因为中文版的翻译比较差,名字叫《Go语言并发之道》)。

程序设计步骤

首先,分析程序涉及到的资源,从而确定独占它的协程。我们可以想一想,程序中会有哪些资源?资源要怎么分?需不需要分得更细?比如终端作为一种资源,它又可以分为标准输入和标准输出。我们是否要把它分开,要从我们程序逻辑上来考虑,看两者之间耦合性是否强。假如我们在终端上进行用户交互:展示菜单,等待用户键盘输入字符,以进入下一步。这是强耦合的,输入输出就不应当分开,应该把终端算作整个资源。

然后围绕资源设计类(struct)。这里我们不需要像传统的并发程序一样,明确对象内的可变状态,把它们和线程安全的数据分开;因为我们接下来会把这一对象完全隔离在一个域(协程)中。

接下来,通过对执行逻辑的分析,来确定每一个协程的生命期。自然,主协程的生命期和整个程序相同。又比如,对于日志管理协程的控制流,它启动时需要时间初始化本地日志库,之后维护日志对象的状态,接收日志读取或写入请求,操作日志文件。因为协程启动有耗时,且所有协程都会用到日志系统,所以日志协程最好一次启动、持续服务,生命期应该接近整个程序的生命期。

再然后,确定协程的控制流是否可以多开。翻译成人话,就是我们能不能就一个函数像下面这样启动多个协程。

go OnceOnlyLogger()

for request := range requests{
    go handle(request) // handle 函数的控制流多开
}

这还是从原则上考虑。像日志这样独占资源的我们不能重复它的控制流,否则就会在底层资源的控制上发生冲突。但是如果资源是分开的,我们就可以把它变成生产者消费者模式。比如服务端程序,为每个用户做业务处理;用户所占据的文件空间,以及他的有关状态属于同一类型不同对象的资源,我们可以多开控制流来进行服务。

最后,分析各个协程间的通信关系,确定如何分发通道的引用。在 CSP 模型中,谁持有了通道的引用,谁就可以参与通信,因此我们要仔细考虑通道引用的分发问题。现在再一次仔细考虑我们要设计的程序,理清各个协程的通信关系。比如对于我们的日志协程,其他协程都需要写入日志;因此我们的日志协程应当为所有协程服务,所以所有的协程都应该持有通向日志的通道引用。要实现这一点,可以把这个通道变成全局变量,但是,从编程规范角度考虑,我们最好还是把这个工作交给主协程。主协程首先启动日志协程,获得通道,然后把通道分发给需要的协程。同时我们注意到日志的通道还应该分为读取和写入两部分,写入可以给所有协程,但是不应当让所有协程都有权读取日志。所以我们可以把读写分成两个通道,同样的,我们把两个通道都交给主协程负责分发。同时要注意通过分发单向通道可以起到一些限制作用

常用的模式

第一种是守护协程。顾名思义,它工作方式类似于守护进程,贯穿程序的整个生命期。还是我们说的日志协程,启动有耗时,生命期长,就按照这种模式设计。程序初始化时由主协程启动,其通道的引用由主协程负责分发;协程工作在一个大 Loop 中,从请求通道中取请求=》处理请求( =》返回结果 ),循环往复地工作。

// main routine
var LoggerHandle = make(chan struct{})
LogRequestChan := StartLoggerRoutine(LoggerHandle)
var SomeHandle = make(chan struct{})
SomeRequestChan := StartSomeRoutine(SomeHandle, LogRequestChan)

小巧的助手模式。很多情况下,我们只是单纯地想通过协程解决一些简单的小问题,这个时候我们就可以使用助手模式。比如,我们可以写一个小协程,来帮助我们对指定数据进行排序,好让我们的主协程可以去处理其他事务。对于助手模式而言,开辟过多的通道进行交流,反而失去了简洁特性。所以,我们可以有所取舍,允许助手协程获得一些引用。助手协程通常使用匿名函数给出,所以引用可以通过闭包捕获。注意,给助手协程的引用必须是由父协程独占的,否则会与其他协程冲突。同时,对于助手协程,一定要尽可能的逻辑简单、执行速度较快,最好不包含可能会引发错误的逻辑,以免使问题复杂化。然后我们可以采用一个简单的同步通道,以保证主协程在知晓助手协程退出之后收回它的引用继续操作。可以看出,在这里并不是绝对意义上的并发安全,它的安全性很大程度上依赖于开发者的自觉,无法从语言上保证。并发安全性与程序的简洁之间有时是要有取舍的。

data := LoadData()
var finished = make(chan struct{})
go func(){
    data.Sort()
    finished<-struct{}{}
}
// do other things without using data

<-finished
// now we can use data safely


最后附上GitHub:https://github.com/gonearewe