我是一个 Python 程序员,也对 Lua,Go,Rust 感兴趣。目前在蚂蚁金服工作。

我的日常工作离不开 Vim 和 Tmux,当然还有 Git。平时喜欢在看 Github 上有趣的项目,学习新的东西,看书,写博客等。博客的内容一般是我的人生感悟,观后感读后感,以及技术方面的内容。

我的梦想是让网络变得更加开放、自由和快速。

声明:本博客内容仅代表本人观点,和我的雇主无关。本站所有内容未加说明均为原创,一经发布自动进入公共领域,本人放弃所有权利。

欧格玛教会与言论自由

我喜欢欧格玛教会,欧格玛教会认为知识是至高无上的,尤其是最原始的知识,最纯粹的理念。理念没有重量,但是可以移山。

欧格玛教会信奉没有任何知识是应该被销毁的,知识本身是没有问题的,可怕的是使用它的人。即使是最疯狂、最错误的想法,藏匿起来也无济于事,应该交给大众来判断。所以欧格玛教会神殿内经常会藏有被其他教会认为是禁忌的知识,比如死灵术。

欧格玛信仰如下:

Knowledge, particularly the raw knowledge of ideas, is supreme. An idea has no weight, but it can move mountains. The greatest gift of humankind, an idea outweighs anything made by mortal hands. Knowledge is power and must be used with care, but hiding it away from others is never a good thing. Stifle no new ideas, no matter how false and crazed they seem; rather, let them be heard and considered. Never slay a singer, nor stand by as others do so. Spread knowledge wherever it is prudent to do so. Curb and deny falsehoods, rumor, and deceitful tales whenever you encounter them. Write or copy lore of great value at least once a year and give it away. Sponsor and teach bards, scribes, and record keepers. Spread truth and knowledge so that all folk know more. Never deliver a message falsely or incompletely. Teach reading and writing to those who ask (if your time permits), and charge no fee for the teaching.

我在我的博客上写着:我的梦想是让网络变得更加开放、自由和快速。

对于网络,我的想法也是所有的内容不都应该被删除、藏匿。应该交由大众去判断。这是我理解的言论自由。但是最近发生了两件事,让我开始怀疑自己的想法。

第一个是 Cloudflare 停止了对 8chan 的服务

CloudFlare 是我非常崇拜的一家公司,我一度认为互联网服务就应该是 CloudFlare 提供的那样。但是有证据证明,一起枪击事件的犯罪嫌疑人从 8chan 上受到了鼓动,从而导致了这起枪击事件。8chan 创建的本意是 4chan 依然会删除部分内容,所以 8chan 想创造一个不会删除内容的 4chan,最终却成为了仇恨的温床。

第二个是Twitter关闭了一批来自大陆的账号

这么做的理由是 Twitter 认为,这部分账号进行了受组织协调的行动。

那么究竟,我们需要怎么样的言论自由呢?

 

iRedis 开发记录:Redis 命令语法的处理

我最近在写一个命令行应用程序,一个支持自动补全、命令校验、语法高亮的 redis-cli 。项目的地址:

https://github.com/laixintao/iredis

写这个插件是想达到 mycli/pgcli 的那种效果,让 redis-cli 用起来非常顺手,本质上,这又是一个满足我自己个人需求的项目。但是我觉得应该很多人会喜欢这个。

开发的过程中遇到了很多有意思的问题,一直想分享一下,但是最近太忙了,白天工作比较忙。晚上我基本都花在写这个项目的代码上,没有时间系统的写一下。今天这篇文章批评了我这里面写过的一段正则,所以今天借这个机会,就写一下吧。

这个正则在项目的代码库里面:代码地址

为什么用这么大一个正则表达式呢?

完成语法校验、高亮,基于语法的补全,肯定要实现一个 lexer。这个 Lexer 是写一个状态机,还是用一个正则去匹配语法,基本上是每天都在想的问题,也每天在想用正则是不是对的,现在看来,遇到了很多问题,但是多都解决了。

对于这个 lexer 我一开始是写的 Pygments lexer,见这个commit 。但是我后来选择了用一个正则表达式来处理所有的 Redis 命令:任何能match这个正则的字符串就是一个合法的 Redis 命令。为什么呢?因为 Redis Grammar 基本不能叫做一个 Grammar,它基本没有 Grammar。要么是一个 Command 后面跟一个 key,要么是 Command 后跟一个 DB index 这种,如果你写一个 Lexer,你会发现全都是一层扁平的 if-else。

所以我放弃了用Lexer,直接用正则。

这个正则难理解吗?

我觉得不难,首先一个 command 属于一种格式,比如 command + key 是一种,command + ip + port 也是种,我是这么对这些 command 分类的:ip/port/key/fields 这种基本的token定义在最前面,并且每一种格式都会有单元测试覆盖。我觉得无论是维护还是新手理解都是可以的。

这么做的另一个好处是,我可以直接使用正则表达式里面的分组,将token拿到,lexer和 completer 都变得很简单,completer 源代码。但是你可以去看下 mycli 的 completer 。

回到本文中:

这个正则表达式你们自己都看不下去了,所以才会需要使用拼接的方式生成。

这个说的不对,我很冤枉。通过我的 commit log 可以看出,我选择用正则,是一开始就想好了要拼接的,先定义基本的 Token,然后用基本的 Token 定于语法。

费脑子,难以理解,难维护。

这不对,可能读者没有按照我的方式理解,现在这个项目开发比较快,不好写文档(我也没啥时间写),因为都在变。如果我写完了稳定你在看的话就简单多了。首先是 command_syntax.csv 文件,定义了每一个 command 的语法。然后 grammar 里面解释了每一种的语法的组成,按照字面就可以理解。比如 <command-key-field> <key> <field> ,<command-key> <key> 我觉得无论是下面的大正则,还是csv文件,都是比较好理解的。

这个组织是扁平的,其实比 Lexer 好理解、维护不少。

并且每一种语法都带测试,测试已经帮我发现了很多问题。

其实这么做也有一些坑。比如用户输入第一个字符是空格,那么按照我那个正则,空格是“可能匹配”所有的情况的。就导致用户输入第一个空格,整个进程会卡10+s。解决办法是我patch了原生的 Completer,strip() 之后的空字符串不做 completer。(PR

compile 太慢的问题,我想过去缓存 re.compile 的结果(laike9m 的建议,我觉得这个想法太天才了。)。但是最后放弃了,原因见这里

现在这个问题是通过应用在启动的时候新启动一个线程去编译正则,主线程正常接收用户输入来实现的。

还有一个坑是,这个正则其实不是Python本身的正则,断言无法使用,{} 风格的 repeat 无法使用,所以必须用很多 tricky 的方式绕过去。这导致有一些语法树也很难实现,比如这个

但总体上,我觉得相比 Lexer,是比较好维护一些,但是功能弱一些。

 

下面再说一个好玩的东西,比较烧脑,我觉得可以作为一个不错的面试题目:如何实现 Bash 风格的引号处理?要注意双引号里面可以有单引号,单引号里面可以有双引号。双引号里面可以有 \ 转义的双引号,单引号也是。答案在这里。这个 PR 里面也有一个单元测试,有兴趣的同学可以试下能否写一个更优雅的函数,来通过这个单元测试。

最后,这个项目我跟朋友说了之后,得到很多不错的 Feature 建议,未来想要实现的都记录在 issues 里面了,有兴趣的可以看下。如果您有什么想法也可以跟我交流下。主要的目的是实现一个行为和 redis-cli 一致、但是更好用的 redis-cli。

 

附录:本文所谈论的原文相关的部分,这里备份一下,以防读者不知道我在说啥:

多说一句
以下内容与本次讨论的re.compile无关。

@Manjusaka给出了一个compile需要3秒钟的大型正则表达式,并以此作为例子说明re.compile的合理性。

首先这种情况下,确实需要提前re.compile。

但我所想表达的是,在这种情况下,就不应该使用正则表达式。既然要做Redis的语法校验,那么就应该使用有限状态机。这种使用很多的f表达式拼出来的正则表达式,才是真正的难以维护,难以阅读。

否则为什么里面需要用一个csv文件来存放命令呢?为什么不直接写在正则表达式里面呢?使用CSV文件每行一个命令尚且可以理解,但是SLOT/SLOTS/NODE/NEWKWY这些正则表达式,可就说不过去了。或条件连接的每一段都要加上这些东西,如果直接写进去,这个正则表达式你们自己都看不下去了,所以才会需要使用拼接的方式生成。

我在读这段代码的时候,首先看到正则表达式里面的t[xxx],会先去找t是什么东西,发现t是一个字典,字典是在commands_csv_loader.py中生成的,然后去到这个文件里面,发现它读的是一个存放Redis命令的CSV文件。然后去项目根目录读取这个csv文件的内容,知道了它的结构,于是推测出t的结构。然后再回到正则表达式里面,继续看这个超大的正则表达式。整个过程会非常费时间和脑子。

但是,我又不能直接打印REDIS_COMMANDS这个变量,因为它多且乱,不同命令长短不一,拼出来以后再打印出来根本没法看。

这个正则表达式只有两位维护者知道什么意思,如果别人想贡献新的Redis命令,那么理解这个超大正则表达式都需要花很久的时间。

如果换成有限状态机,并且t使用Python的data class来表示,而不是使用字典,那么就会简洁很多。有限状态机的一个特点是,只需要关注当前状态、转移条件和目标状态,可能一开始写起来有点麻烦,但是以后维护和新增,都是直接定位目标,直接修改,不用担心会影响不相干的其他地方。

算上维护时间,正则表达式真是一个非常糟糕的方式。

 

上海PyCon见!

2019 PyCon 中国马上要开始了,今年是 Python 诞生30周年,所以 PyCon 组织的同学们非常卖力。

PyCon 中国官网地址

举办的形式和去年一样。有一个城市是主会场,其他城市是分会场。去年主会场是北京,今年是在上海,其他城市是分会场:北京、杭州、深圳、成都、南宁。主会场和分会场的区别是:主会场城市的演讲很多,分为 web、测试、运维主题等等,但是所有的主题是同时进行的,你需要挑自己感兴趣的主题去听;分会场不分主题,所有演讲是顺序进行的。

上海一共会有2天,第一天是演讲,第二天是 Tutorials。其他城市都是一天办完。

今年的讲师阵容非常豪华。有 Armin Ronacher,Flask 社区非常活跃;Luciano Ramalho,《流畅的 Python》作者,我最喜欢的一本书。Giampaolo Rodola, CPython 的 commiter。当然还有很多老朋友。

我会参加上海场,分享的主题是 Django Migration Under the Hood , 中文标题实在不知道怎么起,英文可能更贴切一点。

这个主题我想讲 migration 的原理,内容和我最近写的一篇博客差不多。但是在演讲上,我想讲的更加浅显、容易理解一些。我选的方面特别小,本来想讲一下映射和查询的原理,但是本着多不如精的原则,就只讲 migrations 这一小点了。我会把用一些动画和图片尽量简单地展示 migration 的过程和原理,演讲内容上也会分享我用 Django 的经历,和一些想法,生动一些,减少大家理解的难度,希望能没有用过 Django 甚至没有用过 Python 的同学也能知道这里面的设计思想。

下面是我提交给 PyCon 的一个主题介绍。

主题介绍:Django强大的ORM几乎屏蔽了SQL的复杂性,让我们只要写 Python 代码,然后 python manage.py makemigrations & migrate,就可以让数据持久化起来。但是这两行命令的背后发生了什么呢?为什么有时候这个命令会执行失败呢?在部署的什么过程去执行最合适?在PyCon上我将和大家分享:

  • 我与Django的故事;
  • Django migrations的工作原理;
  • 使用Django migrations会遇到的问题,如何从原理入手去解决问题;
  • 部署 Django migrations 的最佳实践;
  • 其他一些 migrations 的思路,如果做一个 migrations 平台,如何做数据库结构版本化,DDL 回滚;

有关这个主题,大家有什么想法,或者想听什么,可以和我交流下。

另外我们 Pythonhunter 播客的4位主播也会在 PyCon 上有一个展台,四位主播会在现场和大家当面交流。我们准备了一些贴纸(有漫威、Rick and Morty,Github 等等)吸引大家,免费索取,没有扫码,没有关注,见者有份。

大会购票地址:https://www.bagevent.com/event/5293611

 

Django migration 原理

Django 自带了一套 ORM,就是将数据库的表映射成 Python 的 Class,这样我们在写程序的时候,就基本不需要写 SQL 和数据库交互了,直接操作 Python 对象就可以了,Django 的 ORM 把我们对类的操作,转换成对数据库的 SQL 操作。

那么 Class 的属性是如何对应到 Table 的 Column 的呢?Class 的属性变了,如何关联到表结构的变化?本文就介绍这种 migration 实现的原理。

 

参与 Class 和 Table 结构一致的,一共有3个角色:

  1. Models:只记录了现在的 Model 是什么样子的;
  2. Migrations:记录了 Model 是如何从最初的样子,变成现在这个样子的——即要变成现在的样子,需要走哪几步;
  3. 数据库中的 django_migrations 表:记录在 Migrations 的所有步骤中,当前的 Table 已经执行了哪几步,这样就可以只执行没有执行过的步骤,来变成最新的状态;

一次 migration 之旅

前面我们说过,Django ORM 基本屏蔽了用户需要做的 SQL 操作,包括 DDL 操作。

从一个用户的角度看,我们做一次 migration,只需要3步:

  1. 修改 models.py,比如添加字段;
  2. 执行 python manage.py makemigrations ;
  3. 执行 python manage.py migrate ;

这时候,数据库中的 Table 和我们 Django 中的 Model 就对上了,我们写业务逻辑就可以了。

但实际上,Django 在背后做了什么呢?如果不了解这些,遇到一些问题就束手无策了(文末会提到一些经典的问题)。

在整个流程中,Django 负责的是自动识别出 Model 进行了哪些变化,将这些变化应用到 Table 中,保证最终 Model 和 Table 还是对应的。本质上,就是将 py 文件的变化,转成数据库的 DDL 变化。

所以首先,Django 要知道你的 Model 做了哪些变化。这是第2步:生成 migrations。Migrations 生成的方式,颇有点现在“声明式”的意思。

它的过程是这样的(这里我们将修改后的 Model 记作 Model-2,修改前的 Model 记作 Model-1):从 migrations/ 文件夹下的 0001_xxx.py 开始 apply,到 0002_xxx.py,一直到最后一个文件,这些文件都记录了 Model 的变化,都 apply 一遍之后,就得到了修改前的的 Model-1 的状态,然后和修改过的 Model-2 来比较,得到了 diff,然后看如何消除这些 diff——即产生最新的 migrations 文件,将 Model-1 的状态转移到 Model-2.这样新的 migrations 就生成了,如果你再跑一遍的话,就发现什么 migrations 也不会出现,因为 migrations 文件记录的变化已经和最新的 Model 一样了(声明式天生的幂等性!)。

这些 migrations 记录了 Model 从 0001_init 开始的所有变化,有了 migrations 文件,你就可以从一个空的数据库,变成现在的结构了。

但是如果数据库不是空的,而是已经有一些结构呢?通常是这种情况:生产环境的数据库,表结构是 v3(执行过migrations 0001,0002,0003),你在开发的时候生成了 migrations 0004,0005。那么怎么知道这个数据库只要执行 0004,0005 就可以了呢?更复杂一点的话,假如你有一个灰度服务器,一个生产服务器,灰度服务器执行过了 0001-0004,生产服务器落后一个版本,执行了0001-0003,怎么知道这些数据库应该执行哪些 migrations 呢?

这里根本的问题是:migrate 的执行(DDL的执行)不是幂等的。所以我们就需要一个地方,记录一下哪些 migrations 已经被执行过了。Django 会自动在你的数据库中建立一张 django_migrations 表,用来记录执行过的 migrations。这样你在执行 python manage.py migrate 的时候,django 会去对比 django_migrations 表,只执行没有执行过的 migrations,无论你有多少表结构版本不同的数据库都没有关系。

下面再用一张图来表示一下整个过程。其实想明白了很简单(我也不知道为啥这张图画出来咋这么复杂,点开可以看大图)。这张图是给一个很简单的 User Model 增加了一个字段。注意一开始蓝色的 Model 和 Table 是 map 的,最终状态红色的 Model 和 Table 也是 map 的。

一些骚操作

有了上面的知识,我们处理起来一些问题就得心应手啦!

1.migrate执行到一半失败了怎么办?

这种情况是很容易发生的,因为 make migrations 的时候,Django 只知道消除 diff,并不知道表结构。这就很有可能导致生成的 migrations 实际上是违反数据库约束的,而不能执行成功。

最糟糕的情况是,一个 migrations 中有很多步,其中部分执行成功了。就造成数据库的结构处于一个“未知”的状态。

不过不要慌,只要从 log 中看下哪些 migrations 步骤已经执行了,去手动 revert,然后从 django_migrations 删除这些记录即可。参考这篇文章

2.migrations应不应该加入到 git 仓库中?

应该!虽然是生成的代码,但是也是要用版本管理,让所有的数据库都执行一套 migrations!看看我的教训吧。

3.我什么 models 都没改,但是每次 make migrations 都会生成新的?

不要慌,我也遇到过。这时候你去看下生成的 migrations 做了啥操作,再想想 Django 为什么会这样做即可。

举个例子吧,我之前的 models 里面一个 default 值用了字典,我们知道字典 key 是没有顺序的(Python 3.7)之前,就导致每次 make migrations 的时候,顺序都有几率不一样,Django 就认为状态不一致,需要新的 migration 文件来消除 diff。

4.migrations 文件冲突了,我和同事开发的时候,我生成了一个 0003_xxx.py 我同事也生成了这个 0003.

不要慌,这是正常现象。按照 Django 的提示,执行生成一个新的 migrations 来 merge 前面的两个就好啦。python manage.py makemigrations --merge 。

5.我的migrations太多了,每次 makemigrations 都要等很久,或者我的 migrations 文件名已经快到 999_xxx.py 了!

我一生中只遇到过一个这样的项目。解决方法也很简单,因为我们只需要一个最终的状态,所以我们可以将之前的 migrations 都删掉。

步骤如下:

  1. 备份整个项目和数据库(非常重要);
  2. 删除除了 __init__.py 之外的所有 migrations 文件夹下的文件:ls */migrations/*.py | grep -v __init__ | xargs rm;
  3. 重新生成 migrations 文件,不出意外的话,所有的 app 都只有一个 migration 文件,就是 init;
  4. 删除数据库 django_migrations 表中所有的记录:delete from django_migrations;
  5. 因为我们的表结构已经是最新的了,所以新生成的 init migrations 并不需要执行。所以我们插入记录到 django_migrations 中,骗 djagno 我们已经执行过了。用这个命令:python manage.py migrate --fake

大功告成,不出意外,makemigrations 又快了起来。

其他的一些骚操作

如何查看一个 migration 文件对应的 SQL 是啥?

如何查看这个数据库执行过哪些migrations了?

最后 n 次执行的 migrations 有问题,数据库炸了,要不要删库跑路?

不用。

 

最后,不要在部署的时候自动执行 migrations。具体推荐大家看下这篇文章:Decoupling database migrations from server startup: why and how (这里面的问题我都遇到过,哭笑)。

其他一些类似 Django ORM 的 migrations 的项目:

 

Linux 进程的生命周期

这篇文章将介绍 Linux 中一个进程是如何诞生的,如何结束的。进程有哪些的不同状态,这些状态如何进行转换。

首先我们从进程的基本概念说起。

Linux 中,进程是由父进程创建的,每一个进程又可以作为父进程再创建子进程。每一个进程都有一个ID,叫做 pid,作为进程的唯一标志。Pid 在系统的同一时间不会重复,当分配 pid 的时候,kernel 会取当前系统中最大的 pid + 1,直到这个值超过 /proc/sys/kernel/pid_max ,所以 kernel 不保证 pid 在不同时间不会复用。在进程的结构体里面保存了 ppid,就是父进程的 id。根据 pid 和 ppid 我们可以找到每一个进程的父进程,这样,系统中所有的进程就像一个树一样可以串起来。

通过 pstree 这个工具,我们用一个树展示出所有的进程。

可以看到,最上层的进程的 pid 是 1,其他的进程要么是 pid 1 进程的子进程,要么是 pid 1 子进程的子进程,pid 1 的进程是所有进程的“祖先”。Pid 为 1 的进程,是在 kernel 启动的时候就创建的,所以实际 init 进程没有通常意义上的“父进程”,这里显示出它的父进程是进程0,只是一个标志。

Pid 为 1 的进程是可以指定的,我这个系统中是 systemd。如果不指定的话,Kernel 将尝试以下 4 个可执行文件:

  1. /sbin/init: The preferred and most likely location for the init process.
  2. /etc/init: Another likely location for the init process.
  3. /bin/init: A possible location for the init process.
  4. /bin/sh: The location of the Bourne shell, which the kernel tries to run if it fails to find an init process.

除了 pid 为 1 的进程外,其实还有一个 pid 为 2 的进程,父进程也是 0. 这个进程叫 kthreadd。

同理,这个进程也是在 Kernel 启动的时候就创建的。Linux 中有一些进程是跑在 Kernel 中的,但是为了统一调度,这些进程也同用户进程一样放在一起管理。kthreadd 就是这些 kernel 进程的父进程,等同于 init 进程(或者 systemd 进程)之于我们的用户进程。

Pid 0可以认为就是 kernel 本身。这个进程也是 idel process,当没有可以运行的进程的时候,kernel 就运行进程 0.

那么一个进程是如何产生自己的子进程的呢?

进程创建

在 Unix 中,创建进程和载入要执行的程序是分开的。创建一个进程一共2步,涉及2个 syscall。

第一个是 fork(),负责创建出来一个新的进程。那么新的进程也需要有可执行的代码、地址空间啥的呀,默认是啥呢?总不能是空的吧,如果是空的,那 kernel 执行到这个进程怎么办?

答案是 fork() 出来的进程,和父进程是一模一样的,除了:

  • 子进程的 pid 被重新分配;
  • 子进程的父进程被设置为原来的进程;
  • 子进程资源的统计被初始化成0;
  • 子进程中,未处理的 signal 全部清除;
  • 父进程的 file lock 不会继承到子进程;

那两个进程是一模一样的,我执行了 fork() 之后,怎么让这两个进程做不一样的事情呢?答案是 fork() 的返回值不同,fork() 在父进程中返回子进程的 pid,在子进程中返回 0.这样我们就可以在调用 fork() 之后,通过一个 if 判断,让子进程和父进程做不一样的事情。一般是下面这种代码模板:

 

新的进程创建好了,我们想让新的进程去执行另一部分任务,但是无法像上面这样都写在代码中,怎么办呢?举个很好理解的场景,我在终端的 bash 中执行了一个 top 命令,那么 bash 的代码肯定是和 top 没有关系的,怎么让 bash 新创建的子进程去执行 top 的代码吗?

这就是载入部分了。载入的 syscall 是一个函数族,这里用调用最简单的 execl() 举例:

有关 execl 具体的行为,可以参考下 manual.  exec 是一个 syscall family,除了 execl() 这个之外,还有:execl, execlp, execle, execv, execvp, execvpe。本质上他们都一样,只是传入参数的格式、path、环境变量有些许不同。这些函数都是 glibc 提供的库函数(其他的 C 库提供的会有所不同,比如 libc 提供的函数族就是 execl, execle, execlp, execv, execvp, execvP ),底层他们调用的都是 execve(感谢 Nitroethane 指正)。

通常,打开的文件可以通过 exec 继承。比如父进程打开了一个文件,那么父进程的子进程是有 full access 的。如果不想这么做的话,可以在 exec 之前关闭这些文件。

 

Copy-on-write

在早期的 Unix 系统中,fork() 之后要复制很多东西到子进程,最耗时的是要一个 page 一个 page 地拷贝内存,这个操作非常耗时。

现代的 Unix 系统中,有一种叫做 copy-on-write 的机制。本质上是对这种资源复制的优化策略:如果多个消费者都需要一段内存的副本的话,先不去真正的做拷贝,而是让这些消费者都认为自己持有这段内存。如果进行读操作,那其实大家读到的还是同一段内存;如果要进行写操作,那这段内存(以 page 为单位)就会透明地被 kernel 拷贝出来。所以是一种懒加载的策略,只有用到的时候才去拷贝。

fork() 可以说是这个机制的最大受益者,因为 fork() 之后经常跟的是 exec ,也就是说子进程被 fork 出来之后都去干别的了,你复制一遍父进程的内存过来也没有意义。

这里我还想提一下,Redis 的 rdb 机制也是 copy-on-write 的一大受益者。Redis 的 rdb 就是定期将 Redis 内存中存储的数据转存到硬盘上,这个功能是基于 fork 实现的:当 Reids 要要 dump rdb 的时候,先 fork 出一个子进程,然后子进程开始从内存读出来数据,写到硬盘上,而原来的进程(父进程)继续提供服务。得益于 copy-on-write,子进程不需要完全复制一份父进程的内存来做写硬盘操作,即节省了时间和CPU,也节省了内存——它和父进程读的是同一段内存。

但是这里有个问题就是,假如 Reids 用在了一个写操作非常频繁的场景的话,那 copy-on-write 的意义就不大了。子进程 fork 出来之后,父进程因为写入很频繁,大部分内存都脏了,都需要被 kernel copy 出一份进行写操作。就可能导致在 dump rdb 的时候,内存占用提升了一倍(子进程和父进程分别有一份内存)。

进程销毁

一个进程要结束自己,非常简单。调用 exit() 这个 syscall,kernel 就会结束这个函数的调用者进程。我们也可以通过 atexit() 或 on_exit() 函数注册一些进程退出时的 hook。

除了显示的调用 exit() 之外,一个经典的退出方式是执行完整个程序。比如 main() 函数的代码最后,编译器会插入一个 _exit()

进程收到 SIGTERM 或 SIGKILL 之后也会退出。具体的 signal 下面会介绍。

最后一种结束进程的方式是,惹的 kernel 不高兴了。比如出现 OOM 了(out-of-memory),或者 segment fault。kernel 会结束这个进程。

子进程退出之后,谁关心子进程是如何退出的呢?比如 daemon 进程退出了,systemd 如何实现“restart on failure”呢?所以这里还需要某种机制,来关心子进程的退出状态。

当子进程的状态改变之后,父进程会发生2件事情:

  • 父进程会收到 SIGCHLD 信号;
  • 父进程 block 调用 waitpid() 会返回(即这个系统调用会拿到子进程具体的事件);

也就是说父进程如果关心子进程的状态的话,一般是会设置 SIGCHLD 信号的 handler(进程如何处理信号,本文暂不涉及),收到这个信号之后去调用 wait() 系统调用,得到具体的事件。

这里有一个 Python 写的例子,展示了父进程如何得到子进程的状态。

执行这段程序,输出如下:

这段代码执行的流程如下:

子进程在退出之后,保存进程状态的一些信息并不会立即销毁,因为这样的话,父进程就无法获得这些信息了。它们等待父进程调用 wait() 来读出来后,才会真正销毁。

这种已经退出,但是 state change 并没有被父进程读到的进程,叫做讲僵尸进程(Zombies)。父进程创建了子进程,就需要对子进程负责,在子进程退出之后,去调用 wait() 来 clear 这些子进程状态,即使父进程并不关心这些状态是什么。不然的话,这些子进程的状态将一直存在,占用资源(虽然很少),成为 ghosts,这些 ghosts 的 parent 也成为了不负责任的父母。

通常,我们可以安装一个 signal handler 来 wait() 这些子进程。需要注意的是,发给父进程的 SIGCHLD 可能被合并,比如有 3 个子进程退出了,但是父进程实际上只会收到一次 SIGCHLD。所以我们在 wait() 的时候要注意使用一个循环。

 

如果一个进程创建了子进程,但是在子进程之前就退出了呢?这样子进程就没有父进程来调用 wait() 了。

当一个进程结束的时候,kernel 会遍历它的子进程,将这些父进程已经死掉的进程,分配给 init 进程(即 pid 是1的进程)作为它们的父进程。这就保证了系统中每一个进程,都有一个直接的父进程。init 进程会定期去 wait() 它所有的子进程。

进程的状态

上面我们已经提到了一些进程的状态:Zombies,Runing,Stopped。

这里重点解释一下几种状态:

  • R – runnable,处于这个状态的进程是可以执行的,会被 kernel 放到一个待执行的 queue 中,分配时间片去执行。我们无法控制进程的运行,runnable 的进程由 kernel 的 scheduler 来决定什么时候运行,运行多长时间;
  • D – 不可中断的 sleep(一般是IO);
  • S – 可中断的 sleep;
  • Z – 进程已死,或者失去响应,死透了;
  • T – 进程以暂停(是可以恢复的,还没死透);

这里面其他状态都比较好理解,D 和 S 有点模糊。

代码在执行的时候,会在 user space 和 kernel space 之间切换。当执行 syscall 的时候,代码的执行从用户空间切换到内核空间。当在等待 system call 的时候,进程处于 S 状态或者 D 状态。S 状态比如 read() ,这时收到像 SIGTERM 这样的信号,进程就会提前结束 syscall,返回用户空间,执行 signal hanlder 的代码;如果是处于 D 状态,进程就是不可 kill 的

T 状态是可以恢复的暂停。比如我们给进程发送 SIGSTOP 或者按 CTRL+Z,就可以将进程置为暂停状态,可以通过 bg/fg 命令,或者发送 SIGCONT 信号恢复。

下面开启了一个 sleep 命令,然后用 CTRL+Z 暂停它,再用 fg 重新开启。展示了 S -> T -> S 的状态转换:

将 sleep 命令替换成 yes > /dev/null ,上面实验的状态 S 会变成 R,其他一样。

综上,状态流转如下(再盗图一张):

所有的进程状态,可以看下这个文档,介绍了源代码中定义的进程状态。

信号

上面介绍了一些信号,这里再列一些常用的:

  • SIGCHLD:子进程状态改变;
  • SIGCONT: 进程继续执行;
  • SIGSTOP: 进程暂停,和上面的 SIGCONT 是一对;
  • SIGTERM: 指示进程结束,这个信号可以被捕捉或者忽略,允许进程收到这个信号之后 gracefully exit;
  • SIGKILL: 立即结束进程,收到这个信号的进程无法忽略这个信号,必须退出。但是 init 除外,init 不接受 SIGKILL;
  • SIGINT: 从终端(用 CTRL+C)发出的终端信号。在一些 REPL 中会介绍当前的命令,也有些进程的表现是直接退出;

信号本质上是一中进程间通讯方式(IPC),Signal wiki

 

以上就是基本的进程知识了,本文所有的参考资料已经在文中链接。这是我最近读 Linux System Programming 的笔记,如有理解错误,请指出。接下来还会分享一些有关 Linux 的文章。