在设计并发程序时,一个经常出现的决策是将程序状态表示为控制流还是数据。本文将讨论这个决策的含义以及如何处理它。如果处理得当,将存储在数据中的程序状态改为存储在控制流中,可以使程序比原本更清晰、更易维护。
在深入探讨之前,重要的是要注意并发和并行不是一回事:
- 并发是关于编写程序的方式,关于能够独立执行控制流(无论你将它们称为进程、线程、协程、goroutine等),使得你的程序可以同时处理多个任务而不会变成一团糟。
- 另一方面,并行是关于执行程序的方式,允许多个计算同时运行,以使得你的程序可以高效地同时执行多个任务。
并发天然适合于并行执行,但本文关注的重点是如何利用并发编写更清晰的程序,而不是更快的程序。
并发程序和非并发程序的区别在于,并发程序可以被编写得好像它们在同时执行多个独立的控制流。这些较小的控制流的名称因语言而异:线程、任务、进程、纤程、协程、goroutine等等。不管名称如何,本文的基本观点是,以多个独立执行的控制流形式编写程序,允许你将程序状态存储在其中一个或多个控制流的执行状态中,具体来说是存储在程序计数器(正在执行的行号)和堆栈中。当然,控制流状态始终可以作为显式数据来维护,但这样显式的数据形式实际上是在模拟控制流。大多数情况下,使用编程语言内置的控制流特性比在数据结构中模拟它们更易于理解、推理和维护。
本文的其余部分将通过一些具体示例来说明我之前提到的将数据存储在控制流中的抽象概念。这些示例恰好是用Go语言编写的,但这些思想适用于任何支持编写并发程序的语言,包括几乎所有现代语言。
分步示例
下面是一个看似微不足道的问题,它展示了将程序状态存储在控制流中的意义。假设我们正在从文件中读取字符,并希望扫描C风格的双引号字符串。在这种情况下,我们有一个非并行的程序。这里没有并行的机会,但正如我们将看到的,并发仍然可以发挥有用的作用。如果我们不关心字符串中的精确转义序列,只需匹配正则表达式"([^"]|.)*"即可,它匹配一个双引号,然后是零个或多个字符,最后是另一个双引号。在引号之间,一个字符可以是任何不是引号或反斜杠的字符,或者是一个反斜杠后面跟着任何字符(包括引号或反斜杠)。每个正则表达式都可以编译成有限自动机或状态机,所以我们可以使用一个工具将这个规范转换成以下Go代码:
go
state := 0
for {
c := read()
switch state {
case 0:
if c != '"' {
return false
}
state = 1
case 1:
if c == '"' {
return true
}
if c == '\\' {
state = 2
} else {
state = 1
}
case 2:
state = 1
}
}
代码中有一个名为state的变量,表示自动机的状态。for循环读取一个字符并更新状态,不断循环,直到找到字符串的末尾或语法错误。这是一种只有程序才能喜欢的代码。对于人来说很难理解,维护起来也很困难。
这个程序如此晦涩的主要原因是它的程序状态存储为数据,具体来说是存储在名为state的变量中。当可以将状态存储在代码中时,通常会得到一个更清晰的程序。为了看到这一点,让我们逐步将程序转换为等价但更易理解的版本。我们可以从将读取调用复制到switch的每个case中开始:
go
state := 0 state := 0
for { for {
c := read()
switch state { switch state {
case 0: case 0:
c := read()
if c != '"' { if c != '"' {
return false return false
} }
state = 1 state = 1
case 1: case 1:
c := read()
if c == '"' { if c == '"' {
return true return true
} }
if c == '\\' { if c == '\\' {
state = 2 state = 2
} else { } else {
state = 1 state = 1
} }
case 2: case 2:
c := read()
state = 1 state = 1
} }
} }
(在下面和所有后续的显示中,旧程序在左边,新程序在右边,未更改的行以灰色文本打印。)
现在,我们可以使用代码标签和goto语句,而不是将结果写入state然后立即再次进入for循环以查找在该状态下要执行的操作:
go
state := 0 state0:
for {
switch state {
case 0:
c := read() c := read()
if c != '"' { if c != '"' {
return false return false
} }
state = 1 goto state1
case 1: state1:
c := read() c := read()
if c == '"' { if c == '"' {
return true return true
} }
if c == '\\' { if c == '\\' {
state = 2 goto state2
} else { } else {
state = 1 goto state1
} }
case 2: state2:
c := read() read()
state = 1 goto state1
}
}
然后我们可以进一步简化程序。位于 state1 标签之前的 goto state1 是一个无操作指令,可以删除。而且我们可以看到只有一种方式可以到达 state2,因此我们可以将 goto state2 替换为 state2 中的实际代码:
lua
state0: state0:
c := read() c := read()
if c != '"' { if c != '"' {
return false return false
} }
goto state1
state1: state1:
c := read() c := read()
if c == '"' { if c == '"' {
return true return true
} }
if c == '\\' { if c == '\\' {
goto state2
} else {
goto state1
}
state2:
read() read()
goto state1 goto state1
} else {
goto state1
}
然后我们可以将 if 语句的两个分支中的 "goto state1" 提取出来。
lua
state0: state0:
c := read() c := read()
if c != '"' { if c != '"' {
return false return false
} }
state1: state1:
c := read() c := read()
if c == '"' { if c == '"' {
return true return true
} }
if c == '\\' { if c == '\\' {
read() read()
goto state1 }
} else { goto state1
goto state1
}
然后我们可以删除未使用的 state0 标签,并用一个真正的循环替换 state1 的循环。现在我们有了一个看起来像真正的程序的东西:
lua
state0:
c := read() c := read()
if c != '"' { if c != '"' {
return false return false
} }
state1: for {
c := read() c := read()
if c == '"' { if c == '"' {
return true return true
} }
if c == '\\' { if c == '\\' {
read() read()
} }
goto state1 }
我们可以进一步简化,消除一些不必要的变量,并将对最后一个引号 (c == "") 的检查作为循环终止条件。
lua
c := read() if read() != '"' {
if c != '"' {
return false return false
} }
for { var c byte
c := read() for c != '"' {
if c == '"' { c = read()
return true
}
if c == '\\' { if c == '\\' {
read() read()
} }
} }
return true
最终版本如下:
csharp
func parseQuoted(read func() byte) bool {
if read() != '"' {
return false
}
var c byte
for c != '"' {
c = read()
if c == '\\' {
read()
}
}
return true
}
之前我解释了正则表达式,说它"匹配一个双引号,然后是零个或多个字符的序列,然后是另一个双引号。在引号之间,一个字符可以是除了引号或反斜杠之外的任何字符,或者是一个反斜杠后面跟着任何字符。"很容易看出,这个程序恰好做到了这一点。手写程序也可以利用控制流。例如,这是一个人可能手写的版本:
kotlin
if read() != '"' {
return false
}
inEscape := false
for {
c := read()
if inEscape {
inEscape = false
continue
}
if c == '"' {
return true
}
if c == '\\' {
inEscape = true
}
}
可以使用相同的小步骤将变量 inEscape 的数据表示方式转换为控制流,最终得到相同的简化版本。
无论哪种方式,原始程序中的状态变量现在隐式地由程序计数器表示,即表示程序的哪个部分正在执行。这个版本中的注释指示了原始状态变量(或 inEscape)的隐式值:
csharp
func parseQuoted(read func() byte) bool {
// state == 0
if read() != '"' {
return false
}
var c byte
for c != '"' {
// state == 1 (inEscape = false)
c = read()
if c == '\\' {
// state == 2 (inEscape = true)
read()
}
}
return true
}
原始程序本质上是使用显式的状态变量作为程序计数器来模拟这个控制流,跟踪正在执行的代码行。如果一个程序可以转换为将显式状态存储在控制流中,那么显式状态只是对控制流的笨拙模拟。
更多线程更多状态
在广泛支持并发性之前,这种笨拙的模拟通常是必要的,因为程序的不同部分希望使用控制流。例如,假设要解析的文本是对base64输入进行解码的结果,在该解码器中,每个由64个字符组成的6位字符序列解码为3个8位字节。解码器的核心如下所示:
css
for {
c1, c2, c3, c4 := read(), read(), read(), read()
b1, b2, b3 := decode(c1, c2, c3, c4)
write(b1)
write(b2)
write(b3)
}
如果我们希望这些写入调用传递给上一节中的解析器,我们需要一个可以逐字节调用的解析器,而不是一个要求读回调的解析器。这个解码循环不能被呈现为一个读回调,因为它一次获取3个输入字节,并使用其控制流来跟踪已写入的字节。因为解码器在其控制流中存储了自己的状态,所以parseQuoted无法这样做。
在一个非并发程序中,这个base64解码器和parseQuoted会陷入僵局:一个必须放弃对控制流状态的使用,并退回到某种模拟版本中。为了重写parseQuoted,我们必须重新引入状态变量,可以将其封装在一个带有Write方法的结构体中:
go
type parser struct {
state int
}
func (p *parser) Init() {
p.state = 0
}
func (p *parser) Write(c byte) Status {
switch p.state {
case 0:
if c != '"' {
return BadInput
}
p.state = 1
case 1:
if c == '"' {
return Success
}
if c == '\\' {
p.state = 2
} else {
p.state = 1
}
case 2:
p.state = 1
}
return NeedMoreInput
}
Init方法初始化状态,然后每个Write方法加载状态,在状态和输入字节的基础上执行相应的操作,然后将状态保存回结构体中。
对于parseQuoted函数来说,状态机足够简单,这种方式可能完全可以。但是,如果状态机更加复杂,或者算法最好以递归方式表达,那么按照调用者以每次一个字节的方式传递输入序列意味着将所有这些状态明确地放在一个模拟原始控制流的数据结构中。并发消除了程序中不同部分之间关于哪个部分可以在控制流中存储状态的争用,因为现在可以有多个控制流。假设我们已经有了parseQuoted函数,它庞大而复杂,经过了测试且正确无误,我们不想对其进行任何更改。我们可以通过编写以下包装器来避免对该代码进行任何编辑:
go
type parser struct {
c chan byte
status chan Status
}
func (p *parser) Init() {
p.char = make(chan byte)
p.status = make(chan Status)
go p.run()
<-p.status // always NeedMoreInput
}
func (p *parser) run() {
if !parseQuoted(p.read) {
p.status <- BadSyntax
} else {
p.status <- Success
}
}
func (p *parser) read() byte {
p.status <- NeedMoreInput
return <-p.c
}
func (p *parser) Write(c byte) Status {
p.c <- c
return <-p.status
}
请注意在run方法中完全未修改的parseQuoted的使用。现在base64解码器可以使用p.Write并保留其程序计数器和局部变量。
Init创建的新goroutine运行p.run方法,该方法使用适当的read实现调用原始的parseQuoted函数。在启动p.run之前,Init为p.run方法和调用p.Write(如base64解码器的goroutine)之间的通信分配了两个通道。通道p.c用于将字节从Write发送到read,通道p.status用于返回状态更新。每次parseQuoted调用read时,p.read会在p.status上发送NeedMoreInput并等待p.c上的输入字节。每次调用p.Write时,它执行相反的操作:在p.c上发送输入字节c,然后等待并返回来自p.status的更新状态。这两个调用轮流进行,来回进行,任何时刻只有一个调用执行,另一个调用处于等待状态。为了启动这个循环,Init方法执行了对p.status的初始接收,它对应于parseQuoted中的第一次读取。对于第一个更新的实际状态,保证是NeedMoreInput,并且会被丢弃。为了结束这个循环,我们假设当Write返回BadSyntax或Success时,调用者知道不要再调用Write。如果调用者错误地继续调用Write,由于parseQuoted已经完成,p.c上的发送操作将永远阻塞。在实际的生产实现中,我们当然会更加健壮地处理这个问题。通过创建新的控制流(新的goroutine),我们能够保留基于代码状态的parseQuoted和基于代码状态的base64解码器。我们避免了必须理解这两个实现的内部细节。在这个例子中,两者都足够简单,重写其中一个不会造成太大的问题,但在一个更大的程序中,能够编写这种适配器而不必对现有代码进行更改可能会带来巨大的优势。正如我们之后将讨论的,这种转换并非完全免费,我们需要确保额外的控制流得到清理,我们需要考虑上下文切换的成本,但它可能仍然是一个净胜利。
在堆栈上存储堆栈
base64解码器的控制流状态不仅包括程序计数器,还包括两个局部变量。如果必须更改解码器以不使用控制流状态,那么这些变量必须提取到一个结构体中。程序可以使用它们的调用堆栈来使用任意数量的局部变量。例如,假设我们有一个简单的二叉树数据结构:
css
type Tree[V any] struct {
left *Tree[V]
right *Tree[V]
value V
}
如果不能使用控制流状态,那么为了实现对该树的迭代,必须引入一个显式的"迭代器":
go
type Iter[V any] struct {
stk []*Tree[V]
}
func (t *Tree[V]) NewIter() *Iter[V] {
it := new(Iter[V])
for ; t != nil; t = t.left {
it.stk = append(it.stk, t)
}
return it
}
func (it *Iter[V]) Next() (v V, ok bool) {
if len(it.stk) == 0 {
return v, false
}
t := it.stk[len(it.stk)-1]
v = t.value
it.stk = it.stk[:len(it.stk)-1]
for t = t.right; t != nil; t = t.left {
it.stk = append(it.stk, t)
}
return v, true
}
另一方面,如果可以使用控制流状态,并且确信程序中需要自己的状态的其他部分可以在其他控制流中运行,那么可以在没有显式迭代器的情况下实现迭代,即通过调用 yield 函数来获取每个值的方法:
scss
func (t *Tree[V]) All(f func(v V)) {
if t != nil {
t.left.All(f)
f(t.value)
t.right.All(f)
}
}
All方法显然是正确的。Iter版本的正确性要不那么明显。最简单的解释是Iter在模拟All。NewIter方法中设置stk的循环模拟了t.All(f)中的递归,沿着连续的t.left分支向下进行。Next弹出并保存堆栈顶部的t,然后模拟了t.right.All(f)中的递归,沿着连续的t.left分支向下进行,为下一个Next做准备。最后,它返回了堆栈顶部的t的值,模拟了f(value)。我们可以编写类似NewIter的代码,并通过解释它模拟了一个像All这样简单的函数来证明其正确性。但我更愿意编写All并在此停止。
比较二叉树
有人可能会认为NewIter比All更好,因为它不使用任何控制流状态,因此可以在已经使用其控制流保存其他信息的上下文中使用。例如,如果我们想同时遍历两个二叉树,检查它们是否包含相同的值,即使它们的内部结构不同。使用NewIter,这很简单:
go
func SameValues[V any](t1, t2 *Tree[V]) bool {
it1 := t1.NewIter()
it2 := t2.NewIter()
for {
v1, ok1 := it1.Next()
v2, ok2 := it2.Next()
if v1 != v2 || ok1 != ok2 {
return false
}
if !ok1 && !ok2 {
return true
}
}
}
这个程序不能像使用All那样轻松地编写,原因在于SameValues想要使用自己的控制流(同时推进两个列表),而无法用All的控制流(对树的递归)替代。但这是一个错误的二分法,就像我们在parseQuoted和base64解码器中看到的那样。如果两个不同的函数对控制流状态有不同的要求,它们可以在不同的控制流中运行。在我们的例子中,我们可以这样编写:
go
func SameValues[V any](t1, t2 *Tree[V]) bool {
c1 := make(chan V)
c2 := make(chan V)
go gopher(c1, t1.All)
go gopher(c2, t2.All)
for {
v1, ok1 := <-c1
v2, ok2 := <-c2
if v1 != v2 || ok1 != ok2 {
return false
}
if !ok1 && !ok2 {
return true
}
}
}
func gopher[V any](c chan<- V, all func(func(V))) {
all(func(v V) { c <- v })
close(c)
}
函数"gopher"使用"all"来遍历树,将每个值传入一个通道。在遍历完成后,关闭通道。"SameValues"启动两个并发的"gopher",每个"gopher"遍历一个树并将值传入同一个通道。然后,"SameValues"执行与之前完全相同的循环来比较两个值流。需要注意的是,"gopher"函数并不特定于二叉树,它适用于任何迭代函数。换句话说,使用一个goroutine来运行"All"方法的这种一般想法适用于将任何基于代码状态的迭代转换为逐步迭代器。
局限性
将数据存储在控制流中的这种方法并非万能解决方案。以下是一些需要注意的问题:
- 如果状态需要以不符合自然控制流映射的方式进行演化,通常最好将状态保留为数据。例如,在分布式系统中维护的状态通常不适合用控制流表示,因为超时、错误和其他意外事件往往需要以不可预测的方式调整状态。
- 如果状态需要进行快照等操作进行序列化,或者需要通过网络发送,通常使用数据比代码更容易实现。
- 当需要创建多个控制流以保存不同的控制流状态时,辅助控制流需要被关闭。当"SameValues"返回false时,它会导致两个并发的"gopher"被阻塞,等待发送它们的下一个值。相反,它应该解除阻塞。这需要在另一个方向上进行通信,告知"gopher"提前停止。
- 在多线程情况下,切换开销可能会很大。在我的笔记本电脑上,C线程切换需要几微秒。通道操作和goroutine切换要便宜一个数量级:几百纳秒。优化的协程系统可以将成本降低到几十纳秒甚至更低。
总的来说,将数据存储在控制流中是编写清晰、简单、易于维护的程序的有价值的工具。就像所有工具一样,它对于某些任务非常有效,但对于其他任务可能效果不佳。
作者:Russ Cox
更多技术干货请关注公众号"云原生数据库"
squids.cn ,目前可体验全网zui低价RDS,免费的迁移工具DBMotion、SQL开发工具等。