日常学习

Art of UNIX programming

October 21, 2017

The Art of unix programming

哲学

哲学基础:

unix 哲学并不算是一种正规设计方法, 不打算从计算机科学的理论高度产生出完美的软件, 那些毫无动力、松松垮垮的,薪水微薄的程序员,能在短短期限内,如同神灵附体般的创造出稳定而又新颖的软件, 这只不过是经理人永远的梦呓罢了。 (社会经济学基础)
unix 哲学,是自下而上的,注重实效,立足于丰富的经验,你不会在正规的方法学和标准中找到她,她更接近与隐性的 半本能知识,UNIX 文化所传播的 专业经验。

模块性: 保持清晰,保持简洁

代码划分,又一个自然的层次体系, 一开始,一切都是机器吗,最早的过程语言引入了子程序来划分代码的概念,再后来发明了独立地址空间和可以互相通讯的进程,今天,习以为常的把程序系统分布到个个主机上。

要编写复杂的软件又不至于一败涂地的唯一方法: 用定义清晰的接口把若干简单模块组合起来,如此一来,多数问题只会出现在局部。

  1. 封装和最佳模块的大小: 模块直接通过应用程序编程接口(API) 来进行通信。
    1. 实现层面API,阻止各自的内部细节被其他模块知晓。
    2. 设计层面, API,定义了真个系统(验证API是否定义良好,试着使用纯人类语言来描述设计,能够把事情说清楚?先开始定义接口,进行描述,然后开始实践,使用代码阐述设计。)
    3. 模块过大或者过小都会造成更多的bug, 400-800 之间的代码是最佳点。
  2. 紧凑性和正交性:
    1. 紧凑性: 一个设计是否能够被人脑理解,需不需要操作手册,引导指南?(功能集合)
    2. 正交性: 有助于是复杂设计也能紧凑的最重要特性之一, 纯粹的设计中,任何操作都应该是没有副作用的。(不知道为什么是使复杂设计紧凑的重要特性,而不是,将模块解耦?所以模块越紧凑越 少耦合)《程序员修炼之道》对其中有很深入的讨论。
    3. SPOT原则(Single Point of Truth, DRY,Don’t repeat yourself), 保持任何一个知识点,在系通中是唯一的,明确的。
    如果有大量的样板式代码,是不是可以用单一的高层次的表现形式来生成呢?
  3. 自顶向下设计和自底向上设计
    1. 区分: 问问设计是否围绕 主事件循环 组织的。还是围绕着主循环可能调用的所有操的服务库组织的。自顶向下的设计者通常先考虑程序的主事件循环,然后才插入具体的事件。自底向上通常考虑封装具体的任务,然后再按照某种次序将这些操作粘合在一起。
    2. 应该双管齐下,自底向上中,能够很好的封装操作原语,从而易于变动,重用底层代码,但是容易做与逻辑无关的工作。自顶向下:可能面临应用逻辑需要的跟真正能够实现的操作原语不同。从而让然封装底层的代码操作。
    3. 双管齐下的设计下:就需要胶合层的出现,来缓解冲突。
  4. unix 和 面向对象
    1. 面向对象编程中,作用于具体的数据结构的函数,和数据一起封装在 可视为单元的一个对象中。非OO中,数据跟函数的联系变的毫无规律。
    2. OO的设计理念在 图形系统,图形用户界面中得到非常多的认可。然而,其他领域则很难发现有多少显著的优点。
    3. OO语言鼓励 『复杂的抽象层次』, 在简单事情中,使事情变得很复杂。使程序员容易陷入过度分层的抽象中,过多的层次破坏了透明性。无在头脑中理清楚代码到底在干什么、如何运行的。

好协议产生好实践

透明性:来点光(Let There Be light)最有效的方法: 就是不要在具体操作的代码上叠放太多的抽象层

需要思考的问题:

  1. 程序调用层次最大的静态深度是多少?
  2. 代码是否具有强大、明显的不变性质。不变性质帮助人们推演代码和发现有问题的情况
  3. 各个函数是否正交?是否有太多的特征标记和模式位,使得一个调用要完成多个任务,完全避免模式标志会导致混乱, 里面包含太多的一模一样的代码,但是频繁的使用模式标志更容易产生错误。(代码中常见)
  4. 程序的数据结构或者分类和他们所代表的外部实体之间,是否存在一对一的映射?(不必要,设计中不必一对一)
  5. 是否容易找到给定函数的部分代码?不论单个模块,函数,需要花多少时间读懂?
  6. 代码是增加了特殊情况还是避免了特殊情况? 每一个特殊情况可能对任何其他特殊情况产生影响和隐含的冲突,特殊情况是的代码更难以理解。
  7. 代码简单最好,但是如果代码很好的解决了以上问题,则代码也可以复杂。而且不会对维护人员造成认知负担。

多道程序设计: 分离进程为独立的功能

Unix 最具特点的程序模块化技法: 就是将大型程序分解成为多个协作进程。但是,几乎没有好的实践方法。尽管将程序划分成协作进程带来了全局复杂度的降低,蛋代价是我们必须更多的关注在进程间传递消息和命令的协议设计。和 通信各方设计状态机的问题。

  1. Unix为多进程提供的有:
    1. 降低多进程 生成的开销
    2. 提供方法、IO、管道、消息传递、套接字 简化进程通信
    3. 提倡使用能有管道、套接字传递的简单、透明的文本数据格式
  2. 进程: 为了降低复杂度,而非为了利用并发提升性能。线程: 为了提高性能,然而,线程并不是降低而是提高的全局复杂度。除非万不得已,不要使用线程。
  3. Unix IPC 方法分类:
    1. shell调用其他程序,这种情形的要点在于, 专门程序在运行时不需要跟父进程交流,只要任意一方接受了 另外一个程序的输入。
    2. 管道、重定向、过滤器: 管道线中的所有阶段的程序是并发运行的。注意到这一点很重要,每一段在等待前一段的输出作为输入。管道已经成为老古董,被套接字取代。
    3. 从进程
    4. 对等进程间通讯: 通常意味着数据能够自由的双向流动。
    • 临时文件: 文件名字冲突(shell脚本中的管理实在临时文件命中包含’$$’,这个shell变量将被展开为载入shell进程的ID, 从而保证文件名的唯一性)
    • 信号: Unix信号是一种软中断形式,每个信号对接受进程产生默认作用(杀掉它),进程可以声明 信号处理程序,让信号处理程序覆盖默认行为,信号最初被涉及到Unix中,最初是最为操作系统就某些错误或关键事件通知程序的一种方式, 举例来说, SIGHUP信号,会在会话结束时被发送给每一个该指定终端会话启动的程序,例: 在用户键入中断(Ctrl-c)时候, 发送给当前每一个连接键盘的程序,然而,信号经常被用作,守护程序的控制通道,随着信号IPC经常使用的一种技法是所谓的pidfile, 需要信号的程序会向已知的位置写入一个包含进程ID的文件(/var/run)其他程序可以通过该文件来获取PID, 如果守护程序只允许一个实例运行,该pidfile, 也作为隐含的文件锁使用。
    • 套接字: 一种封装网络数据访问的方法, 使用协议族来告诉网络层如何解释套接字的名称, AF_UNIX作为同一台机器上两个进程之间的通信方式.
    • 共享内存: 要求生产者和消费者都必须在一个机器上,但是如果通信进程能够访问同一块物理内存,则共享内存为最快的信息传递方法。需要处理竞争和死锁问题.
    • 要避免的问题和方法
      1. 废弃的Unix IPC方法, System V IPC, Steams
      2. 远程过程调用(RPC), 最成功的RPC应用,是网络文件系统,都是那些在应用定义域上本来就只涉及很少量简单数据类型的应用。支持RPC的理由通常是, 允许更丰富的接口,但是更不简介,(Unix 强烈赞成使用透明,可显的接口, 这是Unix文化不断坚持文本协议的动力)。RPC旺旺更多延迟的原因有: a: 无法准确估算一个指令调用会涉及到多少数据的列表和散列,b:RPC模型鼓励程序员忽视网络交易成本,造成网络时间消耗。
      3. 线程: 共享全局内存,在这个共享地址空间管理竞争和临界区的任务相当困难,增加整体复杂度。随着锁定机制复杂度的增加意外交互作用所造成的竞争和死锁机会也会增加。锁定共享数据结构以防止互相干涉的开销非常昂贵,最关键的难题在于,各个系统中实现的标准,没有标准,所以不可能进行移植。
      4. 线程的使用应该是最后一招而不是第一招,如果能够使用优先的共享内存和信号量,使用SIGIO的一部I/O,poll,select 而不是使用线程,那就保持简单。
      5. 线程、远程过程调用、重量级的面向对象设计结合使用的时候,非常危险,如果你被邀请加入到使用这三者的项目中,逃之夭夭并不丢面子(快屁股走人,但是现在的似乎并不少,因为大家实在是现有的稳定的软件API,进行隔离,并没有自己写bug ಥ_ಥ)
      6. 编程是控制复杂度。能够管理复杂度的工具是好工具,但是如果不是,扔掉!永远不要忘记这一点,它是UNIX智慧的重要组成部分。

微型语言:

对软件错误模式进行大量研究得出的一个一致的结论就是: 程序员每百行代码出错率,和所使用的编程语言在很大程度上相关。更高级的语言可以使用更少的行数完成更多的任务,也意味着更少的bug.

生成: 提升规格说明的层次

人类更善于肉眼观察数据,而不是推到控制流程。不同的方式在透明性和清晰性方面具有非常显著的差别。数据比程序更容易驾驭。尽可能的把设计的复杂度从程序代码中转移到数据中是个很好的实践。

  1. 数据驱动编程
    需要把代码跟代码作用的数据结构划分清楚。
    跟面向对象区分是: 数据驱动编程中,数据不仅仅是某个对象的状态,实际上还定义了程序的控制流程。 b:OO首先考虑的封装,而数据驱动编程看中的事编写尽可能少的固定代码。

配置: Starting on the Right foot

  1. 配置在哪里?
    * /etc 下的运行控制文件(配置文件)
    * 有系统设定的环境变量
    * 用户主目录下的控制文件
    * 用户设置的环境变量
    * 启动程序命令行所传递的开关、参数
   后面的会覆盖前面的参数
   考虑使用何种机制想程序传递配置数据时候, 要考虑参数的使用周期。
    1. 对调用时可能发生变化项: 使用命令开关。
    2. 改动很少但是确实应该由每个用户控制的项: 用户主目录的运行控制文件
    3. 需要管理员控制,而不是用户能够改变的系统级项: 系统的控制文件
  1. 运行控制文件(配置文件的格式)
    1. 通常只在程序启动时,一次性读取,因此性能不是主要考虑的问题, 互用性和透明性要求我们使用文本格式。
    2. 特例: 如果程序是某种解释器,那么应该是用 该语言语言写成,并在启动时候,执行命令文件。(mina deploy.rb, 不知道静态语言是不是会费点劲?)

优化: Optimization

关于性能优化, Unix经验告诉我们,最主要的就是如何知道合适不去优化, 其次,最有效的优化往往是优化之外的其他事情,比如干净的设计。

  1. 程序员工具箱中最强大的优化技术就是不做优化
  2. 先估量,后优化。

复杂度: 尽可能简单,但是别简单的过了头(As simple as possible, but Note Simpler)

偶然复杂 违反SPOT原则 过早优化 非正交性
选择复杂度 方法学开销 别的一切 有效功能
本质复杂度 开发工具 核心数据结构 功能需求
  代码库规模 实现复杂度 接口复杂度

偶然复杂度经常来源于

  1. 接口设计并非正交– 即 没有仔细的分解接口操作以使得每个操作只完成一件事情。
  2. 过早优化
    本质接口复杂度: 通常无法去除,除非调整软件的基本功能需求,代码库的本质大小同选择的开发工具有关,其中最重要的因素可能就是实现语言的选择。

复杂度的不同来源必须以不同的方法应对: 代码库规模可以采用更好的工具来解决, 实现复杂度可以选择更好的算法来处理,接口的复杂度必须着眼于更好的交互设计。
处理各种复杂度: 必然更依赖于见识而非方法,通过发现更简单的方法,可以去除偶然复杂度,依赖上下文环境判断哪些功能值得去做,可以去除选择复杂度。而要去除本质复杂度, 只能依赖于对现实真谛的洞察和顿悟,从根本上重新定义所要解决的问题。

  1. 当简洁不能胜任:

对于Unix坚持简单的精神,常常伴随着一种错误的理解方式, Unix程序员常常认为似乎所有的可能复杂度都是偶然复杂度, 更为甚者, 在Unix传统中存在一个强烈的偏好,宁可去掉功能,也不能接受可能的复杂性。
计算资源以及人类的思考,同财富一样,不是靠储藏而是靠消费来证明其价值的。同其他美学形势一样,我们需要主义何时设计上的简约已经不在是有价值的自律形式,——而开始成为一件伪装的苦行者外衣—— 实际上把美德作为接口来敷衍工作的纵容方式。

  1. 编辑器的适当规模
    所有真正有用的程序都想变成瑞士军刀, Unix世界之外大型的成功商业整合应用程序套件通常也证实了这一点,而且直接挑战了Unix的最简(基础)哲学。Zawinski
    定律是正确的,他表明有些程序需要小巧,有些程序需要庞大,但中间的路是行不通的。vi编辑器的表面问题可以归咎于历史,然而更深次的问题应该追溯到增加功能的压力同vi信徒,添加新功能根本考虑不到对整体设计的复杂度影响。
    Emacs和Wily的例子进一步证明, 为什么有些程序需要做的如此庞大: 这样几个相关任务就可以共享环境。从实现者的角度,编辑和版本控制是独立的,但是用户更愿意有一个大的环境让他们能够指向文本部分,无需要在程序之间切来切去。
    更普遍的, 让我们假设整个Unix环境可以视作是社区的单一设计工作。那么『小巧锐利工具』的教义,降低接口复杂度和代码库规模的压力,可能正好导向过手工陷阱——– 用户不得不自己维护所有共享的上下文环境,因为工具并不会替他们完成此项工作。
    选择 复杂度的价值依赖于对目标的选择。而在所有与任务相关的面相文本工具间共享上下文环境的能力是非常有价值的。

  2. Emacs 是个反传统Unix的例子吗?

    Emacs 这样庞大的程序会使Unix程序员极不舒服,恰恰因为他们强迫我们面对这些冲突, 这些程序表明, 旧学派的Unix的简约主义作为一个原则虽然有其价值,蛋我们可能已经陷入教条主义的错误中。

  3. Unix风格的小巧伶俐工具存在数据共享的困难, 除非他们生存在彼此之间通讯便利的框架中,Emacs就是这样的一个框架,而对共享上下文环境的统一管理,正是其选择复杂度换来的(共享上下文统一管理的实际效果就是用户不需要负担底层的命名和资源管理问题, 旧学派的unix中唯一的框架就是管道,重定向以及Shell,而共享上下文本质上就是文件系统本身,但这并不是进化的终点。)

最简原则暗示: 选择需要管理的上下文环境,并按照边界所允许的最小化方式架构程序。 这就是『尽可能的简单,而不是过于简单』,集中关注选择共享上下文环境,实际上,这并不仅仅适用于框架,也是用于应用和程序系统。

然而, 究竟共享上下文环境该有多大实在很容易草率对待,隐藏在Zawinski定律后的压力往往驱使应用程序需要为便利性而共享上下文环境,很容易因为负载太多任务, 太多需求设想而最终失败,也很容易就把程序编制的过于复杂、臃肿、庞大。
矫正这种趋势的方法直接来源于旧学派Unix的赞美诗集。这就是吝啬原则: 只有实证了其他方法行不通时才写庞大的程序—-也就是,已经尝试过分解问题但遭遇失败。 格言表明对待庞大程序的一种严谨怀疑态度以及一种谨慎的做法: 首先寻找小巧程序的解决方案,如果单个小程序无法完成任务,尝试在现有框架结构内构造一个协作小程序工具包来解决问题,如果两者都失败了,才可以自由的构建一个巨型的程序,而不会觉得已经完败于设计。

但编制一个框架时,牢记分离原则。框架实际值,尽可能少的包含策略。再多数情况中,根本就不需要策略,尽可能多的将行为分解到使用框架的模块中。编制或重用框架的好处之一,是能够有益于将『不这样做会是大块策略』的东西分离到独立的模块,模式或工具—- 可以有效的同其他程序重新组合起来的部分中去。
这些准则是颇有价值和启发性的方法,但是Unix传统深处的这种矛盾冲突,并不能将任何给定的工程划分为合理的最佳规模,并分而治之。具体情况具体分析,而锻炼良好的判断力和品位恰好是软件设计者所追求的。行程才是目的,顿悟在每日的实践中。

未来: 危机与机遇

Unix 设计中的问题:

  1. Unix文件就是一大袋字节: 没有其他的文件属性, 特别是没有能力存储有关文件类型的信息。
  2. Unix对GUI的支持羸弱
  3. 文件删除不可撤销
  4. Unix假定文件系统是静态的(假定程序的运行总是暂时的,所以文件和目录环境在整个执行中都是可以作为静态的,如果某个指定的文件或目录发生了改变,没有标准的、良好的方式让系统通知应用程序,当编写长期运行的用户界面软件时候,这将成为重大的问题。)
  5. Unix API并没有异常,C语言缺乏抛出附带数据的命名异常机制。