Golang中使用context进行基准测试的技巧与实践

Golang中使用context进行基准测试的技巧与实践 我想对一个函数进行计时,以了解其执行所需的时间。不幸的是,等待 <-ctx.Done()(由 context.WithTimeout 产生)似乎需要很长时间——大约比预期多出 1 毫秒(在我的机器上)。考虑以下示例,我对函数 taskWithContext 进行计时。上下文设置为 50 毫秒后超时,这远远超过了 taskWithContext 完成所需的时间。然而,结果是它花费了大约 51.2 毫秒(比设定的预期超时时间多了 1.2 毫秒)。是我遗漏了什么,还是有办法让 <-ctx.Done() 更快?

func main() {
	timeout := 50 * time.Millisecond
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	start := time.Now()

	go taskWithContext(ctx)
	<-ctx.Done()

	finish := time.Since(start)
	fmt.Printf("Finished in %v\n", finish)

}

func taskWithContext(ctx context.Context) {
	doWork()
	fmt.Println("Task completed successfully")
}

func doWork() {
	total := 0
	for i := 0; i < 500000; i++ {
		total += i
	}
}

作为比较,运行一个类似的函数(仍然在单独的 goroutine 中)大约需要 200 微秒(注意数量级的差异:10^-6 秒 vs 10^-3 秒)。不同之处在于,上下文是在 goroutine 完成工作后由其自身取消的(然而,这在实践中并不理想)。

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	start := time.Now()

	go taskWithContext(ctx, cancel)

	<-ctx.Done()

	finish := time.Since(start)
	fmt.Printf("Finished in %v\n", finish)

}

func taskWithContext(ctx context.Context, cancel func()) {
	defer cancel()

	doWork()

	fmt.Println("Task completed successfully")
}

func doWork() {
	total := 0
	for i := 0; i < 500000; i++ {
		total += i
	}
}

这个时间与直接计算(不创建任何 goroutine)所花费的时间相似:

func main() {
	start := time.Now()

	task()

	finish := time.Since(start)
	fmt.Printf("Finished in %v\n", finish)
}

func task() {
	doWork()
	fmt.Println("Task completed successfully")
}

func doWork() {
	total := 0
	for i := 0; i < 500000; i++ {
		total += i
	}
}

当性能至关重要时,是否有比第一个代码片段更好的方法来处理上下文?


更多关于Golang中使用context进行基准测试的技巧与实践的实战教程也可以访问 https://www.itying.com/category-94-b0.html

5 回复

这些代码片段是最小可复现示例,因此在实践中,在 select 块中使用 <-ctx.Done() 等待是有意义的(类似地,使用 sync.Waitgroup)。

然而,我更好奇的是,为什么使用由定时器产生的 <-ctx.Done() 会产生大量额外的开销。

更多关于Golang中使用context进行基准测试的技巧与实践的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


你好 @enobat

<-ctx.Done() 总是会等待,直到上下文被取消或超时。它通常用在 select 语句块中,以便在上下文被取消时退出。

要等待 goroutine 完成,你可以使用 sync.Waitgroup 来代替(或者,也可以使用 errgroup 包,它类似于 Waitgroup,但允许你的 goroutine 返回错误)。

抱歉,我之前理解错了。现在我明白你的意思了。

我不知道为什么 ctx.Done() 会额外增加 1.2 毫秒。不过,请考虑到 ctx.Done() 是在上下文超时或被另一个 goroutine 取消时触发的。这两种情况基本上都是告诉 goroutine 无事可做了,因为另一个 goroutine 已经完成了任务(-> cancel())或者某个关键组件未能响应(-> timeout)。无论如何,接收到 <-ctx.Done() 的 goroutine 已经处于应用程序的关键路径之外。剩下的唯一工作就是清理和退出。因此,在典型场景中,<-ctx.Done() 的性能应该不是那么重要。

我在GitHub上找到了几个相关的讨论(4460844343)以及实际解释,该解释已通过此更新添加到time包中。这类事情与许多因素相关,在实践中几乎难以察觉。由于Go应用程序的工作方式与那些示例不同,我认为,如果你正在寻求性能改进,你需要对你的实际代码进行基准测试和分析,而不是在假设的伪代码上进行。

func main() {
    fmt.Println("hello world")
}

在基准测试中使用 context.WithTimeout 时,确实会引入额外的延迟。这是因为 Go 的计时器实现基于最小堆,并且存在调度延迟。以下是更精确的计时方法:

1. 使用 time.After 替代上下文(适用于纯计时场景)

func benchmarkTask() {
    timeout := 50 * time.Millisecond
    done := make(chan struct{})
    
    start := time.Now()
    
    go func() {
        doWork()
        close(done)
    }()
    
    select {
    case <-done:
        // 任务在超时前完成
    case <-time.After(timeout):
        // 任务超时
    }
    
    elapsed := time.Since(start)
    fmt.Printf("Finished in %v\n", elapsed)
}

2. 使用高精度计时器(减少调度延迟)

func benchmarkWithPreciseTimer() {
    const timeout = 50 * time.Millisecond
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
    
    // 使用 time.Now().UnixNano() 获取纳秒级时间戳
    start := time.Now().UnixNano()
    
    result := make(chan int)
    go func() {
        total := doWork()
        result <- total
    }()
    
    select {
    case <-result:
        elapsed := time.Duration(time.Now().UnixNano() - start)
        fmt.Printf("Task completed in %v\n", elapsed)
    case <-ctx.Done():
        elapsed := time.Duration(time.Now().UnixNano() - start)
        fmt.Printf("Timeout after %v\n", elapsed)
    }
}

3. 使用 sync.WaitGroup 进行精确控制

func benchmarkWithWaitGroup() {
    var wg sync.WaitGroup
    wg.Add(1)
    
    start := time.Now()
    
    go func() {
        defer wg.Done()
        doWork()
    }()
    
    // 使用 channel 实现超时控制
    done := make(chan struct{})
    go func() {
        wg.Wait()
        close(done)
    }()
    
    select {
    case <-done:
        elapsed := time.Since(start)
        fmt.Printf("Completed in %v\n", elapsed)
    case <-time.After(50 * time.Millisecond):
        elapsed := time.Since(start)
        fmt.Printf("Timed out after %v\n", elapsed)
    }
}

4. 微基准测试推荐方法(使用 testing 包)

func BenchmarkDoWork(b *testing.B) {
    for i := 0; i < b.N; i++ {
        doWork()
    }
}

// 带上下文的基准测试
func BenchmarkDoWorkWithContext(b *testing.B) {
    ctx := context.Background()
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        select {
        case <-ctx.Done():
            b.Fatal("context done")
        default:
            doWork()
        }
    }
}

5. 减少上下文开销的优化版本

func efficientTaskWithContext(ctx context.Context) error {
    done := make(chan struct{})
    
    go func() {
        doWork()
        close(done)
    }()
    
    select {
    case <-done:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    timeout := 50 * time.Millisecond
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
    
    start := time.Now()
    
    if err := efficientTaskWithContext(ctx); err != nil {
        fmt.Printf("Error: %v\n", err)
    }
    
    elapsed := time.Since(start)
    fmt.Printf("Finished in %v\n", elapsed)
}

关键点:

  1. context.WithTimeout 的延迟主要来自 Go 运行时计时器的最小精度(通常约 1ms)
  2. 对于微秒级精度的基准测试,建议使用 time.After 或直接计时
  3. 使用 testing 包进行基准测试能获得更准确的结果
  4. 避免在热路径中频繁创建和取消上下文
回到顶部