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

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 对它们的解释不同。

Go bufio SplitFunc 的 data atEOF 与 advance、nil token、空 token、ErrFinalToken 之间的静态契约关系图
图1:SplitFunc 返回值组合的静态契约说明图。
返回组合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 只交付一次。

连续分隔符、尾分隔符、advance 大于零、nil token、ErrFinalToken 与无进展 panic 之间的静态关系图
图2:空 token 安全返回方式与无进展风险的静态关系图。
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 必须与当前格式的分隔符长度一致。

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