go内核源码解析之源码阅读分析技巧
本文主要分析的是源码阅读的技巧,不会具体分析源码的实现细节,只会分享阅读技巧,我个人认为技巧远比看别人分析总结更重要,所以本文主以技巧为主分享;
个人经验上的实践体验总结
- 模仿: 优秀的开源程序源码的设计编码是一种好的学习资料,我常会阅读源码,从中寻找设计思路和设计灵感
- 解决问题: 我们有一些程序在开发的时候会遇到扩展,或者使用等相关问题,有些问题的异常比较隐蔽,不是panic直接性的抛出,因此需要从源码中去了解并解决问题
- 面试:面试.....基本上大大小小的面试都会问这些相关的问题,特别是go的gc几乎场场遇见
0. 现状
go的设计很好,就是在源码的阅读上需要些技巧,不然就不知道其所以然,咋们先讲讲现状:
我们就以之前分享过的优化go生成随机数中提到的关于math/rand进行分析;
package utils
import (
"time"
"math/rand"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
func docGoRandInt32(maxN int) int {
return rand.Intn(maxN)
}
如上就是rand较为朴素的用法;现在我们想要看它如何实现的,以及底层是哪儿用到锁的【哪儿用到锁,这很关键】..不对应该是你要看它哪里有用到锁;然后你就先点击rand.Init()方法,就跳转到GOPOOT/src/math/rand/rand.go
func Int31n(n int32) int32 { return globalRand.Int31n(n) }
func Intn(n int) int { return globalRand.Intn(n) }
func Float64() float64 { return globalRand.Float64() }
func Float32() float32 { return globalRand.Float32() }
func Perm(n int) []int { return globalRand.Perm(n) }
你不知道上面的方法是啥,然后你再继续点击globalRand.Intn(n)
func (r *Rand) Intn(n int) int {
if n <= 0 {
panic("invalid argument to Intn")
}
if n <= 1<<31-1 {
return int(r.Int31n(int32(n)))
}
return int(r.Int63n(int64(n)))
}
后面你又继续点击r.Int31n并沿着它的方法往下点
func (r *Rand) Int63() int64 { return r.src.Int63() }
func (r *Rand) Int31() int32 { return int32(r.Int63() >> 32) }
func (r *Rand) Int31n(n int32) int32 {
if n <= 0 {
panic("invalid argument to Int31n")
}
if n&(n-1) == 0 { // n is power of two, can mask
return r.Int31() & (n - 1)
}
max := int32((1 << 31) - 1 - (1<<31)%uint32(n))
v := r.Int31()
for v > max {
v = r.Int31()
}
return v % n
}
最后点击func (r *Rand) Int63() int64 { return r.src.Int63() }中的r.src.Int63()发现是一个接口...
type Source interface {
Int63() int64
Seed(seed int64)
}
。。。然后你瞬间懵了,这还怎么找;臣妾做不到呀...
1. 总结
对于go的源码阅读还是具有相对技巧
- 先确定目标
- 源码探索讲究适量而止,切记不可死磕往死里点
- 一定要看方法名,一定要看注释;
- 不明来历看继承,看初始化、看特殊方法及相关特征
- 巧用程序提供的打印函数
- 记录过程,及调度链
- 暂时跳过不会的,不知道的,或者尝试猜测
- 善用工具-goland
技巧与PHP源码阅读技巧文章分析大体是一致
其中是1,2,3点是非常重要基本上对于很多相关的语言的第一阅读都是可以运用到的,一样适用,特别在不能运行代码的时候非常有帮助;
唯一与之前有区别的关键在于go阅读源码的时候4,6需要时刻注意,6点是对阅读理解go源码我认为是一个比较关键的地方;【看后面分析会解释】
细节拆分具体思路
【如下摘抄之前写的内容】
- 先确定目标
这是非常关键关键关键!!!的第一个点,因为很多同志,知道我要去看源码一通点击下又回归原点,最后自己被自己转晕了;
其关键问题是不清楚自己到底是想看那个功能实现过程没有什么目标,因此对于目标的清晰是非常重要的事情;
- 源码探索讲究适量而止,切记不可死磕往死里点
这一步,主要是针对探索的过程设计,第一次看的同时往往是看到一个方法就点一个方法,然后发现还有方法再点一个方法,就这样一直点击下去;
也忘了自己是谁,是在哪里,为什么我要看源码?(是的话评论扣个666)
对于源码阅读一定要注意适量而止,源码的阅读需要基于第一个点为主线;主线中往往会随带较多的分支,而分支多了就会迷惑大家,这里建议对于每个方法最多点击三级,在第一次对方法分析的时候;
三级主要是指比如A方法,在A方法中含有(B,C,D)等方法;对于A方法的查阅视为1级,第二级则是对B,C,D的点击阅读,第三级就是对B,C,D方法内部调用的方法去查看;
当大概了解了A方法中B,C,D方法的情况再依据其源码阅读带来的信息去分辨主线,这样就避免死磕往死里点;避免出现爱的魔力转圈圈
- 一定要看方法名,一定要看注释;
对于这一条,大部分同志对于开源程序,很少去关注(我曾经也是);
首先我们需要理解;一个方法的封装(且不谈方法里面是何种牛马蛇神)是具有其关键性质的功能及作用的;
而优秀的开源程序的程序往往会把方法的功能及相关的说明以注释和方法名的方式传达给了阅读者;
当然,也不排除有英语不是很会的也问题不大,翻译安排
结合第二步,当我们点击了一个方法之后可以先看看方法的注释,并对方法名翻译了解它干了什么;有一些方法我们实际只需要看方法名就即可,还是不理解的时候才进一步点击往下看,往下看的时候也需主要适量而止;
- 不明来历看继承,看初始化、看特殊方法及相关特征
在源码中阻碍我们对于程序理解的就是这些特殊存在;
在方法中不乏还有变量调用有如全局/局部属性,注意也包含方法;最麻烦的问题主要是那些“来历不明的方法和属性”
在go中需要时刻注意init的“暗度陈仓”,因为很多不明来历都可能是它的功劳
- 巧用程序提供的打印函数
这是我认为对程序代码调试比较好的技巧;在程序源码阅读中,在第四点提到会存在不明来历的属性和方法;
那么我们可以借助打印方法可以尝试确定对应属性的来源或者其不明方法的作用是什么;
可以把相关调用的变量利用打印函数打印参数信息以此来观察和探究属性背后的源头,但这个也要分语言不是特别万能;
但是有一点是很有用的,就是我们可以利用打印了解程序的执行和参数的变化过程
比如:
func a(num){
fmt.Println(num) // 处理前
// 对num处理
fmt.Println(num); // 处理后
}
- 记录过程,及调度链
这一点主要是方便自己回顾;
在开源程序及开源框架中整体的调度链一般都很复杂,因此对于每个方法的作用和调度过程,建议可以绘制流程图以及相关的说明这样就可以方便日后温故而知新
对于第6点往往应该是配合一个整体的调度流程,本文例子....这个作者很懒,懒得画了;
- 暂时跳过不会的,不知道的 或者尝试猜测
在阅读源码的时候经常会遇到不会的情况,以及看不懂不是特别能理解为什么是这样或者是做什么的;
这个时候我的建议是如果你通过前面6个步骤还是不理解,就直接跳过;
程序的学习并不是一定要把某个点理解好了才往后学习,在我们实际学习中可能暂时不会但是之后就会了;“用着,用着,用着就会了”
另外也可以在看的时候尝试根据方法名或能够看到了解的相关信息作为线索猜测其作用
- 善用工具-goland
有些时候你需要快速了解程序的某一些设置,但是可能阅读的是一个接口就不是很方便再继续阅读下去,这个时候我们就可以利用goland这个工具帮助我们去查找对应的结构体并进行分析
02. 实践运用-了解math/rand底层哪儿的实现情况哪儿用到锁
在之前的话题中提到关于math/rand底层是用到了锁机制,具体是哪儿呢?
GOPOOT/src/math/rand/rand.go
func Intn(n int) int { return globalRand.Intn(n) }
func (r *Rand) Intn(n int) int {
// ..
if n <= 1<<31-1 {
return int(r.Int31n(int32(n)))
}
return int(r.Int63n(int64(n)))
}
func (r *Rand) Int63() int64 { return r.src.Int63() }
func (r *Rand) Int31() int32 { return int32(r.Int63() >> 32) }
func (r *Rand) Int31n(n int32) int32 {
// ..
max := int32((1 << 31) - 1 - (1<<31)%uint32(n))
// ..
return v % n
}
type Source interface {
Int63() int64
Seed(seed int64)
}
上面是之前分析过程中的代码;目前存在的问题就是,分析到Int63()方法的时候通过工具点击发现是一个接口,并没有指向具体实现的结构体方法;
- 采用技巧5,7,8【善用工具-goland,巧用程序提供的打印函数,尝试猜测】
这一步我们可以直接利用goland帮助我们测试,通过
点击Source查看实现它的结构体有哪些,这个时候我们可以看到有很多结构体,而我们的目标结构体也在其中;如何区分呢?
首先你明确你目前所看的源码对应的包,及对应的目录;目前我们是在看math/rand包,在GOROOT/src/math/rand目录,go的结构体对接口的实现上一般是同目录或者当前目录的下级目录的结构体实现的;
根据查找发现是存在多个可选项,这个时候我们根据接口名再次排查一部分
这个时候就有两个可选项:“source in /rand/rand.go” 不选择是因为目录不对;我们可以先选择第一个点击
可以明确的发现是确实用了锁机制,但真的是这个结构体嘛?这个时候我们可以利用5打印;
根据之前的分析是会调用Source.Int63方法,因此我们可以在这个方法中增加打印的方法;
type lockedSource struct {
lk sync.Mutex
src *rngSource
}
func (r *lockedSource) Int63() (n int64) {
println("dn-jinmin 太帅气了")
r.lk.Lock()
n = r.src.Int63()
r.lk.Unlock()
return
}
测试代码
import (
"testing"
"time"
"math/rand"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
func docGoRandInt32(maxN int) int {
return rand.Intn(maxN)
}
func TestMRand(t *testing.T) {
docGoRandInt32(26)
}
测试结果:
还真是。。那这个时候我们就可以肯定了
我们首先用8工具查找实现的元素,利用7排除,通过5调试;
- 利用1,2,3,4,6
1,2我提的很少其实一直都有存在只是没有刻意的去解释;因为目前这个太简单...没什么意义;重点讲讲4,6;
在我们第一次点击rand.Intn方法
func init() {
rand.Seed(time.Now().UnixNano())
}
func docGoRandInt32(maxN int) int {
return rand.Intn(maxN)
}
进入到math/rand中之后,这个时候不要往下点,而是选择4,6;
func Intn(n int) int { return globalRand.Intn(n) }
去看globalRand
var globalRand = New(&lockedSource{src: NewSource(1).(*rngSource)})
我们就很自然的发现是创建lockedSource;然后利用6将globalRand = New(&lockedSource{src: NewSource(1).(*rngSource)})记录
这里我们还需要继续分析,globalRand本身是变量那么自然就会记录对应的结构体或信息,所以我们需要看New做了什么,而NewSource不用看,因为rngSource已经给出答案了;也就是这里用了lockedSource与rngSource
type Rand struct {
src Source
s64 Source64 // non-nil if src is source64
readVal int64
readPos int8
}
func New(src Source) *Rand {
s64, _ := src.(Source64)
return &Rand{src: src, s64: s64}
}
在运行之后等到Rand,并且设置了Rand属性src为lockedSource;这个时候我们再回去看之前的调度;
注意!!要做好记录记录Rand以及相关的属性值;
我们再往下分析的时候遇到下面的情况,就可以知道是调用哪儿的方法
func (r *Rand) Int63() int64 { return r.src.Int63() }
0x. 调试注意
对于源码调试的时候,打印输出的时候;如果属于内核建议用println,如果说非内核用fmt.Println就行,内核用println主要是避免清除调试的时候包忘了删除影响整个程序的运行并且有些不支持fmt包;
当然大部分是支持fmt包的,在打印结构体上可以考虑fmt.Println的打印方式
.