http://www.zhzq.site
多个goroutine之间大部分情况是代码交叉执行,在执行过程中,可能会修改或读取共享内存变量,这样就会产生数据竞争,发生一些意外之外的结果。 举例:
package main
import (
"fmt"
"time"
)
var balance int
//存款
func Deposit(amount int) {
balance = balance + amount
}
//读取余额
func Balance() int {
return balance
}
func main() {
//小王:存600,并读取余额
go func() {
Deposit(600)
fmt.Println(Balance())
}()
//小张:存500
go func() {
Deposit(500)
fmt.Println(Balance())
}()
time.Sleep(time.Second) // 为了简单,不使用waitGroup
fmt.Println(balance) // 如果使用go run -race race.go会提示,此行也有竟态问题。因为主协程没有加waitGroup等待,所以也可能和子协程并发,所以主协程读余额也要处理
}
银行存款问题,结果可能不会是1100。如果让两个协程都执行100次。结果大概率不是110000.
解决竟态问题的两个思路:
简言之,Golang中Goroutine 可以通过 Channel 进行安全读写共享变量,或者通过原子性操作同一时间只允许一个协程操作共享变量
先说一下通过Channel通道读写共享变量的方式。
package main
import (
"fmt"
"time"
)
var balance int
//存款
func Deposit(ch chan bool, amount int) {
ch <- true // 抢channel,没抢到的被阻塞
balance = balance + amount
<-ch // 释放channel,其他被阻塞的可以继续抢
}
//读取余额
func Balance(ch chan bool) int {
ch <- true
ret := balance
<-ch
return ret
}
func main() {
ch := make(chan bool, 1)
//小王:存600,并读取余额
go func() {
Deposit(ch, 600)
fmt.Println(Balance(ch))
}()
//小张:存500
go func() {
Deposit(ch, 500)
fmt.Println(Balance(ch))
}()
// 为了简单,不使用 sync.WaitGroup , 所以需要defer和Sleep
defer close(ch) // 记得关闭channel。单一程序是可以自动被GC清理掉channel的。但是如果goroutine很多,机器性能差,长时间积累或短时间爆发会压垮机器的CPU和内存,手动关闭channel是个好习惯。
time.Sleep(time.Second)
fmt.Println(Balance(ch))
}
还有一种使用多个channel处理共享变量的思考方式: link
对共享变量的每一种操作都要定义一个channel,然后死循环select监听信道变化,发生对应的信道变化时,就对共享变量执行对应的操作。
package bank
var deposits = make(chan int) // send amout to deposit
var balances = make(chan int) // receive balance
func Deposit(amount int) {
deposits <- amount
}
func Balance() int {
return <-balances
}
func teller() {
var balance int // balance 只在 teller 中可以访问
for {
select {
case amount := <-deposits:
balance += amount
case balances <- balance:
}
}
}
func init() {
go teller()
}
这样的程序,稳定性较差:循环阻塞Channel可能会导致死锁。channel中传递的都是数据的拷贝,可能会影响性能。channel中如果传递指针又会导致数据竞态问题。另外代码量也会比较大。唯一比单一channel好的就是能解决读写共用一把锁的问题。
然后说几个保证同一时间只能有一个 goroutine 来访问变量的方案。
package main
import (
"fmt"
"time"
"sync"
)
var balance int
var mu sync.Mutex
//存款
func Deposit(amount int) {
mu.Lock()
defer mu.Unlock()
balance = balance + amount
}
//读取余额
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}
func main(){
//小王:存600,并读取余额
go func(){
Deposit(600)
fmt.Println(Balance())
}()
//小张:存500
go func(){
Deposit(500)
fmt.Println(Balance())
}()
time.Sleep(time.Second)
fmt.Println(Balance()) // 外部也要加锁,因为主协程没有加waitGroup,所以可能和子协程并发
}
package main
import (
"fmt"
"sync"
"time"
)
var balance int
var mu sync.RWMutex // 读锁
//存款
func Deposit(amount int) {
mu.Lock()
defer mu.Unlock()
balance = balance + amount
}
//读取余额
func Balance() int {
mu.RLock() // 读取余额要获取读锁
defer mu.RUnlock()
return balance
}
func main() {
//小王:存600,并读取余额
go func() {
Deposit(600)
fmt.Println(Balance())
}()
//小张:存500
go func() {
Deposit(500)
fmt.Println(Balance())
}()
time.Sleep(time.Second)
fmt.Println(Balance())
}
package main
import (
"fmt"
"sync"
"time"
)
var balance int
//存款
func Deposit(amount int) {
balance = balance + amount
}
//读取余额
func Balance() int {
return balance
}
func main() {
o := &sync.Once{}
// o.Do(f func())
o.Do(func() {
//小王:存600,并读取余额
go func() {
Deposit(600)
fmt.Println(Balance())
}()
//小张:存500
go func() {
Deposit(500)
fmt.Println(Balance())
}()
})
time.Sleep(time.Second)
fmt.Println(balance) // once无法解决此行race问题
}
原理比较:
相关函数:
atomic操作是直接进行原语级操作,如果操作得当,效果是最好的,但是风险也是最高的。更建议用atomic实现的其他sync包中的功能。
sync.RWMutex比sync.Mutex和sync.Once功能更强,也更方便使用。
channel与前面四种方法在思想上是不同的,符合go语言的理念“不要通过共享内存来通信,而应该通过通信来共享内存”。
但是只有涉及到通信的时候才有必要用channel,不需要通信就直接加锁,不然代码写起来又多又乱容易出错。channel并没有太好用,因为可能涉及死锁。
ch := make(chan int) 无缓冲的channel由于没有缓冲发送和接收需要同步.
ch := make(chan int, 2) 有缓冲channel不要求发送和接收操作同步.
在计算机科学中,通信顺序过程(communicating sequential processes,CSP)是一种描述并发系统中交互模式的正式语言,它是并发数学理论家族中的一个成员,被称为过程算法(process algebras),或者说过程计算(process calculate),是基于消息的通道传递的数学理论。
CSP模型是上个世纪七十年代提出的,不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。 CSP中channel是第一类对象,它不关注发送消息的实体,而关注于发送消息时使用的channel。
Golang中channel 是被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,一个实体通过将消息发送到channel 中,然后又监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,其中 channel 是同步的一个消息被发送到 channel 中,最终是一定要被另外的实体消费掉的,在实现原理上其实类似一个阻塞的消息队列。
注:在流水线设计模式之外,主从模式(Boss-worker)也是一种重要的多线程设计模式。在主从模式中,存在一个主人线程(Boss),它负责将工作分成同样的几份,并分配给从线程(Worker),Worker各自分头完成工作,最后Boss负责将多个Worker线程的工作成果合并
Goroutine 是Golang实际并发执行的实体,它底层是使用协程(coroutine)实现并发,coroutine是一种运行在用户态的用户线程,类似于greenthread,go底层选择使用coroutine的出发点是因为,它具有以下特点:
Golang中的Goroutine的特性:

Golang是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言;也正是Go语言的并发特性,吸引了全球无数的开发者。
Golang的CSP并发模型,是通过Goroutine和Channel来实现的。
因此GPM的简要概括即为:事件循环,线程池,工作队列。
Golang中常用的并发模型有三种:
通过channel通知实现并发控制
func main() {
ch := make(chan struct{})
go func() {
fmt.Println("start working")
time.Sleep(time.Second * 1)
ch <- struct{}{}
}()
<-ch
fmt.Println("finished")
}
<-ch 接受 channel 的值的时候,如果该 channel 中没有数据,就会一直阻塞等待,直到有值。 这样就可以简单实现并发控制通过sync包中的WaitGroup实现并发控制
func main(){
var wg sync.WaitGroup
var urls = []string{
"http://www.golang.org/",
"http://www.google.com/",
}
for _, url := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
http.Get(url)
}(url)
}
wg.Wait()
}
A WaitGroup must not be copied after first use,在 WaitGroup 第一次使用后,不能被拷贝。否则会提示所有的 goroutine 都已经睡眠了,出现了死锁。这是因为 wg 给拷贝传递到了 goroutine 中,导致原wg对象只进行了Add 操作,而Done操作是在 wg 的副本执行的。在Go 1.7 以后引进的强大的Context上下文,实现并发控制. link
// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
// Done returns a channel that is closed when this `Context` is canceled
// or times out.
// Done() 返回一个只能接受数据的channel类型,当该context关闭或者超时时间到了的时候,该channel就会有一个取消信号
Done() <-chan struct{}
// Err indicates why this Context was canceled, after the Done channel
// is closed.
// Err() 在Done() 之后,返回context 取消的原因。
Err() error
// Deadline returns the time when this Context will be canceled, if any.
// Deadline() 设置该context cancel的时间点
Deadline() (deadline time.Time, ok bool)
// Value returns the value associated with key or nil if none.
// Value() 方法允许 Context 对象携带request作用域的数据,该数据必须是线程安全的。
Value(key interface{}) interface{}
}
// 创建了一个过期时间为 1s 的上下文,并向上下文传入 handle 函数,该方法会使用 500ms 的时间处理传入的请求
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
go handle(ctx, 500*time.Millisecond)
select {
case <-ctx.Done():
fmt.Println("main", ctx.Err())
}
}
func handle(ctx context.Context, duration time.Duration) {
select {
case <-ctx.Done():
fmt.Println("handle", ctx.Err())
case <-time.After(duration):
fmt.Println("process request with", duration)
}
}
// 因为过期时间大于处理时间,所以我们有足够的时间处理该请求,运行上述代码会打印出下面的内容
//$ go run context.go
//process request with 500ms
//main context deadline exceeded
// 因为handle 函数没有进入超时的 select 分支,main 函数的 select 会等待 context.Context 超时并打印出 main context deadline exceeded。
// 如果我们将处理请求时间增加至 1500ms,整个程序都会因为上下文的过期而被中止
//$ go run context.go
//main context deadline exceeded
//handle context deadline exceeded
综上。Channel无缓冲同步阻塞通道;sync.WaitGroup让主协程等待所有子协程完成;Context上下文包括程序的运行环境、现场和快照,使数据从主协程更快传递给子协程。
首先Go的JSON 标准库对 nil slice 和 空 slice 的处理是不一致.
通常错误的用法,会报数组越界的错误,因为只是声明了slice,却没有给实例化的对象。
var slice []int
slice[1] = 0
此时slice的值是nil,这种情况可以用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。
empty slice 是指slice不为nil,但是slice没有值,slice的底层的空间是空的,此时的定义如下:
slice := make([]int,0)
slice := []int{}
当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值。
总之,nil slice 和 empty slice是不同的东西,需要我们加以区分的。实际上,代码中使用slice时,尽可能用make创建,可以避免出现数组越界panic。
进程
线程
协程
通常小对象过多会导致GC三色法消耗过多的CPU。
优化思路是,减少对象分配.
还有一种思路:
一直分配对象不回收,最后整个分配器一起丢掉。中间只有分配器占用了一个byte数组对象需要GC扫描,但是因为Go认为数组里面没有指针,所以就只标记数组头为可达,扫描就完成了。目标达成!
Data Race问题可以使用互斥锁 sync.Mutex, 或者也可以通过CAS无锁并发解决.
其中使用同步访问共享数据或者CAS无锁并发是处理数据竞争的一种有效的方法.
golang在1.1之后引入了竞争检测机制,可以使用 go run -race 或者 go build -race来进行静态检测。
其在内部的实现是,开启多个协程执行同一个命令, 并且记录下每个变量的状态.
竞争检测器已经完全集成到Go工具链中,仅仅添加-race标志到命令行就使用了检测器。
$ go test -race mypkg // 测试包
$ go run -race mysrc.go // 编译和运行程序
$ go build -race mycmd // 构建程序
$ go install -race mypkg // 安装程序
要想解决数据竞争的问题可以使用互斥锁 sync.Mutex,解决数据竞争(Data race),也可以使用管道解决,使用管道的效率要比互斥锁高.
解决 race 的问题时,无非就是上锁。可能很多人都听说过一个高逼格的词叫「无锁队列」。 都一听到加锁就觉得很 low,那无锁又是怎么一回事?其实就是利用 atomic 特性,那 atomic 会比 mutex 有什么好处呢?
mutex 由操作系统实现,而 atomic 包中的原子操作则由底层硬件直接提供支持。在 CPU 实现的指令集里,有一些指令被封装进了 atomic 包,这些指令在执行的过程中是不允许中断(interrupt)的,因此原子操作可以在 lock-free 的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展。
若实现相同的功能,后者通常会更有效率,并且更能利用计算机多核的优势。所以,以后当我们想并发安全的更新一些变量的时候,我们应该优先选择用 atomic 来实现。
Channel是Go中的一个核心类型,可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication),Channel也可以理解是一个先进先出的队列,通过管道进行通信。
Golang的Channel,发送一个数据到Channel和从Channel接收一个数据都是原子性的。
Go的设计思想就是, 不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。
也就是说,设计Channel的主要目的就是在多任务间传递数据的,本身就是安全的。
什么是垃圾回收?
内存管理是程序员开发应用的一大难题。传统的系统级编程语言(主要指C/C++)中,程序开发者必须对内存小心的进行管理操作,控制内存的申请及释放。 因为稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位。
如何解决?
过去一般采用两种办法:
为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。
常用的垃圾回收的方法:
Golang GC 时会发生什么? 完整介绍 link link link 原理

golang 一轮完整的 GC 的过程:
Golang 1.5后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法。优化的核心就是尽量使得 STW(Stop The World) 的时间越来越短。

|  |
Go中对 GC 的触发时机存在两种形式:
G、M、P、Sched
Go的调度器内部有四个重要的结构:M,P,G,Sched,如上图所示(Sched未给出).
M:M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息 P:P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。 Sched:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。
调度实现:

从上图中可以看到,有2个物理线程M,每一个M都拥有一个处理器P,每一个也都有一个正在运行的goroutine。P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。
图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出一个goroutine执行。
当一个OS线程M0陷入阻塞时,P转而在运行M1,图中的M1可能是正被创建,或者从线程缓存中取出。

当M0返回时,它必须尝试取得一个P来运行goroutine,一般情况下,它会从其他的OS线程那里拿一个P过来, 如果没有拿到的话,它就把goroutine放在一个global runqueue里,然后自己睡眠(放入线程缓存里)。所有的P也会周期性的检查global runqueue并运行其中的goroutine,否则global runqueue上的goroutine永远无法执行。
另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了这个处理器P很忙,但是其他的P还有任务,此时如果global runqueue没有任务G了,那么P不得不从其他的P里拿一些G来执行。
通常来说,如果P从其他的P那里要拿任务的话,一般就拿run queue的一半,这就确保了每个OS线程都能充分的使用。
并行是指两个或者多个事件在同一时刻发生;并发是指两个或多个事件在同一时间间隔发生。
并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。
并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。
并发编程是指在一台处理器上“同时”处理多个任务。并发是在同一实体上的多个事件。多个事件在同一时间间隔发生。并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能。
Go语言的运行环境(runtime)会在goroutine需要的时候动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小。
分块式的栈是最初Go语言组织栈的方式。当创建一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。我们可能会考虑当这8KB的栈空间被用完的时候该怎么办?
为了处理这种情况,每个Go函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用 morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)。
在新分配的栈底部,还插入了一个叫做 lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到 lessstack。lessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(stack pointer)。这样就完成了从新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就可以被释放掉了。
分块式的栈让我们能够按照需求来扩展和收缩栈的大小。 Go开发者不需要花精力去估计goroutine会用到多大的栈。创建一个新的goroutine的开销也不大。当 Go开发者不知道栈会扩展到多少大时,它也能很好的处理这种情况。
这一直是之前Go语言管理栈的的方法。但这个方法有一个问题。缩减栈空间是一个开销相对较大的操作。如果在一个循环里有栈分裂,那么它的开销就变得不可忽略了。一个函数会扩展,然后分裂栈。当它返回的时候又会释放之前分配的内存块。如果这些都发生在一个循环里的话,代价是相当大的。 这就是所谓的热分裂问题(hot split problem)。它是Go语言开发者选择新的栈管理方法的主要原因。新的方法叫做 栈复制法(stack copying)。
栈复制法一开始和分块式的栈很像。当goroutine运行并用完栈空间的时候,与之前的方法一样,栈溢出检查会被触发。但是,不像之前的方法那样分配一个新的内存块并链接到老的栈内存块,新的方法会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里。这样做意味着当栈缩减回之前大小时,我们不需要做任何事情。栈的缩减没有任何代价。而且,当栈再次扩展时,运行环境也不需要再做任何事。它可以重用之前分配的空间。
栈的复制听起来很容易,但实际操作并非那么简单。存储在栈上的变量的地址可能已经被使用到。也就是说程序使用到了一些指向栈的指针。当移动栈的时候,所有指向栈里内容的指针都会变得无效。然而,指向栈内容的指针自身也必定是保存在栈上的。这是为了保证内存安全的必要条件。否则一个程序就有可能访问一段已经无效的栈空间了。
因为垃圾回收的需要,我们必须知道栈的哪些部分是被用作指针了。当我们移动栈的时候,我们可以更新栈里的指针让它们指向新的地址。所有相关的指针都会被更新。我们使用了垃圾回收的信息来复制栈,但并不是任何使用栈的函数都有这些信息。因为很大一部分运行环境是用C语言写的,很多被调用的运行环境里的函数并没有指针的信息,所以也就不能够被复制了。当遇到这种情况时,我们只能退回到分块式的栈并支付相应的开销。
这也是为什么现在运行环境的开发者正在用Go语言重写运行环境的大部分代码。无法用Go语言重写的部分(比如调度器的核心代码和垃圾回收器)会在特殊的栈上运行。这个特殊栈的大小由运行环境的开发者设置。
这些改变除了使栈复制成为可能,它也允许我们在将来实现并行垃圾回收。
另外一种不同的栈处理方式就是在虚拟内存中分配大内存段。由于物理内存只是在真正使用时才会被分配,因此看起来好似你可以分配一个大内存段并让操 作系统处理它。下面是这种方法的一些问题
首先,32位系统只能支持4G字节虚拟内存,并且应用只能用到其中的3G空间。由于同时运行百万goroutines的情况并不少见,因此你很可 能用光虚拟内存,即便我们假设每个goroutine的stack只有8K。
第二,然而我们可以在64位系统中分配大内存,它依赖于过量内存使用。所谓过量使用是指当你分配的内存大小超出物理内存大小时,依赖操作系统保证 在需要时能够分配出物理内存。然而,允许过量使用可能会导致一些风险。由于一些进程分配了超出机器物理内存大小的内存,如果这些进程使用更多内存 时,操作系统将不得不为它们补充分配内存。这会导致操作系统将一些内存段放入磁盘缓存,这常常会增加不可预测的处理延迟。正是考虑到这个原因,一 些新系统关闭了对过量使用的支持。
进程是内存资源管理和cpu调度的执行单元。为了有效利用多核处理器的优势,将进程进一步细分,允许一个进程里存在多个线程,这多个线程还是共享同一片内存空间,但cpu调度的最小单元变成了线程。
那协程又是什么呢,以及与线程的差异性?
channel在goroutines之间通信的过程:
监视Goroutine的数量,属于性能分析范畴
可以通过标准库内置的net/http/pprof监听pprof数据,并开启http服务器下载pprof性能分析报告
package main
import (
_ "net/http/pprof"
"net/http"
"log"
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
select{}
}
浏览器访问127.0.0.1:6060/debug/pprof/goroutine下载goroutine文件到本地。
来到文件所在文件夹,执行
go tool pprof -http=”:8081” goroutine
会自动打开浏览器,访问:http://localhost:8081/ui/

在图中可以清晰的看到goroutine的数量以及调用关系。
左侧的菜单栏,可以查看Top、Graph、Flame Graph等。
pprof也可以用于CPU、内存等分析,以及用火焰图分析代码性能。
火焰图分析性能 link
火焰图的调用顺序从下到上,每个方块代表一个函数,它上面一层表示这个函数会调用哪些函数,方块的大小代表了占用 CPU 使用的长短。
在Golang中,GOMAXPROCS中控制的是未被阻塞的所有Goroutine,可以被 Multiplex 到多少个线程上运行,通过 GOMAXPROCS可以查看Goroutine的数量。
在Go1.8版本以后,它被自动设置为主机上的逻辑CPU数量。
在代码中,可以通过runtime.GOMAXPROCS(1)指定最大Process(可用CPU数量)= 1。
import (
"fmt"
"runtime"
)
func main(){
cpuNum := runtime.NumCPU()
fmt.Println("当前机器的逻辑CPU数量为:",cpuNum)
runtime.GOMAXPROCS(cpuNum - 2)
}
在Golang中,Goroutine虽然很好,但是数量太多了,往往会带来很多麻烦,比如耗尽系统资源导致程序崩溃,或者CPU使用率过高导致系统忙不过来。
所以我们可以限制下Goroutine的数量,这样就需要在每一次执行go之前判断goroutine的数量,如果数量超了,就要阻塞go的执行。
所以通常我们第一时间想到的就是使用通道。每次执行的go之前向通道写入值,直到通道满的时候就阻塞了。但是新的问题于是就出现了,因为并不是所有的goroutine都执行完了,在main函数退出之后,还有一些goroutine没有执行完就被强制结束了。这个时候我们就需要用到sync.WaitGroup。使用WaitGroup等待所有的goroutine退出。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
// Pool Goroutine Pool
type Pool struct {
queue chan int
wg *sync.WaitGroup
}
// New 新建一个协程池
func NewPool(size int) *Pool{
if size <=0{
size = 1
}
return &Pool{
queue:make(chan int,size),
wg:&sync.WaitGroup{},
}
}
// Add 新增一个执行
func (p *Pool)Add(delta int){
// delta为正数就添加
for i :=0;i<delta;i++{
p.queue <-1
}
// delta为负数就减少
for i:=0;i>delta;i--{
<-p.queue
}
p.wg.Add(delta)
}
// Done 执行完成减一
func (p *Pool) Done(){
<-p.queue
p.wg.Done()
}
// Wait 等待Goroutine执行完毕
func (p *Pool) Wait(){
p.wg.Wait()
}
func main(){
// 这里限制5个并发
pool := NewPool(5)
fmt.Println("the NumGoroutine begin is:",runtime.NumGoroutine())
for i:=0;i<20;i++{
pool.Add(1)
go func(i int) {
time.Sleep(time.Second)
fmt.Println("the NumGoroutine continue is:",runtime.NumGoroutine())
pool.Done()
}(i)
}
pool.Wait()
fmt.Println("the NumGoroutine done is:",runtime.NumGoroutine())
}
其中,Go的GOMAXPROCS默认值已经设置为CPU的核数, 这里允许我们的Go程序充分使用机器的每一个CPU,最大程度的提高我们程序的并发性能。
分析上述代码。
假设我们要运行20个协程,但是限制同时只能有5个协程并发。就需要设计一个协程池。NewPool()用于新建携程池,池子数据结构有两个字段,queue表示带size个缓冲的信道,用于控制并发数量,wg用于向下传递wg以及主协程Wait。
三种锁:读写锁,互斥锁,还有map的安全的锁
Go并发程序对共享资源进行访问控制的主要手段,由标准库代码包中sync中的Mutex结构体表示。
sync.Mutex包中的类型只有两个公开的指针方法Lock和Unlock。
// Locker表示可以锁定和解锁的对象。
type Locker interface {
Lock()
Unlock()
}
// 锁定当前的互斥量
// 如果锁已被使用,则调用goroutine
// 阻塞直到互斥锁可用。
func (m *Mutex) Lock()
// 对当前互斥量进行解锁
// 如果在进入解锁时未锁定m,则为运行时错误。
// 锁定的互斥锁与特定的goroutine无关。
// 允许一个goroutine锁定Mutex然后安排另一个goroutine来解锁它。
func (m *Mutex) Unlock()
可能会犯一个错误:忘记及时解开已被锁住的锁,从而导致流程异常。但Go由于存在defer,所以此类问题出现的概率极低。defer解锁的方式如下:
var mutex sync.Mutex
func Write() {
mutex.Lock()
defer mutex.Unlock()
}
不可重入。如果对一个已经上锁的对象再次上锁,那么就会导致该锁定操作被阻塞,直到该互斥锁回到被解锁状态.
func main() {
var mutex sync.Mutex
fmt.Println("begin lock")
mutex.Lock()
fmt.Println("get locked")
for i := 1; i <= 3; i++ {
go func(i int) {
fmt.Println("begin lock ", i)
mutex.Lock()
fmt.Println("get locked ", i)
}(i)
}
time.Sleep(time.Second)
fmt.Println("Unlock the lock")
mutex.Unlock()
fmt.Println("get unlocked")
time.Sleep(time.Second)
}
在for循环之前开始加锁,然后在每一次循环中创建一个协程,并对其加锁,但是由于之前已经加锁了,所以这个for循环中的加锁会陷入阻塞直到main中的锁被解锁。 time.Sleep(time.Second) 是为了能让系统有足够的时间运行for循环,输出:
> go run mutex.go
begin lock
get locked
begin lock 3
begin lock 1
begin lock 2
Unlock the lock
get unlocked
get locked 3
这里,main解锁后,三个协程会重新抢夺互斥锁权,最终协程3获胜。
虽然互斥锁可以被多个协程共享,但还是建议将对同一个互斥锁的加锁解锁操作放在同一个层次的代码中。
互斥锁锁定操作的逆操作并不会导致协程阻塞,但是有可能导致引发一个无法恢复的运行时的panic,比如对一个未锁定的互斥锁进行解锁时就会发生panic。避免这种情况的最有效方式就是使用defer。
我们知道如果遇到panic,可以使用recover方法进行恢复,但是如果对重复解锁互斥锁引发的panic却是无用的(Go 1.8及以后)。
读写锁是针对读写操作的互斥锁,可以分别针对读操作与写操作进行锁定和解锁操作 。
读写锁的访问控制规则如下:
在这样的控制规则下,读写锁可以大大降低性能损耗。
在Go的标准库代码包中sync中的RWMutex结构体表示为:
// RWMutex是一个读/写互斥锁,可以由任意数量的读操作或单个写操作持有。
// RWMutex的零值是未锁定的互斥锁。
// **首次使用后,不得复制RWMutex**。
// 如果goroutine持有RWMutex进行读取而另一个goroutine可能会调用Lock,那么在释放初始读锁之前,goroutine不应该期望能够获取读锁定。
// 特别是,这种禁止递归读锁定。 这是为了确保锁最终变得可用; 阻止的锁定会阻止新读操作获取锁定。
type RWMutex struct {
w Mutex //如果有待处理的写操作就持有
writerSem uint32 // 写操作等待读操作完成的信号量
readerSem uint32 //读操作等待写操作完成的信号量
readerCount int32 // 待处理的读操作数量
readerWait int32 // number of departing readers
}
sync中的RWMutex有以下几种方法:
//对读操作的锁定
func (rw *RWMutex) RLock()
//对读操作的解锁
func (rw *RWMutex) RUnlock()
//对写操作的锁定
func (rw *RWMutex) Lock()
//对写操作的解锁
func (rw *RWMutex) Unlock()
//返回一个实现了sync.Locker接口类型的值,实际上是回调rw.RLock and rw.RUnlock.
func (rw *RWMutex) RLocker() Locker
Unlock方法会试图唤醒所有想进行读锁定而被阻塞的协程,而RUnlock方法只会在已无任何读锁定的情况下,试图唤醒一个因欲进行写锁定而被阻塞的协程。
若对一个未被写锁定的读写锁进行写解锁,就会引发一个不可恢复的panic,同理对一个未被读锁定的读写锁进行读写锁也会如此。
由于读写锁控制下的多个读操作之间不是互斥的,因此对于读解锁更容易被忽视。对于同一个读写锁,添加多少个读锁定,就必要有等量的读解锁,这样才能其他协程有机会进行操作。
因此Go中读写锁,在多个读线程可以同时访问共享数据,写线程必须等待所有读线程都释放锁以后,才能取得锁.
同样的,读线程必须等待写线程释放锁后,才能取得锁,也就是说读写锁要确保的是如下互斥关系,可以同时读,但是读-写,写-写都是互斥的。
golang中的sync.Map是并发安全的,其实也就是sync包中golang自定义的一个名叫Map的结构体。
sync.Map的数据结构:
type Map struct {
// 该锁用来保护dirty
mu Mutex
// 存读的数据,因为是atomic.value类型,只读类型,所以它的读是并发安全的
read atomic.Value // readOnly
//包含最新的写入的数据,并且在写的时候,会把read 中未被删除的数据拷贝到该dirty中,因为是普通的map存在并发安全问题,需要用到上面的mu字段。
dirty map[interface{}]*entry
// 从read读数据的时候,会将该字段+1,当等于len(dirty)的时候,会将dirty拷贝到read中(从而提升读的性能)。
misses int
}
sync.Map是通过冗余的两个数据结构(read、dirty),实现性能的提升。
为了提升性能,load、delete、store等操作尽量使用只读的read;为了提高read的key击中概率,采用动态调整,将dirty数据提升为read;对于数据的删除,采用延迟标记删除法,只有在提升dirty的时候才删除。
具体过程:todo
Channel是异步进行的, channel存在3种状态:
下面我们对channel的三种操作解析:
| 操作 | 一个零值nil通道 | 一个非零值但已关闭的通道 | 一个非零值且尚未关闭的通道 |
|---|---|---|---|
| 关闭 | 产生恐慌 | 产生恐慌 | 成功关闭 |
| 发送数据 | 永久阻塞 | 产生恐慌 | 阻塞或者成功发送 |
| 接收数据 | 永久阻塞 | 永不阻塞 | 阻塞或者成功接收 |
从调度上看,goroutine的调度开销远远小于线程调度开销。
OS的线程由OS内核调度,每隔几毫秒,一个硬件时钟中断发到CPU,CPU调用一个调度器内核函数。这个函数暂停当前正在运行的线程,把他的寄存器信息保存到内存中,查看线程列表并决定接下来运行哪一个线程,再从内存中恢复线程的注册表信息,最后继续执行选中的线程。这种线程切换需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构。某种意义上,这种操作还是很慢的。
Go运行的时候包涵一个自己的调度器,这个调度器使用一个称为一个M:N调度技术,m个goroutine到n个os线程(可以用GOMAXPROCS来控制n的数量),Go的调度器不是由硬件时钟来定期触发的,而是由特定的go语言结构来触发的,他不需要切换到内核语境,所以调度一个goroutine比调度一个线程的成本低很多。
从栈空间上,goroutine的栈空间更加动态灵活。
每个OS的线程都有一个固定大小的栈内存,通常是2MB,栈内存用于保存在其他函数调用期间哪些正在执行或者临时暂停的函数的局部变量。这个固定的栈大小,如果对于goroutine来说,可能是一种巨大的浪费。作为对比goroutine在生命周期开始只有一个很小的栈,典型情况是2KB, 在go程序中,一次创建十万左右的goroutine也不罕见(2KB*100,000=200MB)。而且goroutine的栈不是固定大小,它可以按需增大和缩小,最大限制可以到1GB。
goroutine没有一个特定的标识。
在大部分支持多线程的操作系统和编程语言中,线程有一个独特的标识,通常是一个整数或者指针,这个特性可以让我们构建一个线程的局部存储,本质是一个全局的map,以线程的标识作为键,这样每个线程可以独立使用这个map存储和获取值,不受其他线程干扰。
goroutine中没有可供程序员访问的标识,原因是一种纯函数的理念,不希望滥用线程局部存储导致一个不健康的超距作用,即函数的行为不仅取决于它的参数,还取决于运行它的线程标识。
什么是defer?如何理解 defer 关键字?Go 中使用 defer 的一些坑。
defer 意为延迟,在 golang 中用于延迟执行一个函数。它可以帮助我们处理容易忽略的问题,如资源释放、连接关闭等。但在实际使用过程中,有一些需要注意的地方.
Go 的函数返回值是通过堆栈返回的, return 语句不是原子操作,而是被拆成了两步.
func main() {
fmt.Println(increase(1))
// 输出:2
}
func increase(d int) (ret int) {
defer func() {
ret++
}()
return d
}
闭包与匿名函数:
func main() {
for i := 0; i < 5; i++ {
defer func() {
fmt.Println(i)
// 输出:5 5 5 5 5
// 因为,defer 表达式中的 i 是对 for 循环中 i 的引用。循环结束,i 加到 5。退出循环,main结束前,执行defer,获取i的值=5,最后全部打印 5。
}()
}
}
func main() {
for i := 0; i < 5; i++ {
defer func(i int) {
fmt.Println(i)
// 输出:4 3 2 1 0
// 因为,将 i 作为参数传入 defer 表达式中,在传入最初就会进行求值保存,只是没有执行延迟函数而已。
}(i)
}
}
// f1
func f1() (result int) {
defer func() {
result++
}()
return 0
}
// 输出:1
// result=0;result++;return result;
// f2
func f2() (r int) {
t := 5
defer func() {
t = t + 5
// fmt.Println(t) // 这里加打印会打印10,因为临时变量t=闭包变量t+5,两个t只是名字相同,编译时不是同一个变量
}()
return t
}
// 输出:5
// f2内t:=5;r = f2内t;defer内用到f2内5的值(拷贝),给临时变量t赋值 = f2的t + 5;return r
// 可以理解成:
func f() (r int) {
t := 5
r = t // 赋值指令
func() { // defer被插入到赋值与返回之间执行,这个例子中返回值r没被修改过
t = t + 5
}()
return // 空的return指令
}
// f3
func f3() (r int) {
defer func(r int) {
r = r + 5
}(r)
return 1
}
// 输出:1
// 这里将 r 作为参数传入了 defer 表达式。故 func (r int) 中的 r 非 func f() (r int) 中的 r,只是参数命名相同而已。
// r = 1;defer 用到参数r,赋值给临时变量r,返回也是临时变量r,都和参数r无关。把r=r+5换成r++操作的也是临时变量;return 没有变化的变量r
// 可以理解成:
func f() (r int) {
r = 1 // 给返回值赋值
func(r int) { // 这里改的r是传值传进去的r,不会改变要返回的那个r值
r = r + 5
}(r)
return // 空的return
}
// 要想在闭包中改变外部变量,需要传入指针(注意,func内由于go语言的特性,*r可以简写成r,编译后是指针)
func f4_omit_pointer() (r int) {
defer func(*int) {
r = r + 5
}(&r)
return 1
}
// 输出6
select, poll, epoll 的功能:监听多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。
golang 的 select 机制是,监听多个channel,每一个 case 是一个事件,可以是读事件也可以是写事件,随机选择一个执行,可以设置default,它的作用是:监听的多个事件都会阻塞住执行default的逻辑。
goroutine作为Golang并发的核心,我们不仅要关注它们的创建和管理,当然还要关注如何合理的退出这些协程,不(合理)退出不然可能会造成阻塞、panic、程序行为异常、数据结果不正确等问题。goroutine在退出方面,不像线程和进程,不能通过某种手段强制关闭它们,只能等待goroutine主动退出。
使用for-range退出
go func(in <-chan int) { // 补充:channel可以从读写信道转成单向信道,所以用单向信道作为参数是个很好的习惯
// Using for-range to exit goroutine
// range has the ability to detect the close/end of a channel
for x := range in {
fmt.Printf("Process %d\n", x)
}
}(in)
在退出协程的时候需要注意:
详情todo
在 Go http包的Server中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和RPC服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。
在Google 内部,我们开发了** Context 包,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用**。
context包的作用:处理多个 goroutine 之间共享数据,及多个 goroutine 的管理
需要注意一点的是在goroutine中使用context包的时候,通常我们需要在goroutine中新创建一个上下文的context,原因是:如果直接传递外部context到协程中,一个请求可能在主函数中已经结束,在goroutine中如果还没有结束的话,会直接导致goroutine中的运行的被取消.比如,当api1调用api2时,由于api1花费了太多时间在其他问题上,导致临近context超时时间,然后调用api2后,瞬间超时,这样api1超时影响到了api2的调用,看起来还像是api2超时了一样。
context包的struct Context包含的方法:Done、Err、Deadline、Value(数据必须线程安全)Context 对象是线程安全的,你可以把一个 Context 对象传递给任意个数的 gorotuine,对它执行 取消 操作时,所有 goroutine 都会接收到取消信号。
context.Background函数的返回值是一个空的context,经常作为树的根结点,它一般由接收请求的第一个routine创建,不能被取消、没有值、也没有过期时间。
WithCancel 和 WithTimeout 函数 会返回继承的 Context 对象, 这些对象可以比它们的父 Context 更早地取消。
当请求处理函数返回时,与该请求关联的 Context 会被取消。 当使用多个副本发送请求时,可以使用 WithCancel取消多余的请求。 WithTimeout 在设置对后端服务器请求截止时间时非常有用。
Context上下文数据的存储就像一个树,每个结点只存储一个 key/value对。WithValue()保存一个 key/value对,它将父context嵌入到新的子context,并在节点中保存了 key/value数据。Value()查询key对应的value数据,会从当前context中查询,如果查不到,会递归查询父context中的数据。
值得注意的是,context中的上下文数据并不是全局的,它只查询本节点及父节点们的数据,不能查询兄弟节点的数据。
Context 使用原则:
sync.WaitGroup。WaitGroup,就是用来等待一组操作完成的。WaitGroup内部实现了一个计数器,用来记录未完成的操作个数.
它提供了三个方法,Add()用来添加(1)或减少(-1)计数。Done()用来在操作结束时调用,使计数减一。Wait()用来等待所有的操作结束,即计数变为0,该函数会在计数不为0时等待,在计数为0时立即返回。
slice是 Go 中的一种基本的数据结构,使用这种结构可以用来管理数据集合。slice常见的操作有 reslice、append、copy。
slice自身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。slice本身是一个只读对象,其工作机制类似数组指针的一种封装。
slice是对数组一个连续片段的引用,所以切片是一个引用类型(因此更类似于 C/C++ 中的数组类型,或者 Python 中的 list类型)。这个片段可以是整个数组,或者是由起始和终止索引标识的一些项的子集。
这里需要注意的是,终止索引标识的项不包括在切片内。切片提供了一个指向数组的动态窗口。
slice是可以看做是一个长度可变的数组。
slice数据结构如下:
type slice struct {
array unsafe.Pointer
len int
cap int
}
slice的结构体由3部分构成,Pointer 是指向一个数组的指针,len 代表当前切片的长度,cap 是当前切片的容量。cap 总是大于等于 len 的。
通常我们在对slice进行append等操作时,可能会造成slice的自动扩容。
其扩容时的大小增长规则是:
Golang中map的遍历输出的时候是无序的,不同的遍历会有不同的输出结果。如果想要顺序输出的话,需要额外保存顺序,例如使用slice保存map的key,将slice排序,再通过sort包对slice排序后,按序读取map。
package main
import (
"fmt"
"sort"
)
func main() {
var m = map[string]int{
"hello": 0,
"morning": 1,
"keke": 2,
"jame": 3,
}
// fmt.Println(m) // 打印得到固定顺序:map[hello:0 jame:3 keke:2 morning:1]
var keys []string
for k := range m { // 忽略v,把k入slice
// fmt.Println(k) // 从map读数据是无序的
keys = append(keys, k)
}
// fmt.Println(keys) // 打印会得到不同的顺序,比如:[jame hello morning keke]
sort.Strings(keys) // 对key slice 原地排序
// fmt.Println(keys) // 打印得到固定顺序:[hello jame keke morning]
for _, k := range keys {
fmt.Println("Key:", k, "Value:", m[k])
}
}
输出:
Key: hello Value: 0
Key: jame Value: 3
Key: keke Value: 2
Key: morning Value: 1
package main
import (
"fmt"
"sort"
)
func main() {
/* 声明索引类型为字符串的map */
var testMap = make(map[string]string)
testMap["Bda"] = "B"
testMap["Ada"] = "A"
testMap["Dda"] = "D"
testMap["Cda"] = "C"
testMap["Eda"] = "E"
for key, value := range testMap {
fmt.Println(key, ":", value)
}
var testSlice []string
testSlice = append(testSlice, "Bda", "Ada", "Dda", "Cda", "Eda")
/* 对slice数组进行排序,然后就可以根据key值顺序读取map */
sort.Strings(testSlice)
fmt.Println("排序输出:")
for _, Key := range testSlice {
/* 按顺序从MAP中取值输出 */
if Value, ok := testMap[Key]; ok {
fmt.Println(Key, ":", Value)
}
}
}
输出:
Bda : B
Ada : A
Dda : D
Cda : C
Eda : E
排序输出:
Ada : A
Bda : B
Cda : C
Dda : D
Eda : E
CAS算法(Compare And Swap),是原子操作的一种, CAS算法是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。
该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。
Go中的CAS操作是借用了CPU提供的原子性指令来实现。CAS操作修改共享变量时候不需要对共享变量加锁,而是通过类似乐观锁的方式进行检查,本质还是不断的占用CPU资源换取加锁带来的开销(比如上下文切换开销)。
sync/atomic包用于原子操作。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var (
counter int32 //计数器
wg sync.WaitGroup //信号量
)
func main() {
threadNum := 50000
wg.Add(threadNum)
for i := 0; i < threadNum; i++ {
go incCounter(i)
}
wg.Wait()
fmt.Printf("final counter:%d\n", counter)
}
func incCounter(index int) {
defer wg.Done()
// 逻辑:自旋锁中持续CAS,修改成功就break,不成功就进入下一循环(计数+1)
spinNum := 0 // 自旋失败计数
for {
// 原子操作
old := counter
ok := atomic.CompareAndSwapInt32(&counter, old, old+1)
if ok {
break
} else {
spinNum++
}
}
if spinNum > 0 {
fmt.Printf("thread,%d,spinnum,%d\n", index, spinNum)
}
}
当协程数量=5时:
主函数main首先创建了5个信号量,然后开启五个线程执行incCounter方法,incCounter内部执行, 使用cas操作递增counter的值,atomic.CompareAndSwapInt32具有三个参数,第一个是变量的地址,第二个是变量当前值,第三个是要修改变量为多少,该函数如果发现传递的old值等于当前变量的值,则使用第三个变量替换变量的值并返回true,否则返回false。
这里之所以使用无限循环是因为在高并发下每个线程执行CAS并不是每次都成功,失败了的线程需要重写获取变量当前的值,然后重新执行CAS操作。
现在把线程数改为50000就会发现输出
...
thread,49946,spinnum,3
thread,49936,spinnum,3
thread,49994,spinnum,3
final counter:50000
这里3就说明该线程尝试了4次CAS操作,前3次都失败了,第4次才成功。
因此呢, go中CAS操作可以有效的减少使用锁所带来的开销,但是需要注意在高并发下这是使用cpu资源做交换的。
其他CAS函数 todo
在Go中逃逸分析是一种确定指针动态范围的方法,可以分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析。
当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或者去调用子程序。如果使用尾递归优化(通常在函数编程语言中是需要的),对象也可能逃逸到被调用的子程序中。 如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中的任何一个地方被访问到——这样指针就成功“逃逸”了。
如果指针存储在全局变量或者其它数据结构中,它们也可能发生逃逸,这种情况是当前程序中的指针逃逸。 逃逸分析需要确定指针所有可以存储的地方,保证指针的生命周期只在当前进程或线程中。
导致内存逃逸的情况比较多,有些可能还是官方未能够实现精确的分析逃逸情况的 bug,通常来讲就是如果变量的作用域不会扩大并且其行为或者大小能够在编译的时候确定,一般情况下都是分配到栈上,否则就可能发生内存逃逸分配到堆上。
内存逃逸的五种情况:
有效的避免上述的五种逃逸的情况,就可以避免内存逃逸.
Go中的方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。
在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。
也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。
**如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。
如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。
因而呢,我们是使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的本质。
如果类型具备“原始的本质”,也就是说它的成员都是由 Go 语言里内置的原始类型,如字符串,整型值等,那就定义值接收者类型的方法。像内置的引用类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个 header, 对于他们也是直接定义值接收者类型的方法。这样,调用函数时,是直接 copy 了这些类型的 header,而 header 本身就是为复制设计的。
如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 sync.WaitGroup就不应该被复制,应该只有一份实体,作为参数传入goroutine时应该用指针:
func (wg *WaitGroup) Wait() {}
接口值的零值是指动态类型和动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下,这个接口值就才会被认为 接口值 == nil。
Go的内存分配原则:
Go在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。
申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。

arena区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan。
todo
栈和堆只是虚拟内存上2块不同功能的内存区域:
栈和堆相比优势:
通常在Golang中,当我们谈论内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。
堆内存管理中主要是三部分, 1.分配内存块,2.回收内存块, 3.组织内存块。
一个内存块包含了3类信息,如下图所示,元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请5Byte内存的时候,就需要进行内存对齐。

释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。
上面这个简单的设计中还没考虑内存碎片的问题,因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。
todo
通常内存泄漏,指的是能够预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。
在 Go 中,由于 goroutine 的存在,因此,内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。
因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象.
在Go中,值类型和引用类型:
这里需要注意的是: 对于引用类型的变量,我们不仅要声明变量,更重要的是,我们得手动为它分配空间.
因此new该方法的参数要求传入一个类型,而不是一个值,它会申请一个该类型大小的内存空间,并会初始化为对应的零值,返回指向该内存空间的一个指针。
而make也是用于内存分配,但是和new不同,只用来引用对象slice、map和channel的内存创建,它返回的类型就是类型本身,而不是它们的指针类型。
package main
import "fmt"
type myChan chan int
func main() {
var1 := make(chan int)
var2 := make(myChan)
var3 := new(myChan)
fmt.Printf("%T, %T, %T", var1, var2, var3) // 输出chan int, main.myChan, *main.myChan
}
锁是一种同步机制,用于在多任务环境中限制资源的访问,以满足互斥需求。
go源码sync包中经常用于同步操作的方式:
waitgroup
atomic 和 waitGroup 略
todo
Channel 收发操作均遵循了先入先出(FIFO)的设计,具体规则如下:
Channel 通常会有以下三种类型:
常用用法:
一般都是用来实现同步。channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。
举例:
package main
import "fmt"
func worker(ch chan struct{}) {
ch <- struct{}{}
fmt.Println("do something")
close(ch) // 技巧:虽然gc可以回收ch,但是消耗资源。所以写信道操作结束后最好关闭信道
}
func main() {
ch := make(chan struct{})
go worker(ch)
<-ch
}
Go中Map是一个KV对集合。底层使用 hash table,用链表来解决冲突 ,出现冲突时,不是每一个Key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv。
选择hash函数主要考察的是两点:性能、碰撞概率。
bmap 就是我们常说的“桶”(bucket),桶里面会最多装 8 个 key,这些 key之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和赋值中详细说明。
todo
在桶内,又会根据key计算出来的hash值的高8位来决定 key到底落入桶内的哪个位置(一个桶内最多有8个位置)。
Golang中http包中处理 HTTP 请求主要跟两个东西相关:ServeMux 和 Handler。
ServeMux 本质上是一个 HTTP 请求路由器(或者叫多路复用器,Multiplexor)。它把收到的请求与一组预先定义的 URL 路径列表做对比,然后在匹配到路径的时候调用关联的处理器(Handler)。
处理器(Handler)负责输出HTTP响应的头和正文。任何满足了http.Handler接口的对象都可作为一个处理器。通俗的说,对象只要有个如下签名的ServeHTTP方法即可:
ServeHTTP(http.ResponseWriter, *http.Request)
Go 语言的 HTTP 包自带了几个函数用作常用处理器,比如 FileServer,NotFoundHandler 和 RedirectHandler。
举例:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
rh := http.RedirectHandler("http://www.baidu.com", 307)
mux.Handle("/foo", rh)
log.Println("Listening...")
http.ListenAndServe(":3000", mux)
// 访问http://localhost:3000/foo
}
// net/http/server.go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
// 重定向处理方法
func RedirectHandler(url string, code int) Handler {
return &redirectHandler{url, code}
}
type redirectHandler struct {
url string
code int
}
// 重定向处理方法实现了ServeHTTP,使得rh成为Handler接口类型
func (rh *redirectHandler) ServeHTTP(w ResponseWriter, r *Request) {
Redirect(w, r, rh.url, rh.code)
}
// 1. 创建mux
func NewServeMux() *ServeMux { return new(ServeMux) }
// 2. 给mux绑定pattern和处理过程(只要实现ServerHttp方法,就成为Handle接口类型)
func (mux *ServeMux) Handle(pattern string, handler Handler) {}
// 3. 开启服务
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
在这个应用示例中,首先在 main 函数中我们只用了 http.NewServeMux 函数来创建一个空的 ServeMux。 然后我们使用 http.RedirectHandler 函数创建了一个新的处理器,这个处理器会对收到的所有请求,都执行307重定向操作到 http://www.baidu.com。
接下来我们使用 ServeMux.Handle 函数将处理器注册到新创建的 ServeMux,所以它在 URL 路径 /foo 上收到所有的请求都交给这个处理器。 最后我们创建了一个新的服务器,并通过 http.ListenAndServe 函数监听所有进入的请求,通过传递刚才创建的 ServeMux来为请求去匹配对应处理器。
在浏览器中访问 http://localhost:3000/foo,你应该能发现请求已经成功的重定向了。
此刻你应该能注意到一些有意思的事情:ListenAndServer 的函数签名是 ListenAndServe(addr string, handler Handler) ,但是第二个参数我们传递的是个 ServeMux。
通过这个例子我们就可以知道,net/http包在编写golang web应用中有很重要的作用,它主要提供了基于HTTP协议进行工作的client实现和server实现,可用于编写HTTP服务端和客户端。
可以通过Go自带的工具pprof或者使用Gops去检测诊断当前在系统上运行的Go进程的占用的资源.
pprof过程在上面,略。
gops用来获取go进程运行时信息。
可以查看:
可以获取:
gops的原理是,代码中导入gops/agent,建立agent服务,gops命令连接agent读取进程信息。
package main
import (
"log"
"runtime"
"time"
"github.com/google/gops/agent"
)
func main() {
if err := agent.Listen(agent.Options{
Addr: "0.0.0.0:8848",
// ConfigDir: "/home/centos/gopsconfig", // 最好使用默认
ShutdownCleanup: true}); err != nil {
log.Fatal(err)
}
// 测试代码
_ = make([]int, 1000, 1000)
runtime.GC()
_ = make([]int, 1000, 2000)
runtime.GC()
time.Sleep(time.Hour)
}
$ go get github.com/google/gops
agent有3个配置:
通常可以把Addr设置为要监听的IP,把ShutdownCleanup设置为ture,进程退出后,残留在ConfigDir目录的文件不再有用,最好清除掉。
分析过程:
gops可以列出当前机器上的go进程,其中带”*“的是agent进程。也可以通过lsof -i:8848查到agent监听的进程id
下面是一些常用命令:
对于内存泄露分析,就可以用memstats查看内存数据
$ gops memstats 67944
alloc: 136.80KB (140088 bytes) // 当前分配出去未收回的内存总量
total-alloc: 152.08KB (155728 bytes) // 已分配出去的内存总量
sys: 67.25MB (70518784 bytes) // 当前进程从OS获取的内存总量
lookups: 0
mallocs: 418 // 分配的对象数量
frees: 82 // 释放的对象数量
heap-alloc: 136.80KB (140088 bytes) // 当前分配出去未收回的堆内存总量
heap-sys: 63.56MB (66650112 bytes) // 当前堆从OS获取的内存
heap-idle: 62.98MB (66035712 bytes) // 当前堆中空闲的内存量
heap-in-use: 600.00KB (614400 bytes) // 当前堆使用中的内存量
heap-released: 62.89MB (65945600 bytes)
heap-objects: 336 // 堆中对象数量
stack-in-use: 448.00KB (458752 bytes) // 栈使用中的内存量
stack-sys: 448.00KB (458752 bytes) // 栈从OS获取的内存总量
stack-mspan-inuse: 10.89KB (11152 bytes)
stack-mspan-sys: 16.00KB (16384 bytes)
stack-mcache-inuse: 13.56KB (13888 bytes)
stack-mcache-sys: 16.00KB (16384 bytes)
other-sys: 1.01MB (1062682 bytes)
gc-sys: 2.21MB (2312192 bytes)
next-gc: when heap-alloc >= 4.00MB (4194304 bytes) // 下次GC的条件
last-gc: 2020-03-16 10:06:26.743193 +0800 CST // 上次GC的时间
gc-pause-total: 83.84µs // GC总暂停时间
gc-pause: 44891 // 上次GC暂停时间,单位纳秒
num-gc: 2 // 已进行的GC次数
enable-gc: true // 是否开始GC
debug-gc: false
支持远程主机:
agent.Listen(agent.Options{Addr:"0.0.0.0:8848"})gops stats 192.168.9.137:8848在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上
Go中两个Nil可能不相等。
接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。
两个接口值比较时,会先比较 T,再比较 V。 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
func main() {
var p *int = nil
var i interface{} = p
fmt.Println(i == p) // true
fmt.Println(p == nil) // true
fmt.Println(i == nil) // false
}
将一个nil非接口值p赋值给接口i,此时,i的内部字段为(T=*int, V=nil),i与p作比较时,将 p 转换为接口后再比较,因此 i == p。
p 与 nil 比较,直接比较值,所以 p == nil
当 i 与nil比较时,会将nil转换为接口(T=nil, V=nil),与i(T=*int, V=nil)不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。
GM 模型的缺点:
Go1.0 的 GM 模型的 Goroutine 调度器限制了用 Go 编写的并发程序的可扩展性,尤其是高吞吐量服务器和并行计算程序。
GM调度存在的问题:
频繁的线程阻塞/解阻塞: 在存在 syscalls 的情况下,线程经常被阻塞和解阻塞。这增加了很多额外的性能开销。
为了解决 GM 模型的以上诸多问题,在 Go1.1 时,Dmitry Vyukov 在 GM 模型的基础上,新增了一个 P(Processor)组件。并且实现了 Work Stealing 算法来解决一些新产生的问题。
加了 P 之后会带来什么改变呢?
每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。 每个 P 相对的平衡上,在 GMP 模型中也实现了Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
为什么要有P呢?
一般来讲,M 的数量都会多于 P。像在 Go 中,M 的数量默认是 10000,P 的默认数量的 CPU 核数。另外由于 M 的属性,也就是如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。
M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。这显然是不合理的,因为本地队列的管理会变得复杂,且 Work Stealing 性能会大幅度下降。
M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。
因此使用 M 是不合理的,那么引入新的组件 P,把本地队列关联到 P 上,就能很好的解决这个问题。
前四种IO都是同步IO。对于一次read IO访问,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
同步和异步的主要区别在于谁负责拷贝数据:同步方式下由用户线程将数据拷贝到用户空间;异步方式下由内核负责将数据拷贝到用户空间,拷贝完成后会通知用户线程或者调用用户线程注册的回调函数进行后续处理。
阻塞和非阻塞的主要区别在于是否需要等待完成:阻塞和非阻塞主要是针对同步方式,阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。
I/O多路复用(multiplexing)
select函数
poll函数
epoll函数
应用场景
总结

安全
其他包:std todo
四个测试框架:
todo
模拟接口调用方法,有明确的参数值与返回值,就是最简单的打桩。
桩,或称桩代码,是指用来代替关联代码或者未实现代码的代码。如果函数 B 用 B1 来代替,那么,B 称为原函数,B1 称为桩函数。打桩就是编写或生成桩代码。
HttpMock - 模拟 http 请求 - 官方自带的 http 包
SqlMock - 模拟数据库请求
同一个struct的两个实例可比较也不可比较,当结构不包含不可直接比较成员变量时可直接比较,否则不可直接比较
基本数据类型哪些是可比较的,哪些是不可比较的:
但在平时的实践过程中,当我们需要对含有不可直接比较的数据类型的结构体实例进行比较时,是不是就没法比较了呢? 事实上并非如此,golang还是友好滴,我们可以借助 reflect.DeepEqual 函数 来对两个变量进行比较。 DeepEqual函数用来判断两个值是否深度一致。具体比较规则如下:
todo
todo
对于Set类型的数据结构,其实本质上跟List没什么多大的区别。无非是Set不能含有重复的Item的特性,Set有初始化、Add、Clear、Remove、Contains等操作。
在Java中很容易知道HashSet的底层实现是HashMap,核心就是用一个常量来填充Map键值对中的Value选项。除此之外,重点关注Go中Map的数据结构,Key是不允许重复的。
两种 go 实现 set 的思路, 分别是 map 和 bitset。
map 的 key 肯定是唯一的,而这恰好与 set 的特性一致,天然保证 set 中成员的唯一性。而且通过 map 实现 set,在检查是否存在某个元素时可直接使用 _, ok := m[key] 的语法,效率高。
set := make(map[string]bool) // New empty set
set["Foo"] = true // Add
for k := range set { // Loop
fmt.Println(k)
}
delete(set, "Foo") // Delete
size := len(set) // Size
exists := set["Foo"] // Membership
但是map 的 value 是布尔类型,这会导致 set 多占一定内存空间,而 set 不该有这个问题。
设置 value 为空结构体,在 Go 中,空结构体不占任何内存。当然,如果不确定,也可以来证明下这个结论。
unsafe.Sizeof(struct{}{}) // 结果为 0
优化后:
type void struct{}
var member void
set := make(map[string]void) // New empty set
set["Foo"] = member // Add
for k := range set { // Loop
fmt.Println(k)
}
delete(set, "Foo") // Delete
size := len(set) // Size
_, exists := set["Foo"] // Membership
然后按照这个思路做封装,初始化,添加、包含、长度、清除(重新初始化)、set相等(遍历两个set的每个元素)、子集(遍历判断包含),过程如下:link
其实,github 上已经有个成熟的包,名为 golang-set,它也是采用这个思路实现的。访问地址 golang-set,描述中说 Docker 用的也是它。包中提供了两种 set 实现,线程安全的 set 和非线程安全的 set。
一个demo:
package main
import (
"fmt"
mapset "github.com/deckarep/golang-set"
)
func main() {
// 默认创建的线程安全的,如果无需线程安全
// 可以使用 NewThreadUnsafeSet 创建,使用方法都是一样的。
s1 := mapset.NewSet(1, 2, 3, 4)
fmt.Println("s1 contains 3: ", s1.Contains(3))
fmt.Println("s1 contains 5: ", s1.Contains(5))
fmt.Println("s1 length: ", s1.Cardinality()) // 基数(集合长度)
// interface 参数,可以传递任意类型
s1.Add("poloxue")
fmt.Println("s1 contains poloxue: ", s1.Contains("poloxue"))
s1.Remove(3)
fmt.Println("s1 contains 3: ", s1.Contains(3))
s2 := mapset.NewSet(1, 3, 4, 5)
// 并集
fmt.Println(s1.Union(s2))
}
bitset 中每个数字用一个 bit 即能表示,对于一个 int8 的数字,我们可以用它表示 8 个数字,能帮助我们大大节省数据的存储空间。
bitset 最常见的应用有 bitmap 和 flag,即位图和标志位。这里,我们先尝试用它表示一些操作的标志位。比如某个场景,我们需要三个 flag 分别表示权限1、权限2和权限3,而且几个权限可以共存。我们可以分别用三个常量 F1、F2、F3 表示位 Mask。
示例:
type Bits uint8
const (
F0 Bits = 1 << iota
F1
F2
)
func Set(b, flag Bits) Bits { return b | flag }
func Clear(b, flag Bits) Bits { return b &^ flag }
func Toggle(b, flag Bits) Bits { return b ^ flag }
func Has(b, flag Bits) bool { return b&flag != 0 }
func main() {
var b Bits
b = Set(b, F0)
b = Toggle(b, F2)
for i, flag := range []Bits{F0, F1, F2} {
fmt.Println(i, Has(b, flag))
}
}
例子中,我们本来需要三个数才能表示这三个标志,但现在通过一个 uint8 就可以。bitset 的一些操作,如设置 Set、清除 Clear、切换 Toggle、检查 Has 通过位运算就可以实现,而且非常高效。 bitset 对集合操作有着天然的优势,直接通过位运算符便可实现。比如交集、并集、和差集,示例如下:
交集:a & b 并集:a | b 差集:a & (~b)
底层的语言、库、框架常会使用这种方式设置标志位。
bitset 包也有人实现了,github地址 yourbasic/bit
举例:
package main
import (
"fmt"
"github.com/yourbasic/bit"
)
func main() {
s := bit.New(2, 3, 4, 65, 128)
fmt.Println("s contains 65", s.Contains(65))
fmt.Println("s contains 15", s.Contains(15))
s.Add(15)
fmt.Println("s contains 15", s.Contains(15))
fmt.Println("next 20 is ", s.Next(20))
fmt.Println("prev 20 is ", s.Prev(20))
s2 := bit.New(10, 22, 30)
s3 := s.Or(s2)
fmt.Println("next 20 is ", s3.Next(20))
s3.Visit(func(n int) bool {
fmt.Println(n)
return false // 返回 true 表示终止遍历
})
}
bitset 和前面的 set 的区别:
bitset 的成员只能是 int 整型,没有 set 灵活。平时的使用场景也比较少,主要用在对效率和存储空间要求较高的场景。
传统的单体应用程序将所有的功能都封装在一个单元中。相反,在微服务架构中,应用程序被划分为可以单独部署、升级和替换的小型独立服务。每个微服务都是针对单个业务目的而构建的,它使用轻量级机制与其他微服务通信。
微服务架构是一种构建系统的方式,其中,几个独立的服务按定义好的方式彼此通信(通常通过 Web RESTful 服务)。关键在于每个微服务都可以独立更新和部署。
采用微服务的主要风险,尤其是当起步于一个单体应用时,是设计的服务不是真正独立的。这将增加服务间通信的开销和复杂性。
从一开始就使用微服务架构可能存在风险,因为它主要适用于大型系统和大型团队。
亚马逊的“双披萨团队”原则。该原则规定,如果一个负责微服务的团队两个披萨都不够吃,那么这个团队就太大了。
微服务允许你使用不同的技术,因为在理想情况下,每个微服务都由一个独立的团队处理。
简单来说微服务架构是:
采用一组服务的方式来构建一个应用,服务独立部署在不同的进程中,不同服务通过一些轻量级交互机制来通信,例如 RPC、HTTP 等,服务可独立扩展伸缩,每个服务定义了明确的边界,不同的服务甚至可以采用不同的编程语言来实现,由独立的团队来维护。
具体架构,参考:pdf
当从单体应用正确的迁移到微服务架构的时候,可以获得以下收益:
举例:你正在构建用于订购披萨的微服务。你可以基于功能构建下面这些组件,诸如InventoryService,OrderService,PaymentService,UserProfileService,DeliveryNotificationService等。InventoryService仅仅有获取或更新披萨种类或配料库存相关的API,同样的,其他也只会提供对应功能的API。
如果你的所有微服务都共享一个数据库,这就违背了使用微服务的目的。对这个数据库的任何的改变或者故障都会影响使用该数据库的所有微服务。
理想情况下,任何需要访问该数据的其他微服务只能通过拥有写权限的微服务提供的API来访问。
为了避免构建出一个紧密耦合的组件网格(Mesh),可以考虑在微服务之间使用异步通信。
a. 对依赖的服务异步调用,如下例子。
例如:有一个服务A依赖服务B的例子。当服务B返回响应消息,服务A再返回成功给调用服务A的调用者。如果调用者对服务B的内容不关心,那么服务A可以异步调用服务B,并且这个时候可以立即返回成功给调用者。
b. 一个更好的选择是在微服务通信之间使用事件机制。你的微服务可以发布一个事件消息到消息总线上,可以用来通知一个状态的改变或者一个失败事件,并且任何对该事件感兴趣的微服务都可以获得该消息然后做出相应的处理。
例如:上面提到的披萨订单系统中,当客户的订单被接收到或者订单已经完成以及运输的状态消息都可以使用异步通信给客户发送通知消息。通知服务可以监听订单提交的消息事件然后将相应的通知推送给客户。
如果你的微服务依赖于另一个系统来提供响应,并且该系统需要很长时间才会响应,那么你的总体响应SLA将会受到影响。为了避免这种场景并且快速做出响应,你需要遵循的一个简单的微服务最佳实践是使用熔断器来使外部的调用超时,然后返回一个默认响应或者错误。熔断器模式可以参考最下面的引用。这种方式可以隔离故障服务,而不会导致级联故障,可以让你的服务保持在健康的状态。你可以选择使用流行的产品,比如Netflix开发的Hystrix。这要比使用HTTP CONNECT_TIMEOUT和READ_TIMEOUT设置更好,因为它不会启动超出配置范围的其他线程。
相比于系统中的每个微服务都单独提供API授权,请求/响应日志以及限流功能,使用一个单独API网关做这些事情会更有价值。调用你微服务的客户端可以连接到API网关而不是直接调用微服务接口。这种方式可以让你的微服务避免做那些额外的调用,并且微服务内部URL可以被隐藏,这可以让你更灵活的从API网关重定向流量到一个微服务的更新版本。当允许第三方访问你的微服务时,那么更有必要使用这种方式,因为你可以在请求到达微服务之前对传入流量进行限流以及拒绝来自API网关的未授权请求。你也可以选择一个单独的外部网关,让它可以接收外部网络的流量。
你可以安全的对API进行变更并且快速的发布它们,只要这些改变不影响已有的调用者。一种可能的选项是通知你的调用者,让他们通过集成测试来对做出的变更进行验证。但是,这种代价会比较高,因为所有依赖项都需要在一个环境中排队,这会使你的协调工作变慢。一个更好的选项是对你的API使用合约测试。你的API消费者对API提供预期响应结果的合约。作为API提供者的你可以集成这些合约测试作为你构建的一部分并且这些可以安全的保证重大的API变更。消费者可以测试你发布的存根(stubs)作为他们构建的一部分。这种方式可以让你通过独立测试合约变更来更快速的发布产品。
不可能让变更总是保持向后兼容。当你做了一个重大的变更的时候,同时需要继续支持老的接口,这时候可以暴露一个新版本的接口。消费者可以在方便的时候选择新的版本。但是有太多版本的API对于维护相应的代码人来说会是一场噩梦。因此,有一种规范的方法是通过和客户端一起协作或在内部将流量重新路由到较新的版本,从而弃用较旧的版本。
将你的微服务基础设施与其他组件隔离可以实现故障隔离和最佳性能。隔离微服务依赖的组件基础设施也同样重要。
例如:上面披萨订单的案例中,库存微服务使用库存数据库。使用专用的托管机器不仅对于库存微服务很重要,而且对于库存数据库同样也很重要。
你的微服务需要有一个单独的发布通道,这个通道不和你所在组织中的其他组件关联。这样的话你就不会和别人有冲突以及浪费和多个团队协调的时间。
尽管微服务给你提供了独立开发和发布的自由,但是对于跨领域关注(cross cutting concerns)来说,某些标准还是需要遵循的,这样才不会让每个团队都花费时间为这些问题创建独特的解决方案。这在诸如微服务分布式架构中是非常重要的,在这种架构中,你需要能够连接难题(puzzle)中的所有部分才能看清全局。因此,对于API安全,日志聚合,监控,API文档,秘钥管理,配置管理,分布式追踪等,企业级解决方案是必须要有的。

GET
安全且幂等
获取表示
变更时获取表示(缓存)
200(OK) - 表示已在响应中发出
204(无内容) - 资源有空表示
301(Moved Permanently) - 资源的URI已被更新
303(See Other) - 其他(如,负载均衡)
304(not modified)- 资源未更改(缓存)
400 (bad request)- 指代坏请求(如,参数错误)
404 (not found)- 资源不存在
406 (not acceptable)- 服务端不支持所需表示
500 (internal server error)- 通用错误响应
503 (Service Unavailable)- 服务端当前无法处理请求
POST
不安全且不幂等
使用服务端管理的(自动产生)的实例号创建资源
创建子资源
部分更新资源
如果没有被修改,则不过更新资源(乐观锁)
200(OK)- 如果现有资源已被更改
201(created)- 如果新资源被创建
202(accepted)- 已接受处理请求但尚未完成(异步处理)
301(Moved Permanently)- 资源的URI被更新
303(See Other)- 其他(如,负载均衡)
400(bad request)- 指代坏请求
404 (not found)- 资源不存在
406 (not acceptable)- 服务端不支持所需表示
409 (conflict)- 通用冲突
412 (Precondition Failed)- 前置条件失败(如执行条件更新时的冲突)
415 (unsupported media type)- 接受到的表示不受支持
500 (internal server error)- 通用错误响应
503 (Service Unavailable)- 服务当前无法处理请求
PUT
不安全但幂等
用客户端管理的实例号创建一个资源
通过替换的方式更新资源
如果未被修改,则更新资源(乐观锁)
200 (OK)- 如果已存在资源被更改
201 (created)- 如果新资源被创建
301(Moved Permanently)- 资源的URI已更改
303 (See Other)- 其他(如,负载均衡)
400 (bad request)- 指代坏请求
404 (not found)- 资源不存在
406 (not acceptable)- 服务端不支持所需表示
409 (conflict)- 通用冲突
412 (Precondition Failed)- 前置条件失败(如执行条件更新时的冲突)
415 (unsupported media type)- 接受到的表示不受支持
500 (internal server error)- 通用错误响应
503 (Service Unavailable)- 服务当前无法处理请求
DELETE
不安全但幂等
删除资源
200 (OK)- 资源已被删除
301 (Moved Permanently)- 资源的URI已更改
303 (See Other)- 其他,如负载均衡
400 (bad request)- 指代坏请求
404 (not found)- 资源不存在
409 (conflict)- 通用冲突
500 (internal server error)- 通用错误响应
503 (Service Unavailable)- 服务端当前无法处理请求
实践中常见的问题:
POST和PUT用于创建资源时有什么区别?
统一资源接口对URI有什么指导意义?
直接忽视缓存可取吗?
响应代码的处理有必要吗?
客户端如何知道服务端提供哪种表述形式呢?
在URI里边带上版本号
若服务器不支持,它应该返回一个HTTP 406响应,表示拒绝处理该请求。
实际上,状态应该区分应用状态和资源状态,客户端负责维护应用状态,而服务端维护资源状态。
客户端与服务端的交互必须是无状态的,并在每一次请求中包含处理该请求所需的一切信息。
服务端不需要在请求间保留应用状态,只有在接受到实际请求的时候,服务端才会关注应用状态。
这种无状态通信原则,使得服务端和中介能够理解独立的请求和响应。
在多次请求中,同一客户端也不再需要依赖于同一服务器,方便实现高可扩展和高可用性的服务端。
但有时候我们会做出违反无状态通信原则的设计,例如利用Cookie跟踪某个服务端会话状态,常见的像J2EE里边的JSESSIONID。
这意味着,浏览器随各次请求发出去的Cookie是被用于构建会话状态的。
当然,如果Cookie保存的是一些服务器不依赖于会话状态即可验证的信息(比如认证令牌),这样的Cookie也是符合REST原则的。
在使用微服务时,由于有多个微服务协同工作,测试变得非常复杂。因此,测试分为不同的级别。
说到DDD(Domain-driven design 领域驱动设计),绕不开MVC,在MVC三层架构中,我们进行功能开发的之前,拿到需求,解读需求。往往最先做的一步就是先设计表结构,在逐层设计上层dao,service,controller。对于产品或者用户的需求都做了一层自我理解的转化。
用户需求在被提出之后经过这么多层的转化后,特别是研发需求在数据库结构这一层转化后,将业务以主观臆断行为进行了转化。一旦业务边界划分模糊,考虑不全,大量的逻辑补充堆积到了代码层实现,变得越来越难维护。
DDD的特点:
举个栗子: 以电商订单场景为例。假如我们现在要做一个电商订单下单的需求。涉及到用户选定商品,下订单,支付订单,对用户下单时的订单发货。 MVC架构里面,我们常见的做法是在分析好业务需求之后,就开始设计表结构了,订单表,支付表,商品表等等。然后编写业务逻辑。这是第一个版本的需求,功能迭代了,订单支付后我可以取消,下单的商品我们退换货,是不是又需要进行加表,紧跟着对于的实现逻辑也进行修改。功能不断迭代,代码就不断的层层往上叠。 DDD架构里面,我们先进行划分业务边界。这里面核心是订单。那么订单就是这个业务领域里面的聚合逻辑体现。支付,商品信息,地址等等都是围绕着订单实体。订单本身的属性决定之后,类似于地址只是一个属性的体现。当你将订单的领域模型构建好之后,后续的逻辑边界与仓储设计也就可以在此基础上设计了。
通用语言:就是能够简单、清晰、准确描述业务涵义和规则的语言。
限界上下文:用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。
其他:todo
双因素身份验证为帐户登录过程启用第二级身份验证。假设用户必须只输入用户名和密码,那么这被认为是单因素身份验证。

幂等性是能够以这样的方式做两次事情的特性,即最终结果将保持不变,即好像它只做了一次。
用法:在远程服务或数据源中使用 Idempotence,这样当它多次接收指令时,它只处理指令一次。
加入api put超时,需要重试,就需要保证api幂等
OAuth: OAuth(开放授权)是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。
wikipedia对OAuth的解释:OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。
QQ登录OAuth2.0总体处理流程如下:
todo:设计OAuth api
每个阶段的测试过程涉及面不同。端到端测试指客户端到服务端,只是不包括UI测试的完整测试。

容器是管理基于微服务的应用程序以便单独开发和部署它们的好方法。您可以将微服务封装在容器映像及其依赖项中,然后可以使用它来运行按需实例的微服务,而无需任何额外的工作。
语义监控,也称为 综合监控,将自动化测试与监控应用程序相结合,以检测业务失败因素。
Prometheus是由SoundCloud开发的开源监控报警系统和时间序列数据库(TSDB)。Prometheus使用Go语言开发,是Google BorgMon监控系统的开源版本。
特点
基本原理
服务过程
Grafana
非确定性测试(NDT)基本上是不可靠的测试。所以,有时可能会发生它们通过,显然有时它们也可能会失败。当它们失败时,它们会重新运行通过。
从测试中删除非确定性的一些方法如下:
todo
平时开发的业务代码,单个函数往往不是独立的,它需要依赖于其他模块、第三方库、数据库、消息交互的结果等等。
但是单元测试一般不允许有任何外部依赖(文件依赖,网络依赖,数据库依赖等),我们不会在测试代码中去连接数据库,调用api等。这些外部依赖在执行测试的时候需要被模拟(mock/stub)。在测试的时候,我们使用模拟的对象来模拟真实依赖下的各种行为。所以在做单元测试的时候,我们只需要让这些被依赖的其他函数返回我们期望的数据,就可以继续测试我们当前需要测试的函数。
区别:
Mock示例
生产代码
//auth.go
//假设我们有一个依赖http请求的鉴权接口
type AuthService interface{
Login(username string,password string) (token string,e error)
Logout(token string) error
}
Mock代码
//auth_test.go
type authService struct {}
func (auth *authService) Login (username string,password string) (string,error){
return "token", nil
}
func (auth *authService) Logout(token string) error{
return nil
}
在这里我们用 authService实现了 AuthService接口,这样测试 Login,Logout就不再需需要依赖网络请求了。而且我们也可以模拟一些错误的情况进行测试:
//auth_test.go
//模拟登录失败
type authLoginErr struct {
auth AuthService //可以使用组合的特性,Logout方法我们不关心,只用“覆盖”Login方法即可
}
func (auth *authLoginErr) Login (username string,password string) (string,error) {
return "", errors.New("用户名密码错误")
}
//模拟api服务器宕机
type authUnavailableErr struct {
}
func (auth *authUnavailableErr) Login (username string,password string) (string,error) {
return "", errors.New("api服务不可用")
}
func (auth *authUnavailableErr) Logout(token string) error{
return errors.New("api服务不可用")
}
Stub示例
Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法。
生成代码
//storage.go
//发送邮件
var notifyUser = func(username, msg string) { //<--将发送邮件的方法变成一个全局变量
auth := smtp.PlainAuth("", sender, password, hostname)
err := smtp.SendMail(hostname+":587", auth, sender,
[]string{username}, []byte(msg))
if err != nil {
log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
}
}
//检查quota,quota不足将发邮件
func CheckQuota(username string) {
used := bytesInUse(username)
const quota = 1000000000 // 1GB
percent := 100 * used / quota
if percent < 90 {
return // OK
}
msg := fmt.Sprintf(template, used, percent)
notifyUser(username, msg) //<---发邮件
}
//storage_test.go
func TestCheckQuotaNotifiesUser(t *testing.T) {
var notifiedUser, notifiedMsg string
notifyUser = func(user, msg string) { //<-看这里就够了,在测试中,覆盖了发送邮件的全局变量
notifiedUser, notifiedMsg = user, msg
}
// ...simulate a 980MB-used condition...
const user = "joe@example.org"
CheckQuota(user)
if notifiedUser == "" && notifiedMsg == "" {
t.Fatalf("notifyUser not called")
}
if notifiedUser != user {
t.Errorf("wrong user (%s) notified, want %s",
notifiedUser, user)
}
const wantSubstring = "98% of your quota"
if !strings.Contains(notifiedMsg, wantSubstring) {
t.Errorf("unexpected notification message <<%s>>, "+
"want substring %q", notifiedMsg, wantSubstring)
}
}
在Go中,如果要用stub,那将是侵入式的,必须将生产代码设计成可以用stub方法替换的形式。
上述例子体现出来的结果就是:为了测试,专门用一个全局变量 notifyUser来保存了具有外部依赖的方法。然而在不提倡使用全局变量的Go语言当中,这显然是不合适的。所以,并不提倡这种Stub方式。
mock+stub:todo
持续集成(CI)是每次团队成员提交版本控制更改时自动构建和测试代码的过程。这鼓励开发人员通过在每个小任务完成后将更改合并到共享版本控制存储库来共享代码和单元测试。
todo
我们知道拥有自己的数据库的每个微服务都是一个可独立部署的程序单元,这反过来又让我们可以创建一个状态机。因此,我们可以为特定的微服务指定不同的状态和事件。
例如,我们可以定义Order微服务。订单可以具有不同的状态。Order状态的转换可以是Order微服务中的独立事件。
Reactive Extensions也称为Rx。这是一种设计方法,我们通过调用多个服务来收集结果,然后编译组合响应。这些调用可以是同步或异步,阻塞或非阻塞。Rx是分布式系统中非常流行的工具,与传统流程相反。
什么是 gRPC? gRPC 是一种新式的高性能框架,它发展了基于 RPC (远程) 调用。 在应用程序级别,gRPC 简化了客户端和后端服务之间的消息传递。
典型的 gRPC 客户端应用将公开实现业务操作的本地进程内函数。 在此之下,该本地函数在远程计算机上调用另一个函数。 似乎是本地调用实质上成为对远程服务的透明进程外调用。 RPC 管道提取计算机之间的点到点网络通信、序列化和执行。
在云原生应用程序中,开发人员通常跨编程语言、框架和技术工作。 这种 互操作性 使消息协定和跨平台通信所需的管道变得复杂。 gRPC 提供”统一的水平层”来抽象这些问题。 开发人员在本机平台中编写代码,专注于业务功能,而 gRPC 处理通信管道。
gRPC 使用 HTTP/2 作为传输协议。 虽然与 HTTP 1.1 兼容,但 HTTP/2 具有许多高级功能:
gRPC 轻量且性能高。 它可以比 JSON 序列化快 8 倍,消息小 60-80%。
gRPC 采用名为协议缓冲区 的开放源代码技术。 它们提供高度高效且与平台无关的序列化格式,用于序列化服务相互发送的结构化消息。 开发人员使用跨平台接口定义语言 (IDL) **为每个微服务定义服务协定。 作为基于文本的文件实现的协定描述了每个服务 .proto 的方法、输入和输出。 **同一proto文件可用于基于不同开发平台构建的 gRPC 客户端和服务。
Protobuf 编译器使用 proto 文件为目标平台生成客户端 protoc 和服务代码。 该代码包括以下组件:
对于以下情况,倾向于使用 gRPC:
一般我们使用分布式锁有两个场景:
特点:
一般实现分布式锁有以下几个方式:
自研分布式锁:如谷歌的Chubby。
创建一个锁表保存锁信息:
阻塞锁:
非阻塞锁:
解锁:
锁超时:
分析:
乐观锁:
前面我们介绍的都是悲观锁,这里想额外提一下乐观锁,在我们实际项目中也是经常实现乐观锁,因为我们加行锁的性能消耗比较大,通常我们会对于一些竞争不是那么激烈,但是其又需要保证我们并发的顺序执行使用乐观锁进行处理,我们可以对我们的表加一个版本号字段,那么我们查询出来一个版本号之后,update或者delete的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。这样的一个策略很像我们的CAS(Compare And Swap),比较并交换是一个原子操作。这样我们就能避免加select * for update行锁的开销。
ZooKeeper
ZooKeeper也是我们常见的实现分布式锁方法,相比于数据库如果没了解过ZooKeeper可能上手比较难一些。ZooKeeper是以Paxos算法为基础分布式应用程序协调服务。Zk的数据节点和文件目录类似,所以我们可以用此特性实现分布式锁。我们以某个资源为目录,这个目录下面的节点就是我们需要获取锁的客户端,未获取到锁的客户端注册需要注册Watcher到上一个客户端
/lock是我们用于加锁的目录,/resource_name是我们锁定的资源,其下面的节点按照我们加锁的顺序排列。
锁超时
Zookeeper不需要配置锁超时,由于我们设置节点是临时节点,我们的每个机器维护着一个ZK的session,通过这个session,ZK可以判断机器是否宕机。如果我们的机器挂掉的话,那么这个临时节点对应的就会被删除,所以我们不需要关心锁超时。
ZK小结
Redis
加锁了之后如果机器宕机那么这个锁就不会得到释放所以会加入过期时间,加入过期时间需要和setNx同一个原子操作,在Redis2.8之前我们需要使用Lua脚本达到我们的目的,但是redis2.8之后redis支持nx和ex操作是同一原子操作。
redis原子命令set:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:
对于分布式锁,就可以 set resourceName value ex 5 nx
Redisson
RedLock
Redis小结

etcd
etcd、consul,这种服务发现的分布式应用,都可以做分布式锁。
在对一致性要求很高的业务场景下(电商、银行支付),一般选择使用zookeeper或者etcd。对比zookeeper与etcd,如果考虑性能、并发量、维护成本来看。由于etcd是用Go语言开发,直接编译为二进制可执行文件,并不依赖其他任何东西,则更具有优势。
在CAP理论中,由于分布式系统中多节点通信不可避免出现网络延迟、丢包等问题一定会造成网络分区,在造成网络分区的情况下,一般有两个选择:CP or AP。
所以,对于分布式锁来说,在对数据有强一致性要求的场景下,AP模型不是一个好的选择。如果可以容忍少量数据丢失,出于维护成本等因素考虑,AP模型的Redis可优先选择。
etcd实现分布式锁的相关接口
对于分布式锁,主要用到etcd对应的添加、删除、续约接口。
// KV:键值相关操作
type KV interface {
// 存放.
Put(ctx context.Context, key, val string, opts ...OpOption) (*PutResponse, error)
// 获取.
Get(ctx context.Context, key string, opts ...OpOption) (*GetResponse, error)
// 删除.
Delete(ctx context.Context, key string, opts ...OpOption) (*DeleteResponse, error)
// 压缩rev指定版本之前的历史数据.
Compact(ctx context.Context, rev int64, opts ...CompactOption) (*CompactResponse, error)
// 通用的操作执行命令,可用于操作集合的遍历。Put/Get/Delete也是基于Do.
Do(ctx context.Context, op Op) (OpResponse, error)
// 创建一个事务,只支持If/Then/Else/Commit操作.
Txn(ctx context.Context) Txn
}
// Lease:租约相关操作
type Lease interface {
// 分配一个租约.
Grant(ctx context.Context, ttl int64) (*LeaseGrantResponse, error)
// 释放一个租约.
Revoke(ctx context.Context, id LeaseID) (*LeaseRevokeResponse, error)
// 获取剩余TTL时间.
TimeToLive(ctx context.Context, id LeaseID, opts ...LeaseOption) (*LeaseTimeToLiveResponse, error)
// 获取所有租约.
Leases(ctx context.Context) (*LeaseLeasesResponse, error)
// 续约保持激活状态.
KeepAlive(ctx context.Context, id LeaseID) (<-chan *LeaseKeepAliveResponse, error)
// 仅续约激活一次.
KeepAliveOnce(ctx context.Context, id LeaseID) (*LeaseKeepAliveResponse, error)
// 关闭续约激活的功能.
Close() error
}
etcd实现分布式锁代码示例
package main
import (
"context"
"fmt"
"go.etcd.io/etcd/clientv3"
"time"
)
var conf clientv3.Config
// 锁结构体
type EtcdMutex struct {
Ttl int64//租约时间
Conf clientv3.Config //etcd集群配置
Key string//etcd的key
cancel context.CancelFunc //关闭续租的func
txn clientv3.Txn
lease clientv3.Lease
leaseID clientv3.LeaseID
}
// 初始化锁
func (em *EtcdMutex) init() error {
var err error
var ctx context.Context
client, err := clientv3.New(em.Conf)
if err != nil {
return err
}
em.txn = clientv3.NewKV(client).Txn(context.TODO())
em.lease = clientv3.NewLease(client)
leaseResp, err := em.lease.Grant(context.TODO(), em.Ttl)
if err != nil {
return err
}
ctx, em.cancel = context.WithCancel(context.TODO())
em.leaseID = leaseResp.ID
_, err = em.lease.KeepAlive(ctx, em.leaseID)
return err
}
// 获取锁
func (em *EtcdMutex) Lock() error {
err := em.init()
if err != nil {
return err
}
// LOCK
em.txn.If(clientv3.Compare(clientv3.CreateRevision(em.Key), "=", 0)).
Then(clientv3.OpPut(em.Key, "", clientv3.WithLease(em.leaseID))).Else()
txnResp, err := em.txn.Commit()
if err != nil {
return err
}
// 判断txn.if条件是否成立
if !txnResp.Succeeded {
return fmt.Errorf("抢锁失败")
}
returnnil
}
//释放锁
func (em *EtcdMutex) UnLock() {
// 租约自动过期,立刻过期
// cancel取消续租,而revoke则是立即过期
em.cancel()
em.lease.Revoke(context.TODO(), em.leaseID)
fmt.Println("释放了锁")
}
// groutine1
func try2lock1() {
eMutex1 := &EtcdMutex{
Conf: conf,
Ttl: 10,
Key: "lock",
}
err := eMutex1.Lock()
if err != nil {
fmt.Println("groutine1抢锁失败")
return
}
defer eMutex1.UnLock()
fmt.Println("groutine1抢锁成功")
time.Sleep(10 * time.Second)
}
// groutine2
func try2lock2() {
eMutex2 := &EtcdMutex{
Conf: conf,
Ttl: 10,
Key: "lock",
}
err := eMutex2.Lock()
if err != nil {
fmt.Println("groutine2抢锁失败")
return
}
defer eMutex2.UnLock()
fmt.Println("groutine2抢锁成功")
}
// 测试代码
func EtcdRunTester() {
conf = clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 5 * time.Second,
}
// 启动两个协程竞争锁
go try2lock1()
go try2lock2()
time.Sleep(300 * time.Second)
}
总结:数据要求强一致性推荐支持CP的etcd、zookeeper,数据允许少量丢失、不要求强一致性的推荐支持AP的Redis
etcd 支持以下功能,正是依赖这些功能来实现分布式锁的:
实现过程:
客户端连接 Etcd,以 /lock/mylock 为前缀创建全局唯一的 key,假设第一个客户端对应的 key=”/lock/mylock/UUID1”,第二个为 key=”/lock/mylock/UUID2”;客户端分别为自己的 key 创建租约 - Lease,租约的长度根据业务耗时确定,假设为 15s;
当一个客户端持有锁期间,其它客户端只能等待,为了避免等待期间租约失效,客户端需创建一个定时任务作为“心跳”进行续约。此外,如果持有锁期间客户端崩溃,心跳停止,key 将因租约到期而被删除,从而锁释放,避免死锁。
步骤 5: 执行业务 获得锁后,操作共享资源,执行业务代码。
步骤 6: 释放锁 完成业务流程后,删除对应的key释放锁。
选举
etcd有多种使用场景,Master选举是其中一种。说起Master选举,过去常常使用zookeeper,通过创建EPHEMERAL_SEQUENTIAL节点(临时有序节点),我们选择序号最小的节点作为Master,逻辑直观,实现简单是其优势,但是要实现一个高健壮性的选举并不简单,同时zookeeper繁杂的扩缩容机制也是沉重的负担。
master 选举根本上也是抢锁,与zookeeper直观选举逻辑相比,etcd的选举则需要在我们熟悉它的一系列基本概念后,调动我们充分的想象力:
一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。

Consistency 一致性
Availability 可用性 可用性指“Reads and writes always succeed”,即服务在正常响应时间内一直可用。
Partition Tolerance分区容错性 分区容错性指“the system continues to operate despite arbitrary message loss or failure of part of the system”,即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性或可用性的服务。

1、客户端(gRPC Stub)调用 A 方法,发起 RPC 调用。
2、对请求信息使用 Protobuf 进行对象序列化压缩(IDL)。
3、服务端(gRPC Server)接收到请求后,解码请求体,进行业务逻辑处理并返回。
4、对响应结果使用 Protobuf 进行对象序列化压缩(IDL)。
5、客户端接受到服务端响应,解码请求体。回调被调用的 A 方法,唤醒正在等待响应(阻塞)的客户端调用并返回响应结果。
配置通常都只会有一个入口修改,因此可以考虑在配置修改后,通知应用服务清理本地缓存和分布式缓存。这里可以引入mq或ZooKeeper。最后通过,推拉相结合的方式,完成数据的一致性。

负载均衡(Load Balancing),简单地说就是将多台服务器组成一个服务器集群,然后根据我们设置的规则给服务器集群分配“工作任务”。

多种方案:
解决方案主要有两种: 1)配置反向代理的转发规则,让同一个用户的请求一定落到同一台机器上(通过分析cookie),复杂的转发规则将会消耗更多的CPU,也增加了代理服务器的负担。 2)将session这类的信息,专门用某个独立服务来存储,例如Redis/memchache,这个方案是比较推荐的。
反向代理服务,也是可以开启缓存的,如果开启了,会增加反向代理的负担,需要谨慎使用。这种负载均衡策略实现和部署非常简单,而且性能表现也比较好。 但是,它有“单点故障”的问题,如果挂了,会带来很多的麻烦。而且,到了后期Web服务器继续增加,它本身可能成为系统的瓶颈。
CDN在Web系统中,一般情况下是用来解决较大的静态资源(html/Js/Css/图片/视频/文件等)的加载问题,让这些比较依赖网络下载的内容,尽可能离用户更近,提升用户体验。 这种方式,和前面的DNS负载均衡一样,性能极佳,而且支持配置多种策略。但是,搭建和维护成本非常高。互联网一线公司,会自建CDN服务,中小型公司一般使用第三方提供的CDN。
分布式事务是指单个事件导致两个或多个不能以原子方式提交的单独数据源的突变的任何情况。在微服务的世界中,它变得更加复杂,因为每个服务都是一个工作单元,并且大多数时候多个服务必须协同工作才能使业务成功。
CAP 略(上面有了)
ACID(Atomicity原子性,Consistency一致性,Isolation隔离性,Durability持久性)是事务的特点,具有强一致性,一般用于单机事务,分布式事务若采用这个原则会丧失一定的可用性,属于CP系统。
BASE(Basically Availabe基本可用,Soft state软状态,Eventually consistency最终一致性)理论是对大规模的互联网分布式系统实践的总结,用弱一致性来换取可用性,不同于ACID,属于AP系统。
在高并发领域,在分布式系统中,可能因为一个小小的功能扛不住压力,宕机了,导致其他服务也跟随宕机,最终导致整个系统宕机。所以需要熔断器,避免雪崩。
如果你的微服务依赖于另一个系统来提供响应,并且该系统需要很长时间才会响应,那么你的总体响应SLA将会受到影响。为了避免这种场景并且快速做出响应,你需要遵循的一个简单的微服务最佳实践是使用熔断器来使外部的调用超时,然后返回一个默认响应或者错误。熔断器模式可以参考最下面的引用。这种方式可以隔离故障服务,而不会导致级联故障,可以让你的服务保持在健康的状态。你可以选择使用流行的产品,比如Netflix开发的Hystrix。这要比使用HTTP CONNECT_TIMEOUT和READ_TIMEOUT设置更好,因为它不会启动超出配置范围的其他线程。
熔断本质上是一个过载保护机制。
做熔断的思路大体上就是:一个中心思想,分四步走。
首先,需秉持的一个中心思想是:量力而行。因为软件和人不同,没有奇迹会发生,什么样的性能撑多少流量是固定的。这是根本。
然后,这四步走分别是:
识别:
切断联系:
我们不能将偶发的瞬时异常等同于系统“不可用”(避免以偏概全)。由此我们需要引入一个「时间窗口」的概念,这个时间窗口用来“放宽”判定“不可用”的区间,也意味着多给了系统几次证明自己“可用”机会。但是,如果系统还是在这个时间窗口内达到了你定义“不可用”标准,那么我们就要“断臂求生”了。
这个标准可以有两种方式来指定。
作为客户端一方,在自己进程内通过代理发起调用之前就可以直接返回失败,不走网络。
尝试探测本质上是一个“试错”,要控制下“试错成本”。所以我们不可能拿100%的流量去验证,在服务端,一般会有以下两种方式:
恢复:
一旦通过了衡量是否“可用”的验证,整个系统就恢复到了“正常”状态,此时需要重新开启识别“不可用”的策略。就这样,系统会形成一个循环。
熔断往往应作为最后的选择,我们应优先使用一些「降级」或者「限流」方案。
todo:降级、限流
服务提供者是什么,简单点说就是一个HTTP服务器,提供了API服务,有一个IP端口作为服务地址。服务消费者是什么,它就是一个简单的进程,想要访问服务提供者提供的服务来干一些事情。一个HTTP服务器既可以是服务提供者对外提供服务,也可以是消费者需要别的服务提供者提供的服务,这就是服务依赖
服务发现有三个角色,服务提供者、服务消费者和服务中介。服务中介是联系服务提供者和服务消费者的桥梁。服务提供者将自己提供的服务地址注册到服务中介,服务消费者从服务中介那里查找自己想要的服务的地址,然后享受这个服务。服务中介提供多个服务,每个服务对应多个服务提供者。
服务中介就是一个字典,字典里有很多key/value键值对,key是服务名称,value是服务提供者的地址列表。服务注册就是调用字典的Put方法塞东西,服务查找就是调用字典的Get方法拿东西。
当服务提供者节点挂掉时,要求服务能够及时取消注册,比便及时通知消费者重新获取服务地址。
当服务提供者新加入时,要求服务中介能及时告知服务消费者,你要不要尝试一下新的服务。
有两种主要的服务发现模式:客户端服务发现(client-side discovery)和服务器端服务发现(server-side discovery)。
当使用客户端服务发现的时候,客户端负责决定可用的服务实例的网络地址,以及围绕他们的负载均衡。客户端向服务注册表(service registry)发送一个请求,服务注册表是一个可用服务实例的数据库。客户端使用一个负载均衡算法,去选择一个可用的服务实例,来响应这个请求
一个服务实例被启动时,它的网络地址会被写到注册表上;当服务实例终止时,再从注册表中删除。这个服务实例的注册表通过心跳机制动态刷新。
客户端的服务发现模式有优势也有缺点。这种模式相对直接,但是除了服务注册表,没有其它动态的部分了。而且,由于客户端知道可用的服务实例,可以做到智能的,应用明确的负载均衡决策,比如一直用hash算法。这种模式的一个重大缺陷在于,客户端和服务注册表是逻辑耦合,必须为服务客户端用到的每一种编程语言和框架实现客户端服务发现逻辑。
客户端通过负载均衡器向一个服务发送请求,这个负载均衡器会查询服务注册表,并将请求路由到可用的服务实例上。通过客户端的服务发现,服务实例在服务注册表上被注册和注销。
AWS的ELB(Elastic Load Blancer)就是一个服务器端服务发现路由器。一个ELB通常被用来均衡来自互联网的外部流量,也可以用ELB去均衡流向VPC(Virtual Private Cloud)的流量。一个客户端通过ELB发送请求(HTTP或TCP)时,使用的是DNS,ELB会均衡这些注册的EC2实例或ECS(EC2 Container Service)容器的流量。没有另外的服务注册表,EC2实例和ECS容器也只会在ELB上注册。
HTTP服务器和类似Nginx、Nginx Plus的负载均衡器也可以被用做服务器端服务发现负载均衡器。
服务实例必须要从注册表中注册和注销,有很多种方式来处理注册和注销的过程。一个选择是服务实例自己注册,即self-registration模式。另一种选择是其它的系统组件管理服务实例的注册,即第third-party registration模式。
自注册模式(The Self-Registration Pattern)
在self-registration模式中,服务实例负责从服务注册表中注册和注销。如果需要的话,一个服务实例发送心跳请求防止注册过期。
第三方注册模式(The Third-Party Registration Pattern)
在third-party registration模式中,服务实例不会自己在服务注册表中注册,由另一个系统组件service registrar负责。service registrar通过轮询部署环境或订阅事件去跟踪运行中的实例的变化。当它注意到一个新的可用的服务实例时,就会到注册表中去注册。service registrar也会将停止的服务实例注销
third-party registration模式主要的优势在于解耦了服务和服务注册表。不需要为每个语言和框架都实现服务注册逻辑。服务实例注册由一个专用的服务集中实现。缺点是除了被内置到部署环境中,它本身也是一个高可用的系统组件,需要被启动和管理。
todo
todo
在微服务架构下,我们更倾向于将 Oauth 和 JWT 结合使用,Oauth 一般用于第三方接入的场景,管理对外的权限,所以比较适合和 API 网关结合,针对于外部的访问进行鉴权(当然,底层 Token 标准采用 JWT 也是可以的)。 JWT 更加轻巧,在微服务之间进行访问鉴权已然足够,并且可以避免在流转过程中和身份认证服务打交道。当然,从能力实现角度来说,类似于分布式 Session 在很多场景下也是完全能满足需求,具体怎么去选择鉴权方案,还是要结合实际的需求来。
elasticsearch、fluentd、kubana




version: '3.7'
services:
zookeeper:
image: wurstmeister/zookeeper
volumes:
- ./data:/data
ports:
- 2181:2181
kafka:
image: wurstmeister/kafka
ports:
- 9092:9092
environment:
KAFKA_BROKER_ID: 0
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:9092
KAFKA_CREATE_TOPICS: "kafeidou:2:0"
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
volumes:
- ./kafka-logs:/kafka
depends_on:
- zookeeper
注意:KAFKA_LISTENERS和KAFKA_ADVERTISED_LISTENERS中的ip是需要确认的。
# 从根路径查找包含kafka的文件名
$ find / -name \*kafka_\* | head -1 | grep -o '\kafka[^\n]*'
# kafka版本号前面是scala版本,后面是kafka版本
kafka_2.13-2.7.1
# 安装路径位于/opt/kafka_2.13-2.7.1
# 创建叫做“web_log” 的topic, 它有一个分区和一个副本
$ bin/kafka-topics.sh --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic web_log
# 列出全部topic
$ bin/kafka-topics.sh --list --zookeeper localhost:2181
# 启动生产者,在交互模式下输入消息
$ bin/kafka-console-producer.sh --broker-list localhost:9092 --topic web_log
# 启动消费者,从开始部分消费,会将消息转储到标准输出
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic web_log --from-beginning
import ( “fmt” “github.com/Shopify/sarama” )
// 基于sarama第三方库开发的kafka client
func main() { config := sarama.NewConfig() // 默认配置 config.Producer.RequiredAcks = sarama.WaitForAll // 发送完数据需要leader和follow都确认 config.Producer.Partitioner = sarama.NewRandomPartitioner // 新选出一个partition config.Producer.Return.Successes = true // 成功交付的消息将在success channel返回
// 构造一个消息 msg := &sarama.ProducerMessage{} msg.Topic = “web_log” msg.Value = sarama.StringEncoder(“this is a test log”) // 连接kafka client, err := sarama.NewSyncProducer([]string{“127.0.0.1:9092”}, config) if err != nil { fmt.Println(“producer closed, err:”, err) return } defer client.Close()
// 发送消息 pid, offset, err := client.SendMessage(msg) if err != nil { fmt.Println(“send msg failed, err:”, err) return } fmt.Printf(“partition_id:%v offset:%v\n”, pid, offset) }
- 消费者
```Go
package main
import (
"fmt"
"time"
"github.com/Shopify/sarama"
)
// kafka consumer
func main() {
consumer, err := sarama.NewConsumer([]string{"127.0.0.1:9092"}, nil)
if err != nil {
fmt.Printf("fail to start consumer, err:%v\n", err)
return
}
partitionList, err := consumer.Partitions("web_log") // 根据topic取到所有的分区
if err != nil {
fmt.Printf("fail to get list of partition:err%v\n", err)
return
}
fmt.Println(partitionList)
for partition := range partitionList { // 遍历所有的分区
// 针对每个分区创建一个对应的分区消费者
pc, err := consumer.ConsumePartition("web_log", int32(partition), sarama.OffsetOldest) // OffsetOldest表示取过去的消息
if err != nil {
fmt.Printf("failed to start consumer for partition %d,err:%v\n", partition, err)
return
}
defer pc.AsyncClose()
// 异步从每个分区消费信息
go func(sarama.PartitionConsumer) {
for msg := range pc.Messages() {
fmt.Printf("Partition:%d Offset:%d Key:%v Value:%v", msg.Partition, msg.Offset, msg.Key, string(msg.Value))
}
}(pc)
}
time.Sleep(time.Second * 1) // 延时,等待协程,要用wg代替
}
深拷贝(deep copy)的是数据本身,创造一个新对象,新对象与原对象不共享内存,新对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。既然内存地址不同,释放内存地址时,可分别释放。
值类型的数据,默认全部都是深复制,Array、Int、String、Struct、Float,Bool。
浅拷贝(Shallow Copy)拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。释放内存地址时,同时释放内存地址。
引用类型的数据,默认全部都是浅复制,Slice,Map。
package main
import (
"fmt"
"reflect"
)
func main() {
s1 := 1
s2 := &s1
s3 := s1
fmt.Println(s1)
fmt.Println(s2)
fmt.Println(*s2)
fmt.Println(reflect.TypeOf(s1))
fmt.Println(reflect.TypeOf(s2)) // s2被自动声明为*int类型,注意不是int*
*s2 = 2 // 注意s2是*int类型,不能被赋值为2。所以需要用地址取对应的值再修改。也就是修改了s1内存中的数值。
fmt.Println(s1)
fmt.Println(s2)
s1 = 10
fmt.Println(s1)
fmt.Println(s2)
fmt.Println(*s2)
s3 = 3
fmt.Println(s1)
fmt.Println(s3)
}
/*
输出:
1
0xc000016060
int
*int
2
0xc000016060
10
0xc000016060
10
10
3
*/