Go 语言面试题详解之上下文 (Context) 解密

上下文(Context)是 Go 语言中非常有特色的一个特性, 在 Go 1.7 版本中正式引入新标准库 context。

其主要的作用是在 goroutine 中进行上下文的传递,而在传递信息中又包含了 goroutine 的运行控制、上下文信息传递等功能。

Go 语言的上下文(Context)是处理并发编程中的重要工具,尤其是在 HTTP 服务、数据库操作和其他 I/O 操作中,用于管理请求的生命周期和控制超时、取消操作。掌握 context 是 Go 语言开发者的必备技能,尤其在面试中经常会考察它的理解和应用。

本文将深入讲解 Go 语言中的 context 包,解密常见的面试题,并通过示例代码加深理解。

什么是上下文 (Context)?

在 Go 中,context 包提供了一种用于管理多个 Goroutine 之间共享状态和取消信号的机制。它常用于以下场景:

  1. 超时和取消:控制操作的超时时间和取消信号。
  2. 请求范围的上下文:在一个请求的生命周期内传递数据,确保每个请求都能正确地处理。
  3. 避免阻塞:通过在上下文中添加取消信号,防止长时间的阻塞操作。

一、Context 的基本使用

Go 语言中的 context 包提供了几个重要的函数来创建、取消和传递上下文:

  • context.Background() :创建一个空的、根上下文,通常用于程序的入口点。
  • context.TODO() :与 Background 相似,但用于暂时不知道使用哪种上下文的情况。
  • context.WithCancel() :创建一个可以取消的上下文,适用于需要取消操作的场景。
  • context.WithTimeout() :创建一个具有超时机制的上下文,用于设置操作的最大执行时间。
  • context.WithDeadline() :类似于 WithTimeout,但提供具体的截止时间。

1. 使用 context.Background()context.TODO()

context.Background() 通常用于 main 函数、测试和服务器启动的地方,作为根上下文。context.TODO() 则是开发者暂时不确定何时使用上下文的情况下,作为一个占位符。

为加强大家对 Go 语言的 context 的设计,本文将对标准库 context 进行深入剖析,看看他里面到底暗含了何物,又为何能够做那么多事。

整体的描述结构是:"了解 context 特性,熟悉 context 流程,剖析 context 原理" 三个板块进行。目录如下:

什么是 context

Go 语言的独有的功能之一 Context,最常听说开发者说的一句话就是 "函数的第一个形参真的要传 ctx 吗?",第二句话可能是 "有没有什么办法不传,就能达到传入的效果?" ,听起来非常魔幻。

在 Go 语言中 context 作为一个 "一等公民" 的标准库,许多的开源库都一定会对他进行支持,因为标准库 context 的定位是上下文控制。会在跨 goroutine 中进行传播:

本质上 Go 语言是基于 context 来实现和搭建了各类 goroutine 控制的,并且与 select-case联合,就可以实现进行上下文的截止时间、信号控制、信息传递等跨 goroutine 的操作,是 Go 语言协程的重中之重。

context 基本特性

演示代码:

css 复制代码
func main() {
 parentCtx := context.Background()
 ctx, cancel := context.WithTimeout(parentCtx, 1*time.Millisecond)
 defer cancel()

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

输出结果:

复制代码
context deadline exceeded

我们通过调用标准库 context.WithTimeout 方法针对 parentCtx 变量设置了超时时间,并在随后调用 select-case 进行 context.Done 方法的监听,最后由于达到截止时间。因此逻辑上 select 走到了 context.Errcase 分支,最终输出 context deadline exceeded

除了上述所描述的方法外,标准库 context 还支持下述方法:

scss 复制代码
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
type Context
    func Background() Context
    func TODO() Context
    func WithValue(parent Context, key, val interface{}) Context
  • WithCancel:基于父级 context,创建一个可以取消的新 context。

  • WithDeadline:基于父级 context,创建一个具有截止时间(Deadline)的新 context。

  • WithTimeout:基于父级 context,创建一个具有超时时间(Timeout)的新 context。

  • Background:创建一个空的 context,一般常用于作为根的父级 context。

  • TODO:创建一个空的 context,一般用于未确定时的声明使用。

  • WithValue:基于某个 context 创建并存储对应的上下文信息。

context 本质

我们在基本特性中介绍了不少 context 的方法,其基本大同小异。看上去似乎不难,接下来我们看看其底层的基本原理和设计。

context 相关函数的标准返回如下:

scss 复制代码
func WithXXXX(parent Context, xxx xxx) (Context, CancelFunc)

其返回值分别是 ContextCancelFunc,接下来我们将进行分析这两者的作用。

接口

\1. Context 接口:

scss 复制代码
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline:获取当前 context 的截止时间。

  • Done:获取一个只读的 channel,类型为结构体。可用于识别当前 channel 是否已经被关闭,其原因可能是到期,也可能是被取消了。

  • Err:获取当前 context 被关闭的原因。

  • Value:获取当前 context 对应所存储的上下文信息。

\2. Canceler 接口:

go 复制代码
type canceler interface {
 cancel(removeFromParent bool, err error)
 Done() <-chan struct{}
}
  • cancel:调用当前 context 的取消方法。

  • Done:与前面一致,可用于识别当前 channel 是否已经被关闭。

基础结构

在标准库 context 的设计上,一共提供了四类 context 类型来实现上述接口。分别是 emptyCtxcancelCtxtimerCtx 以及 valueCtx

emptyCtx

在日常使用中,常常使用到的 context.Background 方法,又或是 context.TODO 方法。

源码如下:

csharp 复制代码
var (
 background = new(emptyCtx)
 todo       = new(emptyCtx)
)

func Background() Context {
 return background
}

func TODO() Context {
 return todo
}

其本质上都是基于 emptyCtx 类型的基本封装。而 emptyCtx 类型本质上是实现了 Context 接口:

go 复制代码
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 的实现非常简单,因为他是空 context 的定义,因此没有 deadline,更没有 timeout,可以认为就是一个基础空白 context 模板。

cancelCtx

在调用 context.WithCancel 方法时,我们会涉及到 cancelCtx 类型,其主要特性是取消事件。源码如下:

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

func newCancelCtx(parent Context) cancelCtx {
 return cancelCtx{Context: parent}
}

其中的 newCancelCtx 方法将会生成出一个可以取消的新 context,如果该 context 执行取消,与其相关联的子 context 以及对应的 goroutine 也会收到取消信息。

首先 main goroutine 创建并传递了一个新的 context 给 goroutine b,此时 goroutine b 的 context 是 main goroutine context 的子集:

传递过程中,goroutine b 再将其 context 一个个传递给了 goroutine c、d、e。最后在运行时 goroutine b 调用了 cancel 方法。使得该 context 以及其对应的子集均接受到取消信号,对应的 goroutine 也进行了响应。

接下来我们针对 cancelCtx 类型来进一步看看:

go 复制代码
type cancelCtx struct {
 Context

 mu       sync.Mutex            // protects following fields
 done     chan struct{}         // created lazily, closed by first cancel call
 children map[canceler]struct{} // set to nil by the first cancel call
 err      error                 // set to non-nil by the first cancel call
}

该结构体所包含的属性也比较简单,主要是 children 字段,其包含了该 context 对应的所有子集 context,便于在后续发生取消事件的时候进行逐一通知和关联。

而其他的属性主要用于并发控制(互斥锁)、取消信息和错误的写入:

go 复制代码
func (c *cancelCtx) Value(key interface{}) interface{} {
 if key == &cancelCtxKey {
  return c
 }
 return c.Context.Value(key)
}

func (c *cancelCtx) Done() <-chan struct{} {
 c.mu.Lock()
 if c.done == nil {
  c.done = make(chan struct{})
 }
 d := c.done
 c.mu.Unlock()
 return d
}

func (c *cancelCtx) Err() error {
 c.mu.Lock()
 err := c.err
 c.mu.Unlock()
 return err
}

在上述代码中可以留意到,done 属性(只读 channel)是在真正调用到 Done 方法时才会去创建。需要配合 select-case 来使用。

timerCtx

在调用 context.WithTimeout 方法时,我们会涉及到 timerCtx 类型,其主要特性是 Timeout 和 Deadline 事件,源码如下:

scss 复制代码
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
 return WithDeadline(parent, time.Now().Add(timeout))
}

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
 ...
 c := &timerCtx{
  cancelCtx: newCancelCtx(parent),
  deadline:  d,
 }
}

你可以发现 timerCtx 类型是基于 cancelCtx 类型的。我们再进一步看看 timerCtx 结构体:

go 复制代码
type timerCtx struct {
 cancelCtx
 timer *time.Timer // Under cancelCtx.mu.

 deadline time.Time
}

其实 timerCtx 类型也就是 cancelCtx 类型,加上 time.Timer 和对应的 Deadline,也就是包含了时间属性的控制。

我们进一步看看其配套的 cancel 方法,思考一下其是如何进行取消动作的:

scss 复制代码
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
 return c.deadline, true
}

func (c *timerCtx) cancel(removeFromParent bool, err error) {
 c.cancelCtx.cancel(false, err)
 if removeFromParent {
  removeChild(c.cancelCtx.Context, c)
 }
 c.mu.Lock()
 if c.timer != nil {
  c.timer.Stop()
  c.timer = nil
 }
 c.mu.Unlock()
}

先会调用 cancelCtx 类型的取消事件。若存在父级节点,则移除当前 context 子节点,最后停止定时器并进行定时器重置。而 Deadline 或 Timeout 的行为则由 timerCtxWithDeadline 方法实现:

scss 复制代码
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
 if cur, ok := parent.Deadline(); ok && cur.Before(d) {
  // The current deadline is already sooner than the new one.
  return WithCancel(parent)
 }
 ...
}

该方法会先进行前置判断,若父级节点的 Deadline 时间早于当前所指定的 Deadline 时间,将会直接生成一个 cancelCtx 的 context。

go 复制代码
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
 ...
 c := &timerCtx{
  cancelCtx: newCancelCtx(parent),
  deadline:  d,
 }
 propagateCancel(parent, c)
 dur := time.Until(d)
 if dur <= 0 {
  c.cancel(true, DeadlineExceeded) // deadline has already passed
  return c, func() { c.cancel(false, Canceled) }
 }
 c.mu.Lock()
 defer c.mu.Unlock()
 if c.err == nil {
  c.timer = time.AfterFunc(dur, func() {
   c.cancel(true, DeadlineExceeded)
  })
 }
 return c, func() { c.cancel(true, Canceled) }
}

接下来将会正式生成成为一个 timeCtx 类型,并将其加入到父级 context 是 children 属性中。最后进行当前时间与 Deadline 时间的计算,并通过调用 time.AfterFunc 在到期后自动调用 cancel 方法发起取消事件,自然也就会触发父子级的事件传播。

valueCtx

在调用 context.WithValue 方法时,我们会涉及到 valueCtx 类型,其主要特性是涉及上下文信息传递,源码如下:

vbnet 复制代码
func WithValue(parent Context, key, val interface{}) Context {
 ...
 if !reflectlite.TypeOf(key).Comparable() {
  panic("key is not comparable")
 }
 return &valueCtx{parent, key, val}
}

你会发现 valueCtx 结构体也非常的简单,核心就是键值对:

go 复制代码
type valueCtx struct {
 Context
 key, val interface{}
}

其在配套方法上也不会太复杂,基本就是要求可比较,接着就是存储匹配:

vbnet 复制代码
func (c *valueCtx) Value(key interface{}) interface{} {
 if c.key == key {
  return c.val
 }
 return c.Context.Value(key)
}

这时候你可能又有疑问了,那多个父子级 context 是如何实现跨 context 的上下文信息获取的?

这秘密其实在上面的 valueCtxValue 方法中有所表现:

本质上 valueCtx 类型是一个单向链表,会在调用 Value 方法时先查询自己的节点是否有该值。若无,则会通过自身存储的上层父级节点的信息一层层向上寻找对应的值,直到找到为止。

而在实际的工程应用中,你会发现各大框架,例如:gin、grpc 等。他都是有自己再实现一套上下文信息的传输的二次封装,本意也是为了更好的管理和观察上下文信息。

context 取消事件

在我们针对 context 的各类延伸类型和源码进行了分析后。我们进一步提出一个疑问点,context 是如何实现跨 goroutine 的取消事件并传播开来的,是如何实现的

这个问题的答案就在于 WithCancelWithDeadline 都会涉及到 propagateCancel 方法,其作用是构建父子级的上下文的关联关系,若出现取消事件时,就会进行处理:

go 复制代码
func propagateCancel(parent Context, child canceler) {
 done := parent.Done()
 if done == nil {
  return
 }

 select {
 case <-done:
  child.cancel(false, parent.Err())
  return
 default:
 }
 ...
}
  • 当父级上下文(parent)的 Done 结果为 nil 时,将会直接返回,因为其不会具备取消事件的基本条件,可能该 context 是 BackgroundTODO 等方法产生的空白 context。

  • 当父级上下文(parent)的 Done 结果不为 nil 时,则发现父级上下文已经被取消,作为其子级,该 context 将会触发取消事件并返回父级上下文的取消原因。

go 复制代码
func propagateCancel(parent Context, child canceler) {
 ...
 if p, ok := parentCancelCtx(parent); ok {
  p.mu.Lock()
  if p.err != nil {
   child.cancel(false, p.err)
  } else {
   if p.children == nil {
    p.children = make(map[canceler]struct{})
   }
   p.children[child] = struct{}{}
  }
  p.mu.Unlock()
 } else {
  atomic.AddInt32(&goroutines, +1)
  go func() {
   select {
   case <-parent.Done():
    child.cancel(false, parent.Err())
   case <-child.Done():
   }
  }()
 }
}

经过前面一个代码片段的判断,已得知父级 context 未触发取消事件,当前父级和子级 context 均正常(未取消)。

将会执行以下流程:

  • 调用 parentCancelCtx 方法找到具备取消功能的父级 context。并将当前 context,也就是 child 加入到 父级 context 的 children 列表中,等待后续父级 context 的取消事件通知和响应。

  • 调用 parentCancelCtx 方法没有找到,将会启动一个新的 goroutine 去监听父子 context 的取消事件通知。

通过对 context 的取消事件和整体源码分析,可得知 cancelCtx 类型的上下文包含了其下属的所有子节点信息:

也就是其在 children 属性的 map[canceler]struct{} 存储结构上就已经支持了子级关系的查找,也就自然可以进行取消事件传播了。

而具体的取消事件的实际行为,则是在前面提到的 propagateCancel 方法中,会在执行例如cacenl 方法时,会对父子级上下文分别进行状态判断,若满足则进行取消事件,并传播给子级同步取消。

深度思考

作为 Go 语言的核心功能之一,其实标准库 context 非常的短小精悍,使用的都是基本的数据结构和理念。既满足了跨 goroutine 的调控控制,像是并发、超时控制等。

同时也满足了上下文的信息传递。在工程应用中,例如像是链路ID、公共参数、鉴权校验等,都会使用到 context 作为媒介。

目前官方对于 context 的建议是作为方法的首参数传入,虽有些麻烦,但也有人选择将其作为结构体中的一个属性传入。但这也会带来一些心智负担,需要识别是否重新 new 一个。

也有人提出希望 Go2 取消掉 context,换成另外一种方法,但总体而言目前未见到正式的提案,这是我们都需要再思考的。

三、上下文的面试题解析

1. contextgoroutine 的关系是什么?

context 是用来管理 goroutine 之间的共享状态和取消信号的。它的主要作用是:

  • 在多个 goroutine 之间传递请求范围的元数据。
  • 启动或停止一组并发操作。
  • 实现超时和取消操作,防止不必要的资源消耗。

面试题示例:

  • 问题:如何在多个 Goroutine 中使用上下文进行取消控制?
  • 回答 :可以使用 context.WithCancel 创建一个可取消的上下文,将该上下文传递给多个 Goroutine。如果需要取消操作,可以调用 cancel() 函数,所有使用该上下文的 Goroutine 会收到取消信号并停止执行。

2. 在上下文中传递数据是否安全?

上下文是不可变的,当创建一个新的上下文时,它会创建一个包含数据的新上下文。对于并发操作,这样的设计保证了数据的安全性,不会出现并发访问的竞态条件。

面试题示例:

  • 问题:如何在上下文中存储和传递数据?有何限制?
  • 回答 :可以通过 context.WithValue() 存储数据,但它的使用需要谨慎。通常,上下文用于传递请求级别的元数据,如认证信息或跟踪信息。使用上下文传递大量数据或频繁访问可能会导致性能下降,因此建议仅用于小数据。

3. 为什么不能将上下文作为参数传递给所有函数?

Go 语言的设计哲学是避免将上下文滥用,通常上下文只在与请求相关的函数中传递。频繁地传递上下文可能导致代码混乱,影响可读性和维护性。因此,上下文应该仅限于需要的地方,避免将其作为全局传递的参数。

面试题示例:

  • 问题:为什么 Go 不建议将上下文作为所有函数的参数?
  • 回答:上下文应该仅用于与请求生命周期相关的操作,频繁传递上下文可能会导致代码复杂化。如果没有实际需求,传递上下文反而可能会降低代码的可读性和维护性。

四、总结

context 是 Go 语言中非常强大的工具,它使得并发编程变得更加可控、安全和高效。通过合理使用 context,可以轻松管理超时、取消信号和请求的生命周期。在面试中,考察 context 的使用及其在并发中的应用是很常见的,掌握其基础用法和进阶技巧,将帮助你在面试中脱颖而出。

通过对上下文的深入理解和实践,你不仅能应对面试中的相关问题,还能在实际开发中更好地管理并发任务和请求。

相关推荐
Victor35613 小时前
Redis(41)Redis集群的最大节点数是多少?
后端
青鱼入云13 小时前
【面试场景题】如何理解设计模式
设计模式·面试·职场和发展
嘟嘟MD14 小时前
程序员副业 | 2025年8月复盘
后端·创业
Sagittarius_A*14 小时前
Maven + JUnit:Java单元测试的坚实组合
java·后端·单元测试·maven
毕设源码-郭学长15 小时前
【开题答辩全过程】以 基于SpringBoot的流浪猫狗领养系统为例,包含答辩的问题和答案
java·spring boot·后端
码出财富15 小时前
SpringBoot 事务管理避坑指南
java·spring boot·后端
2501_9096867015 小时前
基于SpringBoot的古典舞在线交流平台
java·spring boot·后端
泽虞15 小时前
《LINUX系统编程》笔记p8
linux·运维·服务器·c语言·笔记·面试
海洋的渔夫18 小时前
1-ruby介绍、环境搭建、运行 hello world 程序
开发语言·后端·ruby