缓存在实际开发工作中的应用是非常常见的,比如内存缓存、redis缓存、本地文件缓存等,其主要的目的是方便数据的下一次访问,减少应用程序重复计算的次数和资源消耗,缓存是一种典型的空间换时间的方案。那么我们就来看看在开发Python程序时,functools提供的 cache 装饰器是如何工作的。
从一个小例子开始
首先我们看一下阶乘的实现:
1 | def factorial(n): |
我们分别计算了 5!
和 10!
,从程序的运行输出看,在计算 10!
时,把 5!
重复计算了一遍,在计算阶乘数字较小的情况下是没问题的,但是当阶乘数非常大时,重复的计算会占用CPU很多资源,而这些重复的计算完全是没必要的,那我们会想到实现一个缓存,将计算过的数据存起来,当下次请求时直接返回。
实现一个缓存
1 | class MyCache(object): |
我们简单实现了一个缓存类,使用了 dict
数据类型,存储了中间的计算结果,我们看下输出:
1 | compute: 5 |
从输出能看出,5!
和 4!
都命中了缓存,直接拿到了结果,这就节省了CPU的计算资源,当然,如果数据量很小,就没有必要使用缓存了。
functools中的lru_cache
从3.2版本开始,Python的functools模块提供了 lru_cache
函数,用于缓存计算的中间结果,函数定义如下:
1 | @functools.lru_cache(user_function) |
一个为函数提供缓存功能的装饰器,缓存 maxsize
传入参数,在下次以相同参数调用时直接返回上一次的结果。用以节约高开销或I/O函数的调用时间。由于使用了字典存储缓存,所以该函数的固定参数和关键字参数必须是可哈希的。
不同模式的参数可能被视为不同从而产生多个缓存项,例如, f(a=1, b=2) 和 f(b=2, a=1) 因其参数顺序不同,可能会被缓存两次。
如果指定了 user_function
,它必须是一个可调用对象。 这允许 lru_cache
装饰器被直接应用于一个用户自定义函数,maxsize
默认值为128。
我们使用这个装饰器重写一下上边的代码:
1 | from functools import lru_cache |
从输出可以看到,hits=2
表示命中了2次缓存。
那我们就来看下这个装饰器的实现:
1 | def lru_cache(maxsize=128, typed=False): |
如果 maxsize
设为 None
,LRU 特性将被禁用且缓存可无限增长。
如果 typed
设置为true,不同类型的函数参数将被分别缓存。例如, f(3)
和 f(3.0)
将被视为不同而分别缓存。
被装饰的函数带有一个 cache_info()
函数。当调用 cache_info()
函数时,返回一个具名元组,包含命中次数 hits
,未命中次数 misses
,最大缓存数量 maxsize
和 当前缓存大小 currsize
。在多线程环境中,命中数与未命中数是不完全准确的。
该装饰器也提供了一个用于清理/使缓存失效的函数 cache_clear()
。
原始的未经装饰的函数可以通过 __wrapped__
属性访问。它可以用于检查、绕过缓存,或使用不同的缓存再次装饰原始函数。
LRU(最久未使用算法)缓存) 在最近的调用是即将到来的调用的最佳预测值时性能最好(例如,新闻服务器上最热门文章倾向于每天更改)。 缓存的大小限制可确保缓存不会在长期运行进程如网站服务器上无限制地增长。
下面我们再看下 _lru_cache_wrapper
的具体实现:
1 | def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo): |
函数根据 maxsize
不同,定义不同的 wrapper
。
maxsize == 0
,其实也就是没有缓存,那么每次函数调用都不会命中,并且没有命中的次数misses
加 1。maxsize is None
,不限制缓存大小,如果函数调用不命中,将没有命中次数misses
加 1,否则将命中次数hits
加 1。- 限制缓存的大小,那么需要根据 LRU 算法来更新
cache
- 如果缓存命中 key,那么将命中节点移到双向循环链表的结尾,
hits += 1
,并且返回结果。这里通过字典加双向循环链表的组合数据结构,实现了用 O(1) 的时间复杂度删除给定的节点。 - 如果没有命中,并且缓存满了,那么需要将最久没有使用的节点(root 的下一个节点)删除,并且将新的节点添加到链表结尾。在实现中有一个优化,直接将当前的 root 的 key 和 result 替换成新的值,将 root 的下一个节点置为新的 root,这样得到的双向循环链表结构跟删除 root 的下一个节点并且将新节点加到链表结尾是一样的,但是避免了删除和添加节点的操作
- 如果没有命中,并且缓存没满,那么直接将新节点添加到双向循环链表的开头
- 如果缓存命中 key,那么将命中节点移到双向循环链表的结尾,
最后给 wrapper
添加两个属性函数 cache_info
和 cache_clear
,cache_info
显示当前缓存的命中情况的统计数据,cache_clear
用于清空缓存。
最后需要说明的是,对于有多个关键字参数的函数,如果两次调用函数关键字参数传入的顺序不同,会被认为是不同的调用,不会命中缓存。另外,被 lru_cache 装饰的函数不能包含可变类型参数如 list,因为它们不支持 hash。
Read More: