Have you ever thought ?

前段时间,我在编写一个 Go 程序,这个程序要做的一件事是在操作系统上执行一个命令(可执行文件或者可执行脚本),程序大概像下面这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmdSlice := strings.Fields(strings.TrimSpace(cmdString))
if len(cmdSlice) == 0 {
return errors.New("index out of range [0] with length 0")
}
// search for an executable named file in the
// directories named by the PATH environment variable.
// If file contains a slash, it is tried directly and the PATH is not consulted.
// The result may be an absolute path or a path relative to the current directory.
executableFile, err := exec.LookPath(cmdSlice[0])
if err != nil {
return errors.WithStack(NewPathError(cmdSlice[0], err.Error()))
}

cmd := exec.Command(executableFile, cmdSlice[1:]...)

当我让程序去执行一个 shell 脚本的时候,收到了 fork/exec: exec format error 的错误,然而我在 shell 下执行这个脚本却是正常的,这让我很迷惑。

当我弄清楚原因是我没有在脚本里加 Shebang#!) 的时候,我更加疑惑了:为什么操作系统会容忍我的过错呢?对此,我会在稍后的章节中进行解释。当我搞清楚问题的始末的之后,我突然对操作系统执行程序的方式产生了极大的兴趣,并试图去搞清楚它。这就是我写这篇文章的初衷。

你是否想过,除了在 shell 下启动一个程序,是否还有其它的方式? 我们是不是永远无法摆脱 shell? 你是否曾经对 shell 下各种的执行方式感到困惑?比如,“source xxx.sh”“. xxx.sh”“./xxx.sh”“. ./xxx.sh”“sh ./xxx.sh”。 没关系,这篇文章会带你走出迷雾。

本文会涉及到一点 SysvinitSystemd 的内容, 但我不会过多的去介绍他们,只是简单说明,这是一种能让你的程序运行起来的方式,我最后的重点会放在 shell 上面。

让程序跑起来有多少种可能的方法?

当我产生这个疑问之后,我努力的思考,并去寻找答案,最后总结了如下几种:

  1. 传统的 Sysvinit 方式
  2. Systemd
  3. crontab 或者 Systemd Timer
  4. shell(无论是终端还是 sshd )
  5. GUI

其中 1, 2 两种方式可以算作同一类,虽然他们的工作方式有所不同,但都属于系统管理层面。如果你的程序是一个随系统启动,并托付给系统管理的 Daemon ,那么最好的方式就是通过 Sysvinit 或者 Systemd 来管理,他们都是 Linux 的 init 系统。相似的 init 系统还有 Upstart ,但我并不熟悉它,所以不准备做介绍,当然这并不影响,因为他们属于一类系统。

定时任务是另一个可能会拉起一个程序的方式,相信很多朋友都有在 Linux 上使用 crontab 的经历,而它的替代品 Systemd Timer 可能就没那么多人熟悉了。

shell 是最常见的启动程序的方式。事实上 shell 的主要作用就是去运行其它的程序,即便是前面 3 种方式,很多时候也是使用 shell 来启动需要运行的程序的,只不过不是我们手动在 shell 里执行而已。

还有一种方式就是在桌面环境下,使用 GUI 来启动一个前台程序,你可能通过点击一个 .desktop 的快捷方式来启动一个桌面应用,在我的 Manjaro 下桌面应用全部是由 plasmashell 这个进程 fork 出来的子进程。

承前启后的 SysV init

Linux 操作系统的启动首先从 BIOS 开始,然后由 Boot Loader 载入内核,并初始化内核。内核初始化的最后一步就是启动 init 进程。这个进程是系统的第一个进程,PID 为 1,又叫超级进程,也叫根进程。它负责产生其他所有用户进程。所有的进程都会被挂在这个进程下,如果这个进程退出了,那么所有的进程都被 kill 。如果一个子进程的父进程退了,那么这个子进程会被挂到 PID 1 下面。

因为大多数 Linux 发行版的 init 系统是和 Unix System V 是相互兼容的,因此 Linux 上的 init 系统也被成为 Sysvinit

在 sysvinit 下有几个 runlevel ,并且有 0~6 七个运行级别,比如:常见的 3 级别指定启动到多用户的字符命令行界面,5 级别指定启动到图形界面,0 表示关机,6 表示重启。其配置在 /etc/inittab 文件中。

与此配套的还有 /etc/init.d/ 和 /etc/rc[X].d,前者存放各种进程的启停脚本(需要按照规范支持 start,stop子命令),后者的 X 表示不同的 runlevel 下相应的后台进程服务,如:/etc/rc3.d 是 runlevel=3 的。 里面的文件主要是 link 到 /etc/init.d/ 里的启停脚本。其中也有一定的命名规范:S 或 K 打头的,后面跟一个数字,然后再跟一个自定义的名字,如:S01rsyslog,S02ssh。S 表示启动,K表示停止,数字表示执行的顺序。

为了将操作系统带入可操作状态,init 系统通过读取 /etc/inittab 获得 runlevel,然后依次顺序执行对应 level 下的脚本。rc[X].d 下都是些 link, 链接到 rc.d 中的 shell 脚本, 可见系统初始化过程中依然是使用的 shell 来启动相应程序的。

然而这些脚本中需要使用 awk, sed, grep, find, xargs 等等这些操作系统的命令,这些命令需要生成进程(这涉及到 shell 的工作方式,我稍后在 shell 小节详细介绍),生成进程的开销很大,关键是生成完这些进程后,这个进程就干了点屁大的事就退了。这完全是杀鸡用牛刀啊,操作系统废了九牛二虎之力拉起来一个进程,结果这个进程就干了个把字符串转为小写的活,然后丢下一脸懵逼的操作系统就潇洒的退出了。

可以想见,当rc.d中有大量的脚本,且脚本中又有成百上千个类似于 awk、sed、grep 这样的命令,系统的启动过程就会慢的要死。当然对于启停不那么频繁的服务器来说,这依然可以接受,而且这样的系统设计也很符合 Unix 设计哲学:Do one thing and Do it well,所以 sysvinit 可以一统江湖几十年。直到 2006年 Linux 内核进入 2.6 时代,Linux 开始进入桌面系统,而桌面系统和服务器系统不一样的是,桌面系统面临频繁重启,而且,用户会非常频繁的使用硬件的热插拔技术。于是,这些新的场景,让 sysvint 受到了很多挑战。

更详细的 sysvint 介绍可以参考 浅析 Linux 初始化 init 系统-sysvinit

步行夺猛马的 Systemd

历史上总是会有人站出来对现状说不,2010 年 Lennart Poettering和他的小伙伴们开源并发布了一个新的 init 系统——systemd

Systemd 是 Linux 系统中最新的初始化系统(init),它主要的设计目标是克服 sysvinit 固有的缺点,提高系统的启动速度。systemd 和 ubuntu 的 upstart 是竞争对手,而 ubuntu 在15.04及后续版本中已将 systemd 设置为默认 init 程序,redhat 和 centos 也从 7.0 之后开始使用 systemd,截止目前 systemd 已经运行在大部分的 Linux发行版中。

在系统启动上 systemd 拥有绝对的优势,有张三方对比图可见分晓:

如今 systemd 成为 1 号进程,后续所有的进程都是由它 fork 出来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
systemd(1)─┬─ModemManager(494)─┬─{ModemManager}(495)
│ └─{ModemManager}(498)
├─NetworkManager(463)─┬─{NetworkManager}(467)
│ └─{NetworkManager}(470)
├─agent(1229)─┬─{agent}(1232)
│ └─{agent}(1236)
├─avahi-daemon(465)───avahi-daemon(468)
├─baloo_file(655)───{baloo_file}(671)
├─bluetoothd(464)
├─crond(460)
├─cupsd(476)
├─dbus-daemon(462)
├─dbus-daemon(1458)
├─dockerd(557)─┬─containerd(583)─┬─{containerd}(589)
│ │ ├─{containerd}(590)
│ │ └─{containerd}(13694)
│ ├─{dockerd}(561)
│ └─{dockerd}(861)
└──fcitx(1431)─┬─sh(1576)
└─{fcitx}(1478)

深入了解请参考:LINUX PID 1 和 SYSTEMD

定时任务

简单说一下定时任务,当我们使用 crontab 配置了一个定时执行任务之后,Cron每分钟做一次检查,看看哪个命令可执行,当 Cron 检查到有命令需要执行时则 fork 子进程,再由此子进程 fork-execve 执行真正的命令:

1
2
3
4
5
init(1)-+-NetworkManager(1747)
└-crond(2107)-+-crond(355)---sh(356)---sleep(14329)
|-crond(10967)---sh(10968)---sleep(14326)
|-crond(12098)---sh(12099)---sleep(14327)
`-crond(15114)---sh(15115)---sleep(14328)

这篇 Linux cron运行原理 有更详细的介绍。

对于 systemd 我测试一个 timer,其进程是挂在 systemd 进程之下的,我猜测也是 systemd 进程去 fork 执行 timer 中的任务。

1
2
{17:22}~/PycharmProjects ➭ ps -ef|grep udp
root 17002 1 0 17:19 ? 00:00:00 /usr/bin/python3 /home/liupeng/udpClient.py

伟大的造物主——shell

通过前面的分析,会发现 Linux 上的程序绝大多数情况下是通过 shell 来执行的,所以我们接下来将重点放在 shell 上。

你可能会问: shell 有什么好讲的,它不就是个与内核交互的外壳程序么?

没错,它的功能就是如此纯粹——a shell is a user interface for access to an operating system's services,但它却无处不在。

传统的 Sysvinit 系统下绝大部分的系统服务都是通过 shell 拉起来的,虽然到了 Systemd 时代,很多工作由 C 语言重新实现了(具体见LINUX PID 1 和 SYSTEMD),但是你依然可以使用 systemd 来管理你的启停脚本,这些脚本用来启停你的程序。而对于非 Daemon 方式的程序。你仍然需要用 shell 来启动它们到前台,或者使用 nohup、setsid等方式启动到后台。

可见,我们无法逃离 shell,它就像是一个造物主,系统中几乎所有的进程都是或曾是它的子民。

要讲清楚shell是一个十分艰巨的任务,对于只查过几天资料的我来说自然无法胜任,但是择其一两点来讲,以多少理清一些 Linux 下程序启动与运行的原理为目的,或可一试。

文中涉及到关于 shell 的实验或者结论皆以 Bash 作为参考依据。

What is a shell?

Bash 主页上有关于 shell 的定义:

At its base, a shell is simply a macro processor that executes commands. The term macro processor means functionality where text and symbols are expanded to create larger expressions.

这段话真不太好翻译,勉强翻译一下为:从根本上说,shell 只是执行命令的宏处理器。术语宏处理器是指将文本和符号扩展以创建更大的表达式的功能。

对于 Unix shell 来说,它既是一个命令行解释器也是一个编程语言。shell 作为命令行解释器为丰富的 GNU 工具集提供了用户接口,而作为编程语言它成功的将这些工具集结合在一起,之后就可以将命令编写进文件,去完成各种各样的任务。

很多人可能傻傻分不清 terminalttyconsoleshell,这里第一个高票回答对这些概念做了详细的解释:What is the exact difference between a 'terminal', a 'shell', a 'tty' and a 'console'?。如果英文阅读不畅,知乎上有人将其翻译了一下:终端、Shell、tty 和控制台(console)有什么区别?,我不再做额外的阐述了,接下来只需要记住 shell 是一个命令行解释器就好,它可以运行在交互模式和非交互模式。

shell 是如何查找命令的

当我们在交互式 shell 下敲下一个命令时,shell 查找命令文件的规则大概如下:

  1. 执行命令前 shell 会先检查是否有 alias,如果有就会使用 alias 中的内容。

  2. 如果 command 名字不包含 "/" ,shell 将尝试寻找它。如果存在同名的函数,则会调用函数。

  3. 如果没有匹配到函数,则从 shell 内置命令(builtins)中寻找,如果找到则调用该命令。

  4. 如果都没有找到则从 $PATH 中寻找,为了避免每次遍历 $PATH ,shell 维护了一张 HASH 表,记录了每个命令对应的绝对路径,如果 HASH 表中没有再去 $PATH 中的目录遍历,如果 PATH 中未找到就执行一个预定义的函数 command_not_found_handle 。如果函数存在,则在子 shell 中调用,如果不存在则打印错误信息并返回 127 状态码。

  5. 如果寻找成功或者 command 中含有 “/”, shell 将在新环境中执行它( fork 一个新进程 )。

  6. 如果 command 不是异步启动的,shell 将等待其完成并收集退出状态码。

如上所述就是 shell 在执行命令式的查找规则。也是时候破解一下我们在文章开头留下的谜题了,先从 ./ 开始吧。

./ 在类 Unix 系统中表示相对路径指向某个文件或者目录,因为在 Unix 系统中 PATH 不包含当前路径,也无法包含当前路径。如 ./testtouch ./a

. 是 BASH 的一个内建命令,它继承自 Bourne shell (sh),并且是 source 同义词,跟 source 功能相同。

. filename [arguments] 的功能是在当前 shell 上下文中( 不会 fork )读取并执行 filename 中的命令,如果 filename 不包含 “/” , shell 将从 $PATH 中寻找该文件,如果当前 shell 不是 POSIX 模式,则在 PATH 中寻找失败后,继续从当前目录中寻找。

对于 sh ./test.sh 这种模式,在 bash 的文档中可以找到对应的描述 Invoked with name sh(大多数 Linux 发行版会把 sh 设置成 bash 的软连接,所以这里只针对此种情况):

When invoked as sh, Bash enters POSIX mode after the startup files are read.

POSIX mode 我会在后面展开介绍,这里暂且略去,开始进入 shell 如何执行一个 command 吧。

shell 是如何执行命令的

我在介绍 Systemd 和 cron 的时候用了 fork 这个词,而在描述 shell 的时候仅仅说“shell 启动相应程序”。其实,shell 执行一个程序的方式也一样使用了 fork,我只是为了能在本章节重点作介绍才故意没有使用 fork 这个词。

我们知道, Linux 下的可执行文件可以分为 ELF 文件脚本文件,当我们在 bash 下输入一个命令执行某个 ELF 程序时,Linux 系统是如何装载并执行它的呢?

首先,在用户层面,bash 进程会调用 fork() 系统调用创建一个新的进程。其次,新的进程通过调用 execve() 系统调用来执行指定的 ELF 文件。原先的 bash 进程继续返回并等待刚才启动的新进程结束,之后继续等待用户输入命令。

当进入 execve() 系统调用之后,Linux 内核就开始进行真正的装载工作。在内核中,execve() 系统调用相应的入口是 sys_execve()sys_execve() 进行一些参数的检查复制之后,调用 do_execve()do_execve() 会首先查找被执行的文件,如果找到文件,则读取文件的前 128 个字节。

为什么要先读取文件的前 128 个字节?这是因为 Linux 支持的可执行文件不止 ELF 一种,还包括 a.outJava 程序、以 #! 开头的脚本程序。do_execve() 通过读取前 128 个字节来判断文件的格式。每种可执行文件格式的开头几个字节都是很特殊的,尤其是前4个字节,被称为 魔数(Magic Number)。

我们用一段 C 程序来读取一些各种文件的前 4 个字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
int main (int argc, const char * argv[]) {

FILE *fp;
int r;
int i;
fp = fopen(argv[1], "rb");
fread(&r, 4, 1, fp);

printf("%X \n", r);

fclose(fp);
return 0;
}
  1. ELF

    我们编译这段程序,并读取程序自身

    1
    2
    3
    {15:32}~/Learing/c/src:master ✗ ➭ gcc -o read4bytes read4bytes.c 
    {15:33}~/Learing/c/src:master ✗ ➭ ./read4bytes read4bytes
    464C457F

    可以看到输出为 464C457F,我们查看ASCII 表,得出如下的对应关系:
    ELF Header

    我的操作系统字节序是小端法排序,因此,ELF的可执行文件格式的头 4 个字节为 0x7F、E、L、F

  2. shell 脚本

    1
    2
    {15:33}~/Learing/c/src:master ✗ ➭ ./read4bytes ~/meta    
    622F2123

    前 4 个字节为 622F2123,我们再查一下 ASCII 表的对应关系:
    shell script header

    翻转一下就是 #!/b,可以猜测如果我们多读 7 个字节,结果肯定是#!/bin/bash.

    对于 pythonperlphp脚本处理方式相同。

  3. java class

    1
    2
    {15:51}~/Learing/c/src:master ✗ ➭ ./read4bytes ~/java/HelloWorld.class 
    BEBAFECA

    《程序员的自我修养》一书 6.5 章节介绍 Linux 装载可执行文件时,依次介绍了 ELFjava 可执行文件#!三种情况。ELF 的前4个字节将 16 进制转换为 ASCII 字符是 ELF;但是 java 的 class 文件则不同,由上可知读出的前4个字节的 16 进制表示为 BEBAFECA。因为是小端,所以 16 进制的表示法刚好是 CAFEBABE,并不需要转化成具体的字符,而书中介绍说 “Java的可执行文件格式的头 4 个字节为 c、a、f、e”,我猜可能书中存在前后逻辑不一致的问题,除非真的存在所谓的 java 可执行文件,如果有朋友了解,欢迎联系我,给我批评指正。

    关于 CAFEBABE的来源可以参见 wiki 上 James Gosling 的自白。

do_execve() 读取了 128 个字节的文件头部之后,调用 search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程。Linux 中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handler() 会通过判断头部的魔数确定文件的格式,并且调用相应的装载处理过程。常见的可执行程序及其装载处理过程的对应关系如下所示.

  • ELF 可执行文件:load_elf_binary()
  • a.out 可执行文件:load_aout_binary()
  • 可执行脚本程序:load_script()

有必要提一下 a.out, a.out 本身要追溯到更早的 Unix 时代,并且伴随 Linux 的诞生至今在 Linux 中有将近 ~28 年的历史。从 内核 5.1 开始, Linux 移除 a.out 格式的消息,因为ELF 自 1994 年进入 Linux 1.0 以来,已经 ~25 年了,a.out 早已年久失修,而且现在基本上找不到能产生 a.out 格式的编译器了。

大家可能会说,gcc 默认编译生成的不就是 a.out 么?非也,此 a.out 非彼 a.out。gcc 默认生成的 a.out 的实际格式也是 ELF,如果你按照刚才的方式读取 a.out 的前四个字节,你会发现同样是 464C457F,a.out 这个名字很大意义上属于计算机历史文化的沿袭,想了解更多可以参考为 a.out 举行一个特殊的告别仪式

我在这里省去 load_elf_binary() 的过程,只需提一下其中一步会修改系统调用的返回地址为 ELF 文件的入口地址,细节可以去参考《程序员的自我修养》 6.5 节。我们说当 load_elf_binary() 执行完毕之后,返回至 do_execve() 再返回至 sys_execve() 时,因为 load_elf_binary() 已经修改了返回地址,所以当 sys_execve() 系统调用从内核态返回到用户态时,EIP 寄存器直接跳转到了 ELF 程序的入口地址,于是开始执行新程序的代码指令, ELF 可执行文件装载完成。

是时候去破解我在文章开头留下的问题了,我用 Go 程序通过 forkexec 去执行脚本的时候收获到 fork/exec: exec format error 的错误。现在来看是 search_binary_handle() 的过程出了问题,内核并没有识别到脚本文件格式,经查确认是我脚本中没有加入 Shebang,当我在首行增加了 #!/bin/bash 之后,程序便可以正确运行了。

我还没有解释为什么在交互式的 shell 下执行不带 Shebang 的脚本不会触发错误,因为说起我寻找答案的过程总让我喟叹不已,我是从一篇几近 30 年前的文章中找到答案的。这让我想到了 5 年前我学习 Oracle 调优时从一本 10 年前出版的书中获益的经历。我难以想象,我今天写就的一篇博文,有可能会在 30 年后帮助到另一个人,这会让我永葆写作的热情......

就是这篇 (Why do some scripts start with #! ... ?)写于 1992 年的文章帮助我找到事实的真相。

简单概括一下就是早在 Unix 时代,为了不让内核什么东西都拿来执行,程序员们发明了 “magic number”,通过 magic number 内核可以辨别出哪些是可执行程序,在文件不可执行时抛出 ENOEXEC 错误,但是 shell 代码扩充了这项功能,在收到 ENOEXEC 失败后会去使用 “/bin/sh” 尝试将其作为 shell 脚本去执行,所以脚本执行是由 shell 来完成的,而不是内核,代码逻辑大概像这样:

1
2
3
4
5
6
7
8
9
10
/* try to run the program */
execl(program, basename(program), (char *)0);

/* the exec failed -- maybe it is a shell script? */
if (errno == ENOEXEC)
execl ("/bin/sh", "sh", "-c", program, (char *)0);

/* oh no mr bill!! */
perror(program);
return -1;

后来,伯克利的一些 guys 扩充了内核的功能,使其可以识别魔数 “#!”,如果内核读到 #! 则将继续读取该行的剩余部分,并将其作为命令去运行文件中的内容。

试想,当你执行一个没有正确填写 Shebang 的脚本文件的时候,shell 很可能会给你报一个没有执行权限的错误,当你依照错误提示给予 +x 权限的时候,你很可能收到更多的错误,原因很可能是你正在编写一个 python 脚本。

乖乖的写 Shebang 吧 !

事实上,后来我在 Bash 的文档中也找到了相关描述:

this execution fails because the file is not in executable format, and the file is not a directory, it is assumed to be a shell script and the shell executes it as described in Shell Scripts.

也许这一节描述没有燃起你的兴奋点,因为我假设你对 forkexec 函数族以及虚拟内存有所了解,如果你不了解的话可以参考下面我给出的链接:

  • Unix/Linux fork前传
  • Linux fork那些隐藏的开销
  • Fork三部曲之clone的诞生
  • 深入理解计算机系统 第9章 Virtual Memory

login、non-login、interactive 、non-interactive 与 Startup Files

因为 shell 可以运行在交互模式和非交互模式下,并且有 login 和 non-login 的情况,所以每一种组合他们读取并执行的 Startup Files 都有所不同,下面我给出一幅图来展示各种不同的情况:

bash and startup files

所谓的 login & interactive 模式我举两个例子,一个是我们登录 Linux 字符界面的时候,输入用户名密码进入的那个 shell 就是登录交互式的,另一个就是我们使用 sshd 服务远程登录,在输入用户名密码后获得的 shell 也是登录交互式的。

对于非交互式的 shell 典型的情况就是执行脚本啦,而在执行脚本的时候可以通过添加 --login 或者 -l 的选项来使这个 shell 去读取 Startup Files,因为它没有输入口令的登录动作,只有读取和执行 Startup Files 。

另外,你在 X Windows 下运行 terminal 软件打开的 shell 是 non-login & interactive 模式的。如果你曾有在视窗下打开 shell 却无法获取 ~/bash_profile 中定义的变量的疑惑的话,现在你可以释然了。

来做个实验吧,我事先在 /etc/profile/etc/bashrc~/.bash_profile~/.bashrc 中增加了 echo “Hello from xxxx” 的语句,让我们来看看各种情况下我们得到的 shell 到底执行了哪些文件:

  1. sshd

    1
    2
    3
    4
    5
    6
    {11:07}~ ➭ ssh root@192.168.1.41
    Last login: Wed Dec 4 10:44:06 2019 from 192.168.1.183
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile

    ~/.bash_profile 调用了 ~/.bashrc, ~/.bashrc 调用了 /etc/bashrc,所以 shell 调用的是/etc/profile 和 ~/.bash_profile

  2. GUI Terminal

    terminal bash

    GUI 下打开 shell 只运行了 ~/.bashrc

  3. 运行 bash

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [root@afis-db ~]# bash
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    [root@afis-db ~]# exit
    exit
    [root@afis-db ~]# bash -l
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile

    默认情况下,bash 命令进入的是一个非登录的交互式子 shell,当使用 -l--login 选项后进入的是登录的交互式子 shell 。

  4. su

    su 的功能是切换用户,其中 - 选项表示登录,一个登录的 shell 在 ps 中显示为-bash,非登录的显示为 bash

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    [root@afis-db ~]# su - oracle
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile
    [oracle@afis-db ~]$ echo $$
    11935
    [oracle@afis-db ~]$ ps -ef|grep 11935
    oracle 11935 11934 0 13:22 pts/1 00:00:00 -bash
    oracle 11960 11935 0 13:22 pts/1 00:00:00 ps -ef
    oracle 11961 11935 0 13:22 pts/1 00:00:00 grep 11935
    [oracle@afis-db ~]$ exit
    logout
    [root@afis-db ~]# su oracle
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    [oracle@afis-db root]$ echo $$
    11965
    [oracle@afis-db root]$ ps -ef|grep 11965
    oracle 11965 11964 0 13:22 pts/1 00:00:00 bash
    oracle 11982 11965 4 13:22 pts/1 00:00:00 ps -ef
    oracle 11983 11965 0 13:22 pts/1 00:00:00 grep 11965
  5. 运行脚本—非交互模式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [root@afis-db ~]# ./script.sh 
    I am script!
    [root@afis-db ~]# bash script.sh
    I am script!
    [root@afis-db ~]# bash -l script.sh
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile
    I am script!

    可见在非交互模式下不会读取任何文件,增加了登录选项则会依次读取 Startup Files。

我们很少以 bash script.sh 这种方式执行脚本,更多的是以 ./script.sh运行,当以后一种方式执行时,真正执行脚本的解释器依赖于具体的 Shebang。而我们经常看到使用 sh script.sh 这样的方式执行,那 sh 究竟是什么呢?

在大多数 Linux 发行版中,sh 通常是 bash 的软连接,但是 bash 文章中有如下描述:

If Bash is invoked with the name sh, it tries to mimic the startup behavior of historical versions of sh as closely as possible, while conforming to the POSIX standard as well.

When invoked as sh, Bash enters POSIX mode after the startup files are read.

意思是当你用 sh 来启动 shell 的时候,bash 会以 posix 标准模式运行,就如同调用了 bash --posix。需要注意的是:sh 并不是一个具体的 shell 实现,而是一种规格标准,bash 在这种模式下运行的时候,将遵循 posix 的标准去读取执行文件。如下图所示:

sh and startup files

来实地验证一下:

  1. 运行 sh

    1
    2
    3
    4
    5
    6
    [root@afis-db ~]# sh
    sh-4.1# exit
    exit
    [root@afis-db ~]# sh -l
    Hello from /etc/profile
    Hello from ~/.profile.(this file is touched by me)
  2. 执行脚本

    1
    2
    3
    4
    5
    6
    [root@afis-db ~]# sh script.sh 
    I am script!
    [root@afis-db ~]# sh -l script.sh
    Hello from /etc/profile
    Hello from ~/.profile.(this file is touched by me)
    I am script!

因为生产服务器上使用 Bash 居多,而线上服务多少都依托于 shell 去调用,因为不同的调用方式下 shell 读取执行文件的规则不同,这样就可能对应用造成一定程度的困扰。我曾经维护一个线上的 java 项目,这个项目有几百个 java 服务需要每天定时重启,项目上线时反复检查验证了 cron 服务的配置以确保万无一失,而没有想到启停脚本对环境变量 JAVA_HOME 的依赖会在 cron 调用的时候失效。如今看来,只要使用登录非交互模式即可。

总结

本片文章细节太过零散,去验证以及查阅资料花了不少时间,其实写作的最初兴奋点是想从 fork & exec 的角度去理解 Linux 上各种执行程序的方式,但是回头一看,关于 fork 和 exec 的介绍只有寥寥几笔,剩下的都是关于细节的追求与验证,但是 Done is better than perfect

因能力有限,行文或有疏漏与错误之处,望阅读本文的朋友给予斧正,也希望了解其它启动方式的朋友不吝赐教。

参考文章:

  1. Why do some scripts start with #! ... ?
  2. Bash Reference Manual
  3. 浅析 Linux 初始化 init 系统——sysvinit
  4. 浅析 Linux 初始化 init 系统——Systemd
  5. Linux 的启动流程
  6. LINUX PID 1 和 SYSTEMD
  7. Difference between a 'terminal', a 'shell', a 'tty' and a 'console'?
  8. 为什么执行自己的程序时需要加上点斜杠
  9. Shebang
  10. Java class file
  11. 为 a.out 举行一个特殊的告别仪式
  12. Linux cron运行原理
  13. 《程序员的自我修养》

左耳朵耗子说过一段话,我深以为然。

技术的发展过程非常重要,要尽可能早的进入时下的新技术,而不应等待这些技术成熟之后再进入。我进入 Go 和 Docker 的技术不能算早,但也不算晚,从 2012 年学习 Go,到 2013 年学习 Docker 到今天,我清楚地看到了这两种技术的生态圈发展过程。让我收获最大的并不是这些技术本身,而是一个技术的变迁和行业的发展。从中,我看到了非常具体的各种思潮和思路,这些东西比起 Go 和 Docker 来说更有价值。因为,这不但让我重新思考我已掌握的技术以及如何更好地解决已有的问题,而且还让我看到了未来。

改变世界的“箱子”

1956 年 4 月 26 号,麦克莱恩的第一艘集装箱货船理性X号从纽约港起航,他无从预想,这个简陋的发明,在未来几十年内会彻底改变人类的命运,将人类的航运业带入了一个巨大的新时代。

集装箱的发明,极大地推动了全球化的协作浪潮,使得制造业的产业链条发生了翻天覆地的变化。从集装箱规模化应用的那一刻,地球变成了一座巨大的工厂,开始轰鸣了起来。

因为集装箱,商品以及原材料可以以极低的运输成本送往世界各地,从而使得全球的资源都卷入到了分工链条当中,经济学家讲:分工产生效能,因为生产效能的极大提升,人类创造了不可胜数的财富。而集装箱的影响依然在持续,依然在不断的改变着我们每一个人的生活。

到了 80 年代,突然大门一开,进来一个胖子,就是我们中国人。我们啥都没有,我们没有资金没有技术,但是我们有勤劳的双手,我们有庞大的人口存量,所以我们用这个优势进入到了全球化的分工当中。

结果呢,中国用 35 年的时间完成了西方 300 多年的工业革命进程,而这已经是中国第四次进行工业化革命的尝试了,你能说集装箱没有功劳么。

可是,像这样具有伟大意义的发明,在其问世的前十年可谓历尽坎坷,并没有让麦克莱恩占据领航者的地位。甚至最早的集装箱都不是麦克莱恩发明的,但他却是集装箱真正意义上的践行者。因此,人类最终将集装箱之父的荣誉授予他。

《经济学家》这样评价:如果没有集装箱,就不会有全球化!

如果你打开 Google 翻译翻译一下“集装箱”这个词,你会得到如下的答案:

Container

在英文中一词多义可能表示多义之间具有意义的延伸

容器 不正是如此么?

第一道灵光

让我们将历史拨回到 1979 年,贝尔实验室的一群大男孩亦或是老男孩正在开发 unix v7(Version 7 Unix) 操作系统。项目进行到了最后的开发和测试阶段,即便是对于这些天才般的程序员来说,系统级别的软件构建和测试仍然是一个繁复且无比棘手的难题。你可以想象:当一次测试开始,源代码编译和安装完成之后,整个测试环境就被“污染”了。要想进行下一次测试,就需要重新搭建,并且再编译安装测试。那有没有办法来避免重复的环境搭建,快速的销毁和重建所需的基础设施环境呢?

我们今天有虚拟化和云计算,这个问题听起来好像不难解决。但是,在那个计算机软件刚刚萌芽的蛮荒时代,这样的想法近乎是异想天开。一块 64K 的内存条要卖 419 美元的年代,“快速销毁和重建基础设施”的想法还是有点“科幻”了。

但天才毕竟是天才,这些黑客程序员们就想,能不能在现有的操作系统环境下“隔离”出一个新环境用于软件的构建和测试呢?

于是,一个叫做 chroot(Change Root) 的系统调用就此诞生了!

chroot 会重定向进程及其子进程的根目录到操作系统上的一个新位置,使得该进程的文件“视图”与外面的“世界”完全隔离,它不能够对这个指定根目录之外的文件进行访问动作,不能读取,也不能更改它的内容。

这是容器史上第一道乍现的灵光,在 unix v7 上面被孕育出来。

但令人叹息的是,unix v7 是贝尔实验室发布的最后一个可自由分发的版本,之后 AT&T 开始收回 Unix 的版权,倾情弹奏 Unix 商业化的序曲。也正因为此, Richard Matthew Stallman 在 1983 年发起 GUN(GNU's Not UNIX) 计划和自由软件运动,几十年后垂垂老矣的 Unix 最终被商业的 Linux 击溃。

然而,chroot 毕竟打开了进程隔离的大门,虽然孕育它的 Unix 在后续的发展中逐渐式微,但容器化的思想却如同奔流不息的河流一般,跨越了重重艰难险阻,不断地在历史的精彩处漫延。

百家争鸣

2000 年,Unix伯克利大学分发版 FreeBSD 操作系统发布了 jail 命令。jail 是从 chroot 得到的启发,并从中进一步发展而来,它将隔离扩展到了整个用户环境,使得进程在一个沙盒内运行。在进程看来,跟实际的操作系统几乎是一样的,对于进程来说就像被关进了监狱,这也是 jail 名称的由来,jail 中的进程甚至可以拥有自己的 IP 地址,可以对环境进行各种定制。我们可以说, chroot 开创了进程隔离的思想,但 FreeBSD Jails 才真正实现了进程的沙箱化

2004年,Solaris Containers 发布,它也是秉承了 jail 的思想,为进程提供一个隔离的沙盒,进程在其中独立运行。它也被称为“吃了类固醇的 chroot ”(chroot on steroids)

不过,无论是 FreeBSD Jails ,还是紧接着出现的 Solaris Containers ,都没有能在更广泛的软件开发和交付场景中扮演到更重要的角色。在这段属于 Jails 们的时代,进程沙箱技术因为“云”的概念尚未普及,始终被局限在了小众而有限的世界里。就如同集装箱刚刚出现的那十年,所有和集装箱配套的设施都还没有,整个社会都还没有为集装箱做好准备。你看,他们两者不仅意义上相近,就连命运也如出一辙。

可见,任何一种新技术,即便问世很早,可要整个社会系统去适应它,却需要一个无比漫长的过程。

让我们回到容器技术上来,事实上,在 Jails 大行其道的这几年间,同样在迅速发展的 Linux 阵营上也陆续出现多个类似的沙箱技术比如 Linux VServerOpen VZ (未进入内核主干)。但如同那些 jail 的前辈一样,受当时计算机环境的制约,被局限在一个小众的圈子里。

早在 2002 年,Plan 9 from Bell Labs(Go语言的运行时就是使用的该操作系统的汇编器语法)操作系统对 Namespace 的广泛运用为 Linux 带来了灵感, Linux 在其内核 2.4.19 版本上加入了 Namesapce 功能(可以实现对应资源的隔离)。最初只有mount namespace,后续的pidnetipcUTSuser等一直到内核3.8版本才实现完成。内核4.6中又添加了Cgroup namespace

要知道 Namespace 是现代容器技术最底层的技术支撑。它虽然解决了虚拟化和资源环境隔离的问题,但是我们还希望对隔离的进程在资源使用上加以限制,namespace并没有提供解决方案。

2007年,一种名叫 Process Container 技术的发布。它是由 Google 的工程师 Paul MenageRohit Seth 发起并实现的,旨在对一组进程进行资源上的隔离、限制。在合并入 Linux 内核的时候,由于 Linux 中存在 Container 的概念,故被重命名为 Cgroups

Cgroups 有两个版本,v1 版本由 Paul MenageRohit Seth 维护。v2 版本首次出现在 2016 年 3 月发布的内核 4.5 中,Tejun Heo 重新设计并重写了Cgroups,主要解决 v1 在用户体验上的问题。

2008 年,通过将 Cgroups 的资源管理能力和 Linux Namespace 的视图隔离能力组合在一起, LXC(Linux Container) 这样的完整的容器技术出现在了 Linux 内核当中。它是第一个完善的容器管理方案,你可以通过 LXC 来创建和启动容器了。 LXC 跟之前出现的沙盒技术非常类似,但其赶上了 Linux 大规模商用的浪潮,境遇要比那些前辈们要好一些。伴随着公有云市场的崛起,很快催生了一个全新的、名为 PaaS 的产业。

2011 年,由 Vmware 主导的 Cloud Foundry 开发了一个新项目:Warden,它最开始是一个 LXC 的封装,后来重构成了直接对 Cgroups 以及 Linux Namespace 操作的架构。

Cloud Foundry 项目的诞生,第一次对 PaaS 的概念完成了清晰而完整的定义。这其中,“PaaS 项目通过对应用的直接管理、编排和调度让开发者专注于业务逻辑而非基础设施”,以及“PaaS 项目通过容器技术来封装和启动应用”等理念,也第一次出现在云计算产业当中并得到认可。

按照这个剧本,容器技术以及云计算的发展,理应向着 PaaS 的和以应用为中心的方向继续演进下去。

如果不是有一家叫做 Docker 的公司出现的话。

另一只”箱子“

时间来到了 2013 年,Docker 的第一个版本发布,它是基于 LXC 的,但它创建和使用应用容器的逻辑跟 Warden 等没有本质不同。只不过是把 LXC 复杂的创建和使用方式简化成了自己的一套命令体系。但是 Docker 作为 PaaS 行业的搅局者,其真正的杀手锏是容器镜像。Docker 通过镜像技术,提出了buildshiprun的概念,创造了一次构建、处处运行的新思想,将容器技术向IT产业链条的上游和下游进行了延伸。

关于如何封装应用,这本身不是开发者所关心的事情,所以 PaaS 项目有着无数的发挥空间。但到这如何定义应用这个问题,就是跟每一位技术人员息息相关了。在那个时候,Cloud Foundry 给出的方法是 Buildpack ,它是一个应用可运行文件(比如 WAR 包)的封装,然后在里面内置了 Cloud Foundry 可以识别的启动和停止脚本,以及配置信息。

然而,Docker 项目通过容器镜像,直接将一个应用运行所需的完整环境,即:整个操作系统的文件系统也打包了进去。这种思路,可算是解决了困扰 PaaS 用户已久的一致性问题,制作一个“一次构建、处处运行”的 Docker 镜像的意义,一下子就比制作一个连开发和测试环境都无法统一的 Buildpack 高明了太多。

更为重要的是,Docker 项目还在容器镜像的制作上引入了“层”的概念,这种基于“层”(也就是“commit” ) 进行 buildpushupdate 的思路,显然是借鉴了 Git 的思想。所以这个做法的好处也跟 Github 如出一辙:制作 Docker 镜像不再是一个枯燥而乏味的事情,因为通过 DockerHub 这样的镜像托管仓库,你和你的软件立刻就可以参与到全世界软件分发的流程当中了。

至此,你就应该明白,Docker 项目实际上解决的确实是一个更高维度的问题:软件究竟应该通过什么样的方式进行交付?

更重要的是,一旦当软件的交付方式定义的如此清晰并且完备的时候,利用这个定义在去做一个托管软件的平台比如 PaaS,就变得非常简单而明了了。这也是为什么 Docker 项目会多次表示自己只是“站在巨人肩膀上”的根本原因:没有最近十年 Linux 容器等技术的提出与完善,要通过一个开源项目来定义并且统一软件的交付历程,恐怕如痴人说梦。

然而,这并没有使 Docker 站在领导者的位置上。CoreOS 是一个专注于容器的操作系统,一度与 Docker 打的很火热,但是 CoreOS 渐渐的发现 Docker 野心很大,甚至动了 CoreOS 的市场。至于 Docker 有什么野心,我后面会讲。 CoreOS 不肯坐以待毙,于是在 2014 年推出了新的容器引擎 rocket 。后来谷歌开始支持 CoreOS ,容器就此分化成了 Docker 阵营和 Google 阵营。也就在同一年,Docker 0.9 发布,用 libcontainer 库代替了原来的 LXC 。

到了 2015 年,容器圈太乱了,大家觉得长此以往不利于发展,于是就组织起来,在 Linux 基金会的支持下成立了 OCI(Open Constitution Initiative)OCI 致力于围绕容器镜像格式容器运行时建立开发的行业标准,让容器可以在各种兼容性的操作系统和平台上移植,没有人为的技术屏障。大家并不希望这个工业标准由 Docker 一家说了算。

2016 年 Docker 发布了 1.11 版本,做了一些架构调整,里面新出现了符合 OCI 标准的 runC 。runC 其实就是对 libcontainer 的调用,是一种符合开放式容器格式标准的一种实现,后来 Docker 就把 runC 贡献出来了。

下图是 2018 年容器市场份额统计:

Container Market Shares

可见 2018 年的容器市场 Docker 占了 83% ,这个数据乍看之下相当可观,可是你要知道在 2017 年这个份额还是99%。位居第二的就是占比12%的 rocket ,第三是 mesos ,占比4%,第四是我们介绍过的 LXC 占比仅有1%。

现在,我们可以得出一个结论,Docker 并不等于容器。如果你想用容器,其实你有很多选择,Docker 仅仅是你的首选而已。如今下一代容器架构的呼声言犹在耳( Podman + Skopeo + Buildah ),我们不禁要问了:既然 Docker 这么优秀,为什么会落到如此众叛亲离的地步呢?

编排大战

如今我们知道,现代容器的底层技术为 NamespaceCgroups ,Docker 因为带来了革命性的容器镜像思想而异军突起,在容器领域大张挞伐,并在短时间内挤占市场。虽然表面上风光无限,却终究难掩容器技术门槛偏低的事实,也就是说任何公司和组织都可以利用 NamespaceCgroups 进入容器市场(比如 CoreOS 的 rocket ), Docker 更是深谙此道,因此它必须寻求突破。

试想一下, Docker 容器发端之后,感觉一下子拥有了可以施展黑科技的魔法棒,但为什么只能搞搞开发、测试这种小打小闹的活儿呢?根本没有生产力大爆发啊?好像也没有改变整个行业的协作方式啊?你看,这是不是和集装箱刚刚问世的前十年非常像呢?那个时候整个社会协作体系还没有为集装箱做好准备,配套的道路、桥梁、码头、吊车等设备和基础设施全都没有就绪。

回到 Docker 上,我们不禁要问:我们的码头和吊车,乃至相应的货轮、桥梁准备好了么?全产业界已经接受了以容器镜像为主要形态的软件发布模式了吗?应用的执行都基于容器了吗?分布式以及微服务架构已经非常普及了吗?

显然还没有。

这里我们不得不提一个观点:容器本身并没有价值。就像集装箱一样,本身就是个大铁盒子,它没有太多价值,单单靠它提高不了社会协作的效能。它只有流动起来,才会产生价值,把货物从一个车间运到另一个车间,这种连接才是其价值所在。容器也一样,本身没有太多商业价值,你弄得再完美,你也只能在一个服务器上折腾,翻不出多大的浪花,那怎么样才能产生价值呢?

那就是真正价值所在——容器编排

在既有硬件资源的基础上,启动容器不需要关注具体运行的节点,各个容器之间仍能保持通信,信息在容器之间依然可以流动。这样就拥有了商业价值,容器技术便可以付诸商用,整个软件的开发交付流程就会变得高效和颠覆。所以我们需要的是一个分布式的调度器,其主要功能就是容器编排。

Docker 也深知这一点,当它不顾一切于 2015 年带着 Swarm 挤进编排领域的时候,发现面前立着一座大山: Kubernetes

Kubernetes 源于 Google 内部的 Borg 项目,经 Google 使用 Go 语言重写后,被命名为 Kubernetes,并于 2014 年 6 月开源。Kubernetes 希腊语意思是“舵手”,致力于管理数以万计的容器集群。你看舵手不正是隐喻了方向流动么。因其开头字母和结尾字母之间共有 8 个字,所以简短的称其为k8s

2014 年 k8s 开源之后,同年底 Docker 就设计了 machine+swarm+compose 的组合方案。2015 年 7 月 k8s 发布了第一个商用版本 1.0 ,同一年 Docker 的 Swarm 发布,编排大战已经开始上演了。

2016 年 2 月 Docker 发布了 1.12 版本,它不顾众怒,将 Swarm 强行内置到 Docker 的容器引擎里面,企图利用 Docker 项目在容器领域的领导地位推动 Swarm 的发展。这有点像什么?举个不恰当的例子,我订购了一个集装箱,你却附赠了一个不太好用的货轮。这一下一石惊起千层浪,业界都纷纷谴责 Docker 。同年的 7 月 Apache mesos 发布了 1.0 ,也是一个容器编排框架,至此,KubernetesDocker SwarmApache Mesos 已成三足鼎立之势。

然而,2017 年 9 月,Mesosphere 宣布支持 Kubernetes,这也是迫于用户压力,在对抗和妥协面前,不得不选择后者。

2017 年 10 月,在欧洲的 DockerCon 大会上,Docker 公司 CTO Solomon Hykes 宣布,Docker 的下个版本将支持 Kubernetes,台下观众响起热烈掌声,因为这是容器圈等待已久的消息。

Kubernetes 在众多厂商和开源爱好者的共同努力下迅速崛起,时至今日已成长为容器管理领域的事实标准。Kubernetes 极大推动了云原生领域的发展,被称为影响云计算未来 10 年的技术。

毫无疑问,Kubernetes 已经赢得了容器编排的大战。

Docker 会是改变世界的那只“箱子”吗?

2001 年 5 月 30 号,集装箱之父麦克莱恩去世。全世界所有的集装箱船,不管在哪个港口,不管在全球的哪个角落,都拉响了汽笛,向这位老人致敬,我想这是一个创新者得到的最高荣誉。

那么, Docker 会是改变世界的那只“箱子”吗?

这要看你怎么理解 Docker ,如果我们把它理解为容器技术buildshiprun 这样一次构建,处处运行的理念,那么我相信它是改变世界的箱子。当我们软件发布模式已经切换到容器镜像形态的时候,当我们应用的运行都是基于容器的时候,当分布式的操作系统或者平台已经整装待发的时候,它有什么理由不改变世界呢?我们没法想象那时 IT 世界会是什么样子的,但有一点可以肯定,IT 世界的分工协作方式以及产业链条肯定是一个全新的面貌。

反观 Docker 作为一家公司,它会是改变世界的箱子么?我觉得很大可能不会。

上个世纪 80 年代,在集装箱刚刚发力的时候,麦克莱恩破产了。那他犯了什么错误吗?没有,他就是跑慢了,没有什么实质性的错误。Docker 很可能步他的后尘,但是那又怎么样呢? Docker 已经完成了它的历史使命,在让 IT 的世界工厂运转起来的路上,它已经推了一把,这本身就已经足够了。

总结

早在 2014 年,RedHat 就与 Kubernetes 达成了战略合作关系,宣布全面投入 Kubernetes。在当时的一份官宣中, RedHat 以非常自信的姿态表达了对容器的“颠覆性”创新的认可,并大胆预言 Kubernetes 会在 2015 年后取得应用编排与管理领域的统治地位。

当时业界对这个论断大多不以为然,甚至嗤之以鼻,但今天回过头来再看,预言已经成为事实。

Rancher 的创始人梁胜博士有过一句评论,非常在理:时至今日,在容器技术领域依然有许多创新,只不过这些创新大多发生在 Kubernetes 以及 CNCF 生态系统中了。

是的,比如最近很火的 Service Mesh 的实现 istio , 它就是基于 kubernetes 进行的创新啊。

这有点像以前在单片机上玩汇编,汇编说白了就是单片机上运行的应用程序,但是后来有人写了个超级大的应用程序,那就是操作系统,然后大家就都在操作系统上玩了。而 Kubernetes 不正是云时代分布式的操作系统么?

回到文章开头的主题上,我们应该及早的进入到一个新兴的技术领域,即使 Docker 会在不久的将来被下一代容器取代,我们也仍然有必要去学习它。因为你最终得到的并不是这项技术本身,而是整个技术领域的发展脉络与思潮演变,这才是无比珍贵的东西。因为一旦你对技术的发展有所感知,它就可能会影响到你未来的人生选择。

我们仍然身处在容器化变革的浪潮当中,你无法想象它将来会对你的命运产生什么样的影响。我们每一个人都像是这个湍流中行进的小船,方向决定了你驶向远方还是抵触暗礁。我们唯一能做的,就是拿一根竹篙,根据水流的情况和环境的变化随时的轻轻点那么一下,微微的改变一下我们的航迹。

注:本文很多段落,大段引用了张磊老师的文章,我读过他的《Docker 容器与容器云》,订阅过他的极客时间专栏《深入剖析Kubernetes》,深深佩服其学识之渊博。在有些关于容器发展史的描述中,我基本上引用了原话,因为自知不能写的更好。

参考文章:

  1. Linux namespaces
  2. cgroups
  3. DOCKER基础技术:LINUX CGROUP
  4. Docker 会是改变世界的那只“箱子”吗?
  5. 为什么说 2019,是属于容器技术的时代?
  6. Linux 容器技术史话:从 chroot 到未来
  7. DOCKER基础技术:LINUX NAMESPACE(上)
  8. Linux Namespace : 简介
  9. 下一代容器架构已出,Docker何去何处?
  10. Docker 背后的标准化容器执行引擎——runC

Go语言中有两个builtin函数newmake,这个两个函数经常让初学者摸不着头脑,也许使用过程中并未有什么阻碍,但回过头看细想又难以说清道明。本文将针对这两个函数进行分析,希望能抽丝剥茧,彻底搞清楚他们的区别。

函数定义

当然,我们先看Go官方对这两个函数的解释:

func new(Type) *Typel

The new built-in function allocates memory. The first argument is a type, not a value, and the value returned is a pointer to a newly allocated zero value of that type.

大意:new函数会分配内存,它唯一的一个参数是type不是value,返回值是一个指针,指向刚刚分配的那块内存,并且这块内存中存储着type零值(zero value)

重点:

  1. 分配内存
  2. 返回指向这块内存的指针
  3. 内存存储type零值

func make(t Type, size ...IntegerType) Type

The make built-in function allocates and initializes an object of type slice, map, or chan (only). Like new, the first argument is a type, not a value. Unlike new, make's return type is the same as the type of its argument, not a pointer to it.

大意:make仅用于分配和初始化slice、map、chanel,同new一样,第一个参数要传入一个type;不同的是,make返回的是初始化过之后的type的一个值,而不是指针。

重点:

  1. 分配内存并初始化
  2. 仅用于slicemapchanel
  3. 返回的是值,不是指针

zero value

上文提到new()会分配内存,并且为相应的Type存储零值那什么是零值呢?官方对于zero value的描述如下:

When storage is allocated for a variable, either through a declaration or a call of new, or when a new value is created, either through a composite literal or a call of make, and no explicit initialization is provided, the variable or value is given a default value. Each element of such a variable or value is set to the zero value for its type: false for booleans, 0 for numeric types, "" for strings, and nil for pointers, functions, interfaces, slices, channels, and maps. This initialization is done recursively, so for instance each element of an array of structs will have its fields zeroed if no value is specified.

大意:当通过声明或调用new为变量分配存储时,或者在创建新值时(通过复合字面值或调用make),并且没有提供显式初始化时,将为变量或值提供默认值。此类变量或值的每个元素的类型都设置为该类型的零值:布尔值为false,数值类型为0,字符串为"",指针、函数、接口、片、通道和映射为nil。这个初始化是递归完成的,因此,如果结构体中没有指定相应field的值,那么默认将是该field的零值。

下面表格中展示了Go中主要类型的零值:

Type Zero Value
boolean false
numeric 0
string ""
pointer nil
function nil
interface nil
slice nil
map nil
channel nil

其中,array和struct两个复合类型的零值为其承载的基础类型的零值,因为array和struct都是值类型,不像slice、map是引用类型。

但是,个人感觉上面一段话中关于通过复合字面值或调用make创造值的相关描述略有不准确。因为Composite literals(复合字面值)是为structsarraysslicesmaps构造值,而make仅用于分配并初始化slicemapchanel。如果使用字面值构造一个值且不显示的初始化,那么该值就是一个空值(empty),和make的结果相同,而不是零值nil。看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var nilSlice []string
newNilSlice := new([]string)
emptySlice := make([]string, 0)
emptySliceLiteral := []string{}
fmt.Println(nilSlice) // Output: []
fmt.Println(len(nilSlice), cap(nilSlice)) // Output: 0 0
fmt.Println(nilSlice == nil) // Output: true
fmt.Println(*newNilSlice) // Output: []
fmt.Println(len(*newNilSlice), cap(*newNilSlice)) // Output: 0 0
fmt.Println(*newNilSlice == nil) // Output: true
fmt.Println(emptySlice) // Output: []
fmt.Println(len(emptySlice), cap(emptySlice)) // Output: 0 0
fmt.Println(emptySlice == nil) // Output: false
fmt.Println(emptySliceLiteral) // Output: []
fmt.Println(len(emptySliceLiteral), cap(emptySliceLiteral)) // Output: 0 0
fmt.Println(emptySliceLiteral == nil) // Output: false

slicemap这样的引用类型的零值是nil,并不是“the variable or value is given a default value”,因为通过复合字面值或调用make构造变量时default valueempty。(如果有人觉得这是咬文嚼字,那我也只能承认,毕竟nil让我很痛苦,后面我会再论述default value)。

一个nil的slice和一个Empty的slice很容易让你迷惑,它们都被fmt.Println打印出[],它们拥有相同的lengthcapacity

除非nil或者Empty会对你的逻辑产生影响,否则不用区别对待它们。如果你需要测试一个slice是否是空的,使用len(s) == 0来判断,而不应该用s == nil来判断。除了和nil相等比较外,一个nil值的slice的行为和其它任意0长度的slice一样。

另一个值得注意的问题是,当你使用encoding/json时,你要特别注意:Golang的encoding/json会将Nil Slice编码为null

what is nil?

nil的定义

nil 为预声明的标示符,定义在builtin/builtin.go,

1
2
3
4
5
6
7
8
9
// nil is a predeclared identifier representing the zero value for a
// pointer, channel, func, interface, map, or slice type.
// Type must be a pointer, channel, func, interface, map, or slice type
var nil Type

// Type is here for the purposes of documentation only. It is a stand-in
// for any Go type, but represents the same type for any given function
// invocation.
type Type int

可见,nil没有默认类型,它是一个预定义好的变量,有多种可能的类型(pointermapslicefunctionchannelinterface)。它代表指针、通道、函数、接口、映射或切片的零值

你必须给编译器以足够的信息,使得编译器可以推导出nil的类型,因此下面的使用方式是非法的:

1
var n = nil // illegal, doesn't compile

正确的做法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
// There must be sufficient information for
// compiler to deduce the type of a nil value.
_ = (*struct{})(nil)
_ = []int(nil)
_ = map[int]bool(nil)
_ = chan string(nil)
_ = (func())(nil)
_ = interface{}(nil)

// This lines are equivalent to the above lines.
var _ *struct{} = nil
var _ []int = nil
var _ map[int]bool = nil
var _ chan string = nil
var _ func() = nil
var _ interface{} = nil
}

nil的地址

各种类型的nil的内存布局始终相同,换一句话说就是:不同类型nil的内存地址是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {

var m map[int]string
var ptr *int
var sl []int
testNewAddr := new([]string)
testMakeAddr := make([]string, 0)
fmt.Printf("%p\n", m) //0x0
fmt.Printf("%p\n", ptr) //0x0
fmt.Printf("%p\n", sl) //0x0
fmt.Printf("%p\n", *testNewAddr) //0x0
fmt.Printf("%p\n", testMakeAddr) //0x57db60

}

可见,值为nil的变量都指向同样的内存地址0x0,这是一个无效的地址,如果对这个地址进行读写,会引发panic

1
2
3
4
func main() {
var p *int // Declare a nil value pointer
*p = 10 // Write the value 10 to address 0x0
}
1
2
3
4
5
6
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x4525b2]

goroutine 1 [running]:
main.main()
/home/alarm/go/test/test.go:5 +0x2

我们来看一下这段代码的plan9汇编指令:

1
2
3
4
5
6
go tool objdump -s main.main test

TEXT main.main(SB) /home/alarm/go/test/test.go
test.go:5 0x4525b0 31c0 XORL AX, AX
test.go:5 0x4525b2 48c7000a000000 MOVQ $0xa, 0(AX)
test.go:6 0x4525b9 c3 RET

从汇编指令可以看到,AX寄存器被清零,之后试图将0xa(10)写入AX指向的内存地址,然后就导致了panic。

现在我们可以作如下总结:

非引用类型一旦赋予default value(或者说zero value),那么将会实际分配内存,并且内存中数据初始化为相应类型的zero value;而零值为nil的类型都是引用类型,其背后引用了使用前必须初始化的数据结构,它们的default valuenil,尚未分配内存,相应的数据结构也未被初始化。例如,slice是一个三元描述符,包含一个指向数据(在数组中)的指针、长度、以及容量,在这些项被初始化之前,slice都是nil的。对于slice,map和channel,make初始化这些内部数据结构,并准备好可用的值(对应类型的zero value)。

不是关键字

另一个值得注意的地方:nil不是Go的关键字,你可以重写他,但是最好不要这样做:

1
2
3
4
5
6
7
8
9
func main() {
nil := 123
fmt.Println(nil) // 123

// The following line fails to compile,
// for nil represents an int value now
// in this scope.
var _ map[string]int = nil
}

kinds of nil

我们知道nil的种类有pointer, channel, func, interface, map, or slice,下面分别讨论一下这几种类型的nil行为。

先说明一下这几种类型的nil含义:

Type meaning
pointer 什么也不指向
function 没有初始化
interface 没有赋值,空指针
slice 没有底层数组
map 没有初始化
channel 没有初始化

pointer

在go中,指针指向一个内存地址,同c/c++中一样,但go中的指针没有指针运算,所以是安全的。可以有以下几种方式生成pointer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
// 第一种:直接声明指针类型
var p *[]int
fmt.Println(p == nil) // Output: true
fmt.Println(*p == nil) // panic: runtime error: index out of range
// 第二种:使用new()函数返回指针
pNew := new([]int)
fmt.Println(pNew == nil) // Output: false
fmt.Println(*pNew == nil) // Output: true

// 第三种:通过取址符&,产生一个指向变量的指针
s := []int{}
pAnd := &s
fmt.Println(pAnd == nil) // Output: false
fmt.Println(*pAnd == nil) // Output: false
// 给nil的指针赋值
p = &s
fmt.Println(p == nil) // Output: false
fmt.Println(*p == nil) // Output: false
}

上述代码我故意用了slice的指针类型,因此当pNew不是nil的时候,*pNew依然是nil

nil的指针无法解引用,如果试图对一个nil的指针解引用的话会产生panic。

但是Nil却可以作为合法的接收器:

1
2
3
4
5
6
7
8
9
10
11

type PointerReciver int

func (a *PointerReciver) showme(){
fmt.Printf("Yeah, it works!")
}

func main() {
var ta *PointerReciver
ta.showme() // Yeah, it works!
}

slice

slice的底层是一个数组,那么一个nil的slice是没有底层数组的。一个nil的或者长度为0的非nil的slice都无法被索引,但是可以使用append去填充值。下面代码展示了各种构造slice的方式,以及nil的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func main() {
// 显示声明
var ts []int
fmt.Println(ts == nil) // Output: true
ts[0] = 1 // panic: runtime error: index out of range
ts = append(ts, 2) // slice: [2]

// 使用new函数
p := new([]int)
fmt.Println(*p == nil) // Output: true
(*p)[0] = 1 // panic: runtime error: index out of range
*p = append(*p, 2) // slice: [2]

// 使用字面值初始化
literal := []int{}
fmt.Println(literal == nil) // Output: false
literal[0] = 1 // panic: runtime error: index out of range
literal = append(literal, 2) // slice: [2]

// 使用make初始化
mp := make([]int, 0)
fmt.Println(mp == nil) // Output: false
mp[0] = 1 // panic: runtime error: index out of range
mp = append(mp, 2) // slice: [2]

// 从数组截取
arrayExample := [10]int{}
sliceFromArray := arrayExample[2:5]
fmt.Println(sliceFromArray == nil) // Output: false
sliceFromArray[0] = 1
sliceFromArray = append(sliceFromArray, 2) // slice: [1 0 0 2]

var tnilslice []int
// 不会迭代
for k ,v := range tnilslice{
fmt.Printf("k: %d, v: %d\n", k, v)
}
}

Slice小结:

1. 显示声明、未初始化值时为nil
2. 使用new函数生成slice为nil
3. 字面值初始化的slice,未提供具体值的为Empty,不是nil,但长度和容量与nil相同都为0
4. 使用make初始化的slice未提供长度和容量的为Empty,提供的初始化为相应类型的零值
5. nil和Empty的slice(长度为0)无法被索引
6. 使用for...range遍历nil的slice不会迭代,也不会报错

map

map的底层是一个哈希表,通过内置的make函数可以快速构建一个map。make创建map时,实际底层调用的是makemap函数,返回值是一个结构体指针。

func makemap(t maptype, hint int64, h hmap, bucket unsafe.Pointer) *hmap

使用make可以选填capacity,capacity 不限制map的大小,map会自适应增长,但是nil的map除外。除了不允许添加元素以外,nil的map等价于Empty的map。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func main() {

// 直接声明
var dmap map[string]int
fmt.Println(dmap == nil) // Output: true
fmt.Println(dmap["a"]) // Output: 0
dmap["b"] = 1 // panic: assignment to entry in nil map
delete(dmap, "c") // no-op

// 使用new函数
nmap := new(map[string]int)
fmt.Println(*nmap == nil) // Output: true
fmt.Println((*nmap)["a"]) // Output: 0
(*nmap)["b"] = 1 // panic: assignment to entry in nil map
delete(*nmap, "c") // no-op

// 字面量初始化
lmap := map[string]int{}
fmt.Println(lmap == nil) // Output: false
fmt.Println(lmap["a"]) // Output: 0
lmap["b"] = 1 // sucess
delete(lmap, "c") // no-op

// 使用make初始化
mmap := make(map[string]int)
fmt.Println(mmap == nil) // Output: false
fmt.Println(mmap["a"]) // Output: 0
mmap["b"] = 1 // sucess
delete(mmap, "c") // no-op

var tnilmap map[string]int
// 不会迭代
for key, value := range tnilmap{
fmt.Printf("k: %s, v: %d\n", key, value)
}
}

map小结:

1. 显示声明、未初始化值时为nil
2. 使用new函数生成的map为nil
3. nil的map只读,无法写入
4. map读取,如果没有要读取的key,则返回key对应类型的零值
5. delete时如果map为nil或者key不存在则什么也不做
5. map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。
6. 使用for...range遍历nil的slice不会迭代,也不会报错

channel

一个未被make初始化的channel是nil的,channel是通过make来初始化的,make在创建channel时底层调用了makechan函数,返回值是一个结构体指针。

func makechan(t chantype, size int64) hchan

这里不去讨论详细的channel用法,仅仅对nil的情况做一下阐述。

  1. 读写一个nil的channel会造成永远阻塞。

    1
    2
    3
    var ch chan int

    <- ch // block
    1
    2
    3
    var ch chan int

    ch <- 1 // block
  2. 关闭一个nil的channel会产生panic。

    1
    2
    3
    var ch chan int

    close(ch) // panic: close of nil channel
  3. 往一个已经关闭的channel发送数据会产生panic

    1
    2
    3
    4
    5
    ch := make(chan int)

    close(ch)

    ch <- 1 // panic: send on closed channel
  4. 从一个已关闭的channel接收数据会收到最后发送的数据或者对应类型的零值

    1
    2
    3
    4
    5
    ch := make(chan int, 1)
    ch <- 1
    close(ch)
    fmt.Println(<-ch) // Output: 1
    fmt.Println(<-ch) // Output: 0
  5. for...range语句会自动感知channel的关闭,但遇到nil会永远阻塞

    利用for...range优雅退出协程:

    1
    2
    3
    4
    5
    6
    7
    go func(in <-chan int) {
    // Using for-range to exit goroutine
    // range has the ability to detect the close/end of a channel
    for x := range in {
    fmt.Printf("Process %d\n", x)
    }
    }(inCh)

    遍历nil的通道:

    1
    2
    3
    4
    5
    var tc  chan int
    // 永远阻塞
    for v := range tc {
    fmt.Println(v)
    }

function

function和map、channel一样底层是一个指针,如果一个函数没有被初始化,那么它就是nil的

1
2
3
var myFun func(int) string

fmt.Println(myFun == nil) // Output: true

interface

interface是比较有意思的一个类型,也是go能够具有面向对象特征以及多态基石,它是一个结构体,包含了动态类型动态值

interface

对于一个接口的零值就是它的类型和值的部分都是nil, 只有都为nil的情况下接口值 == nil才成立。

nil interface

  1. 调用一个空接口值上的任意方法都会产生panic

    1
    2
    3
    4
    5
    var w io.Writer

    fmt.Println(w == nil) // Output: true

    w.Write([]byte("hello")) // panic: nil pointer dereference
  2. 一个不包含任何值的nil接口值和一个刚好包含nil指针的接口值是不同的(此时nil不是nil)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    func main() {
    var buf *bytes.Buffer
    f(buf) // NOTE: subtly incorrect!
    }

    // If out is non-nil, output will be written to it.
    func f(out io.Writer) {
    // ...do something...
    if out != nil {
    out.Write([]byte("done!\n"))
    }
    }

    上述示例,虽然我们给函数f传入了一个nil的指针(*bytes.Buffer),但是go将out的动态类型设为了*bytes.Buffer,动态值设为nil,意思就是out变量是一个包含了nil指针值的非nil接口,所以out != nil仍然为true,nil经过一道赋值的关卡后已不再是nil。

non nin interface with nil pointer

the use of nil

nil并不总是为我们制造困难,有些时候也有其妙用。

  1. nil的指针可以作为合理的方法接收者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

    type PointerReciver int

    func (a *PointerReciver) showme(){
    fmt.Printf("Yeah, it works!")
    }

    func main() {
    var ta *PointerReciver
    ta.showme() // Yeah, it works!
    }
  2. nil的slice可以正常的append

    1
    2
    3
    4
    var s []int
    for i:=0; i <10; i++ {
    s = append(s,i)
    }
  3. nil的map是只读的, 当你需要一个空map参数时可以使用nil

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func NewGet(url string, headers map[string]string) (*http.Request, error) {
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
    return nil, err
    }
    for k, v := range headers {
    req.Header.Set(k, v)
    }
    return req, nil
    }

    你可能想做如下调用,传入空的map

    1
    2
    3
    4
    req, err := NewGet(
    "http://google.com",
    map[string]string{},
    )

    你只需传入一个nil即可:

    1
    req, err := NewGet("http://google.com", nil)
  4. nil的通道永远阻塞

    有时候我们可以利用nil通道的阻塞特性,比如有如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func merge(out chan<- int, a, b <-chan int) {
    for {
    select {
    case v := <-a:
    out <- v
    case v := <-b:
    out <- v
    }
    }
    }

    这个函数不断的从通道a和b读取数据,然后写入out通道,如果a和b其中有通道关闭,根据关闭通道的特性,我们知道会从 读取到对应类型的零值,那么如何才能跳过已经close的分支呢?
    对一个nil的channel发送和接收操作会永远阻塞,在select语句中操作nil的channel永远都不会被select到。

    1
    2
    3
    4
    5
    6
    7
    case v, ok := <-a:
    if !ok {
    a = nil
    fmt.Println("a is now closed")
    continue
    }
    out <- v
  5. nil的接口

    不用多说了

    1
    2
    3
    if err != nil {
    // do somthing
    }

总结

本篇文章通过探索newmake的用法,揭示了go中初始化变量的一些规律,避免新手gopher使用过程中的困惑,对于这两个内置方法的使用时机,我个人的看法是:如果你需要初始化一个slice、map、chan类型的变量,那么优先使用make如果你需要一个指针接收器或者明确需要一个指针的时候,new会是一个不错的选择

在查阅new和make的过程中,我们遭遇了恼人的nil,本文也通过一些浅薄的分析,总结出了nil的一些规律,希望能给阅读本文的人带来一些帮助,同时也作为个人学习中的笔记可以随时翻阅,加强理解。

参考文章:

  1. The Go Programming Language Specification
  2. video:Understanding nil
  3. nils in Go
  4. Golang: Nil vs Empty Slice
  5. 深入学习golang(4)—new与make
  6. 深度解密Go语言之slice
  7. 深度解密Go语言之map
  8. 深度解密Go语言之channel
  9. 深度解密Go语言之关于interface的 10 个问题

友情提醒:本文主要介绍由《三体》引发的关于计算机的某些思考,其中略有剧透,介意的朋友慎入。

旅行者1号

大约17年前我刚刚升入高中,那时候我们学校每周只放周日下午半天假,就在一个周日的下午,我整个人都泡在学校附近的新华书店,被一本人类探索太空的书迷住了。

从书中我第一次知道了旅行者1号,那个当时已经飞了25年的人类探测器,携带着地球文明的问候在太空中不知疲倦的飞行了25年,却仍没有飞出太阳系。

我并不是一个爱读书的人,至少当时是这样,我对宇宙的所有了解也大部分来源于那个下午,而我最基本的宇宙观也正是建立于那个下午,那一次我知道了柯伊伯带奥尔特星云。在后来的人生中,我对旅行者1号也格外的亲切。

2012年8月25日,旅行者1号成为第一个穿越太阳圈并进入星际介质的宇宙飞船,它已经飞行了35年。

截至2019年8月28日止,旅行者1号正处于离太阳146.7 AU($2.19 \times 10^{10} km$)的位置,是离地球最远的人造物体,已经飞行了42年。

那个下午出了书店,很长一段时间我都精神恍惚,震撼于宇宙的浩瀚与人类的渺小。

最近,我花了一周的时间读完了刘慈欣《三体》,又一次体验到了少年时代的那种恍惚的感觉,这也是让我想起旅行者1号的缘由。

《三体》问题

在小说《三体》中,可以通过特定的方法改变一个恒星系的光速,比如将太阳系的光速降低到第三宇宙速度,也就是光速由30万km/s降为16.7km/s,书中将此时的星系称为“黒域”,形成黒域的星系光速永远被限制,科技也永远停滞,人类永远也无法脱离该星系,这让该星系看起来安全,从而可以逃避“黑暗森林”的打击。

在大刘的假设中,当光速降为原来的万分之一,这个时候原来的电子计算机根本就无法运行了,需要特殊制造的芯片才可以,那么就引出了一个具体的问题:光速降低对我们现在的电子计算机会产生哪些影响呢?

计算机的物理限制

纵观计算机的发展历史,我们总是朝着更快的处理速度,更短的响应时间,更高的性能这条追求极致的道路上发展,我们的CPU不断的追求更多的晶体管,更先进的制程,更低的功耗,更加高性能的架构设计;我们的软件也在不断的改良,架构不断的演进,从单体到分布式,从传统数据中心到云计算,从巨石应用到微服务;在这个过程中是否存在一个我们永远无法逾越的物理限制呢?就像机械硬盘无论怎么样优化都无法打破磁头旋转这一物理动作的桎梏一样。是的,这样的物理限制存在,那就是光速。

3GHz的CPU为例,频率是周期的倒数:

晶振每秒钟可以振荡30亿次,每次耗时大约为0.33纳秒。光在1纳秒的时间内,可以前进30厘米。也就是说,在CPU的一个时钟周期内,光可以前进10厘米。这意味着如果要在一个时钟周期内完成一次信号的往返,并且假设组件没有延迟并且信号真的可以以光速运行,那么这个组件就要距离CPU不能超过5厘米。

这又意味着什么?我们假设数据的传输过程中,没有门延迟,电信号以光速传播,这时所需处理的数据距离CPU越远,它的传输时延越大。你看,我们一直不断优化的也不断提高的性能,居然受到一个宇宙常数的制约。

光速降为第三宇宙速度

距离CPU越远,传输的时延越大,这个结论肯定会让你想到冯诺依曼计算机的存储金字塔:

simplified computer memory hierarchy

从塔尖到塔底,存储器件的时延由低到高,容量由小到大,如果将主频为3Ghz的CPU的一个时钟周期换算为人类习惯的1s的话,就会体会到CPU寄存器有多快,现代操作系统为什么要在I/O的时候进行上下文切换了:

Computer Action AVG Latency Human Time
3GhzCPU clock cycle 0.3ns 1s
Level 1 Cache access 0.9ns 3s
Level 2 Cache access 2.8ns 9s
Level 3 Cache access 12.9ns 43s
RAM access 70-100ns 3.5 to 5.5 min
NVMe SSD I/O 7-150μs 2 hrs to 2 days
Hard Disk I/O 1-10ms 11 days to 4 mos
Internet: SF to NYC 40ms 1.2 years
Internet: SF to Australia 183ms 6 years
OS virtualization reboot 4s 127 years
virtualization reboot 40s 1200 years
Physical system reboot 90s 3000 years

如果光速降为原来的$1 \over 20000$会发生什么?我们进行一个不严谨的换算,假设所有组件本身工作延迟为0,所有的时延都是信号的传输花费,那么延时列表如下:

Computer Action AVG Latency Human Time Lower c(third cosmic velocity)
3GhzCPU clock cycle 0.3ns 1s 6μs
Level 1 Cache access 0.9ns 3s 18μs
Level 2 Cache access 2.8ns 9s 54μs
Level 3 Cache access 12.9ns 43s 258μs
RAM access 70-100ns 3.5 to 5.5 min 1.4ms to 2ms
NVMe SSD I/O 7-150μs 2 hrs to 2 days 140ms to 3s
Hard Disk I/O 1-10ms 11 days to 4 mos 20s to 3.4 min
Internet: SF to NYC 40ms 1.2 years 14 min
Internet: SF to Australia 183ms 6 years 1 hrs
OS virtualization reboot 4s 127 years 1 days
virtualization reboot 40s 1200 years 9 days
Physical system reboot 90s 3000 years 21 days

可以看到,光速降为原来的$1 \over 20000$之后,CPU访问内存要花费1~2ms,访问最快的NVMe设备要花140ms~3s,这意味着什么?从外界事件的光线到达你的视网膜,到这个事件产生的神经脉冲到达你的大脑皮层,这之间的时间大约为100ms,如果一个交互系统延迟超过140ms才对你的操作做出反应,那么你就会感知到“卡顿”,也就是说在低光速下,你可以“看到”磁盘正在读写。

再看看网络传输,从旧金山纽约的网络延时由原来的40ms增加到了14分钟,也就是说ICMP包的往返需要14分钟,我们假设单程就是7分钟,那么一个TCP连接的建立需要三次握手,共需花费21分钟理论上一个TCP数据包发送到接到ACK至少需要14分钟,可见在现有的计算机网络设计上,光速骤降会导致计算机根本无法工作,一次硬重启就要花费21天的时间。

《三体》中描述说没有计算机能在只有十几千米每秒的光速下运行,因此关一帆所在的银河系人类设计了可以在低光速下运行的神经元计算机,里面的所有芯片都是为低光速设计的,即便是经过专门的设计,在低光速下加载操作系统也要花12天的时间!

这个脑洞足以让你惊掉下巴,虽然它谈不上严谨,也不够科学(注:这里的不严谨仅仅指我的推导过程,并非指大刘的分析逻辑,实际上大刘仅仅描述了结果,并未作详细解释),但是如果你仅仅得出光速降低会影响电子计算机这么个简单的结论,那你就太小看了大刘的脑洞。

《三体》的猜想中,光速也并非永恒不变,光速在宇宙创世之初是无限快的,不严谨的描述就是:光可以瞬间从宇宙的一端传输至另一端。不知道你想到了什么,反正我想到了量子纠缠,那么为什么现在的光速只有30万公里每秒呢?答案是黑暗森林法则。

读过《三体》的朋友都知道,高维打低维是非常轻松的一件事。宇宙最初是十维的,因为文明猜疑链黑暗森林法则,不断的有不同维度的、同维度的文明互相攻击,使得宇宙不断的从高维向低维坍缩,光速也不断在下降(太阳系属于三维,光速为$3 \times 10^8 m/s$),小说中的太阳系就遭到了降维打击,从三维变到二维,太阳系成为了一副壮丽的画卷。

脑洞到此为止,我们还有个实际的问题要搞明白,那就是寄存器为什么会比内存快?除了距离上的优势之外,还有哪些内在的因素?

为什么寄存器速度要比内存快

在冯诺依曼计算机存储层次中,寄存器最快,容量最小,其次是内存,容量居中,硬盘最慢,容量最大;硬盘我们暂且不讨论,那么同样是晶体管存储设备,为什么寄存器的速度要比内存快呢?

距离

距离是一个因素,之前已经讨论过,越远离CPU,信号传输的距离越长,而电信号的传输速度受到光速的制约;而距离对于PC的影响就要比手机大,因为手机内存距离CPU更近,手机的CPU频率也通常比桌面电脑CPU低。

成本

成本是一个比较现实的原因,从register->L1->L2->L3->DRAM容量依次增大,以苹果的A7处理器为例,寄存器共有6000比特(32个64位的通用寄存器和32个128位的浮点寄存器等等)。但是目前的手机内存普遍拥有有640亿比特(8GB),因此,为这些为数不多的寄存器花费大量的成本去提高性能也是值得的,毕竟与内存比起来它太少了。

在设计上更加能突出这一点,寄存器和Cache相对于DRAM有更多更大的晶体管和更复杂的设计,且更加昂贵和耗电,并且一直有电;而DRAM的设计就相对简单,只有一个晶体管和一个很小的电容,也只有用到时才有电,所以更加便宜和省电。因此,DRAM上的cell排列非常稠密,而寄存器和SRAM就相对稀疏。这个道理同时适用于register->L1->L2->L3->DRAM这个链条,设计由复杂趋向简单,成本由高昂趋于节省,功耗由高变低。

可以想见,高性能、高成本、高耗电的设计就可以用在寄存器上,也只有低成本、低功耗的设计才可以堆出高容量的DRAM,一个简单的SRAM就需要至少6个晶体管,加上其他的元器件就有数十个晶体管,而DRAM只有一个晶体和一个电容。下面是SRAM和DRAM的cell的电路设计对比:

static RAM celldynamic RAM cell

有人会问,如果我们不计成本,只做L1缓存,甚至做到跟内存一样大,不要内存了,会不会让CPU产生质的飞跃呢?

很遗憾,即便是现有的Cache层级下,也不是容量越大效果越好,命中率的增长到达一定程度时就趋于平缓,而如果真把L1做那么大容量,体积也会增大,就难以保证在一个时钟周期内访问到L1,那么cpu的频率就不能做那么高,这相当于自残,所以如何平衡缓存的层级和每个层级的大小一种trade-off艺术。这里有一篇文章有更详细的介绍:Cache为什么有那么多级?为什么一级比一级大?是不是Cache越大越好?

工作方式

寄存器的工作方式很简单,只有两步:

  1. 找到相关的位
  2. 读取这些位。

Cache的工作方式便又复杂了些,这里不讨论内存如何映射到Cache,也不考虑各种处理器架构的区别,只是简单大概的介绍一下Cache的运作流程,系统启动时,缓存内没有任何数据。之后,需要读取内存的数据便被以一定的大小(Cache Line)依次存入L3、L2、L1中,处理器读取指令或数据的过程如下:

  1. 将地址由高至低划分为四个部分:标签、索引、块内偏移、字节偏移。
  2. 用索引定位到相应的缓存块。
  3. 用标签尝试匹配该缓存块的对应标签值。如果存在这样的匹配,称为命中(Hit);否则称为未命中(Miss)。
  4. 如命中,用块内偏移将已定位缓存块内的特定数据段取出,送回处理器。
  5. 如未命中,先用此块地址(标签+索引)从内存读取数据并载入到当前缓存块,再用块内偏移将位于此块内的特定数据单元取出,送回处理器。

内存的工作方式就要复杂得多:

  1. 找到数据的指针。(指针可能存放在寄存器内,所以这一步就已经包括寄存器的全部工作了。)
  2. 将指针送往内存管理单元(MMU)。
  3. 由MMU将虚拟的内存地址翻译成实际的物理地址。
  4. 将物理地址送往内存控制器(memory controller)
  5. 由内存控制器找出该地址在哪一根内存插槽(bank)上。
  6. 确定数据在哪一个内存块(chunk)上,从该块读取数据。
  7. 数据先送回内存控制器
  8. 再送回CPU。
  9. 然后开始使用。

内存的工作流程比寄存器多出许多步。每一步都会产生延迟,累积起来就使得内存比寄存器慢得多。

以上就是为什么寄存器会比Cache和RAM快的大致原因,而缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。

《三体》带来的其它思考

给岁月以文明,而不是给文明以岁月

如果说读完《三体》能让我记住些什么,那就是这句给岁月以文明,而不是给文明以岁月,这句话是出自帕斯卡的:给时光以生命,而不是给生命以时光。(To the time to life, rather than to life in time)

两句话的都有相似的主旨,虽然表述的对象不同,看似略有深奥的话,其实阐明了一个很简单的道理:活在当下。不要被过去和未来迷惑,让活着的每一刻都有意义,不让生命虚度。如果时光蹉跎,那生命再长也不过是行尸走肉。

同样的道理,人类遭遇《三体》危机,全球都思考着如何延续我们的文明,而在这个过程中造成了太多的血与泪,不由得让人怀疑,文明若斯,当真还值得延续?

这不过就是王宝强老师的那句:有意义的事儿就是好好活,好好活就是做有意义的事儿。

所有那些有意义的事儿串联起来,就是人生。

是的,既往不恋,当下不杂,未来不迎

参考文章:

  1. Why Registers Are Fast and RAM Is Slow
  2. Compute Performance – Distance of Data as a Measure of Latency
  3. Difference Between SRAM and DRAM
  4. 为什么寄存器比内存快?
  5. 为什么主流CPU的频率止步于4G?
  6. wiki:CPU缓存
  7. Cache为什么有那么多级?
  8. Cache是怎么组织和工作的?
  9. L1,L2,L3 Cache究竟在哪里?
  10. 为什么程序员需要关心顺序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence?)

当程序运行时,函数的局部变量是在线程的栈上进行分配,虽然线程共享进程的虚拟地址空间,但因为每个线程有自己的线程栈,所以栈中的数据是互相隔离的,互不侵扰;而全局变量在heap上进行分配,heap在各个线程间是共享的,所以在对共享的资源进行读写时,需要有同步机制来确保线程安全;然而有一种多线程下的编程方式,可以使得全局变量或静态变量只对单个线程可见,而对其它线程不可见,这就是Thread Local Storage,又叫线程本地存储线程局部存储

Thread Local Storage

维基百科上对Thread Local Storage的解释如下:

Thread-local storage (TLS) is a computer programming method that uses static or global memory local to a thread.

翻译下来就是:线程本地存储(TLS),对于线程来讲是一种对本地化使用静态或全局内存的计算机编程方法。

线程局部存储(TLS)是一个后来者, 产生于多线程概念之后.而在软件发展的早期, 全局变量经常用在库函数中, 用于存储全局信息, 比如errno, 多线程程序产生之后, 全局变量errno就成为所有线程都共享的一个变量, 而实际上, 每个线程都想维护一份自己的errno, 隔离于其他线程.这个时候, 没人愿意去修改库函数的接口. 于是线程局部存储就诞生了, 它主要是为了避免多个线程同时访存同一全局变量或者静态变量时所导致的冲突,尤其是多个线程同时需要修改这一变量时,而这些变量逻辑上又可以在各个线程中独立,也就是说线程并不共享这些变量。

为了解决这个问题,我们可以通过TLS机制,为每一个使用该全局变量的线程都提供一个变量值的副本,每一个线程均可以独立地改变自己的副本,而不会和其它线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量。而从全局变量的角度上来看,就好像一个全局变量被克隆成了多份副本,而每一份副本都可以被一个线程独立地改变。

TLS简单使用

C语言实现

编写如下一段程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#define _MULTI_THREADED

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

__thread int TLS_data1;
__thread int TLS_data2;

//int TLS_data1;
//int TLS_data2;

#define NUMTHREADS 4


void *theThread(void *a) {
int arg = *(int *)a;
printf("Thread %lu before change: arg:%d TLS data=%d %d\n",
pthread_self(), arg, TLS_data1, TLS_data2);
TLS_data1 = arg;
TLS_data2 = arg +1;
printf("Thread %lu after change: arg:%d TLS data=%d %d\n",
pthread_self(), arg, TLS_data1, TLS_data2);
return NULL;
}


int main(int argc, char **argv) {
pthread_t thread[NUMTHREADS];
int rc = 0;
int i;

int ar[NUMTHREADS];

printf("Enter Testcase - %s\n", argv[0]);

printf("Create/start threads\n");
for (i = 0; i < NUMTHREADS; i++) {
/* Create per-thread TLS data and pass it to the thread */
ar[i] = i;
rc = pthread_create(&thread[i], NULL, theThread, &ar[i]);
}

printf("Wait for the threads to complete, and release their resources\n");
for (i = 0; i < NUMTHREADS; i++) {
rc = pthread_join(thread[i], NULL);
}
printf("Main completed\n");
return 0;
}

该段程序使用__thread声明了两个变量TLS_data1TLS_data2为线程局部存储,然后分别在4个线程中修改他们的值,观察运行结果:

1
2
3
4
5
6
7
8
9
10
11
Create/start threads
Thread 139919563978496 before change: arg:0 TLS data=0 0
Thread 139919563978496 after change: arg:0 TLS data=0 1
Thread 139919547193088 before change: arg:2 TLS data=0 0
Thread 139919547193088 after change: arg:2 TLS data=2 3
Wait for the threads to complete, and release their resources
Thread 139919538800384 before change: arg:3 TLS data=0 0
Thread 139919538800384 after change: arg:3 TLS data=3 4
Thread 139919555585792 before change: arg:1 TLS data=0 0
Thread 139919555585792 after change: arg:1 TLS data=1 2
Main completed

可以看到每个线程可以从容的修改他们。并且相互之间没有造成干扰,那么我们去掉__thread而使用普通的全局变量的话,就会使这段程序变得线程不安全:

1
2
3
4
5
6
7
8
9
10
11
Create/start threads
Thread 140450580477696 before change: arg:0 TLS data=0 0
Thread 140450580477696 after change: arg:0 TLS data=0 1
Thread 140450572084992 before change: arg:1 TLS data=0 1
Thread 140450572084992 after change: arg:1 TLS data=1 2
Thread 140450563692288 before change: arg:2 TLS data=1 2
Thread 140450563692288 after change: arg:2 TLS data=2 3
Wait for the threads to complete, and release their resources
Thread 140450555299584 before change: arg:3 TLS data=2 3
Thread 140450555299584 after change: arg:3 TLS data=3 4
Main completed

可以试着删除__thread关键字,再编译运行观察,你会看到一个错乱的运行结果。

python实现

把上面的例子用python来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import threading

local = threading.local()
local.TLS_data1 = 0
local.TLS_data2 = 0


def func(info):
myname = threading.currentThread().getName()
local.TLS_data1 = info
local.TLS_data2 = info + 1
print('Thread {0} after change TLS data: {1}, {2}'.format(myname, local.TLS_data1, local.TLS_data2))


t = [0, 1, 2, 3]
for i in t:
t[i] = threading.Thread(target=func, args=[i])
t[i].start()


for i, v in enumerate(t):
v.join()


print('Thread {0} TLS data: {1}, {2}'.format("main", local.TLS_data1, local.TLS_data2))

执行结果:

1
2
3
4
5
Thread Thread-1 after change TLS data: 0, 1
Thread Thread-2 after change TLS data: 1, 2
Thread Thread-3 after change TLS data: 2, 3
Thread Thread-4 after change TLS data: 3, 4
Thread main TLS data: 0, 0

TLS的误区

网上有很多文章误将TLS当成是编写线程安全代码的银弹,其实哪里是这样,这都取决于global variable在你的线程之间是不是shared,如果你的本意就是共享,那么TLS反而使你南辕北辙,你仍然需要mutex之类的锁去同步你的操作,那么TLS的本质到底是什么?

我认为TLS的本质就是填补了全局变量和局部变量之间的空白,它不像全局变量那样在多个线程中可见,也不像局部变量那样仅仅生存在在函数的作用域之内,它的可见度,大于局部变量,又小于全局变量。

TLS适用场景

综上所述,线程本地存储并不是解决多线程变量共享的并发问题,而是限制变量仅在当前线程中可见,可想而知这样带来的好处之一就是线程内各个方法之间不用再通过传参就可以共享变量;另外一个可想而知的使用场景就是可以实现每个线程需要单独拥有一个实例的情况。

还有一个也是wiki上提到的,就是对一个global variable进行累加的情况,为了避免race condition的传统做法是使用mutex,但也可以使用TLS先在每个线程本地累加,然后再讲每个线程的累加结果同步到一个真正的global variable之上。

当然,我认为TLS的适用场景肯定远不止这些,只是我个人平时工作当中,编码并不是很多,其中多线程编程便又少了一些,而在多线程编程中适用TLS的情况更是为零,本篇文章仅当做学习过程中的记录,以后有更深层次的思考会随时补充,也希望大家可以共同探讨。

参考文章:

  1. Thread-local storage
  2. 线程局部存储漫谈
  3. A Deep dive into (implicit) Thread Local Storage

Docker的Daemon程序绑定到socket文件上(/var/run/docker.sock),而不是tcp端口.因此,默认情况下这个socket文件只能被root用户或者拥有sudo权限的用户访问. Docker daemon总是以root用户运行。

如果你不想总是在docker命令的前边加上sudo,那么可以创建一个名为docker的group,并且将你的用户加入到该组,那么docker daemon启动的时候会创建一个docker组成员可以访问的socket,例如:

1
2
ll /var/run/docker.sock 
srw-rw---- 1 root docker 0 Sep 24 11:24 /var/run/docker.sock

To create the docker group and add your user:

  1. Create the docker group.

    1
    $ sudo groupadd docker
  2. Add your user to the docker group.

    1
    $ sudo usermod -aG docker $USER
  3. Log out and log back in so that your group membership is re-evaluated.
    If testing on a virtual machine, it may be necessary to restart the virtual machine for changes to take effect.
    On a desktop Linux environment such as X Windows, log out of your session completely and then log back in.
    On Linux, you can also run the following command to activate the changes to groups:

    1
    $ newgrp docker

前几天go1.13发布,modules默认开启,从此modules转正成为golang官方原生的包依赖管理方式;除了modules,go1.13中还增加了新的语法,如二进制、八进制、十六进制字面量表示法,defer性能的增强,新的errors等等,社区已有很多相关特性的论述文章;此文仅简单讨论一下go1.13中modules的一些改变,毕竟包的管理跟我们日常开发是息息相关的,行文仓促,若有不当之处,希望读者斧正。

本文仅介绍modules在1.11/1.12/1.13等版本中的变化,并不介绍modules的使用,如需了解modules的详细使用方法,请参考官方文档或其他社区文章

Go Moudles

modules 是 go1.11 推出的特性,官方称是 GOPATH 的替代品,是一个完整的支持包分发和版本控制的工具,使用modules,工作区不再局限于GOPATH之内,从而使构建更加可靠和可重复,但modules在go1.11版本中仅仅是一个实验性的功能,紧接着在go1.12中得到了增强,而刚刚发布的go1.13中得到了转正,GOPATH的作用进一步被弱化,Go Moudles开始大规模使用。

两个模式

对于 modules 这种模式官网有一个称呼是Module-aware,我不知道如何去翻译这个组合词,与之相对的,就是在Module-aware mode之前我们使用的包管理方式称为GOPATH mode,他们的区别如下:

  • GOPATH mode: go command从vendorGOPATH下寻找依赖,依赖会被下载至GOPATH/src

  • Module-aware mode: go command不再考虑GOPATH,仅仅使用GOPATH/pkg/mod存储下载的依赖,并且是多版本并存

注意:Module-aware开启和关闭的情况下,go get 的使用方式不是完全相同的。在 modules 模式开启的情况下,可以通过在 package 后面添加 @version 来表明要升级(降级)到某个版本。如果没有指明 version 的情况下,则默认先下载打了 tag 的 release 版本,比如 v0.4.5 或者 v1.2.3;如果没有 release 版本,则下载最新的 pre release 版本,比如 v0.0.1-pre1。如果还没有则下载最新的 commit。这个地方给我们的一个启示是如果我们不按规范的方式来命名我们的 package 的 tag,则 modules 是无法管理的。version 的格式为 v(major).(minor).(patch) ,

在 modules 开启的模式下,go get 还支持 version 模糊查询,比如 > v1.0.0 表示大于 v1.0.0 的可使用版本;< v1.12.0 表示小于 v1.12.0 版本下最近可用的版本。version 的比较规则按照 version 的各个字段来展开。

除了指定版本,我们还可以使用如下命名使用最近的可行的版本:

  • go get -u 使用最新的 minor 或者 patch 版本

  • go get -u=patch 使用最新的 patch 版本

GO111MODULE

在 1.12 版本之前,使用 Go modules 之前需要环境变量 GO111MODULE:

  • GO111MODULE=off: 不使用 Module-aware mode。

  • GO111MODULE=on: 使用 Module-aware mode,不会去 GOPATH 下面查找依赖包。

  • GO111MODULE=auto或unset: Golang 自己检测是不是使用Module-aware mode。

go1.11时GO111MODULE=on有一个很不好的体验,就是go command依赖go.mod文件,也就是如果在module文件夹外使用go get等命令会报如下错误:

1
2
3
4
5
[root@k8s-node1 ~]# go get github.com/google/go-cmp
go: cannot find main module; see 'go help modules'
[root@k8s-node1 ~]# touch go.mod
[root@k8s-node1 ~]# go get github.com/google/go-cmp
go: cannot determine module path for source directory /root (outside GOPATH, no import comments)

这个情况在go1.12中得到了解决,可以在module directory之外使用go command。

但我个人比较喜欢不去设置GO111MODULE,根据官方描述在不设置GO111MODULE的情况下或者设为auto的时候,如果在当前目录或者父目录中有go.mod文件,那么就使用Module-aware mode, 而go1.12中,如果包位于GOPATH/src下,且GO111MODULE=auto, 即使有go.mod的存在,go仍然使用GOPATH mode:

1
2
3
4
[root@VM_0_6_centos test]# go get github.com/jlaffaye/ftp        
go get: warning: modules disabled by GO111MODULE=auto in GOPATH/src;
ignoring go.mod;
see 'go help modules'

这个现象在go1.13中又发生了改变.

modules in go1.13

GO111MODULE

The GO111MODULE environment variable continues to default to auto, but the auto setting now activates the module-aware mode of the go command whenever the current working directory contains, or is below a directory containing, a go.mod file — even if the current directory is within GOPATH/src.

Go 1.13 includes support for Go modules. Module-aware mode is active by default whenever a go.mod file is found in, or in a parent of, the current directory.

可见,modules 在 Go 1.13 的版本下是默认开启的,GOPATH的地位进一步被弱化。

GOPROXY

GOPROXY环境变量是伴随着modules而生的,在go1.13中得到了增强,可以设置为逗号分隔的url列表来指定多个代理,其默认值为https://proxy.golang.org,direct,也就是说google为我们维护了一个代理服务器,但是因为墙的存在,这个默认设置对中国的gopher并无卵用,应第一时间修改。

go命令在需要下载库包的时候将逐个试用设置中的各个代理,直到发现一个可用的为止。direct表示直连,所有direct之后的proxy都不会被使用,一个设置例子:

1
GOPROXY=https://proxy.golang.org,https://myproxy.mysite:8888,direct

GOPROXY环境变量可以帮助我们下载墙外的第三方库包,比较知名的中国区代理goproxy.cn。当然,通过设置https_proxy环境变量设也可以达到此目的。但是一个公司通过在内部架设一个自己的goproxy服务器来缓存第三方库包,库包下载速度会更快,可以感觉到module有一点maven的意思了,但是易用性上还有很长的路要走。

GOPRIVATE

使用GOPROXY可以获取公共的包,这些包在获取的时候会去https://sum.golang.org进行校验,这对中国的gopher来说又是一个比较坑的地方,Go为了安全性推出了Go checksum database(sumdb),环境变量为GOSUMDB,go命令将在必要的时候连接此服务来检查下载的第三方依赖包的哈希是否和sumdb的记录相匹配。很遗憾,在中国也被墙了,可以选择设置为一个第三方的校验库,也可更直接点将GOSUMDB设为off关闭哈希校验,当然就不是很安全了。

除了public的包,在现实开发中我们更多的是使用很多private的包,因此就不适合走代理,所以go1.13推出了一个新的环境变量GOPRIVATE,它的值是一个以逗号分隔的列表,支持正则(正则语法遵守 Golang 的 包 path.Match)。在GOPRIVATE中设置的包不走proxy和checksum database,例如:

1
GOPRIVATE=*.corp.example.com,rsc.io/private

GONOSUMDB 和 GONOPROXY

这两个环境变量根据字面意思就能明白是设置不进行校验和不走代理的包,设置方法也是以逗号分隔

go env -w

可能是go也觉得环境变量有点多了,干脆为go env增加了一个选项-w,来设置全局环境变量,在Linux系统上我们可以这样用:

1
2
3
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOPRIVATE=*.gitlab.com,*.gitee.com
go env -w GOSUMDB=off

总结

1.13中包管理的改变来看,有些乏善可陈,go的包管理很难让大多数开发者满意,当我看到越来越多的环境变量时,心里忍不住唾弃,使用这么多环境变量是一个多么蠢的方法,希望未来Go能给大家带来更好的包管理方式吧,就像Java的maven那样。

参考文献:

  1. Go 1.13 Release Notes
  2. Go 1.12 Release Notes
  3. Go 1.11 Release Notes
  4. Go Modules 不完全教程

%0A的问题

在使用Go语言的net/url包进行编码组装url的时候,遇到如下报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
2019-07-31 16:55:46.850 ERROR   executor/driver_rollback.go:41  encounter an error:bad response code: 404
github.com/glory-cd/agent/executor.(*HttpFileHandler).Get
/home/liupeng/cdp/src/agent/executor/file_http.go:115
github.com/glory-cd/agent/executor.(*Client).Get
/home/liupeng/cdp/src/agent/executor/client.go:66
github.com/glory-cd/agent/executor.Get
/home/liupeng/cdp/src/agent/executor/filehandler.go:33
github.com/glory-cd/agent/executor.(*Roll).getCode
/home/liupeng/cdp/src/agent/executor/driver_rollback.go:77
github.com/glory-cd/agent/executor.(*Roll).Exec
/home/liupeng/cdp/src/agent/executor/driver_rollback.go:52
runtime.goexit
/usr/lib/go/src/runtime/asm_amd64.s:1337
the kv is: {"url":"http://admin:xxxx@192.168.1.75:32749/test/1.0.0/Gateway.zip%0A"}

可见,最终组装成的url末尾多了%0A,从而导致http请求返回404,那么%0A是怎么来的呢? 那就回溯一下url的组装过程吧。

我用来组装url的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//创建url.URL
func (hu *HttpFileHandler) newPostUrl() string {
requestURL := new(url.URL)

requestURL.Scheme = "http"

requestURL.User = url.UserPassword(hu.client.User, hu.client.Pass)

requestURL.Host = hu.client.Addr

requestURL.Path += hu.client.RelativePath

return requestURL.String()
}

其中Path部分是使用hu.client.RelativePath进行拼接的,RelativePathient的来源如下:

1
2
3
4
5
6
7
8
9
func (d *driver) readServiceVerion() (string, error) {
versionFile := filepath.Join(d.Dir, common.PathFile)
path, err := ioutil.ReadFile(versionFile)
if err != nil {
return "", errors.WithStack(err)
}

return string(path), nil
}

发现RelativePathient是从文件中读取的,我这个文件中只有一行内容,那么再结合url encode来看,这个%0A就是一个linefeed,是一个换行符,那么处理方法就简单了,返回的时候Trim一下就可以解决换行和空格的问题。

1
return strings.TrimSpace(string(path)), nil

接下来再简单聊一下url encode

url encode


百分号编码(英语:Percent-encoding,又称:URL编码(英语:URL encoding)),是特定上下文的统一资源定位符 (URL)的编码机制. 实际上也适用于统一资源标志符(URI)的编码。也用于为application/x-www-form-urlencodedMIME准备数据,因为它用于通过HTTP的请求操作(request)提交HTML表单数据。


上面是维基百科对url编码的解释,通常如果一样东西需要编码,说明这样东西并不适合传输。原因多种多样,如Size过大,包含隐私数据,对于Url来说,之所以要进行编码,是因为Url中有些字符会引起歧义。

例如,Url参数字符串中使用key=value键值对这样的形式来传参,键值对之间以&符号分隔,如/s?q=abc& ie=utf-8。如果你的value字符串中包含了=或者&,那么势必会造成接收Url的服务器解析错误,因此必须将引起歧义的&和= 符号进行转义,也就是对其进行编码。

又如,Url的编码格式采用的是ASCII码,而不是Unicode,这也就是说你不能在Url中包含任何非ASCII字符,例如中文。否则如果客户端浏览器和服务端浏览器支持的字符集不同的情况下,中文可能会造成问题。

Url编码的原则就是使用安全的字符(没有特殊用途或者特殊意义的可打印字符)去表示那些不安全的字符。

语法构成

URI是统一资源标识的意思,通常我们所说的URL只是URI的一种。典型URL的格式如下所示。下面提到的URL编码,实际上应该指的是URI编码。

rfc3986中解释URI的构成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
   The generic URI syntax consists of a hierarchical sequence of
components referred to as the scheme, authority, path, query, and
fragment.

URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ]

hier-part = "//" authority path-abempty
/ path-absolute
/ path-rootless
/ path-empty

The following are two example URIs and their component parts:

foo://example.com:8042/over/there?name=ferret#nose
\_/ \______________/\_________/ \_________/ \__/
| | | | |
scheme authority path query fragment
| _____________________|__
/ \ / \
urn:example:animal:ferret:nose

哪些字符需要编码

RFC3986文档规定,Url中只允许包含英文字母(a-zA-Z)、数字(0-9)、-_.~4个特殊字符以及所有保留字符。 RFC3986文档对Url的编解码问题做出了详细的建议,指出了哪些字符需要被编码才不会引起Url语义的转变,以及对为什么这些字符需要编码做出了相 应的解释。

US-ASCII字符集中没有对应的可打印字符:Url中只允许使用可打印字符。US-ASCII码中的10-7F字节全都表示控制字符,这些 字符都不能直接出现在Url中。同时,对于80-FF字节(ISO-8859-1),由于已经超出了US-ACII定义的字节范围,因此也不可以放在 Url中。

保留字符:Url可以划分成若干个组件,协议、主机、路径等。有一些字符(:/?#[]@)是用作分隔不同组件的。例如:冒号用于分隔协议和主 机,/用于分隔主机和路径,?用于分隔路径和查询参数,等等。还有一些字符(!$&'()*+,;=)用于在每个组件中起到分隔作用的,如=用于 表示查询参数中的键值对,&符号用于分隔查询多个键值对。当组件中的普通数据包含这些特殊字符时,需要对其进行编码。

RFC3986中指定了以下字符为保留字符:! * ' ( ) ; : @ & = + $ , / ? # [ ]

不安全字符:还有一些字符,当他们直接放在Url中的时候,可能会引起解析程序的歧义。这些字符被视为不安全字符,原因有很多。

  • 空格:Url在传输的过程,或者用户在排版的过程,或者文本处理程序在处理Url的过程,都有可能引入无关紧要的空格,或者将那些有意义的空格给去掉。
  • 引号以及<>:引号和尖括号通常用于在普通文本中起到分隔Url的作用
  • :通常用于表示书签或者锚点

  • %:百分号本身用作对不安全字符进行编码时使用的特殊字符,因此本身需要编码
  • {}|\^[]`~:某一些网关或者传输代理会篡改这些字符

需要注意的是,对于Url中的合法字符,编码和不编码是等价的,但是对于上面提到的这些字符,如果不经过编码,那么它们有可能会造成Url语义 的不同。因此对于Url而言,只有普通英文字符和数字,特殊字符$-_.+!*'()还有保留字符,才能出现在未经编码的Url之中。其他字符均需要经过 编码之后才能出现在Url中。


如何对url进行编码

Url编码通常也被称为百分号编码(Url Encoding,also known as percent-encoding),是因为它的编码方式非常简单,使用%百分号加上两位的字符——0123456789ABCDEF——代表一个字节的 十六进制形式。Url编码默认使用的字符集是US-ASCII。例如a在US-ASCII码中对应的字节是0x61,那么Url编码之后得到的就 是%61,我们在地址栏上输入http://g.cn/search?q=%61%62%63,实际上就等同于在google上搜索abc了。又如@符号 在ASCII字符集中对应的字节为0x40,经过Url编码之后得到的是%40。

对于非ASCII字符,需要使用ASCII字符集的超集进行编码得到相应的字节,然后对每个字节执行百分号编码。对于Unicode字 符,RFC文档建议使用utf-8对其进行编码得到相应的字节,然后对每个字节执行百分号编码。如"中文"使用UTF-8字符集得到的字节为0xE4 0xB8 0xAD 0xE6 0x96 0x87,经过Url编码之后得到"%E4%B8%AD%E6%96%87"。

如果某个字节对应着ASCII字符集中的某个非保留字符,则此字节无需使用百分号表示。例如"Url编码",使用UTF-8编码得到的字节是 0x55 0x72 0x6C 0xE7 0xBC 0x96 0xE7 0xA0 0x81,由于前三个字节对应着ASCII中的非保留字符"Url",因此这三个字节可以用非保留字符"Url"表示。最终的Url编码可以简化 成"Url%E7%BC%96%E7%A0%81" ,当然,如果你用"%55%72%6C%E7%BC%96%E7%A0%81"也是可以的。

使用vim去除EOL

还可以使用vim去除文件末尾的换行

1
2
3
4
5
6
You can turn off the 'eol' option and turn on the 'binary' option to write
a file without the EOL at the end of the file:

:set binary
:set noeol
:w

什么是inode

理解inode,要从文件储存说起。

文件储存在硬盘上,硬盘的最小存储单位叫做"扇区"(Sector)。每个扇区储存512字节(相当于0.5KB)。

操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个"块"(block)。这种由多个扇区组成的"块",是文件存取的最小单位。"块"的大小,最常见的是4KB,即连续八个 sector组成一个 block。

文件数据都储存在"块"中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做inode,中文译名为"索引节点"。

每一个文件都有对应的inode,里面包含了与该文件有关的一些信息。

inode的内容

inode包含文件的元信息,具体来说有以下内容:

  • 文件的字节数
  • 文件拥有者的User ID
  • 文件的Group ID
  • 文件的读、写、执行权限
  • 文件的时间戳
  • 链接数,即有多少文件名指向这个inode
  • 文件数据block的位置

使用stat命令查看某个文件的inode信息

1
2
3
4
5
6
7
8
9
 $ stat proxy.sh 
File: proxy.sh
Size: 89 Blocks: 8 IO Block: 4096 regular file
Device: 802h/2050d Inode: 9714171 Links: 1
Access: (0755/-rwxr-xr-x) Uid: ( 1000/ liupeng) Gid: ( 1000/ liupeng)
Access: 2019-07-29 10:25:57.880429139 +0800
Modify: 2019-07-29 10:25:57.880429139 +0800
Change: 2019-07-29 10:26:05.910683677 +0800
Birth: 2019-07-29 10:25:57.880429139 +0800

inode的大小

inode也会消耗硬盘空间,所以硬盘格式化的时候,操作系统自动将硬盘分成两个区域。一个是数据区,存放文件数据;另一个是inode区(inode table),存放inode所包含的信息。

每个inode节点的大小,一般是128字节或256字节,甚至可以手动指定到2K,inode节点的总数,在格式化时就给定,以ext3/ext4为例:

  • 每个 inode 大小为 256byte,block 大小为 4k byte;
  • 根据 block count 和 inode count,我们也可以算出 16k bytes-per-inode(15728384*4096/3932160)

也就是文件系统在创建的时候每16k空间自动划分一个inode,如果你需要存储的是大量的小文件,那么你应该在格式化分区的时候手动修改bytes-per-inode的值,例如:

1
mkfs.ext4 -i 8192 /dev/sda1

而在xfs文件系统中-i inode_options中的maxpct=value描述如下:

This specifies the maximum percentage of space in the filesystem that can be allocated to inodes. The default value is 25% for filesystems under 1TB, 5% for filesystems under 50TB and 1% for filesystems over 50TB.

可见默认情况下xfs文件系统要比ext文件系统分配更多的inode

可以使用df -i查看inode的大小和使用率

1
2
3
4
5
6
7
8
9
10
$ df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
dev 1015658 414 1015244 1% /dev
run 1017797 680 1017117 1% /run
/dev/sda2 14057472 1003259 13054213 8% /
tmpfs 1017797 129 1017668 1% /dev/shm
tmpfs 1017797 18 1017779 1% /sys/fs/cgroup
tmpfs 1017797 629 1017168 1% /tmp
/dev/sda1 0 0 0 - /boot/efi
tmpfs 1017797 37 1017760 1% /run/user/1000

inode号

每个inode都有一个号码,操作系统用inode号码来识别不同的文件。

这里值得重复一遍,Unix/Linux系统内部不使用文件名,而使用inode号码来识别文件。对于系统来说,文件名只是inode号码便于识别的别称或者绰号。

表面上,用户通过文件名,打开文件。实际上,系统内部这个过程分成三步:首先,系统找到这个文件名对应的inode号码;其次,通过inode号码,获取inode信息;最后,根据inode信息,找到文件数据所在的block,读出数据。

使用ls -i命令,可以看到文件名对应的inode号码:

1
2
$ ls -i proxy.sh 
9714171 proxy.sh

目录文件

Unix/Linux系统中,目录(directory)也是一种文件。打开目录,实际上就是打开目录文件。

目录文件的结构非常简单,就是一系列目录项(dirent)的列表。每个目录项,由两部分组成:所包含文件的文件名,以及该文件名对应的inode号码。

查看目录的inode

通过介绍我们知道,通常情况下每个文件对应一个inode,那么如果想查找某个目录使用的inode数量,则可以使用如下命令:

1
clear;echo "Detailed Inode usage: $(pwd)" ; for d in `find -maxdepth 1 -type d |cut -d\/ -f2 |grep -xv . |sort`; do c=$(find $d |wc -l) ; printf "$c\t\t- $d\n" ; done ; printf "Total: \t\t$(find $(pwd) | wc -l)\n"

输出如下:

1
2
3
4
5
6
7
8
Detailed Inode usage: /home/liupeng/liupzmin.github.io
312 - .git
8049 - node_modules
294 - public
4 - scaffolds
45 - source
409 - themes
Total: 9120

要清理inode,只要找到包含大量文件的目录删除之即可。

参考文献:

  1. 理解inode
  2. How to find the INODE usage on Linux

Golang提供了几个包可以将文件压缩为不同的类型,这篇博客主要展示一下archive/zip这个包的用法,如何将文件或文件夹压缩为zip格式,以及如何进行解压缩。

Compressing

usage

1
2
zipit("/tmp/documents", "/tmp/backup.zip", "*.log")
zipit("/tmp/report.txt", "/tmp/report-2015.zip", "*.log")

func Zipit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//压缩为zip格式
//source为要压缩的文件或文件夹, 绝对路径和相对路径都可以
//target是目标文件
//filter是过滤正则(Golang 的 包 path.Match)
func Zipit(source, target, filter string) error {
var err error
if isAbs := filepath.IsAbs(source); !isAbs {
source, err = filepath.Abs(source) // 将传入路径直接转化为绝对路径
if err != nil {
return errors.WithStack(err)
}
}
//创建zip包文件
zipfile, err := os.Create(target)
if err != nil {
return errors.WithStack(err)
}

defer func() {
if err := zipfile.Close(); err != nil{
log.Slogger.Errorf("*File close error: %s, file: %s", err.Error(), zipfile.Name())
}
}()

//创建zip.Writer
zw := zip.NewWriter(zipfile)

defer func() {
if err := zw.Close(); err != nil{
log.Slogger.Errorf("zipwriter close error: %s", err.Error())
}
}()

info, err := os.Stat(source)
if err != nil {
return errors.WithStack(err)
}

var baseDir string
if info.IsDir() {
baseDir = filepath.Base(source)
}

err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {

if err != nil {
return errors.WithStack(err)
}

//将遍历到的路径与pattern进行匹配
ism, err := filepath.Match(filter, info.Name())

if err != nil {
return errors.WithStack(err)
}
//如果匹配就忽略
if ism {
return nil
}
//创建文件头
header, err := zip.FileInfoHeader(info)
if err != nil {
return errors.WithStack(err)
}

if baseDir != "" {
header.Name = filepath.Join(baseDir, strings.TrimPrefix(path, source))
}

if info.IsDir() {
header.Name += "/"
} else {
header.Method = zip.Deflate
}
//写入文件头信息
writer, err := zw.CreateHeader(header)
if err != nil {
return errors.WithStack(err)
}

if info.IsDir() {
return nil
}
//写入文件内容
file, err := os.Open(path)
if err != nil {
return errors.WithStack(err)
}

defer func() {
if err := file.Close(); err != nil{
log.Slogger.Errorf("*File close error: %s, file: %s", err.Error(), file.Name())
}
}()
_, err = io.Copy(writer, file)

return errors.WithStack(err)
})

if err != nil {
return errors.WithStack(err)
}

return nil
}

Extracting

usage

1
unzip("/tmp/report-2015.zip", "/tmp/reports/")

func Unzip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//解压zip
func Unzip(archive, target string) error {
reader, err := zip.OpenReader(archive)
if err != nil {
return errors.WithStack(err)
}

if err := os.MkdirAll(target, 0755); err != nil {
return errors.WithStack(err)
}

for _, file := range reader.File {
unzippath := filepath.Join(target, file.Name)
if file.FileInfo().IsDir() {
err := os.MkdirAll(unzippath, file.Mode())
if err != nil {
return errors.WithStack(err)
}
continue
}

fileReader, err := file.Open()
if err != nil {
return errors.WithStack(err)
}
defer fileReader.Close()

targetFile, err := os.OpenFile(unzippath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
if err != nil {
return errors.WithStack(err)
}
defer targetFile.Close()

if _, err := io.Copy(targetFile, fileReader); err != nil {
return errors.WithStack(err)
}
}

return nil
}

参考文献:

  1. Golang: Working with ZIP archives
0%