对ROMA、散列表、ACID、进程间通信等在部分章节重复叙述,有点啰嗦。
译者是周自恒。
6.2 UNIX管道 图8 并行化后速度提升比例的公式
6.2 UNIX管道 图9 C语言编译流程
所谓技术,就是用来解决现实问题的手段。
创造出一种人类和计算机都能够理解的语言(编程语言),并通过这样的语言将人类的意图传达给计算机,这样的行为就叫做编程。
由于通过提高单一CPU的密度来实现性能的提升已经非常困难,因此在一个LSI中集成多个CPU的方法逐渐成为主流。
“巴纳姆效应”是一种心理学现象,指的是将一些原本是放之四海而皆准的、模棱两可的一般性描述往自己身上套,并认为这些描述对自己是准确的。
很多算命先生和自称预言家的人,实际上都是利用了被称为“冷读术”(Cold reading)和“热读术”(Hotreading)的技巧,来让人们相信他们真有不同寻常的“能力”。
冷读术,就是通过观察对方言行举止中的一些细微之处来进行揣测的技巧,就像夏洛克·福尔摩斯对他的委托人所运用的那种技巧差不多。
热读术则是通过事先对对方进行详细的调查,来准确说出对方的情况(逢场作戏)。由助记符自动生成机器语言的汇编器,以及由人类较易懂的算式型语句生成机器语言的编译器,当时都被认为是革新性的技术,被称为“自动编程”。
从编程语言的进化过程来看,一个显著的关键词就是“抽象化”。抽象化就是提供一个抽象的概念,使用者即便不具备关于其内部详细情况的知识,也能够对其进行运用。由于不必了解其内部的情况,因此也被称为“黑箱化”。
在数据抽象化的延长线上,就自然而然产生了面向对象编程的概念。所谓对象,就是抽象化的数据本身,因此面向对象和数据抽象化之间仅仅隔了薄薄的一张纸。
人类一次所能掌握的概念数量是有限的,有说法称,大部分人一次只能驾驭7±2个左右的概念。
在讲述软件开发的一本名著《人月神话》中,作者弗雷德里克·布鲁克斯写道:无论使用什么编程语言,生产一条基本语句所需要的工数几乎是一定的。
在我看来,编程语言的进化动机,不是工具和语言本身的简化,而是将通过这些工具和语言所得到的结果(解决方案)更简洁地表达出来。
基于上述观点,如果要我来预测100年后编程语言的样子,我认为应该会是下面三种情况的其中之一:
(1)变化不大。编程语言的写法从20世纪80年代开始就几乎没有什么进化,今后即便出现新的写法,也只是现有写法的变形而已。(从发展上来看,是比较悲观的未来)
(2) 使用编程语言来编程这个行为本身不存在了。人类可以通过和计算机对话(大概是用自然语言)来查询和处理信息。(类似《星际迷航》中的世界,对于编程语言家来说是比较失落的未来)
(3) 发明了采用更高抽象度写法的编程语言。这种语言在现在很难想象,不过应该是比现在更加强调What,而对于如何解决问题的How部分的细节,则不再需要人类去过问。(难以预测的未来)20年后的语言,应该是在分布处理(多台计算机协作处理)和并行处理(多个CPU协作处理)功能上进行强化,使得开发者不需要特别花心思就能够使用这些功能。
总之,未来的编程语言可能不会像过去的编程语言那样,让语言本身单独存在,而是和编辑器、调试器、性能分析器等开发工具相互配合,以达到提高整体生产效率的目的。
所谓DSL(Domain Specific Language,特定领域语言),是指利用为特定领域(Domain)所专门设计的词汇和语法,简化程序设计过程,提高生产效率的技术,同时也让非编程领域专家的人直接描述逻辑成为可能。DSL的优点是,可以直接使用其对象领域中的概念,集中描述“想要做到什么”(What)的部分,而不必对“如何做到”(How)进行描述。
像以这些迷你语言为代表的,由专用的语言引擎来实现的DSL,称为外部DSL。
内部DSL并不是创造一种新的语言,而是在现有语言中实现DSL,而作为DSL基础的这种现有语言,称为宿主语言。仔细想想就会发现,不涉及对象领域的内部细节,而是在高级层面上进行描述,这就是近半个世纪以来编程语言进化的方向——抽象化。
据说,在诞生了UNIX的AT&T贝尔实验室[插图]中有一句名言:库设计就是语言设计(Library design is languagedesign)。
我们可以通过“编程达人”大卫·托马斯[插图]的这句话理解这一过程:
Programming is a process of designing DSL foryour own application.
(编程就是为自己的应用程序设计DSL的过程)曾经在诸多Ruby相关活动中发表过演讲的著名Rubyist——Glenn Vanderburg认为,构成一种优秀的(内部)DSL的要素包括下列5种:
❑ 上下文(Context)
❑ 语句(Sentence)
❑ 单位(Unit)
❑ 词汇(Vocabulary)
❑ 层次结构(Hierarchy)描述数据所具有的结构的数据,也就是关于数据本身的数据,被称为元数据(Metadata)。
小说中的角色如果知道自己所身处的故事是虚构的,这样的小说就被称为元小说(Metafiction)。
所谓元编程,就是“用程序来编写程序”的意思。
在Ruby和Lisp这样的语言中,由于程序本身的信息是可以被访问的,因此在程序运行过程中也可以对程序本身进行操作,这就是元编程。硬盘的容量比内存大,但相对的,速度却非常缓慢,如果和硬盘之间的数据交换过于频繁,处理速度就会下降,表面上看起来就像卡住了一样,这种现象被称为抖动(Thrushing)。应该有很多人有过计算机停止响应的经历,而造成死机的主要原因之一就是抖动。
将内存管理,尤其是内存空间的释放实现自动化,这就是GC(Garbage Collection)。
所谓垃圾(Garbage),就是需要回收的对象。
如果程序(通过某个变量等等)可能会直接或间接地引用一个对象,那么这个对象就被视为“存活”;与之相反,已经引用不到的对象被视为“死亡”。将这些“死亡”对象找出来,然后作为垃圾进行回收,这就是GC的本质。
所谓根(Root),就是判断对象是否可被引用的起始点。至于哪里才是根,不同的语言和编译器都有不同的规定,但基本上是将变量和运行栈空间作为根。主要的GC算法。
1.标记清除(Mark and Sweep)是最早开发出的GC算法(1960年)。它的原理非常简单,首先从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收。
2.标记清除算法有一个缺点,就是在分配了大量对象,并且其中只有一小部分存活的情况下,所消耗的时间会大大超过必要的值,这是因为在清除阶段还需要对大量死亡对象进行扫描。
复制收集(Copy and Collection)则试图克服这一缺点。在这种算法中,会将从根开始被引用的对象复制到另外的空间中,然后,再将复制的对象所能够引用的对象用递归的方式不断复制下去。
这种算法的另一个好处是它具有局部性(Locality)。在复制收集过程中,会按照对象被引用的顺序将对象复制到新空间中。于是,关系较近的对象被放在距离较近的内存空间中的可能性会提高,这被称为局部性。局部性高的情况下,内存缓存会更容易有效运作,程序的运行性能也能够得到提高。
3.引用计数(Reference Count)方式是GC算法中最简单也最容易实现的一种,它和标记清除方式差不多是在同一时间发明出来的。
它的基本原理是,在每个对象中保存该对象的引用计数,当引用发生增减时对计数进行更新。
实现容易是引用计数算法最大的优点。标记清除和复制收集这些GC机制在实现上都有一定难度;而引用计数方式的话,凡是有些年头的C++程序员(包括我在内),应该都曾经实现过类似的机制,可以说这种算法是相当具有普遍性的。
除此之外,当对象不再被引用的瞬间就会被释放,这也是一个优点。其他的GC机制中,要预测一个对象何时会被释放是很困难的,而在引用计数方式中则是立即被释放的。而且,由于释放操作是针对每个对象个别执行的,因此和其他算法相比,由GC而产生的中断时间(Pause time)就比较短,这也是一个优点。
引用计数最大的缺点,就是无法释放循环引用的对象。
引用计数的第二个缺点,就是必须在引用发生增减时对引用计数做出正确的增减,而如果漏掉了某个增减的话,就会引发很难找到原因的内存错误。
最后一个缺点就是,引用计数管理并不适合并行处理。进一步改良的应用方式
1.分代回收(Generational GC)的基本思路,是利用了一般性程序所具备的性质,即大部分对象都会在短时间内成为垃圾,而经过一定时间依然存活的对象往往拥有较长的寿命。
像这种只扫描新生代对象的回收操作,被称为小回收(Minor GC)。
首先从根开始一次常规扫描,找到“存活”对象。这个步骤采用标记清除或者是复制收集算法都可以,不过大多数分代回收的实现都采用了复制收集算法。
在分代回收中,会对对象的更新进行监视,将从老生代对新生代的引用,记录在一个叫做记录集(remembered set)的表中。在执行小回收的过程中,这个记录集也作为一个根来对待。
这种检查程序需要对所有涉及修改对象内容的地方进行保护,因此被称为写屏障(Write barrier)。
2.在对实时性要求很高的程序中,比起缩短GC的平均中断时间,往往更重视缩短GC的最大中断时间。
为了维持程序的实时性,不等到GC全部完成,而是将GC操作细分成多个部分逐一执行。这种方式被称为增量回收(IncrementalGC)。
3.并行回收的基本原理是,是在原有的程序运行的同时进行GC操作,这一点和增量回收是相似的。像标记清除和复制收集这样,从根开始进行扫描以判断对象生死的算法,被称为跟踪回收(Tracing GC)。
美国IBM公司沃森研究中心的David F. Bacon等人,于2004年发表了一篇题为“垃圾回收的统一理论”(A UnifiedTheory of Garbage Collection)的论文,文中阐述了一种理论,即:任何一种GC算法,都是跟踪回收和引用计数回收两种思路的组合。所谓正常化偏见(normalcy bias)指的是人们的一种心理倾向,对于一些偶然发生的情况,一旦发生了便会不自觉地忽略其危害。
使用特殊返回值这个方法不需要编程语言的支持,是一种非常简便的方法,但它有两个重大的缺点。
第一,由于对错误的检测不是强制进行的,可能会出现没有注意到发生了错误而继续运行程序的情况。
第二,原本的程序容易被错误处理埋没。在Java中调用某个方法时,对于在该方法定义中所声明的异常,如果没有用异常处理来进行捕获,且没有用throws继续抛给上层的话,就会产生一个编译错误,因为异常已经成为方法的数据类型的一部分了。像这样的异常被称为检查型异常(checked exception)。在广泛使用的编程语言中,Java应该是第一个采用检查型异常的语言。
在Icon中,还有一种叫做every的控制结构,它可以对所有的组合进行尝试,直到失败为止。Icon的这种求值方式,由于包含了“继续求值到达到某种目标为止”的含义,因此被称为目标导向求值(Goal-directed evaluation)。
Eiffiel中强调了一种称为Design by Contract(契约式设计,简称DbC)的概念。即所有的方法(在Eiffel中称为子程序)都必须规定执行前需要满足的条件和执行后需要满足的条件,当条件不能满足时,就会产生异常。
这样的思路,就是将对子程序的调用,看作是一种“只要兑现满足先验条件的约定,后验条件就必定得到满足”的契约。如果key不存在的情况完全是超出预计的,错误就应该作为异常来处理;反之,如果key不存在的情况在某种程度上是预计范围内的,那么就应该返回错误值。
所谓函数对象,顾名思义,就是作为对象来使用的函数。
所谓高阶函数,就是用函数作为参数的函数。
高阶函数这样的方式,通过将一部分处理以函数对象的形式转移到外部,从而实现了算法的通用化。
作用域(Scope)指的是变量的有效范围,也就是某个变量可以被访问的范围。
从函数对象中能够对外部变量进行访问(引用、更新),是闭包的构成要件之一。
所谓生存周期(Extent),就是变量的寿命。相对于表示程序中变量可见范围的作用域来说,生存周期这个概念指的是一个变量可以在多长的周期范围内存在并被能够被访问。
在函数对象中,将局部变量这一环境封闭起来的结构被称为闭包(Closure)。因此,C语言的函数指针并不是闭包,JavaScript的函数对象才是闭包。
“过程与数据的结合”是形容面向对象中的“对象”时经常使用的表达。对象是在数据中以方法的形式内含了过程,而闭包则是在过程中以环境的形式内含了数据。JavaScript中方法与函数的区别很模糊,同样一个函数,在作为通常函数调用时,和作为对象的方法调用时,this的值会发生变化。
所谓静态,就是不实际运行程序,仅通过程序代码的字面来确定结果的意思;而所谓动态,就是只有当运行时才确定结果的意思。
有一种叫做JIT(Just In Time)编译的技术,可以在运行时将字节码转换成机器语言,经过转换之后就可以获得和原生编译一样快的运行速度。
在JavaScript中与服务器进行异步通信的API叫做XMLHttpRequest,因此从它所衍生出的手法便被称为Ajax(Asynchronous JavaScript and XML,异步JavaScript与XML)。
所谓CGI(Common Gateway Interface, CGI,通用网关接口),是通过Web服务器的标准输入输出与程序进行交互,从而生成动态HTML页面的接口。
所谓静态,就是无需实际运行,仅根据程序代码就能确定结果的意思;而所谓动态,则是只有到了运行时才能确定结果的意思。
所谓动态运行模式,简单来说,就是运行中的程序能够识别自身,并对自身进行操作。对程序自身进行操作的编程,也被称为元编程(Metaprogramming)。
动态类型的立场是数据拥有类型,且只有数据才拥有类型;而静态类型的立场是数据拥有类型,而存放数据的变量、表达式也拥有类型,且类型是在编译时就固定的。
所谓子类型(Subtype),是指具有继承关系,或者拥有同一接口,即静态类型与数据的类型在系统上“拥有同一性质”。
不考虑某个对象到底是哪一个类的实例,而只关心其拥有怎样的行为(拥有哪些方法),这就是鸭子类型(Duck Typing)。
像这样,以类型的结构来确定可代换性的类型关系,被称为结构子类型(Structural Subtyping);另一方面,像Java这样根据声明拥有继承关系的类型具有可代换性的类型关系,被称为名义子类型(Nominal Subtyping)。
如果忘记对不需要的对象进行释放,程序所占用的内存容量就会不断增大,从而导致内存泄漏(Memory leak)bug;反过来,如果释放了仍然在使用中的对象,就会导致内存空间损坏的悬空指针(Dangling pointer)bug。
JIT是Just In Time Compiler的缩写,指的是在程序运行时将其编译为机器语言的技术。
所谓特殊化,指的是一种在将函数转换为内部表达时所运用的技术。通过假定参数为特定类型,事先准备一个特殊化的高速版本,在函数调用的开头先执行类型检查,当前提条件成立时直接运行高速版本。动态语言运行速度慢的理由之一,就是因为在运行时需要伴随大量的类型检查,而通过特殊化则可以回避这一不利因素。
所谓CoffeeScript,一言以蔽之,就是用Javascript实现的用于编写JavaScript的方便语言。
对于没有任何前提条件的查找,线性查找几乎是唯一的算法,但实际上,大多数情况下,数据和查找条件中都存在着一定的前提。利用这些前提条件,有一些算法就可以让计算量大幅度减少。
从计算量的角度来看,理想的数据结构就是散列表。散列表是表达一个对象到另一个对象的映射的数据结构。Ruby中有一种名为Hash的内建数据结构,它就是散列表。从概念上来看,由于它是一种采用非数值型索引的数组,因此也被称为“联想数组”,但在Ruby中(Perl也是一样)从内部实现上被称为Hash。而相应地,Smalltalk和Python中相当于Hash的数据结构则被称为字典(Dictionary)。
不同的数据拥有相同散列值的情况,被称为“冲突”。
在散列表的实现中,应对冲突的方法大体上可以分为链地址法(chaining)和开放地址法(open addressing)两种。
链地址法是将拥有相同散列值的元素存放在链表中,因此随着元素个数的增加,散列冲突和查询链表的时间也跟着增加,就造成了性能的损失。
开放地址法则是在遇到冲突时,再继续寻找一个新的数据存放空间(一般称为槽)。
在采用链地址法的Ruby的Hash中,当冲突产生的链表最大长度超过5时,就会增加散列表的槽数,并对散列表进行重组。另外,在采用开放地址法的Python中,当三分之二的槽被填满时,也会进行重组。布隆过滤器(Bloom filter)是一种可以判断某个数据是否存在的数据结构,或者也可以说是判断集合中是否包含某个成员的数据结构。
像布隆过滤器这样“偶尔会出错”的算法,被称为概率算法(probabilisticalgorithm)。
在分布式环境下工作的散列表被称为DHT(DistributedHash Table,分布式散列表)。
所谓C10K问题,就是Client 10000 Problem,即“在同时连接到服务器的客户端数量超过10000个的环境中,即便硬件性能足够,依然无法正常提供服务”这样一个问题。
在使用套接字(socket)的网络连接中,不能忽视第一次建立连接所需要的开销。在HTTP访问中,如果对一个一个的小数据传输请求每次都进行套接字连接,当访问数增加时,反复连接所需要的开销是相当大的。
为了避免这种浪费,从HTTP1.1开始,对同一台服务器产生的多个请求,都通过相同的套接字连接来完成,这就是keep-alive技术。在安全领域有一个“最弱连接”(Weakest link)的说法。如果往两端用力拉一条由很多环(连接)组成的锁链,其中最脆弱的一个连接会先断掉。因此,锁链整体的强度取决于其中最脆弱的一环。安全问题也是一样,整体的强度取决于其中最脆弱的部分。
每次创建一个线程,作为栈空间,一般也会产生1MB到2MB左右的内存开销。
当大量的进程导致内存开销超过物理内存容量时,每次进行进程切换都不得不产生磁盘访问,这样一来,消耗的时间太长导致操作系统整体陷入一种几乎停止响应的状态,这样的情况被称为抖动(thrashing)。
所谓文件描述符(file descriptor),就是用来表示输入输出对象的整数,例如打开的文件以及网络通信用的套接字等。文件描述符的数量也是有限制的,在Linux中默认状态下,一个进程所能打开的文件描述符的最大数量是1024个。
写时复制(Copy-on-Write)技术,就是在创建子进程时,对于所有的内存空间并非一开始就创建副本,而是先进行共享,只有当实际发生对数据的改写时才进行复制。通过这一技术,就可以避免对内存空间的浪费。
在Linux中还有一个称为OOM Killer(Out of MemoryKiller)的功能。当发生抖动时,会选择适当的进程并将其强制结束,从而对抖动做出应对。
在Linux等UNIX系操作系统中,同一台计算机上进行进程间通信的手段有以下几种:
❑ 管道(pipe)
❑ 消息(message)
❑ 信号量(semaphore)
❑ 共享内存
❑ TCP套接字
❑ UDP套接字
❑ UNIX域套接字消息、信号量和共享内存都是UNIX的System V(5)版本中加入的进程间通信API。其中消息用于数据通信,信号量用于互斥锁,共享内存用于在进程间共享内存状态。它们结合起来被称为sysvipc。
TCP是Transmission Control Protocal(传输控制协议)的缩写。
UDP则是User Datagram Protocol(用户数据报协议)的缩写。网络字节序(network byteorder),即各字节的发送顺序。
套接字所能传输的数据只是字节序列而已,如果要传输文本以外的数据,在传输前需要将数据转换为字节序列。
这种转换一般称为序列化(serialization)或者封送(marshaling)。用Apache或nginx作为反向代理,对多个Thin服务器进行请求分配的情况下,请求会由前端服务器按顺序转发给下属的Thin服务器。这种形态很像是上层服务器对下层的“发号施令”,因此又被称为推模式(push model)。
相对地,在Unicorn中,完成处理的Slave会主动去获取请求,即拉模式(pull model),因此从原理上说,不会发生某个请求被卡死在一个忙碌的服务器进程中的情况。有一种说法指出,对于网站从开始访问到显示出网页之间的等待时间,一般用户平均可以接受的极限为4秒。由于这个时间是数据传输的时间和浏览器渲染HTML时间的总和,因此Web应用程序用于处理请求的时间应尽量控制在3秒以内。
Hash(散列表)指的是通过创建键和值的配对,由键快速找到值的一种数据结构。
Hash对象是用于保存key对象到value对象之间对应关系的数据结构。这种数据结构在其他编程语言中有时也被称为Map(映像)或者Dictionary(字典)。可以超越进程的范围来保存数据的特性,在编程的世界中被称为“永久性”(persistence)。
ACID是4个单词首字母的缩写,它们分别是:Atomicity(原子性)、Consistency(一致性)、Isolation (隔离性)和Durability(持久性)。
所谓Atomicity,是指对于数据的操作只允许“全部完成”或“完全未做改变”这两种状态中的一种,而不允许任何中间状态。
所谓Consistency,是指数据库的状态必须永远满足给定的条件这一性质。
所谓Isolation,是指保持原子性的一系列操作的中间状态,不能由其他事务进行干涉这一性质,由此可以保持隔离性而避免对其他事务产生影响。
所谓Durability,是指当保持原子性的一系列操作完成时,其结果会被保存并且不会丢失这一性质。当组成计算环境的计算机数量达到几百台以上(有些情况下甚至会达到万台规模)时, ACID特性就很难满足,换句话说,ACID是不可扩展的。
对此,有人提出了CAP原理,即在大规模环境中:
❑ Consistency(一致性)
❑ Availability(可用性)
❑ Partition Tolerance(分裂容忍性)
这三个性质中,只能同时满足其中的两个。在分布式系统中,像这样“局部故障会导致整体故障”的要害,被称为Single point of failure (单一故障点),在分布式系统中是需要极力避免的。
即便现实世界如此残酷,我们却还是进行着各种交易(事务)。这样看来,即便是在某种程度上无法满足一致性的环境中,数据处理也是能够完成的。例如,网上商城的商品信息页面上明明写着“有货”,到实际提交订单的时候却变成了“缺货”,这种事已经是家常便饭了,倒也不会产生什么大问题。和银行汇款不同,其实大多数处理都不需要严格遵循ACID特性。
在这样的环境中,BASE这样的思路也许会更加合适。BASE是下列英文的缩写:
❑ Basically Available
❑ Soft-state
❑ Eventually consistent
ACID无论在任何情况下都要保持严格的一致性,是一种比较悲观的模式。而实际上数据不一致并不会经常发生,因此BASE比较重视可用性(Basically Available),但不追求状态的严密性(Soft-state),且不管过程中的情况如何,只要最终能够达成一致即可(Eventually consistent)。这种比较乐观的模式,也许更适合大规模系统。说句题外话,一开始我觉得BASE这个缩写似乎有点牵强,但其实BASE(碱)是和ACID(酸)相对的,这里面包含了一个文字游戏。所谓池(pooling),就是指对使用过的资源进行反复利用的技术。
所谓NoSQL,是一个与象征关系型数据库的SQL语言相对立而出现的名词,它是包括键-值存储在内的所有非关系型数据库的统称。
在大规模环境下使用关系型数据库,一般有水平分割和垂直分割两种分割方式。
所谓水平分割,就是将一张表中的各行数据直接分割到多个表中。
相对地,所谓垂直分割就是将一张表中的某些字段(列)分离到其他的表中。数据库系统。大体上,可以分为以下三种:
❑ 键-值存储数据库
❑ 面向文档数据库
❑ 面向对象数据库
键-值存储是一种让键和值进行关联的简单数据库,查询方式基本上限定为通过键来进行,可以理解为在关系型数据库中只能提供对“拥有特定值的记录”进行查询的功能,而且还是有限制的。
所谓面向文档数据库,是指对于键-值存储中“值”的部分,存储的不是单纯的字符串或数字,而是拥有结构的文档。
所谓面向对象数据库,是将面向对象语言中的对象直接进行永久保存,也就是当计算机断电关机之后对象也不会消失的意思。MongoDB的结构分为数据库(database)、集合(collection)、文档(document)三层。
MongoDB中虽然不支持事务,但可以支持原子操作(atomic operation)和乐观并发控制(optimistic concurrency control)。
为各种语言访问MongoDB所提供的库被称为驱动(driver)。更新操作不会半路中断,也不会留下不完整状态的操作,被称为“原子操作”。
像这种将对象与记录进行对应的库,被称为OR Mapper。其中O代表对象(Object), R代表关系(Relation),因此它就是将关系与对象进行映射的意思。
在简单的水平上,“一条记录=一个对象”这样的抽象化模型实现起来很容易,只要将SQL调用所得到的记录封装成对象就可以了。但是,当对象的调用变得越来越频繁和复杂时,就会产生性能上的问题。结果,关系型数据库中的记录,并没有成为真正意义上的对象,在特殊情况下,会暴露出抽象化中的纰漏。这样的问题,被称为抽象泄漏(leakyabstraction)。
由于MongoDB不是关系型数据库,因此这些库也不能称之为OR Mapper,而是应该称之为Object Document Mapper(OD Mapper)了吧。
在专业领域,这种数据库分割被称为Sharding或者Partitioning。这种手法的目的,是通过将数据库中大量的记录分别存放到多台服务器中,从而避免数据库服务器的瓶颈。
根据这篇文章,决定SQL数据库性能的,是客户端与服务器之间的通信开销,以及服务器上的事务处理开销。而通信开销可以通过将大部分处理放在服务器上的存储过程(Stored Procedure)在一定程度上得以解决。
而对于服务器上的处理,大致进行分类的话,主要有4个瓶颈,而对于这些瓶颈的应对就是决定性能的关键。这4个瓶颈具体如下。
日志(Logging):为了防止磁盘崩溃等故障的发生,大多数关系型数据库都会执行两次写入。即向数据库执行一次写入,再向日志执行一次写入。
事务锁(Locking):在对记录进行操作之前,为了防止其他线程对记录进行修改,需要对事务加锁。
内存锁(Latching):Latch是门闩的意思,这里是指对锁和B树等共享数据结构进行访问时所需要的一种排他处理方式,斯通布雷克管这种方式叫做Latching。
缓存管理(Buffer Management):一般来说,数据库的数据是写入到固定长度的磁盘页面中的。对于哪个数据写入哪个页面,或者是哪个页面的数据缓存在内存中,都需要由数据库进行管理。像这样“能够高速访问的数据存放地点”,被称为缓存(cache)。
如果需要对内存进行访问,则在执行取出数据这一步的时间内,整个流水线就需要等待几百个时钟周期,这样一来流水线化对指令执行速度带来的那一点提升也就被抵消了。
像这样流水线发生停顿的问题被称为气泡(bubble /pipeline stall)。产生气泡的原因有很多种,需要针对不同的原因采取不同的对策。
上述这样由于内存访问速度缓慢导致的流水线停顿问题,被称为“数据冒险”(data hazard),针对这种问题的对策,就是我们刚刚提到过的“高速缓存”。高速缓存,实际上是消耗一定数量的晶体管用作CPU内部高速存储空间,从而提升速度的一种技术。
还有其他一些原因会产生气泡,例如由于CPU内部电路等不足导致的资源冒险(resource hazard);由于条件分支导致的分支冒险(branch hazard)等。资源冒险可以通过增设内部电路来进行一定程度的缓解。所谓投机执行,就是对条件分支后的跳转目标进行预测后,不仅仅是执行取出命令的操作,还会进一步执行实际的运算操作。
流水线是一种在垂直方向上对指令处理进行重叠来提升性能的技术,相对地,在水平方向上将指令进行重叠的技术称为超标量(superscalar)。
为了充分利用流水线带来的好处,出现了一种叫做RISC的CPU架构。RISC是Reduced Instruction Set Computer(精简指令集)的缩写,它具备以下特征:
❑ 精简且高度对称的指令集。
❑ 指令长度完全相同(也有例外)。
❑ 和传统CPU相比寄存器数量更多。
❑ 运算的操作数只能为寄存器,内存中的数据需要显式地加载到寄存器中。
这样的特征所要达到的目的如下:
❑ 通过减少指令种类使电路设计简单化(高速化)。
❑ 通过统一指令的粒度使流水线更加容易维持。
❑ 根据依赖关系对指令的重新排序可通过编译器的优化来实现。与RISC相对的叫做CISC:Complex Instruction Set Computer,复杂指令集
那么,为什么执行单元无法被有效填充呢?原因在于数据之间存在相互依赖关系。既然如此,那可以将没有依赖关系的多个执行同时进行吧?这也就是所谓的超线程(HyperThreading)技术。超线程是英特尔公司的一个专有名词,这一技术的一般名称应该叫做SMT(Simultaneous Multi-Threading,同时多线程),不过为了简便起见,这里统一使用超线程一词。
所谓超线程,就是通过同时处理多个取出并执行指令的控制流程,从而将没有相互依赖关系的运算同时送入运算器中,通过这一手段,可以提高超标量的利用效率。实际上,为了同时处理多个控制流程(线程),还需要增加相应的寄存器等资源。
根据英特尔公司发布的数据,超线程最多可提升30%左右的性能。不过,为了实现这30%的性能提升,晶体管数量仅仅增加了5%。多核分为两种形式,即包含多个相同种类核心的同构多核(homogeneous multi-core),以及包含多个不同种类核心的异构多核(heterogeneous multi-core)。在异构多核中,除了通常的CPU以外,还可以包含GPU(Graphic Processing Unit,图像处理单元)和视频编码核心等。此外,包含数十个甚至数百个核心的芯片也正在研究,这被称为超多核(many-core)。
当电路变得非常精细时,就会发生一些超出经典物理而进入量子物理范畴的现象,其中一个例子就是“隧穿效应”。关于隧穿效应的详细知识在这里就省略了(因为我自己也不太明白),简单来说,即便是电流本该无法通过的绝缘体,在微观尺度上也会有少量电子能够穿透并产生微弱的电流。这样的电流被称为渗漏电流,现代CPU中有一半以上的电力都消耗在了渗漏电流上。
最近,GPGPU(General Purpose GPU,即将GPU用于图形处理之外的通用编程)受到了越来越多的关注,由于GPU与传统CPU的计算模型有着本质的区别,因此需要采用专门的编程技术。
所谓结构化文件就是拥有结构的记录的罗列。
如果要重复执行同样的操作,只要将操作过程记录到文件中,就能够很容易地作为程序来执行。像这样由“执行记录”生成的程序,被称为脚本(script),这也是之后脚本语言(script language)这一名称的辞源。
阿姆达尔定律是一个估算通过多核并行能够获得多少性能提升的经验法则,是由吉恩·阿姆达尔(Gene Amdahl,1922~)提出的,它的内容是:
(通过并行计算所获得的)系统性能提升效果,会随着无法并行的部分而产生饱和。像这样通过“事件及应对处理”的方式来工作的软件架构,被称为事件驱动模型(event driven model)。
像这样处理发生停滞的情况被称为阻塞。
在事件监视中,对事件的检测方法有两种,即边沿触发(edge trigger)和电平触发(level trigger)。这两个词原本是用在机械控制领域中的,边沿触发是指只在状态变化的瞬间发出通知,而电平触发是指在状态发生变化的整个过程中都持续发出通知。
select系统调用属于电平触发,epoll默认也是电平触发,但epoll也可以通过显式设置来实现边沿触发。我从软件开发中学会了如何提高效率,作为应用,总结出了下面几个方法:
❑ 减负
❑ 拖延
❑ 委派说点正经的,在软件开发中,如果不更换硬件,还可以用以下方法来改善软件的运行速度:
❑ 采用更好的算法
❑ 减少无谓的开销
❑ 用空间来换时间不过,和多核一样,这种委派的做法也会遇到一些困难。多核的困难大概有下面几种:
❑ 任务分割
❑ 通信开销
❑ 可靠性我们可以把任务分为两种:存在后续处理,在任务完成时需要获得通知的“同步任务”;执行开始后不必关心其完成情况的“异步任务”。同步任务也就意味着存在依赖关系,委派的效果也就不明显。因此,如何将工作分割成异步任务就成了提高效率的关键。
能够活用多CPU的处理,基本上可以分解为下列步骤:
(1) 数据分割、分配
(2) 对已分配的数据进行并行处理
(3) 将已处理的数据进行集约
其中,能够并行处理的部分基本上只有(2),而数据的分割和集约无论有多少个CPU,也无法最大限度地运用它们的性能。UNIX的System V(Five)版本引入了一组称为SysV IPC的进程间通信API,其中IPC就是Inter ProcessCommunication(进程间通信)的缩写。
SysV IPC包括下列3种通信方式。
❑ 消息队列
❑ 信号量
❑ 共享内存TCP(Transmission Control Protocol,传输控制协议)套接字和UDP(User Datagram Protocol,用户数据报协议)套接字都是建立在IP(Internet Protocol,网际协议)协议之上的上层网络通信套接字。
TCP套接字是一种基于连接的、具备可靠性的数据流通信套接字。所谓基于连接,是指通信的双方是固定的;而所谓具备可靠性,是指能够侦测数据发送成功或是发送失败(出错)的状态。所谓数据流通信,是指发送的数据是作为字节流来处理的,和通常的输入输出一样,不会保存写入的数据长度信息。
UDP套接字和TCP套接字相反,是一种能够无需连接进行通信、但不具备可靠性的数据报通信套接字。所谓能够无需连接进行通信,是指无需固定连接到指定对象,可以直接发送数据;不具备可靠性,是指可能会出现中途由于网络状况等因素导致发送数据丢失的情况。ZeroMQ为分布式应用程序的构建提供了丰富多彩的连接模型,主要有以下这些。
❑ REQ/REP
❑ PUB/SUB
❑ PUSH/PULL
❑ PAIR
REQ/REP是REQUEST/REPLY的缩写,表示向服务器发出请求(request),服务器向客户端返回应答(reply)这样的连接模型(图1)。
作为网络连接来说,这种方式是非常常见的。例如HTTP等协议,就遵循REQ/REP模型。通过网络进行函数调用的RPC(Remote Procedure Call,远程过程调用)也属于这一类。REQ/REP是一种双向通信。
PUB/SUB是PUBLISH/SUBSCRIBE的缩写,即服务器发布(publish)信息时,在该服务器上注册(subscribe,订阅)过的客户端都会收到该信息(图2)。这种模型在需要向大量客户端一起发送通知,以及数据分发部署等场合非常方便。PUB/SUB是一种单向通信。
PUSH/PULL是向队列中添加和取出信息的一种模型。PUSH/PULL模型的应用范围很广,如果只有一个数据添加方和一个数据获取方的话,可以用类似UNIX管道的方式来使用(图3a),如果是由一台服务器PUSH信息,而由多台客户端来PULL的话,则可以用类似任务队列的方式来使用(图3b)。
在图3b的场景中,处于等待状态的任务中只有一个能够取得数据。相对地,PUB/SUB模型中则是所有等待的进程都能够取得数据。
反过来说,如果有多个进程来PUSH,则能够用来对结果进行集约(图3c)。和PUB/SUB一样, PUSH/PULL也是一种单向通信。
PAIR是一种一对一的双向通信。
6.5 ZeroMQ 图1 REQ/REP模型
6.5 ZeroMQ 图2 PUB/SUB模型
6.5 ZeroMQ 图3 PUSH/PULL模型