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

Go x/image/font/sfnt Buffer 为什么不适合多协程同时复用

来源:17golang原创

时间:2026-10-27 08:08:27 378浏览 收藏

我第一次把 golang.org/x/image/font/sfnt 接进并发字体查询服务时,直觉上把 Font 和 Buffer 都当成了“解析后的字体对象”。结果是:单协程下看不出问题,一旦多个 goroutine 共用一个 *sfnt.Buffer,返回的指标和字形数据就出现了不稳定的交叉覆盖。

官方地址:https://pkg.go.dev/golang.org/x/image/font/sfnt

结论很明确:同一个 *sfnt.Buffer 不适合被多个协程同时复用;真正可以共享的是同一个 *sfnt.Font。让每次 Font 方法调用拿到不同的 Buffer,或者传入 nil 使用一次性临时 Buffer。

先把 Font 和 Buffer 看成两种不同职责

sfnt 是 TTF、OTF 等 SFNT 字体的低层解码器。Font 保存了字体数据的解析结果,而 Buffer 是给多次 Font 方法调用复用的临时工作区,目的是减少重复分配。它不是字体本身,也不是一个可以被并发请求共享的“上下文”。

官方文档把这两个边界说得很清楚:Font 的方法可以并发调用,但每次调用必须使用不同的 *Buffer,也可以传入 nil。同一个 Buffer 可以在不同调用之间复用,也可以服务不同的 Font,但前一个调用已经结束、后一个调用还没有开始,才是安全的时间关系。

sfnt.Font 共享与每次调用独立 Buffer 的并发结构说明图
图1:并发结构说明图,展示 sfnt.Font 与每次调用独立 Buffer 的关系,不是运行截图。

共享 Font 没问题,共享 Buffer 会把临时状态混在一起

下面这种写法的问题不在于多个 goroutine 读取同一个 Font,而在于它们同时修改同一个 Buffer。Buffer 内部字段没有作为公共并发接口暴露出来,但 Font 的方法会把它当作可写的复用空间;两个调用一旦交错,谁最后写入了临时空间,谁就可能改变另一方正在使用的数据。

package main

import (
    "fmt"
    "golang.org/x/image/font/sfnt"
)

var sharedBuffer sfnt.Buffer

func unsafeQuery(f *sfnt.Font, r rune) (sfnt.GlyphIndex, error) {
    // 错误示例:全局 Buffer 会被多个并发调用同时当作临时工作区。
    return f.GlyphIndex(&sharedBuffer, r)
}

func safeQuery(f *sfnt.Font, r rune) (sfnt.GlyphIndex, error) {
    var local sfnt.Buffer
    // 每个调用拥有自己的临时区,Font 本身仍然可以由多个调用共享。
    glyph, err := f.GlyphIndex(&local, r)
    if err != nil {
        // 保留库返回的错误,让调用方区分解析失败与缺少字形。
        return 0, fmt.Errorf("查询字形 %q:%w", r, err)
    }
    return glyph, nil
}

上面的 unsafeQuery 还没有启动 goroutine,但只要把同一个 Buffer 放进共享服务结构体,问题就会变成竞态。更稳妥的原则是:Font 可以长期共享,Buffer 只在一次调用的边界内被借用。

不需要复用时,传 nil 反而更简单

如果一次请求只查询一两个字符,或者当前瓶颈根本不在临时分配,直接传 nil 是合法的。文档说明 nil 表示这次调用使用一个临时 Buffer;它避免了调用方维护共享池,也不会把 Buffer 的生命周期泄漏到业务对象中。

func glyphIndexWithoutReuse(f *sfnt.Font, r rune) (sfnt.GlyphIndex, error) {
    // nil 表示只为本次调用使用临时 Buffer,适合低频或简单查询。
    glyph, err := f.GlyphIndex(nil, r)
    if err != nil {
        // ErrNotFound 也要交给上层决定是回退字体还是返回业务错误。
        return 0, err
    }
    return glyph, nil
}

这也是我后来采用的取舍:先用 nil 把并发所有权写对,再在确认分配成本确实影响吞吐时引入 sync.Pool。不要为了“看起来高性能”一开始就把可复用对象升级成全局共享状态。

LoadGlyph 返回的 Segments 不能越过 Buffer 的复用边界

LoadGlyph 是更容易踩坑的一组 API。它返回字形的矢量线段;当传入非 nil Buffer 时,官方文档明确说明:Buffer 一旦被再次使用,返回的 Segments 就失效。也就是说,问题不止是“调用期间不能共享”,还包括“调用结束后不能把依赖临时 Buffer 的结果继续保存”。

LoadGlyph 返回 Segments 在 Buffer 复用后失效的生命周期说明图
图2:返回值生命周期说明图,展示 Segments 复制与 Buffer 复用的安全边界,不是并发运行证据。

如果业务需要在函数返回后继续保存线段,就在归还 Buffer 前复制一份。sfnt.Segment 是值类型,复制切片即可把结果与临时工作区分开。

func loadGlyphCopy(f *sfnt.Font, glyph sfnt.GlyphIndex, ppem fixed.Int26_6) (sfnt.Segments, error) {
    var b sfnt.Buffer
    // Segments 只在 b 未被再次使用前可靠,先在当前调用内完成复制。
    segments, err := f.LoadGlyph(&b, glyph, ppem, nil)
    if err != nil {
        // ErrColoredGlyph 等错误不能用空切片伪装成成功结果。
        return nil, err
    }
    copied := append(sfnt.Segments(nil), segments...)
    // copied 不再依赖 b,函数返回后可以安全交给异步消费者。
    return copied, nil
}

这段示例需要补上 golang.org/x/image/math/fixed 导入。真正的业务代码中,如果只是当场绘制或读取边界,不需要把 Segments 返回出去,就可以在 Buffer 仍由当前调用独占时完成全部工作。

用 sync.Pool 复用 Buffer 时,借还必须包住完整调用

当查询量很大时,可以让 sync.Pool 管理 Buffer。关键不是 Pool 本身能否并发,而是每次 Get 到 Put 之间,Buffer 只能属于当前调用;并且任何依赖它的返回值都必须在 Put 前处理或复制。

var bufferPool = sync.Pool{
    New: func() any {
        // Pool 只保存临时工作区,不保存 Font 或字体源数据。
        return new(sfnt.Buffer)
    },
}

func queryMetrics(f *sfnt.Font, ppem fixed.Int26_6, hinting font.Hinting) (font.Metrics, error) {
    b := bufferPool.Get().(*sfnt.Buffer)
    defer bufferPool.Put(b)
    // 借出后到归还前,b 只服务当前调用;Font 仍可被其它协程共享。
    metrics, err := f.Metrics(b, ppem, hinting)
    if err != nil {
        // 先返回错误,defer 仍会把临时 Buffer 放回池中。
        return font.Metrics{}, err
    }
    return metrics, nil
}

这里的 Metrics 返回的是独立的值结构,归还 Buffer 后不会留下像 Segments 那样的切片引用。若换成 LoadGlyph,就必须先复制 Segments,再执行 Put。这正是“接口能不能并发”与“返回值活多久”两个问题的交叉处。

字体源数据也有自己的生命周期

Buffer 的并发问题修好后,还有一个容易被误归类的边界:sfnt.Parse 并不会把传入的字体字节完全复制成一个与源无关的 Font。使用 []byte 解析时,调用方在 Font 仍被使用期间不能修改这段数据;使用 io.ReaderAt 解析时,也不能在 Font 仍使用期间关闭或改变 ReaderAt。

因此一个长期运行的字体服务通常有三层所有权:字体源字节只读并保持有效,Font 作为共享解析对象,Buffer 作为每次调用独占的临时区。把这三层放进同一个可变结构体,并不等于它们拥有相同的并发规则。

最后用一张表做调用前判断

对象或返回值能否跨协程共享实践建议
*sfnt.Font可以,Font 方法支持并发保持底层字体数据不被修改
*sfnt.Buffer不能并发复用每次调用独立实例,或传 nil
sfnt.Segments取决于来源非 nil Buffer 被复用前先复制
Font.NumGlyphs 等无 Buffer 方法可以并发调用仍要遵守 Font 源数据生命周期

我的最终判断是:如果只是读取字形索引、名称或度量,先选独立 Buffer 或 nil;如果要把线段交给异步流程,先复制再离开调用边界;如果用 sync.Pool,把借还写在同一个函数里,不要把 Buffer 作为服务级共享字段暴露出去。

相关问题

同一个 Buffer 能给不同 Font 轮流使用吗?

可以,但必须是前一次调用已经完成、后一次调用尚未开始的串行复用。不同 Font 不会改变 Buffer 不能并发复用的规则。

所有 Font 方法都需要 Buffer 吗?

不是。官方文档中不接收 Buffer 的方法可以安全并发调用;接收 Buffer 的方法则要为每次并发调用提供不同 Buffer 或传 nil。

LoadGlyph 的结果为什么要复制?

因为非 nil Buffer 被再次使用后,返回的 Segments 会失效。若结果要跨函数、跨 goroutine 或跨缓存保存,就要在归还或再次使用 Buffer 前复制。

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