├── .gitignore ├── Consensus-Algorithm └── raft_design.pdf ├── Golang-Internals ├── Part-0.head.md ├── Part-1.Main.Concepts.and.Project.Structure.md ├── Part-2.Diving.Into.the.Go.Compiler.md ├── Part-3.The.Linker.Object.Files.and.Relocations.md ├── Part-4.Object.Files.and.Function.Metadata.md ├── Part-5.the.Runtime.Bootstrap.Process.md ├── Part-6.Bootstrapping.and.Memory.Allocator.Initialization.md └── Part-x.footer.md ├── Golang-Reads ├── C++14.Spec.pdf └── Part-0.spec.head.md ├── Media ├── haha1.mp3 ├── haha10.mp3 ├── haha11.mp3 ├── haha12.mp3 ├── haha13.mp3 ├── haha14.mp3 ├── haha15.mp3 ├── haha16.mp3 ├── haha17.mp3 ├── haha18.mp3 ├── haha19.mp3 ├── haha2.mp3 ├── haha20.mp3 ├── haha21.mp3 ├── haha22.mp3 ├── haha23.mp3 ├── haha3.mp3 ├── haha4.mp3 ├── haha5.mp3 ├── haha6.mp3 ├── haha7.mp3 ├── haha8.mp3 ├── haha9.mp3 ├── ss1.mp3 └── testaievilaudio.mp3 ├── README.md ├── Regex ├── Finite_Automata_and_Their_Decision_Problems.pdf ├── Part01.Regular_Expression_Matching_Can_Be_Simple_And_Fast.md ├── Part02.Regular_Expression_Matching_the_Virtual_Machine_Approach.md ├── Part03.Regular_Expression_Matching_in_the_Wild.md ├── Part04.Regular_Expression_Matching_with_a_Trigram_Index.md ├── Programming_Techniques_Regular_expression_search_algorithm.thompson1968.pdf └── regexp_ken_thompson_slides.pdf ├── Tech-Reads ├── Alignment-in-C.md └── Cache-Memory-in-Wiki.md ├── WTS └── character-set-encoding.md ├── index.html └── index.tpl /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /Consensus-Algorithm/raft_design.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Consensus-Algorithm/raft_design.pdf -------------------------------------------------------------------------------- /Golang-Internals/Part-0.head.md: -------------------------------------------------------------------------------- 1 | Golang-Internal:深入浅出 Golang 2 | ====== 3 | 4 | 想要了解Golang 的实现原理,一窥运行时的内部机制,可以阅读当前文章 5 | -------------------------------------------------------------------------------- /Golang-Internals/Part-1.Main.Concepts.and.Project.Structure.md: -------------------------------------------------------------------------------- 1 | # 深入浅出 Golang,第一部分:工程的结构以及主要概念 [Part 1: Main Concepts and Project Structure][1] 2 | 3 | ## 这一系列的博文主要是给那些已经对Go有基础了解后想对语言内部机制做更加深入探索的同学。在阅读完这个系列博文后读者应该能回答如下的三个问题: 4 | 5 | 1. Go源代码的工程结构是怎么样的? 6 | 2. Go的编译器是怎么工作的? 7 | 3. 基础结构**Node-tree**在Go编译器实现里面到底是一个神马东东? 8 | 9 | - - - 10 | 11 | ## 开始装逼 12 | 13 | 开始接触一门编程语言,我们通常会接触到很多书籍专注在语言的语法、语义甚至标准库等相关方面。但是你很难从这些书籍中去了解关于语言的内存对象模型,以及内置函数调用的时候到底编译器生成了什么样的中间代码(汇编代码或者类汇编代码)。当然对于一个开源的编程语言,你想要了解的这些相对深入的语言机制都可以从源代码里面获取到,回过头来以个人经验来说,要想从源代码理解到这些内容却也是相当困难的,所以我们这里写一个引子,和大家一起从源代码里面窥探一下golang的胴体(咔擦。。。)。 14 | 15 | 在开始真正代码之旅之前,我们需要先git一份源代码: 16 | 17 | `git clone https://github.com/golang/go` 18 | 19 | _注意:主干代码持续在变更,我们这里为了保持文章的一致性我们选用go1.4发布分支的代码作为本系列文章的参照_ 20 | 21 | - - - 22 | 23 | ## 工程结构 24 | Understanding project structure 25 | 26 | 从Go的仓库src目录下,我们会看到很多子目录。大部分子目录都是Go标准库的代码。标准库里面的每个子目录的里面代码的包名和目录名保持一致,这也是go的标准命名规则。除开这部分标准库,还有一些目录,其中重要的目录有如下几个: 27 | 28 | | 目录 | 描述 | 29 | | --------------------------------- | ---------------------------------------- | 30 | | /src/cmd/ | 包含了各种golang的命令 | 31 | | /src/cmd/go | 包含了命令行工具Go的实现:通过调用其他编译,链接工具,实现下载编译Go的源文件 | 32 | | /src/cmd/dist | 这个工具负责编译目录下的其他命令行工具,并且负责编译Go的标准库 | 33 | | /src/cmd/gc | 这是一个与架构无关的Go的编译器实现,是第一个Go的编译器 | 34 | | /src/cmd/ld | Go的链接器的实现,与平台相关的代码会放在以平台架构命名的子目录下面 | 35 | | /src/cmd/5a,6a,8a,9a | 这里面是Go为各个不同平台实现的汇编指令编译器,Go的汇编指令跟本机的汇编指令不一样,这些工具负责把Go的汇编指令翻译为不同架构的汇编指令,详细信息可以在[这里](https://golang.org/doc/asm)查看。 | 36 | | /src/lib9,/src/libio,/src/liblink | 这些是编译器,链接器以及Go运行时用到的一些库 | 37 | | /src/runtime/ | 最重要的Go包,会包含进所有的Go程序里面,这是整个Go的运行时:比如垃圾回收内存管理, gorountines, channel 等等 | 38 | 39 | - - - 40 | 41 | ## Go 编译器 42 | 43 | 上面表格里面呈现的一样,与架构无关的Go的编译器实现代码在/src/cmd/gc这个目录里面,程序的入口在[lex.c][4]这个文件里面,姑且跳过一些程序的类似命令行参数处理等常规处理步骤,编译器主要执行如下的一些步骤: 44 | 45 | 1. 初始化基础数据结构 46 | 2. 遍历所有Go的源文件,对每一个源文件调用`yyparse`函数。这个函数里面会执行具体的编译解析工作,Go编译器用的是[Bison][2]做解析器,Go的语法描述全部在[go.y][3](后续会详细说明这个文件)这个文件里面,经过这个步骤后,会生成完整的语法树。 47 | 3. 会对生成的语法树做几次遍历操作,为树上的每个节点推导并填充类型信息,为一些必要的地方做type-casting等。 48 | 4. 执行正在的编译操作,生成每个节点的[汇编指令-Go-assembler][10]。 49 | 5. 然后生成object文件,以及相关符号表等。 50 | 51 | 这里我们可以对比到clang的完整步骤: 52 | 53 | 1. (source code) 54 | 2. ==> preprocessing ==> (.i,.ii,.mi,.mii) 55 | 3. ==> parsing and semantic analysis ==> (ast:abstract syntax tree) 56 | 4. ==> code generation and optionzation ==> (.s) 57 | 5. ==> assembler ==> (.object) 58 | 6. ==> linker ==> (.so, .dylib) 59 | 60 | - - - ​ 61 | 62 | ## 深入Go的语法看看 63 | 现在我们详解前面编译流程里面的第二步。[go.y][3] 这个文件包含李golang的语义设计规则,是我们学习go的编译器并且深入理解golang语法规则的一个很好的入手点。这个文件由一系列如下的声明组成: 64 | 65 | xfndcl: 66 | LFUNC fndcl fnbody 67 | fndcl: 68 | sym '(' oarg_type_list_ocomma ')' fnres 69 | | '(' oarg_type_list_ocomma ')' sym '(' oarg_type_list_ocomma ')' fnres 70 | 71 | 上面的代码段声明了两个节点xfndcl和fndcl的定义。fundcl这个节点可以有两种表现形式,第一种形式对应如下的一个构造函数: 72 | 73 | somefunction(x int, y int) int 74 | 75 | 第二种形式对应到如下的形式: 76 | 77 | (t *SomeType) somefunction(x int, y int) int 78 | 79 | xfndcl节点由存储在LFUNC里面的关键字func以及节点fndcl、fnbody组成。 80 | 81 | [Bison][2]或者[Yacc][5]语法解析器一个重要的特性就是允许放置一段C代码在节点的声明后面,这一小段C代码会每次在找到源文件里面匹配的代码块的时候执行,在执行的代码块里面可以通过$$引用result节点, 用$1、$2、$3... 引用子节点。 82 | 我们用一个例子(从g.y里面截取的一个简化版的节点配置)来理解我们这里提到的解析器怎么插入代码: 83 | 84 | fndcl: 85 | sym '(' oarg_type_list_ocomma ')' fnres 86 | { 87 | t = nod(OTFUNC, N, N); 88 | t->list = $3; 89 | t->rlist = $5; 90 | 91 | $$ = nod(ODCLFUNC, N, N); 92 | $$->nname = newname($1); 93 | $$->nname->ntype = t; 94 | declare($$->nname, PFUNC); 95 | } 96 | | '(' oarg_type_list_ocomma ')' sym '(' oarg_type_list_ocomma ')' fnres 97 | 98 | 首先创建一个节点存储函数的参数类型信息,类型信息里面会用到第3个子节点作为参数列表和第5个子节点作为返回值列表;然后生成一个新的节点作为result节点返回。上面的声明是伪造的一段,在go.y文件里面是找不到的。 99 | 100 | 这里关于Bison、Flex,Yacc,这些想深入了解的可以继续观看[龙书][7]、[虎书][8],[鲸书][9]。 101 | 102 | - - - 103 | 104 | ## 理解节点 105 | 106 | 现在我们要花点时间来理解"node"节点是一个啥子东东。node肯定是一个struct,你可以在[这里][6]找到结构体的定义。这个结构体你会看到有灰常多的属性,节点会有不同的用途,也会分成不同类型的node,会有他相应的属性。下面会对一些我认为对理解node比较重要的属性做说明: 107 | 108 | | 成员 | 说明 | 109 | | ---- | ---------------------------------------- | 110 | | op | 这个用来区分节点类型,前面的例子里面我们有看到OTFUNC(operation type function)和ODCLFUNC(operation declaration function)两个类型 | 111 | | type | 这个是Type的实例,如果节点需要类型说明,这个变量就指向相关类型信息,当然也有一些节点是没有类型信息,比如一些控制流statements:if、switch或者for等 | 112 | | val | Val类型的变量,里面存储了节点的合法有效值 | 113 | 114 | 到这里我们已经说明了基础结构[node][6],你可以结合现在的了解去详细阅读相关源代码。在下一节,我们会用一个简单的go程序来解读go编译器具体在生成节点这个阶段做了啥子黑科技。 115 | 116 | - - - 117 | 118 | [1]: http://blog.altoros.com/golang-part-1-main-concepts-and-project-structure.html "Golang Internals, Part 1: Main Concepts and Project Structure" 119 | [2]: https://www.gnu.org/software/bison/ "Bison" 120 | [3]: https://github.com/golang/go/blob/release-branch.go1.4/src/cmd/gc/go.y "golang1.4/src/gc/go.y" 121 | [4]: https://github.com/golang/go/blob/release-branch.go1.4/src/cmd/gc/lex.c#L199 "main" 122 | [5]: http://dinosaur.compilertools.net/yacc/ "yacc" 123 | [6]: https://github.com/golang/go/blob/release-branch.go1.4/src/cmd/gc/go.h#L245 "node" 124 | [7]: https://github.com/chenruiao/ares/blob/master/books/Compilers%20Principles%20Techniques%20and%20Tools%20(2nd%20Edition)%20.pdf "Compilers: Principles,Techniques,and Tools" 125 | [8]: https://www.cs.princeton.edu/~appel/modern/basic/c/extract.pdf "Modern Compiler Implementation in C" 126 | [9] https://cseweb.ucsd.edu/classes/sp14/cse231-a/lectures/A-intro.pdf "Advanced Compiler Design and Implementation" 127 | [10] https://golang.org/doc/asm "Go assembler" 128 | -------------------------------------------------------------------------------- /Golang-Internals/Part-2.Diving.Into.the.Go.Compiler.md: -------------------------------------------------------------------------------- 1 | # 深入浅出 Golang,第二部分:深入虎穴,一探编译器 [Part 2: Diving Into the Go Compiler][1] 2 | 3 | 你是否知道我们是怎么通过Golang的运行时,让[interface][2]去引用到一个变量的?这其实是一个非常有深度的问题,因为在Golang里面一个type实现了某一个interface,但type本身没有存储任何信息关联到这个interface,当然我们可以尝试用我们从[Part 1]里面了解到的信息,从go的编译器实现角度来回答这个问题。 4 | 5 | 为了更精准的回答上面的类似问题,我们接下来更加深入的分析go的编译器:我们写一个非常小的golang版本的"hello world",然后通过分析这个"hello world",了解内部的类型转换等相关机制,通过例子也进一步对node-tree的生成过程做详细解析。当然了解node-tree的生成过程不是我们的最终目的,我们的目的是以此为基础去横向涉猎go编译器的其他特性。 6 | 7 | - - - 8 | 9 | ## 前戏 10 | 为了准备了解编译器,我们准备我们的实验环境,我们从golang的编译器直接入手,而不是通过golang的集成工具。可以通过如下的方式使用编译器(注意安装go1.4版本): 11 | 12 | ``` 13 | go tool 6g test.go 14 | ``` 15 | 16 | 如果使用的是go1.5 或者更高的版本可以用如下的命令: 17 | 18 | ``` 19 | go tool compile test.go 20 | ``` 21 | 22 | 上面的命令会编译源文件test.go,然后在当前目录生成object文件。在笔者机器上6g是一个amd64架构的编译器,要生成其他架构的代码必须要用相应架构的编译器。 23 | 我们直接操作编译器的时候,我们可以手动在编译器上加上一些命令行参数,让编译器给我们生成相关辅助信息,更详细的编译器参数可以参考这个[地方][3],我们这里会给编译器上架_-W_参数,加上这个参数,编译器会把node-tree打印出来。 24 | 25 | ## 编写"hello world"程序 26 | 首先我们编写一个用于分析的简单go程序,我的版本是这样的: 27 | 28 | ``` 29 | 1 package main 30 | 2 31 | 3 type I interface { 32 | 4 DoSomeWork() 33 | 5 } 34 | 6 35 | 7 type T struct { 36 | 8 a int 37 | 9 } 38 | 10 39 | 11 func (t *T) DoSomeWork() { 40 | 12 } 41 | 13 42 | 14 func main() { 43 | 15 t := &T{} 44 | 16 i := I(t) 45 | 17 print(i) 46 | 18 } 47 | ``` 48 | 49 | 上面的示例代码非常简洁,严格意义上也就只有第17行看起来是可以不用的,但在golang里面第17行却也是必须的,因为没有17行就会是unused variable,这在golang里面是编译器错误。把上面的代码保存为test.go, 接下来我们就通过编译器编译这个源文件。 50 | ``` 51 | go tool 6g -W test.go 52 | ``` 53 | 执行上面的命令会看到编译器打印了源文件里面每一个函数的node-tree,我们这里主要是打印出main函数和init函数,其中init函数编译器自动为每一个包文件加上的内部函数,我们这里直接无视init函数就好。 54 | 编译器打印node-tree的时候,会为每一个函数打印两个版本的node-tree,第一个版本是语法此法解析后的原始版本,第二个版本是执行了类型检查,并填充了相关类型信息并做一些必要的修正后的版本。 55 | 56 | ``` 57 | before I.DoSomeWork 58 | . CALLINTER l(20) tc(1) 59 | . . DOTINTER l(20) x(0+0) tc(1) FUNC-method(*struct {}) func() 60 | . . . NAME-main..this u(1) a(true) g(1) l(20) x(0+0) class(PPARAM) f(1) esc(s) tc(1) used(true) main.I 61 | . . . NAME-main.DoSomeWork u(1) a(true) l(20) x(0+0) 62 | after walk I.DoSomeWork 63 | . CALLINTER u(100) l(20) tc(1) 64 | . . DOTINTER u(2) l(20) x(0+0) tc(1) FUNC-method(*struct {}) func() 65 | . . . NAME-main..this u(1) a(true) g(1) l(20) x(0+0) class(PPARAM) f(1) esc(s) tc(1) used(true) main.I 66 | . . . NAME-main.DoSomeWork u(1) a(true) l(20) x(0+0) 67 | ``` 68 | 69 | ## 理解main方法的node-tree 70 | 我们开始先看一下在编译器修订之前的main方法的node-tree: 71 | ``` 72 | DCL l(15) 73 | . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T 74 | 75 | AS l(15) colas(1) tc(1) 76 | . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T 77 | . PTRLIT l(15) esc(no) ld(1) tc(1) PTR64-*main.T 78 | . . STRUCTLIT l(15) tc(1) main.T 79 | . . . TYPE l(15) tc(1) implicit(1) type=PTR64-*main.T PTR64-*main.T 80 | 81 | DCL l(16) 82 | . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I 83 | 84 | AS l(16) tc(1) 85 | . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T 86 | . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T 87 | 88 | AS l(16) colas(1) tc(1) 89 | . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I 90 | . CONVIFACE l(16) tc(1) main.I 91 | . . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T 92 | 93 | VARKILL l(16) tc(1) 94 | . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T 95 | 96 | PRINT l(17) tc(1) 97 | PRINT-list 98 | . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I 99 | ``` 100 | 尝试理解一下上面node-tree。接下来的解释过程,我们会对node-tree做适当的删减,去掉一些意义不大的部分,让他看起来更加简洁精炼。 101 | 第一个node非常简单: 102 | ``` 103 | DCL l(15) 104 | . NAME-main.t l(15) PTR64-*main.T 105 | ``` 106 | 第一行`DCL l(15)` 声明node,其中`l(15)`的意思是node来自于源文件的第15行。下面的一行`NAME-main.t l(15) PTR64-*main.T`是节点的名字是`main.t`对应到源文件的15行变量`t`,变量t是一个64位的指针,指向类型为`main.T`的变量 107 | 108 | 接下来的会稍微复杂一点,理解起来也会棘手一些: 109 | ``` 110 | AS l(15) 111 | . NAME-main.t l(15) PTR64-*main.T 112 | . PTRLIT l(15) PTR64-*main.T 113 | . . STRUCTLIT l(15) main.T 114 | . . . TYPE l(15) type=PTR64-*main.T PTR64-*main.T 115 | ``` 116 | 第一行`AS l(15)` 说明是一个用于赋值的node, node的一个孩子`NAME-main.t l(15) PTR64-*main.T`是一个具名的节点,代表`main.t`这个变量;第二个孩子`PTRLIT l(15) PTR64-*main.T`是我们用来赋值给`main.t`的节点,这个用来赋值的节点字面`PRTLIT`上是一个取地址操作,相当于`&`取地址符,这个取地址操作的节点也有一个孩子节点`STRUCTLIT l(15) main.T`,这个节点是指向具体的类型`main.T`; 117 | 接下来又是一个声明节点: 118 | ``` 119 | DCL l(16) 120 | . NAME-main.i l(16) main.I 121 | ``` 122 | 声明了一个类型为`main.I`的变量`main.i` 123 | 124 | 然后编译器创建了一个临时变量`autotmp_0000`,并且把`main.t` 赋值给它。 125 | 126 | ``` 127 | AS l(16) tc(1) 128 | . NAME-main.autotmp_0000 l(16) PTR64-*main.T 129 | . NAME-main.t l(15) PTR64-*main.T 130 | ``` 131 | 好接下来的几个节点是我们真正感兴趣的部分: 132 | ``` 133 | AS l(16) 134 | . NAME-main.i l(16)main.I 135 | . CONVIFACE l(16) main.I 136 | . . NAME-main.autotmp_0000 PTR64-*main.T 137 | ``` 138 | 我们看到编译器把一个特殊节点`CONVIFACE`赋值给了`main.i`,这一步有一些意思,但是还是裹了一层薄纱,没有真正看到内涵。要搞清楚内涵信息我们得继续往下看编译器做了修订以后的版本(也就是标注为`after walk main`以后的片段)。 139 | 140 | - - - 141 | 142 | ## 编译器是怎么修订赋值node的 143 | 下面我们可以看到编译器修订过程具体做了哪些猫腻: 144 | ``` 145 | AS-init 146 | . AS l(16) 147 | . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 148 | . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8 149 | 150 | . IF l(16) 151 | . IF-test 152 | . . EQ l(16) bool 153 | . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 154 | . . . LITERAL-nil I(16) PTR64-*uint8 155 | . IF-body 156 | . . AS l(16) 157 | . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 158 | . . . CALLFUNC l(16) PTR64-*byte 159 | . . . . NAME-runtime.typ2Itab l(2) FUNC-funcSTRUCT-(FIELD- 160 | . . . . . NAME-runtime.typ·2 l(2) PTR64-*byte, FIELD- 161 | . . . . . NAME-runtime.typ2·3 l(2) PTR64-*byte PTR64-*byte, FIELD- 162 | . . . . . NAME-runtime.cache·4 l(2) PTR64-*PTR64-*byte PTR64-*PTR64-*byte) PTR64-*byte 163 | . . . CALLFUNC-list 164 | . . . . AS l(16) 165 | . . . . . INDREG-SP l(16) runtime.typ·2 G0 PTR64-*byte 166 | . . . . . ADDR l(16) PTR64-*uint8 167 | . . . . . . NAME-type.*"".T l(11) uint8 168 | 169 | . . . . AS l(16) 170 | . . . . . INDREG-SP l(16) runtime.typ2·3 G0 PTR64-*byte 171 | . . . . . ADDR l(16) PTR64-*uint8 172 | . . . . . . NAME-type."".I l(16) uint8 173 | 174 | . . . . AS l(16) 175 | . . . . . INDREG-SP l(16) runtime.cache·4 G0 PTR64-*PTR64-*byte 176 | . . . . . ADDR l(16) PTR64-*PTR64-*uint8 177 | . . . . . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8 178 | AS l(16) 179 | . NAME-main.i l(16) main.I 180 | . EFACE l(16) main.I 181 | . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 182 | . . NAME-main.autotmp_0000 l(16) PTR64-*main.T 183 | ``` 184 | 从截取的输出看到,编译器首先给赋值节点加另一个初始化节点`AS-init`,在初始化节点`AS-init`里面,创建一个新的自动变量`autotmp_0003`,并且赋值为`go.itab.*"".T."".I`,这一步后,就检查这个变量是否为nil`LITERAL-nil`,如果变量不为nil,则调用函数`runtime.typ2Itab`,并传递如下参数: 185 | 186 | `NAME-type.*"".T l(11)` 一个指向类型`main.T`的指针 187 | `NAME-type."".I l(16)` 一个指向类型`main.I`的指针 188 | 以及`NAME-go.itab.*"".T."".I l(16)` 一个指向`go.itab.*"".T."".I`的变量 189 | 190 | 从上面不难发现编译器创建了一个临时变量存储`main.T`转到`main.I`的类型转换结果。 191 | 192 | ## 深入老巢,观察gititab函数 193 | 我们先把the-fucking-code:[typ2Itab][4]列出来: 194 | ``` 195 | func typ2Itab(t *_type, inter *interfacetype, cache **itab) *itab { 196 | tab := getitab(inter, t, false) 197 | atomicstorep(unsafe.Pointer(cache), unsafe.Pointer(tab)) 198 | return tab 199 | } 200 | ``` 201 | 擦。。。,上面的代码太简单了,所有事情其实都是给[getitab][5]干了,自己只是把结构存储到了cache里面。好接下来看getitab: 202 | ``` 203 | m = 204 | (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, 205 | &memstats.other_sys)) 206 | m.inter = interm._type = typ 207 | 208 | ni := len(inter.mhdr) 209 | nt := len(x.mhdr) 210 | j := 0 211 | for k := 0; k < ni; k++ { 212 | i := &inter.mhdr[k] 213 | iname := i.name 214 | ipkgpath := i.pkgpath 215 | itype := i._type 216 | for ; j < nt; j++ { 217 | t := &x.mhdr[j] 218 | if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath { 219 | if m != nil { 220 | *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn 221 | } 222 | } 223 | } 224 | } 225 | ``` 226 | getitab函数非常大,这里只截取了笔者认为最有价值的部分。首先会申请内存存储返回的结果: 227 | ``` 228 | m = 229 | (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, 230 | &memstats.other_sys)) 231 | m.inter = interm._type = typ 232 | ``` 233 | 234 | 这里调用的是一个很奇怪的函数申请的内存,为什么会调用这么一个东东,我们得看下`itab`结构体的定义: 235 | ``` 236 | type itab struct { 237 | inter *interfacetype 238 | _type *_type 239 | link *itab 240 | bad int32 241 | unused int32 242 | fun [1]uintptr // variable sized 243 | } 244 | ``` 245 | 结构体的最后一个变量`fun`是一个只有一个元素的数组,这里其实是一个可变长度的函数指针数组,存储了对应到interface定义的所有函数,Go的设计者这里是通过unsafe包提供的能力自己手动管理内存的,所以需要申请的内存大小原来的大小在加上interface定义的方法数减1乘以指针大小`unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize`。 246 | 247 | 接下来会看到两个嵌套loop, 第一个loop我们遍历interface的方法,为每个方法尝试找到在type里面对应的方法,用如下的代码判断方法是否相等: 248 | `if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath` 249 | 如果找到了我们就把函数指针存储到`fun`里面: 250 | `*(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn` 251 | 252 | 笔者发现这里的一个优化点:在interfae和type的方法都是按照字母序排序好的情况下,这里的循环其可以O(n+m)的情况下完成,而不需要O(n*m){译者注:这里的n 和m对应到interface和type的方法数,也就意味着n和m都是很小的数,所以这里没太大必要做过度优化去牺牲可阅读性}。 253 | 254 | 好时光倒转一下,我们继续看前面关于node-tree的解析的最后一部分: 255 | ``` 256 | AS l(16) 257 | . NAME-main.i l(16) main.I 258 | . EFACE l(16) main.I 259 | . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 260 | . . NAME-main.autotmp_0000 l(16) PTR64-*main.T 261 | ``` 262 | 这里把一个`EFACE l(16)`的节点赋值给了`NAME-main.i`变量,而`EFACE l(16)`包含了一个`autotmp_0003`的引用,而且前面的分析我们也知道`autotmp_0003`是一个指向`itab`结构体的指针,存储了`untime.typ2Itab`返回值;同时`EFACE l(16)`还包含了`autotmp_0000`的引用,而这个变量存储的值就是`main.t`,所以`main.i`就已经关联了一个`itab`和`main.T`,就能访问相关的方法和变量了。也就是`main.i`其实是runtime包里面的`iface`的实例,`iface`结构体定义如下: 263 | ``` 264 | type iface struct { 265 | tab *itab 266 | data unsafe.Pointer 267 | } 268 | ``` 269 | 270 | {译者注:好吧,看到这里,对于学过C++等多态语言的,不难类比到C++的多态机制,只是go的这里是动态多态,他的虚表itab是动态生成的, 同时把虚表单独拿出来和一个对象结合到一起变成一个iface,这样就提供了非常大的灵活性。} 271 | 272 | 273 | ## 接下来路在何方 274 | 通过前面的缀叙,我们还仅仅只是覆盖了go编译器和go运行时非常小的一部分,还有非常多的东西可以探讨。在本系列接下来的文章里面我们还会继续探讨:object文件,链接过程,重定向等。 275 | 276 | 277 | 278 | [1]: http://blog.altoros.com/golang-internals-part-2-diving-into-the-go-compiler.html "Part 2: Diving Into the Go Compiler" 279 | [2]: http://jordanorelli.com/post/32665860244/how-to-use-interfaces-in-go "interface in go" 280 | [3]: https://golang.org/cmd/compile/ "Compiler" 281 | [4]: https://golang.org/src/cmd/compile/internal/gc/builtin/runtime.go?h=typ2Itab#L63 "typ2Itab" 282 | [5]: https://golang.org/src/runtime/iface.go?h=getitab#L22 "getitab" -------------------------------------------------------------------------------- /Golang-Internals/Part-3.The.Linker.Object.Files.and.Relocations.md: -------------------------------------------------------------------------------- 1 | # 深入浅出 Golang,第三部分:链接,重定向以及Object文件 [Part 3: The Linker, Object Files, and Relocations][1] 2 | 3 | 今天我们聊聊Go的链接器,Object文件,以及重定向。 4 | 5 | 好,为什么我们需要关心上述的这些概念呢,这个道理其实也很简单,比如你要了解一个大项目,那么你首先肯定得把大项目分解成很多小的模块;然后你再搞清楚模块与模块之间的依赖关系,以及他们之间项目调用的约定。对于Go这个大项目来说,他可以分解成:编译器,链接器,运行时;然后编译器生成Object文件,链接器在这个基础上工作。我们今天这里主要讨论就是这一部分。 6 | 7 | - - - 8 | 9 | ## 怎么生成Go的Object文件 10 | 我们来做一个实验,我们写一个超级简单的示例程序,看Go的编译器会生成什么样的Object文件。我们用如下的程序来做实验: 11 | ``` 12 | package main 13 | 14 | func main() { 15 | print(1) 16 | } 17 | ``` 18 | 19 | 是不是简单到你怀疑人生!好我们编译这个简单程序: 20 | ``` 21 | go tool 6g test.go 22 | ``` 23 | 如果是Go1.5或者以上的版本可以执行: 24 | ``` 25 | go tool compile test.go 26 | ``` 27 | 上面的命令会生成test.6的Object文件(如果是Go1.5以上会生成test.o的Object文件),为了刺探这个Object文件的内部结构我们需要用到[goobj][2]库,这个库随着Go的源代码一起发布,主要用来检查Object文件格式是否正确。为了解释内部结构,笔者基于这个库写了一个小工具,用来打印Object文件内部结构信息,小工具的源代码可以在[这里][3]找到。 28 | 我们先下载和安装这里需要用到的这个小工具(go1.4后加了[internal机制][6],这个工具不能直接这样编译了): 29 | 30 | go get github.com/s-matyukevich/goobj_explorer 31 | 32 | 然后执行如下命令: 33 | 34 | goobj_explorer -o test.6 35 | 36 | 好,你将在控制台里面看到`goob.Package`结构体。 37 | 38 | - - - 39 | 40 | ## 详解Object文件 41 | Object文件比较有意义的部分是Sym数组,这其实是一个符号表。应用程序里面定义所有符号信息都在这个表里面,包括定义的函数,全局变量,类型,常量等等。我们看一下关于main函数的符号(我们这里暂时把`Reloc`和`Func`这两个部分先省略,我们后续对这两部分再详细讨论)。 42 | ``` 43 | &goobj.Sym{ 44 | SymID: goobj.SymID{Name:"main.main", Version:0}, 45 | Kind: 1, 46 | DupOK: false, 47 | Size: 48, 48 | Type: goobj.SymID{}, 49 | Data: goobj.Data{Offset:137, Size:44}, 50 | Reloc: ..., 51 | Func: ..., 52 | } 53 | ``` 54 | 我们用一个表格对goobj.Sym的各个字段做一个说明: 55 | 56 | |字段|描述| 57 | |---|---| 58 | |SumID|全局唯一的符号ID,由符号名和版本组成,版本用来区分同名的不同符号| 59 | |Kind|表明符号属于什么类型,后续会进一步说明这个字段| 60 | |DupOK|表明这个符号是否可以存在多个同名的| 61 | |Size|符号的内存大小| 62 | |Type|可以指向另外一个详细说明类型信息的符号,可以是空| 63 | |Data|包含符号的二进制信息,不同类型的符号这个字段的内容解释是不一样的,如果是函数类型的符号,这里存储的是汇编代码,如果是字符串类型的符号,这个字段存储的是字符串的值| 64 | |Reloc|包含重定向信息,后面详细说明...| 65 | |Func|如果是函数类型的符号,这里存储的是函数的元信息| 66 | 67 | 所有不同类型的符号都以常量的形式定义在`goobj`包里面,可以在[这里][4]找到。这里我们截取一部分: 68 | ``` 69 | const ( 70 | _ SymKind = iota 71 | 72 | // readonly, executable 73 | STEXT 74 | SELFRXSECT 75 | 76 | // readonly, non-executable 77 | STYPE 78 | SSTRING 79 | SGOSTRING 80 | SGOFUNC 81 | SRODATA 82 | SFUNCTAB 83 | STYPELINK 84 | SSYMTAB // TODO: move to unmapped section 85 | SPCLNTAB 86 | SELFROSECT 87 | ``` 88 | 从前面的代码段我们看到`main.main`符号的Kind是1,对应到`STEXT`符号类型,这个类型的符号包含的是可执行代码。好让我们来看一下`Reloc`数组,我们先列一下数组成员的结构体: 89 | ``` 90 | type Reloc struct { 91 | Offset int 92 | Size int 93 | Sym SymID 94 | Add int 95 | Type int 96 | } 97 | ``` 98 | 上面的结构体的代表的操作是:把符号所在地址加上偏移量Add这个地方的内存复制到内存地址范围[Offset, Offset+Size]的地方, 99 | 也就是[memmove][5]: `memmove(Offset, sym_addr+Add, Size)` 100 | 101 | 102 | ## 理解 relocations 103 | 104 | 接下来我们用一个例子来说明relocations。首先我们在编译的时候带上一个`-S`的选项,让编译器帮我们打印出生成的相关汇编代码。 105 | go tool 6g -S test.go 106 | go tool compile -S test.go // Go1.5 or greater 107 | 108 | 我们找到生成的汇编代码关于main函数的那一段: 109 | 110 | ``` 111 | "".main t=1 size=48 value=0 args=0x0 locals=0x8 112 | 0x0000 00000 (test.go:3) TEXT "".main+0(SB),$8-0 113 | 0x0000 00000 (test.go:3) MOVQ (TLS),CX 114 | 0x0009 00009 (test.go:3) CMPQ SP,16(CX) 115 | 0x000d 00013 (test.go:3) JHI ,22 116 | 0x000f 00015 (test.go:3) CALL ,runtime.morestack_noctxt(SB) 117 | 0x0014 00020 (test.go:3) JMP ,0 118 | 0x0016 00022 (test.go:3) SUBQ $8,SP 119 | 0x001a 00026 (test.go:3) FUNCDATA $0,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB) 120 | 0x001a 00026 (test.go:3) FUNCDATA $1,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB) 121 | 0x001a 00026 (test.go:4) MOVQ $1,(SP) 122 | 0x0022 00034 (test.go:4) PCDATA $0,$0 123 | 0x0022 00034 (test.go:4) CALL ,runtime.printint(SB) 124 | 0x0027 00039 (test.go:5) ADDQ $8,SP 125 | 0x002b 00043 (test.go:5) RET , 126 | ``` 127 | 128 | 在后续的博文里面我们会再次详解这一段汇编,并且尝试通过解析理解Go的运行时是怎么工作的。这个阶段我们对上述的汇编我们只关心这一句就可以了: 129 | 130 | 0x0022 00034 (test.go:4) CALL ,runtime.printint(SB) 131 | 132 | 这一条指令位于函数区偏移量为0x0022(十六进制)的位置,或者说是偏移量为00034(十进制)的位置,这一行指令他实际上的作用是调用运行时的函数`runtime.printint`,这里的问题是编译器在编译期间其实是不知道运行时函数`runtime.printint`的真正地址的,这个函数是位于运行时的Object文件里面,当前编译的文件肯定是不知道这个函数地址,在这种情况下我们就用到了重定向技术,接下来的代码段正是对函数`runtime.printint`这个的重定向,笔者从goobj_explorer工具的汇编里面拷贝过来的。 133 | 134 | ``` 135 | { 136 | Offset: 35, 137 | Size: 4, 138 | Sym: goobj.SymID{Name:"runtime.printint", Version:0}, 139 | Add: 0, 140 | Type: 3, 141 | }, 142 | ``` 143 | 上面的重定向告诉链接器,用符号`Sym: goobj.SymID{Name:"runtime.printint", Version:0}`的地址加上偏移量`Add: 0` 替换当前Object文件偏移量为`Offset: 35`开始的`Size: 4`的内容,这里是35而不是前面跳到的34的偏移量是,因为第34字节是一个字节的call指令,第35字节才是call的函数地址。 144 | 145 | - - - 146 | 147 | ## 链接器怎么执行重定向呢 148 | 149 | 经过上面的解析,我们基本理解了重定向这个。这里我们对整个过程做一下梳理: 150 | 151 | 1. 链接器从main包开始收集所有关联到的相关代码里面的需要重定向的符号,把这个放入一个巨大的二进制的数组结构里面 152 | 2. 链接器计算每一个符号在当前镜像里面的偏移量 153 | 3. 执行重定向操作,把符号里面相关真正的地址和数据复制到响应的位置 154 | 4. 链接器准备pe头需要的所有信息,然后生成可执行的二进制镜像文件 155 | 156 | 157 | ## 理解TLS(thread-local-storage) 158 | 159 | 在前面打印出来main函数的符号表里面,细心的读者可能注意到一个比较奇怪的重定向,他没有对应到任何函数调用,连符号也是一个空的`Sym: goobj.SymID{}`: 160 | 161 | ``` 162 | { 163 | Offset: 5, 164 | Size: 4, 165 | Sym: goobj.SymID{}, 166 | Add: 0, 167 | Type: 9, 168 | }, 169 | ``` 170 | 那么上面的重定向是干啥的?我们看到他的偏移量是5,替换4个字节的数据,查看这个偏移量对应的汇编指令为: 171 | 172 | 0x0000 00000 (test.go:3) MOVQ (TLS),CX 173 | 174 | 我们也可以观察到这条指令的偏移量是0,然后下一条指令的偏移量就已经是9了,所以这一条指令占据了9个字节的空间,我们初步的估计就是他与TLS有关,但是TLS具体做了一些什么事情呢? 175 | 176 | TLS的全称是[线程局部存储][7],很多编程语言里面都有这个概念,这个简单介绍就是线程局部存储,定义一个变量,这个变量在每个线程都存在一个单独的实例。 177 | 178 | 在Go语言里面,用TLS存储了当前Goroutine的环境变量G结构体的指针,链接器对这个指针是感知的,上面的偏移量为0的指令就是把这个结构体的指针放到寄存器CX里面,TLS的实现在不同的架构上他的实现是不一样的,比如在AMD64处理器上,这个不是指针会被存储到FS寄存器,那么前面的指令就会变成`0x0000 00000 (test.go:3) MOVQ (TLS),FS`。 179 | 180 | 这里我们列举所有重定向类型,来结束我们关于重定向的讨论: 181 | ``` 182 | // Reloc.type 183 | enum 184 | { 185 | R_ADDR = 1, 186 | R_SIZE, 187 | R_CALL, // relocation for direct PC-relative call 188 | R_CALLARM, // relocation for ARM direct call 189 | R_CALLIND, // marker for indirect call (no actual relocating necessary) 190 | R_CONST, 191 | R_PCREL, 192 | R_TLS, 193 | R_TLS_LE, // TLS local exec offset from TLS segment register 194 | R_TLS_IE, // TLS initial exec offset from TLS base pointer 195 | R_GOTOFF, 196 | R_PLT0, 197 | R_PLT1, 198 | R_PLT2, 199 | R_USEFIELD, 200 | }; 201 | ``` 202 | 上面的R_CALL和R_TLS就是我们讨论里面涉及到的两个重定向类型。 203 | 204 | 205 | ## 进一步深挖Object文件 206 | 207 | 下一个主题还是讨论Object文件,下一节会展示更多详细的信息,为后续理解Go的运行时提供更全面的背景知识。 208 | 209 | 210 | 211 | [1] http://blog.altoros.com/golang-internals-part-3-the-linker-and-object-files.html "The Linker, Object Files, and Relocations" 212 | [2] https://github.com/golang/go/tree/master/src/cmd/internal/goobj "goobj" 213 | [3] https://github.com/s-matyukevich/goobj_explorer "goobj_explorer" 214 | [4] https://github.com/golang/go/blob/master/src/cmd/internal/goobj/read.go#L30 "Sym Kind" 215 | [5] http://man7.org/linux/man-pages/man3/memmove.3.html "memmove" 216 | [6] http://golang.org/s/go14internal "internal" 217 | [7] https://en.wikipedia.org/wiki/Thread-local_storage "thread-local-storage" 218 | 219 | 220 | 221 | -------------------------------------------------------------------------------- /Golang-Internals/Part-4.Object.Files.and.Function.Metadata.md: -------------------------------------------------------------------------------- 1 | # 深入浅出 Golang,第四部分:Object文件以及函数元信息 [Part 4: Object Files and Function Metadata][1] 2 | 3 | 今天我们详细探讨Go里面的`Func`这个结构,以及涉及一下Go的垃圾回收工作原理。 4 | 这一篇作为[Part-3.The.Linker.Object.Files.and.Relocations][2] 的姊妹篇,我们也会用同一个示例程序来探讨,如果没有阅读过第三篇,强烈建议先过一下第三篇文章。 5 | 6 | ## 函数结构体 7 | 第三篇文章后,应该对重定向的基本原理已经了解。然后我们来观察下main函数的符号定义: 8 | ``` 9 | Func: &goobj.Func{ 10 | Args: 0, 11 | Frame: 8, 12 | Leaf: false, 13 | NoSplit: false, 14 | Var: { 15 | }, 16 | PCSP: goobj.Data{Offset:255, Size:7}, 17 | PCFile: goobj.Data{Offset:263, Size:3}, 18 | PCLine: goobj.Data{Offset:267, Size:7}, 19 | PCData: { 20 | {Offset:276, Size:5}, 21 | }, 22 | FuncData: { 23 | { 24 | Sym: goobj.SymID{Name:"gclocals·3280bececceccd33cb74587feedb1f9f", Version:0}, 25 | Offset: 0, 26 | }, 27 | { 28 | Sym: goobj.SymID{Name:"gclocals·3280bececceccd33cb74587feedb1f9f", Version:0}, 29 | Offset: 0, 30 | }, 31 | }, 32 | File: {"/home/adminone/temp/test.go"}, 33 | }, 34 | ``` 35 | 你可以认为上面的结构体就是编译器为main函数生成的Metadata,这个Metadata是Go运行时可以访问的(当然实际上函数的Metadata没有这么多字段,下面马上就会看到具体定义)。这里有一篇[文章][4]详细介绍了这个结构体的每一个字段的含义。接下来我们跳过这部分说明,直接介绍运行时是怎么使用这个Metadata的。 36 | 在运行时里面上面的符号定义会对应到一个如下的结构体: 37 | ``` 38 | type _func struct { 39 | entry uintptr // start pc 40 | nameoff int32 // function name 41 | 42 | args int32 // in/out args size 43 | frame int32 // legacy frame size; use pcsp if possible 44 | 45 | pcsp int32 46 | pcfile int32 47 | pcln int32 48 | npcdata int32 49 | nfuncdata int32 50 | } 51 | ``` 52 | 从上面的定义可以清晰看到,并没有把编译器生成的所有字段映射到运行时里面,有一些字段只供链接器使用。这里比较有意义的字段是:pcsp、pcfile、pcln,在真正遇到[指令运算-指令寄存器][3]执行的时候,上述的字段就会翻译成栈指针,文件名,以及相应的行号。一个很常见的情形就是发生panic的时候,运行时得知道当前汇编指令对应的函数,行号,以及相应的文件名,运行时就是通过当前的指令寄存器得到相应的函数名和行号,然后通过回溯字段pcsp获取整个调用栈。 53 | 好,问题来了,我们是怎么通过指令寄存器获取行号这些信息的?为了回答这个问题,我们再回来看下生成的汇编代码,以及行号信息是怎么存储在Object文件里面的: 54 | ``` 55 | 0x001a 00026 (test.go:4) MOVQ $1,(SP) 56 | 0x0022 00034 (test.go:4) PCDATA $0,$0 57 | 0x0022 00034 (test.go:4) CALL ,runtime.printint(SB) 58 | 0x0027 00039 (test.go:5) ADDQ $8,SP 59 | 0x002b 00043 (test.go:5) RET , 60 | ``` 61 | 从上面的汇编代码我们看到指令寄存器从`00026`到`00038`对应的行号是`test.go:4`,从`00039`到下一个函数调用对应的是`test.go:5`,简化这个对应关系,我们存储下面的一个map: 62 | ``` 63 | 26 - 4 64 | 39 - 5 65 | … 66 | ``` 67 | 上面的过程基本也是编译器做的事情。字段`pcln`存储的是与当前函数的起始指令的偏移量,再加上下一个函数的起始指令的偏移量,我们就可以用二分查找找到给定的指令寄存器对应的行号。 68 | 在Go里面很多地方都应用了上面的map机制,不仅仅通过建立一个map,建立指令寄存器与行号的关系,可以通过上述的机制让指令寄存器映射到任何整数。汇编代码里面的`PCDATA`就是用来干这个事情的。每一次链接器发现了下面的指令: 69 | 70 | 0x0022 00034 (test.go:4) PCDATA $0,$0 71 | 72 | 链接器不会为上述的汇编生成任何要执行的指令,相反他会当前指令的第二个参数和当前的指令寄存器建立一个上述的映射关系,而指令的第一个参数表示的就是map的类型,通过传递不同的第一个参数可以建立很多运行时可以感知的映射关系。 73 | 74 | ## 垃圾回收是怎么利用函数的Metadata的呢? 75 | 最后一个Func-Metadata里面需要说明的是`FuncData`数组,它为GC准备了一些必要的信息。Go的GC采用的是[Mark-and-Sweep][5]算法,这个算法分为两个阶段,第一个阶段给所有能达到的对象做标记(mark),第二个阶段释放(sweep)所有没有标记的对象。 76 | 77 | 所以算法的第一阶段就是从几个已知的地方开始扫描所有对象,这些地方包括:全局变量,寄存器,栈帧上,以及已经标记为可达的对象的成员变量上。如果你仔细想想,你也会发现这个扫描过程是一个非常棘手的问题,怎么扫描栈上的指针,怎么来区分是指针还是普通变量等等,这个时候就需要到一些辅助信息了。 78 | 编译器会为每个函数生成两个位图,第一个位图用来跟踪函数的参数里面栈帧上的那些指针变量(堆变量);第二个位图用来跟踪函数体内部栈帧上的指针变量(堆变量)。Garbage-Collector(GC)就可以用上述的两个位图执行扫描操作。 79 | 80 | 这里我们提到了两种附加数据,类似这里` PCDATA, FUNCDATA`的附加数据是由Go编译器生成的伪汇编指令: 81 | 82 | 0x001a 00026 (test.go:3) FUNCDATA $0,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB) 83 | 84 | 上面指令的第一个参数表面是参数位图还是函数体局部变量位图,第二个参数就是真正的包含GC-Mask(位图)的隐藏变量。 85 | 86 | ## 还有啥?? 87 | 88 | 在接下来的篇幅中,我们会探讨Glang的启动引导过程,这个对于理解Go的运行时机制也是非常关键的。 89 | 90 | 91 | 92 | 93 | [1]: http://blog.altoros.com/golang-part-4-object-files-and-function-metadata.html "Part 4: Object Files and Function Metadata" 94 | [2]: https://github.com/JerryZhou/golang-doc/blob/master/Golang-Internals/Part-3.The.Linker.Object.Files.and.Relocations.md "Part-3.The.Linker.Object.Files.and.Relocations" 95 | [3]: https://en.wikipedia.org/wiki/Program_counter "IP/IAR" 96 | [4]: https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub "Func" 97 | [5]: http://www.brpreiss.com/books/opus5/html/page424.html "Mark-and-Sweep Garbage Collection" -------------------------------------------------------------------------------- /Golang-Internals/Part-5.the.Runtime.Bootstrap.Process.md: -------------------------------------------------------------------------------- 1 | # 深入浅出 Golang,第五部分:运行时的启动过程 [Part 5: the Runtime Bootstrap Process][1] 2 | 3 | 搞清楚Golang-Runtime的启动引导过程是理解Golang-Runtime的工作机制非常关键的一步。如果想把Golang玩弄于鼓掌之间,就必须搞清楚它的运行时。所以<深入浅出Golang>这个系列的第五部分,我们就来重点探讨Golang-Runtime以及它的启动引导过程。 4 | 5 | 这一部分我们会着重三个方面的探讨: 6 | 7 | 1. Golang 的启动引导过程 8 | 2. resizable stacks: 动态栈的实现 9 | 3. internal TLS: 内部线程局部存储的实现 10 | 11 | 注意:本文会涉及到一些Go-Assemble-Code, 也就要求你对Golang的汇编应该具备基本的了解。如果不了解,可以先去参考[这篇文章][2],当然如果不习惯E文,也还是有[译文][5]可读的。 12 | 13 | ## 从程序的入口开始 14 | 我们先写一个简单的测试程序,编译一下,看开始执行一个Go程序,最开始调用的函数或者说执行的代码是到底神马。我们用如下的测试程序来做实验: 15 | ``` 16 | package main 17 | 18 | func main() { 19 | print(123) 20 | } 21 | ``` 22 | 然后我们对这个程序做编译链接操作,生成正在的可执行文件。 23 | 24 | go tool 6g test.go 25 | go tool 6l test.6 26 | 27 | {对于Go1.5以上: `go tool compile test.go` 和 `go tool link test.o`} 28 | 29 | 然后我们用objdump工具来看下这个执行镜像的pe头,对于没有这个工具的Windows或者Mac平台用户你就直接跳过这一步,查看笔者这里贴出来的结果就好。 30 | 31 | objdump -f 6.out 32 | 33 | {对于Mac用户,其实可以`brew install binutils`,里面有带一个`gobjdump -f test.out`} 34 | 通过上面的命令应该可以得到如下输出: 35 | ``` 36 | 6.out: file format elf64-x86-64 37 | architecture: i386:x86-64, flags 0x00000112: 38 | EXEC_P, HAS_SYMS, D_PAGED 39 | start address 0x000000000042f160 40 | ``` 41 | 42 | 通过上面的操作,我们知道了起始地址,那我们通过如下命令把执行文件编译到汇编代码: 43 | 44 | objdump -d 6.out > disassemble.txt 45 | 46 | {对于Go1.5以上的用户,其实Golang自带了objdump工具:`go tool objdump test.out`} 47 | 然后我们打开反编译的汇编代码,查找起始地址:`42f160`,然后我们得到起始地址的代码是: 48 | ``` 49 | 000000000042f160 <_rt0_amd64_linux>: 50 | 42f160: 48 8d 74 24 08 lea 0x8(%rsp),%rsi 51 | 42f165: 48 8b 3c 24 mov (%rsp),%rdi 52 | 42f169: 48 8d 05 10 00 00 00 lea 0x10(%rip),%rax # 42f180
53 | 42f170: ff e0 jmpq *%rax 54 | ``` 55 | 好了,我们发现了在笔者的操作系统上的入口函数是:`_rt0_amd64_linux` 56 | 57 | ## 启动那一坨 58 | 现在我们在Go-Runtime的源代码里面查找入口函数,对于笔者的情况入口函数在文件[`rt0_linux_amd64.s`][3]里面。仔细看的话,你会在当前源代码目录下面发现很多rt0_[OS]_[architecture].s 的文件,这些就对应到不同系统,不同架构的入口函数。好,我们来仔细瞧瞧这个[`rt0_linux_amd64.s`][3]文件: 59 | ``` 60 | TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8 61 | LEAQ 8(SP), SI // argv 62 | MOVQ 0(SP), DI // argc 63 | MOVQ $main(SB), AX 64 | JMP AX 65 | 66 | TEXT main(SB),NOSPLIT,$-8 67 | MOVQ $runtime·rt0_go(SB), AX 68 | JMP AX 69 | ``` 70 | `_rt0_amd64_linux`函数非常简单,把参数`argv`和`argc`放到寄存器`SI`和`DI`里面,然后调用了`main` 函数,同时我们也看到`argv`和`argc`是位于`SP`上的,也就说是属于栈变量,可以通过`SP`寄存器访问到。后续的`main`函数也非常简单,只是调用了`runtime·rt0_go`函数。好`runtime·rt0_go`函数相对复杂一些了,我们把这个函数拆成几个部分来解析。 71 | 72 | `runtime·rt0_go`的第一部分: 73 | ``` 74 | MOVQ DI, AX // argc 75 | MOVQ SI, BX // argv 76 | SUBQ $(4*8+7), SP // 2args 2auto 77 | ANDQ $~15, SP 78 | MOVQ AX, 16(SP) 79 | MOVQ BX, 24(SP) 80 | ``` 81 | 这里我前面存在`DI`和`SI`里面的`argv`和`argc`放到`AX`和`BX`里面去,然后我们腾出4个变量栈空间,同时把腾出的空间按照16字节对齐,然后把刚才的`AX`和`BX`里面的参数放到栈上。 82 | 83 | `runtime·rt0_go`的第二部分比第一部分要复杂一些了: 84 | ``` 85 | // create istack out of the given (operating system) stack. 86 | // _cgo_init may update stackguard. 87 | MOVQ $runtime·g0(SB), DI 88 | LEAQ (-64*1024+104)(SP), BX 89 | MOVQ BX, g_stackguard0(DI) 90 | MOVQ BX, g_stackguard1(DI) 91 | MOVQ BX, (g_stack+stack_lo)(DI) 92 | MOVQ SP, (g_stack+stack_hi)(DI) 93 | ``` 94 | 首先把全局变量`runtime·g0`放到`DI`寄存器,这个变量定义在`proc1.go`文件里面,是一个`runtime.g`类型指针,相信看过[Part-3][4]对这个类型应该不陌生,系统会为每一个goroutine创建一个上下文,你应该也能猜到这个就是第一个gorountine的上下文,也就类似于主线程的线程上下文。后面的汇编我们初始化`runtime.g0`的各个成员变量,在汇编里面`stack_lo`和`stack_hi`这两个大家要弄清楚他们的含义,他们是当前goroutine的栈的起始地址和结束地址,汇编里面还有两个`g_stackguard0`和`g_stackguard1`变量,他们分别是搞什么黑科技的呢?要搞清楚这两个变量,我们要要先暂停`runtime·rt0_go`函数的探讨,然后专门讨论一下Go的真正黑科技`resizable stacks`动态栈。 95 | 96 | ## 实现动态栈`resizable stacks` 97 | 98 | Go语言使用一项叫做动态栈的技术,每次一个goroutine启动的时候只会分配一个很小的栈`_StackMin = 2048`,这个栈的大小会在每次函数调用的时候做检查,当达到一定阀值的时候,就调整栈的大小。为了详细了解这个过程我们继续对前面的示例程序做编译`go tool compile -S test.go`,生成相关汇编代码。编译后main函数对应的汇编代码应该是这样的: 99 | ``` 100 | "".main t=1 size=48 value=0 args=0x0 locals=0x8 101 | 0x0000 00000 (test.go:3) TEXT "".main+0(SB),$8-0 102 | 0x0000 00000 (test.go:3) MOVQ (TLS),CX 103 | 0x0009 00009 (test.go:3) CMPQ SP,16(CX) 104 | 0x000d 00013 (test.go:3) JHI ,22 105 | 0x000f 00015 (test.go:3) CALL ,runtime.morestack_noctxt(SB) 106 | 0x0014 00020 (test.go:3) JMP ,0 107 | 0x0016 00022 (test.go:3) SUBQ $8,SP 108 | ``` 109 | 首先我们把TLS(前面[Part-3][4]已经对TLS做过说明)里面的变量放到寄存器`CX`。我们知道TLS里面的变量存储的是一个指向`runtime.g`类型的指针,然后我们比较`SP`栈指针与`runtime.g`结构体偏移为16字节的变量,也就是比较栈栈指针和`runtime.g.stackguard0`字段。 110 | 这个就是进行栈大小比较的相关代码,这里我们检查当前栈是否达到了给定的阀值,如果空间不够则调用函数`runtime.morestack_noctxt`获取更多空间,然后跳转到`JMP ,0`继续做栈空间的检查。注意:关于顶部的一段描述`TEXT "".main+0(SB),$8-0`这里其实已经说明了函数对栈空间的要求,`$8-0`是说函数体的栈空间需求是8个字节,函数的参数包含返回值对栈的空间需求是0。 111 | 这里可以进一步查看一下[stack.go][6]的具体Layout: 112 | ``` 113 | // Stack frame layout 114 | // 115 | // (x86) 116 | // +------------------+ 117 | // | args from caller | 118 | // +------------------+ <- frame->argp 119 | // | return address | 120 | // +------------------+ 121 | // | caller's BP (*) | (*) if framepointer_enabled && varp < sp 122 | // +------------------+ <- frame->varp 123 | // | locals | 124 | // +------------------+ 125 | // | args to callee | 126 | // +------------------+ <- frame->sp 127 | // 128 | // (arm) 129 | // +------------------+ 130 | // | args from caller | 131 | // +------------------+ <- frame->argp 132 | // | caller's retaddr | 133 | // +------------------+ <- frame->varp 134 | // | locals | 135 | // +------------------+ 136 | // | args to callee | 137 | // +------------------+ 138 | // | return address | 139 | // +------------------+ <- frame->sp 140 | ``` 141 | 以及 stack的定义: 142 | ``` 143 | // Stack describes a Go execution stack. 144 | // The bounds of the stack are exactly [lo, hi), 145 | // with no implicit data structures on either side. 146 | type stack struct { 147 | lo uintptr 148 | hi uintptr 149 | } 150 | ``` 151 | 知道栈顶指针指向区间段[lo, hi),已经使用的空间可以通过`used := old.hi - gp.sched.sp`计算得到,那么当前栈的空闲空间是`space := gp.stackAlloc-used`,但可被使用的空闲空间其实是要除掉一部分系统保留区域`StackGuard`,也就是说栈的阀值:`runtime.g.stackguard0 == runtime.stack.lo+StackGuard`。其实关于栈的阀值还有一个是`runtime.g.stackguard1`他是用于cgo里面动态调整栈用的,具体的用法跟这里类似。关于调整栈大小的函数`runtime.morestack_noctxt`其实也是一个值得说一说的函数,后续篇幅中我们再来聊这个,我们这里抓紧回到主线继续讨论启动引导过程。 152 | 153 | ## 继续Go的Bootstrapping过程 154 | 我们看`runtime.rt0_go`函数的第三部分: 155 | ``` 156 | // find out information about the processor we're on 157 | MOVQ $0, AX 158 | CPUID 159 | CMPQ AX, $0 160 | JE nocpuinfo 161 | 162 | // Figure out how to serialize RDTSC. 163 | // On Intel processors LFENCE is enough. AMD requires MFENCE. 164 | // Don't know about the rest, so let's do MFENCE. 165 | CMPL BX, $0x756E6547 // "Genu" 166 | JNE notintel 167 | CMPL DX, $0x49656E69 // "ineI" 168 | JNE notintel 169 | CMPL CX, $0x6C65746E // "ntel" 170 | JNE notintel 171 | MOVB $1, runtime·lfenceBeforeRdtsc(SB) 172 | notintel: 173 | 174 | MOVQ $1, AX 175 | CPUID 176 | MOVL CX, runtime·cpuid_ecx(SB) 177 | MOVL DX, runtime·cpuid_edx(SB) 178 | nocpuinfo: 179 | ``` 180 | 这一部分对于理解整个Go的启动引导过程不是非常关键,而且汇编里面的注释也基本进行了有效的自说明,我们这里只是单纯过一遍。代码开始部分主要是找出当前的CPU架构,如果是Intel架构,则设置变量`runtime·lfenceBeforeRdtsc`为1,这个变量主要是用在函数`runtime·cputicks`里面,在这个函数里面根据这个变量用不同的汇编代码去获取CPU的`ticks`,后面的汇编代码则执行了一个汇编指令`CPUID`然后把结果保存在`runtime.cpuid_ecx`和`runtime.cpuid_edx`里面,这里存储的数值主要是用来根据不同的cpu架构选择我们使用什么样的hash算法。 181 | 182 | 继续探索`runtime.rt0_go`函数的第四部分: 183 | ``` 184 | // if there is an _cgo_init, call it. 185 | MOVQ _cgo_init(SB), AX 186 | TESTQ AX, AX 187 | JZ needtls 188 | // g0 already in DI 189 | MOVQ DI, CX // Win64 uses CX for first parameter 190 | MOVQ $setg_gcc<>(SB), SI 191 | CALL AX 192 | 193 | // update stackguard after _cgo_init 194 | MOVQ $runtime·g0(SB), CX 195 | MOVQ (g_stack+stack_lo)(CX), AX 196 | ADDQ $const__StackGuard, AX 197 | MOVQ AX, g_stackguard0(CX) 198 | MOVQ AX, g_stackguard1(CX) 199 | 200 | CMPL runtime·iswindows(SB), $0 201 | JEQ ok 202 | ``` 203 | 第四部分是只有开启了`cgo`支持的情况下才会执行,好`cgo`又是一个比较独立的主题,在后面的讨论中我们会单独探讨。这里我们还是抓主线,搞清楚Bootstrapping过程,所以我们这里依然跳过这一部分。 204 | 205 | 来到`runtime.rt0_go`函数的第五部分,这一部分主要是关于初始化TLS的: 206 | ``` 207 | needtls: 208 | // skip TLS setup on Plan 9 209 | CMPL runtime·isplan9(SB), $1 210 | JEQ ok 211 | // skip TLS setup on Solaris 212 | CMPL runtime·issolaris(SB), $1 213 | JEQ ok 214 | 215 | LEAQ runtime·tls0(SB), DI 216 | CALL runtime·settls(SB) 217 | 218 | // store through it, to make sure it works 219 | get_tls(BX) 220 | MOVQ $0x123, g(BX) 221 | MOVQ runtime·tls0(SB), AX 222 | CMPQ AX, $0x123 223 | JEQ 2(PC) 224 | MOVL AX, 0 // abort 225 | ``` 226 | 前面我们提到过好几次TLS(thread-local-storage),好这里我们将直面TLS的实现问题。 227 | 228 | 229 | ## TLS的实现细节 230 | 前面关于tls的汇编代码里面,如果过滤一下,我们不难发现真正干事情应该就只有如下两行汇编: 231 | ``` 232 | LEAQ runtime·tls0(SB), DI 233 | CALL runtime·settls(SB) 234 | ``` 235 | 其他汇编指令都是用于检测系统架构是否支持tls,如果不支持则跳过这部分或者如果支持,则检查tls环境是否工作正常。上面真正干事情的两行汇编第一行`LEAQ runtime·tls0(SB), DI`把`runtime.tls0`存入`DI`寄存器,第二行`CALL runtime·settls(SB)`调用了一个函数`runtime.settls`。真正的猫腻都在这个函数里面了,我们继续深入函数: 236 | ``` 237 | // set tls base to DI 238 | TEXT runtime·settls(SB),NOSPLIT,$32 239 | ADDQ $8, DI // ELF wants to use -8(FS) 240 | 241 | MOVQ DI, SI 242 | MOVQ $0x1002, DI // ARCH_SET_FS 243 | MOVQ $158, AX // arch_prctl 244 | SYSCALL 245 | CMPQ AX, $0xfffffffffffff001 246 | JLS 2(PC) 247 | MOVL $0xf1, 0xf1 // crash 248 | RET 249 | ``` 250 | 从汇编代码我们容易看到,它其实主要是用参数`ARCH_SET_FS`,调用了一个系统函数`arch_prctl`,而这个系统调用其实是设置`FS`段寄存器的基址,对应到我们这里的情形,我们设置TLS指向`runtime.tls0`变量。 251 | 是否还记得前面我们讨论`main`函数的时候,开始有一段这样的汇编: 252 | 253 | 0x0000 00000 (test.go:3) MOVQ (TLS),CX 254 | 255 | 前面我们说过,这个指令他会把一个指向`runtime.g`的指针移动到寄存器`CX`里面,而这个指针正是当前goroutine的上下文,结合前面的说明,我们能基本猜测到上面的伪汇编会翻译成什么样的机器代码,我们依然从前面解析的`disassemble.txt`文件里面去查证我们的猜测,我们找到`main.main`这个函数,它的第一条机器指令是: 256 | 257 | 400c00: 64 48 8b 0c 25 f0 ff mov %fs:0xfffffffffffffff0,%rcx 258 | 259 | 上面机器指令里面的冒号代表的是段寻址,也就是我们的`runtime.g`的指针位于段基址,我们通过访问端基址就可以得到当前的tls上下文,关于段寻址更多详细的背景知识可以阅读[这里][7]。 260 | 261 | ## 继续Bootstrapping过程 262 | 函数`runtime.rt0_go`函数还剩下两部分,我们继续看接下来的: 263 | ``` 264 | ok: 265 | // set the per-goroutine and per-mach "registers" 266 | get_tls(BX) 267 | LEAQ runtime·g0(SB), CX 268 | MOVQ CX, g(BX) 269 | LEAQ runtime·m0(SB), AX 270 | 271 | // save m->g0 = g0 272 | MOVQ CX, m_g0(AX) 273 | // save m0 to g0->m 274 | MOVQ AX, g_m(CX) 275 | ``` 276 | 先把tls的地址放到BX寄存器,然后把`runtime.g`的指针保持到tls里面去,然后继续初始化`runtime.m0`,如果`runtime.g0`代表的主goroutine,那么`runtime.m0`代表就是主线程了。后面的文章我们可以对`runtime.g0`和`runtime.m0`的结构体做详细的解读。 277 | 278 | 函数`runtime.rt0_go`的最后一部分依然是初始化相关参数,并调用一些其他的函数,关于启动引导过程,我们已经讨论很大一部分了,我们先花些时间消化一下,我们把这最后的一部分拿到下一章节再单独探讨。 279 | 280 | ## 还有啥? 281 | 经过前面的探讨,我们知道了动态栈是怎么实现的,我们也搞清楚了tls的实现,关于启动引导过程,还剩下最后一部分,我们下一节马上探讨这个。 282 | 283 | 284 | 285 | 286 | [1]: http://blog.altoros.com/golang-internals-part-5-runtime-bootstrap-process.html "Part 5: the Runtime Bootstrap Process" 287 | [2]: https://golang.org/doc/asm "Go's Assembler" 288 | [3]: https://golang.org/src/runtime/rt0_linux_amd64.s "rt0_linux_amd64" 289 | [4]: https://github.com/JerryZhou/golang-doc/blob/master/Golang-Internals/Part-3.The.Linker.Object.Files.and.Relocations.md "Part-3.The.Linker.Object.Files.and.Relocations" 290 | [5]: http://blog.rootk.com/post/golang-asm.html "golang-asm" 291 | [6]: https://github.com/golang/go/blob/master/src/runtime/stack.go "stack" 292 | [7]: http://thestarman.pcministry.com/asm/debug/Segments.html "Segments:OFFSET-Addressing" 293 | -------------------------------------------------------------------------------- /Golang-Internals/Part-6.Bootstrapping.and.Memory.Allocator.Initialization.md: -------------------------------------------------------------------------------- 1 | # 深入浅出 Golang,第五部分:启动过程以及内存分配器的初始化 [Part 6: Bootstrapping and Memory Allocator Initialization][1] 2 | 3 | 本文关注的依然是启动过程,了解整个启动过程对理解Golang运行时非常关键的。前面的小节已经讨论了一部分,这里讨论剩下的部分,这一部分会有非常多的运行时函数调用,我们会对一些重点函数进行讲解。 4 | 5 | ## 启动序列 6 | 上一小节关于`runtime.rt0_go`函数我们还剩下一小节: 7 | ``` 8 | CLD // convention is D is always left cleared 9 | CALL runtime·check(SB) 10 | 11 | MOVL 16(SP), AX // copy argc 12 | MOVL AX, 0(SP) 13 | MOVQ 24(SP), AX // copy argv 14 | MOVQ AX, 8(SP) 15 | CALL runtime·args(SB) 16 | CALL runtime·osinit(SB) 17 | CALL runtime·schedinit(SB) 18 | ``` 19 | 第一个汇编指令`CLD`是清除方向寄存器`FLAGS`,[方向寄存器][2]控制字符串的处理方式。第二条指令是调用了一个函数`runtime.check`,这个函数主要是Golang內建的类型int、string等做一些必要的校验,如果失败就会`panic`,这个函数对于整个过程也不是非常关键,我们不展开,可以在这个[地方][3]了解到详细的内容。后面部分的汇编主要就是初始化命令行参数,并调用了几个有些意思的初始化函数,我们对这几个函数分开解析。 20 | 21 | ## `runtime.args`参数分析 22 | 函数`runtime.args`这个其实不像字面的这么简单,在Linux上,这个函数除了把`argc`和`argv`存到静态变量里面外,他还负责解析`ELF`[PE-ELF][Executable and Linkable]头,并初始化系统调用的地址。 23 | 对于这个可能要稍微解释一下,当操作系统将一个可执行的文件加载进内存的时候,系统会初始化一个初始的可执行栈,然后根据执行镜像的头初始化一些预先定义好的格式化数据,这个可执行栈的顶部区域会存放环境变量相关的参数,同时会把ELF的辅助信息放到可执行栈的底部,如下的代码段所示: 24 | ``` 25 | position content size (bytes) + comment 26 | ------------------------------------------------------------------------ 27 | stack pointer -> [ argc = number of args ] 4 28 | [ argv[0] (pointer) ] 4 (program name) 29 | [ argv[1] (pointer) ] 4 30 | [ argv[..] (pointer) ] 4 * x 31 | [ argv[n - 1] (pointer) ] 4 32 | [ argv[n] (pointer) ] 4 (= NULL) 33 | 34 | [ envp[0] (pointer) ] 4 35 | [ envp[1] (pointer) ] 4 36 | [ envp[..] (pointer) ] 4 37 | [ envp[term] (pointer) ] 4 (= NULL) 38 | 39 | [ auxv[0] (Elf32_auxv_t) ] 8 40 | [ auxv[1] (Elf32_auxv_t) ] 8 41 | [ auxv[..] (Elf32_auxv_t) ] 8 42 | [ auxv[term] (Elf32_auxv_t) ] 8 (= AT_NULL vector) 43 | 44 | [ padding ] 0 - 16 45 | 46 | [ argument ASCIIZ strings ] >= 0 47 | [ environment ASCIIZ str. ] >= 0 48 | 49 | (0xbffffffc) [ end marker ] 4 (= NULL) 50 | 51 | (0xc0000000) < bottom of stack > 0 (virtual) 52 | ``` 53 | 关于ELF辅助信息(ELF auxiliary vector)可以通过阅读[这篇文章][5]加深了解。 54 | 55 | 函数`runtime.args`会负责解析整个elf,但Golang主要关注的是一个字段`startupRandomData`,Go用这个字段来驱动hash函数,并初始化一些系统调用函数的地址。下面的三个函数就是在这个阶段初始化的: 56 | ``` 57 | __vdso_time_sym 58 | __vdso_gettimeofday_sym 59 | __vdso_clock_gettime_sym 60 | ``` 61 | 上面的函数是用来获取各种单位的时间,这三个函数都有默认的实现,Golang通过`vsyscall`的方式调用上述的函数。 62 | 63 | - - - 64 | 65 | ## `runtime.osinit`系统相关的初始化 66 | 下一个初始化阶段调用的函数就是`runtime.osinit`,在linux上,这个函数主要干的一个事情就是通过系统调用获取当前机器的cpu个数,并保存到变量`ncpus`里面。 67 | 68 | - - - 69 | 70 | ## `runtime.schedinit`初始化调度器 71 | 接下来的[这个函数][6]做的事情比前面的`runtime.osinit`要多一些,他会先获取当前goroutine的上下文`runtime.g`的指针,前面关于tls的讨论里面我们已经多次涉及到这个东东了;然后如果开启了竞争检测,会调用函数[`runtime.raceinit`][7],但这个函数通常都不会调用,我们跳过这个;过完这个函数后,紧接着又是一系列的函数调用: 72 | ``` 73 | sched.maxmcount = 10000 74 | 75 | // Cache the framepointer experiment. This affects stack unwinding. 76 | framepointer_enabled = haveexperiment("framepointer") 77 | 78 | tracebackinit() 79 | moduledataverify() 80 | stackinit() 81 | mallocinit() 82 | mcommoninit(_g_.m) 83 | 84 | goargs() 85 | goenvs() 86 | parsedebugvars() 87 | gcinit() 88 | ``` 89 | 我们对上面的函数一个一个进行解析。 90 | 91 | ### 栈回溯初始化`tracebackinit` 92 | 函数`runtime.tracebackinit`这个[函数][8]让我们有能力可以进行调用栈的回溯,这个调用栈保存了从当前goroutine启动到当前函数的完整执行路径,任何时候发生`panic`,我们都可以通过`runtime.gentraceback`[获取当前的调用栈][9],当然这个调用栈里面我们不需要列出一些我们不需要关注的內建函数调用,而函数`runtime.tracebackinit`就是做这个事情,把我们不需要关注的內建函数地址初始化,后续栈追踪的时候去掉这些內建函数。 93 | 94 | ### 符号验证`moduledataverify` 95 | 符号是链接器生成的,这个函数用于验证这些数据的一致性。关于链接器,我们第三篇文章[Golang Internals, Part 3: The Linker, Object Files, and Relocations][10]有重点讨论这个,对于运行时来说,一个符号会对应到一个`moduledata`[结构体][11],函数`runtime.moduledataverify`会验证这个可执行镜像的所有符号的一致性,如果二进制被篡改就会`panic`。 96 | ``` 97 | type moduledata struct { 98 | pclntable []byte 99 | ftab []functab 100 | filetab []uint32 101 | findfunctab uintptr 102 | minpc, maxpc uintptr 103 | 104 | text, etext uintptr 105 | noptrdata, enoptrdata uintptr 106 | data, edata uintptr 107 | bss, ebss uintptr 108 | noptrbss, enoptrbss uintptr 109 | end, gcdata, gcbss uintptr 110 | 111 | typelinks []*_type 112 | 113 | modulename string 114 | modulehashes []modulehash 115 | 116 | gcdatamask, gcbssmask bitvector 117 | 118 | next *moduledata 119 | } 120 | ``` 121 | 122 | ### 初始化动态栈的分配池`stackinit` 123 | 要理解这一部做的事情,要先有一个背景知识,在Golang里面会开始为每一个goroutine分配一个容量比较小的栈,当这个栈的使用量达到某个阀值的时候就会扩容,运行时会从新分配一个两倍大小的栈,然后把原有栈里面的内容拷贝到新的栈里面,然后把新的栈赋值给这个goroutine。 124 | 当然前面我们还是比较初略的讲的,这里面还是有很多细节的,比如Golang是怎么判断栈达到阀值的,然后申请新的栈后,怎么调整栈里面的指针让这个新栈可以继续有效的使用等等,在前面的博文里面我们也讨论了这个问题,关于这个主题读者可以从[这篇文章][12]里面获取更多详细的内容。 125 | 对于栈的管理,Golang使用一个缓冲池来管理,Golang会在`runtime.stackinit`[函数][13]里面初始化这个,这个池是一个数组,数组的每一项都是栈的列表,同一个列表的栈的容量是一样的。另外一个在这个函数里面初始化的变量是`runtime.stackFreeQueue`,这也是一个栈的列表,这个列表在垃圾回收的过程中,会把需要回收的栈加入到这个列表,然后在垃圾回收结束的时候,释放这个列表上的所有栈。注意:这里栈的缓冲池只管理2Kb、4Kb、8Kb大小的栈,更大的栈通过直接分配释放来管理。 126 | 127 | ### 初始化内存分配器 `mallocinit` 128 | 内存分配器是基于`tcmalloc`,Golang 会在函数`runtime.mallocinit`里面初始化它,如果想理解这个分配器,强烈建议读者阅读[代码注释][14]里面提到的[这篇文章][15],我们这里对分配器初始化函数做进一步的探讨: 129 | ``` 130 | func mallocinit() { 131 | initSizes() 132 | 133 | if class_to_size[_TinySizeClass] != _TinySize { 134 | throw("bad TinySizeClass") 135 | } 136 | 137 | var p, bitmapSize, spansSize, pSize, limit uintptr 138 | var reserved bool 139 | 140 | // limit = runtime.memlimit(); 141 | // See https://golang.org/issue/5049 142 | // TODO(rsc): Fix after 1.1. 143 | limit = 0 144 | 145 | // Set up the allocation arena, a contiguous area of memory where 146 | // allocated data will be found. The arena begins with a bitmap large 147 | // enough to hold 4 bits per allocated word. 148 | if ptrSize == 8 && (limit == 0 || limit > 1<<30) { 149 | // On a 64-bit machine, allocate from a single contiguous reservation. 150 | // 512 GB (MaxMem) should be big enough for now. 151 | 152 | arenaSize := round(_MaxMem, _PageSize) 153 | bitmapSize = arenaSize / (ptrSize * 8 / 4) 154 | spansSize = arenaSize / _PageSize * ptrSize 155 | spansSize = round(spansSize, _PageSize) 156 | for i := 0; i <= 0x7f; i++ { 157 | switch { 158 | case GOARCH == "arm64" && GOOS == "darwin": 159 | p = uintptr(i)<<40 | uintptrMask&(0x0013<<28) 160 | case GOARCH == "arm64": 161 | p = uintptr(i)<<40 | uintptrMask&(0x0040<<32) 162 | default: 163 | p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32) 164 | } 165 | pSize = bitmapSize + spansSize + arenaSize + _PageSize 166 | p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved)) 167 | if p != 0 { 168 | break 169 | } 170 | } 171 | } 172 | 173 | if p == 0 { 174 | 175 | arenaSizes := []uintptr{ 176 | 512 << 20, 177 | 256 << 20, 178 | 128 << 20, 179 | } 180 | 181 | for _, arenaSize := range arenaSizes { 182 | bitmapSize = _MaxArena32 / (ptrSize * 8 / 4) 183 | spansSize = _MaxArena32 / _PageSize * ptrSize 184 | if limit > 0 && arenaSize+bitmapSize+spansSize > limit { 185 | bitmapSize = (limit / 9) &^ ((1 << _PageShift) - 1) 186 | arenaSize = bitmapSize * 8 187 | spansSize = arenaSize / _PageSize * ptrSize 188 | } 189 | spansSize = round(spansSize, _PageSize) 190 | 191 | 192 | p = round(firstmoduledata.end+(1<<18), 1<<20) 193 | pSize = bitmapSize + spansSize + arenaSize + _PageSize 194 | p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved)) 195 | if p != 0 { 196 | break 197 | } 198 | } 199 | if p == 0 { 200 | throw("runtime: cannot reserve arena virtual address space") 201 | } 202 | } 203 | 204 | // PageSize can be larger than OS definition of page size, 205 | // so SysReserve can give us a PageSize-unaligned pointer. 206 | // To overcome this we ask for PageSize more and round up the pointer. 207 | p1 := round(p, _PageSize) 208 | 209 | mheap_.spans = (**mspan)(unsafe.Pointer(p1)) 210 | mheap_.bitmap = p1 + spansSize 211 | mheap_.arena_start = p1 + (spansSize + bitmapSize) 212 | mheap_.arena_used = mheap_.arena_start 213 | mheap_.arena_end = p + pSize 214 | mheap_.arena_reserved = reserved 215 | 216 | if mheap_.arena_start&(_PageSize-1) != 0 { 217 | println("bad pagesize", hex(p), hex(p1), hex(spansSize), hex(bitmapSize), hex(_PageSize), "start", hex(mheap_.arena_start)) 218 | throw("misrounded allocation in mallocinit") 219 | } 220 | 221 | // Initialize the rest of the allocator. 222 | mHeap_Init(&mheap_, spansSize) 223 | _g_ := getg() 224 | _g_.m.mcache = allocmcache() 225 | } 226 | ``` 227 | 228 | #### 初始化类大小 229 | 函数`runtime.mallocinit`第一个做的事情就是调用函数`runtime.initSizes`[初始化][16]一个类大小的数组,这个负责预先计算一系列的类大小,这些小的内存块主要应对的是小于32Kb的小对象的分配。对于new一个对象的时候,Go会把申请的大小round到一个固定大小,这个大小会大于等于需要申请的内存大小,当然这样也就导致一部分内存浪费,但是也让不同类型的对象能够共享内存块,提高内存利用率。我们大概贴一下这个函数的关键部分: 230 | ``` 231 | align := 8 232 | for size := align; size <= _MaxSmallSize; size += align { 233 | if size&(size-1) == 0 { 234 | if size >= 2048 { 235 | align = 256 236 | } else if size >= 128 { 237 | align = size / 8 238 | } else if size >= 16 { 239 | align = 16 240 | … 241 | } 242 | } 243 | ``` 244 | 从代码里面可以看到最小的两个类别大小是8字节和16字节,这里会分为四组对齐方式: 245 | 1. [0,16)大小的内存采用8字节对齐 246 | 2. [16, 128)大小的内存采用16字节对齐的方式 247 | 3. [128, 2048)大小的内存采用size/8字节对齐的方式 248 | 5. [2048, -)大小的内存采用256字节对齐的方式 249 | 函数`runtime.initSizes`会初始化`class_to_size`[数组][17],这个数组建立类别与大小的关系,每一个列表对应到一个内存的大小,这里的类别也就是`class_to_size`数组的下标;同时这个函数还会初始化[数组][18]`class_to_allocnpages`,这个数组存储的是类别对应的内存页面数,也就是当前类别对应大小,映射到系统这边需要分配的内存页的数目。从一个内存大小转换到类别,有两个辅助数组`class_to_size8`和`class_to_size128`,这两个数组分别负责[0, 1Kb], 以及[1Kb, 32Kb]的内存分类。 250 | 251 | #### 保留部分虚拟内存 252 | 函数`runtime.mallocinit`干的另外一个事情就是申请虚拟内存,加快后续的内存分配。我们看下在x64体系下是怎么做的,首先初始化如下的变量: 253 | ``` 254 | arenaSize := round(_MaxMem, _PageSize) 255 | bitmapSize = arenaSize / (ptrSize * 8 / 4) 256 | spansSize = arenaSize / _PageSize * ptrSize 257 | spansSize = round(spansSize, _PageSize) 258 | ``` 259 | * arenaSize: 是保留的最大虚拟内存,在x64架构上是512Gb 260 | * bitmapSize: 是GC的辅助位图需要保留的总的内存大小,这个内存位图是一块特殊的内存,这块内存会记录那些地方存放了对象指针,以及指针对象是否已经被标记 261 | * spansSize:是存储Memory-Span指针数组所需要的总的内存大小。而Memory-Span是Golang里面对象内存分配器使用的原始内存区块 262 | 263 | 上述的内存大小计算好后,系统需要保留的总的内存大小可以如下计算得到: 264 | ``` 265 | pSize = bitmapSize + spansSize + arenaSize + _PageSize 266 | p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved)) 267 | ``` 268 | 最后我们初始化全局变量`mheap_`,这个是所有内存释放的中心存储器,所有内存分配都是在这个堆对象上进行。 269 | ``` 270 | p1 := round(p, _PageSize) 271 | 272 | mheap_.spans = (**mspan)(unsafe.Pointer(p1)) 273 | mheap_.bitmap = p1 + spansSize 274 | mheap_.arena_start = p1 + (spansSize + bitmapSize) 275 | mheap_.arena_used = mheap_.arena_start 276 | mheap_.arena_end = p + pSize 277 | mheap_.arena_reserved = reserved 278 | ``` 279 | 注意到:在堆变量初始化里面我们把`mheap_.arena_used `设置为`mheap_.arena_start`,因为在开始的时候我们还没有发生任何内存分配。 280 | 281 | #### 初始化堆 282 | 下一个被调用的[函数][19]是`mHeap_Init`,这个函数里面第一个做的事情是内存分配器的初始化: 283 | ``` 284 | fixAlloc_Init(&h.spanalloc, unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys) 285 | fixAlloc_Init(&h.cachealloc, unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys) 286 | fixAlloc_Init(&h.specialfinalizeralloc, unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys) 287 | fixAlloc_Init(&h.specialprofilealloc, unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys) 288 | ``` 289 | 要理解内存分配器是什么?要先看下内存分配器[是怎么使用的][20]`fixAlloc_Alloc`,每一次需要申请创建[mspan][21]、[mcache][22]、[specialfinalizer][23]或者[specialprofile][24]结构体都需要调用到这个函数`fixAlloc_Alloc`,在这个内存分配器的分配函数里面主要的一段代码如下所示: 290 | ``` 291 | if uintptr(f.nchunk) < f.size { 292 | f.chunk = (*uint8)(persistentalloc(_FixAllocChunk, 0, f.stat)) 293 | f.nchunk = _FixAllocChunk 294 | } 295 | ``` 296 | 对于一个分配器来说,他不是直接从系统申请给定大小`f.size`的内存,而是先通过`persistentalloc`申请一个`_FixAllocChunk`(现在是16Kb)的内存,然后把部分内存返回给调用者,把剩下的存在挂载到分配器上,下次通过同样的分配器申请内存的时候就可以直接使用这些余留的内存,这样避免每次调用`persistentalloc`函数带来的开销,`persistentalloc`函数大家也可以看出来,通过这个函数申请的内存是不参与GC的,上述的过程如下代码所示: 297 | ``` 298 | v := (unsafe.Pointer)(f.chunk) // 返回需要的内存大小 299 | if f.first != nil { 300 | fn := *(*func(unsafe.Pointer, unsafe.Pointer))(unsafe.Pointer(&f.first)) 301 | fn(f.arg, v) 302 | } 303 | f.chunk = (*byte)(add(unsafe.Pointer(f.chunk), f.size)) // 剩下的内存继续放到分配器上 304 | f.nchunk -= uint32(f.size) 305 | f.inuse += f.size // 记录这块已经使用的大小 306 | return v 307 | ``` 308 | 其中函数`persistentalloc`的内存申请流程是如下: 309 | 310 | 1. 如果申请的内存大小操过64Kb,则直接从OS申请 311 | 2. 否则找一个合适的持久化分配器来处理内存分配 312 | * 每一个CPU-内核都会绑定一个持久分配器,这样我们就避免了在分配器上加锁的操作,我们需要做的就是获取当前处理器对应的分配器 313 | * 如果找不到当前处理器对应的分配器,则我们使用一个全局的分配器 314 | 3. 如果当前分配器的内存缓存区域已经不够分配这次内存申请,则先从OS申请更多内存 315 | 4. 确保分配器的缓冲区的内存足够后,我们从分配器的缓冲区分配内存区块给到使用者 316 | 317 | 318 | 函数`persistentalloc`和函数`fixAlloc_Alloc`他们的机制是基本类似的,我们也可以这样理解,他们分别提供了不同层面的内存缓存机制。当然`persistentalloc`函数不仅仅在`fixAlloc_Alloc`函数里面使用,任何其他需要申请持久内存的地方都会使用到它。 319 | 320 | 好,我们把焦点移回到函数`mHeap_Init`身上,在前面初始化分配器的时候,我们初始化了4类结构体的分配器,这对于是4类结构体他们的作用分别是什么呢,我们这里就来分别介绍一下这些结构体的作用: 321 | 322 | * `mspan`这个结构体封装了用于被垃圾回收的内存块,前面讨论`size_to_class`的时候我们有提到这个,需要申请某一种大小的内存的时候,我们会创建一个相应的`mspan`作为这种大小的内存申请的缓冲区。 323 | * `mcache`这个是`mspan`的缓冲区,会为每一个CPU的核准备一个`mcache`,这样也可以避免内存分配的时候上锁 324 | * `specialfinalizer`这个是调用函数`runtime.SetFinalizer`时候分配的结构体,这个结构体里面存储的信息让我们有能力在某一个对象被垃圾回收的时候,我们顺带着做一些自定义的回收操作,一个典型的例子就是创建文件对象`os.NewFile`,每一个文件对象都会通过调用`runtime.SetFinalizer`关联一个析构结构体,当这个文件对象被垃圾回收的时候,我们调用系统函数关闭相应的文件描述符。 325 | * `specialprofile`这个是做性能分析的时候创建的结构体,这里暂时不多聊 326 | 327 | 初始化好这些内存分配器后,`mHeap_Init`后面通过[调用][25]`mSpanList_Init`初始化一些列表结构,`mheap`本身还是包含蛮多列表的。 328 | 329 | * `mheap.free`和`mheap.busy`包含的是大于32Kb小于1Mb的`mspan`数组,这里的大小是内存页的数目,一个内存页是32Kb,这里数组的一个等差数组,第一个元素包含的是32Kb的span列表,第二个元素包含的就是64Kb的span列表,以此类推。 330 | * `mheap.freelarge`和`mheap.busylarge` 是处理大于1Mb的`mspan`,机制与上一致 331 | 332 | 下一个初始化的是`mheap.central`,这里存储的是小于32Kb的`mspan`,在`mheap.central`也是按照大小分组。 333 | 334 | #### 最后的内存初始化操作 335 | 在函数`mallocinit`里面关于内存初始化还有最后一个东西,`mcache`的初始化: 336 | 337 | _g_ := getg() 338 | _g_.m.mcache = allocmcache() 339 | 340 | 首先获取goroutine,然后申请一个`mcache`赋值给`g.m.mcache`,函数`allocmcache`会调用`fixAlloc_Alloc`初始化一个新的`mcache`结构体。细心的读者可能也注意到,前面我们提到`mcache`会绑定到处理器,但这里又把`mcache`关联到了goroutine,goroutine对应到到的是系统的处理单元,类似线程的概念。是的这里没有搞错,一个goroutine的`mcache`会在goroutine的相应执行单元切换也就是线程切换的时候进行调整,找到相应线程的`mcache`。 341 | 342 | ## 到底还有谁? 343 | 344 | 下一篇文章,我们依然紧靠启动引导过程,我们会关注GC是怎么初始化的,以及第一个goroutine是怎么启动起来的。 345 | 346 | 347 | [1]: http://blog.altoros.com/golang-internals-part-6-bootstrapping-and-memory-allocator-initialization.html "Part 6: Bootstrapping and Memory Allocator Initialization" 348 | [2]: https://en.wikipedia.org/wiki/Direction_flag "Direction_flag" 349 | [3]: https://github.com/golang/go/blob/go1.5.1/src/runtime/runtime1.go#L136 "check" 350 | [4]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format "Executable_and_Linkable_Format" 351 | [5]: http://articles.manugarg.com/aboutelfauxiliaryvectors "elf auxiliary vectors" 352 | [6]: https://github.com/golang/go/blob/go1.5.1/src/runtime/proc1.go#L40 "runtime.schedinit" 353 | [7]: https://github.com/golang/go/blob/go1.5.1/src/runtime/race1.go#L110 "raceinit" 354 | [8]: https://github.com/golang/go/blob/go1.5.1/src/runtime/traceback.go#L58 "traceback" 355 | [9]: https://github.com/golang/go/blob/go1.5.1/src/runtime/traceback.go#L120 "gentraceback" 356 | [10]: http://blog.altoros.com/golang-internals-part-3-the-linker-and-object-files.html "Part-3" 357 | [11]: https://github.com/golang/go/blob/go1.5.1/src/runtime/symtab.go#L37 "moduledata" 358 | [12]: https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ/pub "continues stacks" 359 | [13]: https://github.com/golang/go/blob/go1.5.1/src/runtime/stack1.go#L54 "stackinit" 360 | [14]: https://github.com/golang/go/blob/go1.5.1/src/runtime/malloc.go#L5 "malloc-source-code" 361 | [15]: http://goog-perftools.sourceforge.net/doc/tcmalloc.html "tcmalloc" 362 | [16]: https://github.com/golang/go/blob/go1.5.1/src/runtime/msize.go#L66 "initSizes" 363 | [17]: https://github.com/golang/go/blob/go1.5.1/src/runtime/msize.go#L49 "class_to_size array" 364 | [18]: https://github.com/golang/go/blob/go1.5.1/src/runtime/msize.go#L50 "class_to_allocnpages array" 365 | [19]: https://github.com/golang/go/blob/go1.5.1/src/runtime/mheap.go#L273 "mHeap_Init" 366 | [20]: https://github.com/golang/go/blob/go1.5.1/src/runtime/mfixalloc.go#L54 "fixAlloc_Alloc" 367 | [21]: https://github.com/golang/go/blob/go1.5.1/src/runtime/mheap.go#L101 "mspan" 368 | [22]: https://github.com/golang/go/blob/go1.5.1/src/runtime/mcache.go#L11 "mcache" 369 | [23]: https://github.com/golang/go/blob/go1.5.1/src/runtime/mheap.go#L1009 "specialfinalizer" 370 | [24]: https://github.com/golang/go/blob/go1.5.1/src/runtime/mheap.go#L1050 "specialprofile" 371 | [25]: https://github.com/golang/go/blob/go1.5.1/src/runtime/mheap.go#L863 "spanlist_init" 372 | 373 | -------------------------------------------------------------------------------- /Golang-Internals/Part-x.footer.md: -------------------------------------------------------------------------------- 1 | ### 欢迎转载 2 | 本程序基于MIT协议开源。 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 16 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 19 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Golang-Reads/C++14.Spec.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Golang-Reads/C++14.Spec.pdf -------------------------------------------------------------------------------- /Media/haha1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha1.mp3 -------------------------------------------------------------------------------- /Media/haha10.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha10.mp3 -------------------------------------------------------------------------------- /Media/haha11.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha11.mp3 -------------------------------------------------------------------------------- /Media/haha12.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha12.mp3 -------------------------------------------------------------------------------- /Media/haha13.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha13.mp3 -------------------------------------------------------------------------------- /Media/haha14.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha14.mp3 -------------------------------------------------------------------------------- /Media/haha15.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha15.mp3 -------------------------------------------------------------------------------- /Media/haha16.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha16.mp3 -------------------------------------------------------------------------------- /Media/haha17.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha17.mp3 -------------------------------------------------------------------------------- /Media/haha18.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha18.mp3 -------------------------------------------------------------------------------- /Media/haha19.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha19.mp3 -------------------------------------------------------------------------------- /Media/haha2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha2.mp3 -------------------------------------------------------------------------------- /Media/haha20.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha20.mp3 -------------------------------------------------------------------------------- /Media/haha21.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha21.mp3 -------------------------------------------------------------------------------- /Media/haha22.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha22.mp3 -------------------------------------------------------------------------------- /Media/haha23.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha23.mp3 -------------------------------------------------------------------------------- /Media/haha3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha3.mp3 -------------------------------------------------------------------------------- /Media/haha4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha4.mp3 -------------------------------------------------------------------------------- /Media/haha5.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha5.mp3 -------------------------------------------------------------------------------- /Media/haha6.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha6.mp3 -------------------------------------------------------------------------------- /Media/haha7.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha7.mp3 -------------------------------------------------------------------------------- /Media/haha8.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha8.mp3 -------------------------------------------------------------------------------- /Media/haha9.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/haha9.mp3 -------------------------------------------------------------------------------- /Media/ss1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/ss1.mp3 -------------------------------------------------------------------------------- /Media/testaievilaudio.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Media/testaievilaudio.mp3 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # golang-doc: JerryZhou@outlook.com 2 | 3 | ## 文章约定: 4 | - 专有名词: (斜体加下划线) Node-tree 5 | - 引用: [Golang](http://www.golang.org) 6 | -------------------------------------------------------------------------------- /Regex/Finite_Automata_and_Their_Decision_Problems.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Regex/Finite_Automata_and_Their_Decision_Problems.pdf -------------------------------------------------------------------------------- /Regex/Part01.Regular_Expression_Matching_Can_Be_Simple_And_Fast.md: -------------------------------------------------------------------------------- 1 | # Regular Expression Matching Can Be Simple And Fast 2 | 3 | ## Introduction 4 | 关于正则表达式有这么一个小段子,有两个方法来实现正则匹配。其中一种是被广泛使用的,许多的编程语言都是用的这种,几乎是一种标准的实现,包括我们见到的Perl、Python等;另外一种只是在少数几个不太起眼的地方用到,比如Unix下的工具awk和grep。两个方法的性能数据差别很大,如下图所示: 5 | ![a?a?a?aaa](https://swtch.com/~rsc/regexp/grep3p.png)![image](https://swtch.com/~rsc/regexp/grep4p.png) 6 | ```math 7 | a?^3a^3 == a?a?a?aaa 8 | ``` 9 | 前面出现的a?-n-a-n-我们来简化一下表达式,比如a?-3-a-3-就是a?a?a?aaa的简写,上面的测试案例就是用上述的重复式样来匹配a-n-这样的原字符串。 10 | 11 | 我们可以注意到在测试数据差异非常大,Perl匹配长度为29的字符串就已经需要超过60秒,而另外一种叫做Thomposon-NFA的方法只需要20毫秒。而且图里面我们看到对于NFA对应的方法它的时间抽的单位是毫秒,而且对于长度越长的正则串,他们之间的差异就越大,对于100个字符的船,Thompson只需要200毫秒,而Perl采用的方法却需要10-15-年(其实Perl只是一种比较典型的语言,其他常见的Python、PHP或者Ruby等都是类似的状况)。 12 | 13 | 前面的图表看起来非常难以置信:或许你也有使用过Perl,而且也没有觉得它的正则表达式有性能问题。事实上,大部分时间,Perl的正则匹配效率还是很高的,只是确实我们可以很容易写出一种对Perl可以称为“病态”的正则串,但是对Thompson-NFA来说确是一个非常正常的串。需要这个时候你可以心里已经产生了一个疑问:为什么Perl不采用Thompson-NFA的这种实现方式呢?他确实可以,而且应该采用这种方式,这篇文章下面的内容就会详细的讲解NFA的具体实现算法。 14 | 15 | 历史上,正则表达式是计算机科学中一个非常典型的理论走向工程,并完美结合的例子。正则表达式是理论学家发明的一个非常简单的计算模型,Ken Thompson把他带到了工业界,带给了广大的程序员,他在给CTSS实现文本编辑器QED的时候实现了正则表达式。后面Dennis Ritchie跟随脚步,在给GE-TSS写文本编辑器GED的时候也干了这个事情。 后面Thompson和Ritchie一起去倒腾Unix的时候也一并把正则表达带进来了。在70年代后期,正则表达式在Unix上也形成了一道亮丽的独特风景,许多的工具应运而生,包括ed、grep、awk和lex等。 16 | 17 | 今天,正则表达式也成为了一个非常典型的失败例子,我们看到在脱离理论后,在工业界,在广大的程序员手上被整得不成样子。今天我们流行的这些使用正则表达式的工具,他的运行效率比30年的Unix工具效率还低,并且已经低到不能忍的地步了。 18 | 19 | 这篇文章我们会来回顾老头子Thomposon在1960年代中期发明的关于正则表达式,有限状态机,正则匹配搜索算法等理论知识。同时我们也会在这篇文章中把相关理论的实现做描述。其实整个实现还不到400行C代码,但它其实比Perl里面的那种实现不知道要优秀了多少代,同时他的实现复杂度还比那些被用在Perl、Python、PCRE等还要低。这边文章会讨论理论,同时也会把理论怎么到具体的实现做讨论和阐述。 20 | 21 | ## Reglular Expressions 22 | 23 | 正则表达式其实是一个助记符,他描述的就是一组字符串。如果给定的字符串出现在正则表达式描述的字符串组里面,我们就说这个正则表达式匹配当前字符串。 24 | 25 | 一个最简单的正则表达式就是一个单独的字符,当前我们这里要去掉如下的一些元字符`*+?()|`,如果要匹配这些元字符,我们需要在元字符前面加一个反斜杠:比如`\+`匹配加号字符。 26 | 27 | 可以通过两个正则规则:可选和连接来组成新的正则表达式。如果`e1`匹配`s`以及`e2`匹配`t`,那么`e1|e2`可以匹配`s`或者`t`;`e1e2`匹配`st`。 28 | 29 | 元字符 `*`、`+`和`?`都是属于重复操作符:`e*`匹配零个或者多个字符串(字符串可以不一样),其中每一个字符串都匹配规则`e`;`e+`匹配一个或者多个;`e?`匹配零个或者一个。 30 | 31 | 操作符的处理优先级是 或操作 < 连接操作 < 重复操作。一个明确的括号符可以用来强制表达不同的意思,和算术运算里面一样,比如:`ab|cd`和`(ab)|(cd)`是等价的;`ab*`和`a(b*)`是等价的。 32 | 33 | 到现在为止,我们描述的正则表达式是传统的Unix的egrep正则表达式语法的一个子集。现在描述的这个子集已经足够来描绘正则语言:简单说,正则语言就是一组字符串,在一个固定的内存消耗下,这组字符串我们可以通过一个pass就可以来对目标串进行匹配。现在新的正则表达式(比较典型的是Perl里面的正则表达式)都是在这个基础上,新增一些新的操作符合和一些编码串。新增的这些正则表达式规则会让正则语言编写更简单,但有的时候这些新增的规则也把这个搞复杂同时还没有达到强化匹配的目的。而且新增的那些看起来很漂亮的表达式通常都还不如用传统的语法来表达。 34 | 35 | 一个给正则表达式提供额外能力的典型扩展就是`backreferences`后向引用。后向引用的意思就是用`\1`或者`\2`这样的表达式来匹配前面已经匹配过的具体字符串。比如`(cat|dog)\1`可以匹配`catcat`或者`dogdog`,但不能匹配`catdog`或者`dogcat`.严格意义上来说`backreferences`不是正则表达式,而且支持的`backreferences`需要消耗巨大的代价,比如典型的在Perl里面,在某些条件下这个搜索算法是指数复杂度的。而且现在Perl等语言已经不能移除对`backreferences`的支持了。当然这些语言可以改进相关实现,对于没有出现`backreferences`的正则表达式采用一些其他的算法。 36 | 37 | ## Finite Automata 38 | 39 | 另外一个用来描述一组字符串的方法就是有限状态机。在后面想文章中,我们会交替使用`automaton`和`machine`来表达这个。 40 | 41 | 下面一个简单的例子,我们来看下和正则表达式`a(bb)+a`匹配同样的一组字符串的这么一个状态机: 42 | 43 | ![image](https://swtch.com/~rsc/regexp/fig0.png) 44 | 45 | 一个有限状态机他任何时候都会处于它其中的某一个状态(前面图中的一个圆圈就是一个状态,圈圈里面的标签我们文章的后面再说明)。这个状态机从字符串一个一个读取字符的时候,他会从一个状态到另外一个状态进行迁移。状态机有两个特殊的状态:一个起始状态`s0`和匹配状态`s4`。起始状态会有一个缺少来源线的箭头指向它,而匹配状态会被画成两个圈的形式。 46 | 47 | 状态机从字符串一个字符一个字符的读取,输入字符串上的箭头标示当前的读取字符,状态机上状态间的连接箭头上的字符标示的是状态间转移的条件字符。键入输入字符串是`abbbba`。状态机读取的第一个字符是`a`,这个时候状态机处于`s0`,读取`a`后跳转到`s1`。状态机在一次从输入串读取其他字符重复这个过程:依次读`b`跳转到`s2`,读`b`跳转到`s3`,读`b`跳转到`s2`,读`b`跳转到`s3`,最后读`a`跳转到`s4`。 48 | 49 | ![image](https://swtch.com/~rsc/regexp/fig1.png) 50 | 51 | 状态机最后停留在`s4`的匹配状态,这个时候就叫做状态机匹配字符串`abbbba`。如果状态机最后停留的状态不是`s4`,那么状态机就不匹配这个字符串。如果在状态机执行的过程中,读取一个字符后,发现没有相应的状态可以跳转,这个时候状态机就会过早的停下来。 52 | 53 | 我们前面描述的状态机,我们叫做DFA(deterministic finite automaton)确定有限状态机,在任何状态下,每一个可能的字符输入都会有至多有一个新的跳转状态。我们也可以创建一状态机,他在某些情况下可以有多个可选的跳转状态下做选择。比如下面的状态机他就不是确定的: 54 | 55 | ![image](https://swtch.com/~rsc/regexp/fig2.png) 56 | 57 | 这个状态机就不是确定的,因为当他处于`s2`的状态的时候,如果读取`b`后,下一个跳转状态有多个选择,他可以跳转到`s1`也可以跳转到`s3`。因为状态机对后续的输入这个时候还是未知的,他没有足够的信息来做正确与否的决定,这个时候到底跳转到那个状态才是正确的是一个非常有意思的事情。对于这种状态机,我们叫非确定性状态机NFAS后者NDFAS。对于NFAS来说,如果存在一条路径匹配字符串,我们就叫这个NFA匹配这个字符串。 58 | 59 | 有的时候,如果允许NFA里面出现一种零输入的跳转是一个非常便利的方法。我们对于不需要输入的跳转,我们在图上他们的跳转箭头上就不写任何输入标示。一个NFA如果处于存在无标示跳转的状态,他可以选择不读取任何数据而执行相应的无标示跳转。下面图中的状态机与前面的状态机等价,但他更清晰的表达了正则表达式`a(bb)+a`: 60 | 61 | ![image](https://swtch.com/~rsc/regexp/fig3.png) 62 | 63 | ## Converting Regular Expressions to NFAs 64 | 65 | 可以证明在能力上正则表达式和NFAs他们是等值的:每一个正则表达式都有一个NFA(他们匹配同样的字符串组)匹配。(其实也可以证明DFAs在能力上与NFAs以及正则表达式也是一致的,这个我们在文章的后面会看到)。有多种方法可以把正则表达式转换到NFAs。本文描述的方法由Thompson在1968年发表在CACM的论文上。 66 | 67 | 一个正则表达式的NFA是通过组合一组对应到正则表达式的子表达式匹配的NFAs构造的。正则表达式里面的每一个操作符都有一种他自己的组合方式。每一个子表达式对应的NFAs本身是不具备匹配状态的(子表达式对应的状态机是没有终结状态的),反而他们会有一些指向未知状态的箭头,整个构造过程会把这些链接起来构造一个完整的匹配状态。 68 | 69 | 匹配单独一个字符的NFAs看起来是这样的: 70 | 71 | ![image](https://swtch.com/~rsc/regexp/fig4.png) 72 | 73 | 链接操作符`e1e2`的NFA看起来是这样的,`e1`的结束箭头指向`e2`的开始: 74 | 75 | ![image](https://swtch.com/~rsc/regexp/fig5.png) 76 | 77 | 可选操作符`e1|e2`会增加一个起始状态来做选择: 78 | 79 | ![image](https://swtch.com/~rsc/regexp/fig6.png) 80 | 81 | 重复操作符`e?`的状态机会增加一个空的路径: 82 | 83 | ![image](https://swtch.com/~rsc/regexp/fig7.png) 84 | 85 | 重复操作符`e*`和前面的类似,只是增加一个循环路径: 86 | 87 | ![image](https://swtch.com/~rsc/regexp/fig8.png) 88 | 89 | 重复操作符`e+`也会创建一个循环路径,只是他会要求这儿循环路径至少执行一次: 90 | 91 | ![image](https://swtch.com/~rsc/regexp/fig9.png) 92 | 93 | 前面的图我们可以看出,我们会为每一个字符包括每一个元字符会创建一个相应的状态。因此最后NFA的状态数基本会和正则表达式一样。 94 | 95 | 通过前面的NFA例子,我们知道任何时候都可以移除哪些没有标示的状态跳转箭头,我们也可以完全不依赖这种无标示跳转,只是加上这种跳转会更清晰的表达正则表达式,而且更加容易理解,而且让后面的C语言实现也更加简单,所以我们保留这种无标示跳转。 96 | 97 | ## Regular Expression Search Algorithms 98 | 99 | 现在我们已经有方法可以用来检测字符串与正则表达式匹配与否了:通过把正则表达式转换到一个NFA,然后把目标字符串当做NFA的输入。这里需要提醒一下的是NFAs的运作是假定在遇到多个可选的跳转状态的时候,状态机能对下一个跳转状态做出完美预判的能力,也就是我们需要为NFAs找出一种方法来模拟这种预判的能力。 100 | 101 | 其中一种模拟预判的方法是:任意选择一条路径,如果不行再退回来选择另外一条路径。比如正则表达式`abab|abbb`等价的NFA状态机在处理字符输入`abbb`的情形: 102 | 103 | ![image](https://swtch.com/~rsc/regexp/fig10.png) 104 | 105 | ![image](https://swtch.com/~rsc/regexp/fig11.png) 106 | 107 | 在 step-0的时候,NFA做出面临的选择:选择用`abab`来匹配还是`abbb?`来匹配。在上面的图表中,NFA选择了`abab`,但是当输入进行到step-3的时候,匹配失败。然后NFA退回去到step-4选择另外一条路径。这种回退的方法可以用递归的方式来简单实现,只是我们需要对输入字符串进行多次读取。在这总递归的处理中,如果遇到匹配失败,自动机需要退回去选择其他路径,直到所有可能的路劲都尝试到了。在上面的例子中,我们看到只有两个可能的选择,在实际情况中,这种可能的路劲很容易引入指数的复杂度。 108 | 109 | 另外一个更加高效但是相对比较难进行模拟实现的方法是进行平行处理,在面对处理多个选择的时候,我们同时对多个选择进行处理。这种情况需要状态机可以同时处理多个状态,状态机对于匹配成功的状态会同时更新到下一个状态。 110 | 111 | ![image](https://swtch.com/~rsc/regexp/fig12.png) 112 | 113 | 上图中,状态机开始在起始状态,然后在step-1和step-2里面,NFA同时处于两个状态,然后到step-3的时候状态又只匹配到一个状态。在这种多状态并行处理的方法中,我们对于输入字符串只会遍历一次。对于这种并发的状态机,最差的情况就是每次输入我们的状态机都会同时处于所有可能的状态,但是这种情况的复杂度其实与输入字符串的长度是保持线性复杂度的,这个与前面递归情况的指数复杂度是一个巨大的性能提升,这种提升主要来自于我们枚举的是可能的状态数,而不是可能的路径数,比如对于一个有n个状态节点的NFA,任何时候,我们都至多同时处于n个状态,但是对于NFA的路劲来说却有2^n条。 114 | 115 | ## Implementation 116 | 117 | Thompson在他1968年发表的论文里面介绍了一种多状态并发的实现方法。在他的理论体系下,NFA的状态会用一串非常简洁的机器码的序列来表示,其中这些机器码都是一些简洁的函数调用指令。非常关键的是,Thompson把正则表达式编译成非常聪明和优秀的机器码来提升整体的执行效率。在40年后的今天,计算机的计算能力已经有了质的飞跃,这种机器码的方式已经不是非常必须了。接下来的小节中,我们会用ANSI-C的方式来实现,全部的代码量不足400行,我们也对这个做了性能的压力测试,具体相关数据可以在网上找到(对于不熟悉C语言和指针的读者可以跳过具体的实现代码,只看相关的描述就好)。 118 | 119 | ### Implementation: Compiling to NFA 120 | 121 | 第一步要做的就是把正则表达式翻译成NFA状态机。在我们的C的程序里面,我们用如下的结构体代表状态,而状态机是如下结构体所示状态的一个链表: 122 | 123 | 124 | ``` 125 | struct State 126 | { 127 | int c; 128 | struct State *out; 129 | struct State *out1; 130 | int lastlist; 131 | }; 132 | ``` 133 | 每一个状态都根据c的具体的值,代表如下的三种NFA片段中的一种: 134 | 135 | ![image](https://swtch.com/~rsc/regexp/fig13.png) 136 | 137 | (参数lastlist会在状态机执行的过程中用到,后面的小节会详细解释这个参数) 138 | 139 | 在Thompson的论文里面,编译器接受的正则表达式是采用逗号`.`的后缀标记法,什么意思呢,就是会用逗号来明确表达连接操作。里面会写一个函数`re2post`专门来做这个转换,比如把如下的正则表达式`a(bb)+a`转换后缀标记法`abb.+.a.`的形式(在实际的实现上逗号是属于元字符中的一个,一个正儿八经对外用的编译器一般是直接操作正则表达式,而不会做这一次转换,我们这里为了更加贴近Thompson的论文,而且确实后缀标记法也确实有提供一定的便利性,我们依然沿用这种标记方法)。 140 | 141 | 在扫描后缀正则表达式的时候,编译器会维持一个NFA-fragments的栈结构;遇到普通字符的时候会产生新的NFA-fragment然后入栈,遇到操作符的时候会pop栈,然后根据操作的具体类型产生新的NFA-fragment并入栈。比如例子里面的,在扫描了`abb`的时候,栈里面就有三个NFA-fragments片段,分别是`a`,`b`和`b`。然后遇到`.`操作符,他会pop栈上的两个`b`的NFA-fragment,然后会产生一个新的NFA-fragment来代表`bb..`的连接操作。每一个NFA-fragment都由他自己的本身的状态值和相应的outgoing外链箭头组成: 142 | 143 | ``` 144 | struct Frag 145 | { 146 | struct State *start; 147 | Ptrlist *out; 148 | }; 149 | ``` 150 | 其中`start`是片段的起始点,`out`是一个指向`struct State*`的指针列表,指针列表里面的状态暂时还没有指向任何其他下一个状态,处于一个待链接的状态,他们是NFA-fragment里面的dangling-arrows。 151 | 152 | 下面有一些辅助函数来操作这个指针列表: 153 | 154 | ``` 155 | Ptrlist *list1(struct State **outp); 156 | Ptrlist *append(Ptrlist *l1, Ptrlist *l2); 157 | 158 | void patch(Ptrlist *l, struct State *s); 159 | ``` 160 | 函数`List1`会创建一个新的指针列表,指针列表包含一个指针`outp`。`append`函数会连接两个指针列表,然后返回连接后的结果。`Patch`函数会链接列表里面那些待连接的dangling-arraws到状态`s`:也就是变量`l`里面的`outp`指针,并设置`*outp=s`。 161 | 162 | 在给定的fragment和fragment-stack的情况下,编译器要做的事情就是一个简单的遍历后缀表达式的循环。在遍历结束后,栈上只留下一个frament:最后的这个fragment把他的待连接状态链接到一个matching-state,这个NFA就完整了。 163 | 164 | 165 | ``` 166 | State* 167 | post2nfa(char *postfix) 168 | { 169 | char *p; 170 | Frag stack[1000], *stackp, e1, e2, e; 171 | State *s; 172 | 173 | #define push(s) *stackp++ = s 174 | #define pop() *--stackp 175 | 176 | stackp = stack; 177 | for(p=postfix; *p; p++){ 178 | switch(*p){ 179 | /* compilation cases, described below */ 180 | } 181 | } 182 | 183 | e = pop(); 184 | patch(e.out, matchstate); 185 | return e.start; 186 | } 187 | ``` 188 | 上面算法中的switch-case的具体情况,其实前面我们我们已经有过描述,这里再详细展开一次: 189 | 普通字符: 190 | 191 | ``` 192 | default: 193 | s = state(*p, NULL, NULL); 194 | push(frag(s, list1(&s->out)); 195 | break; 196 | ``` 197 | ![image](https://swtch.com/~rsc/regexp/fig14.png) 198 | 199 | 连接操作: 200 | 201 | ``` 202 | case '.': 203 | e2 = pop(); 204 | e1 = pop(); 205 | patch(e1.out, e2.start); 206 | push(frag(e1.start, e2.out)); 207 | break; 208 | ``` 209 | ![image](https://swtch.com/~rsc/regexp/fig15.png) 210 | 211 | 可选操作: 212 | 213 | ``` 214 | case '|': 215 | e2 = pop(); 216 | e1 = pop(); 217 | s = state(Split, e1.start, e2.start); 218 | push(frag(s, append(e1.out, e2.out))); 219 | break; 220 | ``` 221 | ![image](https://swtch.com/~rsc/regexp/fig16.png) 222 | 223 | 零个或者一个: 224 | 225 | ``` 226 | case '?': 227 | e = pop(); 228 | s = state(Split, e.start, NULL); 229 | push(frag(s, append(e.out, list1(&s->out1)))); 230 | break; 231 | ``` 232 | ![image](https://swtch.com/~rsc/regexp/fig17.png) 233 | 234 | 零个或者多个: 235 | 236 | ``` 237 | case '*': 238 | e = pop(); 239 | s = state(Split, e.start, NULL); 240 | patch(e.out, s); 241 | push(frag(s, list1(&s->out1))); 242 | break; 243 | ``` 244 | ![image](https://swtch.com/~rsc/regexp/fig18.png) 245 | 246 | 一个或者多个: 247 | 248 | ``` 249 | case '+': 250 | e = pop(); 251 | s = state(Split, e.start, NULL); 252 | patch(e.out, s); 253 | push(frag(e.start, list1(&s->out1))); 254 | break; 255 | ``` 256 | ![image](https://swtch.com/~rsc/regexp/fig19.png) 257 | 258 | ### Implementation: Simulating the NFA 259 | 260 | 经过前面的步骤,我们已经构建了一个NFA,现在我们需要来模拟运行状态机。模拟运行的过程需要追踪状态,我们把状态存储为一个简单的如下数组: 261 | 262 | ``` 263 | struct List 264 | { 265 | State **s; 266 | int n; 267 | }; 268 | ``` 269 | 270 | 状态机的模拟运行需要用到两个状态列表:`clist`代表当前的NFA所处的状态列表,`nlist`是NFA接受下一个输入以后的的状态列表。状态机运行前会初始化`clist`为zhi只包含开始状态,然后开始执行循环,每一步接受一个输入参数,从`clist`的状态列表转换到`nlist`的状态列表: 271 | 272 | 273 | ``` 274 | int 275 | match(State *start, char *s) 276 | { 277 | List *clist, *nlist, *t; 278 | 279 | /* l1 and l2 are preallocated globals */ 280 | clist = startlist(start, &l1); 281 | nlist = &l2; 282 | for(; *s; s++){ 283 | step(clist, *s, nlist); 284 | t = clist; clist = nlist; nlist = t; /* swap clist, nlist */ 285 | } 286 | return ismatch(clist); 287 | } 288 | ``` 289 | 290 | 避免在循环里面每次都申请创建列表,我们会预创建两个全局列表`l1`和`l2`,作为`clist`和`nlist`来用,每一步我们交换`l1`和`l2`做位`clist`和`nlist`。如果再处理完所有输入以后,最后NFA停留的状态列表里面包含了matching-state,我们就说当前的输入字符串与状态机匹配。 291 | 292 | 293 | ``` 294 | int 295 | ismatch(List *l) 296 | { 297 | int i; 298 | 299 | for(i=0; in; i++) 300 | if(l->s[i] == matchstate) 301 | return 1; 302 | return 0; 303 | } 304 | ``` 305 | 306 | 函数`Addstate`会把一个状态`state`加入列表,这个加入操作如果需要扫描整个列表来确认当前状态是否已经加入列表的话,会相对低效,所以这里我们用通过一个`listid`来代表当前列表的快照id,如果状态上的快照id和列表的快照id一致,就说明状态已经加入列表;`Addstate`函数会处理那种unlabeled-arraw,如果当前处理的状态是一个`Split`类型的状态,`Addstate`会把两个unlabeled-arraws指向的状态加入列表,而不是直接把这个`Split`状态加入列表。 307 | 308 | 309 | ``` 310 | void 311 | addstate(List *l, State *s) 312 | { 313 | if(s == NULL || s->lastlist == listid) 314 | return; 315 | s->lastlist = listid; 316 | if(s->c == Split){ 317 | /* follow unlabeled arrows */ 318 | addstate(l, s->out); 319 | addstate(l, s->out1); 320 | return; 321 | } 322 | l->s[l->n++] = s; 323 | } 324 | ``` 325 | 326 | 函数`Startlist`会创建一个包含起始状态`s`的列表。 327 | 328 | 329 | ``` 330 | List* 331 | startlist(State *s, List *l) 332 | { 333 | listid++; 334 | l->n = 0; 335 | addstate(l, s); 336 | return l; 337 | } 338 | ``` 339 | 340 | 最后函数`step`会处理一个字符输入,把状态机NFA前进一步,计算`clist`的所有状态变更,带入下一个状态列表`nlist`。 341 | 342 | ``` 343 | void 344 | step(List *clist, int c, List *nlist) 345 | { 346 | int i; 347 | State *s; 348 | 349 | listid++; 350 | nlist->n = 0; 351 | for(i=0; in; i++){ 352 | s = clist->s[i]; 353 | if(s->c == c) 354 | addstate(nlist, s->out); 355 | } 356 | } 357 | ``` 358 | 359 | ## Performance 360 | 361 | 上面我们介绍的C语言的实现版本,并不是主要以性能优化为目的编写的。但即使是这样,他的复杂度依然是线性复杂度,而对于一个线性复杂度的算法写得再挫,只要输入参数足够大,也是很容易碾压一个精致实现的指数复杂度算法的版本。我们可以通过测试一些典型的比较难搞的正则表达式,来验证我们这里的结论。 362 | 363 | 考虑如下的正则表达式`a?^n.a^n`,`a?`不匹配任何字符的情况下,这个正则表达式会匹配到`a^n`,在匹配`a^n`的情况下,通过回溯的方式实现`?`zero-or-one的匹配算法,会开始尝试匹配one的情形,然后才会尝试匹配zero的情形,如果有n个类似这样的zero-or-one的选择,那么对于正则表达式来说就存在 2^n 这种潜在可能的路径,对于上面的情形,最快达成匹配结果的路径就是所有zero-or-one的选择,都选择到zero的情形。所以对于回溯算法来说,他的时间复杂度就是2^n的,如果n=25的情况,这个算法基本就是瘫痪的。 364 | 365 | 与Thomposon的算法对比,他需要维持一个与输入字符串长度n相近的状态列表,总的需要的时间复杂度最多是O(n^2)。(但匹配本身的时间复杂度是一个超线性复杂度,因为对于一个正则表达式来说,正则表达式本身不会随着输入的变化而产生变化,所以正则表达式的编译过程来说是一次性的。对于一个长度m的正则表达式,匹配长度为n的输入字符串,Thompson的NFA的匹配时间复杂度为O(mn))。 366 | 367 | 下面的图会列出用`a?^n.a^n`来匹配`a^n`的时间消耗对比: 368 | 369 | ![image](https://swtch.com/~rsc/regexp/grep1p.png) 370 | 371 | 我们会注意到上图中的y轴他是一个非等比的数轴,因为各种语言的正则算法那在时间消耗上差异巨大,为了在一张图里面能够呈现他们的时间消耗,我们采用了这种方式。 372 | 373 | 从图上我们能够清晰的认识到Perl, PCRE,Python和Ruby 都是采用的递归回溯的算法。而且PCRE在n=23的时候就已经不能正常执行匹配了,因为他的递归路径已经太长了。对于Perl来说,他宣称在5.6的版本中,优化了他的正则匹配引擎,最小化在匹配过程中的[内存消耗](http://perlmonks.org/index.pl?node_id=502408 "said to memoize"),他的匹配算法时间复杂度依然是指数的,除非它移除对`backreferences`的支持。其实Perl来说,在图中我们也观察到,即使当前匹配的正则表达式不包含`backreferences`,他的匹配算法的时间复杂度依然是指数的。虽然我们这里没有对Java语言做性能测试,但是可以肯定的告诉读者Java也是采用的递归回溯的方式实现的。而且从`java.util.regex`的对外接口可以看到,他支持一些匹配路径的替换,而且从接口就可以看到他的实现规约就是回溯的方式。对于PHP来说,他是依赖于PCRE库的。 374 | 375 | 上图中的粗体的蓝色线条标示的就是Thompson算法的C语言实现版本。其中Awk, Tcl, GNU grep 和 GNU awk 他们要么采用预编译的方式或者运行时动态编译的方式来编译DFAs,在接下来的小节里面会详细描述。 376 | 377 | 有些读者可能会觉得这个测试不太公平,因为这个地方只对这么一种zero-or-one的情形做测试。这个不公平争论的出发点其实是站不住脚的,给你两个选择,其中一个选择对所有的输入数据提供可预知,一致可靠,高效的运行时匹配,另外一个选择对很多输入可以快速完成,但对一些输入需要消耗甚至以年为单位的CPU时间,两种选择让你选择一种来使用,我相信这个不会是一个会有争论的选择。虽然上面我们用来举例子的那个正则表达式在实际的使用中很少出现,但另外一些类似的正则表达式确是经常出现的,比如 `(.*)(.*)(.*)(.*)(.*)`用来匹配用5个空格分割的字符串,比如用可选操作`|`来构建正则表达式。对于一些程序员来说,经常会构建一些特殊正则表达式的来检测的算法,然后优化相关算法在这些输入情况下的表现,我们通常也会叫这些程序员为[optimizers](http://search.cpan.org/~dankogai/Regexp-Optimizer-0.15/lib/Regexp/Optimizer.pm)。如果使用Thompson的NFA的状态机算法,根本就不需要采用这样的优化方法,因为它根本不存在所谓的特殊正则表达式。 378 | 379 | ## Caching the NFA to build a DFA 380 | 381 | 我们回顾前面关于状态机的部分,我们知道DFAs其实是比NFAs具备更加高效的执行效率的,因为DFAs在任何时候都只会处于一个状态,对于一个输入,它只有一个确定的选择。其实任何一个NFA都可以被转换为一个等级的DFA,只是在这个DFA里面,他的每一个状态都对应到一组NFA的状态。 382 | 383 | 比如拿我们前面用过的对应到`abab|abbb`的NFA的状态机(这里我们为每一个状态加上了一个状态号码): 384 | 385 | ![image](https://swtch.com/~rsc/regexp/fig20.png) 386 | 387 | 与他等级的DFA看起来应该是这样的: 388 | 389 | ![image](https://swtch.com/~rsc/regexp/fig21.png) 390 | 391 | 在DFA中的每一个状态都是对应到NFA中的状态列表。 392 | 393 | 如果你还记得前面Thompson的NFA状态机的运行过程,你应该会意思到,这个执行过程其实相当于执行转换后的DFA:前面的`clist`和`nlist`其实都对应到一组DFA的状态,其中step函数就是具体的执行NFA到DFA状态的转换计算。所以Thompson的NFA的状态机的运行过程其实就是执行NFA到DFA的转换过程,每一次运行就执行一次转换计算,所以我们可以缓存`step`函数的运行结果到一个稀疏数组,避免每次都需要去执行重复的转换计算。这一小节,我们会具体来呈现这个过程,怎么来缓存计算结果。这里在前面的基础上来实现,大约会增加100行的代码。 394 | 395 | 为了实现缓存,我们先介绍一个新的数据结构,他代表的是DFA的一个状态。 396 | 397 | 398 | ``` 399 | struct DState 400 | { 401 | List l; 402 | DState *next[256]; 403 | DState *left; 404 | DState *right; 405 | }; 406 | ``` 407 | 一个DState是列表`l`的缓存,其中`next`字段存储的是所有可能的字符输入的下一个DFA的状态:如果当前状态是`d`,下一个输入字符是`c`,那么下一个状态是`d->next[c]`,如果`d->next[c]`是null,代表下一个状态还没有被计算过,那么函数`NextState`就执行计算,并把结果记录下来。正则表达式的匹配过程就是根据输入一直执行`d->next[c]`的计算过程: 408 | 409 | ``` 410 | int 411 | match(DState *start, char *s) 412 | { 413 | int c; 414 | DState *d, *next; 415 | 416 | d = start; 417 | for(; *s; s++){ 418 | c = *s & 0xFF; 419 | if((next = d->next[c]) == NULL) 420 | next = nextstate(d, c); 421 | d = next; 422 | } 423 | return ismatch(&d->l); 424 | } 425 | ``` 426 | 当所有的`DState`都被计算好以后,我们需要把这些`DState`存到一个结构里面,因为一个`DState`其实是与他里面的`l`一一对应的,也就是一个NFA的状态列表唯一的对应到一个`DState`,所以我们可以通过`DState`的`List`成员`l`唯一的查找到`DState`,为了达到这个目的,我们把所有的`DState`存储到一个二叉树里面。函数`dstate`根据参数`l`返回相应的`DState`,如果还不存在,则创建一个`DState`,插入二叉树并返回。 427 | 428 | 429 | ``` 430 | DState* 431 | dstate(List *l) 432 | { 433 | int i; 434 | DState **dp, *d; 435 | static DState *alldstates; 436 | 437 | qsort(l->s, l->n, sizeof l->s[0], ptrcmp); 438 | 439 | /* look in tree for existing DState */ 440 | dp = &alldstates; 441 | while((d = *dp) != NULL){ 442 | i = listcmp(l, &d->l); 443 | if(i < 0) 444 | dp = &d->left; 445 | else if(i > 0) 446 | dp = &d->right; 447 | else 448 | return d; 449 | } 450 | 451 | /* allocate, initialize new DState */ 452 | d = malloc(sizeof *d + l->n*sizeof l->s[0]); 453 | memset(d, 0, sizeof *d); 454 | d->l.s = (State**)(d+1); 455 | memmove(d->l.s, l->s, l->n*sizeof l->s[0]); 456 | d->l.n = l->n; 457 | 458 | /* insert in tree */ 459 | *dp = d; 460 | return d; 461 | } 462 | 463 | ``` 464 | 465 | 函数`Nextstate`运行`NFA`的函数`step`,并返回相应的`DState`: 466 | 467 | ``` 468 | DState* 469 | nextstate(DState *d, int c) 470 | { 471 | step(&d->l, c, &l1); 472 | return d->next[c] = dstate(&l1); 473 | } 474 | 475 | ``` 476 | 477 | 最后DFA的起始的`DState`状态,对于到NFA的其实状态列表: 478 | 479 | 480 | ``` 481 | DState* 482 | startdstate(State *start) 483 | { 484 | return dstate(startlist(start, &l1)); 485 | } 486 | ``` 487 | (在NFA状态机中,l1是预生成的列表) 488 | 489 | `DState`对应到DFA状态机的一个状态,只是DFA状态机的状态是按需生成的,如果再匹配过程中没有遇到这个DFA的状态,那么这些状态就不会生成,也就不会进入缓存;另外一种可选的实现就是先提前生成好所有的DFA状态。这样做的话也会让运行时效率提高一点点,因为可以快速移除哪些可选的分支,这种是以牺牲启动时间和内存为代价的。 490 | 491 | 对于运行时生成DFA,并缓存DFA的状态,有些同学可能会对内存消耗产生担忧。因为`DState`其实只是函数`step`的运行结果的缓存,其实在`dstate`实现的实现里面,我们是可以根据内存情况,完全抛弃掉这个缓存的,如果要加上这个缓存的管理,只需要再增加50行左右的代码,这里有一份相应的[实现](https://swtch.com/~rsc/regexp/ "自己找")。其实Awk的实现里面就有缓存管理,默认他会缓存32个`DState`,这个也能从前面的性能图从窥探到,知道为什么在n=28的时候性能曲线出现了不一致连续的情况。 492 | 493 | 从正则表达式编译的NFAs其实具备非常好的缓存一致性:对大部分字符的匹配,在执行匹配的时候,状态机访问一个状态的然后总是朝着一样的转换箭头到下一个状态。这种特征让我妈可以和好的利用缓存,当第一次一个转换箭头流转的时候,箭头的下一个状态就需要被计算,但后面再次访问这个箭头的时候,我妈就只需要访问结果所在的内存地址就好。在实际实现基于DFA的匹配算法的时候,还可以利用一些其他的优化手段来加快匹配效率。后面还会有相关的文章来详细讨论基于DFA的正则表达式的实现。 494 | 495 | ## Real world regular expressions 496 | 497 | 在真实的使用场景中的增则表达式比我们前面介绍在某些方便要复杂一些。这小节会简单对实用意义上的正则表达式做完整描述,但一个全面的介绍显然已经超出了本文的篇幅。 498 | 499 | __字符类(Character classes)__ 无论是`[0-9]`还是`\w` 或者`.`, 都表达的一个连续的可选择序列。在编译的时候,字符类我们可以把他编译成可选择操作,显然为了性能考虑,我们会考虑增加一种新的NFA节点来更加有效的表达这种可选择操作。在[POSIX](http://www.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap09.html)里面定义了一些特殊的字符,比如`[[:uppe:]]`会更具上去的locale来确定具体的意义。 500 | 501 | __转义序列(Escape sequences)__ 正则表达式语法需要处理转义字符,这里包括处理类似`\(`,`\)`,`\\`等元字符,还包括哪些不能直接书写的而约定的特殊字符类似`\n`,`\t`,`\r`等等。 502 | 503 | __计数(Counted repetition)__ 许多正则表达式实现了一种操作符`{n}`,用来计数n个匹配给定正则规则的输入字符;还有操作符`{n,m}`用来至少匹配n个,但不操过m; 以及`{n,}`用来匹配大于等于n个。可以用递归回溯来实现这种计数。一个基于NFA 或者 DFAs的实现,需要把这种计数直接展开,比如`e{3}`展开为`eee`;`e{3,5}`会展开为`eeee?e?`; `e{3,}`会展开为`eee+`。 504 | 505 | __子提取(Sub match extraction)__ 当用一个正则表达式来分割字符或者解析字符串的时候,我们通常需要知道输入字符串的那些部分分别匹配到那些子表达式。比如用正则表达式`([0-9]+-[0-9]+-[0-9]+) ([0-9]+:[0-9]+)`来匹配字符串,搜索字符串里面出现的日期和时间,我们通常需要知道匹配结束后匹配到的具体日期和时间是什么。很多正则表达式引擎提供了一种能力,可以提前每一个括号表达式的匹配内容。比如在Perl里面你可以这样写: 506 | 507 | 508 | ``` 509 | if(/([0-9]+-[0-9]+-[0-9]+) ([0-9]+:[0-9]+)/){ 510 | print "date: $1, time: $2\n"; 511 | } 512 | ``` 513 | 提取子表达式的匹配内容,这种语义会被大部分的计算机理论科学家所忽视,这个也是很多正则表达式引擎的实现者说他为什么需要采用递归回溯的方式来实现的论据之一。然而,类Thompson的算法也是可以做适当调整在不牺牲性能的前提下达到提取匹配内容。早在1985年,发行的Unix的第八版里面 regexp(3) 就实现了这个提取子表达式匹配串,这个工具被广泛使用,但大家都没有关注它的实现。 514 | 515 | __非锚点匹配(Unanchored matches)__ 这篇文章在讨论正则表达式的时候是假定表达式与整个输入字符串是否匹配。在实际的使用中,我们遇到的场景通常是从输入字符串里面找到匹配的最长子字符串。对于Unix的匹配工具,通常也是返回从左右开始的最长匹配子串。其实一个队`e`的非锚匹配,是一种子串提取的特例:比如我们扩充表达式为`.*(e).*`,让开始的`.*`匹配尽可能短的字符串,这样就达到了前面的语义。 516 | 517 | __非贪婪操作符(Non-greedy operators)__ 在传统的Unix正则表达式里面,重复操作符 `?`,`*`和`+`被定义为在满足整体匹配规则的情况下尽可能的匹配更多的字符,比如正则表达式`(.+)(.+)`在匹配`abcd`的时候,第一个`(.+)`匹配`abc`,第二个`(.+)`匹配`d`,这种匹配形式我们叫做贪婪匹配。Perl提供了 `??`,`*?`和`+?`重复操作符为非贪婪的版本,这些操作符在符合整体匹配规则的情况下,尽可能少的匹配字符串,比如用`(.+?)(.+?)`匹配`abcd`,第一个`(.+?)`只会匹配`a`,第二个匹配`bcd`。通过前面的定义,我们知道操作符是否是贪婪的他不会影响到整体的匹配,只会影响到子匹配的边界。对于回溯算法来说,实现非贪婪的版本非常简单,先尝试最短匹配再尝试最长匹配。比如在标准的回溯实现里面`e?`首先尝试用`e`来匹配,然后尝试忽略掉`e`去执行匹配;而`e??`用相反的顺序。Thompson的算法也可以通过简单的调整支持到这种非贪婪的操作符。 518 | 519 | __断言(Assertions)__ 传统的正则表达式里面,元字符`^`和`$`可以用来断言操作符周边的字符:`^`断言前面的字符是一个换行(或者是字符串的开头),`$`短夜下一个字符是一个新行(或者是字符串的结束)。Perl增加了更多的断言,比如单词边界`\b`,他断言前面的是一个有效的可输入字符alphanumeric,下一个不是有效的可输入字符,或者说是前面的补集字符。Perl还产生了一种前置的条件断言:`(?=re)`断言当前输入位置后的字符匹配`re`,但不对实际的输入位置做递进,也就是这个匹配不占用实际输入;`?!re`类似前面的断言,只是断言接下来的字符不匹配`re`。还有后置条件断言:`?<=re`和`?,都讲是不完整的讨论,这本书也许是程序员里面关于正则表达式最流行的书。在Friedl的书里面,会告诉大家怎么来有效的使用现在的正则表达式实现,而不是叫大家怎么来高效的实现正则表达式。如果一定要说关于实现部分有什么内容的话,就是书里面清晰的表达了一个大家广泛都知道的理论,后向引用的唯一实现方式就是回溯。但Friedl 也清晰的表述了,他对背后的理论是[不感兴趣](http://regex.info/blog/2006-09-15/248)的也不理解的(感觉Russ Cox 在黑Friedl呀.哈哈)。 546 | 547 | ## Summary 548 | 549 | 正则表达式匹配可以很简单并且迅速的通过基于有限状态机的技术来实现。对比 Perl , PCRE, Python Ruby, Java 等许多基于递归回溯的方式来实现的编程语言来说,回溯的方式固然简单,但是效率在某些情况下会非常糟糕,除掉对于后向引用的支持,正则表达式的其他特性都可以通过基于状态机的技术来实现非常高效且稳定的匹配算法。 550 | 551 | 本系列的下一篇文章 会讨论基于NFA的子串提取。第三篇文章 会测试一个产品级别品质的正则表达式实现,第四篇文章 会解析在谷歌的代码搜索引擎里面是怎么实现的。 552 | 553 | ## Acknowledgements 554 | Lee Feigenbaum, James Grimmelmann, Alex Healy, William Josephson, 和 Arnold Robbins 读了这篇文章的草稿,并且提了很多有用的建议。Rob Pike 对于他实现的正则表达式相关的那段历史跟我讲了很多。谢谢大家。谢谢阅读。 555 | 556 | ## References 557 | [1] L. Peter Deutsch and Butler Lampson, “An online editor,” Communications of the ACM 10(12) (December 1967), pp. 793–799. http://doi.acm.org/10.1145/363848.363863 558 | 559 | [2] Ville Laurikari, “NFAs with Tagged Transitions, their Conversion to Deterministic Automata and Application to Regular Expressions,” in Proceedings of the Symposium on String Processing and Information Retrieval, September 2000. http://laurikari.net/ville/spire2000-tnfa.ps 560 | 561 | [3] M. Douglas McIlroy, “Enumerating the strings of regular languages,” Journal of Functional Programming 14 (2004), pp. 503–518. http://www.cs.dartmouth.edu/~doug/nfa.ps.gz (preprint) 562 | 563 | [4] R. McNaughton and H. Yamada, “Regular expressions and state graphs for automata,” IRE Transactions on Electronic Computers EC-9(1) (March 1960), pp. 39–47. 564 | 565 | [5] Paul Pierce, “CTSS source listings.” http://www.piercefuller.com/library/ctss.html (Thompson's QED is in the file com5 in the source listings archive and is marked as 0QED) 566 | 567 | [6] Rob Pike, “The text editor sam,” Software—Practice & Experience 17(11) (November 1987), pp. 813–845. http://plan9.bell-labs.com/sys/doc/sam/sam.html 568 | 569 | [7] Michael Rabin and Dana Scott, “Finite automata and their decision problems,” IBM Journal of Research and Development 3 (1959), pp. 114–125. http://www.research.ibm.com/journal/rd/032/ibmrd0302C.pdf 570 | 571 | [8] Dennis Ritchie, “An incomplete history of the QED text editor.” http://plan9.bell-labs.com/~dmr/qed.html 572 | 573 | [9] Ken Thompson, “Regular expression search algorithm,” Communications of the ACM 11(6) (June 1968), pp. 419–422. http://doi.acm.org/10.1145/363347.363387 (PDF) 574 | 575 | [10] Tom Van Vleck, “The IBM 7094 and CTSS.” http://www.multicians.org/thvv/7094.html 576 | 577 | Discussion on [reddit](http://programming.reddit.com/info/10c60/comments) and [perlmonks](http://perlmonks.org/?node_id=597262) and [LtU](http://lambda-the-ultimate.org/node/2064) 578 | 579 | -------------------------------------------------------------------------------- /Regex/Part02.Regular_Expression_Matching_the_Virtual_Machine_Approach.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 | # Regular Expression Matching: the Virtual Machine Approach 6 | 7 | 8 | 9 | ## Introduction 10 | 11 | 说出被使用得最多的字节解释器或者说是虚拟机。Sum的JVM?Adobe的Flash? .Net 还是 Mono? Perl? Python? 或者说是 PHP?. 这些确定无疑是非常流行的虚拟机,但有一个使用得比刚才提到的这些加起来还要广泛的字节解释器,就是Henry Spencer 的正则表达式库以及在它的基础上发展起来的后继者。 12 | 13 | 在本系列的[第一篇文章](https://swtch.com/~rsc/regexp/regexp1.html)中描述了实现正则匹配的两种主要的策略:基于NFA-或者DFA-based,具备最差情况下的线性时间复杂度,被主要用在 awk 和 egrep(现在的大部分greps工具);还有另外一种基于递归回溯的,最差情况下是指数时间复杂度,被广泛使用与各大常见的正则引擎比,包括ed, sed, Perl, PCRE 和 Python等。 14 | 15 | 本文会展现这两种策略下怎么实现一个类似.Net和Mono那样的虚拟机,把正则表达式编译为文本匹配的字节码,来执行正则规则匹配。当然这里的虚拟机绝壁不是.Net和Mono一样的虚拟机,.Net这种虚拟机是执行那些被翻译为[CLI](https://en.wikipedia.org/wiki/Common_Language_Infrastructure)字节码的程序。 16 | 17 | 把正则表达式的匹配过程认知为在一个特殊的机器上执行机器指令,这样就很容易通过增加和扩展新的机器指令来为正则表达式增加新的特性。比如,我们可以通过给正则表达式的机器增加子表达式提前指令,这样比如在执行正则表达式`(a+)(b+)`匹配`aabbbb`的时候,使用方就可以知道括号括起来的子表达式`(a+)`("这个子表达式通常会被记为\1或者$1,匹配到了aa),然后(b+)匹配到了bbbb。子匹配或者叫子提取,可以在回溯的虚拟机里面来实现,也可以哪些非回溯的虚拟机里面实现(这总做法可以追溯到1985年,但我详细这篇文章是第一篇书面对这个过程做详细解释的文章)。 18 | 19 | ## A Regular Expression Virtual Machine 20 | 21 | 开始的时候,我们先定义正则表达式的虚拟机(参考到 [Java VM](https://en.wikipedia.org/wiki/Java_Virtual_Machine))。VM会执行一个或者多个线程,每个线程都执行的是一个正则匹配程序,这些匹配程序基本只是一组简单的正则指令。每一个执行的线程都持有两个寄存器:一个指令寄存器(PC)和一个字符指针(SP)。 22 | 23 | 主要的正则指令如下: 24 | 25 | | 指令 | 说明 | 26 | | :--------- | ---------------------------------------- | 27 | | char c | 如果当前的SP指针指向的位置不是c,则当前线程停止运行,标示当前线程的正则匹配失败。否则递进SP到下一个字符,并且递进PC寄存器到下一个指令 | 28 | | match | 停止当前线程的执行,标示当前匹配程序为匹配状态 | 29 | | jmp x | 跳转到位于x的指令(设置当前的PC到x) | 30 | | split x, y | 分割执行:创建一个新线程拷贝当前线程的SP,当前线程继续在 PC=x的地方开始执行指令;另外一个线程在PC=y的地方开始执行(类似同时跳转到两个地方开始并行执行匹配过程) | 31 | 32 | VM 开始的时候启动一个线程,设置PC到程序第一个指令的位置,同时设置SP指向输入字符串的起始地址。线程开始执行的时候,就会开始执行当前线程的PC寄存器指向的指令;执行完当前指令,就移动PC到下一个指令执行。重复整个过程,直到停止状态(遇到了一个char指令导致匹配失败,或者遇到一个match指令匹配成功)。如果有任何一个线程达到匹配状态,我们就认为正则表达式与当前输入字符串匹配。 33 | 34 | 根据正则表达式的具体形式,循环递归的把正则表达式编译为相应的字节码。回忆前面的第一篇文章,我们知道正则表达式具备四种基本形式:单独的字符,类似字符`a`,连接操作e1e2,可选操作e1|e2,或者是重复操作 e?(零个或者一个),e*(零个或者多个),e+(一个或者多个)。 35 | 36 | 一个单独的字符被编译位一个`char a`的指令。一个连接操作会被编译位两个子表达式。一个可选操作会使用`split`指令允许两个选择都会被处理。e?会被编译位可选操作,只是其中一个操作处理空字符串。e*或者e+其实是一个循环匹配,这两个也会被编译为可选操作,其中一个选择是匹配e,另外一个是跳出这个循环匹配过程。 37 | 38 | 准确的编译代码如表格所示: 39 | 40 | 41 | 42 | | 正则表达式 | 指令 | 43 | | :----: | :--------------------------------------- | 44 | | a | char a | 45 | | e1e2 | codes for e1
codes for e2 | 46 | | e1\|e2 | split L1, L2
L1: codes for e1
jmp L3
L2: codes for e2
L3: | 47 | | e? | split L1, L2
L1: codes for e
L2: | 48 | | e* | L1: split L2, L3
L2: codes for e
jmp L1
L3: | 49 | | e+ | L1: codes for e
split L1, L3
L3: | 50 | 51 | 在正则表达式的全部编译完成后,就在最后补充一个match指令作为结束。 52 | 53 | 比如正则表达式`a+b+`会被编译位如下: 54 | 55 | | 指令行号 | 具体指令 | 56 | | ---- | ---------- | 57 | | 0 | char a | 58 | | 1 | split 0, 2 | 59 | | 2 | char b | 60 | | 3 | split 2, 4 | 61 | | 4 | match | 62 | 63 | 前面编译好的正则表达式在匹配字符串`aab`的时候,VM的整个运行过程如下: 64 | 65 | | 线程 | PC | SP | Execution | 66 | | ---- | ------------ | ------- | -------------------------- | 67 | | T1 | 0 char a | **a**ab | 字符匹配 | 68 | | T1 | 1 split 0, 2 | a**a**b | 创建线程T2,设置PC=2 SP=a**a**b | 69 | | T1 | 0 char a | a**a**b | 字符匹配 | 70 | | T1 | 1 split 0, 2 | a**a**b | 创建线程T3, 设置PC=2 SP=aa**b** | 71 | | T1 | 0 char a | aa**b** | 不匹配:线程T1终止 | 72 | | T2 | 2 char b | a**a**b | 不匹配:线程T2终止 | 73 | | T3 | 2 char b | aa**b** | 字符匹配 | 74 | | T3 | 3 split 2, 4 | abb_ | 创建线程T4, 设置PC=4 SP=abb_ | 75 | | T3 | 2 char b | abb_ | 不匹配(当前是字符结束):线程T3停止 | 76 | | T4 | 4 match | abb_ | 达到最终匹配状态 | 77 | 78 | 在前面我们的表格展示的例子里面,先创建的线程会等当前线程结束才会开始执行,新创建的线程的执行顺序也就是他们的创建顺序(first-in-first-out, 老的线程先运行)。这个病不是VM的本身的要求,这个看线程调度的具体实现方法,其他的一些实现方式,可能就是让线程交叉着运行。 79 | 80 | ## VM Interface in C 81 | 82 | 本文的接下来的的部分会采用C代码来阐述VM的具体实现。正则表达式编译以后是一个Inst结构体的数组,结构体的C定义如下: 83 | 84 | ```c 85 | enum { /* Inst.opcode */ 86 | Char, 87 | Match, 88 | Jmp, 89 | Split 90 | }; 91 | 92 | struct Inst { 93 | int opcode; 94 | int c; 95 | Inst *x; 96 | Inst *y; 97 | }; 98 | ``` 99 | 100 | 这个字节码的定义与前面第一篇文章中的NFA图是一致的。我们可以把这个bytecode看做是NFA图里面节点的编码,NFA的节点也就对应到虚拟机的机器指令;当然我们可以把NFA的图看做字节码的执行流程。不同的视角,会让你在某些方便更加容易理解整个机制的某一部分,这篇文章我们会聚焦在机器指令的视角来解读整个过程。 101 | 102 | VM的实现可以看做是一个函数,接受编译好的执行指令的数组,和一个输入字符串作为参数,返回一个整数来表达是否匹配(零代表没有匹配,非零代表匹配成功)。 103 | 104 | ```c 105 | int implementation(Inst *prog, char *input); 106 | ``` 107 | 108 | ## A Recursive Backtracking Implementation 109 | 110 | 一个VM的非常简单的实现,就是不直接引入线程来表达执行过程,而是在需要启动一个线程执行的时候,通过递归调用自己,其中传递的`prog`参数和`input`参数分别可以看做`PC`和`SP`寄存器的初始值。 111 | 112 | ```c 113 | int 114 | recursive(Inst *pc, char *sp) 115 | { 116 | switch(pc->opcode){ 117 | case Char: 118 | if(*sp != pc->c) 119 | return 0; 120 | return recursive(pc+1, sp+1); 121 | case Match: 122 | return 1; 123 | case Jmp: 124 | return recursive(pc->x, sp); 125 | case Split: 126 | if(recursive(pc->x, sp)) 127 | return 1; 128 | return recursive(pc->y, sp); 129 | } 130 | assert(0); 131 | return -1; /* not reached */ 132 | } 133 | ``` 134 | 135 | 上面的递归的版本对于很多程序员来说非常熟悉,尤其是那些熟悉Lisp, ML 和 Erlang等具备重度递归特质的语言的程序员。大部分 C语言的编译器都会重写和优化上面的`return recursive(...);`这些叫做尾调用的语句,优化成goto语句,跳转到函数的顶部,所以上面的函数就会被编译优化为类似 下面的函数: 136 | 137 | ```c 138 | int 139 | recursiveloop(Inst *pc, char *sp) 140 | { 141 | for(;;){ 142 | switch(pc->opcode){ 143 | case Char: 144 | if(*sp != pc->c) 145 | return 0; 146 | pc++; 147 | sp++; 148 | continue; 149 | case Match: 150 | return 1; 151 | case Jmp: 152 | pc = pc->x; 153 | continue; 154 | case Split: 155 | if(recursiveloop(pc->x, sp)) 156 | return 1; 157 | pc = pc->y; 158 | continue; 159 | } 160 | assert(0); 161 | return -1; /* not reached */ 162 | } 163 | } 164 | ``` 165 | 166 | 上面的循环的表述非常清晰。 167 | 168 | 但也注意到,上面的版本依然是有一个分支是递归调用(不是尾调用),在 `case Split`的时候,先尝试 `pc->x`,然后再尝试`pc->y`。 169 | 170 | 上面的实现是Henry Spencer的原版递归回溯实现版本的核心部分,也是Java, Perl, PCRE, Python 等编程语言,以及初始版的工具 ed, sed, 以及grep 所采用的方式。这个版本在没有太多递归的情况下运行是非常快速的,但只要出现一些可选操作,让递归路径以指数的方式增长几次,性能就会非常糟糕(和前面[文章](https://swtch.com/~rsc/regexp/regexp1.html)中看到的一样)。 171 | 172 | 上面的实现里面和真实的产品级别实现对比的话还是相对简单,上面的递归实现有一个致命的缺点:类似`(a*)*`的正则表达式会导致编译程序死循环,上面实现的编译器并没有检测这样的循环。当然这个是非常容易修正的一个问题(文章末尾我们会看到相关细节),因为回溯不是我们的重点要讨论的,所以我们在这里就直接忽略他不做过多的扩展了。 173 | 174 | ## A Non-recursive Backtracking Implementation 175 | 176 | 在前面递归版本的回溯实现里面,是通过启动一个线程执行直到线程结束,然后以线程创建的顺序挑选待运行的线程来执行。线程等待执行的这个没有很清晰的在代码里面表述出来:任何需要递归的时候,通过隐含的方式把`pc`和`sp`的值保持在C的调用栈上面,然后依靠运行栈的的递进和回退来达成线程的选择。如果有太多的线程在等待执行,就可能导致C的调用栈出现溢出的情况,这种错误比性能问题更难调试和诊断。出现栈溢出的情况,通常是出现了很多类似`.*`这样的重复操作符,像这种操作符会为每一个可能的输入创建一个新的线程(和前面的`a+`做的一样)。对于多线程程序来说,通常每个线程的运行栈都不会太大,而且没有特殊的硬件来检测栈溢出,所以这会是一个还蛮需要注意的问题。 177 | 178 | 我们可以通过显式的维护一个C的线程栈来避免C的运行时栈出现溢出的情况。我们定义一个结构体来表示一个线程,并定义一个构造函数来构建线程对象: 179 | 180 | ```c 181 | struct Thread { 182 | Inst *pc; 183 | char *sp; 184 | }; 185 | 186 | Thread thread(Inst *pc, char *sp); 187 | ``` 188 | 189 | 有这个待运行的线程列表以后,VM就是从待运行列表里面获取一个线程,然后运行,直到待运行线程列表为空,或者其中一个运行的线程已经达到了匹配状态,这个是就可以停止VM的执行了。如果所有线程都结束了,但没有到达匹配状态,就说明不匹配。在显式维护线程列表的时候,我们可以简单的设定一个等待线程个数的上限,如果达到上限就报告相关的错误。 190 | 191 | ```c 192 | int 193 | backtrackingvm(Inst *prog, char *input) 194 | { 195 | enum { MAXTHREAD = 1000 }; 196 | Thread ready[MAXTHREAD]; 197 | int nready; 198 | Inst *pc; 199 | char *sp; 200 | 201 | /* queue initial thread */ 202 | ready[0] = thread(prog, input); 203 | nready = 1; 204 | 205 | /* run threads in stack order */ 206 | while(nready > 0){ 207 | --nready; /* pop state for next thread to run */ 208 | pc = ready[nready].pc; 209 | sp = ready[nready].sp; 210 | for(;;){ 211 | switch(pc->opcode){ 212 | case Char: 213 | if(*sp != pc->c) 214 | goto Dead; 215 | pc++; 216 | sp++; 217 | continue; 218 | case Match: 219 | return 1; 220 | case Jmp: 221 | pc = pc->x; 222 | continue; 223 | case Split: 224 | if(nready >= MAXTHREAD){ 225 | fprintf(stderr, "regexp overflow"); 226 | return -1; 227 | } 228 | /* queue new thread */ 229 | ready[nready++] = thread(pc->y, sp); 230 | pc = pc->x; /* continue current thread */ 231 | continue; 232 | } 233 | } 234 | Dead:; 235 | } 236 | return 0; 237 | } 238 | ``` 239 | 240 | 上面的实现和`recursive`以及`recursiveloop`的版本是一致的;只是这个版本不再使用C的运行时栈来存储回溯过程。比较两个版本的`Split`分支: 241 | 242 | ```c 243 | /* recursiveloop */ 244 | case Split: 245 | if(recursiveloop(pc->x, sp)) 246 | return 1; 247 | pc = pc->y; 248 | continue; 249 | ``` 250 | 251 | ```c 252 | /* backtrackingvm */ 253 | case Split: 254 | if(nready >= MAXTHREAD){ 255 | fprintf(stderr, "regexp overflow"); 256 | return -1; 257 | } 258 | /* queue new thread */ 259 | ready[nready++] = thread(pc->y, sp); 260 | pc = pc->x; /* continue current thread */ 261 | continue; 262 | ``` 263 | 264 | 依然是回溯的过程,只是`backtrackingvm`会显式的把这个递归过程写出来的,显式的自己维护待运行线程的列表,这个显式的维护让自己可以很容易的加上栈溢出检测。 265 | 266 | ## Thompson's Implementation 267 | 268 | 把正则表达式的匹配过程看做是运行在VM里面的线程,我们可以在这里给大家呈现Ken Thompson算法的具体实做,这种实做会比第一篇文章中的更加贴近Thompson的PDP-11 机器码。 269 | 270 | Thompson 注意到回溯有的时候需要重复扫描输入字符串的某些输入多次,为了避免这种情况,他构建了一个虚拟机,在虚拟机里面会以锁同步的方式同时运行所有线程:所有线程都会开始处理输入字符串的第一个字符,然后所有线程处理第二个字符,以此类推。在原来的说明里面,我们看到其实新创建的线程是不需要回溯去处理父亲线程已经处理过的输入字符,所以Thompson的这种方式是可行的,新创建的线程是可以与现在的线程以锁同步的方式处理后续的输入字符。 271 | 272 | 因为所有线程是以锁同步的方式运行,也就是他们其实是共享的`SP`,也就意味着不需要把`SP`作为线程的状态保存了: 273 | 274 | ```c 275 | struct Thread 276 | { 277 | Inst *pc; 278 | }; 279 | Thread thread(Inst *pc); 280 | ``` 281 | 282 | 那么,Thompson的VM 实现就是如下的: 283 | 284 | ```c 285 | int 286 | thompsonvm(Inst *prog, char *input) 287 | { 288 | int len; 289 | ThreadList *clist, *nlist; 290 | Inst *pc; 291 | char *sp; 292 | 293 | len = proglen(prog); /* # of instructions */ 294 | clist = threadlist(len); 295 | nlist = threadlist(len); 296 | 297 | addthread(clist, thread(prog)); 298 | for(sp=input; *sp; sp++){ 299 | for(i=0; iopcode){ 302 | case Char: 303 | if(*sp != pc->c) 304 | break; 305 | addthread(nlist, thread(pc+1)); 306 | break; 307 | case Match: 308 | return 1; 309 | case Jmp: 310 | addthread(clist, thread(pc->x)); 311 | break; 312 | case Split: 313 | addthread(clist, thread(pc->x)); 314 | addthread(clist, thread(pc->y)); 315 | break; 316 | } 317 | } 318 | swap(clist, nlist); 319 | clear(nlist); 320 | } 321 | } 322 | ``` 323 | 324 | 假定一个正则表达式被编译后总共是n条执行指令,因为我们的线程状态只有指令计数器`PC`,也就是在`clist`和`nlist`里面最多出现n个不同的线程,如果`addthread`不会重复添加线程(具备同一个`PC`的认为是一样的线程),那么ThreadLists 只最多需要准备n个线程的空间,这样我们也就消除了出现溢出的可能性。 325 | 326 | 因为已经知道在线程列表里面最多有n个线程,这样也就对每一个输入字符的处理时间我们是可以估算出上确界的。假定addthread的时间复杂度是O(1),那么处理一个输入字符的最大消耗时间就就是O(n)了,那么整个字符串的处理时间就是O(nm)。这个是比前面的回溯算法不可同日而语的,同时这种算法还消除了前面提到的死循环的情形。 327 | 328 | 严格来说,这样看就没有任何理由为什么回溯的算法实现上不采用这样的技巧来优化立即的线程数,确保线程不被重复添加(因为在回溯里面一个线程的状态其实有`PC`和`SP`组成,也就是具备同样`PC`和`SP`的线程也是可以认为是一个线程),如果启动这样的优化,那么我们需要追踪 n*m 个可能的线程状态,每一个`pc`和`sp`对都会是一个线程的Key,而实际情况下对于m来说通常都可能是一个多变而且不可控的变量。 329 | 330 | 用一个20字节的正则表达式在一个兆字节的字符串上进行匹配,在实际情况中是很普通的情况。在这种情况下,n最大不会超过40,但是 n*m 会是 4000万。时至今日,兆字节的文本已经算是小的了。Thompson的方法,他一个非常大的优势就是任何一个时间点最多可能有n个线程,而且这个n个线程是以锁同步的方式运行,线程相当于是在任何时间点上是一个完备的并发,而且这个方法隔离了对输入字符串长度的依赖,他的运行时间不会因为输入字符串出现较大的差异。 331 | 332 | ## Tracking Submatches 333 | 334 | 把正则表达式翻译为字节码的方式来处理匹配过程,这样我们也很容易来给正则表达式增加新的特性,比如这里的子表达式提取,只需要定义一个新的字节码,然后实现这个字节码。 335 | 336 | 为了能够处理子表达式的提前,我们在线程状态里面加一个字符指针数组。新定义的字节码`save i`会存储当前的输入字符串指针到当前线程状态的指针数组的第i个槽。为了编译正则表达式`(e)`,这个正则表达式表达了子表达式提前,所以需要存储`e`具体的匹配边界,我们会放两个存储指令到`e`编译后的指令周围,对于第k个子表达式(Perl里面的$k),我们会使用槽2k来存储匹配的起始位置,2k+1来存储匹配的结束位置。 337 | 338 | 比如对比编译`a+b+`和`(a+)(b+)`: 339 | 340 | | a+b+ | (a+)(b+) | 341 | | ------------- | ------------ | 342 | | 0 char a | 0 save 2 | 343 | | 1 split 0, 2 | 1 char a | 344 | | | 2 split 1, 3 | 345 | | | 3 save 3 | 346 | | | 4 save 4 | 347 | | 2 char b | 5 char b | 348 | | 3 split 2, 4 | 6 split 5, 7 | 349 | | | 7 save 5 | 350 | | 4 match | 8 match | 351 | 352 | 如果我们需要找到整个匹配的边界,我们可以在把整个字节码用指令 `save 0` 和 `save 1`包起来。 353 | 354 | 在`recursiveloop`的算法里面实现save 指令是非常直观的:`saved[pc->i]=sp`,只是这个赋值操作在匹配失败的时候要可撤销。下面的代码就说明了处理线程匹配失败的情况: 355 | 356 | ```c 357 | int 358 | recursiveloop(Inst *pc, char *sp, char **saved) 359 | { 360 | char *old; 361 | 362 | for(;;){ 363 | switch(pc->opcode){ 364 | case Char: 365 | if(*sp != pc->c) 366 | return 0; 367 | pc++; 368 | sp++; 369 | break; 370 | case Match: 371 | return 1; 372 | case Jmp: 373 | pc = pc->x; 374 | break; 375 | case Split: 376 | if(recursiveloop(pc->x, sp, saved)) 377 | return 1; 378 | pc = pc->y; 379 | break; 380 | case Save: 381 | old = saved[pc->i]; 382 | saved[pc->i] = sp; 383 | if(recursiveloop(pc+1, sp, saved)) 384 | return 1; 385 | /* restore old if failed */ 386 | saved[pc->i] = old; 387 | return 0; 388 | } 389 | } 390 | } 391 | ``` 392 | 393 | 我们注意到在save指令的分支和split分支一样,也存在一个躲不掉的递归调用。save指令所在的递归其实比split指令所在的递归更难进行拆解;把save指令拟合到backtrackingvm里面其实还需要更多的努力。虽然递归会导致潜在的栈溢出问题,但大部分的实现者还是更加倾向于用递归来实现,不太愿意花更多的心思来拆解递归过程。 394 | 395 | ## Pike's Implementation 396 | 397 | 在类似上面thmpsonvm的“线程制”的实现里面,我们简单的给线程状态结构体增加一个`saved`指针数组。Rob Pike 在他的文本编辑器sam里面最先开始用这种方法。 398 | 399 | ```c 400 | struct Thread 401 | { 402 | Inst *pc; 403 | char *saved[20]; /* $0 through $9 */ 404 | }; 405 | Thread thread(Inst *pc, char **saved); 406 | int 407 | pikevm(Inst *prog, char *input, char **saved) 408 | { 409 | int len; 410 | ThreadList *clist, *nlist; 411 | Inst *pc; 412 | char *sp; 413 | Thread t; 414 | 415 | len = proglen(prog); /* # of instructions */ 416 | clist = threadlist(len); 417 | nlist = threadlist(len); 418 | 419 | addthread(clist, thread(prog, saved)); 420 | for(sp=input; *sp; sp++){ 421 | for(i=0; i>clist.n; i++){ 422 | t = clist.t[i]; 423 | switch(pc->opcode){ 424 | case Char: 425 | if(*sp != pc->c) 426 | break; 427 | addthread(nlist, thread(t.pc+1, t.saved)); 428 | break; 429 | case Match: 430 | memmove(saved, t.saved, sizeof t.saved); 431 | return 1; 432 | case Jmp: 433 | addthread(clist, thread(t.pc->x, t.saved)); 434 | break; 435 | case Split: 436 | addthread(clist, thread(t.pc->x, t.saved)); 437 | addthread(clist, thread(t.pc->y, t.saved)); 438 | break; 439 | case Save: 440 | t.saved[t->pc.i] = sp; 441 | addthread(clist, thread(t.pc->x, t.saved)); 442 | break; 443 | } 444 | } 445 | swap(clist, nlist); 446 | clear(nlist); 447 | } 448 | } 449 | ``` 450 | 451 | pikevm里面的Save指令的分支比recursizveloop的要来得简单,因为每一个线程都有一份他自己的saved:也意味着不需要恢复saved里面的值。 452 | 453 | 在Thompson的VM里面,addthread里面的线程列表大小被限制到了正则表达式编译程序的指令长度n,每一个线程都有唯一的pc指针对应。在Pike的VM里面,线程状态结构体要多一个saved指针,但addthread依然具备同样的约束关系,一个线程唯一的通过PC寄存器来区分,因为saved指针其实并不影响线程后续的执行,Saved指针只是记录线程的父亲线程过去的执行情况,对于具备同样PC寄存器的线程来说,即使他们的saved 指针不一样,他们后续的执行是完全一样的,因此每一个PC地址只需要保留线程在线程列表就可以了。 454 | 455 | ## Ambiguous Submatching 456 | 457 | 一个正则表达式,对于一个输入字符串,有的时候会有多条路径可以达成匹配。比如,用正则表达式`<.*>` 来匹配搜索 ``这个字符串,这个时候正则表达式匹配的是``还是整个``呢?在这个情况下,也就是对于子表达式提前来说,他是不确定的了,子表达式`.*`可以匹配 `html`,也可以匹配`html> L1: codes for e
L2: | e?? split L2,L1
L1: codes for e
L2: | 468 | | e* L1: split L2, L3
L2: codes for e
jmp L1
L3: | e*? L1: split L3, L2
L2: codes for e
jmp L1
L3 | 469 | | e+ L1: codes for e
split L1, L3
L3: | e+? L1: codes for e
split L3, L1
L3: | 470 | 471 | 在前面的回溯版本的实现其实已经默认支持了优先级,这里我们再review一遍前面的实现,看他具体是怎么支持的。 对于`recursive`和`recursiveloop`的实现来说,他只需要简把pc->x放到pc->y前面就可以: 472 | 473 | ```c 474 | /* recursive */ 475 | case Split: 476 | if(recursive(pc->x, sp)) 477 | return 1; 478 | return recursive(pc->y, sp); 479 | 480 | /* recursiveloop */ 481 | case Split: 482 | if(recursiveloop(pc->x, sp)) 483 | return 1; 484 | pc = pc->y; 485 | continue; 486 | ``` 487 | 488 | 而对于`backtrackingvm`的实现,他会创建一个低优先级的线程来执行pc->y,然后把当前线程pc设置为pc->x,然后继续执行: 489 | 490 | ```c 491 | /* backtrackingvm */ 492 | case Split: 493 | if(nready >= MAXTHREAD){ 494 | fprintf(stderr, "regexp overflow"); 495 | return -1; 496 | } 497 | /* queue new thread */ 498 | ready[nready++] = thread(pc->y, sp); 499 | pc = pc->x; /* continue current thread */ 500 | continue; 501 | ``` 502 | 503 | 因为线程是通过一个先进后出的栈来管理的,指令pc->y所在的线程会需要等到pc->x所在的线程以及相应的具备比pc->y线程高优先级的子线程全部执行完成才会开始执行。 504 | 505 | 上面的pikevm的实现里面还并没有完全遵循线程优先级,但可以通过小量修改来修正对线程优先级的支持,`addthread`在处理Jmp, Split, Save指令的时候,通过递归调用`addthread`(这样调整可以看到addthread和第一篇文章中的addstate就是一一对应的了)来代替执行具体指令。这样的调整可以确保在clist和nlist里面线程是按照线程优先级的顺序从高到底排列的。在pikevm的处理循环就会按照线程优先级的顺序在调度线程的执行,贪婪版本的addthread确保nlist会一个级别一个级别的处理线程的加入。 506 | 507 | pikevm 的这个调整都是基于递归的调用顺序必须和线程优先级匹配。在新代码里面,处理一个字符的输入的时候是一个循环过程,nlist是按照优先级顺序添加的,当然整个过程依然是以锁同步的方式递进所有线程的执行过程,具备良好的运行效率。因为nlist的生成过程满足优先级顺序,所以在添加新线程的过程中,"如果具备同样PC的线程已经出现,那么就忽略这个线程"这种启发式的添加方式是安全的:已经出现过的线程是具备更高优先级的,也是已经被存储状态的。 508 | 509 | 还有一个在pikevm必要的调整:如果发现了一个匹配,后面位于clist里面的线程就可以直接中断执行了,因为clist本身是按照优先级排序的,更高优先级的线程应该给更高的权限去运行,允许他匹配尽可能长的输入字符串。调整后的pikevm的主循环看起来是这样的: 510 | 511 | ```c 512 | for(i=0; iopcode){ 515 | case Char: 516 | if(*sp != pc->c) 517 | break; 518 | addthread(nlist, thread(pc+1), sp+1); 519 | break; 520 | case Match: 521 | saved = t.saved; // save end pointer 522 | matched = 1; 523 | clist.n = 0; 524 | break; 525 | } 526 | } 527 | ``` 528 | 529 | 对于thompsonvm也可以做同样的跳转,但因为thompsonvm不需要记录子表达式的匹配位置,所以唯一需要做的调整就是在出现匹配的时候对选择不一样结束的指针。这样thompsonvm的结束位置就会和回溯版本的实现完全一致。这个本系列的下一篇文章会看到非常有用。 530 | 531 | 线程结合的约束规则可以不一样,比如还有一种实现,直接通过比较子匹配集合来约束线程集的大小。在Unix的第八版里面就用的是左边最长匹配规则,用在DFA-based的工具上,比如awk和egrep。 532 | 533 | 534 | 535 | ## Real world regular expressions 536 | 537 | 在实际的生产环节里面使用的正则表达式在某些方面是比我们文章中描述的版本要更加复杂一些的。这个小节简要的描述怎么来实现一些通用的基础设施。 538 | 539 | **Character classes** 字符类是一个非常典型的特殊VM指令,在生产实现里面,我们不会把这个扩展为一些列的可选项。下面的示例代码里面我们会为元字符(metacharacter)逗号实现一个叫做`any byte`的特殊指令。 540 | 541 | **Reptition** 重复操作后面通常会接一个不在重复字符集里面的其他字符,列如:`/[0-9]+:[0-9]+/`, 第一个`[0-9]+`后面会接一个`:`,第二个`[0-9]+`后面接的不能是数字,这样我们就可以推出一个一字节的前向探测操作来避免在重复操作里面创建过多而且不必要的线程。这个技术在回溯版本的实现里面很常见,因为回溯版本会每一个字符都要创建一个新的线程,如果通过地柜的方式来实现线程,这个优化技术在避免一些简单表达式而导致栈溢出的问题上就显得很有必要了。 542 | 543 | **Backtracking loops** 在文章开始的时候,最开始版本的回溯算法的实现是可能导致,在处理一些类似`(a*)*`的表达式的时候,因为要处理空的匹配,会出现死循环。一个简单的方式来避免在回溯算法里面出现死循环的方法就是推出一个进度指令,进度指令要求VM在执行某些指令的时候必须和上一次执行的时候相比有新的步进。 544 | 545 | **Alternate program structures for backtracking** 另外一个避免循环的方法就是为修改重复操作对应的指令集,用introduce-intructions代替。这种指令具备调度能力,能够把给定的一个片段的指令当做一个subroutine来运行,这样就避免了无线循环的问题,这种特性的指令集也更加高效的实现列如重复计数器以及断言等特性。当然,这些指令也会让实现一个非递归的版本变得更难,而且这些指令也直接让自己被排除在了Pike的那种基于自动机技术的VM之外。即便如此,这个也依然很容易让实现的版本失去控制,不行可以去看一下 Perl 和 PCRE 或者其他任意一个宣布实现了“全功能”正则表达式的哪些实作版本。 546 | 547 | **Backreferences** 后向引用在回溯的算法实现里面是非常简单的。在Pike实现的VM里面也是可以调整来实现后向引用的,只是会导致不能像以前一样,通过比较线程的PC值就可以知道两个线程相同了:两个拥有同样PC值的线程可能有不能的捕获集合,而且当前的捕获集会对将来的执行产生影响,这样就只能同时都把两个线程都添加到线程待运行集合里面取,这也带来了潜在的指数增长点。GNU的 grep,结合两个方法:他会通过把后向引用,用具体的表达式来代替,这样会产生一个近似的不带后向引用的正则表达式版本,比如`(cat|dog)\1`会被翻译成`(cat|dog)(cat|dog)`,然后这个生成的正则表达式就可以用DFA的技术了,当匹配发生的时候再回过头来检查后向引用是否一致。 548 | 549 | **Unanchored matches** 为了实现非锚点匹配,很多实现都是先从第0个开始匹配,如果失败则从第1个开始,以此类推。这种方法实现的非锚点匹配的时间复杂度就是以输入字符长度的O(n^2)了。在基于VM的实现里面,一个更有效的实现非锚点搜索的方法就是在正则表达式前面放一个`.*?`,这样就让VM自己去做非锚点匹配了,时间复杂度就会是线性O(n)。 550 | 551 | **Character encodings** 因为在 Thompson和Pike的VM实现里面,每次都是处理一个字符,单次pass,不会出现重复扫描输入字符,而且对字符集大小没有任何约束需求,这样就很容易的扩展支持不同的编码和字符集,甚至支持UTF-8:在输入字符串的输入循环里面,每次都解码一个字符。对于一些复杂的字符解码来说,VM每次都只解码一个字符,这样就非常牛逼了(UTF-8的解码不是特别昂贵,但与ASCII来说还是有一些代价的)。 552 | 553 | 554 | 555 | ## Digression: POSIX Submatching 556 | 557 | POSIX 委员会觉得Perl里面的解决submatch的方法不够明确,容易有歧义,所以他们决定采用更加容易表述的新规则来表述这个问题,但POSIX的方法最后看起来也是非常难实现的方法(新规则里面几乎不可能实现非贪婪操作符,并且与现有的正则表达式不兼容,所以在实作版本里面也几乎没有人使用它)。不言而喻,POSIX的规则是没有流行起来的。 558 | 559 | POSIX定义的子串匹配规则如下:先选择输入字符串的尽可能的左边开始的匹配子串(这个与传统的Perl行为是一致的,当然这里不展开相关的细节)。在所有从最左边匹配的子串里面,选择匹配的最长的一个。如果依然有多个可选项,则选择正则表达式里面匹配最长的子串的那一个。如果依然是多选,则选择最大化下一个正则子串的匹配项,以此类推。在一个基于NFA的实现里面,如果要达成这种规则,就需要我们在每一个连接操作符相应的可变项上加上圆括号。这样就可以对于匹配项目有两个线程,然后根据POSIX定义的规则来选择所谓的更好匹配。在POSIX里面定义的优选规则就相当于把正则表达式 `x*`变成`(xx*)?`,第一个`x`尽可能的长匹配,然后是第二个进行尽可能长的匹配,以此类推。我们可以认为的制造一个例子,展示NFA里面,为了正确的合并匹配线程,我们需要为所有可能的`x`匹配进行追踪,为了达成这种前向的正则搜索,NFA里面的每一个线程维护的状态数其实是不可控的。 560 | 561 | 比如,用`(a|bcdef|g|ab|c|d|e|efg|fg)*`来匹配abcdefg的时候,对于`*`号来说,一共存在三种可能的方法来分割字符串:a bcdef g, ab c d efg, 和 ab c d e fg 。在Perl里面,对于可选操作来说,是倾向于尽可能早的进行确定性选择,也就是选择第一种版本的分割,在第一步的迭代匹配中`a`,而不是`ab`,因为a是最开始匹配到的。在POSIX里面,每一步里面都是倾向于匹配最大的子串,这样就导致如果遵循POSIX规则,就得选择`ab`的,在第四次的迭代里面会选择`efg`,而不是`e`。Glenn Fowler 写了一个[测试套装](http://www.research.att.com/~gsf/testregex/)来测试POSIX语法规则,Chris Kuklewicez 写了一个[更完整的测试套装](http://hackage.haskell.org/package/regex-posix-unittest)来发现实作中的[大部分BUG](http://www.haskell.org/haskellwiki/Regex_Posix)。 562 | 563 | 其实有两种方法来避免POSIX子匹配语法带来的状态追溯空间的爆炸增长。第一种,是反向进行正则匹配,这样就会让匹配过程的状态记录保持和正则表达式本身的长度成线性关系。这个[程序](https://swtch.com/~rsc/regexp/nfa-posix.y.txt)列举了这种计数。第二种,Chris Kuklewicz [观察到](https://mail.haskell.org/pipermail/libraries/2009-March/011379.html),如果再正则匹配的线程直接的比较行为本身是受限的或者说有规则的,那边把那些会产生冲突的每一个线程赋予一个优先级,然后[替换他们的子匹配的运行时状态记录](http://haskell.org/haskellwiki/RegexpDesign),也可以让状态机在前向匹配的过程中不会出现状态空间的爆炸式增长。他在Haskell的包[regex-tdfa](http://hackage.haskell.org/package/regex-tdfa)里面实现了[这项技术](http://hackage.haskell.org/packages/archive/regex-tdfa/1.1.2/doc/html/src/Text-Regex-TDFA-NewDFA-Engine.html#line-152)。 564 | 565 | 566 | 567 | ## Digression: A Forgotten Technique 568 | 569 | 本文中说的最大的技术点主要是关于把正则匹配过程中的子匹配信息存储到匹配执行体的状态里面。我所知道在正则匹配引擎里面最早把这个技术实作出来是在Rob Pike写的sam编辑器里面(几年以后Bruce Janson 在原版的基础上做了修订)。其实关于这个技术最早在1974年的一本算法课本里面就有出现,只是后面的10年没有被任何人注意到,知道在sam编辑器里面实作出来。 570 | 571 | 在1974年Aho, Hopcroft 和 Ullman 的书 ,第九章里面的"Pattern Matching Algorithms",在这章有两个有趣的练习题: 572 | 573 | ​ 9.6 设定 $ x = a_1a_2 \ldots a_n $ 为一个给定的字符串,然后 $ \alpha $ 是一个正则表达式。修改 9.1小节里面的算法(基于NFA状态机) 找出最小的 $ \mit k $ ,然后在 给定 $ \mit k $ 下 , (a) 找出最小的 $ \mit j $ 和 (b) 最大的 $ \mit j $ 让 子字符串 $a_ja_{j+1}\ldots a_k$ 匹配$ \alpha $ [下标 $ \mit j $ 关联状态 $ S_j $] 574 | 575 | ​ *9.7 设定 $ \mit x $ 和 $ \alpha $ 和 9.6小节一致,修改算法9.1,找到最小的 $ \mit j$ , 以及最大的 $\mit k$让$a_ja_{j+1}\ldots a_k$ 匹配 $\alpha$。 576 | 577 | 练习题里面的9.6其实是在求最短匹配。而练习9.7是在求解最长匹配,也就是我们表述的"leftmost longest"匹配。事实上在文章更早的章节里面(9.2小节)就给出了相关的回答: 578 | 579 | 各种不同的模式识别算法都是从9.1小节开始演进的。比如,我们给定正则表达式$\alpha$和文本字符串$x = a_1a_2\ldots a_n$,符合$\mit j<\mit k$ 并且$a_ja_{j+1}\ldots a_k$ 匹配正则表达式的所有匹配子串里面,求解里面最小的$\mit k$。我们可以从$\alpha$构建一个能处理$\mit I*\alpha$语法的 NFDFA 的状态机$\mit M$, 为了找到符合条件的最小的k,我们可以在9.11小节的代码段的2-12行插入一个测试代码,检测 $S_i$是否包含状态 _F_。我们可以把_F_做成单例,这样测试就没有多余的时间开销了;时间复杂度为_O(m)_,其中_m_是状态机_M_里面的状态数 。如果 $S_i$包含状态_F_,我们就可以break出主循环,表示我们已经发现了_x_里面子串$a_1a_2\dots a_i$就是最短的前置子串。 580 | 581 | 算法9.1我们可以更进一步,我们可以找出最大的k,符合_j ​ R. McNaughtom 和 H. Yamada 以及 Ken Thompson 普遍被认为是最先给出用NFAs来构建表达正则表达式的人,虽然他们都没有在发表的名章里面显示的提到NFA这个当时比较时髦的名字。 McNaughton 和 Yamada 的构建过程是创建一个 DFA,Thompson的是构建IBM 7094的机器码,但从他们的字里行间我们是能知道,他们其实都是在呈现一个NFA的构建过程。 592 | 593 | 我们通过两边都没有明确提到NFA的学术文章,来追溯NFA的历史其实还是一个蛮有意思的事情。Thompson的文章里面,他只是提到了一个理论:"根据 Brzozowski,这个算法可以在正则表达式上利用左推导原则来持续对给定的输入字符进行搜索"。 594 | 595 | 在 Aho , Hopcroft 和 Ullman的1974年出版的 _《The Design and Analysis of Computer Algorithms》_课本,是第一个明确的展示状态机算法,并且把正则表达式转换到一个NFA,然后给出明确的执行NFA算法的出处。在本章的目录的说明处有做一个备注:"这里的正则表达式用到的模式匹配算法(9.1节的算法)是对Thompson[1968]算法的抽象"。 596 | 597 | Aho和Ullman的1977年发表的_Principles of Compiler Design_里面展示的是不带attribution的把正则表达式转换到NFAs的算法,从后面的一个关于正则表达式的怎么执行的讨论里面有如下的一段: 598 | 599 | > 把一个正则表达式转换到类似DFA的形式所需要的世界,与扫描输入行成正比,但比单纯的行扫描要花更多的时间。 600 | > 601 | > 为了让整个匹配搜索过程的时间消耗尽可能的高效,我们必须要平衡模式编译过程和输入内容的扫描过程所花费的时间。Thompson[1968]有提出一个可接受的建议,就是把正则表达式转换到一个NFA,而扫描输入的过程就是模拟运行NFA的过程。我们扫描输入行的时候,会保存一个叫做"当前"的状态列表,起始的状态列表就是包含$\varepsilon-CLOSURE$起始状态。如果_a_是下一个输入字符串,我们会创建一个新的状态列表,这个礼拜标示的是接受输入_a_从旧的状态列表可以达到的所有新的状态的列表。这个时候旧的状态列表可以丢弃了,然后我们计算新列表的$\varepsilon-CLOSURE$。如果最后的状态不在新的状态列表里面,我们就继续处理下一个输入字符。 602 | 603 | 目录备注里面也提到:"Thompson[1968] 描述了QED编辑器里面实现的正则表达式识别算法"。 604 | 605 | Aho, Sethi, 和 Ullman的1986年出版的_Compilers: Principles, Techniques, and Tools_(我们所说的龙书)给了一个算法把一个NFA转换到一个正则表达式"算法 3.3. (Thompson 的构建过程)",算法那 3.4,“模拟运行一个NFA”。目录备注里面提到 "许多的文本编辑器都用正则表达式来做内容检索,比如Thompson[1968]年的QED编辑器里面所做的一样"。而1974年的课本里面提到这段的时候是这么表达的"对Thompson算法的一个抽象",而在龙书里面就简单的表述为"Thompson的构建过程"。 两个还是非常容易的区分的,如果你去阅读 NFA 的当时的机器码,Thompson的1968年的论文里面,是每一个NFA的状态对应到一个字母或者转义符。对比龙书里面的,他是用两个状态来表达一个字符或者转义符,每一次连接操作的时候丢弃掉其中的一个状态(1974年的论文描述里面还没有这个链接优化)。这个额外的状态的引入让这个轮转过程会容易,但需要在实际的执行过程中管理两倍的状态。我也发现了很多论文使用龙书里面的构建过程,但引用的却是Thompons的论文。也见过一些文章"优化"Thompson的构建过程,但他其实干的事情是基于龙书里面的算法,然后再加一个后置处理阶段移除多余的那些状态。 606 | 607 | Aho 和 Ullman的1992年出版的[Foundations of Computer Science](http://infolab.stanford.edu/~ullman/focs.html)专门用一章 [Chapter 10]来阐述"Patterns, Automation and Regular Expressions",在正文里面给出了现在大家非常熟悉的不带attribution的构建过程。目录说明里面依然也有这么一段:"10.8节里面的从正则表达式来构建不确定状态机的构建过程有参考McNaughton和Yamada[1960]… 用正则表达式来描述字符串最先出现在Ken Thompson的 QED系统里面,并且这个思想后面影响了非常多的UNIX 系统命令"。提到的McNaughton和Yamada的论文非常有意思,因为他们的论文和Thompson的一样,也都没有明确的给出NFAs,只是给出了从正则表达式构建一个DFA,有了DFA以后才能执行一些列的后续操作编程NFA,但NFA至始至终是没有被提到的。 608 | 609 | Hopcroft , Motwani, 和 Ullman 在2001年出版的 _Introduction to Automation Theory, Languages, and Computation_也给出了一个非常熟悉的构建过程。从第3章里面可以看到"这里的*ε*-NFA构建过程来源于[McNaughton and Yamada, 1960]。。。, 甚至在开发 UNIX之前,K. Thompson 在grep等命令里面用到了他发明的正则表达式。" 610 | 611 | Aho, Lam, Sethi, 和 Ullman 2007年出版的 _Compilers: Principles, Techniques, and Tools (2nd Edition)_给出了更加精确的历史描述。 在1986年的书里面,算法3.3只是简单的称为"Thompson的构建过程",到2007年修订以后,这个算法(3.23)表述被修订成"The McNaughton-Yamada-Thompson"算法,并且第三章有如下的一段阐述: 612 | 613 | > McNaughton 和 Yamada [1960]第一次给出了一个算法把正则表达式直接表示成一个有限状态机。 3.9小节里面的算法3.36,第一次被Aho用来实作他的Unix下的正则搜索工具egrep。这个算法后续也被用在awk[Aho, Kernighan 和 Weinberge, 1988]。而用不确定状态机为中介来处理的方法要归属于Thompson[1968]。后续的论文里面也给出Thompson在QED编辑器里面用到的运行非确定性有限状态机的算法。 614 | 615 | 最后总结起来就是,Brzozowski提出,被Thompson引入的这个方法在工业界被忽视了很多年,但庆幸的是Ownen, Reppy 和 Turon 的另外一个优秀的论文 [Regular-expression derivateives reexamined](http://www.cl.cam.ac.uk/~so294/documents/jfp09.pdf)支持在一个支持symbolic-manipulation的语言里面有比状态机更加高效的方法来做模式匹配。 616 | 617 | 618 | 619 | ## Implementations 620 | 621 | 参考[前面的文章](https://swtch.com/~rsc/regexp/regexp1.html#History)了解实现的详细历史,这篇文章中具体的代码可以在这个[地方](http://code.google.com/p/re1/source/browse)取得。 622 | 623 | 对于想完整了解现在的backtracking-engine的,可以看Henery Spencer的文章 "A Regular-Expression Matcher",1994年 Academic Press 出版的 _《Software Solutions In C》_。 624 | 625 | 626 | 627 | ## Summary 628 | 629 | 把正则表达式理解成一个运行在一个虚拟机上的程序是一个非常有用的抽象: 一个正则表达式解析器可以把正则表达式翻译成相应的字节码,然后可以用不同的虚拟机实现来运行这个字节码。基于状态虚拟机的实现,能够和传统的回溯实现一样,可以追踪子表达式的匹配边界,同时还能保证线性的运行时间。 630 | 631 | 感谢 Alex Healy 和 Chris Kuklewicz 一起关于 Perl和POSIX的自表达式语义的讨论,对我非常有用。 632 | 633 | P.S. 如果你喜欢阅读这篇文章,或者你也会有兴趣阅读 Roberto Ierusalimschy的论文[A Text Pattern-Matching Tool based on Parsing Expression Grammers](http://www.inf.puc-rio.br/~roberto/docs/peg.pdf),里面有用类似的方法来匹配[PEGs](https://en.wikipedia.org/wiki/Parsing_expression_grammar). 634 | 635 | 本系列的下一篇文章[Regular Expression Matching in the Wild]是一个实现之旅。 636 | 637 | 638 | 639 | ## References 640 | 641 | [[1](https://swtch.com/~rsc/regexp/regexp2.html#pike-b)] Rob Pike, “The text editor sam,” Software—Practice & Experience 17(11) (November 1987), pp. 813–845. [*http://plan9.bell-labs.com/sys/doc/sam/sam.html* 642 | 643 | [[2](https://swtch.com/~rsc/regexp/regexp2.html#thompson-b)] Ken Thompson, “Regular expression search algorithm,” Communications of the ACM 11(6) (June 1968), pp. 419–422.[*http://doi.acm.org/10.1145/363347.363387* 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | -------------------------------------------------------------------------------- /Regex/Part03.Regular_Expression_Matching_in_the_Wild.md: -------------------------------------------------------------------------------- 1 | # Regular Expression Matching in the Wild 2 | 3 | ## Introduction 4 | 5 | 本系列的前面两篇文章[Regular Expression Matching Can Be Simple And Fast](https://swtch.com/~rsc/regexp/regexp1.html), [Regular Expression Matching: the Virtual Machine Approach](https://swtch.com/~rsc/regexp/regexp2.html),分别对基于DFA和NFA的正则匹配算法做了解析,为了说明解析过程的原理,在正则规则上我们采用了从简原则。这篇文章从工程实作角度来描述具体的实现过程。 6 | 7 | 2006年,我花了一个暑假做了一个[Code Search](http://www.google.com/codesearch)项目,让程序员可以用正则表达式来搜索代码。也就意味着,可以让你在全球的所有开源代码里面执行[grep](http://plan9.bell-labs.com/magic/man2html/1/grep)操作。我们开始打算采用PCRE做我们的正则匹配引擎,但后面了解到他采用的是回溯算法,会导致潜在的[指数时间复杂度](https://swtch.com/~rsc/regexp/regexp1.html),以及相应的运行时栈溢出。因为代码搜索的服务是面向所有互联网用户的,如果采用PCRE,就会给我们带来攻击风险导致服务不可用。在排除PCRE以外的一个选择,就是我自己来写一个,新的这个匹配引擎是基于Ken Thompson的[开源版本的grep](http://swtch.com/usr/local/plan9/src/cmd/grep/),这个grep采用的是基于DFA算法的。 8 | 9 | 在接下来的三年,我实现了一个新的匹配后端替换了grep里面相应的代码,扩展了原有的功能到支持POSIX的标准grep。这个新版本就是RE2, 提供与PCRE类似的C++的接口,而且功能也和PCRE基本保持一致,同时还保证了线性时间复杂度,同时不会出现栈溢出的问题。RE2现在被广泛的运用在Google里面,包括Code-Search,以及一些内部的系统比如[Sawzall](http://labs.google.com/papers/sawzall.html)和[Bigtable](http://labs.google.com/papers/bigtable.html)。 10 | 11 | 到2010年三月,RE2变成了一个[开源](http://code.google.com/p/re2/source/browse/LICENSE)项目。这篇文章就是对RE2的源代码进行统领说明的,在本文中会详细展示前面两篇文章中提到的技术是怎么在RE2里面实作的。 12 | 13 | ## Step 1: Parse 14 | 15 | 在早期,正则表达式的[语法非常简单](http://www.freebsd.org/cgi/man.cgi?query=grep&apropos=0&sektion=1&manpath=Unix+Seventh+Edition&format=html),回想一下第一篇文章里面提到:concatenation, repetition, 和 alternation。还有字符分类:普通字符,+, ? 等元字符,以及位置断言符 ^ 和 $。表面看起来,今天的程序员,面对的正则表达式字符分类要丰富得多,但现在的正则表达式解析器一个重要的工作就是把输入转义到前面的那些基础概念上去。RE2的解析器定义了一个正则表达式结构体,定义在[regexp.h](http://code.google.com/p/re2/source/browse/re2/regexp.h#103)文件中,他和原有的egrep语法非常接近,只是有多了少量的几个特殊情形: 16 | 17 | 1. 字面字符串由kRegexpLiteralString节点表述,这样比串联一组kRegexpLiteral节点省内存。 18 | 2. 重复链接操作由kRegexpRepeat节点表述,虽然单靠这个节点不能完成重复语义。我们后面会看到这个节点具体是怎么编译的。 19 | 3. 字符类不是通过简单的一组范围或者一个位图来表述,而是平衡二叉树的节点范围来表述,这种方式会带来复杂度的提升,但对于处理Unicode字符类的时候却非常的关键。 20 | 4. 任意字符由一个特殊的节点类型来表述,和任意字节操作符一样。但任意字符和任意字节在RE2里面,在匹配UTF8的输入文本的时候,因为RE2的默认操作模式会有一点点差异。 21 | 5. 大小写不敏感的匹配是通过特殊的标志位来实现的。对于ASCII字符来说和那种多字节字符还不一样,比如 (?i)abc,被解析为 abc 和一个大小写敏感标志位,而不是解析为 `[Aa][Bb][Cc]`。RE2开始的时候,其实解析为后者的。对于后者来说比较消耗内存,特别是哪些tree-based的字符类。 22 | 23 | RE2的解析器实现在[parse.cc](http://code.google.com/p/re2/source/browse/re2/parse.cc#1539)。这是一个纯手写的解析器,主要是为了避免两个事情,一个是避免对另外一个解析器生成器的依赖,另外一个是现在的正则表达式规则已经不规则了,有太多的特殊设计。这里实现的解析器,没有使用递归下降,因为递归的深度会带来潜在的指数增长和栈溢出问题,特别是在多线程环境下。这里的解析器维持了一个解析栈,和LR(1)语法接下器做的类似。 24 | 25 | 有一个时期让我蛮惊讶的,对于同样的正则表达式不同的用户居然会有如此多不同的写法。例如,对于一个单字符类,比如-[.],或者 \\. ,可选项用 a|b|c|d 而不是 [a-d]。接下器里面会要处理这些情况,并且选用最有效的形式来表达相应的匹配语义,而不是把这种情况传递到第二阶段。 26 | 27 | 28 | 29 | ## Walking a Regexp 30 | 31 | 在解析完正则表达式后,接下来就是处理过程了。解析的结果是一个标准的树结构,通常树结构都是用标准的递归遍历。不幸的是,我们这里并不能确保我们是否有足够的栈空间来做递归遍历。比如一些别有用心的用户可能会写出如下的正则表达式`((((((((((a*)*)*)*)*)*)*)*)*)*)*`(或者是更大的)直接就导致懵逼的栈溢出。所以,遍历过程,我们采用显式栈的方式。这里[Walker](http://code.google.com/p/re2/source/browse/re2/walker-inl.h#22)有一个模板隐藏了栈管理,让这种限制条件更可操作。 32 | 33 | 回想一下,我再想解析结果是树的形式,然后我们通过Walker的方式来遍历,也许这整个处理过程就是错误的。如果递归在这里是不允许的,我们或许就应该从根上来避免递归的表述形式,可以把解析结果存储在[Thompson's 1968 论文](http://swtch.com/~rsc/regexp/regexp1.html#thompson)里面提到的[逆波兰式](http://en.wikipedia.org/wiki/Reverse_Polish_notation),如[示例代码](http://swtch.com/~rsc/regexp/nfa.c.txt)里面一样。如果RPN形式记录了最大的栈深度,那么在遍历的时候,我们就可以申请确定大小的栈,然后依次对表达式进行线性的扫描。 34 | 35 | 36 | 37 | ## Step 2:Simplify 38 | 39 | 接下来的补助就是简化,会重写那些复杂操作符为尽可能简单的,让后续的处理更加容易。随着时间的迁移,在RE2里面简化这个步骤的代码大部分都被挪到第一步解析器里面去了,因为简化步骤越早做越好,会减少大量的临时内存消耗。现在在简化里面还有最后的一个工作就是简化重复计数的正则表达式为一个基本的序列。比如把 `x{2,5}`简化为`xx(x(x(x)?)?)?`。 40 | 41 | 42 | 43 | ## Step 3: Compile 44 | 45 | 一旦正则表达式已经变成只使用第一篇文章里面提到的那些基础操作符以后,我们就可以用[这里](https://swtch.com/~rsc/regexp/regexp1.html#compiling)提到的技术进行编译了。我们也很容易的了解到这里的[编译规则](http://code.google.com/p/re2/source/browse/re2/compile.cc#17)。 46 | 47 | 在RE2的编译器里面有一个非常有意思的技巧,是我从Thompson的grep那里学来的。他会把UTF8的编译进一个自动机,也就是一次只读取一个字节。也就是状态机就是用的UTF8解码器来读取输入数据的。比如,为了匹配码点再0000到FFFF的Unicode字符,自动机会接受如下的字节序列: 48 | 49 | ``` 50 | [00-7F] // code points 0000-007F 51 | [C2-DF][80-BF] // code points 0080-07FF 52 | [E0][A0-BF][80-BF] // code points 08000-0FFF 53 | [E1-EF][80-BF][80-BF] // code points 1000-FFFF 54 | ``` 55 | 56 | 这里列举处理,并不是说编译的时候选择其中一种,对于[80-BF]这种通用后缀也是可以被拎出来的。上述的实际编译形式应该是如下图所示: 57 | 58 | ![](https://swtch.com/~rsc/regexp/utf3.png) 59 | 60 | 上面的例子其实是一个具备明显优势的规则表达式。下面的状态机匹配的全域的Unicode,从0000000-10FFFF: 61 | 62 | ![](https://swtch.com/~rsc/regexp/utf4.png) 63 | 64 | 比前面的状态机要大,但依然是规则度很高的。在实际的情况下,也由于Unicode的发展历史,字符类其实面临的是不规则的情况。比如, \p{Sc}, 当前的符号码点就会是如下的状态机: 65 | 66 | ![](https://swtch.com/~rsc/regexp/cat_Sc.png) 67 | 68 | 这个符号类在本文中来看,到目前为止已经是最复杂的了,但实际情况下,还有其他的字符类比这个还要复杂;比如,[`\p{Greek}`](https://swtch.com/~rsc/regexp/script_Greek.png)(所有希腊脚本)或者 [`\p{Lu}`](https://swtch.com/~rsc/regexp/cat_Lu.png)(所有大写字符)。 69 | 70 | 编译的结果其实是一个指令图,从描述上更加和第一篇文章里面的图更加贴近,但打印出来看其起来是一个虚拟机的执行程序。 71 | 72 | 编译成UTF-8的形式会让编译器更加复杂,但是会让匹配引擎执行更快:每次都只处理一个字节。对于每次只处理一个字节,也让很多匹配器更加容易处理匹配过程。 73 | 74 | 75 | 76 | ## Step 4: Match 77 | 78 | 到现在为止,前面所有的讨论都是构建一个RE2。在构建好RE2以后,就可以用来执行匹配操作。从使用者角度来看,只有两个方法:`RE2::PartialMatch`用来在输入文本里面找到第一个匹配的子串,和`RE2::FullMatch`用来完整搜索整个输入字符串的。但从RE2的实现角度,这里是有非常多可以讨论的地方。RE2主要处理4类基本的正则表达式匹配问题: 79 | 80 | 1. 正则表达式是否和整个输入字符串匹配? 81 | 82 | > RE2::FullMatch(s, "re") 83 | > 84 | > RE2::PartialMatch(s, "^re$") 85 | 86 | 2. 正则表达式是否匹配输入字符串里面的一个子串? 87 | 88 | > RE2::PartialMatch(s, "re") 89 | 90 | 3. 如果正则表达式匹配字符串中的一个子串,那么具体是哪个子串? 91 | 92 | > RE2::PartialMatch(s, "(re)", &match) 93 | 94 | 4. 如果正则表达式匹配字符串中的一个子串,那么具体是哪个子串,同时相应的子匹配是什么? 95 | 96 | > RE2::PartialMatch(s, "(r+)(e+)", &m1, &m2) 97 | 98 | 接下来会对上面的4中情况分别做描述。从使用者的角度,提供4种匹配使用看起来应该已经足够。但从实现角度来看的话,并不是这样来区分的,而且前面的问题其实是比后面的问题有更加高效手段实现的(只判定是否匹配肯定其实相对子匹配来说要简单一些)。 99 | 100 | 101 | 102 | ## _Does the regexp match the whole string ?_ 103 | 104 | > RE2::FullMatch(s, "re") 105 | > 106 | > RE2::PartialMatch(s, "^re$") 107 | 108 | 这个问题,其实在第一篇文章里面我们就有解析。在第一篇文章中,我们看到通过运行时生成一个简单的DFA来达到目的。[RE2也是采用的DFA](http://code.google.com/p/re2/source/browse/re2/dfa.cc#5)来解决这个问题,只是这里的DFA在内存使用和线程安全上有更多的改进,主要来自如下的两个修改。 109 | 110 | _Be able to flush the DFA cache_一个给定的正则表达式和输入文本,是可能导致每处理一个字节,都需要DFA创建一个新的状态的。对于大型的输入文本来说,状态是一个快消品。在RE2的DFA里面,他的状态会有一个[cache来管理](http://code.google.com/p/re2/source/browse/re2/dfa.cc#1081)。这样让DFA的整个匹配过程对于内存来说是恒定的。 111 | 112 | _Don't store state in the compiled program_在DFA第一篇文章里面,我们用一个整形的序列号字段在编译程序里面来追踪状态是否出现在特定的列表里面(s->lastlist和listid)。这个追踪手段让我们把状态加入列表的时候可以在常量时间里面进行去重操作。在一个多线程的程序里面,在线程之间共享同一个RE2对象,这个对象来唯一的管理序列号,这样就有引入了锁。但我们肯定是希望在常量时间内能处理列表的插入去重操作的。幸运的是,有一个数据结构稀疏集合就是被设计用来干这个事情的。RE2实现了这个数据结构[SparseArray](http://code.google.com/p/re2/source/browse/util/sparse_array.h),详情可以参考这个[文章](http://research.swtch.com/2008/03/using-uninitialized-memory-for-fun-and.html)。 113 | 114 | 115 | 116 | ## _Does the regexp match a substring of the string ?_ 117 | 118 | > RE2::PartialMatch(s, "re") 119 | 120 | 上一个问题问的是正则表达式是否匹配整个字符串;这个问题问的是是否匹配任意的子串。我们可以把这个问题通过对正则表达式改写为 `.*re.*`降级为上一个问题,但更优的改写是 `.*re`,当匹配发生的时候,然后把余下的部分裁剪掉就得到匹配的子串。 121 | 122 | _找到第一个匹配的字节_。从DFA或者编译程序里面可以分析每一个起始的字节他可能的匹配状态。在这种情况下,DFA寻找一个新的起点的时候,不需要对每一个字符都执行DFA的循环,可以直接通过[`memchr`来查找匹配的起始字符](http://code.google.com/p/re2/source/browse/re2/dfa.cc#1289),而且`memchr`通常都会使用特定平台的硬件指令进行优化。 123 | 124 | _尽早退出循环_,当问询的指数是否存在局部匹配的时候(例如问在字符串`ccccabbbbbddd?`里面是否存在子串匹配`ab+`),DFA可以在发现了`ab`后就停止匹配过程。修改DFA的的循环,在[检查每一个字节的匹配情况](http://code.google.com/p/re2/source/browse/re2/dfa.cc#1397)的时候,他可以尽可能快的停止匹配过程,只要发现了任何一个匹配项,而不用去找到确定的最长匹配是什么。记住,这里我们可以这么干,是因为调用者只关心是否存在子串匹配,而不关心匹配的子串是什么。 125 | 126 | 这样描述,看起来这里的DFA与解决上一个问题的DFA稍有不同啊。DFA的代码是一个简单的循环,循环里面会通过一个flags区控制相应的行为,看是否需要查找第一个有效的字节,是否需要尽早退出循环等。在2008年,我写第一个DFA代码的时候,在内循环里面检查flags,效率很低。后面我写了一个[内联的搜索循环函数](http://code.google.com/p/re2/source/browse/re2/dfa.cc#1254),根据三个布尔变量特化出8个不同类型的循环。每一个循环都根据自身要解决的问题做特殊的优化,外面调用的时候就不是调用原来的DFA循环,而是代用这8个循环里面的一个。我注意到最新版本的g++,已经不支持对InlineSearchLoop函数进行内联操作了,因为这个函数太大的缘故。所以现在的代码里面已经没有8份不同情况的搜索循环了。当然依然也是可以通过模板函数来实现8个不同循环的特化的,只是从现在来看已经意义不大,我尝试过,但发现特化的代码并没有带来效率的提升。 127 | 128 | 129 | 130 | ## _Does the regexp match a substring of the string? If so, where?_ 131 | 132 | > RE2::PartialMatch(s, "(re)", &match) 133 | 134 | 调用者增加了需求,现在调用者想知道匹配的子串具体的位置是什么。当然我们可以直接使用NFA来达到目的,只是需要承担一定的效率为代价。我们这,依然是通过在DFA的基础上做修改,来提取相应的信息。 135 | 136 | _找到endpointd的确切位置_,标准的DFA里面,每一个状态对应的都是一组无需的NFA状态。如果这个地方,我们把DFA状态对应的NFA状态集弄成局部有序的,这样我们就可以比较NFA状态之间的优先级,DFA可以确定相应的匹配点的截止位置。 137 | 138 | 在POSIX的规则里面,起始状态在输入里面的位置越前,优先级越高。比如一个DFA状态对应到五个NFA状态{1,2,3,4,5},他们的优先级可能是 {1,4}{2,3,5}:从1或者4状态开始的匹配比从2,3或者5开始的匹配优先级更高。这里主要的优先级比较是根据"leftmost longest"里面的"leftmost",最左原则。为了实现"longest"的要求,每一次匹配发现的时候,DFA会记录下他,并且只会继续执行那先比当前这个状态优先级更高的状态。一旦所有状态都匹配完成后,最后一个记录的匹配位置就是满足"leftmost longest"要求的匹配位置。 139 | 140 | 在Perl-style的规则里面,对于"leftmost"这个的语义的处理是一样的,只是Perl不处理"longest"语义。在处理NFA状态排序的是,他会把状态处理成完整有序的:没有任何的两个状态具备一样的优先级。比如在处理`a|b`的时候,a就具备比b高的优先级。比如对于`x*`来说就是循环处理可选项,一致优先尝试匹配另外一个x. 上述两种情况,关于节点的分裂如第一篇文章所示意的一样: 141 | 142 | ![](https://swtch.com/~rsc/regexp/fig16.png) 143 | 144 | ![](https://swtch.com/~rsc/regexp/fig18.png) 145 | 146 | 分割的时候,上面分支的优先级更高。对于一个非贪婪的可选操作符来说,就是相当于把优先级反转一下。 147 | 148 | 为了找到一个Perl匹配的结束点,每一次一个匹配发现的时候,DFA都需要记录下来,同时继续执行那些比当前状态优先级更高的状态匹配。一旦所有状态都匹配完成了,最后一个记录的位置就必然是优先级最高匹配达到的位置。 149 | 150 | 很好:现在我们知道匹配在哪里结束了。但调用者要要知道是整个子串的位置,肯定得要知道从哪里开始的。我们怎么来做这个呢? 151 | 152 | _逆序运行一遍DFA找到起始位置_。我们在学习正则表达式相关计算理论的时候,一个很经典的练习就是证明如果把正则表达式的所有连接操作反过来得到一个新的正则表达式(比如 [Gg]oo+gle逆序变成elgo+o[Gg]),这个新的正则表达式是否匹配到原来正序时候匹配的字符串的相应逆序字符串。在正则表达式的很多联系中不见得都有实用价值,但这个练习确实有。DFAs只会包裹匹配结束的位置,如果我们反向运行DFA,这个反向得到的结束位置就会是正序的起始位置,因为我们同时也会逆序输入字符串啊。 153 | 154 | 我们编译正则表达式的的逆序后,我们从前面发现的结束点逆序输入字符,然后在新的输入字符上查找longest的结束点。这样我们找到的结束点就相当于是正序里面的leftmost匹配,也就是匹配的开始位置。 155 | 156 | 157 | 158 | ## _Does this regexp match this string? If so, where ? Where are the submatches ?_ 159 | 160 | > RE2::PartialMatch(s, "(r+)(e+)", &m1, &m2) 161 | 162 | 这是关于正则表达式调用者能够提出的最难回答的问题。 163 | 164 | 这个问题的前面两个部分可以用DFA来解答。第三部分只能通过直接的NFA模拟才能解答了。DFA的运行效率非常高,我们用DFA先来做整体的匹配,同时通过第一遍的DFA匹配,可以减少NFA需要处理的输入字符的量,这个对于在一个巨量的字符里面搜寻小段字符的时候会非常有用,同时在DFA没有搜索匹配的时候,是不需要通过NFA去搜索了,这么搜索不到情形在实际中还是蛮常见的。 165 | 166 | 一旦通过DFA找到了匹配位置,就可以用NFA来查找子匹配的匹配边界了。NFA的运行效率是线性的(与正则表达式的大小以及输入文本的大小成线性时间复杂度),但由于在运行的过程中需要记住子匹配的具体范围,需要拷贝子匹配的边界的集合,所以在很多情况下这种运行方式导致比PCRE的这种回溯遍历的方式要更慢。为了保证在最坏的情况下的性能,对于普通情况下的运行会所到一些些影响(详情可以参考[Regular Expression Matching: the Virtual Machine Approach](https://swtch.com/~rsc/regexp/regexp2.html))。 167 | 168 | 存在一些重要的常见案例,我们可以用特殊的自定义代码处理,而不用通过进入通用的NFA模拟来保证执行效率。 169 | 170 | _**尽可能的使用one-pass的NFA**_。在NFA里面消耗蛮多时间来追踪子匹配的边界(特别是需要拷贝这些边界),其实我们是能够鉴别大部分的正则表达式,不管输入的字符串是什么,他们是只需要NFA来保存一组边界位置就可以了。 171 | 172 | 首先我们来定义什么是**one-pass 正则表达式**,这种表达式具备如下的特征,在一个给定的匹配锚点上,对于每一个字节的输入,只有唯一的跳转选择。比如 `x*yx*`就属于**one-pass**:一直读取x,知道遇到一个y,然后继续读取x。正则表达式表达是内容里面,不会出现那种需要你来选择读取什么或者需要回溯。另外一个例子,`x*x`就不是**one-pass**的:当你读入一个x的时候,你不知道这个x是用来匹配`x*`的,还是用来匹配最后一个`x`的。更多的例子,比如`([^x]*)x(.*)`就是一个**one-pass**;`(.*)x(.*)`不是**one-pass**的。`(\d+)-(\d+)`是one-pass, 而`(\d+).(\d+)`不是。 173 | 174 | 一个简单的直观方法判定一个正则表达式是不是属于**one-pass**的就是当他的结束是一个可选操作的时候,可选操作是显而易见无歧义的单选。比如x(y|z)是属于**one-pass**的,但(xy|xz)不是。 175 | 176 | 因为只会有一个可能的跳转状态,对于one-pass的NFA实现来说,就不需要拷贝子匹配的边界值。一个one-pass的匹配引擎执行分两步。在编译节点,[分析程序](http://code.google.com/p/re2/source/browse/re2/onepass.cc#363)需要判定一个是不是属于one-pass的。如果是,需要计算一个数据结构记录在每一个可能的状态以及相应能接收的字节。然后在执行节点one-pass引擎,可以直接遍历输入字符串,通过一个遍历找到相应的匹配(或者发现不匹配)。 177 | 178 | _**如果可能使用位状态来实现回溯**_。像类PCRE的这种通过递归避免拷贝子匹配边界集合的回溯实现方法:只需要维持一个子匹配边界集合,在递归的过程中来覆写或者恢复边界集。与之对应的是,这类方法他需要对输入字符进行多次遍历,至少一个NFA状态就需要遍历一次。尽管针对每一个NFA状态需要对字符串的同一部分进行多次遍历,但这里这依然至少一个线性时间复杂度的扫描,因为算法上并没有记住他是否一起遍历过同样的路劲。 179 | 180 | 位状态回溯的基本方法还是一个标准的回溯算法,手动管理栈,增加了一个位图来追踪和保存那些已经访问过的遍历路径(位图里面保存状态以及相应的字符串位置)。对于一个较小的正则表达式和较小的输入字符串来说,申请和清理一个bitmap比拷贝NFA状态来得快。RE2里面用了[位状态回溯](http://code.google.com/p/re2/source/browse/re2/re2.cc#669)的方法,其中申请的位图是32Kb。 181 | 182 | ​ _如果所有特殊路径的尝试都不可行,就用[标准的NFA模拟](http://code.google.com/p/re2/source/browse/re2/nfa.cc)_ 183 | 184 | 185 | 186 | ## Analysis 187 | 188 | RE2丢弃了PCRE里面那些不能通过状态机实现的特性(其中最有名的就是反向引用backreferences)。放弃这些难以实现的特性后,RE2可以从理论上来在各种应用场景里面分析正则表达式以及相应的状态机。比如前面在DFA里面用memchr寻找起点,以及判定一个正则是否适用于one-pass等。RE2也提供一些高层的模式分析应用在一些更高层面的检索中。 189 | 190 | _**范围匹配**_。[Bigtable](http://labs.google.com/papers/bigtable.html)按照行的名字存储记录,这样就可以通过行的名字快速检索一个范围内的记录。Bigtable 也允许使用方通过正则表达式来对记录进行过滤。这样对于一些客户来说,就可以只通过正则表达式进行检索,而不提供行的范围。对于有这种需求的用户来说,就可以通过RE2来先计算出一个匹配正则表达式的行的范围,然后在给定的范围内进行检索。比如,`(hello|world)+`,[RE2::PossibleMatchRange](http://code.google.com/p/re2/source/browse/re2/dfa.cc#1861)可以计算出可能的匹配范围是[hello, worldworle]。是不是很疑虑,怎么做到的?通过从DFA图的起点状态开始,找到一个具备最小字节值的路径,和一个具备最大字节值的路径。其中单词 worldworle 最后一个字母 e ,不要认为是一个印刷错误啊:worldworldworld < worldworle 但不是 worldworld: PossibleMatchRange 通常需要截断字符,然后才被用于表示匹配范围,当需要截断的时候通常需要做向上取整的操作。 191 | 192 | _**子串查询**_。如果你又一个很高效的方法来检查一组字符串是否出现某一个巨量文本里面(比方说,你实现了[Aho-Corasick algorithm](http://en.wikipedia.org/wiki/Aho-Corasick_algorithm)) ,但你的用户希望你能够同时提供正则表达式来检索答案。正则表达式的对于字面检索来说通常通常代表的是一组字面字符串;如果用户的输入字符串能够被简单的分解识别为一组字符串,那么分解的结果就可以丢到字符串搜索器里面取搜索,搜索器的搜索结果就可以用来筛选必要的正则表达式。[`FilteredRE2` class](http://code.google.com/p/re2/source/browse/re2/filtered_re2.h)实现了这个分析能力。给一组正则表达式,他会遍历正则表达式,计算出表达字面串形式的一个布尔表达式,同时返回涉及到的所有字符串。比如 FilteredRE2 可以把`(hello|hi)world[a-z]+foo`翻译成一个布尔表达式`(helloworld OR hiworld) NAD foo`,以及返回相应的字符串列表。给多个正则表达式,FilteredRE2会把每一个正则转译为一个布尔表达式,同时返回所有涉及的字符串。在得到所有相关的字面字符串以后,FilteredRE2也就可以评估每一个表达式,找出相应的正则表达式。这种过滤可以减少无效的正则检索。 193 | 194 | 上面提到的两种分析的可行性都是依赖在输入的字符串的简洁特征,第一个依赖DFA表达式,第二个使用正则表达式解析器的中间值(Regexp*)。如果再RE2里面允许出现那些不正则特性,那么这里的分析过程就会变得更复杂甚至不可能。 195 | 196 | 197 | 198 | ## Internationalization 199 | 200 | RE2的正则表达式可接受所有的Unicode序列,能够检索所有UTF-8或者Latin-1编码的文本。在PCRE和其他正则表达式的实现里面会有一些组的命名,比如[[:digit:]]和\d表述包含ASCII,\p{Nd}包含全部的Unicode。对于RE2的挑战是要在兼容实现大范围的Unicode字符集的基础上保持高效性。我们前面有提到一个字符类是用一个平衡二叉树来表述的,但我们需要考虑到要保持库的内存占用尽可能的小,我们的尽可能的压缩Unicode-table在内存的编码表示。 201 | 202 | 为了达到国际化的目标,RE2里面采用的是Unicode5.2的常规类别(比如 \pN 或者 \p{Lu})和Unicode脚本类别(比如\p{Greek})。如果输入字符不仅仅是ASCII字符,我们就得使用这些Unicode定义的类别(比如 用 \pN 或者 \p{Nd} 来代替 [[:digit]]和\d)。 RE2没有实现其他的Unicode 类别(详见 [Unicode Technical Standard #18: Unicode Regular Expressions](http://www.unicode.org/reports/tr18/))。在Unicode的分组表里面,一个分组的名字会对应到一个码点的数组,这个码点数组的范围就是分组的范围。在Unicode5.2的表格里面一共有4258个码点区域。因为Unicode 超过65536个码点,每一个范围都需要用两个32位的数字来表示(起点和终点),或者说总共是34kb的数据。因为大量的范围定义的码点其实都落在65536以内,所以我们这里可以分为两组,一组用16位表示的范围和一组是用32位表示的范围,这样减少总的内存占用到18Kb。 203 | 204 | RE2的实现里面支持了大小写不敏感匹配(通过 (?i)开关开启),按照Unicode5.2的规格:A和a是对应的,Á和á对应,然后even-K和K(Kelvin)对应,S和`ſ` (long s)对应。一共有2061这种特殊指定的字符映射。RE2里面维持一个这样的映射,每一个Unicode的码点映射到下一个比他大但大小写无关的码点上。比如在映射表里面 B 映射到 b,然后 b映射到 B。大部分的这些映射都只涉及两个字符,但也有个别的映射是超过两个的:K 映射到 k, 然后 k 映射到 (Kelvin-symbol)K, 然后 (Kelvin-symbol)K 映射回 K。这个表看起来信息比较冗余,很多重复的关系映射,比如 A 映射到 a, B 映射到 b, 等等。预期把每一个字符做映射,还不如做范围的delta映射:比如把A到Z映射到加上32以后的相应范围,然后a到j映射到减去32的范围,然后k映射到(Kelvin-symbol)K ,等等。这样优化以后的内存占用从2061个映射暂用16Kb,减小到279个映射,暂用3Kb。 205 | 206 | RE2里面,没有实现类似Python里面的别名特性,比如u"\N{LATIN SMALL LETER X}" 作为 "x"的别名。即使去掉了这些明显的用户接口级别的问题,必要的内部表格还是需要大约150Kb的数据。 207 | 208 | 209 | 210 | ## Testing 211 | 212 | 我们怎么知道RE2的代码是没有问题的呢?对正则表达式的实现做测试是一个非常复杂艰巨的任务,尤其当代码里面的分支非常多的时候,类似RE2的这种实现。其他的正则库,类似 [Boost](https://svn.boost.org/trac/boost/browser/trunk/libs/regex/test), [PCRE](http://vcs.pcre.org/viewvc/code/trunk/testdata/), 和 [Perl](http://perl5.git.perl.org/perl.git/tree/455f2c6c92e42c1a9e31268cbd491ba661f99882:/t/re) 他们积累了大量的手工维护的测试用例来检查正则表达式的基础功能,但是如果我们要用手工写的方式来对RE2做覆盖测试,我们很快会发现这是一个海量的工程,所以我们必须通过工具生成的方式做一些自动化的测试。 213 | 214 | 给定一组小的正则表达式,以及相应的链接操作符,指定一个最大的链接数,[RegexpGenerator](http://code.google.com/p/re2/source/browse/re2/testing/regexp_generator.h)会生成所有可能的链接组合。给定一个字母表,和一个最大的长度,[`StringGenerator`](http://code.google.com/p/re2/source/browse/re2/testing/string_generator.h)会生成所有可能的字符串。然后所有生成的正则表达式在每一个生成的字符串上做匹配操作,然后匹配的结果与写的一个基于回溯算法实现的专门用来做测试用的正则表达式版本(通常会直接用PCRE)做比较。因为RE2和PCRE不是在所有测试用例上完全一致,所以还有一个[分析器](http://code.google.com/p/re2/source/browse/re2/mimics_pcre.cc)来检查所有RE2和PCRE不一致的那些情况,这些已知的不一致都已经收藏[Caveats](https://swtch.com/~rsc/regexp/regexp3.html#caveats)里面。除掉了我们已知的那些不匹配的情况外,RE2和PCRE应该在其他情形下有完全一致的输出。 215 | 216 | 虽然这种穷举的测试用例必须限制运行在小的正则表达式和小的输入字符串上,但是绝大部分的Bug其实都可以通过这些小的测试用例暴露出来。而且这些测试用例也基本覆盖了其他正则表达式引擎手写的那些用例。虽然如此,在RE2里面依然有一个随机的测试用例,通过`RegexpGenerator`和 `StringGenerator`生成一些随机的大的用例来做测试。当然基本不会出现只能在大用例里面才能出现,而小用例测试不到的情况。即便如此,我们维护这份大的用例让他可以正常运转依然是非常有意义的。 217 | 218 | 219 | 220 | ## Performance 221 | 222 | RE2在小的检索用例上与PCRE不相上下,但是在大的用例上比PCRE要快很多。小的测试用例,我们用毫秒为单位,因为搜索时间基本与输入字符串的大小(通常是10字节)无关。在大的搜索对比上我们采用MB/s为单位,因为这个时候的检索基本与输入字符的大小成比例。 223 | 224 | 基准测试报告的代码[`re2/testing/regexp_benchmark.cc`](http://code.google.com/p/re2/source/browse/re2/testing/regexp_benchmark.cc)。这个目录[`re2/source/browse/benchlog`](http://code.google.com/p/re2/source/browse/benchlog/)下面有相应的报告(比较的PCRE版本是8.01,写文章时的最新版本)。 225 | 226 | **_编译_**,RE2的编译大概比PCRE要慢3到4倍: 227 | 228 | | **System** | PCRE | RE2 | 229 | | -------------------------------------- | ------- | ------- | 230 | | AMD Opteron 8214 HE, 2.2 GHz | 5.8 µs | 14.1 µs | 231 | | Intel Core2 Duo E7200, 2.53 GHz | 3.8 µs | 10.4 µs | 232 | | Intel Xeon 5150, 2.66 GHz (Mac Pro) | 5.9 µs | 21.7 µs | 233 | | Intel Core2 T5600, 1.83 GHz (Mac Mini) | 6.4 µs | 24.1 µs | 234 | 235 | 每一个正则表达式的编译时间差异大约在5-10毫秒。这个时间包括了释放解析器和编译器内存的时间。大部分的情景都是正则表达式编译一次,然后进行多次匹配,所以这种情况下,解析编译时间就变得不是那么重要了。 236 | 237 | RE2编译以后的对象要比PCRE编译后的对象要大,大概是几KB与几百B的区别。RE2在编译阶段做了更多的正则分析工作,也存储了更加丰富的表达形式。RE2在匹配的过程中也会保存一些状态(逐步建立DFA),运行一段时间后一个RE2一般会占用到10KB的样子,但是并不会无止尽的随着匹配增加内存暂用。RE2可以限定到一个指定的内存大小(默认是1MB)。 238 | 239 | _**全匹配,无子匹配信息**_。我们前面可以看到不同类型的搜索难度是不一样的,并且RE2也会采用不同的实现。这个基准测试是指定大小的随机生成文本上执行`.*$`搜索。下图看到的是一个相对平滑的搜索对比。 240 | 241 | ![*Speed of searching for .\*$ in random text. (Mac Pro)*](https://swtch.com/~rsc/regexp/regexp3g1.png) 242 | 243 | 这里RE2使用的是DFA进行搜索。 244 | 245 | _**全匹配,one-pass 正则,带子匹配信息,小的输入文本**_。下面的基准测试是在文本"650-253-0001"上执行`([0-9]+)-([0-9]+)-(0-9)+`,并获取相应的三个子匹配: 246 | 247 | | System | PCRE | RE2 | 248 | | -------------------------------------- | ------ | ------ | 249 | | AMD Opteron 8214 HE, 2.2 GHz | 0.8 µs | 0.5 µs | 250 | | Intel Core2 Duo E7200, 2.53 GHz | 0.4 µs | 0.3 µs | 251 | | Intel Xeon 5150, 2.66 GHz (Mac Pro) | 0.6 µs | 0.3 µs | 252 | | Intel Core2 T5600, 1.83 GHz (Mac Mini) | 0.7 µs | 0.4 µs | 253 | 254 | RE2采用的是OnePass匹配引擎来执行搜索,避免NFA的滥用。 255 | 256 | _**全匹配,模糊正则表达式,子匹配信息,小输入文本**_。如果正则表达式是那种具备不明确可选项的,RE2就不能使用OnePass引擎,但如果正则表达式和输入文本都足够小的时候,RE2是可以用位状态引擎的。这个基准测试是在文本"650-253-0001"上执行`[0-9]+.(.*)`: 257 | 258 | | System | PCRE | RE2 | 259 | | -------------------------------------- | ------ | ------ | 260 | | AMD Opteron 8214 HE, 2.2 GHz | 0.6 µs | 2.9 µs | 261 | | Intel Core2 Duo E7200, 2.53 GHz | 0.3 µs | 2.1 µs | 262 | | Intel Xeon 5150, 2.66 GHz (Mac Pro) | 0.4 µs | 2.3 µs | 263 | | Intel Core2 T5600, 1.83 GHz (Mac Mini) | 0.5 µs | 2.5 µs | 264 | 265 | 在这里,我可以看到RE2比PCRE要明显慢,因为这里的正则表达式不是OnePass的:因为在遇到数字的时候,他不知道是该继续匹配[0-9]+,还是该匹配'.'。PCRE优化了这种匹配,但当出现输入文本与正则表达式不匹配的情况的时候,运行时会急速的指数增长,即遍是很正常的输入字符的情况下也会导致严重的性能问题。与之对比,RE2就不会出现这种问题,他的运行效率与输入文本和正则表达式的大小只会是一个线性关系。对于小的输入的时候RE2使用位状态来模拟回溯,但在大输入或者大的正则表达式的时候,RE2会回过去使用NFA。 266 | 267 | _**部分匹配,无匹配的情形**_。对于部分匹配(无锚点的匹配),要求匹配引擎考虑输入文本里面的每一个字节位其实输入的情形。PCRE通过一个循环遍历输入文本的每一个字节为起始字节来尝试匹配。RE2实现可以是可以并行匹配的,而且RE2的实现还会对正则表达式进行完整分析,会潜在的增加运行时效率。 268 | 269 | 这个基准测试在再一个随机的文本上执行`ABCDEFGHIJKLMNOPQRSTUVWXYZ$`检索: 270 | 271 | ![*Speed of searching for ABCDEFGHIJKLMNOPQRSTUVWXYZ$ in random text*](https://swtch.com/~rsc/regexp/regexp3g2.png) 272 | 273 | RE2里面的DFA大部分时间都花在调用memchr查找第一个其实字符A。PCRE也注意到了其实字符A,但似乎没有充分优化。我估计在PCRE里面,除了第一次调用了memchr以后,没有再使用memchr来查找起始字符A。 274 | 275 | 下一个基准测试要相对难一点点,因为没有起始字符可以通过memchr来查找了。我们在随机字符上执行`[XYZ]ABCDEFGHIJKLMNOPQRSTUVWXYZ$`检索: 276 | 277 | ![*Speed of searching for [XYZ]ABCDEFGHIJKLMNOPQRSTUVWXYZ$ in random text. (Mac Pro)](https://swtch.com/~rsc/regexp/regexp3g3.png) 278 | 279 | PCRE变得不太适合处理这种情况,而RE2的DFA依然在快速的执行他的一次一个字节的匹配循环。 280 | 281 | 接下来的一个基准测试对PCRE来说非常不适应。执行如下的正则匹配`[ -~]*ABCDEFGHIJKLMNOPQRSTUVWXYZ$`,而且搜寻的文本里面没有相应的匹配字符串,这个时候PCRE为了匹配`[ -~]*`需要以每一个字节位搜寻的起始字节搜寻怎么个输入文本,这种搜寻的时间复杂度是$O(text^2)$。与之对应的是RE2的DFA的时间复杂度依然是线性的,只需要遍历一次文本。 282 | 283 | ![*Speed of searching for [ -~]\*ABCDEFGHIJKLMNOPQRSTUVWXYZ$ in random text. (Mac Pro)*](https://swtch.com/~rsc/regexp/regexp3g4.png) 284 | 285 | _**搜索并解析**_。另外一个RE的典型应用场景就是搜索和解析文本里面出现的特定字符串。这个基准测试会产生一个随机字符串,然后把 "(650 253-0001)"附加在随机字符串后面,然后在这个生成的文本上执行如下的无锚点匹配`(\d{3}-|\(\d{3}\)\s+)(\d{3}-\d{4})`,找到相应的7位电话号码,并提前相应的区位代码。 286 | 287 | ![*Speed of searching for and matching (\d{3}-|\(\d{3}\\)\s+)(\d{3}-\d{4}) in random text ending wtih (650) 253-0001. (Mac Pro)*](https://swtch.com/~rsc/regexp/regexp3g5.png) 288 | 289 | RE2的快速主要归功于DFA提供的线性搜索能力。 290 | 291 | _**总结**_。RE2对每一个正则表达式需要10KB左右的内存占用,与之对应的PCRE只要需要0.5KB或者更少。与内存占用对应的是,RE2保障线性时间复杂度的搜索性能(虽然这里的线性参照值是根据情况而不同的)。 292 | 293 | 在单纯查询一个字符串是否和一个正则表达式匹配的时候,RE2和PCRE的运行效率是等价的(对于RE2来说,就是无任何参数调用RE2::FullMatch和RE2::PartialMatch的情况)。 294 | 295 | 对于模糊正则表达式去解析文本的情况,RE2和PCRE的运行效率也是等价的。而且在查询一些短文本匹配的时候,RE2的运行时间大约是PCRE的两倍,而且当查询一些长文本的时候应该是更慢的。但是回过来说,模糊正则匹配的使用情况,通常数据都不大,而且通常应用场景里面这个匹配过程不是整个场景的效率瓶颈所在(比如,分析一个文件名的性能损失与发开这个文件所需的时间进行对比的话几乎是微不足道的,从前面的基准测试,我们能知道文件名的这种短文本的单个匹配基本在纳秒级别)。 296 | 297 | RE2比较擅长的是在一个巨量的文本上进行搜索,同时找出相应的匹配串的时候;如果相应的正则搜索还需要到PCRE进行递归回溯,那么RE2的效率提升就会特别明显了。 298 | 299 | 上面的基准测试,主要是与PCRE进行比较,因为这两者比较非常直接:首先他们都是C/C++实现,其次两者连接口都基本一致。这里还需要特别强调一下的是,这里的基准测试主要比较的是算法的差异,而不是比较两者的性能调优。PCRE采用的算法,主要是为了全面兼容Perl或者类Perl的正则引擎(大部分情况下的算法选择也就会局限于此)。 300 | 301 | 302 | 303 | ## Caveats 304 | 305 | RE2明确表达了,他不会尝试去支持所有Perl提出的那些正则特性。RE2支持的Perl特性有:支持非贪婪的repetition;支持字符分类类似\d;支持空断言 类似 \A, \b, \B和\z。RE2不支持前向或者后向断言,也不支持后向引用。RE2支持计数的repetition,但他的实现是通过展开正则表达式来桌的(`\d{3}`会被展开为`\d\d\d`),所以在RE2里面计数通常不要搞太大。RE2也支持Python风格的`(?Pexpr)`命名捕获,但不支持Perl或者.Net里面的`(?expr)`和`(?'name'expr)`这种命名语法。 306 | 307 | RE2在匹配行为上与PCRE也不是完全一致。这里有一些已知的差异点: 308 | 309 | * 如果正则表达式包含一个空字符串的重复的正则,类似`(a*)+`,这种情况下PCRE会把重复序列当做空字符串来处理,而RE2不是这样。比如当用`(a*)+`来匹配`aaa`的时候,PCRE会运行两次`+`操作来匹配`aaa`,然后再运行一次`+`来匹配一个空字符串;而RE2只会运行`+`一次,来匹配`aaa`,因为在RE2里面,括号里面的正则表达式已经匹配了尽可能长的文本;也就是在这种情况下,PCRE的`$1`是一个空字符串,而RE2`$2`是`aaa`。当然如果需要PCRE的这种行为也是可以撮到RE2里面去的,但笔者认为是没有必要的。 310 | * Perl和PCRE对于`\v`的语义是不一样的。在Perl里面这个只匹配垂直的Tab字符(VT,0x0B),而在PCRE里面他会同时匹配垂直Tab和换行。这个地方RE2选择和Perl保持一致。 311 | * 在单行模式下,如果输入文本最后是一个换行符,Perl和PCRE是允许用一个`$`来匹配包含或者不包含换行符的。RE2就一定是包含了换行符的。 312 | * 类似在多行模式下,如果输入文本是以换行符结束的,Perl和PCRE是不支持用`^`来匹配行开始处的换行符。但RE2是可以的。 313 | * RE2没有支持完整的Unicode的所有分类,但是实现了基本的Unicode属性类。 314 | * 在UTF-8模式下,PCRE定义了不属于POSIX标准的分类 `[[:^xxx:]]`单独用来匹配ASCII码点,所以`[[:^alpha:]]`匹配所以不属于`[[:alpha]]`的ASCII字符,而`[^[:alpha:]]`用来匹配所以不属于`[[:alpha:]]`的Unicode字符。RE2纠正了这个不一致:`[[:^alpha:]]`和`[^[:alpha:]]`都是匹配不属于`[[:alpha:]]`的所有Unicode字符。 315 | 316 | 还有一部分Perl和PCRE里面笔者看起来比较晦涩的特性,RE2选择不支持的: 317 | 318 | * "扩展后"的正则表达式有一个不成为的规则,如果字符没有被转义的情况下,字符匹配是字符本身,当然标点符号除外。因此`\q`是代表的是有特殊含义的,而`\#`匹配的是字面字符`#`。Perl和PCRE还接受那些还没有被定义任何特定含义的字符也加上一个转义符号。因此在Perl里面,`\q`在没有定义特殊的匹配含义之前,它匹配的是字面字符`q`。RE2不接受这种规则,在RE2里面所有在转义符后面的字符,都是已经定义了特殊含义的才可以出现。通过这个,我们可以诊断到RE2是否支持相应的转义符,比如下面关于\cx的例子。 319 | * RE2里面不支持用`\cx`来代表`Control-X`字符。控制字符可以用C++里面的控制字符插入语法或者直接写8进制或者16进制的转义。 320 | * `\b`,在POSIX里面意味着退格;在Perl里面,如果放在一个字符类里面,比如`[\b]`,他才意味着退格,否则意味着单词边界。为了不带来这总迷惑,RE2干脆选择不支持`\b`来匹配退格。在POSIX模式下,RE2不接受`\b`,在Perl模式下,RE2识别为单词边界,而且不允许把`\b`放到字符类里面去。要识别退格,可以直接插入一个字面的退格,或者直接写相应的码点\010。 321 | * RE2 不支持原子分子运算符`(?>…)`,也不支持`++`运算符。这些运算符的作用主要是为了缓解回溯的性能问题。RE2里面有更加完整的性能方案,所以也就不需要支持这个。 322 | * RE2不识别 `\C`,`\G`和`\X`。 323 | * RE2不支持 条件子模式运算符`(?(...)...)`,不支持注释运算符`(?#...)`,模式引用`(?R)(?1)(?P>foo)`和C标注`(?C...)`。 324 | 325 | 326 | 默认情况下,RE2会限制每一个RE2对象关联的两个处理器以及相应的DFAs占用的总内存不超过1MB。对于大部分的正则表达式来说这个内存大小都已经足够了,除非那些超大的正则表达式或者量级巨大的重复操作符匹配项情况下可能会超过这个限制。在编译阶段如果操过内存限制,会导致`re.ok()`返回false,并且可以通过`re.error()`查询到具体的错误信息。在搜索阶段,RE2是不会出现内存不足的情况的:如果发现内存不够的时候,RE2会丢弃缓存的DFA状态,然后从原有内存上启动一个新的DFA。如果发现需要频繁的去清理DFA,搜索过程会中断。 327 | 328 | 329 | 330 | ## Summary 331 | 332 | RE2证明了是可以基于状态机理论来实作一个新的正则引擎,并且实现今天类似PCRE等基于递归回溯算法的正则引擎所具备的绝大部分正则特性。因为RE2是以状态机为理论基础构建的,所以RE2的运行效率是有严格保障的;并且基于RE2我们是可以做一些比搜索匹配本身更高应用价值的字符分析,这些分析对于基于递归回溯算法的那些正则引擎来说可能是非常困难的或者不可行的。最后RE2还是开源的,随便你倒腾。 333 | 334 | 最近几年和Rob Pike以及Ken Thompson关于正则的讨论,对RE2的实现以及我对正则表达式的理解有非常大的帮助。RE2的C++的接口与Sanjay Ghemawat设计和实现的[基于C++的PCRE的接口](http://man.he.net/man3/pcrecpp)一致,其中FilteredRE2的代码是 Srinivasan Venkatachary 编写的。Philip Hazel 写的PCRE相关的代码非常优秀,为了匹配PCRE正则引擎实现的相关特性,也间接加快了RE2原有实施计划的进程,PCRE最后也成为了与RE2进行对比测试的另外一个具备非常优秀实现的正则引擎。非常感谢所有小伙伴的复出。 335 | 336 | 本系列的下一篇文章[Regular Expression Matching with a Trigram Index](https://swtch.com/~rsc/regexp/regexp4.html),我们会来聊聊Google-Code-Search项目的幕后,看它具体是怎么工作的。 337 | 338 | 339 | -------------------------------------------------------------------------------- /Regex/Part04.Regular_Expression_Matching_with_a_Trigram_Index.md: -------------------------------------------------------------------------------- 1 | # Regular Expression Matching with a Trigram Index or How Google Code Search Worked 2 | 3 | 4 | 5 | ## Introduction 6 | 7 | 2006年暑假,非常幸运能够在Google进行实习。当时,Google内部与一个叫gsearch的工具,这个工具看起来是可以在Google的整个代码库的所有文件进行grep操作,然后打印出搜索结果。当然,当时他的实现是比较挫的,而且运行也是非常慢的,其实gsearch做的就是向一组把整个source-tree加载到内存里面的服务器发请求:在每一台机器上对里面加载的source-tree执行grep操作,然后gsearch会合并所有的搜索结果并打印出来。Jeff Dean, 我实习期间的老板,也是gsearch的作者之一,做了一个提议说,如果做一个web入口,然后上面提交搜索请求,然后可以在全世界所有的开源代码上运行gsearch会是一个很吊的事。我一听觉得有点意思,所以我那个夏天我就在Google干这个自己看来吊吊的事情。由于我们开始的计划过分乐观,我们的发布延后到了10月份,到2006年10月5号的时候,我们终于发布了(那个时候我刚好会学校了,但依然是兼职实习的状态)。 8 | 9 | 因为碰巧对Ken-Thompson的Pan-9里面的grep有过了解,所以这个项目的早期Demo是我基于Pan-9的grep搭建的。原计划是准备切换到一个更加现代的正则引擎,或者是PCRE,或者是自己全新写的。对于PCRE来说,因为他的解析那块的代码有一个众所周知的安全问题,所以对这块代码有完整的Review。那个时候发现的一个问题是现在这些流行的正则实现,[不管是Perl,还是Python或者PCRE都不是基于状态机实现](http://swtch.com/~rsc/regexp/regexp1.html)。这个发现,对于我来说是有点小吃惊的,因为Rob-Pike写了Plan 9 的正则引擎,所以当我跟他交流的时候,他也收到了一万点惊讶(当时Ken还没有在Google,所以当时也就还没有跟Ken进行相关的交流)。我一起再学校的时候,在龙书上有学习过正则表达式以及状态机,也通过阅读Rob和Ken的相关代码对这块实作有过了解。其实在我的理解里面,这块的搜索匹配理所当然的理解应该应该是线性时间复杂度的。但后面发现Rob实作,其实很少有人知道,而且在实作后的这么长时间内也基本是[被大家遗忘的状态](http://swtch.com/~rsc/regexp/regexp2.html#ahu74)。有了前面的了解过后,所以这个项目启动的时候,我们就基于Pan-9的相关grep代码来构建的;这部分在几年后,也已经用我实作的RE2进行替代升级。 10 | 11 | 代码搜索这个是Google第一个也是唯一的一个只接受正则表达式作为输入的搜索引擎。然后发现比较悲剧的是大部分程序猿都不会写正则表达式,更加不用说写一个高效的正则表达式。所以在进代码搜索的时候,会有一个"正则表达式"本身的辅助搜索站点出现,类似当你输入"电话号码"的时候会得到相应的正则表达式`\(\d{3}\) \d{3}-\d{4}`。 12 | 13 | 在2010年5月份,给代码搜索项目写的[RE2](http://code.google.com/p/re2),现在是[Google 开源正则引擎](http://google-opensource.blogspot.com/2010/03/re2-principled-approach-to-regular.html)。代码搜索项目以及相关的RE2成功的帮助很多同学深入的了解了正则表达式;而且Tom Christiansen最近跟我说,在Perl社区也已经开始使用RE2了(Mre::engine::RE2),现在网页上的实际运行的正则引擎已经是RE2了,而不是原来那个容易遭受服务攻击的引擎。 14 | 15 | 在2011年的10月份,Google宣布了新的调整,[重新聚焦在高影响力的产品上](http://googleblog.blogspot.com/2011/09/fall-spring-clean.html),其中也包括[关掉代码搜索项目](http://googleblog.blogspot.com/2011/10/fall-sweep.html),所以现在大家可以看到代码搜索项目已经下线了。在这个时间点,作为深入参与整个项目过程的一个老鸟来说,觉得是应该写一点什么来纪念或者说回顾一下代码搜索项目中做的相关工作。代码搜索项目,其实是建立在Google的世界级的文档索引和检索工具集上的;本文提到的相关实现,聚焦在单机上完成海量代码的索引和检索。 16 | 17 | 18 | 19 | ## Indexed Word Search 20 | 21 | 在我们进入正则搜索前,我们需要先来了解一下基于单词的全文搜索是怎么做的。全文搜索的关键是一个叫做位置列表或者反向索引的数据结构,这个这个列表里面会列出所有可能的搜索条目,每个条目列举的是包含这个条目的所有相关文档。 22 | 23 | 举例来说,有如下三个非常短的文档: 24 | 25 | * (1) Google Code Search 26 | * (2) Google Code Project Hosting 27 | * (3) Google Web Search 28 | 29 | 上面三篇文档的Inverted-index看起来会是这样的: 30 | 31 | ​ Code: {1, 2} 32 | 33 | ​ Google: {1, 2, 3} 34 | 35 | ​ Hosting: {2} 36 | 37 | ​ Project: {2} 38 | 39 | ​ Search: {1, 3} 40 | 41 | ​ Web: {3} 42 | 43 | 为了找到所有包含Code和Search的文档,你需要加载索引 Code {1, 2}和 Search {1, 3},然后进行做集合的差操作,得到集合 {1}。为了找到包含Code或者Search的文档,你需要加载相应的索引然后做集合的并操作。因为Inverted-index本身是有序的,所以相应的集合操作都是线性时间复杂度。 44 | 45 | 在全文搜索引擎里面,为了支持文本分析应用,通常会在Inverted-index记录每一次单词出现的具体的位置,这样上面的索引结构数据会是如下: 46 | 47 | ​ Code: {(1, 2), (2, 2)} 48 | 49 | ​ Google: {(1, 1), (2, 1), (3, 1)} 50 | 51 | ​ Hosting: {(2, 4)} 52 | 53 | ​ Project: {(2, 3)} 54 | 55 | ​ Search: {(1,3), (3, 4)} 56 | 57 | ​ Web: {(3, 2)} 58 | 59 | 比如现在我们需要找到"Code Search",其中的一种方法是先加载Code对应的Inverted-index列表,然后扫描Code的列表,找到那些至少有一个后继单词的条目。其中Code的列表里面的条目(1,2),以及Search列表里面的条目(1,3)都是来自文档1,并且他们具备单词的连续性(一个是2,另外一个是3),所以文档1包含文本"Code Search"。 60 | 61 | 另外一个可选的方案,是把搜索的文本进行分词,然后把他理解为一组单词查询的与操作,其中每个单词都能找到相关的候选文档,然后加载候相应文档后,在文档上执行搜索,并过滤掉那些找不到相应匹配的文档。在实际的应用中,这种方法对于类似"to be or not to be"的搜索文本来说,不太可行。在索引里面存储位置信息会加大索引本身的存储空间,并增加索引更新的代价,但是可以最大限度的来避免去磁盘加载不必要的文档。 62 | 63 | 64 | 65 | ## Indexed Regular Expression Search 66 | 67 | 全球的代码量已经可以用海量来形容了,所以我们基本是没有可能把所有代码都加载到内存,然后在上面进行正则搜索,这个其实不管你的正则匹配效率有多高,都是不可行的。代码搜索项目,我们使用了一个inverted-index来检索每个匹配请求的候选文档,我们对候选文档进行搜索,打分,分级然后合并相关的结果呈现出匹配结果。 68 | 69 | 因为正则匹配并不是以单词为搜索边界的,所以这里的inverted-index是不能像前面的例子一样以单词为基础来构建。对于这种场景,我们使用一种古老的信息检索编码技术[n-grams/n-元语法](https://en.wikipedia.org/wiki/N-gram "n-元语法"),长度为n的字符子串(基于[n阶的马尔科夫链概率模型](https://en.wikipedia.org/wiki/Markov_chain))。光从名字其实看不出特别,在实际的工业应用中,采用2-grams的不多,采用4-grams的有很多,然后采用3-grams(三元语法)的不算多也不算少。 70 | 71 | 继续以上一节的例子来说明问题,假如还是如下的文档集合: 72 | 73 | (1) Google Code Search 74 | 75 | ​ (2) Google Code Project Hosting 76 | 77 | ​ (3) Google Web Search 78 | 79 | 具备如下的三元索引: 80 | 81 | ``` 82 | _Co: {1, 2} Sea: {1, 3} e_W: {3} ogl: {1, 2, 3} 83 | _Ho: {2} Web: {3} ear: {1, 3} oje: {2} 84 | _Pr: {2} arc: {1, 3} eb_: {3} oog: {1, 2, 3} 85 | _Se: {1, 3} b_S: {3} ect: {2} ost: {2} 86 | _We: {3} ct_: {2} gle: {1, 2, 3} rch: {1, 3} 87 | Cod: {1, 2} de_: {1, 2} ing: {2} roj: {2} 88 | Goo: {1, 2, 3} e_C: {1, 2} jec: {2} sti: {2} 89 | Hos: {2} e_P: {2} le_: {1, 2, 3} t_H: {2} 90 | Pro: {2} e_S: {1} ode: {1, 1} tin: {2} 91 | ``` 92 | 93 | (其中字符'_'代表的是空格) 94 | 95 | 给定如下的正则表达式`/Google.*Search/`,我们可以构建一个三元语法单元的AND和OR的符合查询,这个复合查询表达的是正则匹配的超集(匹配正则的文本都需要匹配给出的这个三元语法单元的符合查询)。所以上面的三元复合查询会是: 96 | 97 | `Goo` AND `oog` AND `ogl` AND `gle` AND `Sea` AND `ear` AND `arc` AND`rch` 98 | 99 | 我们可以在上面的三元索引上执行上面的三元查询,找出候选文档的合集,然后在每一篇文档上执行相应的正则匹配。 100 | 101 | 把正则表达式转换到一个三元语法单元的过程并不是简单的从正则表达式里面提前子串然后进行AND连接操作。当正则表达式里面用到`|`操作符的时候需要转换到相应的OR查询,当涉及到括号括起来的子表达式的时候,这个转换过程会变得更加复杂。 102 | 103 | 一个完备的转换规则,会从每个正则表达式计算得到5个结果:空字符串是否是正则表达式的匹配字符串;正则表达式的确切的匹配集合的或者判定出确切集合是未知的;一个正则表达式匹配字符串的前缀字符串集合;一个正则表达式匹配字符串的后缀字符串集合;另外一个与前面类似的所有匹配字符串的集合通常用来表征匹配字符串的中部。下面的规则定义正则表达式的单元的转换规则: 104 | 105 | | ‘’ (empty string) | | | | 106 | | -------------------------- | ----------------------- | ---- | ---------------------------------------- | 107 | | | emptyable(‘’) | = | true | 108 | | | exact(‘’) | = | {‘’} | 109 | | | prefix(‘’) | = | {‘’} | 110 | | | suffix(‘’) | = | {‘’} | 111 | | | match(‘’) | = | ANY (special query: match all documents) | 112 | | `c` (single character) | | | | 113 | | | emptyable(`c`) | = | false | 114 | | | exact(`c`) | = | {`c`} | 115 | | | prefix(`c`) | = | {`c`} | 116 | | | suffix(`c`) | = | {`c`} | 117 | | | match(`c`) | = | ANY | 118 | | *e*? (zero or one) | | | | 119 | | | emptyable(*e*?) | = | true | 120 | | | exact(*e*?) | = | exact(*e*) ∪ {‘’} | 121 | | | prefix(*e*?) | = | {‘’} | 122 | | | suffix(*e*?) | = | {‘’} | 123 | | | match(*e*?) | = | ANY | 124 | | e* (zero or more) | | | | 125 | | | emptyable(*e**) | = | true | 126 | | | exact(*e**) | = | unknown | 127 | | | prefix(*e**) | = | {‘’} | 128 | | | suffix(*e**) | = | {‘’} | 129 | | | match(*e**) | = | ANY | 130 | | *e*+ (one or more) | | | | 131 | | | emptyable(*e*+) | = | emptyable(*e*) | 132 | | | exact(*e*+) | = | unknown | 133 | | | prefix(*e*+) | = | prefix(*e*) | 134 | | | suffix(*e*+) | = | suffix(*e*) | 135 | | | match(*e*+) | = | match(*e*) | 136 | | *e*1 \| *e*2 (alternation) | | | | 137 | | | emptyable(*e*1 \| *e*2) | = | emptyable(*e*1) or emptyable(*e*2) | 138 | | | exact(*e*1 \| *e*2) | = | exact(*e*1) ∪ exact(*e*2) | 139 | | | prefix(*e*1 \| *e*2) | = | prefix(*e*1) ∪ prefix(*e*2) | 140 | | | suffix(*e*1 \| *e*2) | = | suffix(*e*1) ∪ suffix(*e*2) | 141 | | | match(*e*1 \| *e*2) | = | match(*e*1) OR match(*e*2) | 142 | | *e*1 *e*2 (concatenation) | | | | 143 | | | emptyable(*e*1*e*2) | = | emptyable(*e*1) and emptyable(*e*2) | 144 | | | exact(*e*1*e*2) | = | exact(*e*1) × exact(*e*2), if both are known | 145 | | | | | or unknown, otherwise | 146 | | | prefix(*e*1*e*2) | = | exact(*e*1) × prefix(*e*2), if exact(*e*1) is known | 147 | | | | | or prefix(*e*1) ∪ prefix(*e*2), if emptyable(*e*1) | 148 | | | | | or prefix(*e*1), otherwise | 149 | | | suffix(*e*1*e*2) | = | suffix(*e*1) × exact(*e*2), if exact(*e*2) is known | 150 | | | | | or suffix(*e*2) ∪ suffix(*e*1), if emptyable(*e*2) | 151 | | | | | or suffix(*e*2), otherwise | 152 | | | match(*e*1*e*2) | = | match(*e*1) AND match(*e*2) | 153 | 154 | 上面的规则很完整,但是仅仅依据上面规则来转换正则表达式并不能得到有效的查询语句,并且转换某些正则表达式的时候,得到是查询语句集合很容易是指数增长的。所以,在上述的每一个转换步骤,我们可以来进行一些简化,使得到的查询信息可控。首先我们来抽象一个函数计算三元语法单元。 155 | 156 | 三元函数(计算三元语法单元的函数)的输入可以是任意的字符串,如果字符串长度小于3的时候,匹配的是任意单元;如果长度大于3,那么函数得到是字符串里面所有三元语法单元的AND连接串。三元函数输入的如果是一组字符串,那么输出是分别针对每个字符串进行转换得到的三元串,然后对这组三元串进行OR连接,就是这一组字符串的三元查询结果。 157 | 158 | (单个字符串) 159 | 160 | - trigrams(`ab`) = ANY 161 | - trigrams(`abc`) = `abc` 162 | - trigrams(`abcd`) = `abc` AND `bcd` 163 | - trigrams(`wxyz`) = `wxy` AND `xyz` 164 | 165 | (一组字符串) 166 | 167 | - trigrams({`ab`}) = trigrams(`ab`) = ANY 168 | - trigrams({`abcd`}) = trigrams(`abcd`) = `abc` AND `bcd` 169 | - trigrams({`ab`, `abcd`}) = trigrams(`ab`) OR trigrams(`abcd`) = ANY OR (`abc` AND `bcd`) = ANY 170 | - trigrams({`abcd`, `wxyz`}) = trigrams(`abcd`) OR trigrams(`wxyz`) = (`abc` AND `bcd`) OR (`wxy` AND `xyz`) 171 | 172 | 任意的正则表达式,在表达式分析的每一个步骤,我们都可以决定这个步骤上应用哪些三元转换。上述的三元转换会得到不同的计算过程信息,我们可以有选择性的来应用和抉择,以保障整个过程和最后得到的结果信息都是可控的: 173 | 174 | (信息保留转换) 175 | 176 | - At any time, set match(*e*) = match(*e*) AND trigrams(prefix(*e*)). 177 | - At any time, set match(*e*) = match(*e*) AND trigrams(suffix(*e*)). 178 | - At any time, set match(*e*) = match(*e*) AND trigrams(exact(*e*)). 179 | 180 | (信息丢弃转换) 181 | 182 | - If prefix(*e*) contains both *s* and *t* where *s* is a prefix of *t*, discard *t*. 183 | - If suffix(*e*) contains both *s* and *t* where *s* is a suffix of *t*, discard *t*. 184 | - If prefix(*e*) is too large, chop the last character off the longest strings in prefix(*e*). 185 | - If suffix(*e*) is too large, chop the first character off the longest strings in suffix(*e*). 186 | - If exact(*e*) is too large, set exact(*e*) = unknown. 187 | 188 | 一个可取的应用转换的办法是在进行“信息丢弃转换”之前先进行一次"信息保留转换"。高效的转换是尽可能的丢弃从前置,后缀以及确定集上得到的那些信息中的冗余信息,让进入最终查询集本身的信息更加内敛。在另外一个方面,我们也可以在表达式的链接分析中再挤掉一些冗余信息:如果e1e2是不确定的,那么match(e1e2)的时候可以应用如下的转换 `trigrams( suffix(e1) × prefix(e2))`。 189 | 190 | 除了前面提到的那些转换,我们可以简单的用“布尔简化”来简化变换构造后的匹配查询语句:比如`abc OR (abc AND def)`查询比`abc`要复杂,查询代价大,但本身表达是意思还不如`abc`来得精确。 191 | 192 | 193 | 194 | ## Implementation 195 | 196 | 为了示范上面提及的想法,我发布了一个用[Go编写的基础版本](http://code.google.com/p/codesearch)。如果你安装了最新的[Go的版本](http://golang.org/),你可以直接运行: 197 | 198 | ``` 199 | goinstall code.google.com/p/codesearch/cmd/{cindex,csearch} 200 | ``` 201 | 202 | 来安装相应的二进制命令 cindex 和 csearch。如果你么有安装Go,可以[下载二进制的安装包](https://code.google.com/p/codesearch/downloads/list),支持FreeBSD, Linux, OpenBSD, OS X, 和 Windows 平台。 203 | 204 | 首先第一步是运行cindex,cindex接受一个目录的列表或者文件列表作为参数,来建立索引: 205 | 206 | ``` 207 | cindex /usr/include $HOME/src 208 | ``` 209 | 210 | 默认情况下cindex是把生成的索引加入到现在的索引库里面,所以上面的命令其实等价于如下: 211 | 212 | ``` 213 | cindex /usr/include 214 | cindex $HOME/src 215 | ``` 216 | 217 | 在不带参数的情况下,cindex是刷新本地已经存在的索引,所以运行上述命令后,再继续运行: 218 | 219 | ``` 220 | cindex 221 | ``` 222 | 223 | 会重新扫描 /usr/include 和 $HOME/src ,然后重写相关的索引文件。要找到相关的帮助信息可以运行 `cindex -help`。 224 | 225 | [The indexer](https://code.google.com/p/codesearch/source/browse/cmd/cindex/cindex.go)会假定所有处理的文件都是以UTF-8为编码的。对于那些包含无效的UTF-8的文件或者说是行数过大的文件,或者说是拥有的三元语法单元过于庞大的文件,都会被索引处理程序丢弃掉。 226 | 227 | 最后的索引文件里面会包含一个路径列表(前面有说道,主要用来进行索引更新),以及具体被索引分析的文件列表和本文前面描述的带位置信息的具体inverted-index信息,只是这里存储的基本索引单元是三元语法单元。根据实践经验,索引文件的大小会是被索引文件的大约20%的大小。比如,对[Linux 3.1.3 内核源代码](http://www.kernel.org/pub/linux/kernel/v3.0/),总共大约420MB,进行索引分析,会得到77MB的索引文件。当然,对于一个特定的搜索请求,我们并不需要访问整个77MB的索引,因为索引本身的组织方式也会是一个有序的。 228 | 229 | 在我们有索引信息以后,我们就可以运行csearch来进行搜索: 230 | 231 | ``` 232 | csearch [-c] [-f fileregexp] [-h] [-i] [-l] [-n] regexp 233 | ``` 234 | 235 | 这里的正则采用的是RE2实现的正则引擎,基本是一个[不具备后向引用的Perl版本的正则引擎](http://code.google.com/p/re2/wiki/Syntax)。(RE2支持括号捕获;与Perl的详细区分可以参考[code.google.com/p/re2](http://code.google.com/p/re2)底部的脚注)。csearch采用的命令行参数和grep类似,只是csearch是用golang写的,他并不区分长参数和短参数,也就不能做短参的合并:比如单独接受 -i -n做为参数,但不能合并参数为 -in。这里新增的一个参数 -f 标志控制搜索的文件必须匹配给出的 fileregexp 表达式。 236 | 237 | ``` 238 | $ csearch -f /usr/include DATAKIT 239 | /usr/include/bsm/audit_domain.h:#define BSM_PF_DATAKIT 9 240 | /usr/include/gssapi/gssapi.h:#define GSS_C_AF_DATAKIT 9 241 | /usr/include/sys/socket.h:#define AF_DATAKIT 9 /* datakit protocols */ 242 | /usr/include/sys/socket.h:#define PF_DATAKIT AF_DATAKIT 243 | $ 244 | ``` 245 | 246 | 参数-verbose 会控制csearch打印详细的搜索过程统计数据。另外参数-brute会让搜索绕过三元索引,直接搜索索引库里面记录的每一个候选文件。比如上面例子里面搜索Datakit,在使用三元索引的情况下,会让候选搜索文件变为3个。 247 | 248 | ``` 249 | $ time csearch -verbose -f /usr/include DATAKIT 250 | 2011/12/10 00:23:24 query: "AKI" "ATA" "DAT" "KIT" "TAK" 251 | 2011/12/10 00:23:24 post query identified 3 possible files 252 | /usr/include/bsm/audit_domain.h:#define BSM_PF_DATAKIT 9 253 | /usr/include/gssapi/gssapi.h:#define GSS_C_AF_DATAKIT 9 254 | /usr/include/sys/socket.h:#define AF_DATAKIT 9 /* datakit protocols */ 255 | /usr/include/sys/socket.h:#define PF_DATAKIT AF_DATAKIT 256 | 0.00u 0.00s 0.00r 257 | $ 258 | ``` 259 | 260 | 与之对比的是,如果不适用索引的情况下,我们需要搜索2739个文件: 261 | 262 | ``` 263 | $ time csearch -brute -verbose -f /usr/include DATAKIT 264 | 2011/12/10 00:25:02 post query identified 2739 possible files 265 | /usr/include/bsm/audit_domain.h:#define BSM_PF_DATAKIT 9 266 | /usr/include/gssapi/gssapi.h:#define GSS_C_AF_DATAKIT 9 267 | /usr/include/sys/socket.h:#define AF_DATAKIT 9 /* datakit protocols */ 268 | /usr/include/sys/socket.h:#define PF_DATAKIT AF_DATAKIT 269 | 0.08u 0.03s 0.11r # brute force 270 | $ 271 | ``` 272 | 273 | (笔者这里是基于OS X Lion ,搜索本机的目录/usr/include。对于读者来说这里具体的输出是依赖你自己系统的情况的。) 274 | 275 | 用一个更大的搜索源来举例,比如我们需要在Linux 3.1.3 的内核源码里面来搜索 "hello world",在使用三元索引的情况下,搜索的候选文档个数会从36972缩小到25个,有无索引的搜索时间的差异大约在100倍。 276 | 277 | ``` 278 | $ time csearch -verbose -c 'hello world' 279 | 2011/12/10 00:31:16 query: " wo" "ell" "hel" "llo" "lo " "o w" "orl" "rld" "wor" 280 | 2011/12/10 00:31:16 post query identified 25 possible files 281 | /Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 2 282 | /Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3 283 | /Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1 284 | /Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1 285 | /Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1 286 | /Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1 287 | 0.01u 0.00s 0.01r 288 | $ 289 | 290 | $ time csearch -brute -verbose -h 'hello world' 291 | 2011/12/10 00:31:38 query: " wo" "ell" "hel" "llo" "lo " "o w" "orl" "rld" "wor" 292 | 2011/12/10 00:31:38 post query identified 36972 possible files 293 | /Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 2 294 | /Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3 295 | /Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1 296 | /Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1 297 | /Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1 298 | /Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1 299 | 1.26u 0.42s 1.96r # brute force 300 | $ 301 | ``` 302 | 303 | 对于大小写不明感的搜索来说,虽然索引带来的精确性会降低,速度优化没有前面那么明显,但有无索引的搜索效率依然相差一个数量级: 304 | 305 | ``` 306 | $ time csearch -verbose -i -c 'hello world' 307 | 2011/12/10 00:42:22 query: ("HEL"|"HEl"|"HeL"|"Hel"|"hEL"|"hEl"|"heL"|"hel") 308 | ("ELL"|"ELl"|"ElL"|"Ell"|"eLL"|"eLl"|"elL"|"ell") 309 | ("LLO"|"LLo"|"LlO"|"Llo"|"lLO"|"lLo"|"llO"|"llo") 310 | ("LO "|"Lo "|"lO "|"lo ") ("O W"|"O w"|"o W"|"o w") (" WO"|" Wo"|" wO"|" wo") 311 | ("WOR"|"WOr"|"WoR"|"Wor"|"wOR"|"wOr"|"woR"|"wor") 312 | ("ORL"|"ORl"|"OrL"|"Orl"|"oRL"|"oRl"|"orL"|"orl") 313 | ("RLD"|"RLd"|"RlD"|"Rld"|"rLD"|"rLd"|"rlD"|"rld") 314 | 2011/12/10 00:42:22 post query identified 599 possible files 315 | /Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 3 316 | /Users/rsc/pub/linux-3.1.3/Documentation/java.txt: 1 317 | /Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3 318 | /Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1 319 | /Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1 320 | /Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1 321 | /Users/rsc/pub/linux-3.1.3/arch/powerpc/platforms/powermac/udbg_scc.c: 1 322 | /Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1 323 | /Users/rsc/pub/linux-3.1.3/drivers/net/sfc/selftest.c: 1 324 | /Users/rsc/pub/linux-3.1.3/samples/kdb/kdb_hello.c: 2 325 | 0.07u 0.01s 0.08r 326 | $ 327 | 328 | $ time csearch -brute -verbose -i -c 'hello world' 329 | 2011/12/10 00:42:33 post query identified 36972 possible files 330 | /Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 3 331 | /Users/rsc/pub/linux-3.1.3/Documentation/java.txt: 1 332 | /Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3 333 | /Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1 334 | /Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1 335 | /Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1 336 | /Users/rsc/pub/linux-3.1.3/arch/powerpc/platforms/powermac/udbg_scc.c: 1 337 | /Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1 338 | /Users/rsc/pub/linux-3.1.3/drivers/net/sfc/selftest.c: 1 339 | /Users/rsc/pub/linux-3.1.3/samples/kdb/kdb_hello.c: 2 340 | 1.24u 0.34s 1.59r # brute force 341 | $ 342 | ``` 343 | 344 | 为了尽可能减少I/O,充分利用系统的缓存,csearch会通过调用mmap来映射索引文件到内存,然后直接从系统的文件缓存来读取索引文件内容。这个让csearch,在没有以服务进程的形式出现,在第二次调用csearch,因为缓存的缘故,提供了非常高的性能。 345 | 346 | 这里的工具的源代码可以在[code.google.com/p/codesearch](https://code.google.com/p/codesearch)看到。这篇文件提到的相关技术,主要是 [index/regexp.go](https://code.google.com/p/codesearch/source/browse/index/regexp.go)(正则查找),[index/read.go](https://code.google.com/p/codesearch/source/browse/index/read.go) (索引加载),和 [index/write.go](https://code.google.com/p/codesearch/source/browse/index/write.go) (写索引)。 347 | 348 | 该代码还在regex / match.go中包含一个仅针对搜索工具调整的基于DFA的自定义匹配引擎。这个引擎唯一有用的是实现grep,但是它的速度非常快。 因为它可以调用标准的Go包来解析正则表达式,并将它们归结为基本操作,所以新的匹配器是500行以下的代码。 349 | 350 | 351 | 352 | ## Histroy 353 | 354 | 不管是n-grams,还是与它相应的模式匹配方法都不是什么新东西。[Shannon](https://en.wikipedia.org/wiki/Claude_Shannon "又一个信息领域神一般存在的人") 在1948发表的开创新论文__[[1](https://swtch.com/~rsc/regexp/regexp4.html#1)]已经用它来分析英语文本。甚至有比这个时间点还更古老的线索。Shannon在论文里面引用了 Pratt的1939出版的书 __[[2](https://swtch.com/~rsc/regexp/regexp4.html#2)],可以想象到很有可能Shannon自己很早就已经用n-gram来分析他战时参与的那些加解密相关工作。 355 | 356 | Zobel, Moffat, 和 Sacks-Davis 在1993年发表的论文_Searching Large Lexicons for Partially Specified Terms using Compressed Inverted Files_[[3](https://swtch.com/~rsc/regexp/regexp4.html#3)]描述了怎么在一组单词(词典)中使用n-gram的反向索引,将诸如fro * n的模式匹配到与frozen或frogspawn这些单词。Moffat 和Bell 在1994年出版的经典书籍_**Managing Gigabytes**_[[4](https://swtch.com/~rsc/regexp/regexp4.html#4)]总结了类似的方法。还有Zobel 等其他同学的相关论文提到,_**n-grams**_技术可以应用于比只有*通配符更丰富的那些模式匹配语言上,但Zobel的论文里面只演示一个简单的字符类来做具体的例子: 357 | 358 | > 注意到 n-grams 可以用来支撑其他的模式匹配。例如,当n==3的时候,给定一个模式序列比如 `ab[cd]e`,其中方括号标示在字符d和字符e之间的字符必须是c或者d,这个时候的模式匹配的是字符串必须是同时包含 abc 和 bce的字符串; 或者是同时包含 abd 和 bde 的字符串。 359 | 360 | Zobel的论文里面的描述和我们这里的实作的主要区别是,现在的计算机世界,gigabyte 已经不再被称为大数据量,所以在Zobel里面是在一个单词集合上应用n-gram索引和模式匹配,我们是直接在整个文件集上来应用索引和匹配。前面我们描述的三元组生成规则来分析`ab[cd]e`的话会生成如下的三元查询语句: 361 | 362 | ``` 363 | (abc AND bce) OR (abd AND bde) 364 | ``` 365 | 366 | 如果实现上,应用更积极的简化转换策略的话,把上面的查询优化成如下: 367 | 368 | ``` 369 | (abc OR abd) AND (bce OR bde) 370 | ``` 371 | 372 | 会有更小的运行时内存占用。第二个查询语句和第一个从语义上是等价的,但表达不是很精准,所以简化在这里其实是在内存使用量和精确性上做权衡。 373 | 374 | 375 | 376 | ## Summary 377 | 378 | 通过对每一个文档构建相应的trigrams-index,在大量的这些小文档上,执行正则表达式匹配的运行效率其实是非常高的。这样使用trigrams不是什么新花招,只是对于很多同学来说确实也不是非常普遍认知。 379 | 380 | 无论正则表达式语法在具体实现版本上[表面看起来有多么复杂](http://code.google.com/p/re2/wiki/Syntax),从数学语义上都是可以归并到前面我们分析的那集中基本情形(空字符串,但个字符,重复操作,并操作,或操作)。正是由于正则表达式具备这样的基本归并能力,我们可以用非常高效的算法来实现正则表达式,类似本系列里面的[第一篇文章](https://swtch.com/~rsc/regexp/regexp1.html)和[第三篇文章](https://swtch.com/~rsc/regexp/regexp2.html)[articles](https://swtch.com/~rsc/regexp/regexp3.html)描述的。本文前面的分析里面,我们把一个正则表达式进行三元分析,得到相应的三元查询串(是索引匹配器的核心功能),这个也正是由于正则表达式的这个基本归并能力才可以这么干。 381 | 382 | 如果你错过了当年Google的Code-Search项目的发布,没有体验过,但你又想用正则表达式来快速搜索你本地的代码,那么可以用[这个命令行](https://code.google.com/p/codesearch/)来试试看是否符合你的要求。 383 | 384 | 385 | 386 | ## Acknowledgements 387 | 388 | 感谢所有过去五年一起曾经参与过或者支持过Code-Search项目的Google的筒子们。有非常非常多同学,这里就不一一列出了,但缺少整个项目的顺利上线是咱们整个团队合作努力的结果,而且咱们都收获了很多欢乐。 389 | 390 | 391 | 392 | ## References 393 | 394 | [1] Claude Shannon, “[A Mathematical Theory of Communication](http://cm.bell-labs.com/cm/ms/what/shannonday/shannon1948.pdf),” *Bell System Technical Journal*, July, October 1948. 395 | 396 | [2] Fletcher Pratt, *Secret and Urgent: The Story of Codes and Ciphers*, 1939. Reprinted by Aegean Park Press, 1996. 397 | 398 | [3] Justin Zobel, Alistair Moffat, and Ron Sacks-Davis, “[Searching Large Lexicons for Partially Specified Terms using Compressed Inverted Files,](http://www.vldb.org/conf/1993/P290.PDF)” *Proceedings of the 19th VLDB Conference*, 1993. 399 | 400 | [4] Ian Witten, Alistair Moffat, and Timothy Bell, *Managing Gigabytes: Compressing and Indexing Documents and Images*, 1994. Second Edition, 1999. 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | -------------------------------------------------------------------------------- /Regex/Programming_Techniques_Regular_expression_search_algorithm.thompson1968.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Regex/Programming_Techniques_Regular_expression_search_algorithm.thompson1968.pdf -------------------------------------------------------------------------------- /Regex/regexp_ken_thompson_slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JerryZhou/golang-doc/07fc55f1a1430c58d6f35c49bb39d8e416fb9cf6/Regex/regexp_ken_thompson_slides.pdf -------------------------------------------------------------------------------- /Tech-Reads/Alignment-in-C.md: -------------------------------------------------------------------------------- 1 | ## Alignment in C (Effiziente Programmierung in C)[0] 2 | 3 | ### Introduction 4 | 当代的处理器里面,内存操作是相对耗时的,所以,我们需要仔细关注内存的各种问题。这篇文章我们会重点描述两个层面的问题,第一个层面处理器是怎么处理内存寻址的,第二个层面数据结构体的对其怎么来最大化寻址性能。 5 | 6 | #### Memory Addressing 7 | 现代计算机的寻址通常是基于word-sized-chunk为单元的寻址,一个word-sized-chunk是基本寻址单元,通常这个寻址单元的大小是由处理器的架构决定的。现代处理的寻址单元通常是4字节(32位处理器)或者8字节(64位处理器)。早期的计算机,也是只能以这个word-sized为基本单元,这样也就导致计算机的寻址只能寻址word-sized的整数倍的地址。不过这里也需要被注明的是,现代计算机通常是支持多种word-sizes寻址,也就是寻址单元可以从最小的一个字节到自然word-size,最新的处理器甚至可以处理16字节的chunk寻址,或者直接一条[指令处理]( References 8 | [1] http://software.intel.com/en-us/articles/ 9 | increasing-memory-throughput-with-intel-streaming-simd-extensions-4-intel-sse4-)full-cache-line的单元(比较典型的缓存线大小是64字节)。先现在的UNIX机器上,我们可以通过如下的命令获取当前处理器的word-size: 10 | * `getconf WORD_BIT` 11 | * `getconf LONG_BIT` 12 | 比如在`x86_64`的机器上,`WORD_BIT` 会返回32,`LONG_BIT`会返回64。在没有64-bit扩展的单纯x86机器上`WORD_BIT`和`LONG_BIT`都是32。 13 | 14 | #### Alignment 101 15 | 内存对其对于计算机来说非常重要,前面我们已经说到,对于比较老的处理器他们是不能处理没有对其的数据的,对于现代的处理器会采取一些低效的办法来适配没有对其的数据,只有最近的个别计算机具备能力无差别的加载字节对其数据和非对其数据[misaligned-data](http://www.agner.org/optimize/blog/read.php?i=142&v=t)。下面我们有图来形象的表述字节对其是怎么回事: 16 | |0x00000000|0x00000004|0x00000008|0x00000012| 17 | | - - - - | - - - - | - - - - | - - - - | 18 | Figure 1: 32位计算机上的4个word-sized 内存单元 19 | 比如要存储一个4字节的int(####int)到咱们内存里面,这个时候不需要做什么特殊的工作,因为在32位计算机上本身就是4字节对其,这样刚好把int放到一个word-sized单元里面,比如我们把int放到图1里面后的内存如下会如下所示: 20 | |0x00000000|0x00000004|0x00000008|0x00000012| 21 | |####(int) | - - - - | - - - - | - - - - | 22 | Firgure 2: 内存里面存放了一个4字节的int 23 | 如果我们决定要放一个char(#char), 一个short(##short), 和一个int(####int)到我们的内存里面,如果完全不考虑对其,只是把他无脑放到内存,那么内存的布局会是如下: 24 | |0x00000000 |0x00000004 |0x00000008|0x00000012| 25 | |#(char)##(short)#|###(int) - | - - - - | - - - - | 26 | Firgure 3: 没有内存对其的示范 27 | 如果按照上面的内存结构,我们加载一个int到处理器上面,需要进行两次内存寻址然后加上一些bitshifint操作才能完成这个,为了让这个加载操作更加高效,计算机科学家想处理一个主意就是在内存里面加上一些合适的padding,让数据与处理器的word-size对其。在我们前面的例子里面,如果我们在第一个字节(char)后面加上一个padding, 那么后面的数据就都进行合适的内存对其,布局如下: 28 | |0x00000000 |0x00000004|0x00000008|0x00000012| 29 | |#(char)-##(short)|####(int) | - - - - | - - - - | 30 | Firgure 4: 通过在合适的地方加入padding达到内存对其的示例 31 | 上图中表达的其实是一种naturally-aligned, 这个行为是编译器自动为了达到内存对其会在相应的地方加入padding的一种特性,当然我们也可以显式的关闭这个特性。 32 | 33 | #### Consequences of Misalignment 34 | 数据不对其问题导致的后果在不同的处理器上也不完全一样。在一些RISC、ARM、MIPS处理器上,如果我们进行misaligned-address访问会导致一个alignment-fault。对于一些个别处理器,比如SDP,通常是完全不支持对misaligned-address的访问的。当然大部分现代处理器都是支持对misaligned-address进行访问的,只是需要至少花费两倍的时间来处理这种不对其数据。对于SSE指令来说,指令本身是要求数据必须是对其的,如果给的是misaligned-address数据,那么指令的行为就会变得不可定义。 35 | 36 | ### Data Structure Alignment 37 | 这一小节,我们会通过一系列的小例子,介绍C语言里面结构体是怎么进行字节对其的。 38 | 39 | #### Example With Structs 40 | 下面的结构体其实是Figure4里面的一个真实反映: 41 | ``` 42 | struct Foo { 43 | char x; /* 1 byte */ 44 | short y; /* 2 bytes */ 45 | int z; /* 4 bytes */ 46 | }; 47 | ``` 48 | Listing 1: 需要padding进行对其的一个结构体定义 49 | 这个结构体的大小,如果没有进行对其的情况下应该是 1byte + 2 bytes + 4 bytes = 7 bytes,当然聪慧如读者的你肯定知道因为编译器会对结构体进行内存对其,上面的结构体实际的大小会是8字节。下面我们阐述结构体对齐的原则: 50 | **一个结构体总是会对齐到最大的类型** 51 | 接下来我们根据上述的原则,我们看另外一个内存不友好的结构体定义: 52 | ``` 53 | struct Foo { 54 | char x; /* 1 byte */ 55 | double y; /* 8 bytes */ 56 | char z; /* 1 byte */ 57 | }; 58 | ``` 59 | Listing 2: 一个对齐不友好的结构体定义 60 | 这个结构体的没有对其的情况下应该是 1 byte + 8 bytes + 1 bytes. 然后他对其后的大小是 24 bytes。 61 | 我们可以通过对成员重新排列来优化这个结构体的内存占用: 62 | ``` 63 | struct Foo { 64 | char x; /* 1 byte */ 65 | char z; /* 1 byte */ 66 | double y; /* 8 bytes*/ 67 | }; 68 | ``` 69 | Listing 3: 一个通过重排来优化结构体内存占用的例子 70 | 在保持内存对其的情况下,现在这个结构体只暂用16个字节了。 71 | 72 | #### Padding In The Real World 73 | 前面的段落,读者应该已经注意到,我们需要人工花一些精力关注C结构体的定义,当然在实际的开发过程中,现在的编译器都会根据当前的处理器架构自动处理padding问题,有一些编译器也会提供一些编译选项,比如`-Wpadded`会打印一些帮助信息,让开发者优化相关padding,达到高效的内存布局,比如如下的clang的例子: 74 | ``` 75 | clang -Wpadded -o example1 example1.c 76 | example1.c:5:11: warning: padding struct 77 | 'struct Foo' with 1 byte to align 'y' [-Wpadded] short y; 78 | 79 | 1 warning generated. 80 | ``` 81 | Listing 4: clang 加参数 -Wpadded 产生的警告的例子 82 | 当如,如果你想关闭编译器的padding特性也是可以的,有如下的一些途径,比如方一个`__attribute__((packed))`在结构体定义后面,放一个 `#pragma pack (1)`在结构体定义前面,或者通过加一个编译器参数`-fpack-struct`。 这里需要特别提醒的是,如果进行上面的操作,那么这里会让应用程序产生一个[ABI](https://zh.wikipedia.org/wiki/%E5%BA%94%E7%94%A8%E4%BA%8C%E8%BF%9B%E5%88%B6%E6%8E%A5%E5%8F%A3)问题,我们需要在运行时检查一个结构体的sizeof信息,确保传入的参数是合法的。 83 | 84 | #### Performance Implications 85 | 对于开发者来说,内存对齐带来的效率提升我们需要仔细的弄清楚其中的原委,是不是值得开发者花精力来做,或者我们可以完全忽略内存对其,因为效率提升不明显,我们通过购买更快的处理器就好了。当然要弄清楚其中的详细的情况,我们需要根据具体情况来做一些区分,比如内核开发,驱动开发,或者内存极其有限的系统上,或者在一些非常,非常古老的编译器上,这些情景与之关联的情况也都是不一样的。 86 | 如果我们想知道什么时候我们可以不用管内存对其问题,那么我们先来弄清楚他对新能的影响到底是怎么样的,为了测试性能,我们这里用两个同样的结构体,一个是内存对其的,另外一个是不对其的。 87 | ``` 88 | struct Foo { 89 | char x; 90 | short y; 91 | int z; 92 | }; 93 | 94 | struct Foo foo; 95 | clock_gettime(CLOCK, &start); 96 | for (unsigned long i = 0; i < RUNS; ++i) { 97 | foo.z = 1; 98 | foo.z += 1; 99 | } 100 | clock_gettime(CLOCK, &end); 101 | ``` 102 | Listing 5: 字节对其的结构体进行性能压力测试 103 | 104 | ``` 105 | struct Bar { 106 | char x; 107 | short y; 108 | int z; 109 | } __attribute__((packed)); 110 | 111 | struct Bar bar; 112 | 113 | clock_gettime(CLOCK, &start); 114 | for (unsigned long i = 0; i < RUNS; ++i) { 115 | bar.z = 1; 116 | bar.z += 1; 117 | } 118 | clock_gettime(CLOCK, &end); 119 | ``` 120 | Listing 6: Misaligned struct for the benchmark 121 | 压力测试的例子我们是用 gcc(GCC)4.8.2 20131219(prerelease)版本,通过如下的编译指令`gcc -DRUNS=400000000 -DCLOCK=CLOCK_MONOTONIC -std=gnu99 -O0`编译,然后运行在一台Intel Core i7-2670QM CPU的 Linux3.12.5上,性能测试的结果如下: 122 | | aligned runtime: | unaligned runtime: | 123 | | 9.504220399 s | 9.491816620 s | 124 | 我们会发现,两者的时间基本是一致的,这个测试的结果也已经在[参考文献]()里面提到了。在最近的Intel的处理器上,对于misaligned-memory的访问已经基本没有明显的性能影响,这个对于最新的其他处理器也基本是一致的,接下来我们继续在相对旧的处理器上进行试验。 125 | 我们在 Raspberry Pi的处理器上继续前面的试验,所以编译器,操作系统等都一样,只是我们这里把性能测试循环数减少到原来的十分之一,然后我们得到如下的结果: 126 | |aligned runtime: | unaligned runtime: | 127 | | 12.174631568s | 26.453561832s | 128 | 上面的结果其实更符合我们的预期,我们前面也提到过访问不对齐内容会有一个两倍的寻址加上一些bitshifting操作,这里的结果基本印证了我们前面文章里面提到的寻址模型。 129 | 130 | #### SSE 131 | 以前,当使用SIMD指令的时候,比如SSE指令,我们通常会被要求代码要每一条SSE指令都要求的16字节边界对齐, 这里说的其实不仅仅要求数据结构要参照这个对齐,还要求指令stack本身也要参照这个进行边界对齐。对于交叉编译32位平台代码者确实会是一个[问题](http://www.peterstock.co.uk/games/mingw_sse/),因为他本身也不知道应该进行对齐。在后面的小节里面,我们也会具体开到如果一个program调用一个库函数后,如果program和库本身他们的alignment是不一致的,这种情况下回发生什么事情。通常这种情况会导致崩溃。现在`x86_64`已经是主流的处理器,并且他的默认对其是16字节,这样导致的问题也就少了,但对于老的32位处理器来说,还是一个相对普遍的问题。 132 | 现在很多编译器即使在32位处理器上,当使用类似`__m128`等SIMD类的指令的时候,他们也自动把字节对其到16字节。更现代的编译器,他们甚至根本不需要开发者去显式的通过`__m128`等手段告诉编译器,编译器它自动就会对一些类型的循环向量化,让指令在边界处于对其状态,虽然这个时候的对其是没有神马显式的依据。 133 | 134 | ### Stack Alignment 135 | 前面咱们已经提到了不同的平台有不同的stack-alignment,读者需要知道主流平台的对其规则: 136 | 1. Linux : 看情况,以前的是 4字节对齐,现代的是16字节对齐 137 | 2. Windows : 4 字节对齐 138 | 3. OSX: 16字节对齐 139 | 清晰上面的stack-alignment非常重要,因为混合的stack-alignment通常会导致非常严重的问题。 140 | 141 | 考虑如下的情况: 142 | ``` 143 | void foo() { 144 | struct MyType bar; 145 | } 146 | ``` 147 | 上述的函数以及其中的结构体看起来都非常简单,考虑如下的情况,如果上面的函数是用16字节对齐编译生成的库函数,然后我们在4字节对其的`program`里面来调用这个函数会导致什么呢?当然这个行为会导致`stack-corruption`,因为栈指针端了12个字节或者说离真正的指令还有12个字节。 148 | 在现实情况中,上面的问题很少发生,如果真的发生了,这种崩溃也很难查证,如果开发者对字节对其的这个问题没有意思的话,就更不可能发现其中的问题了。这个问题再这里抛出的一个点是,提醒大家在做一些跨架构调用的地方需要特别注意,我们需要做`stack-realignment`. 在gcc或者clang里面,我们可以通过在函数上面加上属性`__attribute__((force_align_arg_pointer))`或者用编译器参数`-mstackrealign`来一次性应用到所有函数。虽然这个可以解决这个问题,但读者也要知道这里面其实是对函数加了一层调用路由的`realignment intro/outro`,这一层路由会确保函数的`stack-pointer`会按照调用方预期的传递。 149 | 150 | ### Conclusion 151 | 结论,现在的编译器在通过padding的方式尽力优化数据结构, 提升效率,这种效率提升是以牺牲内存占用为代价的。但是通过前面我们的性能测试的例子,我们也关注到了,这种牺牲内存为代价或者潜在的性能提升的方法的收益甚微的。当然在一些老的处理器上字节对其带来的性能提升还是非常明显的。 152 | 现在对于开发者来说还需要关注到的字节对齐问题,就是我们应该尽可能优化数据结构体,合理排序成员,尽可能少的浪费内存,生成内存紧凑的结构体。 153 | 154 | 155 | ### Reference 156 | [0] https://wr.informatik.uni-hamburg.de/_media/teaching/wintersemester_2013_2014/epc-14-haase-svenhendrik-alignmentinc-paper.pdf 157 | [1] http://software.intel.com/en-us/articles/increasing-memory-throughput-with-intel-streaming-simd-extensions-4-intel-sse4- 158 | [2] http://www.agner.org/optimize/blog/read.php?i=142&v=t 159 | [3] http://www.peterstock.co.uk/games/mingw_sse/ 160 | 161 | ### 其他文献 162 | https://msdn.microsoft.com/zh-cn/library/83ythb65.aspx 163 | https://en.wikipedia.org/wiki/Data_structure_alignment 164 | http://www.catb.org/esr/structure-packing/ 165 | http://www.cnblogs.com/clover-toeic/p/3853132.html 166 | http://www.cnblogs.com/Dageking/archive/2013/03/11/2954394.html 167 | 168 | 169 | -------------------------------------------------------------------------------- /Tech-Reads/Cache-Memory-in-Wiki.md: -------------------------------------------------------------------------------- 1 | ## Cache Memory [Wiki](https://en.wikipedia.org/wiki/Cache_memory) 2 | 3 | 在数据处理系统(常规意义上的[计算机](https://en.wikipedia.org/wiki/Computers)), 我们说的cache-memory 或者是 memory-cache (或者常规意义上的 [CPU](https://en.wikipedia.org/wiki/Central_processing_unit) 各级cache(`*`)) 是指一个快速存取能力的相对小的内存区域,这个内存区域对软件是不可见的,这块内存区域完全由硬件自己来负责管理,这块区域会存储[最近](https://en.wikipedia.org/wiki/Cache_replacement_policies#Examples "MRU")使用到的[主内存](https://en.wikipedia.org/wiki/Computer_data_storage#Primary_storage "MM")数据。cache-memory 的主要作用是用来__加速__MM数据的访问,并且用于在多核系统上共享内存,__减少[system-bus](https://en.wikipedia.org/wiki/System_bus "system-bus")和MM的通讯瓶颈__,通常system-bus和MM访问会是多核系统的一个主要性能瓶颈。 4 | Cache-Memory 会使用一项称为[SRAM](https://en.wikipedia.org/wiki/Static_random-access_memory "SRAM") (static random-access [memory cells](https://en.wikipedia.org/wiki/Memory_cell_(binary)))的技术,让Cache-Memory直接与处理器连接起来,这个技术是与MM的访问技术[DRAM](https://en.wikipedia.org/wiki/Dynamic_random-access_memory) 想对应的。 5 | 6 | 名词"cache"来自法语的(发音 /ˈkæʃ/cash/ (deprecated template)) and means "隐藏"。 7 | 这个名词根据上下文的不同会有多种不同的含义。比如: [disk-cache](https://en.wikipedia.org/wiki/Disk_buffer), [TLB](https://en.wikipedia.org/wiki/Translation_lookaside_buffer) (translation lookaside buffer) (Page Table cache), branch prediction cache, branch histroy table, Branch Address Cache, trace cache, 这些都是硬件的物理内存。另外也有很多是由软件进行管理的,比如用来存储MM内存空间的临时数据, 比如[disk-cache](https://en.wikipedia.org/wiki/Page_cache "page-cache"), system-cache, application cache, database cache, web cache, [DNS cache](https://en.wikipedia.org/wiki/Name_server#Caching_name_server), browser cache, router cache, 等等。前面提到的这些cache,有些只是缓冲区"buffers", 他们只是支持顺序访问(__sequential-access__)的无关联的内存(__non-associative-memory__),与最开始定义的那种典型的通过关联内存(associative-memory)机制,具备随机访问(__random-accesses__)能力的"memory-to-cache"不一样. 其实这里我们主要就是区分缓存(cache)和缓冲(buffer)两种内存访问机制。 8 | 9 | - - - 10 | 名词 "cache memory" 或者 "memory cache" 如无特别说明我们简称为 "cache",通常他指的就是存储了 [main-memory](https://en.wikipedia.org/wiki/Main_memory "MM")上马上要被处理器用到的,当前执行程序的指令"Instructions"以及相关数据"Data"的一块隐藏的内存区块。 11 | 注意:"CPU cache"这个名词在学术和工业领域都是用的相对少的表达方式,在美国的大量文献里面,名词"CPU cache"的使用率是 2%, 其中 "Cache Memory"的使用率是 83%, "Memory Cache"的使用率是 15%。 12 | - - - 13 | 14 | ### Cache general definition 15 | **"Cache" 是一个用来存储临时数据的内存区块,这个内存区块对上层使用者来说是透明的,他的主要目的是为了提供快速复用** 16 | 17 | ### Functional principles of the cache memory 18 | Cache-Memory 操作基于两个主要的"principles of locality"[Locality of reference](https://en.wikipedia.org/wiki/Locality_of_reference) 19 | - Temporal locality 20 | - Spatial locality 21 | * _Temporal locality_ 22 | - **最近用到的数据,很有可能还会被再次使用** 23 | Cache 存储的是MM里面最近最长被使用到的子集。当数据从MM里面加载到Cache里面的时候,如果处理器请求同样地址的数据,会直接从Cache里面读取。因为在应用程序里面,遍历,循环等是很常见的,这些操作通常是对同一份数据或者变量进行操作,这种情况下Cache就提供很高的性能表现。 24 | * _Spatial locality_ 25 | - **如果一个数据被用到了,那么这份数据周边的数据很有可能也会被后续的操作用到** 26 | MM里面的指令和数据是以固定大小的块读取到Cache里面的,通常把这个固定大小叫做cache-lines. 一个Cache-line 的大小一般是4字节到512字节,所以当从MM里面读取需要处理的数据的时候(4/8字节)的时候,通常会把数据周边的大小为cache-line的数据一次性读取进来,放到一个cache-entry里面。 27 | 大部分程序都是高度线性有效的,下一个指令通常来自于邻近的内存区域。结构化的数据也是一样具备高度线性特性的,它们通常是被连续存储的(比如字符串,数组等)。 28 | 比较大的Cache-line 的大小会增强_spatial locality_的命中有效性,但同时在无效命中时候也增加了换行的成本,详细可以参考[Replacement policy](https://en.wikipedia.org/wiki/Cache_memory#Replacement_policy)。 29 | (Note - 名词 "data" 通常会当做 "cache-line"或者"cache block"的简写) 30 | 31 | ### Cache efficiency 32 | 我们通常用命中率"Hit Rate"来评估缓存的效率。命中率是一个命中百分比,说的是在Cache里面发现数据的次数与总的数据访问次数的百分比。与"Hit"对应的是"Miss"。 33 | Cache-efficiency依赖如下几个因素:缓存的总大小,cache-line的大小,缓存的类型以及缓存的具体架构,以及当前执行体的类型。一个号的缓存效率,通常需要80%到95%的命中率。 34 | 35 | ### Cache organization and structure 36 | Cache通常是如下的三种基本结构和两种基本类型: 37 | - Fully Associative cache 38 | - Direct Mapped cache 39 | - Set Associative cache 40 | 类型: 41 | - 指令缓存(Instruction code) 42 | - 数据缓存(Data cache) 43 | * Stack cache 44 | - 这是一种特殊的"Data cache", 我们叫 *[栈缓存](https://en.wikipedia.org/wiki/Cache_memory#Stack_cache)* 45 | 46 | #### Fully associative cache 47 | ![Any](https://upload.wikimedia.org/wikipedia/commons/9/94/Fully_Associative_Cache.svg "Fully Associative Cache") 48 | memory-block 可以存储在cache里面的任何位置,这种cache称为"fully associative",因为存储在cache里面会给每一个data存储相应的"full address"。 49 | 缓存会分成两个数组:目录*Directory*和数据*Data*。其中*Directory*也会被分为两个成员:*data-attribute-bits*或者叫*State*,和*ADD(data address)*。 50 | *Data-attribute*包括一个"Valid bit"和其他几个标志:*Modified bit(M)*, *Shared bit(S)*和其他几个[状态标志位](https://en.wikipedia.org/wiki/Cache_memory#Cache_states),另外还会包括保护标志位*"protection bits"*,比如"Supervisor/User" 和"Write protection"写保护。 51 | 在"fully-associative cache"里面,我们会存储每一个block的地址*full address*绝对地址。当需要从cache里面读取一个数据的时候,我们会比对所有存储在"Directory"目录字段里面的绝对地址,如果匹配到了,我们就称为一次命中,相应的"Data"就会直接从cache的"Data"读取。如果没有命中,就会从MM里面读取,读取到相应数据的时候,也会把相应的数据存储到cache里面,这个时候会根据[Replacement policy](https://en.wikipedia.org/wiki/Cache_memory#Replacement_policy)选择一个cache-line进行复写替换。 52 | "fully-associative cache"的效率非常高,从MM读取的数据可以存储到cache的任意一个entry,但他的实现电路是比较昂贵的,每一个cache里面的entry都需要一个独立的通道进行并行的地址匹配和存取。因此,通常这种类型的缓存一般都不大,而且不做为通用缓存,只是针对一些特定的用途,比如[TLB](https://en.wikipedia.org/wiki/Translation_lookaside_buffer),通常这种类型的缓存不会作为现代的处理器缓存,现代处理器通常用"direct-mapped"和"set-associative"。 53 | 54 | #### Direct mapped cache 55 | ![In](https://upload.wikimedia.org/wikipedia/commons/a/a2/Direct_Mapped_Cache.svg "Direct mapped cache") 56 | "direct-mapped"或者叫"single set-associacive cache"里面,任意的一个内存块只会被存储到一个特定的cache-entry上。用来存储的cache-entry可以从memory-block的地址直接推导计算出来,这个也是这个类型的cache名字的由来。 57 | 因为Cache 的大小肯定会比MM小,所以MM的内存地址需要某种方式映射到cache-space。所有的内存数据都在一个相对小的地址空间lower-space里面进行操作。有很多类似这样的映射算法,我们叫[hash coding](https://en.wikipedia.org/wiki/Hash_function)或者就叫"hashing"。常用的Cache-space的寻址方案是:用地址的一部分来寻址,或者更精确的说是,用一个称为*Index*的字段,这个字段是排除掉偏移量[offset](https://en.wikipedia.org/wiki/Offset_(computer_science)),地址的[Least Significant Bits](https://en.wikipedia.org/wiki/Least_significant_bit "LSB")当做"Index",如图"Cache addressing"所示。其中[offset](https://en.wikipedia.org/wiki/Offset_(computer_science) "line offset")用来在一个"cache-line"内部进行byte-level的级别进行寻址的。比如在一个32位的地址空间上,有一个4MB的缓存,缓存的cache-line大小是256 B, 其中"Index"字段LBS(less significant bits)(8-21位)用来选择相应的cache-entry。这种线性寻址方案,在[Demand-paging](https://en.wikipedia.org/wiki/Demand_paging) [Virtual memory](https://en.wikipedia.org/wiki/Virtual_memory)用来把全地址空间的内存映射到缓存里面。 58 | ![Cache Addressing](https://upload.wikimedia.org/wikipedia/commons/6/6e/Cache_Addressing.svg "Cache Addressing") 59 | *注意- 另外一种 hash coding 算法有时候也用来做 TLB, "bit XORing", 会对地址进行与一个特定的一对位字段做[XOR](https://en.wikipedia.org/wiki/XOR)运算, 这种算法会产生一个伪随机的寻址* 60 | 映射到同一个*Index*的数据,我们把这些数据较"synonyms",他们会被存储到同一个entry里面,所以这种情况下一次只有一个"synonym"可以被存储到相应的entry里面(这种情况较"synonym"冲突),不同的"Synonyms"他们的[MSB](https://en.wikipedia.org/wiki/Most_significant_bit "Most Significant Bits")地址字段是不一样的。 61 | 为了有效区分不同的"synonyms",字段 MSB(named address Tag)会被存储到 cache的目录字段里面,也就是前面例子中的(22-31)位。当从Cache里面读取数据的时候,相应的MSB会信息比较,这个跟"Fully Associative"里面一样,如果一直就是命中了,就从Cache完成读取,否则需要从MM里面读取。 62 | 在前面我们描述的寻址方案里面,两个"synonyms"之间的距离一定是"cache-size"的整数倍, 如果增大"cache size"的大小,那么两个"synonyms"之间的距离就变大了,那么"synonym"之间冲突的几率也就变小了。 63 | 在这种类型的cache里面,我们只会同事选择操作一个cache-line,所以也就只需要一个comparator电路。 64 | 为了最小化"synonym"冲突问题,我们可以用一个"Associative cache"集合来优化这种情况。 65 | 66 | #### Set associative cache 67 | ![Set Associative Cache](https://upload.wikimedia.org/wikipedia/commons/b/bf/Set_Associative_Cache.svg "Set Associative Cache") 68 | "Set associative cache"或者叫"multi-way-direct-mapped"是一种结合前面两种方案的综合方案,用来尽可能的降低"synonyms"冲突。 69 | 这类Cache由一组采用完全同样寻址算法的"Direct Mapped cache"组成,所以对于一个cache-entry, 可以存储多个"synonym",根据[Replacement policy](https://en.wikipedia.org/wiki/Cache_memory#Replacement_policy)算法,每一个"synonym"可以存储到组内的任意一个"direct maped cache"的entry上。 70 | 通常会有2组8组,16组,乃至48组[AMD Athlon](https://en.wikipedia.org/wiki/Athlon)并发的"direct-mapped-cache",对于[IBM POWER3](https://en.wikipedia.org/wiki/POWER3)甚至有128组,具体依赖于是什么类型的cache(指令缓存还是数据缓存)。 71 | 在"Set Associative cache"里面,每一个相应的"direct-mapped-cache"都需要一个寻址比较器。 72 | *注意:一个单独的 "Direct Mapped cache" 可以理解为只有一组并发的"Set Associative cache"; 一个"Fully Associative cache" 可以理解为n路并发的"Set Associative cache", 只是每一个"Direct Mapped cache"只有一个cache-entry* 73 | 根据采用的[Replacement policy](https://en.wikipedia.org/wiki/Cache_memory#Replacement_policy), "Directory"字段里面有可能需要包含一个"Replace bits"来控制候选的替换cache-line。 74 | 75 | #### Replacement policy 76 | 只要存在多个entry用来存储数据,比如在"Fully Associative cache"和"Set Associative cache", 我们就需要有替换策略和算法来控制具体的换入换出行为。 77 | 大体有如下的几个主流的替换策略: 78 | - [LRU](https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU) -- [Least Recently Used](https://en.wikipedia.org/wiki/Cache_replacement_policies) 79 | - [FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)) -- First-In First-Out 80 | - [LFU](https://en.wikipedia.org/wiki/Least_frequently_used) -- [Least Frequently Used](https://en.wikipedia.org/wiki/Least_frequently_used) 81 | - [Round-robin](https://en.wikipedia.org/wiki/Round-robin_scheduling) 82 | - [Random](https://en.wikipedia.org/wiki/Randomness) 83 | 84 | * LRU 85 | - 通常由于"Set Associative cache" 86 | - "Set"里面的每一个entry, 都会关联一个*age counter*, *age counter*的最大值就是"Set"的组数,优先级最高的entry就是其中拥有最大*age counter*的entry,每一次访问一个cache-line的时候,他的*age counter*就会被设置为0,同时比当前entry小的entry的*age counter*都会被加一。比如有一个4路的"Set Associative cache",那么*age counter*的最大值需要用2bit来存储,比如当前4路的*age counter*值为 3-0-2-1 (从Set-1到Set-4),如果产生一次Set-3的访问,那么*age counter*就会变成 3-1-0-2,那么现在的更新优先级的顺序就会从 1-3-4-2 变成 1-4-2-3, Set-3 就已经变成优先级最低的了。 87 | * FIFO 88 | - 用于"Set Associative cache" 89 | - 跟LRU算法很类似,唯一的区别就是计算器只会在更新行为发生的时候,拥有最大值的cache-line会被选中为当前的替换cache-line,并且他的计数器会被清零,其他的cache-line的计算机都会加一。 90 | * LFU 91 | - 更有效的替换算法,但是代价更大,实际中一般不使用。 92 | * Round-robin 93 | - 用于 "Full Associative cache" 94 | - 用一个指针来选择下一个被替换的cache-line, 每次循环递增指针,指向下一个cache-line, 这个实现方案里面只需要一个指针就可以了。 95 | - 实现成本很小 96 | * Random 97 | - 用于 "Full Associative cache" 98 | - 跟Round-robin类似,只需要一个指针,但是这个指针在每次访问或者时钟周期的时候都会来更新指针 99 | - 实现成本很小 100 | 101 | #### Types of cache 102 | ##### Instruction and data cache 103 | 有两类信息会存储在MM里面,指令也叫code, 另外一个数据也叫操作数。 104 | * "Unified" cache 两者都可以存储 105 | * 在 "Separated" cache 里面,指令和数据会存储在不同的cache上,其中"I-Cache"是用来缓存指令的,"D-Cache"是用来缓存数据的。 106 | 对于分开的这种模式,主要有三个优势: 107 | 1. 两种不同结构类型的数据他们之间的干扰会减少 108 | - 对于指令来说他的线性访问特征更加明显,而对于操作数来说更具随机性;而且对于分开的这种结构,我们可以对他们采用不同的cache实现,通常我们会用2路或者4到8路的"Associative cache"来承载指令缓存,而用4到16路,或者更多的128路"Associative cache"来承载操作数缓存。 109 | 2. 允许采用["Harvard architecture"](https://en.wikipedia.org/wiki/Harvard_architecture)实现, 这种类型的结构会增大处理器的并行执行的能力,因为根据前面执行的具体操作数以及相关的指令,这种结构允许并发的预加载。 110 | 3. 实现多处理器系统上的,_snoopy-activity_和_processor-activity_在"I-Cache"无干扰。_Snoopy activity_通常只会在"D-Cache"上出现,详情可以参考文章下面的[Write policy](https://en.wikipedia.org/wiki/Cache_memory#Write_policy) 和[Snoopy and processor activity interference](https://en.wikipedia.org/wiki/Cache_memory#Snoopy_and_processor_activity_interference)。 111 | ##### Non-blocking cache 112 | 大部分cache一次只能处理一个请求。当向cache查询的时候,如果出现miss行为,那么cache必须等着从MM加载数据,这个请求就会阻塞则,知道加载完成,这其中也不能再处理其他请求。对于一个_non-blocking(或者叫lock-free)_cache,是有能力在等待从MM加载数据的过程中处理其他请求的。 113 | #### Write policy 114 | cache的写策略决定了cache怎么处理缓存中的内存区块怎么回写到MM里面。通常只有"D-Cache"会升级到写的问题,因为指令通常意义是不能self-modifying, 在确实出现self-modifying的情况下,通常软件会采用弃用Cache的策略,直接在MM里面进行操作(比如[AMD64](https://en.wikipedia.org/wiki/AMD64)的 Self-Modifying)。 115 | 有两类基础的写策略: 116 | * 直接写 Write Through 117 | * 延后写 Write-Back (or Copy Back) 118 | ##### Write through 119 | - 数据会立刻在cache和MM同时写,或者先在cache里面写,然后紧接着立刻在MM写. 120 | ##### Write-back (or copy back) 121 | - 数据只会在Cache 里面写,只有在必要的时候才回写到Mm里面,比如发生cache-line替换的时候,或者被加载到其他cache的时候。这种策略会减少总线和内存的冲突,因为cache-line的更新只会发生在cache本身,不升级MM的更新,但我们会在cache-line的Directory里面做"D"和"M"(Dirty 或者 Modified)标记,详情见下面的[缓存状态](https://en.wikipedia.org/wiki/Cache_memory#Cache_states)。 122 | 123 | 根据write的时候如果出现miss行为也有两种不同的处理策略: 124 | ##### Write allocate 125 | 在"miss"的时候发生Write allocate, 也叫 "Fetch-on-write"或者*RWITM*(Read With Intent To Modify) 或者 "Read for Write" 126 | * 在写行为的时候发生miss, 首先会从MM加载cache-line, 或者在[Cache Intervention](https://en.wikipedia.org/wiki/Cache_memory#Snoopy_coherency_operations)的时候从其他的cache加载cache-line,然后就在加载好的cache-line里面进行写入新的数据,这个时候会发生cache-line的更新操作,更具写的偏移量,大小等对cache-line进行局部更新。 127 | ##### Write no-allocate (也叫 no-Write allocate) 128 | 数据被直接绕过cache直接写到MM上。 129 | * Write-allocate 通常是用于 *write-back* 策略, 而 Wite-no-allocate 通常是被用于 *write-through* 130 | 131 | ### Cache levels 132 | 在一个系统里面,通常不只一个cache可以被使用,cache会被分层,通常会被分解到4层,从L1到L4 或者更多。 133 | 大的cache会提高命中率,但是访问延时也会增大,而多层的cache方案运行高命中率的同时,提供更快的访问速度。 134 | 分层的cache方案,通常会从比较小的L1-cache开始查询,如果命中,处理器就直接访问,如果miss,则访问下一个更大的级别的L2-cache,以此类推。 135 | 随着技术的发展,运行在处理芯片内部直接放一个L1-cache,内部的cache会提供比外边cache快得多的访问速度,但是命中率会相对低一些,内部的cache的大小通常也是不大,从8KB到64KB。为了提高命中率,加大缓存区的大小,一个更大的L2-cache也会放到处理器内部,L2的大小通常从64KB到8MB不等,当然也有一些L2-Cache是挂在在处理器外部的,对于挂在外部cache的芯片莱索,也是可以提供一个更大的L3-cache,这种cache通常会是4MB-256MB。对于多核系统来说,L3-Cache可以放在 [McM](https://en.wikipedia.org/wiki/Multi-Chip_Module "Multi-Chip Module")模块上(eg. 比如[POWER4](https://en.wikipedia.org/wiki/POWER4)处理器) 136 | 通常L1是"Set Associative cache", 并且指令和数据分开的;L2 可以是"unified",也可以是"separated",可以是"Direct Mapped cache" 也可以是 "Set Associative cache"。L3和L2类似。 137 | #### Multi-level cache hierarchy function 138 | ![Multi-level cache Hierarchy](https://upload.wikimedia.org/wikipedia/commons/9/93/Multi-level_Cache_Hierarchy_diagram.svg "Multi-level cache Hierarchy") 139 | - L1 --> 处理器芯片内部,快速存储 140 | - 大小是 8KB - 64KB 141 | - L2 --> 增加缓存的总的大小 "for data coherency" 142 | - "Snoopy" 基于bus的多处理器缓存,在多个核之间共享 143 | - 可以在处理器芯片上,也可以外部的 144 | - 从64KB - 8MB 不等的大小 145 | - L3 --> 增加缓存的总的大小, 作为L2的[兜底缓存](https://en.wikipedia.org/wiki/Cache_memory#Inclusive_and_exclusive_cache "Victim cache")存在 146 | - 从 4MB - 128MB 147 | - 如果L2位于芯片内部,或者没有L2缓存的时候,会使用L3 148 | - L4 --> [Remote cache](https://en.wikipedia.org/wiki/Cache_memory#Remote_cache) 或者 [cc-NUMA Clastering System](https://en.wikipedia.org/wiki/Cache_memory#cc-NUMA_.E2.80.93_Clustering_Systems) 149 | - 大小大于L3(512MB或者更大) 依赖于节点数量 150 | - 有的时候做作为L3的兜底缓存,放在[GPU](https://en.wikipedia.org/wiki/Graphics_processing_unit)上 151 | *注意: 上一个级别的cache-line的大小比小一个级别的cache-line 大小要大或者相等* 152 | #### Inclusive and exclusive cache 153 | 154 | ### Shared cache 155 | 156 | ### Multi-bank and multi-ported cache 157 | #### Multi-bank cache 158 | ##### Linear addressing 159 | ##### Cache interleaving 160 | #### Multi-ported cache 161 | #### Multiple cache copies 162 | #### Virtual multi-porting cache 163 | #### Hybrid solution 164 | 165 | ### Cache coherency 166 | #### Snoopy coherency protocol 167 | ##### SMP - symmetric multiprocessor systems 168 | ##### Cache states 169 | ##### Various coherency protocols 170 | ##### Snoopy coherency operations 171 | ###### Bus transactions 172 | ###### Data characteristics 173 | ###### Cache operations 174 | ##### Coherency protocols 175 | ###### MESI protocol 176 | ###### MOESI protocol 177 | ###### Illinois protocol 178 | ###### Write-once (or write-first) protocol 179 | ###### Bull HN ISI protocol 180 | ###### Synapse protocol 181 | ###### Berkeley protocol 182 | ###### Firefly (DEC) protocol 183 | ###### Dragon (Xerox) protocol 184 | ###### MERSI (IBM) / MESIF (Intel) protocol 185 | ###### MESI vs MOESI 186 | ###### RT-MESI protocol 187 | ###### RT-ST-MESI protocol 188 | ###### HRT-ST-MESI protocol 189 | ###### POWER4 IBM protocol 190 | ##### General considerations on the protocols 191 | ##### Snoopy and processor activity interference 192 | #### Directory-based cache coherence - message-passing 193 | ##### Remote cache 194 | ##### cc-NUMA cache coherency 195 | ###### Local memory read 196 | ###### Local memory write 197 | ###### Remote memory read 198 | ###### Remote memory write 199 | #### Shared cache - coherency protocol 200 | ##### Multi-core systems 201 | ###### cc-NUMA in multi-core systems 202 | 203 | ### Stack cache 204 | #### Overview 205 | #### Stack cache implementation 206 | 207 | ### Virtual, physical, and pseudo virtual addressing 208 | #### MMU 209 | #### TLB 210 | #### Virtual addressing 211 | ##### Coherency problem 212 | #### Physical addressing 213 | #### Pseudo-virtual addressing 214 | 215 | ### See also 216 | 217 | ### References 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /WTS/character-set-encoding.md: -------------------------------------------------------------------------------- 1 | ## [乱码][^4] 2 | 3 | 互联网上乱码是一个常见的问题,相信大家或多或少都遇到过。比如浏览网页的时候,打开一个的文件的时候等等。 4 | 5 | 这里的原因大致可以分为如下几类: 6 | 7 | * 字符编码不匹配,一个用UTF8编码的数据,用UTF16去解码 8 | * 字符集不匹配,一个Uniocde的数据,尝试用GBK去匹配 9 | * 字体不支持相应的字符,出现占位框 10 | * 数据不完整或者解码位置错位 11 | 12 | 对于开发同学来说,在各种编程语言里面都会需要跟字符串打交道,也需要审视自己设计的相关系统应该选用的字符编码问题。所以要完整理解这个问题,应该要搞清楚:语言,字符,字符集,字符编码,字体之间的关系。 13 | 14 | 15 | 16 | ## [字符](https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6) 17 | 18 | 从计算机诞生开始,一个重要是使命就是把_**“信息”**_进行_**“数字化”**_,而文本是遇到的第一个需要数字化的信息之一。对于[印欧语系](https://zh.wikipedia.org/wiki/%E5%8D%B0%E6%AC%A7%E8%AF%AD%E7%B3%BB),典型的现代英文来说主要是字母组合而来的单词(也有重音等其他西欧语),还有一种典型的是[汉藏语系](https://zh.wikipedia.org/wiki/%E6%B1%89%E8%97%8F%E8%AF%AD%E7%B3%BB),主体是象形字组合,没有穷尽的最小单元。像东亚的语系是典型的复杂语系,繁体字和简体字就是典型的差异。可以看到不同区域,不同语言的字符差异非常大。全世界现存的语言约[6900种](https://zh.wikipedia.org/wiki/%E8%AF%AD%E8%A8%80%E7%B3%BB%E5%B1%9E%E5%88%86%E7%B1%BB)。 19 | 20 | 21 | 22 | ## 字符集 23 | 24 | 计算机发展起于[印欧语系](https://zh.wikipedia.org/wiki/%E5%8D%B0%E6%AC%A7%E8%AF%AD%E7%B3%BB),所以面对的第一个文本是英文的数字化,因为[印欧语系](https://zh.wikipedia.org/wiki/%E5%8D%B0%E6%AC%A7%E8%AF%AD%E7%B3%BB)的基本语法单元是字母,标点,数字和少数控制字符,所以到1967年,以信息化英文为目标的[ASCII][2]第一次发布,共定义了128个字符。其中33个字符无法显示(一些终端提供了扩展,使得这些字符可显示为诸如[笑脸](https://zh.wikipedia.org/w/index.php?title=%E7%AC%91%E8%87%89&action=edit&redlink=1)、[扑克牌花式](https://zh.wikipedia.org/w/index.php?title=%E6%92%B2%E5%85%8B%E7%89%8C%E8%8A%B1%E5%BC%8F&action=edit&redlink=1)等8-bit符号),且这33个字符多数都已是陈废的[控制字符](https://zh.wikipedia.org/wiki/%E6%8E%A7%E5%88%B6%E5%AD%97%E5%85%83)。但对于非现代英文,比如naïve、café、élite包含重音符合就无法表达了。 25 | 26 | 不同的国家和地区会整理本地区或国家的语言文字基础上考虑使用的现实情况制定相应的标准,比如[GB 2312](https://zh.wikipedia.org/wiki/GB_2312),1981年5月1日实施。GB 2312编码通行于中国大陆;[新加坡](https://zh.wikipedia.org/wiki/%E6%96%B0%E5%8A%A0%E5%9D%A1)等地也采用此编码。中国大陆几乎所有的中文系统和国际化的软件都支持GB 2312。标准共收录6763个[汉字](https://zh.wikipedia.org/wiki/%E6%B1%89%E5%AD%97),其中[一级汉字](https://zh.wikipedia.org/wiki/%E5%B8%B8%E7%94%A8%E5%AD%97)3755个,[二级汉字](https://zh.wikipedia.org/wiki/%E6%AC%A1%E5%B8%B8%E7%94%A8%E5%AD%97)3008个;同时收录了包括[拉丁字母](https://zh.wikipedia.org/wiki/%E6%8B%89%E4%B8%81%E5%AD%97%E6%AF%8D)、[希腊字母](https://zh.wikipedia.org/wiki/%E5%B8%8C%E8%85%8A%E5%AD%97%E6%AF%8D)、[日文](https://zh.wikipedia.org/wiki/%E6%97%A5%E8%AF%AD)[平假名](https://zh.wikipedia.org/wiki/%E5%B9%B3%E5%81%87%E5%90%8D)及[片假名](https://zh.wikipedia.org/wiki/%E7%89%87%E5%81%87%E5%90%8D)字母、[俄语](https://zh.wikipedia.org/wiki/%E4%BF%84%E8%AF%AD)[西里尔字母](https://zh.wikipedia.org/wiki/%E6%96%AF%E6%8B%89%E5%A4%AB%E5%AD%97%E6%AF%8D)在内的682个字符。对于80后参加高考的时候应该有记忆,填写志愿表的时候,名字等信息除了写汉字以外,还要写拼音,除了这个以外还要写区位码等信息。其实同样的汉字本身不足以表达确切的码点(渖(68–41):由“审[審]”类推简化而来,可以归类到沈,但定义的时候是有不同的码点的),所以他们也找了一个最直接的方式,填报志愿的同学自己去找到自己的码点,这里介绍一下GB对于汉字的处理: 27 | 28 | GB 2312中对所收汉字进行了“分区”处理,每区含有94个汉字/符号。这种表示方式也称为[区位码](https://zh.wikipedia.org/wiki/ISO/IEC_2022) 29 | 30 | - 01–09区为特殊符号。 31 | - 16–55区为一级汉字,按[拼音](https://zh.wikipedia.org/wiki/%E6%8B%BC%E9%9F%B3)排序。 32 | - 56–87区为二级汉字,按[部首](https://zh.wikipedia.org/wiki/%E9%83%A8%E9%A6%96)/[笔画](https://zh.wikipedia.org/wiki/%E7%AC%94%E7%94%BB)排序。 33 | 34 | 举例来说,“啊”字是GB 2312之中的第一个汉字,它的区位码就是1601。 35 | 36 | 在字符集定义上基本是百花齐放的姿态,到一个点的时候,互联网的主流同学都意思到了全世界其实如果是一个统一的字符集来表达的话,那么信息交流就简单多了。通用字符集又称Universal Multiple-Octet Coded Character Set(UCS),UCS包含了已知语言的所有字符。除了拉丁语、希腊语、斯拉夫语、希伯来语、阿拉伯语、亚美尼亚语、格鲁吉亚语,还包括中文、日文、韩文这样的方块文字,UCS还包括大量的图形、印刷、数学、科学符号。中国大陆译为**通用多八位编码字符集**,台湾译为**广用多八字节编码字元集** 。到这个地方大家估计心里有一个疑惑,这个统一字符集和Unicode是啥关系呢? 37 | 38 | 历史上存在两个独立的尝试创立单一字符集的组织: 39 | 40 | * 国际标准化组织(ISO)于1984年创建的ISO/IEC 41 | * [统一码联盟](https://zh.wikipedia.org/wiki/%E7%B5%B1%E4%B8%80%E7%A2%BC%E8%81%AF%E7%9B%9F)由[Xerox](https://zh.wikipedia.org/wiki/Xerox)、[Apple](https://zh.wikipedia.org/wiki/Apple)等软件制造商于1988年组成 42 | 43 | 前者开发的ISO/IEC 10646项目,后者开发的[统一码](https://zh.wikipedia.org/wiki/%E7%B5%B1%E4%B8%80%E7%A2%BC)项目。因此最初制定了不同的标准。1991年前后,两个项目的参与者都认识到,世界不需要两个不兼容的字符集。所以通过友好协商,在不拆散任何一方办事机构,增加失业率的情况下,两个机构合作开发一个字符集,这个就是Unicode: 44 | 45 | * 1991年 就发布了 Unicode 1.0 (不包含CJK统一汉字) 46 | * 1993年 发布了Unicode 1.1,这个还有一个名字是ISO 10646-1:1993(CJK统一汉字集的制定于1993年完成) 47 | * 1996年 发布了Unicode 2.0,Unicode采用了与ISO 10646-1相同的字库和字码,ISO也承诺,ISO 10646将不会替超出U+10FFFF的UCS-4编码赋值,以使得两者保持一致 48 | 49 | 到现在为止,两个机构依然是并存的,但关于字符集的字库和编码上两者已经协商高度统一(依然有一些细微差别,比如2.0里面对于变形字的规定)。这里其实有必要对机构的工作做一个大体的描述,标准组织其实不仅仅是找全字符安排码点,这里其实涉及到编码的别名,标准有关的术语,语义符号学,绘制某些语言(如阿拉伯语)表达形式的算法,处理双向文字(比如拉丁文和希伯来文的混合文字)的算法,排序与字符串比较所需的算法等等,其实这些都是标准化组织需要做的事情。 50 | 51 | 举个例子,拿中文来说,古文是经常会出现通假字(严重怀疑很多时候是自别字)的,还有古文里面关于同一个字其实是有非常多的写法的,这些都是标准化组织需要去处理的。 52 | 53 | 54 | 55 | ### GB0 56 | 57 | 在进入字符编码前,我们对中文的字符集也有必要做一个统一的了解: 58 | 59 | **GB 2312** 或 **GB 2312–80** 是[中华人民共和国国家标准](https://zh.wikipedia.org/wiki/%E4%B8%AD%E5%8D%8E%E4%BA%BA%E6%B0%91%E5%85%B1%E5%92%8C%E5%9B%BD%E5%9B%BD%E5%AE%B6%E6%A0%87%E5%87%86)[简体中文](https://zh.wikipedia.org/wiki/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87)[字符集](https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6%E9%9B%86),全称《**信息交换用汉字编码字符集·基本集**》,又称**GB0**,由[中国国家标准总局](https://zh.wikipedia.org/w/index.php?title=%E4%B8%AD%E5%9B%BD%E5%9B%BD%E5%AE%B6%E6%A0%87%E5%87%86%E6%80%BB%E5%B1%80&action=edit&redlink=1)发布,1981年5月1日实施。显然这货搞得很早,想得不够全面,一共也只收纳了6763个汉字,[古汉语](https://zh.wikipedia.org/wiki/%E5%8F%A4%E6%B1%89%E8%AF%AD)等方面出现的[罕用字](https://zh.wikipedia.org/wiki/%E7%BD%95%E7%94%A8%E5%AD%97)和[繁体字](https://zh.wikipedia.org/wiki/%E7%B9%81%E9%AB%94%E5%AD%97)这个都是Cover不到的,比如宝岛就搞了一个[大五码](https://zh.wikipedia.org/wiki/%E5%A4%A7%E4%BA%94%E7%A2%BC)(Big5)搞定繁体古文相关的字符。所以GB0肯定需要再往下走一步,这个时候就有了[GB 12345](https://zh.wikipedia.org/wiki/GB_12345) 和 [GB 18030](https://zh.wikipedia.org/wiki/GB_18030)。 60 | 61 | 1993年发布的Unicode1.1收录[中国大陆](https://zh.wikipedia.org/wiki/%E4%B8%AD%E5%9B%BD%E5%A4%A7%E9%99%86)、[台湾](https://zh.wikipedia.org/wiki/%E5%8F%B0%E6%B9%BE)、[日本](https://zh.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC)及[韩国](https://zh.wikipedia.org/wiki/%E9%9F%A9%E5%9B%BD)通用[字符集](https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6%E9%9B%86)的[汉字](https://zh.wikipedia.org/wiki/%E6%B1%89%E5%AD%97),总共有20,902个。中国大陆订定了等同于Unicode 1.1版本的“[GB 13000.1-93](https://zh.wikipedia.org/wiki/GB_13000)”“信息技术通用多八位编码字符集(UCS)第一部分:体系结构与基本多文种平面”,关于多文种平面后面再侃。因为GB2312收录的实在太少,比如现在来看常见的"啰",以及“镕”都没有,更加不用说繁体字了。所以从厂商的角度,他的产品肯定得出啊,不能等你的标准全部定稿再发布吧。 62 | 63 | 64 | 65 | ### CP936 66 | 67 | 微软利用GB 2312-80未使用的编码空间,收录GB 13000.1-93全部字符制定了GBK编码,这个对应到windows里面来说就是**CP936字码表**(Code Page 936)*的扩展,最早实现于[Windows 95](https://zh.wikipedia.org/wiki/Windows_95)简体中文版。这里有一个名词 Code Page, 这个其实起源于IBM,这个是IBM 对一个字符集的形象描述,他把不同系统,不同区域的字符集按照码点页的形式进行存储,随着系统发布,所以对于厂商内部来说,字符集和CodePage基本是等价的。这里我们看到了GBK, GB12000.1-93, 以及Unicode1.1之间的联系,但这里的联系一定要清晰的认识到这里只是字符集的联系,字符集的和编码是两回事,所以Unicode1.1 和 GB 13000.1-93一致,但所说的UTF8, UTF16,GBK等完全是不兼容的。 这里我们讨论到的Codepage,前面提到的字符集等价是一个约等于,其实Codepage在这里的一个重要作用是通过指定的转换表将非Unicode的字符编码转换为同一字符对应的系统内部使用的Unicode编码。可以在“语言与区域设置”中选择一个代码页作为非Unicode编码所采用的默认编码方式,如936为简体中文[GB码](https://zh.wikipedia.org/wiki/%E5%9B%BD%E5%AE%B6%E6%A0%87%E5%87%86%E4%BB%A3%E7%A0%81),950为繁体中文[Big5](https://zh.wikipedia.org/wiki/Big5)(皆指PC上使用的)。在这种情况下,一些非英语的欧洲语言编写的软件和文档很可能出现乱码。而将代码页设置为相应语言中文处理又会出现问题,这一情况无法避免。只有完全采用统一编码才能彻底解决这些问题,但目前尚无法做到这一点。 68 | 69 | 代码页技术现在广泛为各种平台所采用。UTF-7的代码页是65000,UTF-8的代码页是65001。 70 | 71 | 72 | 73 | ### GBK 74 | 75 | 还有一个是GBK我们说是微软搞的,对应他内部的CP936,但这里也要深巴一下,真正的全面定义的**GBK**,全名为**《汉字内码扩展规范(GBK)》1.0版**,由中华人民共和国[全国信息技术标准化技术委员会](https://zh.wikipedia.org/w/index.php?title=%E5%85%A8%E5%9B%BD%E4%BF%A1%E6%81%AF%E6%8A%80%E6%9C%AF%E6%A0%87%E5%87%86%E5%8C%96%E6%8A%80%E6%9C%AF%E5%A7%94%E5%91%98%E4%BC%9A&action=edit&redlink=1)1995年12月1日制订,[国家技术监督局](https://zh.wikipedia.org/wiki/%E4%B8%AD%E5%8D%8E%E4%BA%BA%E6%B0%91%E5%85%B1%E5%92%8C%E5%9B%BD%E5%9B%BD%E5%AE%B6%E6%8A%80%E6%9C%AF%E7%9B%91%E7%9D%A3%E5%B1%80)标准化司和[电子工业部](https://zh.wikipedia.org/wiki/%E4%B8%AD%E5%8D%8E%E4%BA%BA%E6%B0%91%E5%85%B1%E5%92%8C%E5%9B%BD%E7%94%B5%E5%AD%90%E5%B7%A5%E4%B8%9A%E9%83%A8)科技与质量监督司1995年12月15日联合以《技术标函[1995]229号》文件的形式公布。 GBK共收录21886个汉字和图形符号,其中汉字(包括部首和构件)21003个,图形符号883个。GBK定义之字符较CP936多出95字(15个非汉字及80个汉字),皆为其时未收入ISO 10646 / Unicode之符号。 76 | 77 | 对于很多字符集的定义着来说,通常会流出一部分未定义的区域,一个是未来可以加字符,也有很多会流出一些造字区用使用方来自定义。比如Big5码是一套[双字节字符集](http://zh.wikipedia.org/wiki/%E5%8F%8C%E5%AD%97%E8%8A%82%E5%AD%97%E7%AC%A6%E9%9B%86),明确定义了**0x8140-0xA0FE** 为**保留给用户自定义字符(造字区)**。 78 | 79 | 提到GBK或者CJK这里有一个概念可以进行一下扩展,[**全角和半角**](https://zh.wikipedia.org/zh-cn/%E5%85%A8%E5%BD%A2%E5%92%8C%E5%8D%8A%E5%BD%A2),对于中文输入法来说,默认的标点符号是全角输出,而对于英文输入法来说是半角输出。在使用[等宽字体](https://zh.wikipedia.org/wiki/%E7%AD%89%E5%AE%BD%E5%AD%97%E4%BD%93)(如[DOS](https://zh.wikipedia.org/wiki/DOS)、部分文字编辑器等)的环境下,中日韩文字此时占据两倍于西文字符的显示宽度。所以,中、日、韩等文字称为全角字符,相比起来,拉丁字母或数字就称为半角字符。有时为了使[字体](https://zh.wikipedia.org/wiki/%E5%AD%97%E4%BD%93)看起来齐整,英文字母、数字及其他符号也由原来只占一个字空间,改为占用两个字的空间显示、使用两个字节储存的格式。这样对于原有的128一下的Latin字母来说,在字符集下就有两个不同的码点来表述半角和全角了。 80 | 81 | 82 | 83 | ### Unicode 84 | 85 | 作为从通用字符集发展过来的全球通用字符集支持多语言环境(指可同时处理多种语言混合的情况)。但因为全球的语言环境确实太他妈恶劣了,字符集也是被坑得不行,比如Unicode编码包含了不同写法的字,如“ɑ/a”、“強/强”、“戶/户/戸”,在[汉字](https://zh.wikipedia.org/wiki/%E6%B1%89%E5%AD%97)方面引起了一字多形的认定争议。 86 | 87 | ![Unicode BMP](https://upload.wikimedia.org/wikipedia/commons/thumb/8/8e/Roadmap_to_Unicode_BMP.svg/750px-Roadmap_to_Unicode_BMP.svg.png) 88 | 89 | 目前,几乎所有电脑系统都支持基本拉丁字母,并各自支持不同的其他编码方式。Unicode为了和它们相互兼容,其首256字符保留给ISO 8859-1所定义的字符,使既有的西欧语系文字的转换不需特别考量;并且把大量相同的字符重复编到不同的字符码中去,使得旧有纷杂的编码方式得以和Unicode编码间互相直接转换,而不会丢失任何信息。举例来说,[全角](https://zh.wikipedia.org/wiki/%E5%85%A8%E5%BD%A2)格式区块包含了主要的拉丁字母的全角格式,在中文、日文、以及韩文字形当中,这些字符以全角的方式来呈现,而不以常见的半角形式显示,这对竖排文字和等宽排列文字有重要作用。 90 | 91 | Unicode从统一码过来,最开始集成的是我们常见到的UCS-2的统一码版本,也就是每个字符两个字节,一共可以容纳65536个字符。最新(但未实际广泛使用)的统一码版本定义了16个[辅助平面](https://zh.wikipedia.org/wiki/%E8%BE%85%E5%8A%A9%E5%B9%B3%E9%9D%A2),两者合起来至少需要占据21位的编码空间,比3字节略少。但事实上辅助平面字符仍然占用4字节编码空间,与[UCS-4](https://zh.wikipedia.org/wiki/UCS-4)保持一致。理论上最多能表示$2^{31}$个字符,完全可以涵盖一切语言所用的符号。 92 | 93 | 其实到这个地方有Windows开发经验的同学肯定会想到很多开发场景,char, wchar_t, TCHAR 以及相关的函数,这里有一个操蛋的地方是wchar_t的本意是宽字符定义,但并没有约定wchar_t的字节数,所以在windows上sizeof(wchar_t)等于2,而在linux上是等于4的。也就是在windows上这货只能表达UCS-2,而在Linux上可以表达UCS-4。在Windows内核的存储也是对应的UCS-2,所以关于字符函数很多时候会有对应的Ex版本用来单独处理UCS-4的情形。 94 | 95 | 延展一下,这了还有一个常用的场景,在libc里面有一个函数setlocale,当向终端、控制台输出 wchar_t 类型的字符时,需要设置 setlocale(),因为通常终端、控制台环境自身是不支持 UCS 系列的字符集编码的,使用流操作函数时(如:printf()),在标准/RT库实现的内部会将 UCS 字符转换成合适的本地 ANSI 编码字符,转换的依据就是 setlocale() 设定的活动 locale,最后将结果字符序列传递给终端,对于来自终端的输入流这个过程刚好相反(**Windows CRT 是不支持 UTF-8 编码作为 locale **)。在windows开发里面有两个常见的函数,在UTF-16LE(wchar_t类型)与UTF-8(代码页CP_UTF8)之间的转码。 96 | 97 | ```c 98 | #include 99 | int main() { 100 | char a1[128], a2[128] = { "Hello" }; 101 | wchar_t w = L'页'; 102 | int n1, n2= 5; 103 | wchar_t w1[128]; 104 | int m1 = 0; 105 | 106 | n1 = WideCharToMultiByte(CP_UTF8, 0, &w, 1, a1, 128, NULL, NULL); 107 | m1 = MultiByteToWideChar(CP_UTF8, 0, a2, n2, w1, 128); 108 | } 109 | ``` 110 | 111 | 112 | 113 | 114 | 115 | ## 字符编码 116 | 117 | 定义字符集的同学,是天然有权利定义字符集对应的编码方式的。如果是ASCII来说,这货太简单了,就一个字节就可以表达字符集的所有字符。比如前面的GB 2312就不一样了,定义的字符是大于256的,一个字节搞不定,所以肯定就需要整很多幺蛾子来编码字符,[EUC](https://zh.wikipedia.org/wiki/EUC)就是GB采用的编码方式,[EUC](https://zh.wikipedia.org/wiki/EUC)本身也是一个变长编码,**EUC**全名为**Extended Unix Code**,是一个使用8[位](https://zh.wikipedia.org/wiki/%E4%BD%8D)编码来表示[字符](https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6)的方法。EUC最初是针对Unix系统,由一些Unix公司所开发,于1991年标准化。EUC基于[ISO/IEC 2022](https://zh.wikipedia.org/wiki/ISO/IEC_2022)的7位编码标准,因此单字节的编码空间为94,双字节的编码空间(区位码)为94x94。把每个区位加上0xA0来表示,以便符合ISO 2022。它主要用于表示及储存[汉语文字](https://zh.wikipedia.org/wiki/%E6%B1%89%E8%AF%AD)、[日语文字](https://zh.wikipedia.org/wiki/%E6%97%A5%E8%AF%AD)及[朝鲜文字](https://zh.wikipedia.org/wiki/%E9%9F%93%E8%AA%9E)。 118 | 119 | 在具体深入字符编码前可以统一的介绍一下现代字符编码的[五层模型](https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6%E7%BC%96%E7%A0%81),它们将字符编码的概念分为:有哪些字符、它们的[编号](https://zh.wikipedia.org/wiki/%E7%BC%96%E5%8F%B7)、这些[编号](https://zh.wikipedia.org/wiki/%E7%BC%96%E5%8F%B7)如何编码成一系列的“码元”(有限大小的数字)以及最后这些单元如何组成八位字节流。 120 | 121 | 这里容易绕晕,用一个例子来表达。比如有 a, b, c 三个字符,他们的编号是 1, 2, 3,他们的码元可以定义为 4, 5, 6,定义一个厨房的编码方案 码元 + 0xA1,他们他们编码后的字节是 0xA5, 0xA6, 0xA7,如果我要用Email来传输这货,我希望采用URLEncoding的方式缩小到7bit的域里面来,这个就对应到传输编码方案。 122 | 123 | 124 | 125 | ### EUC 126 | 127 | EUC定义了4个单独的码集(code set)。码集0总是对应于7位的[ASCII](https://zh.wikipedia.org/wiki/ASCII)(或其它的各国定义的[ISO 646](https://zh.wikipedia.org/wiki/ISO_646)),包括了ISO 2022定义的C0与G0空间的值。码集1, 2, 3表示G1空间的值。其中,码集1表示一些未经修饰(unadorned)的字符。码集2的字符编码以0x8E(属于C1控制字符,或称SS2)为第一字节。码集3的字符编码以0x8F(另一个属于C1的控制字符,或称SS3)为第一字节。码集0总是编码为单字节;码集2、3总是编码为至少2个字节;码集1编码为1-3个字节。 128 | 129 | 130 | 131 | ### [UTF16](https://zh.wikipedia.org/wiki/UTF-16)(Unicode Transformation Format,简称为UTF) 132 | 133 | Unicode字符集的抽象[码位](https://zh.wikipedia.org/wiki/%E7%A0%81%E4%BD%8D)映射为16位长的整数(即[码元](https://zh.wikipedia.org/wiki/%E7%A0%81%E5%85%83))的序列,用于数据存储或传递。Unicode字符的码位,需要1个或者2个16位长的码元来表示,因此这是一个变长表示 134 | 135 | 目前在PC机上的Windows系统和Linux系统对于UTF-16编码默认使用UTF-16 LE。UTF16也是一个变长编码,而且因为UTF16的码元是16位的,所以存在大小端的问题。 136 | 137 | Unicode的编码空间从U+0000到U+10FFFF,共有1,112,064个码位(code point)可用来映射字符. Unicode的编码空间可以划分为17个平面(plane),每个平面包含216(65,536)个码位。在第0个平面基本包含了绝大部分用到的统一编码字符,所以用UTF16的话,大部分情况都是一个16位的码元的直接映射,效率非常高。 138 | 139 | 对于需要扩展到2个16位码元的情况的时候,从Unicode的平面设定来说是一个$2^{20}$的区域,所以每一个码元需要的存储空间只需要10bit,刚好在Unicode里面的BMP里面有一个约定,从U+D800到U+DFFF之间的码位区块是永久保留不映射到Unicode字符(**UCS-2的时代,U+D800..U+DFFF内的值被占用,用于某些字符的映射**),所以对于任何一个BMP以外的我们都可以映射到连个位于U+D800到U+DFFF之间的码点。这就是UTF16,具体的映射,其实这里可以区分一下前后,做前导和后导的区分,直接判定码元流是否完整,高位的10比特的值(值的范围为0..0x3FF)被加上0xD800得到第一个码元或称作高位代理(high surrogate),值的范围是0xD800..0xDBFF,低位的10比特的值(值的范围也是0..0x3FF)被加上0xDC00得到第二个码元或称作低位代理(low surrogate),现在值的范围是0xDC00..0xDFFF。 140 | 141 | 142 | 143 | ### UTF8 144 | 145 | 总的来说UTF8是变长编码字符,相对UTF16来说有一个很大的优先是,他的基本码元是8bit的,这样对于128一下的字符,依然是一个字节,完全兼容了ASCII时代的所有资料,同时还没有大小端问题;然后UTF8还是是一个完备的前缀编码方案,变长码元的前缀判定是完备的,也就不会出现位置错误,而乱码了;再者这个编码方案,最长可以到6字节,他的潜在编码区域其实对Unicode是全覆盖的,原则上所有的Unicode都可以被UTF8进行编码(2003年11月UTF-8被RFC 3629重新规范只能使用原来Unicode定义的区域,U+0000到U+10FFFF,也就是说最多四个字节)。 146 | 147 | 巴拉巴拉说了很多,大家应该基本了解了他的强大的地方,而且[互联网工程工作小组](https://zh.wikipedia.org/wiki/%E7%B6%B2%E9%9A%9B%E7%B6%B2%E8%B7%AF%E5%B7%A5%E7%A8%8B%E5%B7%A5%E4%BD%9C%E5%B0%8F%E7%B5%84)(IETF)要求所有[互联网](https://zh.wikipedia.org/wiki/%E7%B6%B2%E9%9A%9B%E7%B6%B2%E8%B7%AF)[协议](https://zh.wikipedia.org/wiki/%E7%BD%91%E7%BB%9C%E5%8D%8F%E8%AE%AE)都必须支持UTF-8编码[[1\]](https://zh.wikipedia.org/wiki/UTF-8#cite_note-1)。[互联网邮件联盟](https://zh.wikipedia.org/w/index.php?title=%E4%BA%92%E8%81%AF%E7%B6%B2%E9%83%B5%E4%BB%B6%E8%81%AF%E7%9B%9F&action=edit&redlink=1)(IMC)建议所有电子邮件软件都支持UTF-8编码。1996年起,[微软](https://zh.wikipedia.org/wiki/%E5%BE%AE%E8%BB%9F)的[CAB](https://zh.wikipedia.org/wiki/CAB)(MS Cabinet)规格在UTF-8标准正式落实前就明确容许在任何地方使用UTF-8编码系统。 148 | 149 | 这个编码的由来也是有一些小的逸闻的: 150 | 151 | 1992年[ISO/IEC 10646](https://zh.wikipedia.org/wiki/%E9%80%9A%E7%94%A8%E5%AD%97%E7%AC%A6%E9%9B%86)的初稿中有一个非必须的附录,名为UTF,定义了一个版本来编码Unicode,但是性能,兼容性压缩率等各方面都不满意,这个时候前面提到的参与Unicode的另外一个联盟机构,赶紧想抢占先机,[X/Open](https://zh.wikipedia.org/wiki/X/Open)委员会XoJIG开始寻求一个较佳的编码系统,[Unix系统实验室](https://zh.wikipedia.org/wiki/Unix%E7%B3%BB%E7%BB%9F%E5%AE%9E%E9%AA%8C%E5%AE%A4)(USL)的Dave Prosser为此提出了一个编码系统的建议。它具备可更快速实现的特性,并引入一项新的改进。其中,7[比特](https://zh.wikipedia.org/wiki/%E4%BD%8D%E5%85%83)的[ASCII](https://zh.wikipedia.org/wiki/ASCII)符号只代表原来的意思,所有多字节序列则会包含第8[比特](https://zh.wikipedia.org/wiki/%E4%BD%8D%E5%85%83)的符号,也就是所谓的[最高有效比特](https://zh.wikipedia.org/wiki/%E6%9C%80%E9%AB%98%E6%9C%89%E6%95%88%E4%BD%8D%E5%85%83)。这个方案很快流传到一些感兴趣的团体,[贝尔实验室](https://zh.wikipedia.org/wiki/%E8%B2%9D%E7%88%BE%E5%AF%A6%E9%A9%97%E5%AE%A4)[九号项目](https://zh.wikipedia.org/wiki/%E8%B2%9D%E7%88%BE%E5%AF%A6%E9%A9%97%E5%AE%A4%E4%B9%9D%E8%99%9F%E8%A8%88%E7%95%AB)[操作系统](https://zh.wikipedia.org/wiki/%E4%BD%9C%E6%A5%AD%E7%B3%BB%E7%B5%B1)工作小组的[肯·汤普逊。大神](https://zh.wikipedia.org/wiki/%E8%82%AF%C2%B7%E6%B1%A4%E6%99%AE%E9%80%8A)对这编码系统作出重大的修改,让编码可以自我同步,使得不必从字符串的开首读取,也能找出字符间的分界。刚好1992年8月Ken Thmpson和Rob pike 一起出差去开会,他们两个基友就在在[美国](https://zh.wikipedia.org/wiki/%E7%BE%8E%E5%9C%8B)[新泽西州](https://zh.wikipedia.org/wiki/%E6%96%B0%E6%BE%A4%E8%A5%BF%E5%B7%9E)一架餐车的餐桌垫上描绘出此设计的要点。开完会回来Rob Pike 就着手进行具体的实现并将这编码系统完全应用在[九号项目](https://zh.wikipedia.org/wiki/%E8%B2%9D%E7%88%BE%E5%AF%A6%E9%A9%97%E5%AE%A4%E4%B9%9D%E8%99%9F%E8%A8%88%E7%95%AB)当中,及后他将有关成果回馈X/Open。 152 | 153 | 到这个地方有必要把一个名词给弄来说说了,因为Windows在字符编码这块一直都有一些些自由的微创新,许多Windows程序(包括Windows记事本)在UTF-8编码的文件的开首加入一段字节串`EF BB BF`,用来标示这个是UTF8编码的。同时也知道UTF16等有字节序的问题,所以在Unicode里面**字节顺序标记**(英语:byte-order mark,**BOM**)是位于码点`U+FEFF`的[统一码](https://zh.wikipedia.org/wiki/%E7%B5%B1%E4%B8%80%E7%A2%BC)字符的名称。微软的`EF BB BF`和这里的BOM也就统称为BOM了,用来标示文件是以[UTF-8](https://zh.wikipedia.org/wiki/UTF-8)、[UTF-16](https://zh.wikipedia.org/wiki/UTF-16)或[UTF-32](https://zh.wikipedia.org/wiki/UTF-32)编码的记号。 154 | 155 | Posix系统明确不建议使用字节序掩码`EF BB BF`。所以对于一个跨环境的团队来说,在Windows工作的同学,用Windows自带的很多编辑器编译的是,注意就不要插入这么一个东西了。 156 | 157 | 这个世界上有标准之说,那么就肯定有非标准的存在,对于UTF8来说也存在一些非标准或者说被局部修改的版本,比如有个修改的版本为了解决UTF8的结束符和优化扩展平面,把 [空字符](https://zh.wikipedia.org/wiki/%E7%A9%BA%E5%AD%97%E7%AC%A6)(null character,U+0000)使用双字节的0xc0 0x80,而不是单字节的0x00。这保证了在已编码字符串中没有嵌入空字节。因为[C语言](https://zh.wikipedia.org/wiki/C%E8%AF%AD%E8%A8%80)等语言程序中,单字节空字符是用来标志字符串结尾的。当已编码字符串放到这样的语言中处理,一个嵌入的空字符将把字符串一刀两断。第二个不同点是[基本多文种平面](https://zh.wikipedia.org/wiki/%E5%9F%BA%E6%9C%AC%E5%A4%9A%E6%96%87%E7%A8%AE%E5%B9%B3%E9%9D%A2)之外字符的编码的方法。在标准UTF-8中,这些字符使用4字节形式编码,而在改正的UTF-8中,这些字符和UTF-16一样首先表示为代理对(surrogate pairs),然后再像[CESU-8](https://zh.wikipedia.org/w/index.php?title=CESU-8&action=edit&redlink=1)那样按照代理对分别编码。 158 | 159 | 一个非常简单的utf8编解码实现可以参考: 160 | 161 | http://stackoverflow.com/questions/4607413/c-library-to-convert-unicode-code-points-to-utf8/4609989#4609989 162 | 163 | ```c 164 | if (c<0x80) *b++=c; 165 | else if (c<0x800) *b++=192+c/64, *b++=128+c%64; 166 | else if (c-0xd800u<0x800) goto error; 167 | else if (c<0x10000) *b++=224+c/4096, *b++=128+c/64%64, *b++=128+c%64; 168 | else if (c<0x110000) *b++=240+c/262144, *b++=128+c/4096%64, *b++=128+c/64%64, *b++=128+c%64; 169 | else goto error; 170 | ``` 171 | 172 | 一个工业级可用的版本可以参考: 173 | 174 | https://github.com/JerryZhou/isee/blob/master/code/foundation/util/iutf8.c 175 | 176 | 非常好理解。 177 | 178 | 最后又一个纪要就是很多服务端开发同学遇到的,关于**数据库的编码方案选择的问题**。**MySql**里面有一个很迷惑人的地方,里面有:`utf8`, `utf8_unicode_ci` `utf8_general_ci`, `utf8mb4`, `utf8mb4_generate_ci`, `utf8mb4_unicode_ci` 等等。utf8 和 utfbmb4的区别是MySql里面,把Utf8的多字节阉割到了至多3字节,也就是只能表达BMP里面的字符,而utf8mb4:utf8-mulitiple-byte-4,很显然他是扩展到了4个字节,这样至少emoji是可以显示了。 179 | 180 | 置于后面的generate_ci 和 unicode_ci 可以详细查看: 181 | 182 | http://stackoverflow.com/questions/766809/whats-the-difference-between-utf8-general-ci-and-utf8-unicode-ci 183 | 184 | 综合的建议就是如果没有特别极度的理由,我们无脑用`utf8mb4_uncidoe_ci`就对了。 185 | 186 | 187 | 188 | [1]: https://www.freetype.org/freetype2/docs/glyphs/glyphs-3.html "Glyph" 189 | [2]: https://zh.wikipedia.org/wiki/ASCII "American Standard Code for Information Interchange" 190 | [3]: https://zh.wikipedia.org/wiki/EASCII "Support 128-256" 191 | [4]: https://en.wikipedia.org/wiki/Mojibake "乱码" 192 | [5]: https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%97%A5%E9%9F%93%E7%B5%B1%E4%B8%80%E8%A1%A8%E6%84%8F%E6%96%87%E5%AD%97 "CJK" -------------------------------------------------------------------------------- /index.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 深入浅出Golang 6 | 38 | 39 | 40 |
41 | 42 |
43 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 | 57 | 58 | 59 | --------------------------------------------------------------------------------