Golang中如何对并发数据结构进行单元测试

Golang中如何对并发数据结构进行单元测试 你已经编写了一个数据结构 D,并且想要测试 D 是并发安全的。

你通常会进行哪些类型的单元测试,以确保你的数据结构 D 是并发安全的,并捕获任何可能的并发相关错误?

10 回复

有人做代码审查吗?把这个移到代码审查区有意义吗?

更多关于Golang中如何对并发数据结构进行单元测试的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


关于 -race 标志,它需要一个实际会生成 goroutine 的场景。纯粹的串行代码永远不会导致数据竞争。

你好 @christophberger

非常好的观点,感谢你给出如此精彩的回答。

关于并行测试,你能提供更多细节并可能链接一个示例吗?你指的是一个能让其测试用例并行运行的表格测试吗?

另外,你能指出一个用于检测并发问题的模糊测试示例吗?我对模糊测试的一般理解是,它们生成随机输入来测试数值算法。我该如何调整一个模糊测试来测试并发问题呢?这听起来是个非常吸引人的想法,请提供更多细节。

是的,我指的是用于并行测试的 t.Parallel() 功能。

模糊测试如何提供帮助?嗯,并发错误通常很难被发现,因为它们依赖于彼此独立运行的任务的(非确定性)行为。生成大量输入数据可以极大地增加触发错误的机会。而模糊测试可以生成大量的输入。

模糊测试不仅限于数值算法。除了数值类型,输入参数(“模糊测试参数”)也可以是 字符串、字节切片、符文或布尔值。

你好 @christophberger

感谢你指出 f.Parallel 中的错误。关于提高模糊测试发现 bug 的能力,你有什么建议吗?它会产生一系列字节切片,但对于其中的绝大多数,代码处理起来都不会有困难。

有没有办法配置我的模糊测试,让它能够做以下事情:a) 总是将空切片作为输入之一发送,b) 发送一个包含许多非常大数字的切片来测试溢出等等。

另外,使用 -race 运行模糊测试默认应该会测试竞态条件,因为模糊测试会并行运行以利用可用的处理器?这个假设正确吗?

你好 @telo_tade

你的代码应该在 f.Parallel() 处出错,因为该方法并不存在。你需要按照 testing 文档 中概述的那样,使用 t.Run()t.Parallel()

// 示例代码结构
func TestMyFunction(t *testing.T) {
    t.Run("subtest1", func(t *testing.T) {
        t.Parallel()
        // 子测试1的代码
    })
    t.Run("subtest2", func(t *testing.T) {
        t.Parallel()
        // 子测试2的代码
    })
}

我宁愿依赖设计评审、代码评审,以及竞态检测器和死锁检测。

  • 设计与代码评审:如果代码在 goroutine 之间不共享内存,并且不以竞争方式访问有限资源,那么它显然是并发安全的。如果代码涉及上述任何一种情况,评审就需要扩展到验证正确的解耦(通过通道)、锁定和资源池。

  • 竞态检测器:对于检测评审可能遗漏的数据竞争条件来说,它是不可或缺的。

  • 死锁检测:同样非常有用,但请记住,只有在二进制文件中的所有 goroutine 都被阻塞时,死锁警报才会触发。即使你除了并发工作代码外只启动了一个 HTTP 服务器,如果你的工作线程锁定了,死锁检测器也会保持沉默。

  • 对于单元测试,我认为有两种方法可以增加捕获死锁、数据竞争或其他并发引发问题的机会:

    • 运行并行测试
    • 进行模糊测试(需要 Go 1.18+ 版本)

telo_tade:

有没有办法配置我的模糊测试,使其能够执行以下操作:a) 总是将空切片作为输入之一发送,b) 发送包含许多非常大数字的切片以测试溢出等。

虽然生成的测试包含相当程度的随机性,但通过向种子语料库提供合适的数据,应该能够影响生成的测试用例。

对于 a),你可以简单地调用 f.Add([]byte{}) 来添加空切片的情况。

对于 b),我想如果你提供一个包含非常大数字的数组,或者一个非常长的数组,模糊测试引擎将创建更多包含大数字或更长数组的测试用例。(我还没有研究模糊测试引擎的内部工作原理,所以这只是一个猜测,但毕竟,种子语料库是模糊测试引擎用作起点的东西。)

如果你心中有一些非常具体的测试用例,那么你可能需要为这些情况创建普通的测试。我不会依赖模糊测试引擎来生成所有类型的边缘情况。模糊测试有助于增加捕获标准测试遗漏的测试用例的机会,但它不能保证捕获它们。

你好 @christophberger

这是一个算法示例,以及我的模糊测试:

type Summable interface {
    	~int | ~int8 | ~int16 | ~int32 | ~int64 |
        	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
	    ~float32 | ~float64 | ~string
}

func Build[S ~[]T, T Summable](input S) S {
    if len(input) == 0 {
	    return *new([]T) 
    }

    result := make([]T, len(input))

    result[0] = input[0]
    for i := 1; i < len(result); i++ {
	    result[i] = result[i - 1] + input[i]
    }

    return result
}

我的模糊测试是:

func FuzzBuild(f *testing.F) {
    f.Add([]byte{1, 2, 11, 12, 3, 3, 15, 8})
        f.Parallel()

    f.Fuzz(func(t *testing.T, slice []byte) {
	    if len(slice) == 0 {
		    assert.Equal(t, 0, len(prefix_sum.Build(slice)))

		    return
	    }

	    got := prefix_sum.Build(slice)
	    assert.Equal(t, got[0], slice[0])

	    expected := slice[0]

	    for i := 1; i < len(slice); i++ {
		    expected += slice[i]

		    assert.Equal(t, expected, got[i])
	    }
    })
}

现在,在这个例子中:

  • 我的 go test -fuzz FuzzBuild -race -v 没有发现问题,显然,如果对同一个输入切片并行调用多次,我的代码会导致竞争问题。
  • 我构建 []byte 并将其添加到 f 的方式——我能做得更好吗?也就是说,我能否以某种方式构建我的模糊测试,从而增加我发现更多错误的机会?

请给我一些关于如何编写这个模糊测试的建议,谢谢!

在Golang中对并发数据结构进行单元测试,通常需要结合多种测试策略来确保线程安全。以下是我推荐的测试方法及示例代码:

1. 竞态条件检测

使用-race标志运行测试,这是最基本且必要的步骤:

go test -race ./...

2. 并发压力测试

模拟多个goroutine同时访问数据结构:

func TestConcurrentAccess(t *testing.T) {
    d := NewD()
    var wg sync.WaitGroup
    iterations := 1000
    goroutines := 100

    for i := 0; i < goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := 0; j < iterations; j++ {
                key := fmt.Sprintf("key-%d-%d", id, j)
                d.Set(key, j)
                val, ok := d.Get(key)
                if !ok || val != j {
                    t.Errorf("数据不一致: key=%s, expected=%d, got=%v", key, j, val)
                }
                d.Delete(key)
            }
        }(i)
    }
    wg.Wait()
}

3. 顺序一致性测试

验证操作在并发环境下的顺序一致性:

func TestSequentialConsistency(t *testing.T) {
    d := NewD()
    const N = 10000
    var wg sync.WaitGroup
    results := make(chan int, N)

    // 并发写入
    for i := 0; i < N; i++ {
        wg.Add(1)
        go func(val int) {
            defer wg.Done()
            d.Set("counter", val)
            results <- d.Get("counter")
        }(i)
    }

    go func() {
        wg.Wait()
        close(results)
    }()

    // 验证读取的值是有效的
    for val := range results {
        if val < 0 || val >= N {
            t.Errorf("无效的值: %d", val)
        }
    }
}

4. 死锁检测测试

func TestDeadlockFreedom(t *testing.T) {
    d := NewD()
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    done := make(chan bool)
    
    go func() {
        // 模拟复杂并发操作
        var wg sync.WaitGroup
        for i := 0; i < 100; i++ {
            wg.Add(1)
            go func(id int) {
                defer wg.Done()
                for j := 0; j < 100; j++ {
                    d.Set(fmt.Sprintf("%d-%d", id, j), j)
                    d.Get(fmt.Sprintf("%d-%d", id, j))
                }
            }(i)
        }
        wg.Wait()
        done <- true
    }()

    select {
    case <-ctx.Done():
        t.Fatal("测试超时,可能存在死锁")
    case <-done:
        // 正常完成
    }
}

5. 边界条件并发测试

func TestBoundaryConditions(t *testing.T) {
    d := NewD()
    
    // 测试空数据结构的并发访问
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(2)
        go func() {
            defer wg.Done()
            d.Get("nonexistent")
        }()
        go func() {
            defer wg.Done()
            d.Delete("nonexistent")
        }()
    }
    wg.Wait()
}

6. 混合操作测试

func TestMixedOperations(t *testing.T) {
    d := NewD()
    const workers = 50
    var wg sync.WaitGroup
    errors := make(chan error, workers*100)

    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for i := 0; i < 100; i++ {
                key := fmt.Sprintf("key-%d", i)
                
                // 混合操作序列
                d.Set(key, workerID)
                val, ok := d.Get(key)
                if ok && val != workerID {
                    errors <- fmt.Errorf("worker %d: 期望 %d, 得到 %d", workerID, workerID, val)
                }
                
                d.Update(key, func(old int) int {
                    return old + 1
                })
                
                d.Delete(key)
                
                // 验证删除
                if _, ok := d.Get(key); ok {
                    errors <- fmt.Errorf("key %s 应该已被删除", key)
                }
            }
        }(w)
    }

    wg.Wait()
    close(errors)
    
    for err := range errors {
        t.Error(err)
    }
}

7. 性能基准测试

func BenchmarkConcurrentAccess(b *testing.B) {
    d := NewD()
    b.RunParallel(func(pb *testing.PB) {
        counter := 0
        for pb.Next() {
            key := fmt.Sprintf("key-%d", counter%1000)
            d.Set(key, counter)
            d.Get(key)
            counter++
        }
    })
}

这些测试组合使用可以有效地发现并发问题:

  • 竞态检测器捕获数据竞争
  • 压力测试暴露同步问题
  • 超时测试检测死锁
  • 混合操作测试验证复杂场景
  • 基准测试确保性能可接受

运行测试时务必使用-race标志,并在CI/CD流水线中强制执行这些测试。

回到顶部