陈硕的 Blog

吾尝终日而思矣,不如须臾之所学也。吾尝跂而望矣,不如登高之博见也。……君子生非异也,善假于物也。

分布式系统中的进程标识

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

昨天跟朋友聊天,谈到了分布式系统中如何为进程取标识符(process identifier),写篇博客简单总结一下我的观点。

本文假定一台机器 (host) 只有一个 IP,不考虑 multihome 的情况。同时假定分布式系统中的每一台机器都正确运行了 NTP,各台机器的时间大体同步。

“进程 process”是操作系统的两大基本概念之一,指的是在内存中运行的程序。在日常交流中,“进程”这个词通常不止这一个意思。有时候我们会说 “httpd 进程”或者“mysqld 进程”,指的其实是 program,而不一定是特指某一个“进程”——某一次 fork() 系统调用的产物。一个“httpd 进程”重启了,它还是“一个 httpd 进程”。本文讨论的是,如何为一个程序每次运行的进程取一个唯一标识符。也就是说,httpd 程序第一次运行,进程是 httpd_1,它原地重启了,进程是 httpd_2。

本文所指的“进程标识符”是用来唯一标识一个程序的“一次运行”的。每次启动一个进程,这个进程应该被赋予一个唯一的标识符,与当前正在运行的所有进程都不同;不仅如此,它应该与历史上曾经运行过,目前已消亡的进程也都不同(这两条的直接推论是,与将来可能运行的进程也都不同)。“为每个进程命名”在分布式系统中有相当大的实际意义,特别是在考虑 failover 的时候。因为一个程序重启之后的新进程和它的“前世进程”的状态通常不一样,凡是与它打交道的其他进程(s)最好能通过它的进程标识符变更来很容易地判断该程序已经重启,而采取必要的救灾措施,防止搭错话。

本文先假定每个服务端程序的端口是静态分配的,在公司内部有一个公用 wiki 来记录端口和程序的对应关系(然后通过 NIS 或 DNS 发布)。比如端口 11211 始终对应 memcached,其他程序不会使用 11211 端口;3306 始终留给 mysqld;3690 始终留给 svnserve。在分布式系统的初级阶段,这是通常的做法;到了高级阶段,多半会用动态分配端口号,因为端口号只有 6 万多个,是稀缺资源,在公司内部也有分配完的一天。本文只考虑 TCP 协议,不考虑 UDP 协议,“端口”都指的是 TCP 端口。

另外,我们假定在一台机器上,一个 listening port 同时只能由一个进程使用,不考虑古老的 listen() + fork() 模型(多个进程可以 accept 同一个端口上进来的连接),关于这点陈硕已经写的很多,见《Linux 新增系统调用的启示》《多线程服务器的适用场合》。

错误做法

在分布式系统中,如何指涉(refer to)某一个进程呢,或者说一个进程如何取得自己的全局标识符 (以下简称 gpid)?容易想到的有两种做法:

  • ip:port (port 是这个进程对外提供网络服务的端口号,一般就是它的 tcp listening port)
  • host:pid

而这两种做法都有问题。为什么?

如果进程本身是无状态的,或者重启了也没有关系,那么用 ip:port 来标识一个“服务”是没问题的,比如常见的 httpd 和 memcached 都可以用它们的惯用 port (80 和 11211)来标识。我们可以在其他程序里安全地引用(refer to)“运行在 10.0.0.5:80 的那个 http 服务器”,或者“10.0.0.6:11211 的 memcached”,就算这两个 service 重启了,也不会有太恶劣的后果,大不了客户端重试一下,或者自动切换到备用地址。

如果服务是有状态的,那么 ip:port 这种标识方法就有大问题,因为客户端无法区分从头到尾和自己打交道的是一个进程还是先后多个进程。在开发服务端程序的时候,为了能快速重启,我们一般都会设置 SO_REUSEADDR,这样的结果是前一秒钟站在 10.0.0.7:8888 后面的进程和后一秒钟占据 10.0.0.7:8888 的进程可能不相同——服务端程序快速重启了。

比方说,考虑一个类似 GFS 的分布式文件系统的 master,如果它仅以 ip:port 来标识自己,然后它向 shadows (不是 chunk server)下达同步指令,那么 shadows 如何得知 master 是不是已经重启呢?发指令的是 master 的“前世”还是“今生”?是不是应该拒绝“前世”的遗命?

如果考虑改成 host:pid 这种标识方式会不会好一点?我认为换汤不换药,因为 pid 的状态空间很小,重复的概率比较大。比如 Linux 的 pid 的最大值是 32768 (/proc/sys/kernel/pid_max),一个程序重启之后,获得与“前世”相同 pid 的概率是 1/32768。或许有读者不相信重启之后 pid 会重复,因为 pid 是递增的,遇到上限再回到目前空闲的最小 pid。考虑一个服务端程序 A,它的 pid 是 1234,它已经稳定运行了好几天,这期间,pid 已经增长了几个轮回(因为这台机器时常会启动一些 scripts 执行一些辅助工作)。在 A 崩溃的前一刻,最近被使用的 pid 已经回到了 1232,当 A 崩溃之后,某个守护进程启动一个脚本(pid = 1233)来清理 A 的 log,然后再重启 A 程序;这样一来,重启之后的 A 程序的 pid 碰巧和它的前世相同,都是 1234。也就是说,用 host:pid 不能唯一标识进程。

那么合在一起,用 ip:port:pid 呢?也不能做到唯一。它和 host:pid 面临的问题是一样的,因为 ip:port 这部分在重启之后不会变,pid 可能轮回。

我猜这时有人会想,建一个中心服务器,专门分配系统的 gpid 好了,每个进程启动的时候向它询问自己的 gpid。这错得更远:这个全局 pid 分配器的 gpid 由谁来定?如何保证它分配的 gpid 不重复(考虑这个程序也可能意外重启)?它是不是成为系统的 single point of failure?如果要对该 gpid 分配器做容错,是不是面临分布式系统的基本问题:状态迁移?

还有一种办法,用一个足够强的随机数做 gpid,这样一来确实不会重复,但是这个 gpid 本身也没有多大额外的意义,不便于管理和维护(比方说根据 gpid 找到是哪个机器上运行的哪个进程)。

正确做法

正确做法:以四元组 ip:port:start_time:pid 作为分布式系统中进程的 gpid,其中 start_time 是 64-bit 整数,表示进程的启动时刻(UTC 时区,muduo::Timestamp)。理由如下:

  • 容易保证唯一性。如果程序短时间重启,那么两个进程的 pid 必定不重复(还没有走完一个轮回:就算每秒创建 1000 个进程,也要 30 多秒才会轮回,而以这么高的速度创建进程的话,服务器已基本瘫痪了。);如果程序运行了相当长一段时间再重启,那么两次启动的 start_time 必定不重复。(见下文关于时间重复的解释)
  • 产生这种 gpid 的成本很低(几次低成本系统调用),没有用到全局服务器,不存在 single point of failure。
  • gpid 本身有意义,根据 gpid 立刻就能知道是什么进程(port),运行在哪台机器(ip),是什么时间启动的,在 /proc 目录中的位置 (/proc/pid) 等,进程的资源使用情况也可以通过运行在那台机器上的监控程序报告出来。
  • gpid 具有历史意义,便于将来追溯。比方说进程 crash,那么我知道它的 gpid,就可以去历史记录中查询它 crash 之前的 cpu/mem 负载有多大。

如果仅以 ip:port:start_time 作为 gpid,则不能保证唯一性,如果程序短时间重启(间隔一秒或几秒),start_time 可能会往回跳变(NTP 在调时间)或暂停(正好处于闰秒期间)。关于时间跳变的问题留给下一篇博客《〈程序中的日期与时间〉第二章:计时与定时》,简单地说,计算机上的时钟不一定是单调递增的。

没有 port 怎么办?一般来说,一个网络服务程序会侦听某个端口来提供服务,如果它是个纯粹的客户端,只主动发起连接,没有主动侦听端口,gpid 该如何分配呢?根据陈硕在《分布式系统的工程化开发方法》一文中的观点“在程序里内置 http 服务器”,分布式系统中的每个长期运行的、会与其他机器打交道的进程都应该提供一个管理接口,对外提供一个维修探查通道,可以查看进程的全部状态。这个管理接口就是一个 TCP server,它会侦听某个 port。

使用这样的维修通道的一个额外好处是,可以自动防止重复启动程序。因为如果重复启动,bind 到那个运维 port 的时候会出错(端口已被占用),程序会立刻退出。更妙的是,不用担心进程 crash 没来得及清理锁(如果用跨进程的 mutex 就有这个风险),进程关闭的时候操作系统会自动把它打开的 port 都关上,下一个进程可以顺利启动。

进一步,还可以把程序的名称和版本号作为 gpid 的一部分,这起到锦上添花的作用。

TCP 协议的启示

我在《分布式系统的工程化开发方法》中提到“从 TCP 协议能学到什么?”,今天讲的这个 gpid 其实也是由 TCP 协议启发而来。TCP 用 ip:port 来表示 endpoint,两个 endpoint 构成一个 socket。这似乎符合一开始提到的以 ip:port 来标识进程的做法。其实不然。在发起 TCP 连接的时候,为了防止前一次同样地址的连接(相同的 local_ip:local_port:remote_ip:remote_port)的干扰(称为 wandering duplicates,即流浪的 packets),TCP 协议使用 seq 号码(这种在 SYN packet 里第一次发送的 seq 号码称为 initial sequence number, ISN)来区分本次连接和以往的连接。TCP 的这种思路与我们防止进程的“前世”干扰“今生”很相像。内核每次新建 TCP 连接的时候会设法递增 ISN 以确保与上次连接最后使用的 seq 号码不同。相当于说把 start_time 加入到了 endpoint 之中,这就很接近我们后面提到的“正确的 gpid”做法了。(当然,原始 BSD 4.4 的 ISN 生成算法有安全漏洞,会导致 TCP sequence prediction attack,Linux 内核已经采用更安全的办法来生成 ISN。)

posted on 2011-03-29 09:29  陈硕  阅读(4552)  评论(2编辑  收藏  举报

导航