3

Hello World 也会有 bug?

 1 year ago
source link: https://gobomb.github.io/post/hello-world-debug/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Hello World 也会有 bug?

29 May 2023

前阵子一个朋友发我一篇文章,大意是说很多语言的经典程序——打印“hello world”——也是会出bug的。

文中举了一个C的例子:

/* Hello World in C, Ansi-style */

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
  puts("Hello World!");
  return EXIT_SUCCESS;
}

编译运行后,却不会报错:

$ gcc hello.c -o hello
$ ./hello > /dev/full
$ echo $?
0

通过 strace 追踪系统调用,是能看到 write 返回错误的:

$ strace -etrace=write ./hello > /dev/full
write(1, "Hello World!\n", 13)          = -1 ENOSPC (No space left on device)
+++ exited with 0 +++

但是上面的 C 程序并没有把错误返回出来。

作者罗列了几个主流的语言,打印函数没有报错的:C、C++、Python 2、Java 等,有报错的 Rust、 Python 3、Bash、C# 等。

没有列出 Go。于是我有点好奇,随手写了一个 Hello World (go1.17.2 linux/amd64):

package main

import "fmt"

func main() {
	fmt.Println("Hello world!")
}

试了下,发现也没有报错:

$ go build -o hello main.go
$ ./hello > /dev/full
$ echo $?
0

rust 的表现

我又用 rust 试了下:

fn main(){
    println!("Hello World!");
}

确实如作者所言,会把错误抛出来,而且错误还很详细:

$ rustc -o hello main.rs
$ ./hello  >/dev/full
thread 'main' panicked at 'failed printing to stdout: No space left on device (os error 28)', library/std/src/io/stdio.rs:1008:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ echo $?
101

添加环境变量还可看完整的 backtrace:

$ RUST_BACKTRACE=1 ./hello  >/dev/full
thread 'main' panicked at 'failed printing to stdout: No space left on device (os error 28)', library/std/src/io/stdio.rs:1008:9
stack backtrace:
   0: rust_begin_unwind
             at ./rustc/84c898d65adf2f39a5a98507f1fe0ce10a2b8dbc/library/std/src/panicking.rs:579:5
   1: core::panicking::panic_fmt
             at ./rustc/84c898d65adf2f39a5a98507f1fe0ce10a2b8dbc/library/core/src/panicking.rs:64:14
   2: std::io::stdio::print_to
             at ./rustc/84c898d65adf2f39a5a98507f1fe0ce10a2b8dbc/library/std/src/io/stdio.rs:1008:9
   3: std::io::stdio::_print
             at ./rustc/84c898d65adf2f39a5a98507f1fe0ce10a2b8dbc/library/std/src/io/stdio.rs:1074:5
   4: main::main
   5: core::ops::function::FnOnce::call_once
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

还能进一步看详细的 backtrace。突然觉得好贴心。

Go 的 bug

到这里,我就有点兴奋,该不会Go的实现真的有bug吧,赶紧看看源码。才点开 fmt.Println 的函数签名,我就发现了“问题”:

// Println formats using the default formats for its operands and writes to standard output.
// Spaces are always added between operands and a newline is appended.
// It returns the number of bytes written and any write error encountered.
func Println(a ...interface{}) (n int, err error) {
	return Fprintln(os.Stdout, a...)
}

Println 是有返回值的,返回写入的字节数和可能的写错误。

所以不是程序有bug,而是使用方式不对。

重新实现如下:

package main

import "fmt"

func main() {
	n, err := fmt.Println("Hello world!")
	if err != nil {
		println("got err: ", err.Error())
	}

	println("written bytes: ", n)

}

编译运行:

$ go build -o hello2 main2.go
$ ./hello2 > /dev/full
got err:  write /dev/stdout: no space left on device
written bytes:  0

符合预期,错误是被处理的。

所以,并不是说语言本身实现有bug,而是使用语言写代码的时候,考虑得不周全。

C 的 bug

那,回到最开头的C的例子,是不是也是使用者的问题呢?

puts 是不是也有返回值呢?

int puts(const char *str)

跟 go 有点像,返回了一个int类型,指示写了多少字节,那错误在哪里呢?

错误可以通过#include <errno.h>,获取到全局变量errno

于是程序可以改写成:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

int main(void)
{
  int n;
  n = puts("Hello World!");
  if(errno!=0){
    // 为了不干扰标准输出,把错误信息打印到标准错误了
    fprintf(stderr,"puts got err %d\n",n,errno);
    return errno;
  }

  fprintf(stderr,"puts %d bytes\n",n );
}

编译运行:

$ gcc hello2.c -o hello2
$ ./hello2 >>/dev/full
puts 13 bytes
$ echo $?
0

已经尝试获取了错误,却仍然没有得到错误?问题在哪里呢?

这里得考虑实现了:puts这个函数是带缓冲的。

翻阅《UNIX 环境高级编程》5.4 章:

标准I/O库提供缓冲的目的是尽可能减少使用read和write调用的次数。它也对每个I/O流自动地进行缓冲管理,从而避免了应用程序需要考虑这一点所带来的麻烦。

(1)全缓冲。这种情况下,在填满标准I/O缓冲区后才进行实际的I/O操作。对于驻留在磁盘上的文件通常是由标准I/O库实施全缓冲的。在一个流上执行第一次I/O操作时,相关标准I/O函数通常调用malloc获得需使用的缓冲区。

术语冲洗(flush)说明标准I/O缓冲区的写操作。缓冲区可由标准I/O例程自动冲洗(例如当填满一个缓冲区时),或者可以调用函数fflush冲洗一个流。值得引起注意的是在UNIX环境中,flush有两种意思:在标准I/O库方面,flush(冲洗)意味着将缓冲区中的内容写到磁盘上(该缓冲区可能只是局部填写的)。在终端驱动程序方面,flush(刷清)表示丢弃已存储在缓冲区中的数据。

所以puts写到缓冲里,是成功的,所以没有报错。那我们调用 fflush 进行写盘呢?

第三版如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

int main(void)
{
  int n;
  n = puts("Hello World!");
  if(errno!=0){
    fprintf(stderr,"puts got err %d\n",n,errno);
    return errno;
  }

  fprintf(stderr,"puts %d bytes\n",n );

  n = fflush(stdout);
  if(errno!=0){
    fprintf(stderr,"fflush got err %d\n",errno);
    return errno;
  }

  fprintf(stderr,"flushed %d bytes\n",n );

  return EXIT_SUCCESS;
}

编译运行:

$ gcc hello3.c -o hello3
$ ./hello3 >>/dev/full
puts 13 bytes
fflush got err 28
$ echo $?
28

啊哈!这个错误终于被捕获了,errno.h里显示错误定义如下:

#define ENOSPC 28 /* No space left on device */

所以,即使是打印一个 hello world,这个每个新语言的经典程序,也有可能出 bug。但准确的说不是语言本身的bug,而是语言的假设结合程序员的使用造成的。

在 C 的情况下,如果不是编程老手,熟悉库函数和各种约定(新手一上来哪知道errno是藏在全局变量里的),底层操作系统知识烂熟于心(标准IO、缓冲),都没有意识到错误的发生。即便如此,深入排查和验证问题,还要3个来回。

Go 同理,默认情况下,错误极易被忽略了。我想很多人都不见得会去处理 fmt.Println 返回的错误。但从函数的封装上,是屏蔽了部分底层细节了,多返回值提高了易用性,还保留了C风味。但不去处理错误,也还是调用方的责任。

而 Rust 则提供了更佳严格的错误返回,把问题显示抛了出来,还提供了分级别的调用栈打印,是我意料之外的。这种丰富的错误打印,是能节省使用者不少时间的。

C假设你是老手,Go也假设你是严谨的,Rust则提供了所有的细节。

写出严谨的程序没有那么容易,即使是最简单的打印 hello world。尽量还是选择趁手先进的工具,以及好的排错工具。我们很难面面俱到地考虑到所有异常情况,所以良好的测试,以及完善的报错信息是很有必要的。

我搜了下这篇文章,发现了一些有意思的讨论:

reddit 上:

if you don’t realize that the buffering is the problem, you may wonder for a while why printf() reports success. Error handling is hard.

还有ycombinator 也有一长串讨论。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK