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,但前一个调用已经结束、后一个调用还没有开始,才是安全的时间关系。

共享 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 的结果继续保存”。

如果业务需要在函数返回后继续保存线段,就在归还 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 前复制。
-
502 收藏
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
471 收藏
-
209 收藏
-
358 收藏
-
439 收藏
-
435 收藏
-
213 收藏
-
348 收藏
-
346 收藏
-
410 收藏
-
366 收藏
-
404 收藏
-
407 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习