如何用Go处理高并发(译文) |Go主题月

700 阅读3分钟

Glyph Lefkowitz 最近写了一篇很有启发性的文章,详细阐述了编写高度并发软件的挑战。如果你还没有读过这篇文章,我建议你读。这是一篇写得很好的文章,充满了现代软件工程师不应该缺少的智慧。

有许多小道消息要提取,但如果我可以大胆地提供一个总结其要点,它将是这样的:抢占式多任务和共享状态的结合通常导致难以管理的复杂性,而开发者更愿意保持自己的一些理智尽量避免。抢占式调度适用于真正的并行任务,但当可变状态在多个并发线程之间共享时,显式协作多任务处理更为可取。

使用协作式多任务处理,你的代码可能仍然很复杂,只是有机会保持可管理的复杂性。当控制传输是显式的时候,代码的读取器至少有一些明显的指示,表明事情可能会偏离轨道。如果没有明确的标记,每一个新的语句都是一个潜在的地雷:“如果这个操作与最后一个操作不是原子的呢?“每一个命令之间的空间都变成了无尽的黑暗空间,可怕的 Heisenbugs 就从这里冒出来。

在过去的一年里,在 Heka (一个高性能的数据、日志和度量处理引擎)的工作中,我主要是使用 Go 编程。Go 的一个卖点是,在语言中有一些非常有用的并发原语。但是,从支持局部推理的鼓励性代码的角度来看,Go 的并发方法如何呢?

恐怕不是很好。Goroutines 都可以访问相同的共享内存空间,默认情况下状态是可变的,Go 的调度程序不能保证上下文切换的确切时间。在单核心设置中,我认为 Go 的运行时属于“隐式协同路由”类别,Glyph 经常呈现的异步编程模式列表中的选项4。当 Goroutines 可以在多个核上并行运行时,所有的赌注都被取消了。

Go 也许不能保护你,但这并不意味着你不能采取措施保护自己。通过使用 Go 提供的一些原语,可以编写代码来最小化与抢占式调度相关的意外行为。考虑 Glyph 的示例帐户转移代码的以下 Go 端口(忽略 float 实际上不是存储定点十进制值的好选择):

 func Transfer(amount float64, payer, payee *Account,
        server SomeServerType) error {

        if payer.Balance() < amount {
            return errors.New("Insufficient funds")
        }
        log.Printf("%s has sufficient funds", payer)
        payee.Deposit(amount)
        log.Printf("%s received payment", payee)
        payer.Withdraw(amount)
        log.Printf("%s made payment", payer)
        server.UpdateBalances(payer, payee) // Assume this is magic and always works.
        return nil
    }

从多个 gorouting 调用这显然是不安全的,因为它们可能同时从余额调用中获得相同的结果,然后集体要求超过撤回调用可用的余额。如果我们做到了,这样代码的危险部分就不能从多个 gorouting 执行,那就更好了。有一种方法可以实现这一点:

 type transfer struct {
        payer *Account
        payee *Account
        amount float64
    }

    var xferChan = make(chan *transfer)
    var errChan = make(chan error)
    func init() {
        go transferLoop()
    }

    func transferLoop() {
        for xfer := range xferChan {
            if xfer.payer.Balance < xfer.amount {
                errChan <- errors.New("Insufficient funds")
                continue
            }
            log.Printf("%s has sufficient funds", xfer.payer)
            xfer.payee.Deposit(xfer.amount)
            log.Printf("%s received payment", xfer.payee)
            xfer.payer.Withdraw(xfer.amount)
            log.Printf("%s made payment", xfer.payer)
            errChan <- nil
        }
    }

    func Transfer(amount float64, payer, payee *Account,
        server SomeServerType) error {

        xfer := &transfer{
            payer: payer,
            payee: payee,
            amount: amount,
        }

        xferChan <- xfer
        err := <-errChan
        if err == nil  {
            server.UpdateBalances(payer, payee) // Still magic.
        }
        return err
    }

这里有更多的代码,但是我们通过实现一个简单的事件循环来消除并发问题。当代码第一次执行时,它会启动一个运行循环的 goroutine。传输请求通过为此目的创建的通道传递到循环中。结果通过错误通道返回到循环外部。由于通道是无缓冲的,它们阻塞,并且无论通过 Transfer 函数传入多少并发传输请求,它们都将由单个运行的事件循环串行地服务。

上面的代码可能有点不方便。对于这样一个简单的情况,互斥对象(mutex)是一个更好的选择,但是我试图演示将状态操纵隔离到一个 goroutine 的技术。即使是在不方便的情况下,它的性能也远远超过了大多数需求,而且它甚至可以在最简单的 Account Struct 实现中工作:

 type Account struct {
        balance float64
    }

    func (a *Account) Balance() float64 {
        return a.balance
    }

    func (a *Account) Deposit(amount float64) {
        log.Printf("depositing: %f", amount)
        a.balance += amount
    }

    func (a *Account) Withdraw(amount float64) {
        log.Printf("withdrawing: %f", amount)
        a.balance -= amount
    }

然而,Account 的实现如此幼稚似乎很愚蠢。让 Account Struct 本身提供一些保护可能更有意义,因为它不允许任何超过当前余额的取款。如果我们把 Withdraw 函数改成下面的呢?:

func (a *Account) Withdraw(amount float64) {
        if amount > a.balance {
            log.Println("Insufficient funds")
            return
        }
        log.Printf("withdrawing: %f", amount)
        a.balance -= amount
    }

不幸的是,这段代码与我们最初的 Transfer 实现有相同的问题。并行执行或不符合实际的上下文切换意味着我们最终可能会处于负平衡。幸运的是,内部事件循环的思想在这里同样适用,也许更灵活,因为事件循环 goroutine 可以很好地与每个单独的 Account struct 实例耦合。下面是一个可能的示例:

type Account struct {
        balance float64
        deltaChan chan float64
        balanceChan chan float64
        errChan chan error
    }
    func NewAccount(balance float64) (a *Account) {
        a = &Account{
            balance:     balance,
            deltaChan:   make(chan float64),
            balanceChan: make(chan float64),
            errChan:     make(chan error),
        }
        go a.run()
        return
    }

    func (a *Account) Balance() float64 {
        return <-a.balanceChan
    }

    func (a *Account) Deposit(amount float64) error {
        a.deltaChan <- amount
        return <-a.errChan
    }

    func (a *Account) Withdraw(amount float64) error {
        a.deltaChan <- -amount
        return <-a.errChan
    }

    func (a *Account) applyDelta(amount float64) error {
        newBalance := a.balance + amount
        if newBalance < 0 {
            return errors.New("Insufficient funds")
        }
        a.balance = newBalance
        return nil
    }

    func (a *Account) run() {
        var delta float64
        for {
            select {
            case delta = <-a.deltaChan:
                a.errChan <- a.applyDelta(delta)
            case a.balanceChan <- a.balance:
                // Do nothing, we've accomplished our goal w/ the channel put.
            }
        }
    }

API 略有不同,Deposit 和 Withdraw 函数现在都返回错误。但是,他们没有直接处理他们的请求,而是将帐户余额调整量放在 deltaChan 上,该 deltaChan 将反馈到 run 方法中运行的事件循环中。类似地,Balance 方法通过阻塞从事件循环请求数据,直到它通过 balanceChan 接收到一个值。

上面代码中需要注意的重要一点是,对结构内部数据值的所有直接访问和变异都是在事件循环触发的代码中完成的。如果公共 API 调用运行良好,并且只使用提供的通道与数据交互,那么无论对任何公共方法进行多少并发调用,我们都知道在任何给定的时间只处理其中一个。我们的事件循环代码更容易推理。

这个图案是 Heka’s 设计的核心。当 Heka 启动时,它读取配置文件并在自己的 goroutine 中启动每个插件。数据通过通道输入插件,时间信号、关机通知和其他控制信号也是如此。这鼓励插件作者使用事件循环 类型 结构 来实现他们的功能,比如上面的例子。

再说一遍,Go不能保护你自己。完全有可能编写一个 Heka 插件(或任何结构),它的内部数据管理松散,并且受竞争条件的影响。但是,只要小心一点,并且自由地应用 Go 的竞争检测器,就可以编写出即使在抢先调度的情况下也可以预测的代码。

Rob Miller

原文链接:blog.mozilla.org/services/20…

Go主题月