Golang 协程并发更新共享数据

前言

以论坛项目为例,其中一个常见的统计更新需求是发布主题或回帖时会相应累加更新数据库相关统计表中目标日期对应的主题数或发帖数。考虑到这种性质的统计数据其实并不需要纯实时更新且增加不必要的数据库写压力,我们可以在 Golang 层面从数据库初始化存储至内存变量并在相应处理业务中更新相关的统计数据变量,再定时更新至数据库即可。

但这里有一个典型的问题是 竞态条件(race condition) ,即数据一旦被多个线程共享操作,那么就很可能会产生争用和冲突的情况,这往往会破坏共享数据的一致性。如果不对这种并发操作共享数据加以控制,则最后得到的累加统计数据很可能是被低估而失真的。

在解决这个问题的角度上,本文不打算阐述诸如数据库锁、第三方内存缓存、消息队列等外部工具的配合使用方案,毕竟 Go 语言是以独特的并发编程模型傲视群雄的语言,我们应该完全可以在此层面解决之。因此本文将列出利用 Go 语言中的几个内置工具来解决的方案。

Golang http 连接中的协程

首先说到并发编程,不得不提到 Golang 中的 协程 。首先 协程 可看作 用户态线程 ,在 Golang 中即 goroutine 。Golang 中启用协程比较方便,使用 go 关键字即可。例如:

go func() {
    // do something
}()

我们现在来看一下 Golang 中 http 服务涉及的 Server struct ,这个结构体中有个 Serve 方法,此方法的部分说明如下:

// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.

截取该方法中循环接收连接的部分如下:

for {
    rw, err := l.Accept()
    if err != nil {
        select {
        case  max {
                tempDelay = max
            }
            srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
            time.Sleep(tempDelay)
            continue
        }
        return err
    }
    connCtx := ctx
    if cc := srv.ConnContext; cc != nil {
        connCtx = cc(connCtx, rw)
        if connCtx == nil {
            panic("ConnContext returned nil")
        }
    }
    tempDelay = 0
    c := srv.newConn(rw)
    c.setState(c.rwc, StateNew) // before Serve can return
    go c.serve(connCtx)
}

可以看到每当 Accept 一个底层网络连接后就会从该连接创建一个新的 http 连接并启用一个协程处理:

c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // before Serve can return
go c.serve(connCtx)

通道

通道(channel)作为 Go 语言最有特色的数据类型,与 goroutine 并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学。Go 语言的主要创造者之一的 Rob Pike 说过:

Don’t communicate by sharing memory, share memory by communicating.

这充分体现了 Go 语言最重要的编程理念。而通道类型恰恰是后半句话的完美实现,我们可以利用通道在多个 goroutine 之间传递数据。通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。

对于同一个通道, 发送操作之间是互斥的 ,接收操作之间也是互斥的。也就是说在同一时刻,Go 语言的运行时系统只会执行对同一个通道的任意个发送操作中的某一个。直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。即使这些操作是并发执行的也是如此。

很明显,相应的解决方案已经有了:

  1. 初始化一个计数通道;
  2. 相关 http 请求处理协程中在相应时机向该通道发送数据标记;
  3. 启用单独的 goroutine 来循环接收该通道中的数据标记,并根据取出的数据标记来统一地累加更新统计数据变量;
  4. 最后定时将统计数据更新至数据库等永久存储中。

互斥锁与读写锁

相比于 Go 语言宣扬的“ 用通讯的方式共享数据 ”,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,毕竟大多数的现代编程语言都是用后一种方式作为并发编程的解决方案的。而并发地操作共享数据就涉及 同步 了,其实 Golang 也为我们提供了用于并发同步的工具包即 sync 包。

同步的用途有两个,一个是避免多个线程/协程在同一时刻操作同一个数据块,另一个是协调多个线程/协程,以避免它们在同一时刻执行同一个代码块。Go 语言中最重要且最常用的同步工具当属 互斥量(mutual exclusion,简称 mutex) 。sync 包中的 Mutex 就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。

使用 互斥锁 的解决方案如下:

  • 相关 http 请求处理协程中在相应时机直接累加更新统计数据变量,示例代码如下:
var mu sync.Mutex
mu.Lock()
// 累加更新统计数据变量的代码
mu.Unlock()
  • 定时将统计数据更新至数据库等永久存储中。

如果需要对共享的统计数据变量的读写进行更细腻的访问控制,我们可以使用 读写锁sync.RWMutex 。读写锁相关的行为特性: 对于某个受到读写锁保护的共享资源,多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。 我们可以在累加更新统计数据变量前 锁定写锁 ,定时读取统计数据前 锁定读锁 ,这样可以最大限度地避免数据在某一时刻还未完全写完就被读取走的情况。但其实在本文所举的业务例子中,由于统计数据最终是正确一致的,所以使用读写锁的需求并不强烈,这里只是举例说明它的使用而已。

原子操作

在 Golang 同步工具中互斥锁其实属于相对重的操作,实际上如果只涉及并发地读写或者只是写入单一的整数类型值,我们完全可以优先考虑原子操作。理由如下:

  1. 互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的 原子性(atomicity) 。在众多的同步工具中,真正能够保证原子性执行的只有 原子操作(atomic operation) 。原子操作在进行的过程中是不允许中断的。在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效;
  2. 原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性;
  3. 原子操作函数的执行速度要比互斥锁快得多。而且它们使用起来更加简单,不会涉及临界区的选择,以及死锁等问题。

使用 原子操作 的解决方案如下:

  1. 初始化统计数据变量为 *uint32 类型;
  2. 相关 http 请求处理协程中在相应时机累加更新该统计数据变量,即调用 atomic.AddUint32 函数;
  3. 最后定时将统计数据更新至数据库等永久存储中。

欢迎关注我们的微信公众号,每天学习Go知识