1. Context 到底是什么从一个线上事故说起干 Go 的同学应该都见过这么一段报错context deadline exceeded。我最早遇到它是在一个 HTTP 服务里下游 RPC 偶发超时然后整个请求链路像多米诺骨牌一样连环超时日志里满满全是这一句。当时第一反应是网络问题查了半天发现根本不是问题出在我自己写的代码上没有正确传递和使用 context。打个比方context 就是一次请求从进入到离开全程跟着的一本“任务手册”。手册上记着三件事这个任务什么时候该停止取消信号、这个任务最晚什么时候必须完成截止时间、这个任务随身带了哪些钥匙元数据。goroutine 想看手册就直接拿 context 参数指过去谁也不能另起炉灶丢掉手册丢了就断了传递链。Context 的核心设计目标就一个让上游能够控制下游。上游超时了、用户断开了、服务要下线了下游每个环节都得知道并且立刻停止干活释放资源。没有这套机制goroutine 会一直跑下去数据库连接不释放临时文件不清理服务迟早被拖垮。这套东西在 Go 里其实很轻核心就是一个接口context.Context加上几个创建派生 context 的函数。但越轻的东西越容易被用歪网上关于 context 的争议基本都是 misuse 引发的比如把context.WithValue当全局变量用、拿着context.Background()到处传、忘了调用 CancelFunc 导致泄漏等等。这篇我会先把 context 的核心机制讲透然后直接给出一套能落地的传参、取消、超时控制方案最后配上高频面试题和踩坑实录希望看完你能直接把这套东西用进项目里。2. 四个创建函数的底层逻辑与选型依据2.1 context.Background 和 context.TODO一切开始的地方任何 context 链条都有源头源头只能是context.Background()或context.TODO()。这俩函数返回的都是同一个空的 Context 实现没有任何值、不绑定取消信号、没有截止时间代码里看内部其实是一个emptyCtx类型本质上就是一空壳。区别纯粹是语义上的Background表示这个 context 就是根没有父 context我明确要在程序顶层使用它一般出现在main函数、HTTP server 的入口、init里或者测试用例里。TODO表示我现在还没想好该传什么 context 进来先用它占个位理论上应该在代码 review 中全部消灭掉因为它是欠债的标记说明这里存在未完成的改造。这两个函数很多人混着用其实问题不大但语义不清晰的项目后续维护起来特别累。我的习惯是新项目里强制go vet扫不到就不要管但 code review 里看到context.TODO()一定会追问什么时候改成真正的 Context。这里有个关键点不要自己在代码里造根 context。有人图省事直接context.WithTimeout(context.Background(), 5*time.Second)当作全局变量封装出来到处用这是错误的。根 context 必须从入口一路传下来中间每层调用根据自己的职责决定是否衍生新的子 context而不是全局共用一个。2.2 context.WithCancel手动取消信号的核心context.WithCancel是最基础、也最容易被忽略的一个函数。签名很简单ctx, cancel : context.WithCancel(parent)调用之后拿到一个新的子 context 和一个cancel函数。子 context 的取消会和父 context 联动父取消子必取消子取消父不会受影响。这个联动机制在实现上是一个树状结构每个子 context 内部维护一个parent指针cancel调用时会把取消信号往所有后代广播。广播的底层实现是关闭一个 channel为什么用关闭而不是往 channel 里写东西因为关闭 channel 天然具备只发生一次、所有监听者都能收到的特性用sync.Once保证只关闭一次就能完美避免重复取消带来的并发问题。实际工程里最常见的错误就是调用了WithCancel但忘了调用cancel。这会导致子 context 泄露它关联的 goroutine 永远收不到取消信号一直悬挂在内存里。如果你用pprof看过 goroutine 数量会发现这类问题藏都藏不住。后面第 5 节我会具体讲怎么排查。func main() { ctx, cancel : context.WithCancel(context.Background()) defer cancel() // 永远不要忘记取消 go worker(ctx) time.Sleep(3 * time.Second) cancel() // 手动触发取消 time.Sleep(time.Second) }注意defer cancel()的位置。放在context.WithCancel后面马上defer这是惯例千万不要等业务逻辑跑完才想起 cancel。2.3 WithTimeout 和 WithDeadline超时控制的两副面孔context.WithTimeout和context.WithDeadline本质上是同一件事文档里写着 WithTimeout 内部就是调用的 WithDeadlinefunc WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { return WithDeadline(parent, time.Now().Add(timeout)) }区别在于参数表达方式一个是从现在起多久之后超时相对时间一个是绝对截止时间点绝对时间。比如调用下游接口最多给 2 秒用WithTimeout清理任务必须在凌晨 3 点前完成用WithDeadline。用WithDeadline有个好处是它可以拿到失效时刻如果你的逻辑需要判断我再等 500ms 就真的来不及了可以调用Deadline()方法获取绝对时间然后自行对比。很多服务端框架的熔断器就是这么干的提前算好剩余时间决定是否直接短路请求。超时触发时会自动调用内部的 cancel所以很多人写代码时不接收CancelFunc直接ctx, _ : context.WithTimeout(...)。这样可以吗运行上没问题因为定时器到点会自动取消但我建议还是要接收并defer cancel()。原因是一个函数可能在超时之前就返回了比如下游提前返回了结果、或者参数校验失败直接return此时定时器还没有触发你不主动 cancel定时器会一直挂在堆里直到自然到期这个窗口期在高并发下会积压大量定时器对象白白浪费内存。看一个实际例子ctx, cancel : context.WithTimeout(r.Context(), 2*time.Second) defer cancel() result, err : queryDatabase(ctx, select ...) if err ! nil { log.Printf(query failed: %v, err) return }这里无论queryDatabase是成功还是超时函数返回前defer cancel()都会执行内部定时器被及时回收。2.4 WithValue传值的正确姿势与隐蔽风险context.WithValue常用于在调用链上传递请求级别的元数据比如请求 ID、用户 ID、trace ID。它的实现就是在一棵链表树上挂个 key-value 节点查询时从叶子往根方向逐层找。这就带来一个特性子 context 能查到父亲的 key但查不到兄弟节点的同一层节点之间是隔离的。官方文档对 key 有两个硬性要求key 必须是自定义类型不能用基础类型。原因是多个包同时往 context 里塞 string 类型的 key很容易撞车。常规做法是定义一个私有类型type requestIDKey struct{} func WithRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, requestIDKey{}, id) } func RequestIDFrom(ctx context.Context) (string, bool) { id, ok : ctx.Value(requestIDKey{}).(string) return id, ok }这种写法把 key 封装在函数内部外部根本拿不到你不 open source 这个包别人就永远访问不到这个 key自然就不会撞车。value 必须是线程安全的。因为 context 会被多个 goroutine 同时读取Value方法本身只读是不加锁的如果 value 是一个可变的 map并发读写就会 panic。再说说争议点。很多人把WithValue滥用成全局依赖注入什么 DB 连接、配置文件全部塞进去这是反模式。context 传值应当仅用于请求生命周期内唯一不变且横跨多个调用层级的数据比如 trace ID、device ID、user ID。凡是能在编译期确定依赖的都应该用参数显式传让类型系统帮你兜底。MySQL 8 驱动和 gRPC 的 metadata 都是典型的 WithValue 用法自己写中间件时会经常用到但业务代码里能不用就不用别让 context 变成另一个全局变量。3. 项目里的 Context 长链路从 HTTP 入口到底层3.1 HTTP Server 层从 r.Context() 开始Go 的net/http包从 1.7 开始就把 context 织进了请求生命周期。每个http.Request自带一个 Context请求结束、连接断开、客户端取消时这个 context 会自动取消。服务端处理器里拿到的就是它func handler(w http.ResponseWriter, r *http.Request) { ctx : r.Context() // 后续所有调用都传 ctx data, err : fetchData(ctx, r.URL.Query().Get(id)) if err ! nil { if errors.Is(err, context.Canceled) { // 客户端已经断开不用写 response 了 return } http.Error(w, internal error, http.StatusInternalServerError) return } _ data }这里有一个很容易被忽略的细节下游函数必须处理context.Canceled和context.DeadlineExceeded的错误。客户端断开时r.Context().Err()会变成context.Canceled超时时则变成context.DeadlineExceeded。不判断就直接http.Error写响应浏览器那边连接已经断了写也白写日志里反而多一堆 misleading error。web 框架里Gin 的c.Request.Context()、Fiber 的c.UserContext()底层拿到的都是这同一个请求级别的 context只是在外层做了封装。有的框架会自动把框架内部的值塞进去比如 Fiber 的实现里你用UserContext得到的是一个包含路由参数的 context所以框架使用者只需要保证拿到 ctx 之后继续向下传这一条主线不错位就行。3.2 中间层跨服务调用与 gRPC 的透传服务端之间的调用context 要跨网络传输就复杂了。以 gRPC 为例grpc.Dial返回的连接对象在建立时如果带上了grpc.WithBlock或超时 context就能在拨号阶段就掐表。每次 RPC 调用时你都要把当前 context 传进去conn, err : grpc.NewClient(service:8080, grpc.WithTransportCredentials(insecure.NewCredentials())) if err ! nil { return err } defer conn.Close() client : pb.NewUserServiceClient(conn) ctx, cancel : context.WithTimeout(baseCtx, 800*time.Millisecond) defer cancel() resp, err : client.GetUser(ctx, pb.GetUserRequest{Id: id})gRPC 内部会把 context 的 deadline 信息编码到 HTTP/2 的 header 里发给服务端服务端解析出来后它会自动设置一个同 deadline 的 context 向下传。这就是 context 在分布式链路里跨进程传播的机制。特别注意context.Value中的数据默认不会跨进程传输如果业务需要传递 trace ID、鉴权信息这类数据需要注册对应的拦截器interceptor或者用 gRPC metadata 显式传递。我踩过的一个真实坑业务代码传了一个 2 秒的 context 去调 gRPC服务端处理很慢1.9 秒时客户端以为必然超时了准备取消并返回错误。但服务端实际在 1.95 秒把响应返回了客户端因为 context 已取消直接丢弃了结果最后日志显示客户端报context canceled服务端却显示成功。这个现象在链路追踪里叫斩断效应排查方向永远是先看哪一层先触发了 deadline而不是单看服务端耗时。3.3 数据处理层数据库、Redis 与文件 IO数据库驱动和 Redis 客户端都原生支持 context。database/sql提供的QueryContext、ExecContext、QueryRowContext都会把 context 的取消和 deadline 透传给驱动层。ctx, cancel : context.WithTimeout(parentCtx, 500*time.Millisecond) defer cancel() rows, err : db.QueryContext(ctx, SELECT ... FROM orders WHERE user_id ?, uid) if err ! nil { if errors.Is(err, context.DeadlineExceeded) { // 查询超时 } return } defer rows.Close()Crucial point很多人都只给 RPC 调用加了超时结果却发现数据库那边也超时了。实际上所有 IO 操作都应该用同一份 context 来控制这样一条链路共用一个 deadline不会出现 RPC 花 1.5 秒、数据库花 1.5 秒、总共花了 3 秒才返回的情况。只要你全程传同一个 ctx每一层的 WithTimeout 都会以上一层的剩余时间为上限重新设定时限链路总耗时永远被最顶层的 deadline 约束。文件 IO 就不太一样了。标准库的os.File操作是不认 context 的它没有任何办法中断一个阻塞在 read 上的系统调用。想让文件读取也能响应取消得自己封一层 goroutine 加 selectfunc readFileWithContext(ctx context.Context, path string) ([]byte, error) { type result struct { data []byte err error } ch : make(chan result, 1) go func() { data, err : os.ReadFile(path) ch - result{data, err} }() select { case -ctx.Done(): return nil, ctx.Err() case r : -ch: return r.data, r.err } }这种写法的问题是被取消的 goroutine 不会真正停止它还在后台啃文件。如果想更彻底得配合带超时的 syscall 或者干脆用golang.org/x/sys里的异步 IO 方案普通项目里上面这种封装够用了至少能做到调用方不再干等。4. 面试高频考点看过这篇才算真懂4.1 八股文高频题目原理与底层实现Go 面试中 context 几乎是必考模块高频问题包括这些Q1context 的 cancel 机制底层是怎么实现的答案是树形结构加 channel 广播。每个 context 在被WithCancel创建时会在父 context 上注册一个child节点调用cancel()时它会执行三件事将自身标记为 canceled、关闭内部的donechannel、遍历所有 child 节点并取消。由于 channel 关闭后读端会立即收到零值所有监听ctx.Done()的 goroutine 都会被唤醒实现广播取消。Q2context.WithCancel时如果不调 cancel会产生什么问题goroutine 泄漏。父 context 的事件循环、计时器、注册表都不会被清理。尤其是在高并发的服务里每次请求都创建 context 但不 cancel会越积越多内存直线上升。Q3context.Context能被比较吗Context 接口类型是不能直接比较的因为它是一个接口底层指向的具体值可能是不可比较的类型。实际使用中更常见的问题是你需要判断两个 context 是否相同标准做法是直接比较接口值ctx1 ctx2但这在某些情况下会 panic所以更好的做法是比较内部值。Q4WithTimeout 和 WithDeadline 有什么区别前文说过WithTimeout 是相对时间内部用time.Now().Add(timeout)转换成 deadlineWithDeadline 是绝对时间点。在 RPC 嵌套调用中子调用使用绝对 deadline 能确保整条链路时间上限一致而使用相对 timeout 会导致误差叠加。Q5这个context deadline exceeded是什么它是context.Context调用Deadline()后超时未完成时Err()方法返回的标准错误值。绝大多数超时错误都源于两个标准错误context.DeadlineExceeded和context.Canceled。判断时要用errors.Is(err, context.DeadlineExceeded)不要直接比较。平时多积累这类问答不是背题真正把它们放到性能调优和故障排查中才有价值。面试官更想看到的是你讲出了源码级原理以及你确实踩过坑并且总结出了应对方案。4.2 深水区追问并发安全、泄漏排查与误用识别【并发安全】context 是并发安全的。多个 goroutine 可以同时调用 ctx.Done() 读取取消信号也可以同时调用 ctx.Value() 读取值内部不加锁也安全因为所有字段都是只读的取消动作靠 channel 关闭实现具备天然的线程安全语义。但要警惕 context 携带的 value 本身不一定是并发安全的比如一个 map 类型的 value两个 goroutine 同时读写这个 map 就会 panic。context 只保证容器安全不保证内容安全value 的线程安全要自己保证。【泄漏排查经验】真实生产里 goroutine 泄漏的排查套路我是这么做的分享一套完整流程访问/debug/pprof/goroutine?debug2导出 goroutine dump 文件。分析 goroutine 栈顶重点找卡在select { case -ctx.Done(): ... }或 channel 等待上的 goroutine。看栈里是谁创建了 context谁在等待取消信号。翻代码检查所有context.WithCancel/WithTimeout的调用点确认cancel()是否一定会被执行到。一次真实案例我们的定时任务框架里每个任务都WithTimeout创建 context但defer cancel()写在go func内层而真正该被取消的必须由外层控制结果任务超时后内层整个 goroutine 直接卡死直到进程重启。用 pprof 看下来goroutine 数量就是一条水平的线往上涨。修正方式是取消职责统一由任务的调度器持有内层只负责监听。【误用识别运维视角的黄金法则】三个红线不要把context.WithValue当依赖注入工具塞 DB、配置、logger 进去。context 的定位是请求级元数据不是全局容器。不要在结构体里存 context 和 cancel 函数。规范写法是所有函数第一个参数都是 context当然现在 Go 新版本引入了context.AfterFunc等但原则不变。不要同时用多个超时 context 叠加控制。链路上每层都WithTimeout会相互影响造成行为不可预期最好由最外层统一设定内层使用context.WithDeadline并用Deadline()动态计算剩余时间。4.3 结合信号量、中间件与取消传播的复合面试题还有一类进阶面试题会结合信号量、中间件这些概念一起考。比如如何用带超时的 context 实现并发限制这就涉及信号量semaphore概念。常见的解法是golang.org/x/sync/semaphore包配合 contextsem : semaphore.NewWeighted(10) func handleRequest(ctx context.Context, id int) error { if err : sem.Acquire(ctx, 1); err ! nil { return err // ctx 超时或被取消 } defer sem.Release(1) // 业务处理 return nil }sem.Acquire(ctx, 1)会阻塞等待权重但一旦 ctx 取消会立刻返回错误不会一直等信号量。这在大流量削峰场景下非常有用请求排队时客户端断开了context 取消acquire 立即返回排队任务被清理掉不会堆积造成死等。再如如何写一个 context 感知的中间件让所有路由共享超时和日志字段以 Fiber 为例// Fiber 中间件统一超时 trace ID 注入 func TimeoutMiddleware(timeout time.Duration) fiber.Handler { return func(c *fiber.Ctx) error { ctx, cancel : context.WithTimeout(c.UserContext(), timeout) defer cancel() // 注入 trace ID ctx context.WithValue(ctx, TraceIDKey, uuid.NewString()) c.SetUserContext(ctx) err : c.Next() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return fiber.ErrRequestTimeout } return err } }这类题目核心考的是你对 context 生命周期、信号量阻塞模型、中间件挂载点三者之间的关系有没有整体认知。很多同学单独讲 context 头头是道一到复合场景就乱了原因就在于没有在代码里实际写过中间件。5. 常见问题与排查技巧实录5.1context canceled还是deadline exceeded傻傻分不清这两个错误在线上日志里非常常见很多人遇到第一反应是把两者混为一谈。实际上它们的产生源头完全不同错误触发方式常见场景context.Canceled有人主动调用 cancel()客户端断开、上游熔断、手动取消context.DeadlineExceeded到达 deadline 自动触发超时控制、RPC 慢调用判断错误时推荐用errors.Is()switch { case errors.Is(err, context.DeadlineExceeded): // 超时逻辑 case errors.Is(err, context.Canceled): // 取消逻辑 }为什么用errors.Is而不是因为业务层经常用fmt.Errorf(wrap: %w, context.DeadlineExceeded)包装错误只能匹配原值errors.Is能沿错误链逐个比较match 到最底层原因。实际排查时如果请求里同时有多个下游调用不可能每个都自己写超时链路里最先触发的那个 deadline 才是根因。但通过错误信息定位到谁先超时非常难因为所有下游都返回同一个context deadline exceeded。建议在每个 RPC 调用点把服务名和 operation 名拼进错误信息里包装一层排查效率会高非常多。5.2 忘记 defer cancel 导致的内存泄漏这是新手最容易犯的问题。每次WithCancel/WithTimeout创建 context 后都会产生一个定时器或注册表对象你不调用 cancel这些对象就一直存在直到父 context 结束。如果父 context 是Background()永远都不会结束泄漏就是永久性的。我在真实项目里见过的一次事故某网关服务每来一个请求就context.WithTimeout但不 cancel高并发跑了 40 分钟后 goroutine 数量从几百涨到 30 万最后直接 OOM 挂掉。看堆内存全是context.timerCtx。预防办法创建 context 后下一行就必须defer cancel()把它当成铁律。code review 时扫所有WithCancel/WithTimeout调用点检查 cancel 是否被调用。写一个简单的单元测试在循环里创建 context 并检查 goroutine 数是否有积压。5.3 在 goroutine 内部协程泄露的场景分析context 取消信号的传播是树状的你在父 goroutine 里cancel()所有从同一个 context 派生出去的子 goroutine 都会收到信号。但这个传播是有前提的子 goroutine 必须真的在监听ctx.Done()。最常见的错误是go func() { time.Sleep(time.Second) fmt.Println(ctx.Value(key)) }()这个 goroutine 在一秒内不会响应 ctx 取消因为time.Sleep无法被打断。如果这里换成真正阻塞的 IO 操作比如http.Get(ctx)或数据库查询那没问题但如果是自定义的耗时逻辑记住一个结论context 只能通知你应该停了不能强制终止 goroutine。在 goroutine 里要响应取消必须用select包一层把ctx.Done()和业务完成事件同时监听。go func() { select { case -ctx.Done(): log.Println(task canceled) return case res : -doWork(): // 正常完成 } }()5.4 context 传值被业务塞得太深的恶性案例有一段时间我们代码里有个万能 ctx函数入口处把*sql.DB、*redis.Client、配置文件全塞进 context下游直接用ctx.Value(dbKey)拿出来。后来加代码的人越来越多根本说不清谁能读到什么有些 key 的 value 类型被悄悄改了老代码一运行就 panic查了整整两天才发现是类型断言失败。这个教训特别痛。context 传值一旦被当成全局容器项目后期基本失控。现在的规范是只传不可变且具有请求唯一性的数据如 trace ID、租户 ID、user ID。所有 context 读值都封装成公开函数外部不要直接接触 key 和 value。依赖项必须走构造函数或参数注入禁止走 context。如果你维护的项目里已经烂成一锅粥建议先把所有ctx.Value()调用点列出来逐个改成显式参数传递。这是一次性重构但收益是长期可维护性。5.5 框架对 context 的额外改造Fiber 与 Gin 的行为差异Gin 的c.Request.Context()是标准的*http.Request自带的 context相对干净。Fiber 的c.UserContext()则是框架自己造的 context内部路由参数都挂在上面如果你直接把 Fiber 的 context 传给纯 Go 的 HTTP 客户端顺手再 Downstream 传 gRPC可能会导致一些意外的 field 不存在而 panic。Fiber 官方文档也提醒过需要把UserContext和标准库 context 结合使用时最好先在入口处把业务需要的 value 拷贝到新的 context不要直接操作框架的 context。之前遇到一个线上 bugFiber 服务里使用了一个数据库操作库它内部对 context 做了Value断言结果传进去的 Fiber context 里存的参数类型不符合库的预期直接 panic。解决办法是创建一个独立的业务 contextctx : context.WithValue(c.UserContext(), TraceIDKey, tracer.GetTraceID())这样业务代码和框架的 context 解耦后续再替换框架也不会影响业务层。6. Context 的性能成本与深度优化方向6.1 context 的创建开销有多大需要关心吗很多同学会问每个请求都派生 context性能上扛得住吗实测下来context.WithCancel的创建成本是非常低的单次在几十纳秒到一两百纳秒级别一次 HTTP 请求创建三五个 context 也就是几百纳秒对于绝大多数业务系统毫无压力。真正的问题不在创建而在取消链路的扩展。如果你的服务里一个请求会拆分出上万个 goroutine每个 goroutine 都从同一个 context 派生取消信号广播时要遍历所有子节点这个成本就会放大。官方在 1.21 版本之后做了一些优化但遇到真正极大规模的扇出场景还是建议考虑用context.AfterFunc这种更轻量的取消注册机制。stop : context.AfterFunc(ctx, func() { // 在这里做清理工作比如关连接、删临时文件 })AfterFunc会在 ctx 被取消时异步执行传入的函数它不占用 child 节点表避免了取消广播时遍历大量子节点的问题适合大扇出小回调场景。6.2 如何把 context 用在并发编排中实现优雅退出系统优雅退出是 context 的高阶用法。服务收到 SIGTERM 信号时希望先把正在处理的请求全部完成或快速失败而不是直接杀掉进程。我的做法是服务里维护一个根 context// 全局根 context var rootCtx context.Context var rootCancel context.CancelFunc func main() { rootCtx, rootCancel context.WithCancel(context.Background()) go handleSignal(rootCancel) server : http.Server{ Addr: :8080, Handler: router, } go server.ListenAndServe() -rootCtx.Done() shutdownCtx, cancel : context.WithTimeout(context.Background(), 10*time.Second) defer cancel() server.Shutdown(shutdownCtx) }当进程收到退出信号rootCancel触发所有依赖 rootCtx 的业务请求都会收到取消信号HTTP server 开始优雅关机等待 10 秒内把存量请求处理完处理不完的直接掐掉。这种设计在容器编排环境下特别重要因为容器滚动更新时旧实例收到的 SIGTERM 信号如果不处理就会直接强制 kill用户请求大面积中断。6.3 错误处理如何把 context 错误透传给上层框架最后再讲一个很多人困惑的点context deadline exceeded为什么会出现在日志里原因是你的框架比如 Gin会把错误原封不动地返回出去。但真正应该展示给调用方的是定制的业务错误码和信息所以务必要在抽象层做一个转换func mapContextErr(err error) error { switch { case errors.Is(err, context.Canceled): return fmt.Errorf(请求已取消: %w, err) case errors.Is(err, context.DeadlineExceeded): return fmt.Errorf(处理超时: %w, err) default: return err } }这样日志里能直接看到处理超时关键字业务层再配合 trace ID排查路径会短很多。我个人在实际操作中的体会是context 这套东西读源码比看十篇文章都有用。你把cancelCtx、timerCtx、valueCtx这三个实现各读一遍很多面试题自然就有答案了。你写任何 RPC、HTTP、数据库调用的代码时都该条件反射式地先问一句context 传了吗cancel 掉了吗超时设了吗这三点做到位线上事故至少能少一半。
