第一章 语言基础
defer
defer的延迟调用特性:
1. 关键字 defer 用于注册延迟调用。 2. 这些调用直到 return 前才被执。因此,可以用来做资源清理。 3. 多个defer语句,按先进后出的方式执行。 4. defer语句中的变量,在defer声明时就决定了。
defer的用途:
1. 关闭文件句柄 2. 锁资源释放 3. 数据库连接释放
go语言规范要求源文件采用UTF8编码
main包中的main函数默认是每一个可执行程序的入口
go函数参数不能传引用,可以传地址给指针
1.3 数组、字符串和切片
数组的长度是数组类型的组成部分,所以在go中很少直接使用数组。切片是可以动态增长和收缩的序列。
一个数组名变量即表示整个数组,并不是隐式指向第一个元素的指针。所以在函数传参或者直接复制的时候如果数组过大整个copy的开销也会大,这个时候需要用指针来复制。通过指向数组的指针访问数组中的元素和直接使用数组来访问写法相似。
一个字符串是一个不可改变的字节序列,是一个只读的字节数组。每个字符串的长度虽然固定,但是长度并不是类型的一部分。字符串其实是一个结构体,由两个信息组成:字符串指向的底层字节数组;字符串的字节长度。字符串虽然不是切片,但是支持切片操作。str[a:b] 前闭后开区间。
简单来说,切片(slice)就是一种简化版的动态数组。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
切片比字符串多了一个Cap成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)。切片的类型和长度无关
添加切片元素
内置的泛型函数append
可以在切片的尾部追加N
个元素:
var a []int
a = append(a, 1) // 追加1个元素
a = append(a, 1, 2, 3) // 追加多个元素, 手写解包方式
a = append(a, []int{1,2,3}...) // 追加一个切片, 切片需要解包
不过要注意的是,在容量不足的情况下,append
的操作会导致重新分配内存,可能导致巨大的内存分配和复制数据代价。即使容量足够,依然需要用append
函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。
在Golang中,数组是值类型而切片是引用类型。因此值的复制与切片的复制并不相同。对于值类型的数组来说,变量指向的并不是第一个元素的指针,而是整个数组。切片的复制可以通过内建函数实现
func copy(dst, src []Type) int
1)值类型:基本数据类型int, float,bool, string以及数组和struct。值类型:变量直接存储值,内容通常在栈中分配
2)引用类型:指针,slice,map,chan等都是引用类型。引用类型:变量存储的是一个地址,这个地址存储最终的值,内容通常在堆上分配,通过GC回收
除了在切片的尾部追加,我们还可以在切片的开头添加元素:
var a = []int{1,2,3}
a = append([]int{0}, a...) // 在开头添加1个元素
a = append([]int{-3,-2,-1}, a...) // 在开头添加1个切片
在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制1次。
没有专门的内置函数用于扩展切片的容量,append
本质是用于追加元素而不是扩展容量,扩展切片容量只是append
的一个副作用。
删除切片元素
对于删除中间的元素,需要对剩余的元素进行一次整体挪动,同样可以用append
或copy
原地完成:
a = []int{1, 2, 3, ...}
a = append(a[:i], a[i+1:]...) // 删除中间1个元素
a = append(a[:i], a[i+N:]...) // 删除中间N个元素
a = a[:i+copy(a[i:], a[i+1:])] // 删除中间1个元素
a = a[:i+copy(a[i:], a[i+N:])] // 删除中间N个元素
删除开头的元素和删除尾部的元素都可以认为是删除中间元素操作的特殊情况。
切片内存技巧
在本节开头的数组部分我们提到过有类似[0]int
的空数组,空数组一般很少用到。但是对于切片来说,len
为0
但是cap
容量不为0
的切片则是非常有用的特性。当然,如果len
和cap
都为0
的话,则变成一个真正的空切片,虽然它并不是一个nil
值的切片。在判断一个切片是否为空时,一般通过len
获取切片的长度来判断,一般很少将切片和nil
值做直接的比较。
切片高效操作的要点是要降低内存分配的次数,尽量保证append
操作不会超出cap
的容量,降低触发内存分配的次数和每次分配内存大小。
1.4 函数、方法和接口
函数对应操作序列,是程序的基本组成元素。Go语言中的方法是依托于类型的,必须在编译时静态绑定。接口定义了方法的集合,这些方法依托于运行时的接口对象,因此接口对应的方法是在运行时动态绑定的。Go语言通过隐式接口机制实现了鸭子面向对象模型。Go语言程序的初始化和执行总是从main.main
函数开始的。但是如果main
包导入了其它的包,则会按照顺序将它们包含进main
包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。
然后创建和初始化这个包的常量和变量,再调用包里的init
函数,如果一个包有多个init
函数的话,调用顺序未定义(实现可能是以文件名的顺序调用),同一个文件内的多个init
则是以出现的顺序依次调用(init
不是普通函数,可以定义有多个,所以也不能被其它函数调用)。最后,当main
包的所有包级常量、变量被创建和初始化完成,并且init
函数被执行后,才会进入main.main
函数,程序开始正常执行。下图是Go程序函数启动顺序的示意图:
要注意的是,在main.main
函数执行之前所有代码都运行在同一个goroutine,也就是程序的主系统线程中。因此,如果某个init
函数内部用go关键字启动了新的goroutine的话,新的goroutine只有在进入main.main
函数之后才可能被执行到。
func Inc() (v int) {
defer func(){ v++ } ()
return 42
}
其中defer
语句延迟执行了一个匿名函数,因为这个匿名函数捕获了外部函数的局部变量v
,这种函数我们一般叫闭包。闭包对捕获的外部变量并不是传值方式访问,而是以引用的方式访问。
Go语言中指针不再是固定不变的了(因此不能随意将指针保持到数值变量中,Go语言的地址也不能随意保存到不在GC控制的环境中,因此使用CGO时不能在C语言中长期持有Go语言对象的地址)。
C/C++编程经验的程序员需要强调的是:不用关心Go语言中函数栈和堆的问题,编译器和运行时会帮我们搞定;同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化,特别是在你不期望它变化的时候。
Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。
Go语言中,通过在结构体内置匿名的成员来实现继承:
import "image/color"
type Point struct{ X, Y float64 }
type ColoredPoint struct {
Point
Color color.RGBA
}
虽然我们可以将ColoredPoint
定义为一个有三个字段的扁平结构的结构体,但是我们这里将Point
嵌入到ColoredPoint
来提供X
和Y
这两个字段。
var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X) // "1"
cp.Point.Y = 2
fmt.Println(cp.Y) // "2"
通过嵌入匿名的成员,我们不仅可以继承匿名成员的内部成员,而且可以继承匿名成员类型所对应的方法。我们一般会将Point看作基类,把ColoredPoint看作是它的继承类或子类。不过这种方式继承的方法并不能实现C++中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。这种展开是编译期完成的, 并没有运行时代价.如果需要虚函数的多态特性,我们需要借助Go语言接口来实现。
所谓鸭子类型说的是:只要走起路来像鸭子、叫起来也像鸭子,那么就可以把它当作鸭子。Go语言中的面向对象就是如此,如果一个对象只要看起来像是某种接口类型的实现,那么它就可以作为该接口类型使用。这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不用去破坏这些类型原有的定义;当我们使用的类型来自于不受我们控制的包时这种设计尤其灵活有用。Go语言的接口类型是延迟绑定,可以实现类似虚函数的多态功能。
在前面的方法一节中我们讲到,通过在结构体中嵌入匿名类型成员,可以继承匿名类型的方法。其实这个被嵌入的匿名成员不一定是普通类型,也可以是接口类型。我们可以通过嵌入匿名的testing.TB
接口来伪造私有的private
方法,因为接口方法是延迟绑定,编译时private
方法是否真的存在并不重要。
package main
import (
"fmt"
"testing"
)
type TB struct {
testing.TB
}
func (p *TB) Fatal(args ...interface{}) {
fmt.Println("TB.Fatal disabled!")
}
func main() {
var tb testing.TB = new(TB)
tb.Fatal("Hello, playground")
}
我们在自己的TB
结构体类型中重新实现了Fatal
方法,然后通过将对象隐式转换为testing.TB
接口类型(因为内嵌了匿名的testing.TB
对象,因此是满足testing.TB
接口的),然后通过testing.TB
接口来调用我们自己的Fatal
方法。
这种通过嵌入匿名接口或嵌入匿名指针对象来实现继承的做法其实是一种纯虚继承,我们继承的只是接口指定的规范,真正的实现在运行的时候才被注入。
1.5 面向并发的内存模型
Go语言是基于消息并发模型的集大成者,它将基于CSP模型的并发编程内置到了语言中,通过一个go关键字就可以轻易地启动一个Goroutine,与Erlang不同的是Go语言的Goroutine之间是共享内存的。
1.5.1 Goroutine和系统线程
Goroutine是Go语言特有的并发体,是一种轻量级的线程,由go关键字启动。在真实的Go语言的实现中,goroutine和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了Go语言并发编程质的飞跃。
首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是2MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是没法同时兼得的。相反,一个Goroutine会以一个很小的栈启动(可能是2KB或4KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个Goroutine。
Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个Goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个runtime.GOMAXPROCS
变量,用于控制当前运行正常非阻塞Goroutine的系统线程数目。
在Go语言中启动一个Goroutine不仅和调用函数一样简单,而且Goroutine之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。
1.5.2 原子操作
所谓的原子操作就是并发编程中“最小的且不可并行化”的操作。一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的CPU指令提供保护。当然,如果仅仅是想模拟下粗粒度的原子操作,我们可以借助于sync.Mutex
来实现
atomic.AddUint64
函数调用保证了total
的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。
WaitGroup用于等待一组线程的结束。父线程调用Add方法来设定应等待的线程的数量。每个被等待的线程在结束时应调用Done方法。同时,主线程里可以调用Wait方法阻塞至所有线程结束。
sync/atomic
包对基本的数值类型及复杂对象的读写都提供了原子操作的支持。atomic.Value
原子对象提供了Load
和Store
两个原子方法,分别用于加载和保存数据,返回值和参数都是interface{}
类型,因此可以用于任意的自定义复杂类型。
Go原子操作 sync/atomic
atomic.AddUint64
函数调用保证了total
的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。
原子操作配合互斥锁可以实现非常高效的单件模式。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。
1.5.3 顺序一致性内存模型
顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行
- 所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见
在Go语言中,同一个Goroutine线程内部,顺序一致性内存模型是得到保证的。但是不同的Goroutine之间,并不满足顺序一致性内存模型,需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序,那么就说这两个事件是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序(CPU也会对一些指令进行乱序执行)。
因此,如果在一个Goroutine中顺序执行a = 1; b = 2;
两个语句,虽然在当前的Goroutine中可以认为a = 1;
语句先于b = 2;
语句执行,但是在另一个Goroutine中b = 2;
语句可能会先于a = 1;
语句执行,甚至在另一个Goroutine中无法看到它们的变化(可能始终在寄存器中)。也就是说在另一个Goroutine看来, a = 1; b = 2;
两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会有不确定的结果。
解决问题的办法就是通过同步原语来给两个事件明确排序:
func main() {
done := make(chan int)
go func(){
println("你好, 世界")
done <- 1
}()
<-done
}
当<-done
执行时,必然要求done <- 1
也已经执行。根据同一个Gorouine依然满足顺序一致性规则,我们可以判断当done <- 1
执行时,println("你好, 世界")
语句必然已经执行完成了。因此,现在的程序确保可以正常打印结果。
当然,通过sync.Mutex
互斥量也是可以实现同步的:
func main() {
var mu sync.Mutex
mu.Lock()
go func(){
println("你好, 世界")
mu.Unlock()
}()
mu.Lock()
}
可以确定后台线程的mu.Unlock()
必然在println("你好, 世界")
完成后发生(同一个线程满足顺序一致性),main
函数的第二个mu.Lock()
必然在后台线程的mu.Unlock()
之后发生(sync.Mutex
保证),此时后台线程的打印工作已经顺利完成了。
1.5.4 初始化顺序
在main.main
函数执行之前所有代码都运行在同一个Goroutine中,也是运行在程序的主系统线程中。如果某个init
函数内部用go关键字启动了新的Goroutine的话,新的Goroutine和main.main
函数是并发执行的。因为所有的init
函数和main
函数都是在主线程完成,它们也是满足顺序一致性模型的。
1.5.5 Goroutine的创建
go
语句会在当前Goroutine对应函数返回前创建新的Goroutine.
1.5.6 基于Channel的通信
Channel通信是在Goroutine之间进行同步的主要方法。在无缓存的Channel上的每一次发送操作都有与其对应的接收操作相配对,发送和接收操作通常发生在不同的Goroutine上(在同一个Goroutine上执行2个操作很容易导致死锁)。无缓存的Channel上的发送操作总在对应的接收操作完成前发生
.
var done = make(chan bool)
var msg string
func aGoroutine() {
msg = "你好, 世界"
done <- true
}
func main() {
go aGoroutine()
<-done
println(msg)
}
可保证打印出“hello, world”。若在关闭Channel后继续从中接收数据,接收者就会收到该Channel返回的零值。因此在这个例子中,用close(c)
关闭管道代替done <- false
依然能保证该程序产生相同的行为。
对于从无缓冲Channel进行的接收,发生在对该Channel进行的发送完成之前。对于带缓冲的Channel,对于Channel的第K
个接收完成操作发生在第K+C
个发送操作完成之前,其中C
是Channel的缓存大小。
严谨的并发也应该是可以静态推导出结果的:根据线程内顺序一致性,结合Channel或sync
同步事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。如果两个事件无法根据此规则来排序,那么它们就是并发的,也就是执行先后顺序不可靠的。
解决同步问题的思路是相同的:使用显式的同步。
1.6 常见的并发模式
作为Go并发编程核心的CSP(Communicating Sequential Process,通讯顺序进程)理论的核心概念只有一个:同步通信。
在并发编程中,对共享资源的正确访问需要精确的控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而Go语言却另辟蹊径,它将共享的值通过Channel传递(实际上多个独立执行的线程很少主动共享资源)。在任意给定的时刻,最好只有一个Goroutine能够拥有该资源。数据竞争从设计层面上就被杜绝了。为了提倡这种思考方式,Go语言将其并发编程哲学化为一句口号:
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信,而应通过通信来共享内存。
这是更高层次的并发编程哲学(通过管道来传值是Go语言推荐的做法)。虽然像引用计数这类简单的并发问题通过原子操作或互斥锁就能很好地实现,但是通过Channel来控制访问能够让你写出更简洁正确的程序。
虽然管道是带缓存的,main
线程接收完成是在后台线程发送开始但还未完成的时刻。
无缓存的管道,先接收才能开始发送。
对于这种要等待N个线程完成后再进行下一步的同步操作有一个简单的做法,就是使用sync.WaitGroup
来等待一组事件。其中wg.Add(1)
用于增加等待事件的个数,必须确保在后台线程启动之前执行。调用wg.Done()
表示完成一个事件。main
函数的wg.Wait()
是等待全部的事件完成。
1.6.2 生产者消费者模型
1.6.3 发布订阅模型
发布订阅(publish-and-subscribe)模型通常被简写为pub/sub模型。在这个模型中,消息生产者成为发布者(publisher),而消息消费者则成为订阅者(subscriber),生产者和消费者是M:N的关系。在传统生产者和消费者模型中,是将消息发送到一个队列中,而发布订阅模型则是将消息发布给一个主题。
为此,我们构建了一个名为pubsub
的发布订阅模型支持包:
// Package pubsub implements a simple multi-topic pub-sub library.
package pubsub
import (
"sync"
"time"
)
type (
subscriber chan interface{} // 订阅者为一个管道
topicFunc func(v interface{}) bool // 主题为一个过滤器
)
// 发布者对象
type Publisher struct {
m sync.RWMutex // 读写锁
buffer int // 订阅队列的缓存大小
timeout time.Duration // 发布超时时间
subscribers map[subscriber]topicFunc // 订阅者信息
}
// 构建一个发布者对象, 可以设置发布超时时间和缓存队列的长度
func NewPublisher(publishTimeout time.Duration, buffer int) *Publisher {
return &Publisher{
buffer: buffer,
timeout: publishTimeout,
subscribers: make(map[subscriber]topicFunc),
}
}
// 添加一个新的订阅者,订阅全部主题
func (p *Publisher) Subscribe() chan interface{} {
return p.SubscribeTopic(nil)
}
// 添加一个新的订阅者,订阅过滤器筛选后的主题
func (p *Publisher) SubscribeTopic(topic topicFunc) chan interface{} {
ch := make(chan interface{}, p.buffer)
p.m.Lock()
p.subscribers[ch] = topic
p.m.Unlock()
return ch
}
// 退出订阅
func (p *Publisher) Evict(sub chan interface{}) {
p.m.Lock()
defer p.m.Unlock()
delete(p.subscribers, sub)
close(sub)
}
// 发布一个主题
func (p *Publisher) Publish(v interface{}) {
p.m.RLock()
defer p.m.RUnlock()
var wg sync.WaitGroup
for sub, topic := range p.subscribers {
wg.Add(1)
go p.sendTopic(sub, topic, v, &wg)
}
wg.Wait()
}
// 关闭发布者对象,同时关闭所有的订阅者管道。
func (p *Publisher) Close() {
p.m.Lock()
defer p.m.Unlock()
for sub := range p.subscribers {
delete(p.subscribers, sub)
close(sub)
}
}
// 发送主题,可以容忍一定的超时
func (p *Publisher) sendTopic(
sub subscriber, topic topicFunc, v interface{}, wg *sync.WaitGroup,
) {
defer wg.Done()
if topic != nil && !topic(v) {
return
}
select {
case sub <- v:
case <-time.After(p.timeout):
}
}
下面的例子中,有两个订阅者分别订阅了全部主题和含有"golang"的主题:
import "path/to/pubsub"
func main() {
p := pubsub.NewPublisher(100*time.Millisecond, 10)
defer p.Close()
all := p.Subscribe()
golang := p.SubscribeTopic(func(v interface{}) bool {
if s, ok := v.(string); ok {
return strings.Contains(s, "golang")
}
return false
})
p.Publish("hello, world!")
p.Publish("hello, golang!")
go func() {
for msg := range all {
fmt.Println("all:", msg)
}
} ()
go func() {
for msg := range golang {
fmt.Println("golang:", msg)
}
} ()
// 运行一定时间后退出
time.Sleep(3 * time.Second)
}
在发布订阅模型中,每条消息都会传送给多个订阅者。发布者通常不会知道、也不关心哪一个订阅者正在接收主题消息。订阅者和发布者可以在运行时动态添加,是一种松散的耦合关系,这使得系统的复杂性可以随时间的推移而增长。在现实生活中,像天气预报之类的应用就可以应用这个并发模式。
select 是 Go 中的一个控制结构,类似于用于通信的 switch 语句。每个 case 必须是一个通信操作,要么是发送要么是接收。
select 随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。一个默认的子句应该总是可运行的。
1.6.7 并发的安全退出
Go语言中不同Goroutine之间主要依靠管道进行通信和同步。要同时处理多个管道的发送或接收操作,我们需要使用select
关键字(这个关键字和网络编程中的select
函数的行为类似)。当select
有多个分支时,会随机选择一个可用的管道分支,如果没有可用的管道分支则选择default
分支,否则会一直保存阻塞状态。
1.6.8 context包
标准库增加了一个context
包,用来简化对于处理单个请求的多个Goroutine之间与请求域的数据、超时和退出等操作
context
主要用于父子任务之间的同步取消信号,本质上是一种协程调度的方式。另外在使用context
时有两点值得注意:上游任务仅仅使用context
通知下游任务不再需要,但不会直接干涉和中断下游任务的执行,由下游任务自行决定后续的处理操作,也就是说context
的取消操作是无侵入的;context
是线程安全的,因为context
本身是不可变的(immutable
),因此可以放心地在多个协程中传递使用。
1.7 错误和异常
在Go语言中,错误被认为是一种可以预期的结果;而异常则是一种非预期的结果,发生异常可能表示程序中存在BUG或发生了其它不可控的问题。Go语言推荐使用recover
函数将内部异常转为错误处理,这使得用户可以真正的关心业务相关的错误处理。
第4章 RPC和Protobuf
Protobuf因为支持多种不同的语言(甚至不支持的语言也可以扩展支持),其本身特性也非常方便描述服务的接口(也就是方法列表),因此非常适合作为RPC世界的接口交流语言。本章将讨论RPC的基本用法,如何针对不同场景设计自己的RPC服务,以及围绕Protobuf构造的更为庞大的RPC生态。
4.1.1 RPC版"Hello, World"
我们先构造一个HelloService类型,其中的Hello方法用于实现打印功能:
type HelloService struct {}
func (p *HelloService) Hello(request string, reply *string) error {
*reply = "hello:" + request
return nil
}
其中Hello方法必须满足Go语言的RPC规则:方法只能有两个可序列化的参数,其中第二个参数是指针类型,并且返回一个error类型,同时必须是公开的方法。
然后就可以将HelloService类型的对象注册为一个RPC服务:
func main() {
rpc.RegisterName("HelloService", new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
rpc.ServeConn(conn)
}
其中rpc.Register函数调用会将对象类型中所有满足RPC规则的对象方法注册为RPC函数,所有注册的方法会放在“HelloService”服务空间之下。然后我们建立一个唯一的TCP链接,并且通过rpc.ServeConn函数在该TCP链接上为对方提供RPC服务。
下面是客户端请求HelloService服务的代码:
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
err = client.Call("HelloService.Hello", "hello", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
首选是通过rpc.Dial拨号RPC服务,然后通过client.Call调用具体的RPC方法。在调用client.Call时,第一个参数是用点号链接的RPC服务名字和方法名字,第二和第三个参数分别我们定义RPC方法的两个参数。
由这个例子可以看出RPC的使用其实非常简单。
4.1.3 跨语言的RPC
Go语言的RPC框架有两个比较有特色的设计:一个是RPC数据打包时可以通过插件实现自定义的编码和解码;另一个是RPC建立在抽象的io.ReadWriteCloser接口之上的,我们可以将RPC架设在不同的通讯协议之上。这里我们将尝试通过官方自带的net/rpc/jsonrpc扩展实现一个跨语言的PPC。
首先是基于json编码重新实现RPC服务:
func main() {
rpc.RegisterName("HelloService", new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
}
}
代码中最大的变化是用rpc.ServeCodec函数替代了rpc.ServeConn函数,传入的参数是针对服务端的json编解码器。
然后是实现json版本的客户端:
func main() {
conn, err := net.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("net.Dial:", err)
}
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
var reply string
err = client.Call("HelloService.Hello", "hello", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
先手工调用net.Dial函数建立TCP链接,然后基于该链接建立针对客户端的json编解码器。
在确保客户端可以正常调用RPC服务的方法之后,我们用一个普通的TCP服务代替Go语言版本的RPC服务,这样可以查看客户端调用时发送的数据格式。比如通过nc命令nc -l 1234
在同样的端口启动一个TCP服务。然后再次执行一次RPC调用将会发现nc输出了以下的信息:
{"method":"HelloService.Hello","params":["hello"],"id":0}
这是一个json编码的数据,其中method部分对应要调用的rpc服务和方法组合成的名字,params部分的第一个元素为参数,id是由调用端维护的一个唯一的调用编号。
请求的json数据对象在内部对应两个结构体:客户端是clientRequest,服务端是serverRequest。clientRequest和serverRequest结构体的内容基本是一致的:
type clientRequest struct {
Method string `json:"method"`
Params [1]interface{} `json:"params"`
Id uint64 `json:"id"`
}
type serverRequest struct {
Method string `json:"method"`
Params *json.RawMessage `json:"params"`
Id *json.RawMessage `json:"id"`
}
在获取到RPC调用对应的json数据后,我们可以通过直接向架设了RPC服务的TCP服务器发送json数据模拟RPC方法调用:
$ echo -e '{"method":"HelloService.Hello","params":["hello"],"id":1}' | nc localhost 1234
返回的结果也是一个json格式的数据:
{"id":1,"result":"hello:hello","error":null}
其中id对应输入的id参数,result为返回的结果,error部分在出问题时表示错误信息。对于顺序调用来说,id不是必须的。但是Go语言的RPC框架支持异步调用,当返回结果的顺序和调用的顺序不一致时,可以通过id来识别对应的调用。
返回的json数据也是对应内部的两个结构体:客户端是clientResponse,服务端是serverResponse。两个结构体的内容同样也是类似的:
type clientResponse struct {
Id uint64 `json:"id"`
Result *json.RawMessage `json:"result"`
Error interface{} `json:"error"`
}
type serverResponse struct {
Id *json.RawMessage `json:"id"`
Result interface{} `json:"result"`
Error interface{} `json:"error"`
}
因此无论采用何种语言,只要遵循同样的json结构,以同样的流程就可以和Go语言编写的RPC服务进行通信。这样我们就实现了跨语言的RPC。
socket则是对TCP/IP协议的封装和应用(程序员层面上)。实际上socket是对TCP/IP协议的封装,Socket本身并不是协议,而是一个调用接口(API),通过Socket,我们才能使用TCP/IP协议。 实际上,Socket跟TCP/IP协议没有必然的联系。Socket编程接口在设计的时候,就希望也能适应其他的网络协议。所以说,Socket的出现 只是使得程序员更方便地使用TCP/IP协议栈而已,是对TCP/IP协议的抽象,从而形成了我们知道的一些最基本的函数接口,比如create、 listen、connect、accept、send、read和write等等。网络有一段关于socket和TCP/IP协议关系的说法比较容易理解:
TCP/IP也要提供可供程序员做网络开发所用的接口,这就是Socket编程接口。”
实际上,传输层的TCP是基于网络层的IP协议的,而应用层的HTTP协议又是基于传输层的TCP协议的,而Socket本身不算是协议,就像上面所说,它只是提供了一个针对TCP或者UDP编程的接口。socket是对端口通信开发的工具,它要更底层一些.
4.1.4 Http上的RPC
4.2 Protobuf
Protobuf是Protocol Buffers的简称,它是Google公司开发的一种数据描述语言,并于2008年对外开源。Protobuf刚开源时的定位类似于XML、JSON等数据描述语言,通过附带工具生成代码并实现将结构化数据序列化的功能。但是我们更关注的是Protobuf作为接口规范的描述语言,可以作为设计安全的跨语言RPC接口的基础工具。
4.2.1 Protobuf入门
通过Protobuf来最终保证RPC的接口规范和安全。Protobuf中最基本的数据单元是message,是类似Go语言中结构体的存在。在message中可以嵌套message或其它的基础数据类型的成员。
第6章 分布式系统
分布式锁
redis setnx 分布式锁
使用场景:setnx
很适合在高并发场景下,用来争抢一些“唯一”的资源。
优缺点:
zookeeper 分布式锁
使用场景:基于ZooKeeper的锁与基于Redis的锁的不同之处在于Lock成功之前会一直阻塞,这与我们单机场景中的mutex.Lock
很相似。其原理也是基于临时Sequence节点和watch API,例如我们这里使用的是/lock
节点。Lock会在该节点下的节点列表中插入自己的值,只要节点下的子节点发生变化,就会通知所有watch该节点的程序。这时候程序会检查当前节点下最小的子节点的id是否与自己的一致。如果一致,说明加锁成功了。
优缺点:这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。按照Google的Chubby论文里的阐述,基于强一致协议的锁适用于粗粒度
的加锁操作。这里的粗粒度指锁占用时间较长。
延时任务系统
最常见的时间堆一般用小顶堆实现,小顶堆其实就是一种特殊的二叉树,见图6-4
图 6-4 二叉堆结构
小顶堆的好处是什么呢?对于定时器来说,如果堆顶元素比当前的时间还要大,那么说明堆内所有元素都比当前时间大。进而说明这个时刻我们还没有必要对时间堆进行任何处理。定时检查的时间复杂度是O(1)
。
当我们发现堆顶的元素小于当前时间时,那么说明可能已经有一批事件已经开始过期了,这时进行正常的弹出和堆调整操作就好。每一次堆调整的时间复杂度都是O(LgN)
。
我们还需要把这些“定时”或是“延时”(本质也是定时)任务分发出去。下面是一种思路:
图 6-7 分布式任务分发
每一个实例每隔一小时,会去数据库里把下一个小时需要处理的定时任务捞出来,捞取的时候只要取那些task_id % shard_count = shard_id
的那些任务即可。
当这些定时任务被触发之后需要通知用户侧,有两种思路:
- 将任务被触发的信息封装为一条消息,发往消息队列,由用户侧对消息队列进行监听。
- 对用户预先配置的回调函数进行调用。
两种方案各有优缺点,如果采用1,那么如果消息队列出故障会导致整个系统不可用,当然,现在的消息队列一般也会有自身的高可用方案,大多数时候我们不用担心这个问题。其次一般业务流程中间走消息队列的话会导致延时增加,定时任务若必须在触发后的几十毫秒到几百毫秒内完成,那么采用消息队列就会有一定的风险。如果采用2,会加重定时任务系统的负担。我们知道,单机的定时器执行时最害怕的就是回调函数执行时间过长,这样会阻塞后续的任务执行。在分布式场景下,这种忧虑依然是适用的。一个不负责任的业务回调可能就会直接拖垮整个定时任务系统。所以我们还要考虑在回调的基础上增加经过测试的超时时间设置,并且对由用户填入的超时时间做慎重的审核。