go-resilency 源码阅读 (全部模块)

50 阅读26分钟

go-resilency 源码阅读

💡 golang 的弹性模式。部分基于 Hystrix、Semian 等
  • 断路器在 (在 breaker 目录)
  • 信号量(在 semaphore 目录)
  • deadline/超时(在 dealine 目录)
  • 批处理 (在 batcher 目录)
  • 可恢复(或者翻译为可重试?)(retriable) (在 retrier 目录)

项目地址: github.com/eapache/go-…

batcher

golang 的批处理弹性模式

创建批处理需要两个参数

  • 收集批处理时等待的超时
  • 批处理完成后运行的函数

还可以选择设置一个预过滤器,以便在进入批处理之前,过滤掉,不让它进入批处理

仓库给的使用例子:

b := batcher.New(10*time.Millisecond, func(params []interface{}) error {
	// do something with the batch of parameters
	return nil
})

b.Prefilter(func(param interface{}) error {
	// do some sort of sanity check on the parameter, and return an error if it fails
	return nil
})

for i := 0; i < 10; i++ {
	go b.Run(i)
}

网上搜索了一下这个库的详细解释,大部份文章都是对这个库如何使用或者简要的流程分析,很少有对源码一行行进行分析的资料,我认为知道这个库是如何使用的以及中间执行的简要流程,学习到的东西是表象的,应该深入代码,看看它内部具体是如何实现的,才能达到我们学习的目的。

上面一段是废话,现在让我们开始进入源码分析阶段。


// New constructs a new batcher that will batch all calls to Run that occur within
// `timeout` time before calling doWork just once for the entire batch. The doWork
// function must be safe to run concurrently with itself as this may occur, especially
// when the timeout is small.
func New(timeout time.Duration, doWork func([]interface{}) error) *Batcher {
	return &Batcher{
		timeout: timeout,
		doWork:  doWork,
	}
}

根据注释,我们可以知道,这里新创建一个新的批处理器,它将批处理在 timeout 的时间内发生的所有对 Run 的调用,然后对整个批处理器只调用一次 doWork ,这个 dowork 的函数必须能安全能安全地与自身并发运行。

Batcher 的结构体所含有的参数如下所示:

type Batcher struct {
	timeout   time.Duration
	prefilter func(interface{}) error

	lock   sync.Mutex
	submit chan *work
	doWork func([]interface{}) error
	done   chan bool
}

其中 timeout 和 dowork 是我们 New 这个 Batcher 时所传入的,根据上面仓库给出的使用例子,当我们需要过滤掉一些不需要进行批处理阶段的的参数时,可以在 New 完 Batcher 后,给 prefilter 这个字段,设置我们的过滤函数,其他的字段在后面会一一介绍。

当我们创建好 Batcher 并按需设置了过滤函数后,调用 Run 函数,Run 函数根据我们传入的参数执行 work 函数,并将在超时时间内发生的调用一起批量运行


func (b *Batcher) Run(param interface{}) error {
	// 当过滤函数不等于 nil时,执行过滤函数,过滤掉不需要进入批处理的参数
	if b.prefilter != nil {
		if err := b.prefilter(param); err != nil {
			return err
		}
	}
	
	// 当 timeout 时间为 0 时,直接执行我们设置的 work 函数,并返回
	// 意味着当我们设置的 timeout 参数为 0 时,就相当于不会批量处理了
	if b.timeout == 0 {
		return b.doWork([]interface{}{param})
	}
	
	// 根据我们传入的参数初始化要执行的任务
	w := &work{
		param:  param,
		future: make(chan error, 1),
	}
	
	// 提交任务
	b.submitWork(w)
	
	// 阻塞在这里,等待提交的任务执行完成,等待 future 的通信,然后执行完成
	return <-w.future
}

提交任务时,内部的实现:

func (b *Batcher) submitWork(w *work) {
	// 加锁,保证并发运行不会出问题
	b.lock.Lock()
	defer b.lock.Unlock()
	
	if b.submit == nil {
		// 当 submit 等于 nil时,初始化 done 和 submit 字段,都是 chan 类型
		// 这里的 submit 为什么的 chan 长度为什么要设置为 4 ? 
		b.done = make(chan bool)
		b.submit = make(chan *work, 4)
		// 并发执行批处理,这里并发执行了,那么不会出现执行 batch 方法时 ,submit 为空的吗?
		// 这里就涉及到 golang 中 channel 的知识点了
		// 当循环没有 close 掉的channel 时,range 遍历是会发生阻塞的
		go b.batch()
	}
	
	// 将要执行的任务传递给 submit 字段
	b.submit <- w
}

执行批处理,内部的实现:

func (b *Batcher) batch() {
	var params []interface{}
	var futures []chan error
	// 将 submit 设置为输入参数
	input := b.submit
	// 并发执行定时器,定时器做了什么处理,后面会说到
	go b.timer()
	// 遍历输入参数,搜集超时时间内的所有传入的参数
	for work := range input {
		params = append(params, work.param)
		futures = append(futures, work.future)
	}
	// 将搜集到的所有参数,传入我们要执行的真正的函数,也就是上面讲的只执行一个的 dowork 函数
	ret := b.doWork(params)
	// 遍历所有要执行的 work 的 futures
	for _, future := range futures {
	  // 将批处理后的值传递给它们
		future <- ret
		// 关闭每个要执行的 work 的 future 通道
		close(future)
	}
	// 关闭批处理的 done 通道
	close(b.done)
}

定时器内部实现:

func (b *Batcher) timer() {
  // 根据我们设定的超时时长,进行休眠
	time.Sleep(b.timeout)
	// 然后保存所有的变更
	b.flush()
}

func (b *Batcher) flush() {
	b.lock.Lock()
	defer b.lock.Unlock()
	
	// 当 submit 为 nil 时,直接返回
	if b.submit == nil {
		return
	}
	// 关闭 submit 通道
	close(b.submit)
	// 重新设置 submit 为 nil
	// 我刚开始困惑,会不会超时时间到了之后,后面传入的参数就被丢弃了呢?
	// 这里解答了我的疑惑,当超时时间到了之后,重新设置 submit 为 nil
	// 然后进入一个新的收集阶段
	b.submit = nil
}

到此基本分析完了,此时我还发现测试用例,还可以执行 shutdown 操作,

// Shutdown flush the changes and wait to be saved
func (b *Batcher) Shutdown(wait bool) {
	// 保存所有的变更
	b.flush()
	
	// 如果我们传入的参数是 true 的话,会等待当前批处理操作执行完
	if wait {
		if b.done != nil {
			// wait done channel
			<-b.done
		}
	}
}

总结

可以看出 go-resilency 这个项目设计到了大量 channel 通信和并发的知识点,如果让我推荐学习 channel 通信和并发的东西, go-resilency 将会是我的首选。

breaker

golang 的熔断弹性模式

创建断路器需要三个参数

  • 错误阈值(用于打开断路器)
  • 成功阈值 (用于关闭断路器)
  • 超时(断路器保持打开状态的时间)

仓库给的使用例子:

b := breaker.New(3, 1, 5*time.Second)

for {
	result := b.Run(func() error {
		// communicate with some external service and
		// return an error if the communication failed
		return nil
	})

	switch result {
	case nil:
		// success!
	case breaker.ErrBreakerOpen:
		// our function wasn't run because the breaker was open
	default:
		// some other error
	}
}

源码分析阶段

// New constructs a new circuit-breaker that starts closed.
// From closed, the breaker opens if "errorThreshold" errors are seen
// without an error-free period of at least "timeout". From open, the
// breaker half-closes after "timeout". From half-open, the breaker closes
// after "successThreshold" consecutive successes, or opens on a single error.
func New(errorThreshold, successThreshold int, timeout time.Duration) *Breaker {
	return &Breaker{
		errorThreshold:   errorThreshold,
		successThreshold: successThreshold,
		timeout:          timeout,
	}
}

根据注释我们知道,这里新建了一个新的断路器,初始状态是闭合的。从闭合开始,如果 "errorThreshold"(错误阈值)错误出现,而无错时间至少为 "timeout"(超时),则断路器断开。从打开开始,断路器在 "timeout" 后半闭。 从半开开始,断路器在 "成功阈值 "连续成功后关闭,或在出现一次错误后打开。

Breaker 的结构体所含有的参数如下所示:

// Breaker implements the circuit-breaker resiliency pattern
type Breaker struct {
	errorThreshold, successThreshold int
	timeout                          time.Duration

	lock              sync.Mutex
	state             uint32
	errors, successes int
	lastError         time.Time
}

其中 errorThreshold、successThreshold 、 timeout 是我们 New 这个 breaker 时所传入的,lock 字段在接下来的 processResult 函数和 timer 函数出现, 不言而喻就是为了防止并发执行出现竞争的情况。

state 状态 有三种,分别时关闭状态,打开状态和半打开状态,从这里我们也可以看出来,由于我们 New 一个 Breaker时,并没有传入 state 相关的状态,所以默认的状态相应的零值就是 closed

const (
	closed uint32 = iota
	open
	halfOpen
)

errors,success 是记录出现失败和成功的次数,当断路器更改状态的时候,会将它们重新置为 0

lastError 记录了最后一次出现错误的时间点

让我们进入执行函数:

// Run will either return ErrBreakerOpen immediately if the circuit-breaker is
// already open, or it will run the given function and pass along its return
// value. It is safe to call Run concurrently on the same Breaker.
func (b *Breaker) Run(work func() error) error {
	// 利用原子的方式取出,取出熔断器的状态,对 atomic 感兴趣的,可以看下我的第一篇文章最后一节
	state := atomic.LoadUint32(&b.state)

	if state == open {
		return ErrBreakerOpen
	}

	return b.doWork(state, work)

根据注释我们知道,如果断路器已经打开,Run 将立即返回 ErrBreakerOpen,否则它将运行给定函数并传递其返回值。并且在同一个断路器上同时调用 Run 是安全的。

执行相应的 work 函数的内部实现:

func (b *Breaker) doWork(state uint32, work func() error) error {
	var panicValue interface{}
	//这里的func()已经执行了,result其实是个error类型,
	//之前一直以为是把result当作一个func()来用的
	result := func() error {
		defer func() {
			panicValue = recover()
		}()
		return work()
	}()

	//如果work函数返回值为nil,panicValue为nil,并且熔断器的状态为关闭时,直接返回 nil
	if result == nil && panicValue == nil && state == closed {
		// short-circuit the normal, success path without contending
		// on the lock
		return nil
	}

	// 否则,根据 result 和 panicValue 进行处理
	// oh well, I guess we have to contend on the lock
	b.processResult(result, panicValue)
	// 如果 panicValue 不为 nil,返回 panic
	if panicValue != nil {
		// GO让我们尽可能地接近“重抛”,尽管不幸的是我们失去了原来的产生panic的地方
		// as close as Go lets us come to a "rethrow" although unfortunately
		// we lose the original panicing location
		panic(panicValue)
	}
	
	// 最后返回相应的 result 值
	return result
}

当上述代码 if result == nil && panicValue == nil && state == closed 不成立时,接下来要执行的 processResult 的内部实现:

func (b *Breaker) processResult(result error, panicValue interface{}) {
	// 加锁
	b.lock.Lock()
	// 最后需要释放锁
	defer b.lock.Unlock()

	//当没有错误产生时
	if result == nil && panicValue == nil {
		//这时如果熔断器的状态处于半开的状态
		if b.state == halfOpen {
			//熔断器成功的次数+1
			b.successes++
			//如果熔断器的成功次数等于最大的成功次数
			if b.successes == b.successThreshold {
				//关闭熔断器
				b.closeBreaker()
			}
		}
	} else {
		//当有错误产生的情况
		//如果错误的次数大于0
		if b.errors > 0 {
			//失效的时间加上熔断器的超时时间
			expiry := b.lastError.Add(b.timeout)
			//如果失效时间小于现在的时间
			if time.Now().After(expiry) {
				//将错误的次数置为0
				b.errors = 0
			}
		}
		//检查熔断器的状态
		switch b.state {
		//如果处于关闭的状态
		case closed:
			//将熔断器的错误次数+1
			b.errors++
			//如果熔断器的错误次数等于熔断器最大的错误次数
			if b.errors == b.errorThreshold {
				//打开熔断器
				b.openBreaker()
			} else {
				//否则将熔断器最后一次产生错误的时间置为现在这个时刻
				b.lastError = time.Now()
			}
		//如果处于半开的状态
		// 这里当容易处于半打开状态时,出现一次出现就打开熔断器
		case halfOpen:
			//打开熔断器
			b.openBreaker()
		}
	}
}

打开熔断器和关闭熔断器的内部实现:

func (b *Breaker) openBreaker() {
	//打开熔断器
	b.changeState(open)
	//执行定时器
	go b.timer()
}

func (b *Breaker) closeBreaker() {
	// 关闭断路器
	b.changeState(closed)
}

这里对比之下,有一点特别的点就是,打开熔断器的内部实现,里面还利用 go 的协程执行了一个定时器,我们来看以下定时器的逻辑:

func (b *Breaker) timer() {
	//打开熔断器的同时,隔固定的时间,将熔断器置为半打开的状态,以免熔断器一直处于打开的状态
	time.Sleep(b.timeout)
	//加锁
	b.lock.Lock()
	defer b.lock.Unlock()
	//将熔断器改为半开的状态
	b.changeState(halfOpen)
}

这个定时器的逻辑,其实就是根据我们 New 一个熔断器时传入的 timeout,进行休眠 timeout 时间后,将熔断器改为半开的状态

我们继续看下 changeState 的内部实现:

func (b *Breaker) changeState(newState uint32) {
	//更改断路器的状态,同时将失败和成功的次数置为 0
	b.errors = 0
	b.successes = 0
	atomic.StoreUint32(&b.state, newState)
}

从这里我们可以看到,改为熔断器的状态时,会将出现 errors 和 success 的次数重新置为 0,然后更改熔断器的状态

到此我们基本已经分析完毕,但是它内部还有一个 Go 函数

// Go will either return ErrBreakerOpen immediately if the circuit-breaker is
// already open, or it will run the given function in a separate goroutine.
// If the function is run, Go will return nil immediately, and will *not* return
// the return value of the function. It is safe to call Go concurrently on the
// same Breaker.
func (b *Breaker) Go(work func() error) error {
	state := atomic.LoadUint32(&b.state)

	if state == open {
		return ErrBreakerOpen
	}

	// errcheck complains about ignoring the error return value, but
	// that's on purpose; if you want an error from a goroutine you have to
	// get it over a channel or something
	go b.doWork(state, work)

	return nil
}

这个 Go 函数的逻辑其实跟上面的 Run 函数大部分是一致的,唯一的区别点是它用了协程执行去执行 dowork 函数,然后立即返回 nil 值,并且不会返回我们要执行的函数的返回值。

deadline

golang 的 deadline/timeout(超时) 弹性模式。

创建 deadline 只需一个参数:等待的时间。

💡 前几天发布后,发现末尾的地方,写得不是很清楚,于是删掉了,今天补充后,重新进行发布

仓库给的使用例子:

dl := deadline.New(1 * time.Second)

err := dl.Run(func(stopper <-chan struct{}) error {
	// do something potentially slow
	// give up when the `stopper` channel is closed (indicating a time-out)
	// 做一些可能很慢的事情
	// 当 `stopper` 通道关闭(表示超时)时放弃
	return nil
})

switch err {
case deadline.ErrTimedOut:
	// execution took too long, oops
	// 执行时间太长了
default:
	// some other error
	// 其他错误
}

首先通过 New 函数,新建一个超时器,传入的参数,是所期待的超时时长

func New(timeout time.Duration) *Deadline {
	return &Deadline{
		timeout: timeout,
	}

然后调用超时器的 Run 方法即可,在运行时间超出所期待的超时时长就会退出,返回 ErrTimedOut 的错误

// ErrTimedOut is the error returned from Run when the deadline expires.
var ErrTimedOut = errors.New("timed out waiting for function to finish")

// Run runs the given function, passing it a stopper channel. If the deadline passes before
// the function finishes executing, Run returns ErrTimeOut to the caller and closes the stopper
// channel so that the work function can attempt to exit gracefully. It does not (and cannot)
// simply kill the running function, so if it doesn't respect the stopper channel then it may
// keep running after the deadline passes. If the function finishes before the deadline, then
// the return value of the function is returned from Run.
// Run 运行给定函数,并传递给它一个 stopper 通道。如果在 
// 函数执行完毕之前截止时间已过,Run 会向调用者返回 ErrTimeOut,并关闭 stopper 
// 通道,以便工作函数可以优雅地退出。它不会(也不能) 
// 简单地杀死正在运行的函数,因此如果它不尊重 stopper 通道,那么它可能 
// 在截止日期过后继续运行。如果函数在截止日期前结束,那么 
// 函数的返回值将从 Run 返回。
func (d *Deadline) Run(work func(<-chan struct{}) error) error {
	result := make(chan error)
	stopper := make(chan struct{})

	go func() {
		value := work(stopper)
		select {
		case result <- value:
		case <-stopper:
		}
	}()

	select {
	case ret := <-result:
		return ret
	case <-time.After(d.timeout):
		close(stopper)
		return ErrTimedOut
	}
}

测试用例所要执行的 work 函数定义:

func takesFiveMillis(stopper <-chan struct{}) error {
	time.Sleep(5 * time.Millisecond)
	return nil
}

func takesTwentyMillis(stopper <-chan struct{}) error {
	time.Sleep(20 * time.Millisecond)
	return nil
}

func returnsError(stopper <-chan struct{}) error {
	return errors.New("foo")
}

可以看出我们要执行的函数的参数都必须是 chan 类型,然后定义的字段名是 stopper,其实定义为其他字段名也是一样的

如果只是想简单地将我们执行的函数超时就不执行了,那么直接定义完相应的执行函数和新建超时器进行执行即可,比如:

func takesFiveMillis(stopper <-chan struct{}) error {
	time.Sleep(5 * time.Millisecond)
	return nil
}

func takesTwentyMillis(stopper <-chan struct{}) error {
	time.Sleep(20 * time.Millisecond)
	return nil
}

func returnsError(stopper <-chan struct{}) error {
	return errors.New("foo")
}

func TestDeadline(t *testing.T) {
	dl := New(10 * time.Millisecond)

	if err := dl.Run(takesFiveMillis); err != nil {
		t.Error(err)
	}

	if err := dl.Run(takesTwentyMillis); err != ErrTimedOut {
		t.Error(err)
	}
}

但是如果你想在超时后执行一些操作,那么就可以利用我们执行函数的传入 stopper 参数,比如:

dl := New(10 * time.Millisecond)
done := make(chan struct{})
	err := dl.Run(func(stopper <-chan struct{}) error {
		<-stopper
		// 可以在这里执行在超时后,进行的一些处理
		close(done)
		return nil
	})·
	if err != ErrTimedOut {
		t.Error(err)
	}
	<-done
}

这里有一个大家可能会很疑惑的点,就是超时之后,Run 函数不应该是立即退出了吗?其实是的,超时之后就立即退出了,但是我们的 work 函数执行的时候是用协程去执行的,所以我们的 work 还是在执行中,

所以就需要 done 通道,去判断 work 函数是否执行完了,否则这个测试用例就直接退出了, 为了我这里说的是否正常,我们将测试用例注释相应的改动:

Untitled.png

查看执行结果后,可以看出我这里所说的是正确的

这里大家可能还有一个疑惑点,就是我们这里的 stopper 阻塞在这里,那么超时之后,不是就进行关闭了吗?

Untitled 1.png

Untitled 2.png

这里就设计到 channel 的知识点了,读已经关闭的 channel 进行读操作的时候,依然是可以读的,我们这里的 stopper 是无缓冲区的 channel,那么读出来的就是相应类型的零值,但是如果是有缓存区的呢?如果关闭前,有缓存区的 channel 里面有数据,我们仍然能读出来,读到没有数据的时候,再读就是相应类型的零值。

这里我们引申一下,如果去写已经关闭的缓存区呢?这种情况就会 **panic:send on closed channel ****的情况,对于有无缓冲区的 channel 都是一样的结果

semaphore

golang 的信号弹性模式

创建信号量需要两个参数:

票数(一次发多少张票)。

超时(如果当前没有可用的票证,则等待多长时间)

开始分析前,我们需要先知道信号量的用途是什么,我用国内的智谱清言问了一下。

信号量的用途主要包括:

  1. 限制并发访问:通过信号量,可以限制同时访问某个资源或执行某个操作的并发 goroutine 的数量,防止过度的并发导致资源耗尽,如数据库连接、文件描述符或硬件设备等
  2. 流量控制:在处理大量请求时,可以用来控制请求处理的速率,防止后端服务因请求过多而崩溃
  3. 资源池管理:例如数据库连接池、线程池等,信号量可以确保池中的资源被合理地分配和回收
  4. 提高系统稳定性:在面临突发高流量或系统异常时,信号量机制能够保证系统按照预设的并发级别工作,避免雪崩效应
  5. 分布式系统中的协调:在分布式系统中,信号量可以用于不同服务或节点之间的协调,保证全局资源的合理使用

知道了信号量的用途后,我们来了解一下 go-resiliency 是如何实现信号量的

仓库给的例子:

sem := semaphore.New(3, 1*time.Second)

if err := sem.Acquire(); err != nil {
	// could not acquire semaphore
	// 无法获取信号量
	return err
}
defer sem.Release()

创建信号量时,传入了两个参数,一个是票证的数量,一个是等待的时间

Semaphore 的结构如下:

// Semaphore implements the semaphore resiliency pattern
// 信号量实现信号量弹性模式
type Semaphore struct {
	sem     chan struct{} // 票证
	timeout time.Duration // 等待时间
}

获取信号量

// Acquire tries to acquire a ticket from the semaphore. If it can, it returns nil.
// If it cannot after "timeout" amount of time, it returns ErrNoTickets. It is
// safe to call Acquire concurrently on a single Semaphore.
// Acquire 尝试从信号量获取票证。
// 如果可以,则返回 nil。
// 如果在“超时”时间后无法返回,则返回 ErrNoTickets。在单个信号量上同时调用 Acquire 是安全的。
func (s *Semaphore) Acquire() error {
	select {
	case s.sem <- struct{}{}:
		return nil
	case <-time.After(s.timeout):
		return ErrNoTickets
	}
}

// ErrNoTickets is the error returned by Acquire when it could not acquire
// a ticket from the semaphore within the configured timeout.
// ErrNoTickets 是 Acquire 在配置的超时时间内无法从信号量获取票证时返回的错误。
var ErrNoTickets = errors.New("could not acquire semaphore ticket")

释放信号量

// Release releases an acquired ticket back to the semaphore. It is safe to call
// Release concurrently on a single Semaphore. It is an error to call Release on
// a Semaphore from which you have not first acquired a ticket.
// Release 将获取的票证释放回信号量。
// 在单个信号量上同时调用 Release 是安全的。
// 在未首先获得票证的信号量上调用 Release 是错误的。
func (s *Semaphore) Release() {
	<-s.sem
}

这个实现比较简单,创建信号量的时候,根据我们传入的票证数量,创建 sem 信号量通道,如果传入的票证数量不为 0,那么这个 sem 是带缓冲区的通道,获取信号量的时候,往 sem 写数据,如果缓冲区数据没有满,写成功后,返回 nil,成功获取信号量;如果 sem 已经满了,等到 select 到超时的那个分支时,返回 ErrNoTickets 的错误

释放信号量的时候,从 sem 通道读数据,释放缓冲区的数据,让后面尝试去获取信号量的,能够正常往 sem 写数据,从而成功获取信号量、

注意点1

这里要注意的点是,我们看代码信号量的代码还有另外一个函数

// IsEmpty will return true if no tickets are being held at that instant.
// It is safe to call concurrently with Acquire and Release, though do note
// that the result may then be unpredictable.
// 如果当时没有持有票证,
// IsEmpty 将返回 true。
// 与 Acquire 和 Release 同时调用是安全的,
// 但请注意,结果可能是不可预测的
func (s *Semaphore) IsEmpty() bool {
	return len(s.sem) == 0
}

这里注意的点是,如果对 channel 不熟悉,可以会误以为这里判断的 sem 通道的长度,比如一下代码,我们创建票证数量的数为3

func TestSemaphoreEmpty2(t *testing.T) {
	sem := New(3, 200*time.Millisecond)
	t.Log(sem.IsEmpty())
	if !sem.IsEmpty() {
		t.Error("semaphore should be empty")
	}
}

output:

=== RUN   TestSemaphoreEmpty2
    semaphore_test.go:71: true
--- PASS: TestSemaphoreEmpty2 (0.00s)
PASS

Process finished with the exit code 0

可以看到 IsEmpty 函数返回的是 true,即 len(s.sem) == 0 条件成立,是不是觉得很奇怪,其实当我们去判断 channel 的长度时,判断的是它的缓冲区的还未被读取的数据 (学到了吧 😃)

注意点2

在没获取到信号量的时候,进行释放信号量操作,会导致死锁

func TestSemaphoreEmpty(t *testing.T) {
	sem := New(2, 200*time.Millisecond)

	if !sem.IsEmpty() {
		t.Error("semaphore should be empty")
	}

	sem.Release()
}

output:

=== RUN   TestSemaphoreEmpty
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
testing.(*T).Run(0xc000051380, {0x91f60a?, 0x84d513?}, 0x929520)
	C:/Program Files/Go/src/testing/testing.go:1630 +0x405

testing.runTests.func1(0xa13280?)
	C:/Program Files/Go/src/testing/testing.go:2036 +0x45
testing.tRunner(0xc000051380, 0xc000117c88)
	C:/Program Files/Go/src/testing/testing.go:1576 +0x10b

testing.runTests(0xc0000781e0?, {0xa0e1c0, 0x3, 0x3}, {0xc0001212a0?, 0x100c000117d10?, 0x0?})
	C:/Program Files/Go/src/testing/testing.go:2034 +0x489

testing.(*M).Run(0xc0000781e0)
	C:/Program Files/Go/src/testing/testing.go:1906 +0x63a

main.main()
	_testmain.go:51 +0x1aa

goroutine 6 [chan receive]:
github.com/eapache/go-resiliency/semaphore.(*Semaphore).Release(...)
	C:/code/go_code/good/go-resiliency/semaphore/semaphore.go:44
github.com/eapache/go-resiliency/semaphore.TestSemaphoreEmpty(0xc000051520)
	C:/code/go_code/good/go-resiliency/semaphore/semaphore_test.go:56 +0x97
testing.tRunner(0xc000051520, 0x929520)
	C:/Program Files/Go/src/testing/testing.go:1576 +0x10b

created by testing.(*T).Run
	C:/Program Files/Go/src/testing/testing.go:1629 +0x3ea

Process finished with the exit code 1

Release 操作,是从 sem 通道读取数据,如果这时没有数据,就阻塞在这里的

注意点3,创建信号量的时候,不要误传了票证的数量为 0

func TestSemaphoreEmpty(t *testing.T) {
	sem := New(0, 200*time.Millisecond)

	if !sem.IsEmpty() {
		t.Error("semaphore should be empty")
	}

	sem.Acquire()

	sem.Release()
	
}

output:

=== RUN   TestSemaphoreEmpty
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
testing.(*T).Run(0xc000051380, {0x87f60a?, 0x7ad513?}, 0x889520)
	C:/Program Files/Go/src/testing/testing.go:1630 +0x405

testing.runTests.func1(0x973280?)
	C:/Program Files/Go/src/testing/testing.go:2036 +0x45
testing.tRunner(0xc000051380, 0xc000117c88)
	C:/Program Files/Go/src/testing/testing.go:1576 +0x10b
testing.runTests(0xc000078140?, {0x96e1c0, 0x3, 0x3}, {0xc000121378?, 0x100c000117d10?, 0x0?})
	C:/Program Files/Go/src/testing/testing.go:2034 +0x489

testing.(*M).Run(0xc000078140)
	C:/Program Files/Go/src/testing/testing.go:1906 +0x63a

main.main()
	_testmain.go:51 +0x1aa

goroutine 6 [chan receive]:
github.com/eapache/go-resiliency/semaphore.(*Semaphore).Release(...)
	C:/code/go_code/good/go-resiliency/semaphore/semaphore.go:44
github.com/eapache/go-resiliency/semaphore.TestSemaphoreEmpty(0xc000051520)
	C:/code/go_code/good/go-resiliency/semaphore/semaphore_test.go:58 +0x9f
testing.tRunner(0xc000051520, 0x889520)
	C:/Program Files/Go/src/testing/testing.go:1576 +0x10b

created by testing.(*T).Run
	C:/Program Files/Go/src/testing/testing.go:1629 +0x3ea

Process finished with the exit code 1

这个原因跟注意点 2 一样,sem.Acquire() 获取信号量这一步是没有问题的,但是因为票证数量为 0,那么我们创建的信号量的 sem 字段是不带缓冲区的通道类型,这时候再去释放信号量的时候,就会阻塞在那里了

!!! select 的 case 是随机的,而 switch 里的 case 是顺序执行

retrier

golang 的可重构弹性模式。

创建重试器需要两个参数:

重试间隔的时间(隐含重试次数) 决定重试哪些错误的分类器

仓库给的例子:

r := retrier.New(retrier.ConstantBackoff(3, 100*time.Millisecond), nil)

err := r.Run(func() error {
	// do some work
	return nil
})

if err != nil {
	// handle the case where the work failed three times
}

创建重试器时,传入了两个参数,一个是重试时间的间隔(它是一个 time.Duration 类型的数组,数组的长度就是它隐含的重试次数),另一个是分类器,可以决定哪些错误需要重试,哪些错误不需要重试。

重试器的结构体

// Retrier implements the "retriable" resiliency pattern, abstracting out the process of retrying a failed action
// a certain number of times with an optional back-off between each retry.
// Retrier 实现了 "可重试 "弹性模式,将重试失败操作的过程抽象为
// 重试一定次数,每次重试之间可选择后退。
type Retrier struct {
 // 重试时间的间隔
	backoff []time.Duration
	// 分类器
	class   Classifier
	// 基数
	jitter  float64
	// 随机数种子
	rand    *rand.Rand
	// 计算休眠时间的锁
	randMu  sync.Mutex
}

新建一个重试器的函数

// New constructs a Retrier with the given backoff pattern and classifier. The length of the backoff pattern
// indicates how many times an action will be retried, and the value at each index indicates the amount of time
// waited before each subsequent retry. The classifier is used to determine which errors should be retried and
// which should cause the retrier to fail fast. The DefaultClassifier is used if nil is passed.
// New 使用给定的后退模式和分类器构建一个 Retrier。后退模式的长度
// 每个索引的值表示每次重试前等待的时间。
// 每次重试前等待的时间。分类器用于确定哪些错误应重试,哪些错误应导致重试。
// 哪些错误会导致重试快速失败。如果传入的是 nil,则使用 DefaultClassifier。
func New(backoff []time.Duration, class Classifier) *Retrier {
  // 如果分类器为 nil,则使用默认的分类器
	if class == nil {
		class = DefaultClassifier{}
	}

	return &Retrier{
		backoff: backoff,
		class:   class,
		rand:    rand.New(rand.NewSource(time.Now().UnixNano())),
	}
}

如果传入的分类器为 nil,则使用默认的分类器

有三种不同的分类器

  • 默认分类器
  • 白名单分类器
  • 黑名单分类器

默认分类器以最简单的方式对错误进行分类。如果错误为 nil,则返回 Succeed,否则返回 Retry

WhitelistClassifier 根据白名单对错误进行分类。如果错误为 nil,则返回 Succeed;如果错误在白名单中,则返回 Retry;否则,它将返回 Fail。

BlacklistClassifier 根据黑名单对错误进行分类。如果错误为 nil,则返回 Succeed;如果错误在黑名单中,则返回 Fail;否则,它将返回 Retry。

重试器的执行有两个函数

一个是执行时,不用传入上下文字段的,实际执行还是调用了需要传入上下文字段的 RunCtx函数,只是传了个非 nil 的空 Context

// Run executes the given work function by executing RunCtx without context.Context.
func (r *Retrier) Run(work func() error) error {
	return r.RunCtx(context.Background(), func(ctx context.Context) error {
		// never use ctx
		return work()
	})
}

一个是执行时,需要传入上下文字段的

// RunCtx executes the given work function, then classifies its return value based on the classifier used
// to construct the Retrier. If the result is Succeed or Fail, the return value of the work function is
// returned to the caller. If the result is Retry, then Run sleeps according to the its backoff policy
// before retrying. If the total number of retries is exceeded then the return value of the work function
// is returned to the caller regardless.
// 分类器对其返回值进行分类。
// 构造 Retrier 所使用的分类器对其返回值进行分类。如果结果是 "成功 "或 "失败",工作函数的返回值将
// 返回给调用者。如果结果是重试,运行将根据其后退策略休眠,然后再重试。
// 在重试之前休眠。如果超过了重试的总次数,则工作函数的返回值
// 返回给调用者。
func (r *Retrier) RunCtx(ctx context.Context, work func(ctx context.Context) error) error {
	// 刚开始重试次数为 0
	retries := 0
	for {
	  // 执行工作函数(即我们想要进行处理的逻辑)
		ret := work(ctx)
	  // 分类器根据返回值,判断是否需要重试
		switch r.class.Classify(ret) {
		case Succeed, Fail:
			return ret
		case Retry:
		  // 如果重试次数大于等于隐含的重试次数,返回工作函数的返回值
			if retries >= len(r.backoff) {
				return ret
			}
			// 如果重试次数小于隐含的重试次数,根据当前已重试的次数,计算休眠的时间
			timeout := time.After(r.calcSleep(retries))
			// 执行休眠函数
			if err := r.sleep(ctx, timeout); err != nil {
				return err
			}

			retries++
		}
	}
}

计算休眠时间的函数

这里不理解的是为什么要加锁,看了测试用例,有可能会并发执行 Run 函数,但实际有场景会用得上吗?

这里还有一个基数的作为休息时间的随机性种子,可以通过 SetJitter 函数设置,jitter 的范围在 [0,1],否则设置无效,设置了基数后,回退时间在一定的范围内,比如你设置了基数为 0.25, backoff[i] 为 10 * time.Millisecond,那么这时的回退时间在 (7500 * time.Microsecond,12500*time.Microsecond)的范围内

func (r *Retrier) calcSleep(i int) time.Duration {
	// lock unsafe rand prng
	r.randMu.Lock()
	defer r.randMu.Unlock()
	// take a random float in the range (-r.jitter, +r.jitter) and multiply it by the base amount
	return r.backoff[i] + time.Duration(((r.rand.Float64()*2)-1)*r.jitter*float64(r.backoff[i]))
}

休眠函数

从代码可以看出,如果到达时间范围了会返回 nil,然后 RunCtx 函数增加重试次数,继续重试,如果传入的上下文有带超时时长,这时候超时时间到了,返回错误,RunCtx 直接退出,这点也就是使用 Run 和 RunCtx 函数的唯一区别

func (r *Retrier) sleep(ctx context.Context, t <-chan time.Time) error {
	select {
	case <-t:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

创建重试器,传递超时时长时,有三个辅助函数

  • ConstantBackoff
    • ConstantBackoff 生成一个简单的回退策略,即重试“n”次,并在每次重试后等待相同的时间。
  • ExponentialBackoff
    • ExponentialBackoff 生成一个简单的回退策略,即重试 'n' 次,并将每次重试后等待的时间加倍。
  • LimitedExponentialBackoff
    • LimitedExponentialBackoff 生成一个简单的回退策略,即重试 'n' 次,并将每次重试后等待的时间加倍。如果回退达到 'limitAmount' ,则此后回退将填充 'limitAmount' 。