Go 语言编程 — 并发 — Context 上下文

目录

文章目录

  • 目录
  • Goroutine 的控制方式
    • WaitGroup
    • Channel + Select 通知
    • Context
  • Context
    • Context 的接口
    • Context 的继承衍生
    • 使用 WithCancel 取消 Context
    • 使用 WithDeadline 取消 Context
    • 使用 WithTimeout 取消 Context
    • 使用 WithValue 传递元数据
  • Context 使用原则

Goroutine 的控制方式

通常的,Goroutine 协程在启动之后就会 “自顾自” 的完成其任务,大部分情况是等待它自己结束。但实际上,我们常需要对 Goroutine 进行控制,继而完成复杂的并发需求。控制并发,有两种经典的方式,一种是 WaitGroup,另外一种就是 Context。

WaitGroup

WaitGroup 是一种控制并发的方式,这种方式是控制多个 Goroutine 同时完成。

例子:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        time.Sleep(2 * time.Second)
        fmt.Println("1号完成")
        wg.Done()
    }()

    go func() {
        time.Sleep(2 * time.Second)
        fmt.Println("2号完成")
        wg.Done()
    }()

    wg.Wait()
    fmt.Println("好了,大家都干完了,放工")
}

结果:

1号完成
2号完成
好了,大家都干完了,放工

上述例子中,WaitGroup 有 2 个 Goroutine,当所有的 Goroutine 都完成了,Group 才算完成。

这是一种控制并发的方式,尤其适用于多个 Goroutine 协同完成一件事情的场景,因为每个 Goroutine 完成的都是这件事情的一部分,只有全部的 Goroutine 都完成时,这件事情才算是完成,这是 Wait(等待)的方式。

Channel + Select 通知

我们还可能遇见这样的场景:需要主动通知某个 Goroutine 进行退出。比如:后台运行着一个监控 Goroutine,当不再需要时,就要通知其结束并退出,否则它会一直运行。

对于这种情况,最简单的思路就是设置一个全局变量,作为 Goroutine 的终止符,在 Goroutine 外通过修改这个变量来实现通知。这就是一种典型的 “线程与锁并发模型”,需要程序员关注锁的控制,显然不是 Golang 推荐的方式。

Golang 倡导使用 CSP(Communicating Sequential Processes,通信顺序进程)并发模型,只需要关注消息在通道(Channel)中的的传输。例子:

package main

import (
    "fmt"
    "time"
)

func main() {
    stop := make(chan bool)

    go func() {
        for {
            select {
            case <-stop:
                fmt.Println("监控退出,停止了。")
                return
            default:
                fmt.Println("Goroutine 监控中...")
                time.Sleep(2 * time.Second)
            }
        }
    }()

    time.Sleep(10 * time.Second)
    fmt.Println("可以了,通知监控停止。")
    stop <- true

    // 为了检测监控过是否停止,如果没有监控输出,就表示停止了。
    time.Sleep(5 * time.Second)
}

结果:

$ go run main.go
Goroutine 监控中...
Goroutine 监控中...
Goroutine 监控中...
Goroutine 监控中...
Goroutine 监控中...
可以了,通知监控停止。
监控退出,停止了。

上例中,我们定义了一个 stop channel 用于与 Goroutine 进行 “通知”,继而实现对Goroutine 程序流的控制。

Channel + Select 的并发控制方式,是一种优雅的实现,不过也有其局限性:无法对多个具有层级嵌套关系的 Goroutine 进行优雅的控制。这意味着我们需要定义多个 Channel 并维护其层级关系。因为 Goroutine 的关系链就导致了这种场景非常复杂。

Context

针对 Goroutine 关系链复杂的场景,例如:一个 HTTP Request 会开启一个 Goroutine,然后这个 Goroutine 又会在其生命周期内开启若干个用于处理 Cache 和 Database 的 Goroutine。

所以我们需要一种可以跟踪 Goroutine 关系链的方案,才可以达到控制他们的目的,这就是 Golang 的 Context,称之为上下文。常用于记录 Goroutine 的上下文。

使用 Context 重写上述例子:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("监控退出,停止了。")
                return
            default:
                fmt.Println("Goroutine 监控中...")
                time.Sleep(2 * time.Second)
            }
        }
    }(ctx)

    time.Sleep(10 * time.Second)
    fmt.Println("可以了,通知监控停止。")
    // 撤销执行所有与该 Context 关联的 Goroutine。
    cancel()

    // 为了检测监控过是否停止,如果没有监控输出,就表示停止了。
    time.Sleep(5 * time.Second)
}

重写的内容很简单,使用 Context 实例来跟踪 Goroutine,包括传递通知,以便进行控制。

  • context.Background() 返回一个空的 Context,一般用于整个 Context Tree(上下文树)的根节点。
  • context.WithCancel(parent) 返回一个可取消的 Sub Context,并作为实参传入 Goroutine 使用,这样就可以使用这个 Sub Context 跟踪这个 Goroutine 了。
  • cancel() 调用 Sub Context 的取消函数,向关联的 Goroutine 发送一个 “取消” 通知。

Context

Golang 在 1.7 版本将 context 加入标准库。目的在于简化处理 “完成单个请求的多个 Goroutine 之间的上下文”,这些上下文包括:设置截止日期、同步信号,传递请求的相关值。

Context 是 Golang 的独特设计,专用于并发场景,所以与 Goroutine 有密切的关系。例如:在 Go HTTP 包的 Server 中,每一个请求在都有一个对应的 Goroutine 去处理。请求处理函数通常会启动额外的 Goroutine 用来访问后端服务,比如:数据库和 RPC 服务。用来处理一个请求的 Goroutine 通常需要访问一些与请求特定的数据,比如:终端用户的身份认证信息、验证相关的 Token、请求的截止时间等。当一个请求被取消或超时时,所有用来处理该请求的 Goroutine 都应该迅速退出,一次来释放这些被 Goroutine 占用的资源。

简而言之,在 Goroutine 构成的树形结构中对信号进行同步以减少计算资源的浪费是 Context 的最大作用。我们可能会创建多个 Goroutine 来处理一次请求,而 Context 的作用就是在不同 Goroutine 之间同步请求特定数据、取消信号以及处理请求的截止日期

下述看一个使用 Context 来控制多个 Goroutine 的例子:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go watch(ctx, "【监控1】")
    go watch(ctx, "【监控2】")
    go watch(ctx, "【监控3】")

    time.Sleep(10 * time.Second)
    fmt.Println("可以了,通知监控停止。")
    cancel()

    // 为了检测监控过是否停止,如果没有监控输出,就表示停止了。
    time.Sleep(5 * time.Second)
}

func watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println(name, "监控退出,停止了。")
            return
        default:
            fmt.Println(name, "Goroutine 监控中...")
            time.Sleep(2 * time.Second)
        }
    }
}

结果:

$ go run main.go
【监控2】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控3】 Goroutine 监控中...
【监控2】 Goroutine 监控中...
【监控3】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控2】 Goroutine 监控中...
【监控3】 Goroutine 监控中...
【监控3】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控2】 Goroutine 监控中...
【监控2】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控3】 Goroutine 监控中...
可以了,通知监控停止。
【监控3】 监控退出,停止了。
【监控1】 监控退出,停止了。
【监控2】 监控退出,停止了。

上述例子,我们启动了 3 个 Goroutine 监控协程。每一个都使用了 Sub Context 进行跟踪,当调用 cancel() 通知所有 Goroutine 取消时,这 3 个 Goroutine 都会被结束。这就是 Context 的控制能力,它就像一个控制器一样,按下开关后,所有基于这个 Context 或者衍生的 Sub Context 都会收到通知,并考虑释放 Goroutine 资源。这样就优雅的解决了对多个 Goroutine 并发进行控制的问题。

另外,对于多层嵌套的 Goroutine,每一个 Context 都会从最顶层的 Goroutine 一层一层传递到最下层。Context 可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层。

Go 语言编程 — 并发 — Context 上下文_第1张图片
如上图所示,当最上层的 Goroutine 因为某些原因执行失败时,下层的 Goroutine 由于没有接收到这个信号所以会继续工作;但是当我们正确地使用 Context 时,就可以在下层及时停掉无用的工作以减少额外资源的消耗。

Go 语言编程 — 并发 — Context 上下文_第2张图片

Context 的接口

Context 的接口为:

// 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() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

定义了 4 个可实现的 Methods:

  1. Deadline:用于获取设置的截止时间。有两个返回值:第一个返回截止时间,到了截止时间,Context 会自动发出 cancel 通知;第二个返回 ok==false 时表示没有设置截止时间,需要显式的编写 cannel() 语句。

  2. Done:用于获取一个只读的 Channel,类型为 struct{}。这个 Channel 会在当前工作完成后或者 Context 被 cannel 后关闭,多次调用 Done 方法会返回同一个 Channel。也就是说,如果在 Goroutine 中调用该方法获取的 Channel 可以读取,就意味着 Parent Context 已经发送了 cannel 通知,Goroutine 在收到这个 cannel 通知后,应该进行清理操作,释放资源,然后退出。之后,Err 方法会返回一个错误,告知为什么 Context 被取消。

  3. Err:返回一个 Context 被 cannel 的错误原因,只有在 Done 返回的 Channel 被关闭时才会返回非空的值:如果 Context 被取消,则返回 Canceled 错误;如果 Context 超时,则返回 DeadlineExceeded 错误。

  4. Value:获取该 Context 上绑定的值,是一个 key/value 键值对,这个值一般是线程安全的,常用于传递请求特定的数据。

其中,最常用的就是 Done 方法了。如果 Context 被取消,就可以得到一个关闭的 Channel,关闭的 Channel 是可以读取的,所以只要可以读取的时候,就意味着收到了 Context 取消的通知:

func Stream(ctx context.Context, out chan<- Value) error {
	for {
		v, err := DoSomething(ctx)
  		if err != nil {
  			return err
  		}
  		select {
  		case <-ctx.Done():
  			return ctx.Err()
  		case out <-v:
  		}
  	}
}

Context 的继承衍生

Golang 内置了两个 Context 接口实现,context.Background 和 context.TODO,作为最顶层的 Parent Context,即 Root Context,这两个方法都会返回预先初始化好的私有变量 background 和 todo,它们会在同一个 Golang 程序中被复用,并以此来衍生出更多的 Sub Context。

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

context.Background 和 context.TODO 函数其实也只是互为别名,没有太大的差别。它们只是在使用和语义上稍有不同:

  • Background:主要用于 main() 函数、初始化以及测试代码中,作为 Context Tree 的 Root Context。
  • TODO:如果我们不知道该使用什么类型 Context 的时候,可以使用这个。

两者本质上都是 emptyCtx 结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的 Context:

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (*emptyCtx) Done() <-chan struct{} {
	return nil
}

func (*emptyCtx) Err() error {
	return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
	return nil
}

可以看到,emptyCtx 实现了 Context 接口,但什么都没做,返回 nil 或者零值。在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background 作为起始的上下文向下传递。

基于这个 emptyCtx 的 Root Context 实例,才可以通过以下的 With* 函数衍生出更多的 Sub Context:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

Go 语言编程 — 并发 — Context 上下文_第3张图片

这四个 With* 函数,都会接收一个 Parent Context,表示继承关系,继而创建出一颗 Context Tree,树的每个节点又可以继承任意个子节点,节点层级可以有任意多个。

Context 的调用应该是链式的,通过 WithCancel、WithDeadline、WithTimeout 或 WithValue 派生出新的 Sub Context。当 Parent Context 被取消时,其派生的所有 Sub Context 都将取消。

  • WithCancel:传入一个 Parent Context,返回一个 Sub Context,以及一个 Cancel 函数用来取消 Context。
  • WithDeadline:传入一个 Parent Context、一个截止时间,返回一个 Cancel 函数。意味着到了截止时间,会自动取消 Context,也可以提前调用 Cancel 函数进行取消。
  • WithTimeout:和 WithDeadline 基本上一样,区别在于表达的是 Timeout 的概念,即超出多少时间后自动取消 Context。
  • WithValue:和 Cancel Context 无关,这是为了生成一个具有 Key/Value 对的 Context,这些绑定的数据可以通过 Context.Value 方法访问。

其中,Cannel 函数是一个 CancelFunc 类型,它的定义非常简单:

type CancelFunc func()

该函数可以取消一个 Context,以及这个节点 Context 下属所有的 Sub Context。如果当前 Context 不是 Root Context,那么调用 CancelFunc 时候还会移除父代对子代的引用,并且停止所有定时器。go vet 工具可以检查所有流程控制路径上使用的 CancelFuncs。

使用 WithCancel 取消 Context

WithCancel 的本质是以一个新的 Done channel 返回一个 Parent Context 的拷贝。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{
        Context: parent,
        done:    make(chan struct{}),
    }
}

下述示例使用一个可取消的上下文,以防止 Goroutine 泄漏。示例函数结束时,defer 调用 cancel 方法,gen goroutine 将返回而不泄漏。

package main

import (
    "context"
    "fmt"
)

func main() {
    // gen generates integers in a separate goroutine and
    // sends them to the returned channel.
    // The callers of gen need to cancel the context once
    // they are done consuming generated integers not to leak
    // the internal goroutine started by gen.
    gen := func(ctx context.Context) <-chan int {
        dst := make(chan int)
        n := 1
        go func() {
            for {
                select {
                case <-ctx.Done():
                    return // returning not to leak the goroutine
                case dst <- n:
                    n++
                }
            }
        }()
        return dst
    }

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // cancel when we are finished consuming integers

    for n := range gen(ctx) {
        fmt.Println(n)
        if n == 5 {
            break
        }
    }
}

结果:

$ go run main.go
1
2
3
4
5

使用 WithDeadline 取消 Context

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    d := time.Now().Add(50 * time.Millisecond)
    ctx, cancel := context.WithDeadline(context.Background(), d)

    // Even though ctx will be expired, it is good practice to call its
    // cancelation function in any case. Failure to do so may keep the
    // context and its parent alive longer than necessary.
    defer cancel()

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err())
    }
}

使用 WithTimeout 取消 Context

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // Pass a context with a timeout to tell a blocking function that it
    // should abandon its work after the timeout elapses.
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err()) // prints "context deadline exceeded"
    }
}

使用 WithValue 传递元数据

package main

import (
	"context"
	"fmt"
	"time"
)

var key string = "name"

func watch(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(ctx.Value(key), "监控退出,停止了。")
			return
		default:
			fmt.Println(ctx.Value(key), "Goroutine 监控中...")
			time.Sleep(2 * time.Second)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	valueCtx := context.WithValue(ctx, key, "【监控1】")
	go watch(valueCtx)

	time.Sleep(10 * time.Second)
	fmt.Println("可以了,通知监控停止。")
	cancel()

	time.Sleep(5 * time.Second)
}

结果:

【监控1】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
【监控1】 Goroutine 监控中...
可以了,通知监控停止。
【监控1】 监控退出,停止了。

注意,我们可以使用 context.WithValue 方法附加一对 K/V 参数,并且 Key 需要具有等价性,Value 要求是线程安全的。在使用的时候,可以通过 Context 实例的 Value 方法来读取 ctx.Value(key)

Context 使用原则

  1. 不要把 Context 作为结构体成员之一,要以参数的方式进行传递。
  2. 以 Context 作为形参的函数或方法,应该把 Context 作为第一个参数,命名为 ctx。
  3. 给一个函数或方法传递 Context 的时候,即使函数允许,也不要传入nil的 Context,如果不知道传递什么,就使用 context.TODO。
  4. Context 的 Value 方法应该传递必要的数据。
  5. Context 是线程安全的,可以放心的在多个 Goroutine 中传递。
  6. 可以把一个 Context 实例传递给任意个 Gorotuine,对它执行 取消操作时,所有 Goroutine 都会接收到取消信号。

你可能感兴趣的:(Golang)