Golang slice append 并发安全指南:避免 goroutine 数据竞争

Golang 并发编程:如何安全地使用 goroutine 操作 slice append

文章目录

在 Golang 并发编程中,goroutine 是轻量级的执行线程,为高效地处理数据提供了强大的支持。Slice 作为一种动态数组,为数据存储提供了灵活性。然而,当多个 goroutine 并发地使用 append 操作向同一个 slice 添加元素时,可能会出现 data race 和数据不一致的情况。这是因为多个 goroutine 会竞争同一个底层数组的内存空间,导致同一个数组下标的元素被多次覆盖。 本文将深入探讨 Golang 并发 append slice 的安全问题,分析其背后的原因,并提供使用加锁机制等解决方案,确保数据安全和程序稳定性。 通过学习本文,您将更好地理解 Golang 并发编程中的潜在陷阱,并掌握编写安全高效代码的技巧。

Goroutine append slice 背景

在导出数据库数据时,为了提高效率,我们使用了并发查询并将结果合并到一个 slice 中。当数据量较小时,一切正常。然而,当数据量超过百万时,我们发现导出的数据量与实际数量不符! 😱 经过排查,我们发现这是由于 goroutine 并发操作 slice 导致的 append 安全问题。

Golang 并发安全问题概述

在 Golang 并发编程中,多个 goroutine 同时访问共享数据时,如果没有进行适当的同步控制,就可能出现 data race,导致程序行为不可预测。例如,多个 goroutine 并发地使用 append 操作向同一个 slice 添加元素,就可能导致数据不一致。

与 map 不同的是,并发 append slice 并不会直接导致程序 panic,因此更容易被开发者忽视。 这也使得 slice append 的并发安全问题更加隐蔽,需要开发者更加谨慎地处理。

golang map 的并发读写示例

回顾一下 map 的并发读写,在并发很小的时候比如 10 个以内,也不是每次都会 panic,比如:

package main

import (
    "sync"
    "testing"
)

func TestMap(t *testing.T) {
    m := map[int]int{}
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int) {
            if _, exists := m[i]; !exists {
                m[i] = i
            }
            wg.Done()
        }(i)
    }
    wg.Wait()

    t.Log(m)
}

执行单元测试,有可能要执行 10 多次才会有 1 次 panic:

go test -v -run TestMap
=== RUN   TestMap
    map_test.go:23: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8 9:9]
--- PASS: TestMap (0.00s)
PASS

调大并发数到 1000 以上则几乎 100% panic 。

为方便快速验证,添加 -race 参数:

go test -v -race -run TestMap
=== RUN   TestMap
==================
WARNING: DATA RACE
Read at 0x00c00002c330 by goroutine 9:
    ...
==================
    map_test.go:23: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8 9:9]
    testing.go:1093: race detected during execution of test
--- FAIL: TestMap (0.00s)
=== CONT
    testing.go:1093: race detected during execution of test
FAIL

相关阅读推荐:Golang 数据竞争详解:Data Race 原因、检测方法与实用解决方案

golang map 主要是使用 hmap 和 bmap 两个结构实现的哈希表, map 的操作因为考虑到使用场景和性能问题,没有实现为原子操作,参考官方文档 FAQ: https://golang.org/doc/faq#atomic_maps

map 底层实现:

map[int]int{} -> hmap -> []bmap -> tophash
                         |           |
                     *overflow     []key
                         |           |
                         []bmap      []value

参考:Golang map 实现原理

Golang 中使用 Goroutine 并发 append slice 的示例

再来看 goroutine 并发 append slice 的情况,与 map 不同,slice 在并发 append 时不会直接抛出 panic,但会导致结果不如预期。使用 10 个 goroutine 并发 append 10000 个数字到 slice s 中,最终 s 正确长度为 100000

测试代码:

package main

import (
    "sync"
    "testing"
)

func TestSlice(t *testing.T) {
    s := []int{}
    var wg sync.WaitGroup

    // 外部变量记录每个 goroutine append 的数量
    count := 0
    // 10 个 goroutine 并发 append 10000 个数字到 slice s 中,最终 s 正确长度为 10 * 10000 = 100000
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i, count int) {
            for j := 0; j < 10000; j++ {
                s = append(s, j)
                count++
            }
            t.Logf("G%d append count:%d\n", i, count)
            wg.Done()
        }(i, count)
    }
    wg.Wait()

    if len(s) != 100000 {
        t.Errorf("s.len:%d != 100000", len(s))
    }
}

同样,循环次数如果很少时也有可能是正常的结果,数量较大时就能明显观察到错误。

执行测试:

go test -v -race -run TestSlice
=== RUN   TestSlice
==================
WARNING: DATA RACE
Read at 0x00c00000e048 by goroutine 10:
    ...
...
==================
    slice_test.go:22: G9 append count:10000
    slice_test.go:22: G0 append count:10000
    slice_test.go:22: G3 append count:10000
    slice_test.go:22: G2 append count:10000
    slice_test.go:22: G8 append count:10000
    slice_test.go:22: G4 append count:10000
    slice_test.go:22: G6 append count:10000
    slice_test.go:22: G5 append count:10000
    slice_test.go:22: G1 append count:10000
    slice_test.go:22: G7 append count:10000
    slice_test.go:29: s.len:26101 != 100000
    testing.go:1093: race detected during execution of test
--- FAIL: TestSlice (0.28s)
=== CONT
    testing.go:1093: race detected during execution of test
FAIL

golang 中如何处理 Data Race 并发安全问题?

为了避免并发 append slice 带来的安全问题,我们需要使用加锁机制来保证同一时间只有一个 goroutine 可以操作 slice。 以下代码展示了如何使用互斥锁 (sync.Mutex) 来保护 slice 的 append 操作:

package main

import (
   "sync"
   "testing"
)

func TestSlice(t *testing.T) {
   s := []int{}
   var wg sync.WaitGroup
   // 锁
   var mu sync.Mutex

   // 外部变量记录每个 goroutine append 的数量
   count := 0
   // 10 个 goroutine 并发 append 10000 个数字到 slice s 中,最终 s 正确长度为 10 * 10000 = 100000
   for i := 0; i < 10; i++ {
       wg.Add(1)
       go func(i, count int) {
           for j := 0; j < 10000; j++ {
               // append slice 加锁解决并发安全问题
               mu.Lock()
               s = append(s, j)
               mu.Unlock()
               count++
           }
           t.Logf("G%d append count:%d\n", i, count)
           wg.Done()
       }(i, count)
   }
   wg.Wait()

   if len(s) != 100000 {
       t.Errorf("s.len:%d != 100000", len(s))
   }
}

运行测试:

go test -v -race -run TestSlice
=== RUN   TestSlice
    slice_test.go:25: G6 append count:10000
    slice_test.go:25: G0 append count:10000
    slice_test.go:25: G9 append count:10000
    slice_test.go:25: G5 append count:10000
    slice_test.go:25: G8 append count:10000
    slice_test.go:25: G7 append count:10000
    slice_test.go:25: G2 append count:10000
    slice_test.go:25: G3 append count:10000
    slice_test.go:25: G1 append count:10000
    slice_test.go:25: G4 append count:10000
--- PASS: TestSlice (0.05s)
PASS

golang slice 并发安全问题分析

先说结论:因为并发的 append 操作的是同一个底层数组,导致同一个数组下标的元素被多次覆盖。slice 的底层数据结构是一个数组,append 操作实际上是在修改数组的内容。当多个 goroutine 并发地 append 元素时,它们会竞争同一个数组的内存空间,导致数据覆盖和不一致。

slice 底层是使用数组保存数据,数组是一段连续的内存空间

[]int{} -> *array -> [连续内存空间]
            |
            len
            |
            cap

slice 实现原理参考:https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array-and-slice/

append 处理流程:

// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
    ptr, len, cap = growslice(slice, newlen)
    newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)

在程序中先声明了一个 slice s 然后并发的不断向其中添加元素, append 追加元素实际是将每个元素依次赋值给对应的数组中的内存指针(即将内存空间的指针指向对应的元素), 并发情况下,所有 goroutine 都操作的时同一个数组,同一个指针可能多次指向了不同的元素,最后导致元素个数和预期不一致。

小结

在并发编程中,切忌忽视并发安全问题。对于涉及共享数据的操作,务必要采用适当的同步机制,确保数据的完整性和一致性。通过加锁等手段,能有效避免并发带来的潜在风险,确保程序的稳定性和正确性。


也可以看看