golang随波逐流之cache2go知识点解读

上篇介绍了cache2go的代码逻辑,梳理代码逻辑时也能从中了解golang的一些语法以及使用技巧,本篇集中将源码中涉及到的知识点梳理下。

基础语法

结构体

在mycachedapp.go文件中,看到的第一行有效代码是type myStruct struct,感觉有点像C语言中的结构体。
这个语句块定义了一个结构体,结构体是golang中一个比较重要的知识点,功能类似于JAVA中的类。

使用type xName struct{}定义一个结构体,mycachedapp.go中结构体相关的代码如下:

1
2
3
4
5
6
type myStruct struct {
text string
moreData []byte
}
// 实力化myStruct的对象
val := myStruct{"This is a test!", []byte{}}

上述代码声明了一个名字为myStruct的结构体,其成员变量为string类型的textbyte类型的数组moreData
紧着这初始化了一个myStruct对象并在初始化时对成员变量赋值,默认是按照变成声明的顺序依次赋值,也可以显示的指定变量进行赋值,例如val := myStruct{text: "This xx"},未赋值的为类型的默认值,有点类似python。

结构体除了可以定义自己的成员变量,也可以定义成员方法。成员发方法的定义与普通函数的定义类似,只是在关键字func和函数名之间增加了函数归属哪个结构体的信息,如下:

1
2
3
4
5
6
7
8
9
10
// 定义一个结构体
type Rect struct {
x, y float64
width, height float64
}
// 定义该结构体的成员方法
// 在关键字func和方法名之间增加方法归属的结构体
func (r *Rect) Area() float64 {
return r.width * r.height
}

需要注意的是结构体虽然类似其他语言中的类,但是golang结构体中并没有构造函数的概念,对象的创建通常交由一个全局的创建函数来完成,以NewXXX来命名,表示”构造函数”,例如:

1
2
3
4
5
6
// 全局函数
func NewRect(x, y, width, height float64) *Rect {
return &Rect{x, y, width, height}
}
// 通过"构造函数"创建Rect
rect := NewRect(3.3, 4.4, 5.5, 6.6)

新类型可以像结构体那样新建,也可以基于已有的类型进行创建一个新的类型,语法为type NewType Typecachetable.go文件中的CacheItemPairList就是基于已有类型进行新建的,代码如下:

1
2
3
4
5
6
type CacheItemPair struct {
Key interface{}
AccessCount int64
}

type CacheItemPairList []CacheItemPair

与基于已有类型新建类型的写法类似的是类型别名,其语法为type alias = Type,注意这里是等号。而且新建类型时Type已经发生了变化,而别名的Type并没有发生变化。

数组 VS 切片

数组几乎是所有编程语言中常用的数据结构,想必大家对其也比较了解,可以通过索引快速访问其值。数组的声明方式为var variable_name [SIZE] variable_type,代码如下:

1
2
3
4
5
6
var x [32]byte =
var y [1000]*float64
var z [3][5]int
var w [2][2][2]float64
var a [10]int = [10]int{}
arr := [...]int{1,2,3,4,5} // 长度为5,...代表自动计算长度

数组在使用过程中有个诟病就是长度在初始化时需要指定,并且后续无法改变。为了解决此问题各种语言都增加其它数据结构,golang中增加的是切片(slice)。
切片有一个初始数据长度,使用len函数获取,还有一个预留空间长度,预留空间用光之后会自动扩容,使用cap函数获取预留空间长度。切片的创建有两种方式,一种是直接创建,另一种是根据数组创建。

  • 直接创建需要使用关键字make,代码为var mySlice []int or mySlice1 := make([]int, 5)
  • 根据数组创建的话需要先创建一个数据,然后将数组的值赋值给切片,代码为
1
2
3
4
var myArray [10]int = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} // 基于数组创建一个数组切片
var mySlice []int = myArray[:5]
// or
mySlice := []int{1, 2, 3, 4, 5} // 注意这里中括号中没有长度,如果有长度就变成数组的创建了

数组和切片还有一个区别就是数组作为参数或者变量之间赋值时,是复制一份,相互之间不影响,例如:

1
2
3
var a = [3]int{1, 2, 3} var b = a
b[1]++
fmt.Println(a, b) // a和b之间不受影响,print [1 2 3] [1 3 3]

但是切片却可以在参数传递中,将修改的结果作用在同一份数据上,例如:

1
2
3
4
5
6
7
8
9
10
11
func main() {
slice1 := []int{6,6,6}
fmt.Println(slice1)
modify(slice1)
fmt.Println(slice1) // 输出1,6,6
}

func modify(slice1 []int){
fmt.Println(slice1)
slice1[0]=1
}

切片slice表现出来的现象有点像引用类型,但是要切记slice并不是引用类型,只是表现出来引用语义而已。在golang中其实只有值传递,slice表现出来的引用语义其实只是golang的一个语法糖,具体看下这个语法糖是怎么实现的。
slice的声明如下:

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

slice结构体有一个unsafe.Poiniter的指针,他指向一个数组,剩下两个属性一个代表数据的长度另一个代表容量。
当slice进行变量传递或者参数传递时,依然是将其值拷贝一份,此时unsafe.Poiniter类型的指针array将其存储的数组地址也拷贝了一份,所以对传递之后的变量进行修改会影响原始slice的结果。示意图如下:
slice变量
其中[]int是一个数组,存放slice中的数据,slice1是slice的一个实例,slice1slice2进行了赋值。赋值时进行了值传递,将slice1中的数据拷贝了一份给slice2slice1中的array指针将其值也就是[]int的地址拷贝了一份给slice2中的array指针,这就类似指针的赋值,所以对slice2的修改可以影响到slice1
如果不想拷贝一份则可以使用指针传递,直接传递slice的指针,代码如下:

1
2
3
4
5
6
7
8
9
10
11
func main() {
slice1 := []int{6,6,6}
fmt.Println(slice1)
modify(&slice1)
fmt.Println(slice1) // 输出1,6,6
}

func modify(slice1 *[]int){
fmt.Println(*slice1)
(*slice1)[0] =1
}

最后记住关键的一句话golang只有值传递

进阶

Timer

Timer是一个定时器,在cache2go中用来定时检查缓存中对象的过期时间。在golang中有两种定时器,分别为TimerTickerTimer只触发一次,如果还需继续触发需要使用Reset进行重置,而Ticker是间隔特定时间触发。

Timer可以通过NewTimer创建或者使用AfterFunc创建。使用NewTimer创建时,当Timer到期时,会将当时的时间发送给channel。
Timer类型定义如下:

1
2
3
4
type Timer struct {
C <-chan Time // The channel on which the time is delivered.
r runtimeTimer
}

这里的C就是上文中,说的会将当前时间发送的channel,NewTimer创建Timer的demo如下:

1
timer := time.NewTimer(time.Second * 3)

这种形式创建的Timer,使用时需要监听channel C中的信号,使用demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
d := time.Duration(time.Second*2)
t := time.NewTimer(d)
defer t.Stop()

for {
// 等待channel C中的信号
<- t.C

fmt.Println("do someting...")

// 重置定时器,因为Timer是一次触发
// Reset 会先调用 stopTimer 再调用 startTimer,类似于废弃之前的定时器,重新启动一个定时器
t.Reset(time.Second*2)
}
}

cache2go中并没有使用这种方式创建Timer,而是使用了AfterFunc,代码如下:

1
2
3
table.cleanupTimer = time.AfterFunc(smallestDuration, func() {
go table.expirationCheck()
})

AfterFunc传入两个参数,第一个是时间参数,间隔多久触发,第二个是执行逻辑,使用一个函数作为参数。当Timer经过smallestDuration长时间之后,会执行协程table.expirationCheck(),这个也是一次触发,所以每次都需要通过AfterFunc再次创建一个Timer。

另一个定时器是Ticker,是周期性的触发,需要注意的是除非程序终止否则定时器会一直触发,停止时应该调用Ticker.Stop来释放相关资源。Ticker使用NewTicker创建,demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
t := time.NewTicker(2*time.Second)
defer t.Stop()

fmt.Println(time.Now())
time.Sleep(5*time.Second)

for {
select {
case <-t.C:
fmt.Println(time.Now())
}
}

}

go协程

go关键字表示协程goroutine,是Go语言中的轻量级线程实现,由Go运行时(runtime)管理。

协程是对线程的一种补充,线程是由系统内核管理的,线程之间的切换会触发用户态到内核态的切换,此时需要保存一些上下文信息,比较消耗资源,而且线程太多也会达到线程上限的瓶颈。
而协程完全是由程序所控制(也就是在用户态执行,所以也叫用户态线程),是在应用层模拟线程,因此避免了用户态和内核态的频繁切换。
程序最终是要由CPU及相关存储器进行执行,而协程在用户态执行,不由系统内核调度,那么他是如何调度到CPU的?在golang中,协程是基于线程的,并且实现了一个逻辑流调度(也就是协程调度器),可以像调度线程那样在用户态也能根据代码的执行状态进行调度协程,更充分的利用并发的优势,又避免反复系统调用,还有进程切换造成的开销

更通俗的说协程是子函数的一个特例,在线程中函数之间的调用只需要将各自的执行状态压入栈中即可,所以协程切换时也是压栈操作,比较轻量。

了解过协程之后,发现协程确实是个好东西,更好的是golang从编译器和语言基础库多个层面对协程做了实现,例如golang对各种io函数进行了封装,内部调用操作系统的异步io函数,根据这些函数返回的状态将现有的执行序列压栈,让线程去拉另一个协程执行,是目前各类有协程概念的语言中实现的最完整和成熟的,最重要的是使用起来很方便,只需在普通函数之前添加go关键字即可,使开发者更多的关注业务逻辑的实现,更少的在这些关键的基础构件上耗费太多精力。

cache2go中只有一处使用了协程,代码如下:

1
2
3
4
5
6
func (table *CacheTable) expirationCheck() {
...
table.cleanupTimer = time.AfterFunc(smallestDuration, func() {
go table.expirationCheck()
})
}

go关键字加普通函数代表协程,不同于函数调用,不会等待调用函数结束,而是继续执行剩下的代码片段,被调用的函数会在适当的时间被调度。需要注意的是,如果这个函数有返回值,那么这个 返回值会被丢弃。

您的肯定,是我装逼的最大的动力!