登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go runtime/metrics.Read 怎样复用样本缓冲区

来源:17golang原创

时间:2026-11-02 03:53:23 472浏览 收藏

如果程序每隔几秒读取一次 Go 运行时指标,不需要每轮都重新创建 []metrics.Sample。更合适的做法是:先创建一个长期持有的样本切片,为每个元素设置一次 Name,之后反复调用 runtime/metrics.Read,让它更新对应的 Value。

真正需要注意的是复用的边界:一次 Read 返回前不要读取或修改这批 Value;多个 Read 并发执行时,传入的切片不能共享底层数组;如果要把直方图等指针型结果交给其他 goroutine,还要先复制其底层数据。

结论:串行定时采样可以复用同一个 []metrics.Sample,但“复用”只适用于样本缓冲区,不等于可以在下一轮 Read 进行时继续使用上一轮的 Value。
Go runtime metrics Read 复用同一 metrics.Sample 切片并更新 Value 的结构示意图
图1:同一组 metrics.Sample 在多次定时采样中复用,Name 保持不变、Value 随 Read 更新;这是静态说明图,不是运行截图。

先把 Name 和 Value 的职责分开

metrics.Sample 只有两个关键字段:Name 指定要读取的指标名称,Value 保存运行时返回的值。Read 接收的是一整个样本切片,它会填充每个元素的 Value,并不会要求调用方为每一次采样重新构造样本对象。

因此,定时采样器应该把“指标选择”放在初始化阶段,把“读取结果”放在循环阶段。这样既减少了切片和样本对象的重复分配,也让采样代码更容易确认每个结果对应哪个指标。

package main

import (
	"fmt"
	"runtime/metrics"
)

// sampler 持有长期复用的样本缓冲区;Name 初始化后不再改变。
type sampler struct {
	samples []metrics.Sample
}

// newSampler 把本次采样需要的指标名写入切片,只执行一次。
func newSampler(names ...string) *sampler {
	samples := make([]metrics.Sample, len(names))
	for i, name := range names {
		// Name 决定 Read 这一项要填充哪一个运行时指标。
		samples[i].Name = name
	}
	return &sampler{samples: samples}
}

func main() {
	s := newSampler(
		"/sched/goroutines:goroutines",
		"/memory/classes/heap/free:bytes",
	)

	// 每次调用都复用 s.samples 的底层数组。
	metrics.Read(s.samples)
	for _, sample := range s.samples {
		// Kind 先决定读取方法,避免用错 Uint64 或 Float64。
		fmt.Printf("%s: %v\\n", sample.Name, sample.Value.Kind())
	}
}

这里的切片可以由采样器长期持有。后续再次调用 metrics.Read(s.samples) 时,Name 仍然是同一组指标名,运行时只需要更新 Value。如果指标集合发生变化,再显式创建新切片,不要在一轮读取过程中修改正在使用的切片结构。

定时采样时复用同一切片

实际程序通常不会只读一次,而是把读取动作放进 ticker 循环。关键顺序是“调用 Read,等待它返回,再读取 Value,最后进入下一轮”。不要把同一切片同时交给一个后台 goroutine 处理,又马上在主循环中发起下一次 Read。

runtime metrics Read 串行复用与并发使用不同底层内存的安全边界图
图2:串行复用同一 slice 与并发使用不同底层内存的安全边界说明图,不是运行截图或性能测试结果。
package main

import (
	"fmt"
	"runtime/metrics"
	"time"
)

// readUint64 读取一组已知为 uint64 的指标,并返回标量副本。
func readUint64(samples []metrics.Sample) map[string]uint64 {
	// Read 完成前不要读取 samples 中的 Value。
	metrics.Read(samples)
	result := make(map[string]uint64, len(samples))
	for _, sample := range samples {
		// 未支持的名称会得到 KindBad,不能继续调用 Uint64。
		if sample.Value.Kind() != metrics.KindUint64 {
			continue
		}
		// 写入 map 后,标量值与下一轮 Read 使用的 Value 脱离。
		result[sample.Name] = sample.Value.Uint64()
	}
	return result
}

func main() {
	samples := []metrics.Sample{
		{Name: "/sched/goroutines:goroutines"},
		{Name: "/memory/classes/heap/free:bytes"},
	}
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop() // 退出时释放 ticker,避免留下定时资源。

	for range ticker.C {
		// 本轮读取和消费完成后,下一轮仍复用同一个 samples 切片。
		fmt.Println(readUint64(samples))
	}
}

这个示例复用了样本切片,但每轮返回一个新的 map。原因是 map 中保存的是已经取出的 uint64 副本,后续 Read 更新样本的 Value 时,不会改写已经交给日志、聚合器或消息队列的数据。

按 ValueKind 读取,不要假设所有指标都是整数

Value 通过 Kind() 表示值的类型。常见的三种有效类型是 KindUint64、KindFloat64 和 KindFloat64Histogram。对应的读取方法分别是 Uint64()、Float64() 和 Float64Histogram();调用与实际类型不匹配的方法会 panic。

如果程序只关心固定指标,可以根据官方描述确认它的 kind 后直接读取;如果程序要适配不同 Go 版本或不同实现,更稳妥的做法是从 metrics.All() 获取支持的指标集合,并对未知类型保留一个降级分支。

// readValue 展示三种有效 ValueKind 的分派方式。
func readValue(sample metrics.Sample) (any, bool) {
	value := sample.Value
	switch value.Kind() {
	case metrics.KindUint64:
		// 整数指标复制出一个独立的标量值。
		return value.Uint64(), true
	case metrics.KindFloat64:
		// 浮点指标同样在当前 Read 完成后立即取出。
		return value.Float64(), true
	case metrics.KindFloat64Histogram:
		// 直方图先交给专门的复制函数,避免把运行时数据指针直接传出去。
		return cloneHistogram(value.Float64Histogram()), true
	case metrics.KindBad:
		// 名称不存在或不再支持时,调用方应记录并跳过这一项。
		return nil, false
	default:
		// 新增 kind 时先安全降级,避免错误调用读取方法。
		return nil, false
	}
}

什么时候必须停止复用,改用独立内存

如果所有工作都在同一个 goroutine 中,并且上一轮 Read 已经返回,复用同一切片通常就是最简单的方案。问题出现在并发边界:一边执行 Read,另一边读取或修改同一个 Value,即使业务上只是“读监控数据”,也可能形成数据竞争。

多个 Read 可以并发执行,但它们的参数必须不共享底层内存。也就是说,两个 goroutine 不能同时使用同一个 samples 切片;应该分别创建两份切片,或者为每个 worker 使用独立的缓冲区。

尤其要注意 Float64Histogram:它返回的是指针型值,底层存储在条件允许时也可能被后续 Read 复用。需要异步处理时,先复制 Counts 和 Buckets,再把副本交给其他 goroutine。

// cloneHistogram 深拷贝直方图的两个底层切片,切断与下一轮 Read 的共享。
func cloneHistogram(src *metrics.Float64Histogram) *metrics.Float64Histogram {
	if src == nil {
		return nil
	}
	return &metrics.Float64Histogram{
		// Counts 是每个桶的计数,必须复制而不是只复制切片头。
		Counts: append([]uint64(nil), src.Counts...),
		// Buckets 是桶边界,同样需要独立的底层数组。
		Buckets: append([]float64(nil), src.Buckets...),
	}
}

如果不想处理深拷贝,也可以在调用方约定“只在当前采样函数返回前消费结果”,不要把 Value 或其中的指针继续向外传。对于两个独立的并发采样任务,直接各自创建 []metrics.Sample 是更直观的隔离方式,代价只是增加少量分配。

把复用策略落成一张检查表

场景推荐做法原因
单 goroutine 定时采样复用同一 []metrics.Sample串行调用,上一轮完成后再读取下一轮
多个 goroutine 同时 Read每个任务使用独立切片避免参数共享底层内存
把整数或浮点值交给异步任务先取出标量副本脱离当前 Sample 的 Value
把直方图交给异步任务深拷贝 Counts 和 Buckets避免底层存储被后续 Read 复用
指标名可能不存在检查 KindBad 并跳过未知名称不能按有效值读取

常见追问

每次调用 Read 都要重新设置 Name 吗?

不需要。只要指标名称没有变化,初始化一次后可以反复复用同一组 Sample。如果要换指标集合,再创建或重建对应的样本切片。

复用切片会不会让上一轮数据丢失?

会覆盖样本中的 Value,所以要保留历史数据时必须在下一轮读取前复制需要的字段。整数和浮点值直接取成标量,直方图则复制其底层切片。

可以把同一个 samples 传给两个 goroutine 吗?

不能让两个并发的 Read 或一个 Read 与另一个消费者共享同一底层内存。并发采样使用独立切片;异步消费使用完成后的深拷贝。

官方资料在哪里?

官方文档:https://pkg.go.dev/runtime/metrics

Read 源码:https://go.dev/src/runtime/metrics/sample.go

记住“样本切片可复用,正在使用的 Value 不可跨边界共享”这一句,基本就能覆盖 runtime/metrics.Read 的性能与并发取舍。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>