从Unix操作系统设计看分布式系统设计

学习一下(类)Unix操作系统的设计,可以推导出各种系统设计的哲学,软件系统的设计也应该参考操作系统的设计理念,参考不是指照抄。

首先聚焦一个,目标。没错,操作系统被设计出来是有目标的,很多人在做系统的时候,往往不知道目标是啥,为了做而做,为了酷炫而用了某些组件、技术,这是非常危险的,容易导致一堆的坑。操作系统的目标,主要有几个:1 是对底层计算机资源的抽象,包括CPU、内存、硬盘、网络以及其他各种各样的IO设备,为上层的应用程序提供简单的接口以方便、安全的使用这些资源。2 是能够对多个任务进行分时调度(Unix是一种分时操作系统),让应用程序不用担心程序会不会被执行,怎么执行。3 是做好进程间的隔离性,以保证每个进程有自己独立使用的资源,而不会互相干扰(入侵)。4 就是安全性,这里的安全性不是我们字面上了解的漏洞攻击之类的安全性,而是保证系统内核自己的安全,不会因为运行了某些应用程序之后,把系统本身弄死(死的只能是应用进程,而不是系统内核)。还有其他的许多小目标,但以上4点是(分时)操作系统的主要目标。

硬件抽象(CPU、内存、硬盘、网络、其他IO设备),让用户进程更方便的去使用这些底层资源。

操作系统,通过定义一组标准的接口(系统调用),来隔离底层资源和上层应用,不用每个应用都去写各种资源的驱动来使用这些资源,而是将这些使用封装成系统调用,应用进程只需要关注系统调用成功与否,不需要关注资源本身应该怎么读写的问题。实际上,除了CPU和内存这个冯诺依曼体系的计算机主要资源之外,其他的设备基本上也有自己的控制逻辑在运行,可以看做一个外设的子系统。操作系统也不用去关注这些子系统运行的好不好,而是通过中断信号,来跟各个硬件设备打交道。其中,各设备的驱动程序的逻辑主要就是来处理这些终端信号,以方便内核和设备之间可以进行通信并操作。

分布式系统中的所谓平台,干的其实就是对其『底层』资源的抽象,然后提供标准化的API,给到上层用户和应用去调用。平台内部的各子系统,一般也会通过一些服务总线、消息中间件的、RPC调用之类的方式来互相协作。顶层应用不需要知道的底层平台的复杂逻辑,只需要(也只能)调用平台抽象出来的应用程序接口(API)就可以实现对底层资源的使用了。

分时调度(CPU轮转、中断)

中断就是外设向cpu push一个信息要求cpu处理,cpu会停止当前工作(保存进程状态)来处理中断并做出响应。对于慢速的设备,这类方式是没问题的,因为处理他们的请求对cpu来说是小case。而对于高速设备(比如网卡)来说,不要只用中断(push)的方式,而应该结合使用buffer + poll(拉取)的模式,反过来让CPU定时来轮询你的buffer数据,然后批量读取。调度可以让一个CPU达到并发处理多个线程的能力,让外部用户看起来是多个任务在同时进行工作的。当然并不是所有分时操作系统都是通过公平的分时调度来实现的,也有通过优先级抢占式来实现的,但最终的模式还是需要内核来做好统一的分配和管控,而不是由着用户进程乱来。内核觉得你需要被调度,你才会被调度。

分布式系统中,对于消息中间件的使用,要结合实际应用场景。如果消息量不大的场景,用生产者push的方式是非常好用的,结构简单,处理也简单。如果是消息量巨大的场景,应该用消费者poll的方式,根据自己的消费能力来拉取消息,再进行消息的消费处理,否则容易被天量的消息压垮。这样在消费能力不足的时候,也能扩展消费者节点,来保障消息的及时处理。

隔离(内存页表,虚拟内存->物理内存,地址空间)

操作系统管理的内存单位是页(page),并未每个应用进程分配自己单独的地址空间。我们知道每个进程的地址空间都是从0开始的,那么操作系统为了让各应用进程读取到的内存空间不会互相干扰,引入虚拟内存的机制。应用进程实际获取的地址空间是虚拟内存空间,而虚拟内存空间到物理内存空间的映射就是有操作系统来完成的。操作系统在时间片给到某个应用进程运行的时候,会去加载应用进程的地址映射数据到寄存器里,以保证应用进行运行的正确性,也实现了虚拟地址到物理地址空间的映射。内核可以通过 page fault 可以实现内存的懒加载,节省内存的初始消耗(按需),在进程的fork、mmap操作的时候很有用。(page fault 会影响一定的性能,属于是用时间换空间的优化手段)。发生page fault的时候,其实就会强制进入内核空间,跳转过程会有一些性能损耗。还有一个优化手段是通过SSW区域记录全零的page映射,比如分配N多page,都是全零的,可以把这些page地址都指向同一个全零的物理地址空间即可(设置为只读),然后在发生page fault的时候,再alloc一份真实的全零page进行操作。隔离性是确保安全性的前提,进程之间不应该互相干扰,用户进程也不应该破坏内核进程。

在分布式系统中,一个节点(node)就相当于一个CPU,节点上的本地内存就可以当作CPU的寄存器,而集中式缓存中间件(memcached、redis等)就相当于内存。需要高速运算的在本地内存解决,需要多机共享的才放到中间件中。现实中的分布式系统,往往一个业务会有自己独立的缓存集群可以使用,不像操作系统,内存是独一份的,要引入命名空间(namespace)来做好不同进程(业务)的隔离。

内存的申请与加载策略,一般有饥饿加载和懒加载,饥饿加载就是申请的时候就直接分配好,懒接在就是申请的时候不分配,但需要使用的时候再分配。懒加载策略在软件开发中也是一种常用的策略,即按需加载,以减少不必要的内存空间浪费。在云计算平台中,经常用这种类似的策略来实现计算资源的超卖,表面上看卖给你是4核8G的CPU,实际上你的业务可能大多数时候值需要2核2G,没必要一开始就分配给你那么多资源,可以最大限度的赚取超卖的利润。全零的内存page其实是一种压缩策略,因为大多数内存在分配的时候都是全零的,那么大家在读的时候都用同一个全零的page也不会导致出错,还能节省大量的空间。

安全(可以失败,不能出错,内核空间与用户空间,系统调用)

用户进程通过trap操作跳入到内核空间,内核空间有一些专属的寄存器可以使用。内核的安全不是说要防止用户进程写了什么安全漏洞,而是要防止用户空间的进程反过来把内核干死,这是内核的底线思维,所以安全上一定是我授予了你什么权限你就只能用这些权限,丝毫不能越界。在分布式系统中,也是类似,不能因为一个用户调用把你系统搞死了,要提前做好防猝死的设计。所以鉴权在分布式系统中也是一个非常核心的模块。此外就是主动拒绝(panic进程),当系统没有足够的资源可以服务你的用户程序的时候,就应该主动say no,我能力不足,你先挂了吧。

微内核(适合嵌入式设备)与宏内核(适合大型计算设备:PC、服务器、手机等)

微内核与宏内核是两种不同的系统设计理念,不存在谁更好谁更差的却别,微内核与宏内核的系统目标是有差别的。微内核是为了各子系统之间解耦,可维护性更强,bug也更少,但个子系统之间的调用会消耗一些系统资源。宏内核更多是为了性能,整个操作系统都在一个内核里,在内核空间内的调用性能更高,但整体测试会比较困难,容易遗留没被注意到的bug。

在分布式系统领域,也有类似的设计。有单体应用(但是能多节点部署),也有微服务应用。单体应用的好处就是服务之间的调用都是本地化的,无需通过网络再跑到其他节点上去来回折腾,性能强悍,缺点是代码仓库容易堆积成山(一不小心就变成屎山),任何修改发布都会影响整个系统,测试起来也会比较困难,适合小微型的研发团队;而微服务的应用之间互相调用是需要通过网络分发的,服务拆的约细,网络上的消耗就自然越大(即使是内网环境,网络也有开销的。曾经我就遇到过内网的服务调用,把网卡写爆了的经历),这些的微服务不可避免的缺点,但微服务的好处是代码模块清晰(前提是有好设计),更容易维护,任何修改发布可以局限在某个小服务模块上,单模块测试比较简单,可以让专业的人【只】干专业的事,适合大型的产研团队。

锁能保证共享数据准确,但有串行化问题,锁是多核CPU的性能杀手。自旋锁必须依靠硬件级别的CAS/TAS才能实现(compare/test and set/swap)。同一时间只有一个cpu能获得同一把锁(锁有id的)。

读写锁(ReadWriteLock):读写锁通过给锁的读写增加计数器,写的时候(计数为-1)不能读写,读的时候(计数从0开始自增,每加一个读,技术加1)不能写,读的时候可以读,实际上也没真正锁定,表面上看起来是读写分离了,但因为读的计数器需要判断原有读的计数是不是已经被release的,实际上会有一些多余的性能浪费。

对于单向链结构(单向链表、树)的数据结构,可以通过RCU(read copy update)算法来解决读写并发的问题:即写操作不直接对数据结构加锁,而是新增一个要更新的元素,指向原元素指向的下游节点,再通过原子操作把上游节点的指针指向新的元素(这个过程只需要对写操作加锁,对读操作几乎没其他开销)。这样一来,读到的数据,要么是更新前的要么是更新后的,保持一致。但这里需要谨防编译器对一些指令重排序,所以要提前声明对这类指令不要做重排序。实际上也要增加一些约束条件,RCU适合读多写少的场景,所以会浪费一定的写性能,来提升读写的并发性。

在分布式系统中,要处理一些共享数据(同时读写、或者同时写)的时候,也是需要引入分布式锁的。分布式锁基本上也是锁在单一的CPU上,所以对锁的粒度设计是一个技术活。设计得太大,容易把整个系统串行化,分布式就失去了意义;设计的太细,可能找不到适合做这类锁的中间件(锁的句柄消耗内存,锁的同步消耗网络和CPU),所以锁的粒度设计,不论是在单机(多CPU)环境还是在分布式环境中,都是一门需要权衡的艺术。此外在分布式系统中,任务处理的设计也是一门学问,尽量不要让多个并发的任务处理共享的数据块,如果无法避免,可以考虑把这些处理通过某种方式集中到单一节点(这种节点可以通过一致性哈希技术扩展)上处理,这样我们可以利用单节点的本地CPU锁来处理并发冲突问题,而不用引入全局性的分布式锁,降低系统在处理此类任务时的集群系统串行化风险。

线程管理

每个CPU都有一个中断器,定期发起中断,传到内核中。 内核的中断处理程序会响应中断,并从用户进程中接管CPU使用权,然后把这个CPU使用权出让(yield)给CPU调度器(scheduler),CPU调度器决定下一次CPU给哪个线程使用。线程或者进程的切换过程就是CPU状态的切换过程,也叫context switch。 CPU的状态其实就是每个CPU拥有的寄存器的值,通过寄存器和内存数据的交换,达到保留前一个进程/线程执行状态的目的。主要的状态就是程序当前的指令地址以及程序对应的内存地址空间信息。除了CPU的调度器会发生切换,进程也可以主动发起切换(比如进程退出,被杀死等等)。进程在sleep的时候也会主动出让CPU。进程进入sleep不一定是进程主动sleep,如果在等待某个外部设备的数据传输,也会进入sleep,从而出让CPU,等这些设备准备完毕(读、写)之后会发起中断,从新唤醒这个线程。

分布式任务中一般不太有这种细时间片的任务调度,一个任务一般可以占有CPU(指节点计算资源)较长的时间。这里可以借鉴的是任务的生命周期管理上,可以额通核心模块来统一调度,处理的开始和结束都由核心模块来,任务自己不用考虑调度上的麻烦事,他只需要知道自己会在大致准确的时间会被执行就OK了。这样调度模块也能清晰的知道某个任务目前的运行状态(就绪、运行中、失败、完成),方便开发者通过这些信息对任务进行管理和维护。

sleep/wakeup

就是wait/notify机制,sleep的时候会把进程状态变成sleeping,并让出(yield)cpu,wakeup之后会改变状态为runable,等待cpu调度。cpu调度只会扫描那些runable的线程。sleep和wakeup之间通过一个通道码来关联(tx_chan)。wakeup一般由设备的中断信号来触发,sleep和wakeup中间通过同一个进程锁来保证线程状态的一致性,通过chan来准确找到sleep和需要wakeup的线程。semaphore信号量也可以用来处理多线程协调的问题,但更依赖计数器,如果不是计数器的场景,sleep和wakeup更直观也更通用。

在消息中间件的Pub/Sub设计模式中,一般也会有通道(channel)来关联发送端和接收端,这样不会导致消息的乱套,一个消息只发给需要的接受者。

exit/wait/kill

exit是进程退出的时候会调用,而即使调用了exit,该进程的资源并没有真实释放。exit更多是把进程状态设置为zombie,然后唤醒父进程,父进程来做清理和释放操作。所有的进程都有父进程(除了操作系统的init进程,pid=1,一直在循环运行),普通进程执行的时候是init进程fork出来的子进程,然后子进程会通过exec系统调用装载目标应用程序,之后init会wait 子进程的结束信号(exit)。当子进程退出后,父进程才会开始清理子进程占用的空间。 普通进程自己fork出来的子进程也是类似,wait他的子进程在exit之后,清理子进程的空间。当父进程先于子进程退出时,他会把他的子进程的父进程ID设置为init进程的pid(也就是1),然后子进程自己退出的时候,受init进程接管并清理和释放子进程占用的空间。kill是比较温和的,只是把进程的killed状态设置为1,并唤醒他,然后内核线程会帮他调用exit系统调用。操作系统的init进程启动后就会调用shell进程,而shell进程中执行的指令,实际上都是靠init fork出来再从这个被fork出来的子进程中exec的。

在分布式系统的调度中,也可以采用类似的策略,各个任务不需要关注自己占用的空间是否被释放了,而是关注自己的运行状态即可。只有状态是runnable的进程才会被调度,其他的任务要么在等待(sleepping)中,要么准备被清理。调度系统可以通过这些状态来控制任务的运行。

文件系统(filesytem或fs)

从底层到上层,依次为 disk、buffer cache、logging、inode cache、inode、file discriptor。 disk就是实际存储的磁盘硬件,其他层都位于内存中。扇区(sector)是磁盘驱动器的最小读写单元,块(block)是操作系统操作磁盘读写的最小单元,两者的大小不一定一致,块大小往往是扇区大小的倍数,当然也经常被混淆。可以把磁盘看做一个巨大的block数组,操作系统在这个数组之前读写数据。文件系统的组织方式有点类似于网络协议的组织方式,前面放表头和元数据,元数据定义了文件索引方式和长度,后面就存放文件。inode 有 type(文件还是目录)、nlink(被链接了多少次)、size(文件长度)几个元属性,后面就放block number,其中block number分为直接和间接的,直接的block number主要方面小文件的快速访问,而间接的block number是为了用于扩展文件大小,而且不影响内存空间。文件系统要想从失败中恢复,需要引入日志的机制(logging)。日志可以先把数据写在内存中,在commit的时候再落盘,然后再根据commit log来把数据实际写到(install)磁盘里。有了commit log之后,install的流程是觅等的,多次操作不会改变数据的准确性。只要有commit log就能从失败中恢复数据。 如果commit log都没落盘,可以认为这条数据写失败了(此时应该抛异常、报错),所以logging 的机制,可以让看起来非常复杂的写磁盘操作,变成原子化的,也是要么成功,要么失败,只不过有些成功的不是实时的而已。

这在数据库系统和很多分布式事务组件中是非常常见的设计。如果涉及多个写操作指令,要保证其原子性,就需要引入logging机制(采用write ahead rule原则)。将多个写操作写写到log里,再把log的单一commit作为原子性的保障。log commit成功了,就可以开始从log中install所有的写操作。如果写log没成功,就是整个事务的失败,并不会发生任何写记录。而log commit成功了,install 一定会从log记录里面把实际的写操作install到实际的数据库系统记录里。注意这里的install每一步操作都是觅等的,如果install到一半失败了,下次系统恢复时候会继续重新install,直到成功为止(最终一致性)。在文件系统写磁盘的过程中,一般会有内存buffer来提升系统性能,而不是让每次系统调用都落盘才返回。上层的写可以提供接口,表明是要fsync的写还是async的写。对于大多数的应用程序来说,写磁盘后需要马上读数据的概率并不高,所以大多数此类应用可以通过buffer/cache的方式来提升磁盘的读写性能。在分布式系统中,缓存也是极其重要的中间件,通过缓存急速提升性能(对比磁盘读和内存读的开销),这些是需要根据具体的应用场景来定的。缓存的数据如何与磁盘的数据保持一致,也是一个技术活,防止分布式缓存中的脏数据是一个常见的话题。一些分布式事务的组件也是通过类似commitlog(甚至有undolog)来实现分布式事务的。

发表评论

Protected by WP Anti Spam

昵称

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据

沙发空缺中,还不快抢~