1.TiDB 源码阅读系列文章(五)TiDB SQL Parser 的源码实现
2.golang chan 最详细原理剖析,全面源码分析!源码看完不可能不懂的源码!
TiDB 源码阅读系列文章(五)TiDB SQL Parser 的源码实现
本文是 TiDB 源码阅读系列文章的第五篇,主要内容围绕 SQL Parser 功能实现进行讲解。源码内容源自社区伙伴马震(GitHub ID:mz)的源码winring0 源码投稿。系列文章的源码目的是与数据库研究者及爱好者深入交流,收到了社区的源码积极反馈。后续,源码期待更多伙伴加入 TiDB 的源码探讨与分享。
TiDB 的源码源码阅读系列文章,帮助读者系统性地学习 TiDB 内部实现。源码最近的源码《SQL 的一生》一文,全面阐述了 SQL 语句处理流程,源码从接收网络数据、源码MySQL 协议解析、SQL 语法解析、查询计划制定与优化、执行直至返回结果。
其中,SQL Parser 的功能是将 SQL 语句按照 SQL 语法规则进行解析,将文本转换为抽象语法树(AST)。此功能需要一定背景知识,下文将尝试介绍相关知识,以帮助理解这部分代码。
TiDB 使用 goyacc 根据预定义的 SQL 语法规则文件 parser.y 生成 SQL 语法解析器。这一过程可在 TiDB 的 Makefile 文件中看到,通过构建 goyacc 工具,使用 goyacc 依据 parser.y 生成解析器 parser.go。
goyacc 是 yacc 的 Golang 版本,因此理解语法规则定义文件 parser.y 及解析器工作原理之前,需要对 Lex & Yacc 有所了解。Lex & Yacc 是仓位监控源码用于生成词法分析器和语法分析器的工具,它们简化了编译器的编写。
下文将详细介绍 Lex & Yacc 的工作流程,以及生成解析器的过程。我们将从 Lex 根据用户定义的 patterns 生成词法分析器,词法分析器读取源代码并转换为 tokens 输出,以及 Yacc 根据用户定义的语法规则生成语法分析器等角度进行阐述。
生成词法分析器和语法分析器的过程,用户需为 Lex 提供 patterns 的定义,为 Yacc 提供语法规则文件。这两种配置都是文本文件,结构相同,分为三个部分。我们将关注中间规则定义部分,并通过一个简单的例子来解释。
Lex 的输入文件中,规则定义部分使用正则表达式定义了变量、整数和操作符等 token 类型。例如整数 token 的定义,当输入字符串匹配正则表达式时,大括号内的动作会被执行,将整数值存储在变量yylval 中,并返回 token 类型 INTEGER 给 Yacc。
而 Yacc 的语法规则定义文件中,第一部分定义了 token 类型和运算符的结合性。四种运算符都是左结合,同一行的运算符优先级相同,不同行的运算符,后定义的行具有更高的优先级。语法规则使用 BNF 表达,大部分现代编程语言都可以使用 BNF 表示。
表达式解析是生成表达式的逆向操作,需要将语法树归约到一个非终结符。蚂蚁庄园案例源码Yacc 生成的语法分析器使用自底向上的归约方式进行语法解析,同时使用堆栈保存中间状态。通过一个表达式 x + y * z 的解析过程,我们可以理解这一过程。
在这一过程中,读取的 token 压入堆栈,当发现堆栈中的内容匹配了某个产生式的右侧,则将匹配的项从堆栈中弹出,将该产生式左侧的非终结符压入堆栈。这个过程持续进行,直到读取完所有的 tokens,并且只有启始非终结符保留在堆栈中。
产生式右侧的大括号中定义了该规则关联的动作,例如将三项从堆栈中弹出,两个表达式相加,结果再压回堆栈顶。这里可以使用 $position 的形式访问堆栈中的项,$1 引用第一项,$2 引用第二项,以此类推。$$ 代表归约操作执行后的堆栈顶。本例的动作是将三项从堆栈中弹出,两个表达式相加,结果再压回堆栈顶。
在上述例子中,动作不仅完成了语法解析,还完成了表达式求值。一般希望语法解析的结果是一颗抽象语法树(AST),可以定义语法规则关联的动作。这样,解析完成时,我们就能得到由 nodeType 构成的小白源码资源店抽象语法树,对这个语法树进行遍历访问,可以生成机器代码或解释执行。
至此,我们对 Lex & Yacc 的原理有了大致了解,虽然还有许多细节,如如何消除语法的歧义,但这些概念对于理解 TiDB 的代码已经足够。
下一部分,我们介绍 TiDB SQL Parser 的实现。有了前面的背景知识,对 TiDB 的 SQL Parser 模块的理解会更易上手。TiDB 使用手写的词法解析器(出于性能考虑),语法解析采用 goyacc。我们先来看 SQL 语法规则文件 parser.y,这是生成 SQL 语法解析器的基础。
parser.y 文件包含 多行代码,初看可能令人感到复杂,但该文件仍然遵循我们之前介绍的结构。我们只需要关注第一部分 definitions 和第二部分 rules。
第一部分定义了 token 类型、优先级、结合性等。注意 union 结构体,它定义了在语法解析过程中被压入堆栈的项的属性和类型。压入堆栈的项可能是终结符,也就是 token,它的类型可以是 item 或 ident;也可能是非终结符,即产生式的左侧,它的类型可以是 expr、statement、item 或 ident。
goyacc 根据这个 union 在解析器中生成对应的主力启动点源码 struct。在语法解析过程中,非终结符会被构造成抽象语法树(AST)的节点 ast.ExprNode 或 ast.StmtNode。抽象语法树相关的数据结构定义在 ast 包中,它们大都实现了 ast.Node 接口。
ast.Node 接口有一个 Accept 方法,接受 Visitor 参数,后续对 AST 的处理主要依赖这个 Accept 方法,以 Visitor 模式遍历所有的节点以及对 AST 做结构转换。例如 plan.preprocess 是对 AST 做预处理,包括合法性检查以及名字绑定。
union 后面是对 token 和非终结符按照类型分别定义。第一部分的最后是对优先级和结合性的定义。文件的第二部分是 SQL 语法的产生式和每个规则对应的 aciton。SQL 语法非常复杂,大部分内容都是产生式的定义。例如 SELECT 语法的定义,我们可以在 parser.y 中找到 SELECT 语句的产生式。
完成语法规则文件 parser.y 的定义后,使用 goyacc 生成语法解析器。TiDB 对 lexer 和 parser.go 进行封装,对外提供 parser.yy_parser 进行 SQL 语句的解析。
最后,我们通过一个简单的例子,使用 TiDB 的 SQL Parser 进行 SQL 语法解析,构建出抽象语法树,并通过 visitor 遍历 AST。我实现的 visitor 只输出节点的类型,运行结果依次输出遍历过程中遇到的节点类型。
了解 TiDB SQL Parser 的实现后,我们有可能实现当前不支持的语法,如添加内置函数。这为我们学习查询计划以及优化打下了基础。希望这篇文章对读者有所帮助。
作者介绍:马震,金蝶天燕架构师,负责中间件、大数据平台的研发,今年转向 NewSQL 领域,关注 OLTP/AP 融合,目前在推动金蝶下一代 ERP 引入 TiDB 作为数据库存储服务。
golang chan 最详细原理剖析,全面源码分析!看完不可能不懂的!
大纲
概述
chan 是 golang 的核心结构,是与其他高级语言区别的显著特色之一,也是 goroutine 通信的关键要素。尽管广泛使用,但对其深入理解的人却不多。本文将从源码编译器的视角,全面剖析 channel 的用法。
channel 的本质
从实现角度来看,golang 的 channel 实质上是环形队列(ringbuffer)的实现。我们将 chan 称为管理结构,channel 中可以放置任何类型的对象,称为元素。
channel 的使用方法
我们从 channel 的使用方式入手,详细介绍 channel 的使用方法。
channel 的创建
创建 channel 时,用户通常有两种选择:创建带有缓冲区和不带缓冲区的 channel。这对应于 runtime/chan.go 文件中的 makechan 函数。
channel 入队
用户使用姿势:对应函数实现为 chansend,位于 runtime/chan.go 文件。
channel 出队
用户使用姿势:对应函数分别是 chanrecv1 和 chanrecv2,位于 runtime/chan.go 文件。
结合 select 语句
用户使用姿势:对应函数实现为 selectnbsend,位于 runtime/chan.go 文件中。
结合 for-range 语句
用户使用姿势:对应使用函数 chanrecv2,位于 runtime/chan.go 文件中。
源码解析
以上,我们通过宏观的用户使用姿势,了解了不同使用姿势对应的不同实现函数,接下来将详细分析这些函数的实现。
makechan 函数
负责 channel 的创建。在 go 程序中,当我们写类似 v := make(chan int) 的初始化语句时,就会调用不同类型对应的初始化函数,其中 channel 的初始化函数就是 makechen。
runtime.makechan
定义原型:
通过这个,我们可以了解到,声明创建一个 channel 实际上是得到了一个 hchan 的指针,因此 channel 的核心结构就是基于 hchan 实现的。
其中,t 参数指定元素类型,size 指定 channel 缓冲区槽位数量。如果是带缓冲区的 channel,那么 size 就是槽位数;如果没有指定,那么就是 0。
makechan 函数执行了以下两件事:
1. 参数校验:主要是越界或 limit 的校验。
2. 初始化 hchan:分为三种情况:
所以,我们看到除了 hchan 结构体本身的内存分配,该结构体初始化的关键在于四个字段:
hchan 结构
makechan 函数负责创建了 chan 的核心结构-hchan,接下来我们将详细分析 hchan 结构体本身。
在 makechan 中,初始化时实际上只初始化了四个核心字段:
我们使用 channel 时知道,channel 常常会因为两种情况而阻塞:1)投递时没有空间;2)取出时还没有元素。
从以上描述来看,就涉及到 goroutine 阻塞和 goroutine 唤醒,这个功能与 recvq,sendq 这两个字段有关。
waitq 类型实际上是一个双向列表的实现,与 linux 中的 LIST 实现非常相似。
chansend 函数
chansend 函数是在编译器解析到 c <- x 这样的代码时插入的,本质上就是把一个用户元素投递到 hchan 的 ringbuffer 中。chansend 调用时,一般用户会遇到两种情况:
接下来,我们看看 chansend 究竟做了什么。
当我们在 golang 中执行 c <- x 这样的代码,意图将一个元素投递到 channel 时,实际上调用的是 chansend 函数。这个函数分几个场景来处理,总结来说:
关于返回值:chansend 返回值标明元素是否成功入队,成功则返回 true,否则 false。
select 的提前揭秘:
golang 源代码经过编译会变成类似如下:
而 selectnbasend 只是一个代理:
小结:没错,chansend 功能就是这么简单,本质上就是一句话:将元素投递到 channel 中。
chanrecv 函数
对应的 golang 语句是 <- c。该函数实现了 channel 的元素出队功能。举个例子,编译对应一般如下:
golang 语句:
对应:
golang 语句(这次的区别在于是否有返回值):
对应:
编译器在遇到 <- c 和 v, ok := <- c 的语句时,会换成对应的 chanrecv1,chanrecv2 函数,这两个函数本质上都是一个简单的封装,元素出队的实现函数是 chanrecv,我们详细分析这个函数。
chanrecv 函数的返回值有两个值,selected,received,其中 selected 一般作为 select 结合的函数返回值,指明是否要进入 select-case 的代码分支,received 表明是否从队列中成功获取到元素,有几种情况:
selectnbsend 函数
该函数是 c <- v 结合到 select 时的函数,我们使用 select 的 case 里面如果是一个 chan 的表达式,那么编译器会转换成对应的 selectnbsend 函数,如下:
对应编译函数逻辑如下:
selectnbsend 本质上也就是个 chansend 的封装:
chansend 的内部逻辑上面已经详细说明过,唯一不同的就是 block 参数被赋值为 false,也就是说,在 ringbuffer 没有空间的情况下也不会阻塞,直接返回。划重点:chan 在这里不会切走执行权限。
selectnbrecv 函数
该函数是 v := <- c 结合到 select 时的函数,我们使用 select 的 case 里面如果是一个 chan 的表达式,那么编译器会转换成对应的 selectnbsrecv 函数,如下:
对应编译函数逻辑如下:
selectnbrecv 本质上也就是个 chanrecv 的封装:
chanrecv 的内部逻辑上面已经详细说明过,在 ringbuffer 没有元素的情况下也不会阻塞,直接返回。这里不会因此而切走调度权限。
selectnbrecv2 函数
该函数是 v, ok = <- c 结合到 select 时的函数,我们使用 select 的 case 里面如果是一个 chan 的表达式,那么编译器会转换成对应的 selectnbrecv2 函数,如下:
对应编译函数逻辑如下:
selectnbrecv2 本质上是个 chanrecv 的封装,只不过返回值不一样而已:
chanrecv 的内部逻辑上面已经详细说明过,在 ringbuffer 没有元素的情况下也不会阻塞,直接返回。这里不会因此而切走调度权限。selectnbrecv2 与 selectnbrecv 函数的不同之处在于还有一个 ok 参数指明是否获取到了元素。
chanrecv2 函数
chan 可以与 for-range 结合使用,编译器会识别这种语法。如下:
这个本质上是个 for 循环,我们知道 for 循环关键是拆分成三个部分:初始化、条件判断、条件递进。
那么在我们 for-range 和 chan 结合起来之后,这三个关键因素又是怎么理解的呢?简述如下:
init 初始化:无
condition 条件判断:
increment 条件递进:无
当编译器遇到上面 chan 结合 for-range 写法时,会转换成 chanrecv2 的函数调用。目的是从 channel 中出队元素,返回值为 received。首先看下 chanrecv2 的实现:
chan 结合 for-range 编译之后的伪代码如下:
划重点:从这个实现中,我们可以获取一个非常重要的信息,for-range 和 chan 的结束条件只有这个 chan 被 close 了,否则一直会处于这个死循环内部。为什么?注意看 chanrecv 接收的参数是 block=true,并且这个 for-range 是一个死循环,除非 chanrecv2 返回值为 false,才有可能跳出循环,而 chanrecv2 在 block=true 场景下返回值为 false 的唯一原因只有:这个 chan 是 close 状态。
总结
golang 的 chan 使用非常简单,这些简单的语法糖背后其实都是对应了相应的函数实现,这个翻译由编译器来完成。深入理解这些函数的实现,对于彻底理解 chan 的使用和限制条件是必不可少的。深入理解原理,知其然知其所以然,你才能随心所欲地使用 golang。