Go bufio.Scanner 连续空 token 为什么会停止
我第一次写自定义 SplitFunc 时,想保留 CSV 风格数据里的尾部空字段,于是在 atEOF 时反复返回 (0, []byte{}, nil)。看起来只是交付一个空字符串,实际却没有消费任何输入,也没有告诉 Scanner 已经结束。结果不是平稳退出,而是触发 bufio.Scan: too many empty tokens without progressing。
- 空 token 本身合法,问题是“非 nil 的空 token + advance 为 0 + 没有终止信号”。
- 中间空字段要在返回 token 的同时消费分隔符,让
advance > 0。 - 最终空字段应配合
bufio.ErrFinalToken返回一次,Scanner 随后结束。 (0, nil, nil)表示暂时没有完整 token,请求更多数据;它不是一个空字段。
官方参考:https://pkg.go.dev/bufio。Go 文档明确说明:如果分割函数返回过多“不推进输入的空 token”,Scan 会 panic。这是对错误 SplitFunc 的保护,不是 Scanner 无法处理空字符串。
一、修复范围:不是禁止空 token,而是禁止没有进展
这个问题通常只出现在自定义 SplitFunc。默认的 ScanLines 可以返回空行,因为它发现换行符后会把 advance 设为换行符之后的位置;即使 token 长度为 0,输入位置也已经前移。
真正危险的是分割函数不断交付非 nil 空切片,却一直返回 advance == 0。在输入尚未结束时,这可能让调用者一遍遍拿到同一个空 token;到达 EOF 后,Scanner 会累计这种无进展空 token,超过保护阈值后 panic。当前标准库实现的阈值常量是 100,但业务代码不应依赖这个数字,应该依赖文档给出的契约:不能无限返回不推进输入的空 token。
二、返回值对照:四种组合的语义完全不同
SplitFunc 返回 (advance, token, err)。我觉得最容易混淆的是 nil 与长度为 0 的非 nil 切片:两者的 len 都可能是 0,但 Scanner 对它们的解释不同。

| 返回组合 | Scanner 的理解 | 适用场景 |
|---|---|---|
(0, nil, nil) | 当前数据不足,没有 token | 未到 EOF,继续读取更多字节 |
(n, []byte{}, nil),n > 0 | 交付合法空 token,并推进输入 | 连续分隔符形成的中间空字段 |
(0, []byte{}, ErrFinalToken) | 交付一个最终空 token,然后结束 | 尾分隔符产生的末尾空字段 |
(0, nil, ErrFinalToken) | 立即结束,不再交付 token | 空输入不应产生字段,或主动提前停止 |
这里的关键不是 token 有没有内容,而是当前调用是否完成了一个可证明的动作:要么消费输入,要么声明需要更多数据,要么明确结束。
三、旧代码风险:EOF 时反复返回同一个空 token
下面这段写法看似想保留尾部空字段,却把“最后一个 token”写成了“每次都可以再次返回的 token”。
func badSplit(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF {
// 错误:交付了非 nil 空 token,却不推进输入也不声明结束。
return 0, []byte{}, nil
}
// 暂时没有完整字段,请求 Scanner 继续读取。
return 0, nil, nil
}
Scanner 的内部状态没有改变,因此下一次 Scan 仍会在 EOF 处调用同一分割函数,得到相同结果。标准库源码会统计 EOF 下这种“token 非 nil、advance 为 0”的连续次数,过多时直接 panic。它不会通过 scanner.Err() 返回普通错误,所以仅仅在循环后检查 Err 不能补救这个 SplitFunc。
我最初不适应的地方就在这里:[]byte{} 明明是一个合法值,为什么会被判定有问题?后来把它理解成迭代器协议就顺了——值可以为空,但迭代器必须能证明自己已经前进,或者明确表示结束。
四、新写法:中间空字段要推进,最终空字段要终止
下面的分割函数按逗号切分,并保留连续逗号和尾逗号形成的空字段。中间字段通过消费逗号推进;EOF 下最后一个字段通过 ErrFinalToken 只交付一次。

package main
import (
"bufio"
"bytes"
"fmt"
"strings"
)
func splitCommaKeepEmpty(data []byte, atEOF bool) (advance int, token []byte, err error) {
if i := bytes.IndexByte(data, ','); i >= 0 {
// 消费逗号,所以即使 data[:i] 是空字段,输入也会向前推进。
return i + 1, data[:i], nil
}
if !atEOF {
// 当前片段没有逗号且还没到 EOF,请求更多数据,不交付 token。
return 0, nil, nil
}
if len(data) == 0 {
// 尾逗号对应一个最终空字段;ErrFinalToken 保证它只返回一次。
return 0, []byte{}, bufio.ErrFinalToken
}
// 没有尾逗号时,交付剩余字段并结束扫描。
return 0, data, bufio.ErrFinalToken
}
func main() {
input := "red,,blue,"
scanner := bufio.NewScanner(strings.NewReader(input))
scanner.Split(splitCommaKeepEmpty) // Split 必须在第一次 Scan 之前设置。
for scanner.Scan() {
fmt.Printf("%q\n", scanner.Text()) // 空字段会打印为 ""。
}
if err := scanner.Err(); err != nil {
fmt.Println("扫描失败:", err) // ErrFinalToken 不会作为错误暴露。
}
}
这段代码会把 red,,blue, 解释为四个字段:red、空字段、blue、最终空字段。需要注意,空输入是否应该得到一个空字段属于业务语义。如果空输入应表示零个字段,就在 atEOF && len(data) == 0 时返回 (0, nil, bufio.ErrFinalToken);不要照搬示例而忽略自己的格式约定。
五、回归检查:连续分隔符和尾分隔符必须分开测
这类 bug 往往不会在普通输入上出现。我的回归表至少会覆盖普通字段、中间空字段、尾部空字段和空输入,并把“空输入算零个还是一个字段”写进断言。
func collectFields(input string) ([]string, error) {
scanner := bufio.NewScanner(strings.NewReader(input))
scanner.Split(splitCommaKeepEmpty) // 使用待验证的分割函数。
var fields []string
for scanner.Scan() {
fields = append(fields, scanner.Text()) // 保留空字符串,方便精确比较。
}
return fields, scanner.Err() // 正常 EOF 和 ErrFinalToken 都应得到 nil。
}
func TestSplitCommaKeepEmpty(t *testing.T) {
tests := []struct {
input string
want []string
}{
{input: "a,b", want: []string{"a", "b"}}, // 普通字段。
{input: "a,,b", want: []string{"a", "", "b"}}, // 中间空字段。
{input: "a,b,", want: []string{"a", "b", ""}}, // 最终空字段。
}
for _, tt := range tests {
got, err := collectFields(tt.input)
if err != nil {
t.Fatalf("input=%q: %v", tt.input, err) // 扫描错误立即暴露。
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("input=%q got=%q want=%q", tt.input, got, tt.want)
}
}
}
测试不需要依赖“恰好连续多少次后 panic”。真正应该固定的是返回语义:中间空字段伴随正向 advance,最终字段伴随 ErrFinalToken,数据不足返回 nil token。
六、迁移清单:把每个返回分支写成契约
- 搜索自定义 SplitFunc 中所有
return 0, []byte{}, nil或等价写法。 - 如果这是中间空字段,确保返回值同时消费分隔符,即
advance > 0。 - 如果这是最终空字段,返回非 nil 空 token 与
bufio.ErrFinalToken。 - 如果只是等待更多数据,返回
(0, nil, nil),不要伪造空 token。 - 如果应该立即停止且不产生字段,返回
(0, nil, bufio.ErrFinalToken)。 - 不要用
recover把 panic 吞掉;修复 SplitFunc 的进展与终止语义。 - 增加连续分隔符、尾分隔符、无分隔符、空输入和单字节分隔符测试。
相关问题
空行也会触发这个 panic 吗?
默认 ScanLines 的空行会消费换行符,因此有输入进展,不会因为 token 为空就触发该保护。
为什么 scanner.Err() 是 nil,程序却 panic?
“过多无进展空 token”是 SplitFunc 合约错误,Scan 直接 panic;它不是通过 Scanner 的普通错误字段返回。
ErrFinalToken 会出现在 scanner.Err() 里吗?
不会。它是专门用于正常结束扫描的哨兵值;可以携带最后一个非 nil token,也可以用 nil token 立即结束。
可以只把 advance 从 0 改成 1 吗?
只有当确实存在一个应该消费的字节时才可以。盲目返回 1 可能跳过有效数据;advance 必须与当前格式的分隔符长度一致。
GitHub App 无状态安装令牌完成上线意味着什么
- 上一篇
- GitHub App 无状态安装令牌完成上线意味着什么
- 下一篇
- 人类基准反应测试的数据安全吗?网页版入口与移动端声明说明
-
- Golang · Go问答 | 53分钟前 | go · bufio · Go bufio.Reader UnreadByte
- Go bufio.Reader.UnreadByte 为什么只能回退一次
- 341浏览 收藏
-
- Golang · Go问答 | 1小时前 | 标准库 · Go问答 · archive/tar Go tar.Header.Format FormatUnknown tar格式识别
- Go tar.Header.Format 为什么会变成未知格式
- 397浏览 收藏
-
- Golang · Go问答 | 3小时前 | 性能优化 · Go SIMD GOEXPERIMENT archsimd
- Go SIMD 实验 API 启用条件与架构回退策略
- 280浏览 收藏
-
- Golang · Go问答 | 3小时前 |
- Go 泛型方法在接口满足关系中的兼容边界
- 460浏览 收藏
-
- Golang · Go问答 | 4小时前 | 标准库 · JSON · go · 兼容性 · Go encoding/json jsontext encoding/json/v2 JSON兼容
- Go encoding/json/v2 试用时的行为差异梳理
- 433浏览 收藏
-
- Golang · Go问答 | 5小时前 |
- Go 1.27 泛型方法迁移旧接口的兼容清单
- 130浏览 收藏
-
- Golang · Go问答 | 5小时前 | go · utf-8 ·
- Go strings.ToValidUTF8 清洗日志内容的边界
- 501浏览 收藏
-
- Golang · Go问答 | 6小时前 | go · utf-8 ·
- Go unicode/utf8 无效字节的替换策略
- 199浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 321次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 377次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 373次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 337次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 163次使用
-
- Go map 并发写 panic 怎么办:从共享 map 到可控写入路径
- 2026-06-30 123浏览
-
- go语言中的defer关键字
- 2023-02-17 150浏览
-
- Golang中Interface接口的三个特性
- 2023-01-07 394浏览
-
- go语言中函数与方法介绍
- 2023-01-07 297浏览
-
- go语言数据类型之字符串string
- 2022-12-30 321浏览
