contextvars模块到底是做什么的?
/ / / 阅读数:13389在 Python 3.7 加入了一个新的模块 contextvars,模块标题说的也非常直接:Context Variables,也就是「上下文变量」。
什么是上下文 (Context)?
Context 是一个包含了相关环境内容的对象。这不是什么很高深的设计,其实和我们的日常生活也是息息相关的。
举个比较实时的例子,权力的游戏第八季刚开播,如果你没看过前七季,不了解过去的剧情、人物关系、过去的种种主线副线发展,去看第八季第一集是完全看不懂的,因为你缺失了这个美剧的上下文。
上下文就带着这些信息,如果有一人非常了解过去的那些剧情甚至看过原著,Ta 可以把那些第八季能关联到的故事、剧情搞一个视频剪辑(上下文对象),那么你不需要把过去完整的七季完整看一遍,可能花一个小时看看这个视频(获得上下文对象),就能继续看第八季(完成之后的操作)。
Flask 的设计中就包含了 Context (下面不再说上下文,而统一用 Context)。这个设计有什么用呢?简单地说:可以在一些场景下隐式地传递变量
我们看一下 Django 和 Sanic 怎么传递请求对象 Request:
# Django from django.http import HttpResponse def index(request): text = request.GET.get('text') return HttpResponse(f'Text is {text}') # Sanic from sanic import response app = Sanic() @app.route('/') async def index(request): text = request.args.get('text') return response.text(f'Text is {text}') |
这 2 个框架都有一个问题:视图函数上要显式的传递 request(请求对象)。我们再看看 Flask 的效果:
from flask import Flask, request app = Flask(__name__) @app.route('/') def index(): text = request.args.get('text') return f'Text is {text}' |
在 Flask 中,request 是 import 进来使用的(不需要就不用 import),和视图解耦了。这种设计下,不需要像 Django/Sanic 那样把参数传来传去。
ThreadLocal
Flask 怎么实现的呢?这就引出了 ThreadLocal(本地线程)对象,看名字可以知道它是线程安全的,是单个线程自己的局部变量。Flask 的实现中并没有直接用 Python 的 ThreadLocal,而是自己实现了一个 Local 类,除了支持线程还支持了 Greenlet 的协程。
Q: 那为什么不用全局变量呢? A: 由于存在 GIL,全局变量的修改必须加锁,会影响效率
先看一下线程库中 ThreadLocal 的例子:
❯ cat threadlocal_example.py import random import threading local_data = threading.local() def show(): name = threading.current_thread().getName() try: val = local_data.value except AttributeError: print(f'Thread {name}: No value yet') else: print(f'Thread {name}: {val}') def worker(): show() local_data.value = random.randint(1, 100) show() for i in range(2): t = threading.Thread(target=worker) t.start() ❯ python threadlocal_example.py Thread Thread-1: No value yet Thread Thread-1: 78 Thread Thread-2: No value yet Thread Thread-2: 64 |
可以感受到 2 个线程的状态互不影响。回到 Flask,请求 Context 在内部作为一个栈来维护(应用 Context 在另外一个栈)。每个访问 Flask 的请求,会绑定到当前的 Context,等请求结束后再销毁。维护的过程由框架实现,开发者不需要关心,你只需要用 flask.request 就可以了,这样就提高了接口的可读性和扩展性。
contextvars 例子
threading.local 的隔离效果很好,但是他是针对线程的,隔离线程之间的数据状态。但是现在有了 asyncio,怎么办?
biu~ 我们回到 contextvars,这个模块提供了一组接口,可用于管理、储存、访问局部 Context 的状态。我们看个例子:
❯ cat contextvar_example.py import asyncio import contextvars # 申明Context变量 request_id = contextvars.ContextVar('Id of request') async def get(): # Get Value print(f'Request ID (Inner): {request_id.get()}') async def new_coro(req_id): # Set Value request_id.set(req_id) await get() print(f'Request ID (Outer): {request_id.get()}') async def main(): tasks = [] for req_id in range(1, 5): tasks.append(asyncio.create_task(new_coro(req_id))) await asyncio.gather(*tasks) asyncio.run(main()) ❯ python contextvar_example.py Request ID (Inner): 1 Request ID (Outer): 1 Request ID (Inner): 2 Request ID (Outer): 2 Request ID (Inner): 3 Request ID (Outer): 3 Request ID (Inner): 4 Request ID (Outer): 4 |
可以看到在数据状态协程之间互不影响。注意上面 contextvars.ContextVar 的传入的第一个参数 (name) 值是一个字符串,它主要是用来标识和调试的,并不一定要用一个单词或者用下划线连起来。
注意,这个模块不仅仅给 aio 加入 Context 的支持,也用来替代 threading.local ()
。
在 Python 3.6 使用 contextvars
contextvars 实现了 PEP 567, 如果在 Python3.6 想使用可以用 MagicStack/contextvars 这个向后移植库,它和标准库都是同一个作者写的,可以放心使用。用之前你需要安装它:
pip install contextvars |
aiotask_context
在 Sanic 里面 request 确实没有用 Context,那在 aio 体系里面怎么用呢?原来我会使用一个独立的库aiotask_context
,在我的技术博客项目中就有用到,我简化一下这部分的代码 (延伸阅读 3 的 commit):
# ext.py import aiotask_context as context # noqa # app.py from ext import context client = None @app.listener('before_server_start') async def setup_db(app, loop): global client client = aiomcache.Client(config.MEMCACHED_HOST, config.MEMCACHED_PORT, loop=loop) loop.set_task_factory(context.task_factory) @app.middleware('request') async def setup_context(request): context.set('memcache', client) # models/mc.py _memcache = None async def get_memcache(): global _memcache if _memcache is not None: return _memcache memcache = context.get('memcache') _memcache = memcache return memcache |
按执行过程,我解释一下:
- app.py 默认 client 是 None,在
before_server_start
中会设置初始化一个 aiomcache.Client,用 global 设置给 client - 每次请求,通过
context.set ('memcache', client)
把 client 设置到 Context 里面 - 在实际业务中,直接用
context.get ('memcache')
获取这个 client。整个逻辑中见不到 client 传来传去,也不需要给 request 设置额外的属性
有一点要提,在 Python 3.6, context 接受的参数必须是 ContextVar 对象,要这么写:
if PY36: import contextvars memcache_var = contextvars.ContextVar('memcache') else: memcache_var = 'memcache' try: memcache = context.get(memcache_var) except AttributeError: # Hack for debug mode memcache = None |
这里捕获了 AttributeError,主要是在 ipython 中调试,由于没有启动 Sanic 所以没有设置上下文,所以需要异常处理一下。
contextvars 的真实例子
接着替换成 contextvars (延伸阅读链接 4 的 commit):
# models/var.py
import contextvars
memcache_var = contextvars.ContextVar('memcache')
# app.py
from models.var import memcache_var
client = None
@app.listener('before_server_start')
async def setup_db(app, loop):
global client
client = aiomcache.Client(config.MEMCACHED_HOST, config.MEMCACHED_PORT, loop=loop)
@app.middleware('request')
async def setup_context(request):
memcache_var.set(client)
# models/mc.py
from models.var import memcache_var
_memcache = None
async def get_memcache():
global _memcache
if _memcache is not None:
return _memcache
memcache = memcache_var.get()
_memcache = memcache
return memcache
在这种模式下,memcache (Redis) 等实例对象不需要放在 request 对象里面,也不需要传来传去,而是放在一个上下文中,需要时直接通过memcache_var.get()
就可以拿到,继而操作缓存了。
你学到了嘛?
好的,我们最近也发现了这个问题,感谢您的回复~欢迎来瞅瞅我们搞的 糖果课程表 微信小程序呀~