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 的文章。

 

如何正确实现分布式锁

分布式锁几乎是分布式系统中必备的一个组件,通常用在两个场景:

  1. 保证耗时的操作同一时间只有一个执行者,减少重复执行的时间;
  2. 保证一个共享资源同时只有一个修改者,防止竞争条件;

我在某些公众号看过好多讨论分布式锁的文章(在公众号里面好像是一个很热门的话题),但遗憾的是,我看过的这些实现里,或多或少都有问题。

一个分布式锁要求满足两点基本的条件:

  1. 同一时刻只有一个锁的持有者,不能出现两个进程同时持有锁的情况(safety);
  2. 不能出现一个进程永远持有锁的情况(死锁)(liveness);

在使用场景上,第一种场景(减少重复执行)对锁是弱依赖的,假如锁失去 safety,那么影响就是某个操作被多执行了几次;而第二种情况对锁的要求是强依赖的,如果锁没有 safety,就会面临数据不一致的严重后果。

最基本的实现

实现分布式锁,最基本的是有一个唯一的资源,支持 CAS 操作,最简单的实现如下面这样:

这也是本地环境中我们保护一个共享资源的方法。但是在分布式系统中,我们会遇到很多复杂的问题,比如获得锁之后,实例宕机了,这样它永远不会把锁还回来,造成了死锁。本地环境基本不需要考虑这种情况,因为线程退出一般会进行清理操作,在 finally 中释放锁;如果是整个进程 crash 了,那么连通锁、共享变量等就一起没了。

于是对于分布式的系统中,这些锁一般都需要带有一个“有效时间”,超过了这个时间,锁自动又变成了可获得的了(注意这一步是锁的提供者将锁过期的)。这就是常见的租期机制。

租期机制

租期机制解决了 liveness 的问题,但是带来了潜在的 safety 的问题。

因为在没有租期机制的情况下,如果原来锁的持有者不释放,那么锁是永远不会被再次 acquire 的。但是现在有了租期的机制,即使原来的进程还持有锁,但租期已经到了,那么锁就可以重新被 acquire。这样就有多个实例同时获得锁的可能性存在。

这就要求锁的持有者必须在锁的租期内完成事务,将锁释放。如果一个租期处理不完,那必须自己延长租期。

延长租期这里有一个细节,非常重要。就是延长租期的时候必须确认自己依然是锁的持有者。举个例子:现在有 A 和 B 两个实例,A 获得了锁,然后出现了网络问题掉线了,A 的租期超时,这时候 B 又成功获得了锁。A 的网络问题解决,重新上线,那么这时候我们就有了两个锁的持有者,A 和 B 同时续期。

这个问题其实很有意思,我在实际就遇到过这种情况:我用的一个定时调度器(Redbeat)为了保证高可用,采用了多个实例的同时运行的方法,然后通过一个分布式锁来保证只有一个获得锁的实例真正工作,然后此实例不断续期。如果这个实例没有续期成功,那其他实例就会获得锁开始工作。

Redbeat 的续期操作没有考虑检查锁的持有者问题,就导致可能出现多个持有者的情况,造成一个任务被执行两次。我在这个 issue 里面详细描述了这种情况和复现方法。

这个问题的解决方法很简单,就是我们在锁上面标志出目前锁的持有者,每次续期操作的时候都检查是否依然是持有者,如果不是,就失败掉续期,并且认为自己不再是锁的持有者(具体的一个实现参考下面)。在 Redbeat 里我提了一个 PR,先检查是否是锁的持有者再进行续期操作。

这样,锁的 liveness 还是有保证的,不会出现锁的持有者已经挂了,但是锁没有释放的问题;续期的机制也阻止了竞争者重复获得锁的问题。

以上都是分布式锁的经典实现,这些实现问题最多的其实是在锁的释放上。

锁的释放

对于提供 CAS 的系统,比如 Redis,我们一般用它的 SETNX 语义(如果 key 不存在则设置)来实现。释放锁的过程是删除这个 key,这样其他进程就可以获得锁了。

这种实现存在的典型问题是在释放的时候,没有检查自己是否仍是锁的持有者(本质上,和上面延长租期的时候要检查自己依然是锁的持有者,是一样的)。

考虑这样一种情况,存在进程A、B,A 获得了锁,然后出现网络问题,锁的租期过了之后 B 获得了锁。之后 A 重新上线,去释放锁,这时候 A 实际上释放了 B 的锁,造成可能有 C 重新获得锁,那么 B 和 C 就同时成为了锁的持有者。

锁的持有者标志方法

解决以上两个问题,就是标志出谁持有了锁,只有锁的当前持有者才能对锁做续期、释放等操作。

一种标致锁的持有者的方式是,设置的锁的时候将 value 设置为持有者的名字,比如 实例、进程、线程ID,不过考虑到 value 要唯一,实现起来可能有些复杂。

另一种方法是将锁的 value 设置成唯一的 token,获得锁的时候记住这个 token,释放、续期的时候检查 token 是否和自己的 token 一样。Redis-py 中的 lock 就是这样实现的。

但是 redis-py 中的 extend 操作(对我来说)很奇怪,这个 extend 10s 实际的语义是:将锁原来的租期还剩下的时间,加上 10 s。考虑上面 Redbeat 的场景,肯定是要在锁过期之前续期的,比如说我在使用锁第 9s 的时候续期,那么续期 10s 效果是锁的过期时间变成 11s。然后再运行 9s 的时候续期,锁的时间变成 12s。就导致运行的时间越长,锁的过期时间越长,假如进程挂了,要经过很长的时间(不可预期),其他进程才能 take over。

实际上在 Redbeat 中我们实现的 extend 是“Extend to”的语义。将锁的过期时间重置到 10s。这样就不会使得锁的过期时间越来越长。

Redis 的 SETNX 文档中描述了一种锁的实现模型。这个文档没有使用过期机制,而是将 timestamp 设置为锁的 value,然后用检查过期时间的方式来判断锁有没有释放。对于锁的 timestamp 过期,多个实例试图获得锁的时候,是通过 GETSET 保证只有一个实例拿到了锁的。其他实例需要拿到锁,发现自己不是第一个的话,要认为自己获得锁失败。

 

同一租期内的问题

以上,看起来不可能出现竞争条件了,但实际上这个实现依然是有问题的。DDIA 的作者在跟 antirez 有关 redlock 的争论中,举了这么一种情况:A获得了锁,在租期内发生了 GC stop the world,然后时间超过了租期,但是 A 并不知道。这时候 B 获得了锁去更新了资源,然后 A 过了暂停之后也去更新了资源。

Client 1 不可能做每一步操作都检查锁是否过期,这样太繁琐了,并且即使这样,依然可能在检查锁ok之后发生进程暂停。然后这时候过期(因为检查操作和写操作不是原子的)。

这个问题的解决方法是 fencing。获得锁的时候,锁服务为其分配一个只递增的值。然后进程在访问共享资源的时候会带上这个递增 token,当存储资源接受写操作的时候,会只接受更大的 token 的写,低于存储资源当前最大 token 的写请求会被拒绝。

注意:这里要求写操作是感知和记录下现在的最大的 token 的,实现的复杂度上也有所增加。

 

以上,我们讨论的锁都是基于一个安全可靠的、提供 CAS 机制的资源来实现我们的分布式锁的,假如要实现这样一种资源(比如 Redlock 的尝试),就要面对进程暂停、时钟偏移、网络延迟问题,要复杂的多。

推荐阅读:

  1. Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency 
  2. Database · 理论基础 · 关于一致性协议和分布式锁
  3. Is Redlock safe?
  4. How to do distributed locking