小记Go里常用的并发控制手段(一)

发布时间:2022-09-28 09:58
最后更新:2022-09-28 09:58
所属分类:
Go

并发编程是Go语言无可置疑的强项之一,为了解决并发编程中常见的一些控制需求,Go也同样在标准库中提供非常常用的控制手段。而且这些控制手段在日常进行Go语言编程的时候也非常的常见。

常用的控制功能主要是context包中提供的各种Contextsync包中提供的各种同步原语。

面向上下文的编程

Context直译过来就是上下文,上下文的使用在Go中也是一种编程模式。在并发编程中,处理超时、取消等操作是比较常见的,一般的处理方式是在这些异常情况出现的时候进行抢占操作或者中断操作。俩要解决这个问题,我们首先想到的可能是使用channel,例如以下这个示例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func main() {
  messageChannel := make(chan int, 10)
  done := make(chan bool)

  defer close(messageChannel)

  ticker := time.NewTicker(1 * time.Second)
  for range ticker.C {
    select {
      case <-done:
        fmt.Println("interrupt...")
        return
      default:
        fmt.Printf("message: %d\n", <-messageChannel)
    }
  }

  // 这里可以向messageChannel中推送一些内容
  close(done)
}

这段代码其实非常容易理解,往名称为done的这个channel发送任意的一个数据(包括关闭这个channel),就会使协程优雅的退出。在这种模式下,每控制一个协程就需要定义一个buffer为0的channel,如果协程变多了以后,那么需要控制的channel也就变的复杂不容易控制了。而且如果各个协程还需要一些超时控制、取消控制,那么可能在程序所花费的大部分精力就都集中在控制上了。

在实际的项目中,常常有这样一种情况,一个协程(例如名称为R1)存在有若干的子任务(例如名称为T1、T2、T3等等),协程R1对其下的子任务是带有超时控制的,而子任务T1、T2等对其下的第二级子任务ST1、ST2等也同样具有超时控制。那么在这种情况下,ST1、ST2这些第二级子任务除了需要感知协程R1的取消信号,也同样需要感知其归属子任务T1、T2这些第一级子任务的取消信号。如果还是采用之前基于channel的控制方案,那么就会出现channel的嵌套,整个控制逻辑就会变得非常复杂,很容易混乱,如果子任务的层级再多一些,程序的代码基本上就会进入到无法维护的境地了。

对于这种情况,最优雅的解决方案是下面这样的:

  1. 上级任务被取消以后,其下的所有下级任务都会被取消。
  2. 中间某一个层级的任务被取消以后,不会影响其上级的和其同级的任务。

Go为了实现这种优雅的控制方法,就引入了Context。标准库中的Context是一个接口,其中包含了四个方法,以下是Context接口的源码,可以看看其中各个方法的用途。

1
2
3
4
5
6
type Context interface {
  Deadline() (deadline time.Time, ok bool)
  Done() <-chan struct{}
  Err() error
  Value(key interface{}) inteface{}
}

从直觉上看起来这个Context的结构有一种很熟悉的感觉,好像看起来也是一个类似于上面基于channel的控制结构。其实原理是差不多的。

  • Deadline()会返回当前context被取消的截止时间,如果context中没有设置之歌截止时限,那么返回的ok将是false
  • Done()会返回一个用于表示当前context是否已经被取消的状态。如果context被取消,那么Done()将返回一个关闭的channel;如果context不会被取消,那么将会返回nil
  • Err()方法会配合Done()返回相应的内容来表示context的状态。如果Done()返回的channel没有被关闭,那么Err()将会返回nil;如果channel已经关闭了,那么Err()将会返回非空的值表示任务结束的原因;如果context被取消,那么Err()将会返回Canceled;如果context超时,那么Err()将会返回DeadlineExceeded
  • Value()用于返回在context存储的键值对中的指定键对应的值,如果找不到指定的键,那么就会返回nil

从这四个方法可以得出,在并发任务中Context要怎样来使用。

  1. Done()方法返回的channel是用来传递结束信号来抢占并中断当前的任务,相当于之前控制方法中名为donechannel
  2. Deadline()用来指示一段时间以后,context是否会被取消。
  3. Err()用来解释context的取消原因。
  4. Context会形成一棵Context树,其中每一个节点都会携带一些键值对,如果Value()方法在当前的节点中找不到指定的键,那么就会到上一级节点中继续查找,直到根节点为止。

Context来重新实现上面的示例,就可以让控制过程变得比较清晰了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func main() {
  messageChannel := make(chan int, 10)

  // 这里可以向messageChannel中推送一些内容

  // 设定5秒钟以后结束context
  ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
  defer cancel()

  go func(ctx context.Context) {
    ticker := timer.NewTicker(1 * time.Second)
    for range ticker.C {
      select {
        case <-ctx.Done():
          fmt.Println("interrupt...")
          return
        default:
          fmt.Printf("message: %d\n", <-messageChannel)
      }
    }
  }(ctx)

  defer close(messageChannel)

  select {
    case <-ctx.Done():
      fmt.Println("main process exit...")
  }
}

在这个示例中虽然只启动了一个协程,但是可以看出来,这个协程的控制是通过传入的context控制的。如果同时启动了多个协程,那么只要使用了相同的Context实例,那么这些协程就可以统一的被这一个Context实例所控制。如此以来,就可以简化协程的控制了。而且前面也提到,Context是可以形成一棵树的,当其中的一个节点变成了取消状态,那么这个取消状态很容易就可以被传递到其子代的节点上,这样也就实现了对子代协程的控制。

常用的Context类型

从前面的Context源码可以看出来,Context只是标准库中规定的一个接口,并不是实际使用中的Context实现。其实在Go的标准库中已经提供了一些常用的Context实现,但是这些实现的共同特点就是不能被显式实例化,只能使用一系列context包中提供的方法来获取。

基础Context类型

标准库中所提供的基础Context类型主要有四个,分别是emptyCtxvalueCtxcancelCtxtimerCtx。它们分别被用作执行不同控制功能的Context使用。

emptyCtx

emptyCtx是所有的Context类型的基础,它的定义非常简单:

1
type emptyCtx int

所以emptyCtx就是一个int类型的别名,但是实现了Context接口所需要的全部方法。emptyCtx没有超时时间,不能被取消,也不能存储任何键值对信息,它的目的只有一个:作为Context树的根节点。

不要尝试直接创建emptyCtx的实例,在Go中,这种首字母为小写的内容都是私有的,标准库就是利用这个规则来阻止你直接实例化所有的Context实例。

valueCtx

valueCtx就比前面的emptyCtx要复杂多了。在它里面可以保存一对键值对信息,它的定义为:

1
2
3
4
type valueCtx struct {
  Context
  key, val interface{}
}

emptyCtx不一样,valueCtx就已经是一个结构体了。valueCtx中利用Context类型的字段来记录其父节点,所以valueCtx也就继承了其父级Context的所有信息。keyval两个字段表示一个键和值都是任意类型的键值对。

看到这里可能会有疑问,valueCtx中只能保存一个键值对信息,那么如果要在Context中保存多个键值对要怎么办。其实在Context的标准库实现中,多个键值对信息是靠一个Context链来保存的。这里只需要看一下valueCtx实现的Value()方法就明白了。

1
2
3
4
5
6
func (c *valueCtx) Value(key interface{}) interface{} {
  if c.key == key {
    return c.val
  }
  return c.Context.Value(key)
}

valueCtxValue()方法的最后,实例返回了其父级ContextValue()方法的值,所以在当前的Context实例中如果找不到指定key的值,Context实例会自动的沿着Context链向上寻找。

如果一直到根节点都不能找到指定key,那么就会返回根节点实现的Value()方法返回的值,也就是nil

cancelCtx

cancelCtx就更加复杂了一些,其中主要是增加了用于表示和控制任务取消的功能。它的定义为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type cancelCtx struct {
  Context

  mu sync.Mutex
  done chan struct{}
  children map[canceler]struct{}
  err error
}

type canceler struct {
  cancel(removeFromParent bool, err error)
  Done() <-chan struct{}
}

cancelCtx中,依旧与valueCtx一样,使用Context字段来保存父级Context,但是多了一个通道类型的done字段来传递取消信号。chidren字段是cancelCtx用来记录其子Context的,当调用cancelCtx中的cancel()方法的时候,实际上会迭代的调用children字段中所有子Contextcancel()方法。

timerCtx

timerCtx是基于cancelCtx构建的类型,相比cancelCtxtimerCtx只是增加了可以定时执行取消的功能。所以它的定义为:

1
2
3
4
5
6
type timerCtx struct {
  cancelCtx
  timer *time.Timer

  deadline time.Time
}

timerCtx内部,Context的取消还是通过内部的cancelCtx来完成,但是取消的激活是靠timerdeadline字段来完成的。

Background()TODO()

标准库中提供的Background()TODO()两个方法是用来获取根Context节点的,因为emptyCtx不能被手动实例化,所以在使用的时候就需要标准库中提供的这两个方法。从代码上看,Background()TODO()返回的内容是一样的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var(
  background = new(emptyCtx)

  todo = new(emptyCtx)
)

func Background() Context {
  return background
}

func TODO() Context {
  return todo
}

但是根据标准库中的解释,这两个Context实例的功用还是不一样的。Background()通常都是被用在主函数中的,所以Background()是我们程序中常用的根Context节点;而TODO()则是在不知道需要使用什么Context实例的时候才用得到。

WithValue()

WithValue()方法是用来根据指定的父级Context创建一个携带着键值对内容的Context节点的,也是向Context中记录内容的唯一方法。

WithCancel()

WithCancel()方法是用来根据给定的父级Context创建一个可以被取消的Context节点,其在调用以后会返回两个值:一个是新创建的Context节点,一个是用于触发取消的CancelFunc。要取消这个Context只需要调用创建的时候返回的CancelFunc即可。

WithTimeout()WithDeadline()

WithTimeout()WithDealine()都是会返回一个timerCtx的实例,但是同时也都会返回一个用于手动触发取消动作的CancelFunc。这两个方法所返回的timerCtx不一样的地方在于所设置的取消时间的设定,WithDeadline()设置的是一个过期的时间点;WithTimeout()设置的是一个相对于当前时间的过期时长。

Context树的形成

在标准库中每一个Context类型都会保存其父级Context实例,而所有用来获取Context的方法也都必须要提供一个父级Context,这样就会形成一个树。就像下面这张图所描述的那样。

Context树的形成
Context树的形成

每调用一次获取Context实例的方法,就会创建一个新的节点,然而对于可以激活取消的Context节点,一旦触发其取消方法,那么就会将其连同其下的所有子代节点都取消掉。这就是Context树真正的使用目的。

一些额外的用法
根据Context中提供的保存键值对的功能,可以利用其在整个程序中共享一些全局内容,比如数据库连接、公共配置等。所以需要尽可能的在程序的各个角落都使用Context

索引标签
Go
Golang
并发控制
Context
Sync