Go中的不可变性模式教程和代码示例

189 阅读10分钟

Go作为一种现代编程语言的缺点之一是缺乏使某些数据结构不可变的本地选项。也就是说,我们经常不得不在我们的应用程序中做出关键的软件设计决定,以确保某些数据在整个代码的运行时间内是不可变的,而这可能看起来并不漂亮。在我的公司Prysmatic Labs,我们经常遇到这样的问题:出于性能的考虑,我们需要在内存中维护某些大型数据结构,同时我们也需要对这些数据进行一次性的本地计算。也就是说,我们的应用中有非常密集的重读工作负载,我们不希望损害数据的安全性。

这方面的一个具体例子是在分布式系统领域,服务器通常维护一些全局 "状态",网络上的其他计算机也通过共识算法维护这些状态。这方面的一个例子是我的团队开发的流行区块链Ethereum,它维护着用户账户、余额和其他大量关键信息的全球状态。这些应用程序是state machines ,它们通过state transition function :一个纯函数,它接收一些数据、全局状态,并以确定的方式输出一个新的全局状态。

让我们举一个基本的例子:

type State struct {
    AccountAddresses []string
    Balances         []uint64
}

type InputData struct {
    Transfers           []*Transfer
    NewAccountAddresses []string
    NewAccountBalances  []uint64
}

type Transfer struct {
    From   string
    To     string
    Amount uint64
}

// ExecuteStateTransition is a pure function which received some input data,
// a pre-state, and outputs a deterministic post-state if the inputs are valid.
func ExecuteStateTransition(data *InputData, preState *State) (*State, error) {
    var err error
    for _, transfer := range data.Transfers {
        // Apply a transfer to the state.
        preState, err = applyTransfer(preState, transfer)
        if err != nil {
            return nil, fmt.Errorf("could not apply transfer to accounts: %v", err)
        }
    }
    if len(data.NewAccountAddresses) != len(data.NewAccountBalances) {
        return nil, errors.New("different number of new account addresses and balances")
    }
    for i, address := range data.NewAccountAddresses {
        balance := data.NewAccountBalances[i]
        preState, err = addAccount(preState, address, balance)
        if err != nil {
            return nil, fmt.Errorf("could not create new account in state: %v", err)
        }
    }
    ...
    // Do some other fancy stuff...
    return preState, nil
}

在这些应用中,一个状态转换可能会失败,这将使状态对象处于一个不一致的状态!这时,你的代码可能会失败。例如,你的代码可能会失败。

if len(data.NewAccountAddresses) != len(data.NewAccountBalances) {
    return nil, errors.New("different number of new account addresses and balances")
}

尽管在前面几行已经对状态中的账户进行了突变。

var err error
for _, transfer := range data.Transfers {
    // Apply a transfer to the state.
    preState, err = applyTransfer(preState, transfer)
    if err != nil {
        return nil, fmt.Errorf("could not apply transfer to accounts: %v", err)
    }
}

天真的解决方案。完全复制

解决这个问题的一个简单而又幼稚的方法是在函数运行前强制执行数据的完全copying 。比如说。

func (s *Server) NaiveCopy() *State {
    newAccountAddresses := make([]string, len(s.accountAddresses))
    copy(newAccountAddresses, s.accountAddresses)
    newBalances := make([]uint64, len(s.balances))
    copy(newBalances, s.balances)
    return &State{
        accountAddresses: newAccountAddresses,
        balances:         newBalances,
    }
}

...
// Retrieve a copy of the current application state.
preState := s.NaiveCopy()
postState, err := ExecuteStateTransition(data, preState)
if err != nil {
    return fmt.Errorf("could not process state transition: %v", err)
}
...

这意味着状态转换函数将在本地范围内操作,100%复制的数据实例,而不是突变我们宝贵的真实状态。然而,当状态可能是一个庞大的数据结构时,这是一个糟糕的选择,也是一个取决于状态转换函数可能运行多少次的糟糕选择。事实上,我们需要复制整个数据结构,这是一个糟糕的模式,不会扩展到真正的应用。深度拷贝可能需要很长的时间来运行,使用大量的内存,并将很快成为你的主要瓶颈。尽管如此,Go中还有很多其他的设计模式可以帮助解决这个问题。

读取时复制

如果我们想在不做完全拷贝的情况下安全地访问一个状态呢?相反,我们可以只复制我们的函数所需要的数据。例如,假设你有一个函数,只是把账户余额加起来。

func TotalBalance(state *State) uint64 {
    total := uint64(0)
    for _, balance := range state.Balances {
        total += balance
    }
    return total
}

...
// Get a copy of the current state.
currentState := s.Copy()
total := TotalBalance(currentState)
fmt.Printf("Total balance: %d\n", total)

即使是这样一个简单的计算,你仍然要复制整个状态的数据结构!这是绝对低效的。这绝对是低效的。你也许可以通过复制状态,而只是使用state.AccountBalances的原始值来解决这个问题,因为你没有修改它,你只是从它那里读取。然而,这样做是很危险的!你不希望有人意外地把状态复制到其他地方。你不希望任何人意外地修改这个值,所以你要严格控制数据访问和修改。Go中的一个简单模式是利用带有getters和setters的未导出的结构字段来防止不必要的数据变异。例如,我们可以将我们的状态重组成这样。

type State struct {
    accountAddresses []string
    balances         []uint64
}

func (s *State) AccountAddresses() []string {
    return DeepCopy(s.accountAddresses)
}

func (s *State) SetAccountAddresses(addresses []string) {
    s.accountAddresses = DeepCopy(addresses)
}

func (s *State) Balances() []uint64 {
    return DeepCopy(s.balances)
}

func (s *State) SetBalances(balances []uint64) {
    s.balances = DeepCopy(balances)
}

现在,你仍然可以传递对你的状态的引用,但是任何使用它的函数只会复制它需要的数据。而不是每次你只想增加账户余额时复制整个状态,你将只复制账户余额列表。

func TotalBalance(state *State) uint64 {
    total := uint64(0)
    for _, balance := range state.Balances() {
        total += balance
    }
    return total
}

这种模式将防止任何人,包括你自己,在你的应用程序中意外地在其定义的包之外突变这个状态的某个字段。这种方法在大多数情况下是可行的,但是,如果我们有一个需要状态的每一个字段的函数,比如状态转换函数,我们就没有在每次都做一个完整的拷贝的天真情况下有任何改进。此外,你的应用程序可能会更频繁地进行数据读取而不是写入,反之亦然。如果你每秒要调用TotalBalance 函数数千次,那么这种模式与完全不复制相比是有代价的。

我们能不能做得比总是复制更好呢...?让我们来看看。

写时复制

在保持不变性的前提下,有效使用内存的一个高级模式是做一个copy on write 。一般的想法是,我们有多个状态的副本,但是它们的每个内部字段都指向一个单一的、共享的引用,直到一个副本需要突变。

这意味着我们可以有多个State 对象的副本,但它们的内部字段,即AccountAddressesBalances 都指向一个共享的引用。这些副本中的每一个都可以从这个单一的引用中任意读取,但如果他们修改这些内容,就会创建一个副本。

使用这种方法,我们可以智能地重用已经存在的旧数据的分配,并确保我们可以执行真正的快速操作,如计算总余额或做其他不应该需要完整副本的计算。有两种方法来强制执行写时拷贝:

  1. 我们总是在写时执行拷贝。也就是说,我们默认维护共享引用,每当任何字段被改变,我们就复制它并执行新的分配。
  2. 我们按字段跟踪分配的引用。只有在存在共享引用的情况下,我们才会在写的时候执行拷贝,否则,我们只是简单地按原样突变字段,给我们一个比要求更高级的行为。

让我们来看看如何在我们的例子中实现(2),因为它是一个更灵活的解决方案。

type fieldIndex int

const (
    accountAddressesField fieldIndex = iota
    balancesField
)

type State struct {
    sharedFieldReferences map[fieldIndex]*reference
    accountAddresses      []string
    balances              []uint64
}
type reference struct {
    refs uint
}

func (r *reference) Refs() uint {
    return r.refs
}

func (r *reference) AddRef() {
    r.refs++
}

func (r *reference) MinusRef() {
    // Prevent underflow.
    if r.refs == 0 {
        return
    }
    r.refs--
}

func (s *State) SetBalances(balances []uint64) {
    if s.sharedFieldReferences[balancesField].Refs() == 1 { // Only this struct has a reference.
        // Mutate in place...
        s.balances = balances
    } else {
        // Decrement reference, allocate full copy, and update.
        s.sharedFieldReferences[balancesField].MinusRef()
        s.sharedFieldReferences[balancesField] = &reference{refs: 1}
        newBalances := make([]uint64, len(balances))
        copy(newBalances, balances)
        s.balances = newBalances
    }
}

func (s *State) SetAccountAddresses(addresses []string) {
    if s.sharedFieldReferences[accountAddressesField].Refs() == 1 { // Only this struct has a reference.
        // Mutate in place...
        s.accountAddresses = addresses
    } else {
        // Decrement reference, allocate full copy, and update.
        s.sharedFieldReferences[accountAddressesField].MinusRef()
        s.sharedFieldReferences[accountAddressesField] = &reference{refs: 1}
        newAddresses := make([]string, len(addresses))
        copy(newAddresses, addresses)
        s.accountAddresses = newAddresses
    }
}

现在,在创建一个新的状态副本时,我们将根据需要递增新对象的共享字段引用:

func (s *State) Copy() *State {
    dst := &State{
        accountAddresses:      s.accountAddresses,
        balances:              s.balances,
        sharedFieldReferences: make(map[fieldIndex]*reference, 2),
    }

    for field, ref := range s.sharedFieldReferences {
        ref.AddRef()
        dst.sharedFieldReferences[field] = ref
    }

    // Finalizer runs when the destination object is being
    // destroyed in garbage collection.
    runtime.SetFinalizer(dst, func(s *State) {
    for _, v := range s.sharedFieldReferences {
        v.MinusRef()
    }
    })
    return dst
}

在上面的代码中,我们利用了Go标准库中的一个特殊函数,叫做runtime.SetFinalizer。从它的godoc定义来看。

SetFinalizer将与obj相关的finalizer设置为提供的finalizer函数。当垃圾收集器发现一个有相关终结器的不可达区块时,它会清除关联并在一个单独的goroutine中运行finalizer(obj)。

这基本上是告诉垃圾收集器在销毁对象分配的内存时,一旦不再需要它,应该执行什么动作。

让我们尝试一下,用一个单元测试来证明这一点:

func TestStateReferenceSharing_GarbageCollectionFinalizer(t *testing.T) {
    // First, we initialize a state with some basic values
    // and shared field reference counts of 1 for each field.
    a := &State{
        accountAddresses:      make([]string, 1000),
        balances:              make([]uint64, 1000),
        sharedFieldReferences: make(map[fieldIndex]*reference, 2),
    }
    a.sharedFieldReferences[accountAddressesField] = &reference{refs: 1}
    a.sharedFieldReferences[balancesField] = &reference{refs: 1}

    func() {
        // Create object in a different scope for garbage collection.
        b := a.Copy()
        if a.sharedFieldReferences[balancesField].refs != 2 {
            t.Error("Expected 2 references to balances")
        }
        _ = b
    }()

    // Now, we trigger garbage collection which will call the
    // RunFinalizer function on object b.
    runtime.GC()
    if a.sharedFieldReferences[balancesField].refs != 1 {
        t.Errorf("Expected 1 shared reference to balances")
    }

    // We initialize b again, which will cause the shared reference count
    // for both objects to go up to 2.
    b := a.Copy()
    if a.sharedFieldReferences[balancesField].refs != 2 {
        t.Error("Expected 2 shared references to balances in a")
    }
    if b.sharedFieldReferences[balancesField].refs != 2 {
        t.Error("Expected 2 shared references to balances in b")
    }

    // Now, we write to b, which will cause the balances field to be copied
    // and decrement the shared field reference for both objects.
    b.SetBalances(make([]uint64, 2000))
    if b.sharedFieldReferences[balancesField].refs != 1 || a.sharedFieldReferences[balancesField].refs != 1 {
        t.Error("Expected 1 shared reference to balances for both a and b")
    }
}

现在运行该测试...

ok      github.com/rauljordan/experiment 0.281s

让我们通过一个基准测试来看看与天真的完全复制方法相比,它到底有多大的区别:

func (s *State) NaiveCopy() *State {
    newAccountAddresses := make([]string, len(s.accountAddresses))
    copy(newAccountAddresses, s.accountAddresses)
    newBalances := make([]uint64, len(s.balances))
    copy(newBalances, s.balances)
    return &State{
        accountAddresses: newAccountAddresses,
        balances:         newBalances,
    }
}

func BenchmarkCopy_SharedReferences(b *testing.B) {
    st1 := &State{
        accountAddresses:      make([]string, 1000),
        balances:              make([]uint64, 1000),
        sharedFieldReferences: make(map[fieldIndex]*reference, 2),
    }
    for i := 0; i < b.N; i++ {
        st1.Copy()
    }
}

func BenchmarkCopy_Naive(b *testing.B) {
    st1 := &State{
        accountAddresses: make([]string, 1000),
        balances:         make([]uint64, 1000),
    }
    for i := 0; i < b.N; i++ {
        st1.NaiveCopy()
    }
}

现在运行基准测试...

$ go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/rauljordan/experiment
BenchmarkCopy_SharedReferences-6         3249732               451 ns/op             112 B/op          2 allocs/op
BenchmarkCopy_Naive-6                     350588              3279 ns/op           24576 B/op          2 allocs/op
PASS
ok      github.com/rauljordan/experiment 3.348s

我们得到了几乎是免费的完整状态的拷贝!这意味着我们可以有成千上万的完整拷贝。这意味着我们可以有数以千计的完整状态副本,而且所有这些副本都会为它们的字段使用一个共享引用。我们可以放心地在我们的应用程序中使用它们,因为任何突变都会创建一个副本并减少字段的共享引用。我们可能没有默认的不可变性,但如果你的应用程序需要安全的、不可变的类型,并且你在你的应用程序中进行了大量的读取,使得复制数据使用起来超级便宜的话,这是一个很好的折衷办法)。

资源

你可能会问,为什么Go不直接支持结构体和其他自定义类型的不可变性作为语言的默认功能?著名的 Go 开发者 Dave Cheney 有一篇很好的文章,介绍了如果将泛型或不可变性等项目作为基元加入 Go 语言会发生什么。Go是一门优秀的语言,有许多优点,尽管有时也有一些明显的问题。尽管如此,只要遵循合理的软件工程原则,仍然可以绕过Go的弱点,取得良好的效果。

有趣的是,这里是Go的不可变类型列表:

  • 接口
  • 布尔、数值(包括int类型的数值)
  • 字符串
  • 指针
  • 函数指针,以及可以简化为函数指针的闭包
  • 有一个字段的结构