bigywater.tech
微服务 · 62

上下文传播:跨线程、跨协程、跨消息队列,异步调用用 span link

线程池、协程、消息队列里 span 的父子关系怎么保持?哪些 Python 写法会悄悄丢掉上下文?消息晚消费了,span 还连得上吗?一次处理多条消息的 span 该用 link 还是 parent?这篇用 OpenTelemetry Python SDK 和 RabbitMQ 逐项试出来。

2 种 / 4 种
线程里创建子 span 的四种做法里,会丢掉上下文的有几种(threading.Thread、ThreadPoolExecutor)
1014 ms
消息带 traceparent 时,消费者 span 比生产者 span 结束时间晚开始的时长,仍然是生产者 span 的子 span
3 个 link
批量处理 3 条来自不同 trace 的消息时,批量 span 的 link 个数(它自己在新 trace 里)

跨线程

实测 · 本机执行输出
  做法                               | 子 span 的归属           
  ---------------------------------+----------------------
  (a)threading.Thread              | 另起新 trace(根 span)    
  (b)ThreadPoolExecutor.submit     | 另起新 trace(根 span)    
  (c)submit(copy_context().run, f) | 同一个 trace,父 span 是调用方
  (d)线程里 attach 保存的上下文             | 同一个 trace,父 span 是调用方
  新线程的 contextvars 是空的:线程池和 threading.Thread 都不会把调用方的上下文带过去,子 span 就成了新 trace 的根;要显式带上(copy_context().run 或者 attach)

新线程的 contextvars 是空的,线程池不带调用方的上下文;要用 copy_context().run 或 attach。

跨协程

实测 · 本机执行输出
  做法                                   | 子 span 的归属           
  -------------------------------------+----------------------
  asyncio.create_task                  | 同一个 trace,父 span 是调用方
  asyncio.gather(两个协程)                 | 同一个 trace,父 span 是调用方
  loop.run_in_executor                 | 另起新 trace(根 span)    
  run_in_executor + copy_context().run | 同一个 trace,父 span 是调用方
  asyncio.to_thread                    | 同一个 trace,父 span 是调用方
  loop.call_soon                       | 同一个 trace,父 span 是调用方
  另一个线程里 call_soon_threadsafe          | 另起新 trace(根 span)    
  事件循环线程里 call_soon_threadsafe         | 同一个 trace,父 span 是调用方
  协程之间(create_task、gather)和 to_thread 会复制调用方的上下文;run_in_executor 不会;call_soon 取决于调用时所在线程的上下文,从另一个线程调用拿到的是那个线程自己的(空)上下文

create_task、gather、to_thread 会复制上下文;run_in_executor 和别的线程里的 call_soon_threadsafe 不会。

跨消息队列

实测 · 本机执行输出
  情形                       | 结果                                                              | 备注                                                       
  -------------------------+-----------------------------------------------------------------+----------------------------------------------------------
  (a)消息头不带 traceparent     | 消费者 span 是新 trace 的根                                            | -                                                        
  (b)带 traceparent,1 s 后消费 | 消费者 span 是生产者 span 的子 span(同一个 trace)                           | 生产者 span 在 0.09 ms 时结束,消费者 span 在它结束之后 1014 ms 才开始       
  (c)3 条消息批量处理             | 批量 span 自己是新 trace 的根(parent=无),带 3 个 link,各自指向对应的生产者 span:True | 3 个生产者的 trace_id 互不相同:True;批量 span 的 trace_id 与它们都不同:True
  parent-child 表示“这个 span 是那个 span 的一部分”;消息被延迟消费、或者一个 span 同时处理多个来源时,用 link 只记录“因果相关”,不放进同一棵树里
ERRORS {}

消息头带 traceparent 就能让消费者 span 成为生产者的子 span,不管晚了多久;一次处理多条消息用 link。

分步演示

上下文传播
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。OpenTelemetry Python SDK 1.45.1,Python 3.14(contextvars 的行为是这个版本的);RabbitMQ 是自己起的单节点(pika 1.4.4);每种做法各测 1 次;“另起新 trace”是用内存导出器里 span 的 trace_id 与父 span 对比得出的
    • 没有验证:Python 其他版本里 asyncio 和线程池的上下文行为(例如 3.14 的 free-threaded 构建、concurrent.futures 的后续版本);自动插桩库对线程池和 pika 的封装;Kafka、RocketMQ 等其他消息系统的头;Java 的 ThreadLocal / 虚拟线程、Go 的 context 显式传递;消息批量消费时 link 在各个追踪后端里的展示方式
    京ICP备2026067502号-1京公网安备11010502063487号