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,跨边界时优先显式传参。
常见误解
- ThreadLocal 不是锁。 它让每个线程拥有自己的副本,不能保护共享对象。
- ThreadLocal 不是全局变量。 它虽然可以从多个位置访问,但每个线程看到的内容不同。
- 局部数据不是自动清理的业务状态。 在线程池中必须明确初始化和清理。
- 它不传递给新线程。 新线程需要自己设置值;如果要传递上下文,应显式封装任务参数或使用适合的上下文传播机制。
设计建议
- 把局部上下文的设置和清理封装在上下文管理器或统一入口中,避免遗漏
finally。 - 不要在底层函数中悄悄创建大量局部字段;集中定义字段含义和生命周期。
- 单元测试应在每个用例前后清理局部状态,避免同一个测试线程中的状态泄漏。
- 优先把核心业务函数写成显式接收参数的纯逻辑,把
ThreadLocal限制在 Web 框架、日志或连接管理等边界层。
ThreadLocal 的价值是让同一调用链中的多层函数方便地取得线程上下文;代价是依赖关系变得隐式。只在上下文确实贯穿很多层、显式传参明显增加噪声时使用它。