如何使用contextvars模块和源码分析
前记
在Python3.7后官方库出现了contextvars
模块, 它的主要功能就是可以为多线程以及asyncio生态添加上下文功能,即使程序在多个协程并发运行的情况下,也能调用到程序的上下文变量, 从而使我们的逻辑解耦.
上下文,可以理解为我们说话的语境, 在聊天的过程中, 有些话脱离了特定的语境,他的意思就变了,程序的运行也是如此.在线程中也是有他的上下文,只不过称为堆栈,如在python中就是保存在thread.local变量中,而协程也有他自己的上下文,但是没有暴露出来,不过有了contextvars
模块后我们可以通过contextvars
模块去保存与读取.
使用contextvars
的好处不仅可以防止’一个变量传遍天’的事情发生外,还能很好的结合TypeHint,可以让自己的代码可以被mypy以及IDE检查,让自己的代码更加适应工程化.
不过用了contextvars
后会多了一些隐性的调用, 需要解决好这些隐性的成本.
更新说明
- 切换web框架
sanic
为starlette
- 增加一个自己编写且可用于
starlette
,fastapi
的context说明 - 更新fast_tools.context的最新示例以及简单的修改行文。
1.有无上下文传变量的区别
如果有用过Flask
框架, 就知道了Flask
拥有自己的上下文功能, 而contextvars跟它很像, 而且还增加了对asyncio的上下文提供支持。Flask
的上下文是基于threading.local
实现的, threading.local
的隔离效果很好,但是他是只针对线程的,只隔离线程之间的数据状态, 而werkzeug
为了支持在gevent
中运行,自己实现了一个Local
变量, 常用的Flask
上下文变量request
的例子如下:
1 |
|
拓展阅读:
Flask
的上下文是怎么实现
与之相比的是Python
的另一个经典Web框架Djano
, 它没有上下文的支持, 所以只能显示的传request
对象, 例子如下:
1 |
|
通过上面两者的对比可以发现, 在Django
中,我们需要显示的传一个叫request的变量,而Flask
则是import一个叫request的全局变量,并在视图中直接使用,达到解耦的目的.
可能会有人说, 也就是传个变量的区别,为了省传这个变量,而花许多功夫去维护一个上下文变量,有点不值得,那可以看看下面的例子,如果层次多就会出现’一个参数传一天’的情况(不过分层做的好或者需求不坑爹一般不会出现像下面的情况,一个好的程序员能做好代码的分层, 但可能也有出现一堆烂需求的时候)
1 |
|
此外, 除了防止一个参数传一天
这个问题外, 通过上下文, 可以进行一些解耦, 比如有一个最经典的技术业务需求就是在日志打印request_id, 从而方便链路排查, 这时候如果有上下文模块, 就可以把读写request_id给解耦出来, 比如下面这个基于Flask
框架读写request_id的例子:
1 |
|
2.如何使用contextvars模块
这里举了一个例子, 但这个例子也有别的解决方案. 只不过通过这个例子顺便说如何使用contextvar模块
首先看看未使用contextvars
时,asyncio的web框架是如何传变量的,根据starlette
的文档,在未使用contextvars
时,传递Redis
客户端实例的办法是通过request.stat这个变量保存Redis
客户端的实例,改写代码如下:
1 |
|
代码非常简便, 也可以正常的运行, 但你下次在重构时, 比如简单的把redis这个变量名改为new_redis, 那IDE不会识别出来, 需要一个一个改。 同时, 在写代码的时候, IDE永远不知道这个方法调用到的变量的类型是什么, IDE也无法智能的帮你检查(如输入request.stat.redis.时,IDE不会出现execute,或者出错时,IDE并不会提示). 这非常不利于项目的工程化, 而通过contextvars
和TypeHints
, 恰好能解决这个问题.
说了那么多, 下面以一个Redis
client为例子,展示如何在asyncio生态中使用contextvars
, 并引入TypeHints
(详细解释见代码).
1 |
|
3.如何优雅的使用contextvars
从上面的示例代码来看, 使用contextvar
和TypeHint
确实能让让IDE可以识别到这个变量是什么了, 但增加的代码太多了,更恐怖的是, 每多一个变量,就需要自己去写一个context,一个变量的初始化,一个变量的get函数,同时在引用时使用函数会比较别扭.
自己在使用了contextvars
一段时间后,觉得这样太麻烦了,每次都要做一堆重复的操作,且平时使用最多的就是把一个实例或者提炼出Headers的参数放入contextvars中,所以写了一个封装fast_tools.context(同时兼容fastapi
和starlette
), 它能屏蔽所有与contextvars的相关逻辑,其中由ContextModel负责contextvars的set和get操作,ContextMiddleware管理contextvars的周期,HeaderHeader负责托管Headers相关的参数, 调用者只需要在ContextModel中写入自己需要的变量,引用时调用ContextModel的属性即可.
以下是调用者的代码示例, 这里的实例化变量由一个http client代替, 且都会每次请求分配一个客户端实例, 但在实际使用中并不会为每一个请求都分配一个客户端实例, 很影响性能:
1 |
|
可以从例子中看到, 通过封装的上下文调用会变得非常愉快, 只要通过一两步方法就能设置好自己的上下文属性, 同时不用考虑如何编写上下文的生命周期. 另外也能通过这个例子看出, 在asyncio生态中, contextvars能运用到包括子协程, 多线程等所有的场景中.
4.contextvars的原理
在第一次使用时,我就很好奇contextvars是如何去维护程序的上下文的,好在contextvars的作者出了一个向下兼容的contextvars库,虽然他不支持asyncio,但我们还是可以通过代码了解到他的基本原理.
4.1 ContextMeta,ContextVarMeta和TokenMeta
代码仓中有ContextMeta
,ContextVarMeta
和TokenMeta
这几个对象, 它们的功能都是防止用户来继承Context
,ContextVar
和Token
,原理都是通过元类来判断类名是否是自己编写类的名称,如果不是则抛错.
1 |
|
4.2 Token
上下文的本质是一个堆栈, 每次set一次对象就向堆栈增加一层数据, 每次reset就是pop掉最上层的数据, 而在Contextvars
中, 通过Token
对象来维护堆栈之间的交互.
1 |
|
可以看到Token
的代码很少, 它只保存当前的context
变量, 本次调用set的数据和上一次被set的旧数据. 用户只有在调用contextvar.context
后才能得到Token
, 返回的Token
可以被用户在调用context后, 通过调用context.reset(token)来清空保存的上下文,方便本次context的变量能及时的被回收, 回到上上次的数据.
4.3 全局唯一context
前面说过, Python中由threading.local()
负责每个线程的context, 协程属于线程的’子集’,所以contextvar直接基于threading.local()
生成自己的全局context. 从他的源代码可以看到, _state
就是threading.local()
的引用, 并通过设置和读取_state
的context
属性来写入和读取当前的上下文, copy_context
调用也很简单, 同样也是调用到threading.local()
API.
1 |
|
关于threading.local()
,虽然不是本文重点,但由于contextvars
是基于threading.local()
进行封装的,所以还是要明白threading.local()
的原理,这里并不直接通过源码分析, 而是做一个简单的示例解释.
在一个线程里面使用线程的局部变量会比直接使用全局变量的性能好,因为局部变量只有线程自己能看见,不会影响其他线程,而全局变量的修改必须加锁, 性能会变得很差, 比如下面全局变量的例子:
1 |
|
这份代码就是模仿一个简单的全局变量调用, 如果是多线程调用的话, 那就需要加锁啦, 每次在读写之前都要等到持有锁的线程放弃了锁后再去竞争, 而且还可能污染到了别的线程存放的数据.
而线程的局部变量则是让每个线程有一个自己的pet_dict
, 假设每个线程调用get_pet
,set_pet
时,都会把自己的pid传入进来, 那么就可以避免多个线程去同时竞争资源, 同时也不会污染到别的线程的数据, 那么代码可以改为这样子:
1 |
|
不过这样子使用起来非常方便, 同时示例例子没有对异常检查和初始化等处理, 如果值比较复杂, 我们还要维护异常状况, 这样太麻烦了.
这时候threading.local()
就应运而生了,他负责帮我们处理这些维护的工作,我们只要对他进行一些调用即可,调用起来跟单线程调用一样简单方便, 应用threading.local()
后的代码如下:
1 |
|
可以看到代码就像调用全局变量一样, 但是又不会产生竞争状态。
4.4contextvar自己封装的Context
contextvars
自己封装的Context比较简单, 这里只展示他的两个核心方法(其他的魔术方法就像dict
的魔术方法一样):
1 |
|
首先, 在__init__
方法可以看到self._data,这里使用到了一个叫immutables.Map()的不可变对象,并对immutables.Map()进行一些封装,所以context可以看成一个不可变的dict。这样可以防止调用copy方法后得到的上下文的变动会影响到了原本的上下文变量。
查看immutables.Map()的示例代码可以看到,每次对原对象的修改时,原对象并不会发生改变,并会返回一个已经发生改变的新对象.
1 |
|
此外,context还有一个叫run
的方法, 上面在执行loop.run_in_executor
时就用过run
方法, 目的就是可以产生一个新的上下文变量给另外一个线程使用, 同时这个新的上下文变量跟原来的上下文变量是一致的.
执行run的时候,可以看出会copy一个新的上下文来调用传入的函数, 由于immutables.Map
的存在, 函数中对上下文的修改并不会影响旧的上下文变量, 达到进程复制数据时的写时复制的目的. 在run
方法的最后, 函数执行完了会再次set旧的上下文, 从而完成一次上下文切换.
1 |
|
4.5 ContextVar
我们一般在使用contextvars模块时,经常使用的就是ContextVar
这个类了,这个类很简单,主要提供了set–设置值,get–获取值,reset–重置值三个方法, 从Context
类中写入和获取值, 而set和reset的就是通过上面的token类进行交互的.
- set – 为当前上下文设置变量
1
2
3
4
5
6
7
8
9
10
11def set(self, value):
ctx = _get_context() # 获取当前上下文对象`Context`
data = ctx._data
try:
old_value = data[self] # 获取Context旧对象
except KeyError:
old_value = Token.MISSING # 获取不到则填充一个object(全局唯一)
updated_data = data.set(self, value) # 设置新的值
ctx._data = updated_data
return Token(ctx, self, old_value) # 返回带有旧值的token - get – 从当前上下文获取变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14def get(self, default=_NO_DEFAULT):
ctx = _get_context() # 获取当前上下文对象`Context`
try:
return ctx[self] # 返回获取的值
except KeyError:
pass
if default is not _NO_DEFAULT:
return default # 返回调用get时设置的值
if self._default is not _NO_DEFAULT:
return self._default # 返回初始化context时设置的默认值
raise LookupError # 都没有则会抛错 - reset – 清理本次用到的上下文数据 则此,contextvar的原理了解完了,接下来再看看他是如何在asyncio运行的.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24def reset(self, token):
if token._used:
# 判断token是否已经被使用
raise RuntimeError("Token has already been used once")
if token._var is not self:
# 判断token是否是当前contextvar返回的
raise ValueError(
"Token was created by a different ContextVar")
if token._context is not _get_context():
# 判断token的上下文是否跟contextvar上下文一致
raise ValueError(
"Token was created in a different Context")
ctx = token._context
if token._old_value is Token.MISSING:
# 如果没有旧值则删除该值
ctx._data = ctx._data.delete(token._var)
else:
# 有旧值则当前contextvar变为旧值
ctx._data = ctx._data.set(token._var, token._old_value)
token._used = True # 设置flag,标记token已经被使用了
5.contextvars asyncio
由于向下兼容的contextvars
并不支持asyncio, 所以这里通过aiotask-context的源码简要的了解如何在asyncio中如何获取和设置context。
5.1在asyncio中获取context
相比起contextvars复杂的概念,在asyncio中,我们可以很简单的获取到当前协程的task, 然后通过task就可以很方便的获取到task的context了,由于Pyhon3.7对asyncio的高级API 重新设计,所以可以看到需要对获取当前task进行封装
1 |
|
不同的版本有不同的获取task方法, 之后我们就可以通过调用asyncio_current_task().context
即可获取到当前的上下文了…
5.2 对上下文的操作
同样的,在得到上下文后, 我们这里也需要set, get, reset的操作,不过十分简单, 类似dict一样的操作即可, 它没有token的逻辑:
- set
1
2
3
4
5
6
7
8
9
10
11
12def set(key, value):
"""
Sets the given value inside Task.context[key]. If the key does not exist it creates it.
:param key: identifier for accessing the context dict.
:param value: value to store inside context[key].
:raises
"""
current_task = asyncio_current_task()
if not current_task:
raise ValueError(NO_LOOP_EXCEPTION_MSG.format(key))
current_task.context[key] = value - get
1
2
3
4
5
6
7
8
9
10
11
12
13def get(key, default=None):
"""
Retrieves the value stored in key from the Task.context dict. If key does not exist,
or there is no event loop running, default will be returned
:param key: identifier for accessing the context dict.
:param default: None by default, returned in case key is not found.
:return: Value stored inside the dict[key].
"""
current_task = asyncio_current_task()
if not current_task:
raise ValueError(NO_LOOP_EXCEPTION_MSG.format(key))
return current_task.context.get(key, default) - clear – 也就是
contextvar.ContextVars
中的reset1
2
3
4
5
6
7
8
9
10def clear():
"""
Clear the Task.context.
:raises ValueError: if no current task.
"""
current_task = asyncio_current_task()
if not current_task:
raise ValueError("No event loop found")
current_task.context.clear()
5.2 copying_task_factory和chainmap_task_factory
在Python的更高级版本中,已经支持设置context了,所以这两个方法可以不再使用了.他们最后都用到了task_factory
的方法.task_factory
简单说就是创建一个新的task,再通过工厂方法合成context,最后把context设置到task
1 |
|
aiotask-context
提供了两个对context处理的函数dict_context_factory
和chainmap_context_factory
.在aiotask-context
中,context是一个dict对象,dict_context_factory
可以选择赋值或者设置新的context
1 |
|
chainmap_context_factory
与dict_context_factory
的区别就是在合并context而不是直接继承.同时借用ChainMap
保证合并context后,还能同步context的改变
1 |
|
至此, asyncio中context的调用就简单的分析完了, 如果想要深入的了解asyncio是怎么传上下文的, 可以查看asyncio都源码.
6.总结
contextvars本身原理很简单,但他可以让我们调用起来更加方便便捷,减少我们的传参次数,同时还可以结合TypeHint使项目更加工成化, 但是还是仁者见仁. 不过在使用时最好能加上一层封装, 最好的实践应该是一个协程共享同一个context而不是每个变量一个context.
- 本文作者:So1n
- 本文链接:http://so1n.me/2019/06/13/contextvars%E6%A8%A1%E5%9D%97/index.html
- 版权声明:本博客所有文章均采用 BY-NC-SA 许可协议,转载请注明出处!