跳到主要内容

ThreadLocal

threading.local() 提供一个“每个线程各有一份”的对象。不同线程使用同一个 local 实例时,看到的属性值彼此隔离;在同一线程内,它又像普通对象一样读写。

它适合保存线程执行期间的上下文,例如当前请求的用户、数据库连接或日志追踪 ID。它解决的是数据可见范围问题,不是线程同步工具,也不能让多个线程共享最新状态。

基本用法​

import threading

current = threading.local()


def worker(name: str) -> None:
current.user = name
print(threading.current_thread().name, current.user)


threads = [
threading.Thread(target=worker, args=("小林",)),
threading.Thread(target=worker, args=("小周",)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

虽然两个线程访问的是同一个 current,但每个线程都有独立的 current.user。一个线程写入属性不会覆盖另一个线程的值。

如果当前线程没有设置某个属性,读取时会得到 AttributeError。可以用 getattr() 提供默认值:

request_id = getattr(current, "request_id", None)

它是如何工作的​

可以把 local 想成一个按线程编号分组的字典:

local 对象
├── 线程 A -> {user: "小林"}
└── 线程 B -> {user: "小周"}

属性查找会根据“当前正在执行的线程”选择对应字典。threading.local 隐藏了线程识别和存储细节,因此调用方不需要手动维护 dict[thread_id],也不容易因为线程结束或编号复用而清理错误。

在线程池中使用要特别小心​

线程池会复用工作线程。线程结束时局部数据通常不会自动按业务请求清空,因此上一次任务留下的值可能被下一次任务看到:

from concurrent.futures import ThreadPoolExecutor
import threading

context = threading.local()


def handle(request_id: str | None) -> None:
if request_id is not None:
context.request_id = request_id
print("当前请求:", getattr(context, "request_id", None))


with ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(handle, "request-1").result()
executor.submit(handle, None).result() # 仍可能看到 request-1

更稳妥的做法是在任务开始时完整初始化,在 finally 中清理:

def handle(request_id: str) -> None:
context.request_id = request_id
try:
process_request()
finally:
context.__dict__.pop("request_id", None)

也可以把上下文作为显式参数传给业务函数。显式参数更容易测试,也不会依赖调用发生在哪个线程。

适合保存什么​

适合:

  • 当前请求 ID、租户 ID、认证用户等短生命周期上下文;
  • 线程安全的日志过滤器所需的字段;
  • 明确绑定到一个工作线程的资源句柄。

不适合:

  • 需要在线程之间共享的计数器或缓存;
  • 应跨线程、跨进程持久化的数据;
  • 需要在 asyncio 协程之间隔离的上下文。

进程也有独立的地址空间,因此 ThreadLocal 不会跨进程共享数据。多进程程序中每个进程还会拥有自己的一套线程局部存储。

与 contextvars 的区别​

threading.local 以线程为隔离单位。一个线程中运行多个协程时,协程会交错执行,线程局部值可能被其他协程覆盖。异步代码应使用 contextvars.ContextVar,它以执行上下文为隔离单位:

import asyncio
from contextvars import ContextVar

request_id: ContextVar[str | None] = ContextVar("request_id", default=None)


async def handle(value: str) -> None:
token = request_id.set(value)
try:
await asyncio.sleep(0)
print(request_id.get())
finally:
request_id.reset(token)


async def main() -> None:
await asyncio.gather(handle("A"), handle("B"))


asyncio.run(main())

实际项目中应根据并发模型选择工具:同步线程代码使用 threading.local,异步协程代码使用 ContextVar,跨边界时优先显式传参。

常见误解​

  1. ThreadLocal 不是锁。 它让每个线程拥有自己的副本,不能保护共享对象。
  2. ThreadLocal 不是全局变量。 它虽然可以从多个位置访问,但每个线程看到的内容不同。
  3. 局部数据不是自动清理的业务状态。 在线程池中必须明确初始化和清理。
  4. 它不传递给新线程。 新线程需要自己设置值;如果要传递上下文,应显式封装任务参数或使用适合的上下文传播机制。

设计建议​

  • 把局部上下文的设置和清理封装在上下文管理器或统一入口中,避免遗漏 finally。
  • 不要在底层函数中悄悄创建大量局部字段;集中定义字段含义和生命周期。
  • 单元测试应在每个用例前后清理局部状态,避免同一个测试线程中的状态泄漏。
  • 优先把核心业务函数写成显式接收参数的纯逻辑,把 ThreadLocal 限制在 Web 框架、日志或连接管理等边界层。

ThreadLocal 的价值是让同一调用链中的多层函数方便地取得线程上下文;代价是依赖关系变得隐式。只在上下文确实贯穿很多层、显式传参明显增加噪声时使用它。