Go bufio.Scanner 连续空 token 为什么会停止
来源:17golang原创
时间:2026-10-04 03:23:10 296浏览 收藏
我第一次写自定义 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 必须与当前格式的分隔符长度一致。
-
397 收藏
-
123 收藏
-
280 收藏
-
179 收藏
-
460 收藏
-
349 收藏
-
433 收藏
-
130 收藏
-
435 收藏
-
501 收藏
-
199 收藏
-
375 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习