ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
在并发程序中,由于连接超时,用户取消或系统故障,往往需要执行抢占操作。我们之前使用done通道来在程序中取消所有阻塞的并发操作,虽然取得了不错的效果,但同样也存在局限。 如果我们可以给取消通知添加额外的信息:例如取消原因,操作是否正常完成等,这对我们进一步处理会起到非常大的作用。 在社区的不断推动下,Go开发组决定创建一个标准模式,以应对这种需求。在Go 1.7中,context包被引入标准库。 如果我们浏览一下context包,会发现其包含的内容非常少: ``` var Canceled = errors.New("context canceled") var Canceled = errors.New("context canceled") type CancelFunc type Context func Background() Context func TODO() Context func WithCancel(parent Context) (ctx Context, cancel CancelFunc) func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) func WithValue(parent Context, key, val interface{}) Context ``` 我们稍后会讨论这些类型和函数,现在让我们把关注点放到Context类型上。这个类型会贯穿你的整个系统,就跟done通道一样。如果你使用context包,从上游衍生出来的每个下游函数都可以使用Context作为参数。其类型定义是这样的: ``` type Context interface { // Deadline 返回任务完成时(该 context 被取消)的时间。 // 如果deadline 未设置,则返回的ok值为false。 // 连续调用该函数将返回相同的结果。 Deadline() (deadline time.Time, ok bool) // Done 返回任务完成时(该 context 被取消)一个已关闭的通道。 // 如果该context无法被取消,Done 将返回nil。 // 连续调用该函数将返回相同的结果。 // // 当cancel被调用时,WithCancel 遍历 Done以执行关闭; // 当deadline即将到期时,WithDeadline 遍历 Done以执行关闭; // 当timeout时,WithTimeout 遍历 Done以执行关闭。 // // Done 主要被用于 select 语句: // // // Stream 使用DoSomething生成值,并将值发送出去 // // 直到 DoSomething 返回错误或 ctx.Done 被关闭 // 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: // } // } // } // // 查看 https://blog.golang.org/pipelines更多示例以了解如何使用 // Done通道执行取消操作。 Done() <-chan struct{} // 如果 Done 尚未关闭, Err 返回 nil. // 如果 Done 已关闭, Err 返回值不为nil的error以解释为何关闭: // 因 context 的关闭导致 // 或 context 的 deadline 执行导致。 // 在 Err 返回值不为nil的error之后, 连续调用该函数将返回相同的结果。 Err() error // Value 根据 key 返回与 context 相关的结果, // 如果没有与key对应的结果,则返回nil。 // 连续调用该函数将返回相同的结果。 // // 该方法仅用于传输进程和API边界的请求数据, // 不可用于将可选参数传递给函数。 // // 键标识着上Context中的特定值。 // 在Context中存储值的函数通常在全局变量中分配一个键, // 然后使用该键作为context.WithValue和Context.Value的参数。 // 键可以是系统支持的任何类型; // 程序中各包应将键定义为未导出类型以避免冲突。 // // 定义Context键的程序包应该为使用该键存储的值提供类型安全的访问器: // // // user包 定义了一个User类型,该类型存储在Context中。 // package user // // import "context" // // // User 类型的值会存储在 Context中。 // type User struct {...} // // // key是位于包内的非导出类型。 // // 这可以防止与其他包中定义的键的冲突。 // type key int // // // userKey 是user.User类型的值存储在Contexts中的键。 // // 它是非导出的; clients use user.NewContext and user.FromContext // // 使用 user.NewContext 和 user.FromContext来替代直接使用键。 // var userKey key // // // NewContext 返回一个新的含有值 u 的 Context。 // func NewContext(ctx context.Context, u *User) context.Context { // return context.WithValue(ctx, userKey, u) // } // // // FromContext 返回存储在 ctx中的 User类型的值(如果存在的话)。 // func FromContext(ctx context.Context) (*User, bool) { // u, ok := ctx.Value(userKey).(*User) // return u, ok // } Value(key interface{}) interface{} ``` 这看起来挺简单。有一个Done方法返回当我们的函数被抢占时关闭的通道。还有一些新鲜的但不难理解的方法:一个Deadline函数,用于指示在一定时间之后goroutine是否会被取消,以及一个Err方法,如果goroutine被取消,将返回非零值。 但Value方法看起来有点奇怪。它是干嘛用的? goroutines的主要用途之一是为请求提供服务。通常在这些程序中,除了抢占信息之外,还需要传递特定于请求的信息。这是Value函数的意义。我们会稍微谈一谈这个问题,但现在我们只需要知道context包有两个主要目的: * 提供取消操作。 * 提供用于通过调用传输请求附加数据的数据包。 让我们看看第一个目的:取消操作。 正如我们在“防止Goroutine泄漏”中所学到的,函数中的取消有三个方面: * goroutine的生成者可能想要取消它。 * goroutine可能需要取消其衍生出来的goroutine。 * goroutine中的任何阻塞操作都必须是可抢占的,以便将其取消。 Context包可以帮助我们处理这三个方面的需求。 前面提到,Context类型将是函数的第一个参数。如果你查看了Context接口的方法,会发现没有任何东西可以改变底层结果的状态。更进一步的说,没有任何东西被系统允许把Context本身干掉。这保护了Context调用堆栈的功能。因此,结合接口中的Done方法,Context类型可以安全的管理取消操作。 这就产生了一个问题:如果Context是一成不变的,那我们如何影响调用堆栈中当前函数的子函数中的取消行为? 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) ``` 你会发现,这些函数都传入了Context类型的值,同时返回了Context类型的值,它们都使用与这些函数相关的选项生成Context的新实例。 WithCancel返回一个新的Context,它在调用返回的cancel函数时关闭done通道。 WithDeadline返回一个新的Context,当机器的时钟超过给定的最后期限时,它关闭done通道。 WithTimeout返回一个新的Context,它在给定的超时时间后关闭done通道。 如果你的函数需要以某种方式在调用中取消它的子函数,可以调用这三个函数中的一个并传递给它的上下文,然后将返回的上下文传递给它的子函数。 如果你的函数不需要修改取消行为,那么函数只传递给定的上下文。 通过这种方式,调用者可以创建符合其需求的上下文,而不会影响其创建者。这为管理调用分支提供了一个可组合的优雅的解决方案。 通过这样的方式,Context的实例可以贯穿你的整个程序。在面向对象的范例中,通常将对经常使用的数据的引用存储为成员变量,但重要的是不要使用context.Context的实例来执行此操作。context.Context的实例可能与外部看起来相同,但在内部它们可能会在每个堆栈帧处发生变化。出于这个原因,总是将Context的实例传递给你的函数是很重要的。通过这种方式,函数具有用于它的上下文,而不是把堆栈里的上下文随意取出来用。 在异步调用链的顶部,你的代码可能不会传递Context。要启动链,context包提供了两个函数来创建Context的空实例。 我们来看一个使用done通道模式的例子,并比较下切换到使用context文包获得什么好处。 这是一个同时打印问候和告别的程序: ``` func main() { var wg sync.WaitGroup done := make(chan interface{}) defer close(done) wg.Add(1) go func() { defer wg.Done() if err := printGreeting(done); err != nil { fmt.Printf("%v", err) return } }() wg.Add(1) go func() { defer wg.Done() if err := printFarewell(done); err != nil { fmt.Printf("%v", err) return } }() wg.Wait() } func printGreeting(done <-chan interface{}) error { greeting, err := genGreeting(done) if err != nil { return err } fmt.Printf("%s world!\n", greeting) return nil } func printFarewell(done <-chan interface{}) error { farewell, err := genFarewell(done) if err != nil { return err } fmt.Printf("%s world!\n", farewell) return nil } func genGreeting(done <-chan interface{}) (string, error) { switch locale, err := locale(done); { case err != nil: return "", err case locale == "EN/US": return "hello", nil } return "", fmt.Errorf("unsupported locale") } func genFarewell(done <-chan interface{}) (string, error) { switch locale, err := locale(done); { case err != nil: return "", err case locale == "EN/US": return "goodbye", nil } return "", fmt.Errorf("unsupported locale") } func locale(done <-chan interface{}) (string, error) { select { case <-done: return "", fmt.Errorf("canceled") case <-time.After(5 * time.Second): } return "EN/US", nil } ``` 这会输出: ``` hello world! goodbye world! ``` 忽略竞争条件,我们可以看到程序有两个分支同时运行。通过创建done通道并将其传递给我们的调用链来设置标准抢占方法。如果我们在main的任何一点关闭done频道,那么两个分支都将被取消。 我们可以尝试几种不同且有趣的方式来控制该程序。也许我们希望genGreeting如果花费太长时间就会超时。也许我们不希望genFarewell调用locale——在其父进程很快就会被取消的情况下。在每个堆栈框架中,一个函数可以影响其下的整个调用堆栈。 使用done通道模式,我们可以通过将传入的done通道包装到其他done通道中,然后在其中任何一个通道启动时返回,但我们不会获得上下文给的deadline和错误的额外信息。 为了将done通道模式与使用context包进行比较,我们将该程序表示为树状图。 树中的每个节点代表一个函数的调用。 :-: ![](https://box.kancloud.cn/bbdad8419e533c12d5e6488d1a3d4364_544x724.png) 让我们使用context包来修改该程序。由于现在可以使用context.Context的灵活性,所以我们引入一个有趣的场景。 假设genGreeting在放弃调用locale之前等待一秒——超时时间为1秒。如果printGreeting不成功,我们想取消对printFare的调用。 毕竟,如果我们不打声招呼,说再见就没有意义了: ``` func main() { var wg sync.WaitGroup ctx, cancel := context.WithCancel(context.Background()) //1 defer cancel() wg.Add(1) go func() { defer wg.Done() if err := printGreeting(ctx); err != nil { fmt.Printf("cannot print greeting: %v\n", err) cancel() //2 } }() wg.Add(1) go func() { defer wg.Done() if err := printFarewell(ctx); err != nil { fmt.Printf("cannot print farewell: %v\n", err) } }() wg.Wait() } func printGreeting(ctx context.Context) error { greeting, err := genGreeting(ctx) if err != nil { return err } fmt.Printf("%s world!\n", greeting) return nil } func printFarewell(ctx context.Context) error { farewell, err := genFarewell(ctx) if err != nil { return err } fmt.Printf("%s world!\n", farewell) return nil } func genGreeting(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, 1*time.Second) //3 defer cancel() switch locale, err := locale(ctx); { case err != nil: return "", err case locale == "EN/US": return "hello", nil } return "", fmt.Errorf("unsupported locale") } func genFarewell(ctx context.Context) (string, error) { switch locale, err := locale(ctx); { case err != nil: return "", err case locale == "EN/US": return "goodbye", nil } return "", fmt.Errorf("unsupported locale") } func locale(ctx context.Context) (string, error) { select { case <-ctx.Done(): return "", ctx.Err() //4 case <-time.After(1 * time.Minute): } return "EN/US", nil } ``` 1. 在main函数中使用context.Background()建立个新的Context,并使用context.WithCancel将其包裹以便对其执行取消操作。 2. 在这一行上,如果从 printGreeting返回错误,main将取消context。 3. 这里genGreeting用context.WithTimeout包装Context。这将在1秒后自动取消返回的context,从而取消它传递context的子进程,即语言环境。 4. 这一行返回为什么Context被取消的原因。 这个错误会一直冒泡到main,这会导致注释2处的取消操作被调用。 这会输出: ``` cannot print greeting: context deadline exceeded cannot print farewell: context canceled ``` 下面的图中数字对应例子中的代码标注。 :-: ![](https://box.kancloud.cn/7bc061feabfbe82b725e2ac92b27eead_447x592.png) 我们可以看到系统输出工作正常。由于local设置至少需要运行一分钟,因此genGreeting将始终超时,这意味着main会始终取消printFarewell下面的调用链。 请注意,genGreeting如何构建自定义的Context.Context以满足其需求,而不必影响父级的Context。如果genGreeting成功返回,并且printGreeting需要再次调用,则可以在不泄漏genGreeting相关操作信息的情况下进行。这种可组合性使你能够编写大型系统,而无需在整个调用链中费劲心思解决这样的问题。 我们可以在这个程序上进一步改进:因为我们知道locale需要大约一分钟的时间才能运行,所以可以在locale中检查是否给出了deadline。下面这个例子演示了如何使用context.Context的Deadline方法: ``` func main() { var wg sync.WaitGroup ctx, cancel := context.WithCancel(context.Background()) defer cancel() wg.Add(1) go func() { defer wg.Done() if err := printGreeting(ctx); err != nil { fmt.Printf("cannot print greeting: %v\n", err) cancel() } }() wg.Add(1) go func() { defer wg.Done() if err := printFarewell(ctx); err != nil { fmt.Printf("cannot print farewell: %v\n", err) } }() wg.Wait() } func printGreeting(ctx context.Context) error { greeting, err := genGreeting(ctx) if err != nil { return err } fmt.Printf("%s world!\n", greeting) return nil } func printFarewell(ctx context.Context) error { farewell, err := genFarewell(ctx) if err != nil { return err } fmt.Printf("%s world!\n", farewell) return nil } func genGreeting(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() switch locale, err := locale(ctx); { case err != nil: return "", err case locale == "EN/US": return "hello", nil } return "", fmt.Errorf("unsupported locale") } func genFarewell(ctx context.Context) (string, error) { switch locale, err := locale(ctx); { case err != nil: return "", err case locale == "EN/US": return "goodbye", nil } return "", fmt.Errorf("unsupported locale") } func locale(ctx context.Context) (string, error) { if deadline, ok := ctx.Deadline(); ok { //1 if deadline.Sub(time.Now().Add(1*time.Minute)) <= 0 { return "", context.DeadlineExceeded } } select { case <-ctx.Done(): return "", ctx.Err() case <-time.After(1 * time.Minute): } return "EN/US", nil } ``` 1. 我们在这里检查是否Context提供了deadline。如果提供了,而且我们的程序时间已经越过这个时间线,这时简单的返回一个context包预设的错误——DeadlineExceeded。 虽然修改的部分很小,但它允许locale函数快速失败,而不必像之前那样等待一分钟。在调用资源耗费较高的程序中,这样做会节省大量时间。唯一的问题是,你得考虑deadline设置多久合适——这需要不断的尝试。 接下来我们讨论context包的另一个用处:存储和检索附加于请求的数据包。请记住,当一个函数创建一个goroutine和Context时,它通常会启动一个为请求提供服务的进程,并且子函数可能需要相关的请求信息。下面是一个示例: ``` func main() { ProcessRequest("jane", "abc123") } func ProcessRequest(userID, authToken string) { ctx := context.WithValue(context.Background(), "userID", userID) ctx = context.WithValue(ctx, "authToken", authToken) HandleResponse(ctx) } func HandleResponse(ctx context.Context) { fmt.Printf("handling response for %v (%v)", ctx.Value("userID"), ctx.Value("authToken"), ) } ``` 这会输出: ``` handling response for jane (abc123) ``` 很简单的用法。不过也是有限制的: * 你使用的key必须在Go中是可比较的,也就是说,== 和 != 必须能返回正确的结果。 * 返回值必须是并发安全的,这样才能从多个goroutine访问。 由于Context的键和值都被定义为interface{},所以当试图检索值时,我们会失去其类型安全性。基于此,Go建议在context中存储和检索值时遵循一些规则。 首先,推荐你在包中自行定义key的类型,这样无论是否其他包执行相同的操作都可以防止context中的冲突。看下面这个例子: ``` type foo int type bar int m := make(map[interface{}]int) m[foo(1)] = 1 m[bar(1)] = 2 fmt.Printf("%v", m) ``` 这会输出: ``` map[1:2 1:1] ``` 可以看到,虽然基础值是相同的,但不同类型的信息会在map中区分它们。由于你为包定义的key类型未导出,因此其他包不会与你在包中生成的key冲突。 由于用于存储数据的key是非导出的,因此我们必须导出执行检索数据的函数。这很容易做到,因为它允许这些数据的使用者使用静态的,类型安全的函数。 当你把所有这些放在一起时,你会得到类似下面的例子: ``` func main() { ProcessRequest("jane", "abc123") } type ctxKey int const ( ctxUserID ctxKey = iota ctxAuthToken ) func UserID(c context.Context) string { return c.Value(ctxUserID).(string) } func AuthToken(c context.Context) string { return c.Value(ctxAuthToken).(string) } func ProcessRequest(userID, authToken string) { ctx := context.WithValue(context.Background(), ctxUserID, userID) ctx = context.WithValue(ctx, ctxAuthToken, authToken) HandleResponse(ctx) } func HandleResponse(ctx context.Context) { fmt.Printf( "handling response for %v (auth: %v)", UserID(ctx), AuthToken(ctx), ) } ``` 这会输出: ``` handling response for jane (auth: abc123) ``` 在本例中,我们使用类型安全的方法来从Context获取值,如果消费者在不同的包中,他们不会知道或关心用于存储信息的key。 但是,这种技术会造成隐患。 在前面的例子中,我们假设HandleResponse存在于另一个名为response的包中,假设ProcessRequest包位于名为pross的包中。 pross包必须导入response包才能调用HandleResponse,但HandleResponse无法访问pross包中定义的访问函数,因为导入会形成循环依赖关系。由于用于Context中存储的key类型对于process包来说是私有的,所以response包无法检索这些数据! 这迫使我们创建以从多个位置导入的数据类型为中心的包。 虽然这不是一件坏事,但它是需要注意的。 context包非常简洁,但依然褒贬不一。在Go社区中一直存在争议。该包的取消操作功能相当受欢迎,但是在Context中存储任意数据的能力以及存储数据的类型不安全的造成了一些分歧。 虽然我们已经部分减缓了访问函数缺乏类型安全性的问题,但是仍然可以通过存储不正确的类型来引入错误。然而,更大的问题在于,开发人员到底应该在Context的实例中存储什么样的数据。 在context包的文档中这样写到: >使用context存储值仅适用于传输进程和API的请求附加数据,而不用于将可选参数传递给函数。 该说明十分含糊,“传输进程和API的请求”实在太过宽泛。我认为最好的解读方法是与开发组一起提出一些约定,并在代码评审中检查它们: 1. **数据应该是由进程传递的或与API相关**。如果你在进程的内存中生成数据,那么除非你也通过API传递数据,否则可能不是一个很好的候选应用程序。 2. **数据应该是不可变的**。如果可变,那么根据定义,你存储的内容肯定不是来自请求。 3. **数据应指向系统简单类型。**我们在上面已经讨论了关于使用安全类型的包导入问题,这个结论是很明显的。 4. **数据应该是纯粹的数据,而不是某种类型的函数。**消费者的逻辑应该是消耗这些数据。 5. **数据应该有助于操作,而不是驱动操作。**如果你的算法根据context中包含或不包含的内容而有所不同,那么就违背了“不用于将可选参数传递”的初衷。 这些不是硬性规定,但如果你发现自己的程序与以上约定由冲突,则可能需要考虑是否存在隐患或使用context是否必要。 另一个需要考虑的方面是该数据在使用之前可能需要经过多少层。如果在接受数据的位置和使用位置之间有几个框架和几十个函数,你可以考虑使用日志,并将数据添加为参数;或者你更愿意将它放在Context中,从而创建一个不可见的依赖关系。每种方法都有优点,最终这由你和你的团队做出决定。 我将这5条约定做成了表格,你可以将之作为参考: :-: ![](https://box.kancloud.cn/03fa98563db15fb9ea105f420da62a0d_681x234.jpg) 对于是否有必要使用context存储值,这里并没有简单的答案,具体取决于你的业务、算法和团队。 我留给你的最后建议是Context提供的取消功能非常有用,这样轻便的功能如果不用实在是太可惜了。 * * * * * 学识浅薄,错误在所难免。我是长风,欢迎来Golang中国的群(211938256)就本书提出修改意见。