返回

cat命令丢字节?Go Scan为何稳定?终端读取原理解析

Linux

揭秘:为何 cat 命令在读取终端时丢字节,而 Go Scan 却安然无恙?

碰到了个怪事! 在使用 Linux 终端(伪终端, pty)时,cat 命令和 Go 的扫描函数在读取行为上,表现得不太一样。 具体来说,用 cat 从另一个终端读数据,时不时会丢几个字节;但用 Go 的 fmt.Scanln,就稳稳当当,一个字节都不落。 这是咋回事?

问题重现

为了说明白,我做了个小实验:

  1. 打开俩终端(假设分别是 /dev/pts/0/dev/pts/1)。
  2. 在第二个终端(/dev/pts/1)里敲下 cat /dev/pts/0。 这样,第一个终端里写啥,第二个终端就显示啥。
  3. 开始在第一个终端(/dev/pts/0)里写东西 (比如用 TUI 程序)。

大部分情况下,挺好使。 可怪就怪在,时不时地,有几个字符在第二个终端里不见了! 更准确地说,没显示的那些字符,在第一个终端里有回显(echo);显示的那些字符,反而没有回显。 感觉就像 echo 和 cat 在抢字符似的。

打个比方,如果我在第一个终端输入 "Hello world, what are you doing",在源终端窗口 (/dev/pts/0)可能显示 "el od eyn"。而在目标终端窗口 (/dev/pts/1)可能显示:"Hlowrl,what ar ou doig"。

但当我用 Go 和 fmt 包里的扫描函数时,情况就不同了。 无论我在终端(也就是 /dev/pts/0)里写什么,扫描函数总能把所有内容读进来(不丢字节),而且输入的内容还会显示在 TUI 里(就是说有“回显”)。

Go 代码大概是这样:

package main

import "fmt"

func main() {
    var input string

    fmt.Println("Enter text:")

    for {
        fmt.Scanln(&input)
        fmt.Println("You wrote:", input)
    }
}

在这种情况下,"回显" 和 Go 程序好像相安无事,没有“争抢”发生...

我原本以为,不管 cat 还是 Go,都是从 /dev/pts/0 读数据,没啥区别,结果应该一样才对。 为什么会有这样的差异呢?

原因分析

问题的根源在于终端的行规程(line discipline)和 cat、Go 读取数据方式的不同。

1. 终端行规程 (Line Discipline)

Linux 终端(更准确地说是伪终端, pty)有个东西叫“行规程”。 行规程是终端驱动的一部分,它负责处理输入、输出,以及像回显(echo)、行编辑这样的功能。

行规程有两种主要模式:

  • 规范模式 (Canonical Mode) :这是默认模式。 在规范模式下,终端会缓存输入,直到遇到换行符(\n)。 这期间,你可以用退格键之类的编辑输入。 只有当你按下回车键,整行输入才会被发送给程序。

  • 原始模式 (Raw Mode) :在原始模式下,终端不会做任何处理。 每个字符都会立即发送给程序,不管是不是换行符。 回显、行编辑这些功能,都得程序自己处理。

2. cat 和 Go 的读取方式

  • catcat 命令通常以一种简单粗暴的方式读取文件(包括 /dev/pts/0 这样的设备文件)。 它直接从文件符读取字节流,没有特别考虑终端的行规程。
    cat 会尽可能快地读取, 没有特别的输入处理和缓冲

  • Go fmt.Scanln :Go 的 fmt.Scanln 函数,以及其他相关的输入函数,对终端的处理更“智能”。 它知道自己在跟终端打交道,并且会跟终端的行规程协作。 它会按照规范模式去读取,会等待输入完整的一行再进行操作。
    Go 会自动管理规范输入并提供默认的输出流同步.

3. 竞争和字节丢失

当你在第一个终端里输入时,终端的行规程会负责回显字符(如果你开启了回显)。 与此同时,cat 在第二个终端里也在拼命读取。 如果 cat 读得太快,赶在了行规程处理回显之前,就可能把字符“抢”走,导致回显没显示,而 cat 得到了这些字符。 因为 cat 和 行规程式是并行工作的。

对于golang, 因为fmt的实现和其交互的方式, go程序使用了缓冲区处理,减少了IO请求和上下文的切换,读取过程是被行规程所感知的, 和 cat 的"原始" 读取操作有显著区别, 从而使得golang代码更"稳定".

解决方案与建议

既然知道了问题出在哪,解决起来就有了方向。 下面列出几种可能的解决方案,各有优缺点。

1. 使用 stty 调整终端设置

stty 命令可以用来查看和修改终端的设置,包括行规程的模式。

  • 原理: 可以尝试关闭回显 (echo),或者把终端设置成原始模式,看看能不能解决问题。

  • 操作步骤:

    • 关闭回显: 在第一个终端里执行 stty -echo。 这会关闭回显。然后 cat,理论上, 字节就不会被echo提前读了。
    • 开启回显: stty echo
    • 设置原始模式: stty raw
    • 恢复规范模式: stty cooked
  • 代码示例 (Bash):

    # 关闭回显
    stty -echo
    
    # 在第二个终端里运行 cat
    cat /dev/pts/0
    
    # 恢复回显(在第一个终端里)
    stty echo
    
  • 安全建议: 修改终端设置可能会影响其他程序的行为,小心操作。 用完记得恢复设置。

  • 进阶:
    stty -a 查看所有当前终端设定. 可以精细地调整很多参数,比如控制字符(intrquiterase 等)、输入处理(icrnlixon 等)、输出处理(opost 等)。

2. 使用管道和缓冲

可以尝试用管道和一些工具(比如 teepv)来缓冲输入,看看有没有帮助。

  • 原理: 通过引入缓冲区,可能可以缓解 cat 和行规程之间的竞争。

  • 操作步骤:

    • 使用 tee
    • pv(Pipe Viewer)命令
  • 代码示例 (Bash):

    # 使用 tee
    # 在第一个终端:
      echo "Hello, World!" | tee /dev/pts/0
      #需要自己程序将标准输入重定向到 /dev/pts/0。
    
    # 使用 pv
      #在第一个终端:
      echo "Hello, World!" | pv > /dev/pts/0
    
    

如果直接使用管道, 有可能出现输出不显示的问题。
这种方案使用时也较为复杂.

3. 在 Go 程序里模拟 cat

既然 Go 的读取行为更靠谱,那干脆在 Go 程序里实现 cat 的功能,不就得了?

  • 原理: 充分利用 Go 的 I/O 和终端处理能力,避免 cat 的问题。

  • 操作步骤:

    1. 获取要读取的终端设备文件名(比如 /dev/pts/0)。
    2. 打开这个文件。
    3. 循环读取文件内容,并输出到标准输出。
  • 代码示例 (Go):

    package main
    
    import (
    	"fmt"
    	"io"
    	"os"
    )
    
    func main() {
    	if len(os.Args) < 2 {
    		fmt.Println("Usage: go-cat <pty-device>")
    		return
    	}
    
    	ptyDevice := os.Args[1]
    
    	file, err := os.Open(ptyDevice)
    	if err != nil {
    		fmt.Println("Error opening file:", err)
    		return
    	}
    	defer file.Close()
    
    	_, err = io.Copy(os.Stdout, file)
    	if err != nil {
    		fmt.Println("Error copying data:", err)
    	}
    }
    
    
  • 安全建议: 确保你的 Go 程序有足够的权限访问终端设备文件。
    谨慎处理用户输入, 避免代码注入.

4. 使用更专业的终端工具

有些专门用于处理终端操作的工具,比如 screentmux,它们对终端的控制更精细,可能可以避免这种问题。

  • 原理 : 专业终端工具自身实现I/O处理.

5. 使用 Expect (或类似的工具)

对于控制交互式程序的自动化脚本, Expect 是一个比较合适的选择.

  • 原理 : Expect 允许根据预期输出来进行匹配操作.

  • 代码示例 (Expect 脚本, 作为示意):

#!/usr/bin/expect

set pts [lindex $argv 0]

spawn cat $pts

expect {
    "*" {
       #  匹配所有内容
        send_user "$expect_out(buffer)"
    }
}
interact

将以上脚本命名, 假设 get_pty.exp, 使用:

./get_pty.exp /dev/pts/0

这些工具在终端处理方面提供了更多的控制和灵活性, 能减少问题的出现概率.

总结

cat 和 Go 程序读取终端时的差异,主要原因在于它们与终端行规程的交互方式不同。cat 比较“粗放”,直接读取字节流;Go 更“精细”,会跟行规程协作。 要想解决 cat 丢字节的问题,可以从调整终端设置、使用管道缓冲、用 Go 模拟 cat、或考虑其他终端工具入手。选择哪种方案,取决于你的具体需求和环境。 针对具体的程序,也可以在了解原理的基础上自行修改输入处理函数,减少甚至消除不同终端行为的差异。