在计算机软件领域,缓存(Cache)指的是将部分数据存储在内存中,以便下次能够更快地访问这些数据,这也是一个典型的用空间换时间的例子。一般用于缓存的内存空间是固定的,当有更多的数据需要缓存的时候,需要将已缓存的部分数据清除后再将新的缓存数据放进去。需要清除哪些数据,就涉及到了缓存置换的策略,LRU(Least Recently Used,最近最少使用)是很常见的一个,也是 Python 中提供的缓存置换策略。

下面我们通过一个简单的示例来看 Python 中的 lru_cache 是如何使用的。

  1. def factorial(n):
  2. print(f"计算 {n} 的阶乘")
  3. return 1 if n <= 1 else n * factorial(n - 1)
  4. a = factorial(5)
  5. print(f'5! = {a}')
  6. b = factorial(3)
  7. print(f'3! = {b}')

上面的代码中定义了函数 factorial,通过递归的方式计算 n 的阶乘,并且在函数调用的时候打印出 n 的值。然后分别计算 5 和 3 的阶乘,并打印结果。运行上面的代码,输出如下

  1. 计算 5 的阶乘
  2. 计算 4 的阶乘
  3. 计算 3 的阶乘
  4. 计算 2 的阶乘
  5. 计算 1 的阶乘
  6. 5! = 120
  7. 计算 3 的阶乘
  8. 计算 2 的阶乘
  9. 计算 1 的阶乘
  10. 3! = 6

可以看到,factorial(3) 的结果在计算 factorial(5) 的时候已经被计算过了,但是后面又被重复计算了。为了避免这种重复计算,我们可以在定义函数 factorial 的时候加上 lru_cache 装饰器,如下所示

  1. import functools
  2. # 注意 lru_cache 后的一对括号,证明这是带参数的装饰器
  3. @functools.lru_cache()
  4. def factorial(n):
  5. print(f"计算 {n} 的阶乘")
  6. return 1 if n <= 1 else n * factorial(n - 1)

重新运行代码,输入如下

  1. 计算 5 的阶乘
  2. 计算 4 的阶乘
  3. 计算 3 的阶乘
  4. 计算 2 的阶乘
  5. 计算 1 的阶乘
  6. 5! = 120
  7. 3! = 6

可以看到,这次在调用 factorial(3) 的时候没有打印相应的输出,也就是说 factorial(3) 是直接从缓存读取的结果,证明缓存生效了。

被 lru_cache 修饰的函数在被相同参数调用的时候,后续的调用都是直接从缓存读结果,而不用真正执行函数。下面我们深入源码,看看 Python 内部是怎么实现 lru_cache 的。写作时 Python 最新发行版是 3.9,所以这里使用的是 Python 3.9 的源码,并且保留了源码中的注释。

  1. def lru_cache(maxsize=128, typed=False):
  2. """Least-recently-used cache decorator.
  3. If *maxsize* is set to None, the LRU features are disabled and the cache
  4. can grow without bound.
  5. If *typed* is True, arguments of different types will be cached separately.
  6. For example, f(3.0) and f(3) will be treated as distinct calls with
  7. distinct results.
  8. Arguments to the cached function must be hashable.
  9. View the cache statistics named tuple (hits, misses, maxsize, currsize)
  10. with f.cache_info(). Clear the cache and statistics with f.cache_clear().
  11. Access the underlying function with f.__wrapped__.
  12. See: http://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)
  13. """
  14. # Users should only access the lru_cache through its public API:
  15. # cache_info, cache_clear, and f.__wrapped__
  16. # The internals of the lru_cache are encapsulated for thread safety and
  17. # to allow the implementation to change (including a possible C version).
  18. if isinstance(maxsize, int):
  19. # Negative maxsize is treated as 0
  20. if maxsize < 0:
  21. maxsize = 0
  22. elif callable(maxsize) and isinstance(typed, bool):
  23. # The user_function was passed in directly via the maxsize argument
  24. user_function, maxsize = maxsize, 128
  25. wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
  26. wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
  27. return update_wrapper(wrapper, user_function)
  28. elif maxsize is not None:
  29. raise TypeError(
  30. 'Expected first argument to be an integer, a callable, or None')
  31. def decorating_function(user_function):
  32. wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
  33. wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
  34. return update_wrapper(wrapper, user_function)
  35. return decorating_function

这段代码中有如下几个关键点

  • 关键字参数

    maxsize 表示缓存容量,如果为 None 表示容量不设限, typed 表示是否区分参数类型,注释中也给出了解释,如果 typed == True,那么 f(3)f(3.0) 会被认为是不同的函数调用。

  • 第 24 行的条件分支

    如果 lru_cache 的第一个参数是可调用的,直接返回 wrapper,也就是把 lru_cache 当做不带参数的装饰器,这是 Python 3.8 才有的特性,也就是说在 Python 3.8 及之后的版本中我们可以用下面的方式使用 lru_cache,可能是为了防止程序员在使用 lru_cache 的时候忘记加括号。

    1. import functools
    2. # 注意 lru_cache 后面没有括号,
    3. # 证明这是将其当做不带参数的装饰器
    4. @functools.lru_cache
    5. def factorial(n):
    6. print(f"计算 {n} 的阶乘")
    7. return 1 if n <= 1 else n * factorial(n - 1)

    注意,Python 3.8 之前的版本运行上面代码会报错:TypeError: Expected maxsize to be an integer or None。

lru_cache 的具体逻辑是在 _lru_cache_wrapper 函数中实现的,还是一样,列出源码,保留注释。

  1. def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo):
  2. # Constants shared by all lru cache instances:
  3. sentinel = object() # unique object used to signal cache misses
  4. make_key = _make_key # build a key from the function arguments
  5. PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields
  6. cache = {}
  7. hits = misses = 0
  8. full = False
  9. cache_get = cache.get # bound method to lookup a key or return None
  10. cache_len = cache.__len__ # get cache size without calling len()
  11. lock = RLock() # because linkedlist updates aren't threadsafe
  12. root = [] # root of the circular doubly linked list
  13. root[:] = [root, root, None, None] # initialize by pointing to self
  14. if maxsize == 0:
  15. def wrapper(*args, **kwds):
  16. # No caching -- just a statistics update
  17. nonlocal misses
  18. misses += 1
  19. result = user_function(*args, **kwds)
  20. return result
  21. elif maxsize is None:
  22. def wrapper(*args, **kwds):
  23. # Simple caching without ordering or size limit
  24. nonlocal hits, misses
  25. key = make_key(args, kwds, typed)
  26. result = cache_get(key, sentinel)
  27. if result is not sentinel:
  28. hits += 1
  29. return result
  30. misses += 1
  31. result = user_function(*args, **kwds)
  32. cache[key] = result
  33. return result
  34. else:
  35. def wrapper(*args, **kwds):
  36. # Size limited caching that tracks accesses by recency
  37. nonlocal root, hits, misses, full
  38. key = make_key(args, kwds, typed)
  39. with lock:
  40. link = cache_get(key)
  41. if link is not None:
  42. # Move the link to the front of the circular queue
  43. link_prev, link_next, _key, result = link
  44. link_prev[NEXT] = link_next
  45. link_next[PREV] = link_prev
  46. last = root[PREV]
  47. last[NEXT] = root[PREV] = link
  48. link[PREV] = last
  49. link[NEXT] = root
  50. hits += 1
  51. return result
  52. misses += 1
  53. result = user_function(*args, **kwds)
  54. with lock:
  55. if key in cache:
  56. # Getting here means that this same key was added to the
  57. # cache while the lock was released. Since the link
  58. # update is already done, we need only return the
  59. # computed result and update the count of misses.
  60. pass
  61. elif full:
  62. # Use the old root to store the new key and result.
  63. oldroot = root
  64. oldroot[KEY] = key
  65. oldroot[RESULT] = result
  66. # Empty the oldest link and make it the new root.
  67. # Keep a reference to the old key and old result to
  68. # prevent their ref counts from going to zero during the
  69. # update. That will prevent potentially arbitrary object
  70. # clean-up code (i.e. __del__) from running while we're
  71. # still adjusting the links.
  72. root = oldroot[NEXT]
  73. oldkey = root[KEY]
  74. oldresult = root[RESULT]
  75. root[KEY] = root[RESULT] = None
  76. # Now update the cache dictionary.
  77. del cache[oldkey]
  78. # Save the potentially reentrant cache[key] assignment
  79. # for last, after the root and links have been put in
  80. # a consistent state.
  81. cache[key] = oldroot
  82. else:
  83. # Put result in a new link at the front of the queue.
  84. last = root[PREV]
  85. link = [last, root, key, result]
  86. last[NEXT] = root[PREV] = cache[key] = link
  87. # Use the cache_len bound method instead of the len() function
  88. # which could potentially be wrapped in an lru_cache itself.
  89. full = (cache_len() >= maxsize)
  90. return result
  91. def cache_info():
  92. """Report cache statistics"""
  93. with lock:
  94. return _CacheInfo(hits, misses, maxsize, cache_len())
  95. def cache_clear():
  96. """Clear the cache and cache statistics"""
  97. nonlocal hits, misses, full
  98. with lock:
  99. cache.clear()
  100. root[:] = [root, root, None, None]
  101. hits = misses = 0
  102. full = False
  103. wrapper.cache_info = cache_info
  104. wrapper.cache_clear = cache_clear
  105. return wrapper

函数开始的地方 2~14 行定义了一些关键变量,

  • hitsmisses 分别表示缓存命中和没有命中的次数
  • root 双向循环链表的头结点,每个节点保存前向指针、后向指针、key 和 key 对应的 result,其中 key 为 _make_key 函数根据参数结算出来的字符串,result 为被修饰的函数在给定的参数下返回的结果。注意,root 是不保存数据 key 和 result 的。
  • cache 是真正保存缓存数据的地方,类型为 dict。cache 中的 key 也是 _make_key 函数根据参数结算出来的字符串,value 保存的是 key 对应的双向循环链表中的节点。

接下来根据 maxsize 不同,定义不同的 wrapper

  • maxsize == 0,其实也就是没有缓存,那么每次函数调用都不会命中,并且没有命中的次数 misses 加 1。

  • maxsize is None,不限制缓存大小,如果函数调用不命中,将没有命中次数 misses 加 1,否则将命中次数 hits 加 1。

  • 限制缓存的大小,那么需要根据 LRU 算法来更新 cache,也就是 42~97 行的代码。

    • 如果缓存命中 key,那么将命中节点移到双向循环链表的结尾,并且返回结果(47~58 行)

      这里通过字典加双向循环链表的组合数据结构,实现了用 O(1) 的时间复杂度删除给定的节点。

    • 如果没有命中,并且缓存满了,那么需要将最久没有使用的节点(root 的下一个节点)删除,并且将新的节点添加到链表结尾。在实现中有一个优化,直接将当前的 root 的 key 和 result 替换成新的值,将 root 的下一个节点置为新的 root,这样得到的双向循环链表结构跟删除 root 的下一个节点并且将新节点加到链表结尾是一样的,但是避免了删除和添加节点的操作(68~88 行)

    • 如果没有命中,并且缓存没满,那么直接将新节点添加到双向循环链表的结尾(root[PREV],这里我认为是结尾,但是代码注释中写的是开头)(89~96 行)

最后给 wrapper 添加两个属性函数 cache_infocache_clearcache_info 显示当前缓存的命中情况的统计数据,cache_clear 用于清空缓存。对于上面阶乘相关的代码,如果在最后执行 factorial.cache_info(),会输出

  1. CacheInfo(hits=1, misses=5, maxsize=128, currsize=5)

第一次执行 factorial(5) 的时候都没命中,所以 misses = 5,第二次执行 factorial(3) 的时候,缓存命中,所以 hits = 1。

最后需要说明的是,对于有多个关键字参数的函数,如果两次调用函数关键字参数传入的顺序不同,会被认为是不同的调用,不会命中缓存。另外,被 lru_cache 装饰的函数不能包含可变类型参数如 list,因为它们不支持 hash。

总结一下,这篇文章首先简介了一下缓存的概念,然后展示了在 Python 中 lru_cache 的使用方法,最后通过源码分析了 Python 中 lru_cache 的实现细节。

Python 中 lru_cache 的使用和实现的更多相关文章

  1. 如何导入python中的模块

    作为一名新手Python程序员,你首先需要学习的内容之一就是如何导入模块或包.但是我注意到,那些许多年来不时使用Python的人并不是都知道Python的导入机制其实非常灵活.在本文中,我们将探讨以下 ...

  2. python中的functools模块

    functools模块可以作用于所有的可以被调用的对象,包括函数 定义了__call__方法的类等 1 functools.cmp_to_key(func) 将比较函数(接受两个参数,通过比较两个参数 ...

  3. Python核心技术与实战——十四|Python中装饰器的使用

    我在以前的帖子里讲了装饰器的用法,这里我们来具体讲一讲Python中的装饰器,这里,我们从前面讲的函数,闭包为切入点,引出装饰器的概念.表达和基本使用方法.其次,我们结合一些实际工程中的例子,以便能再 ...

  4. python中的缓存技术

    python缓存技术 def console(a,b): print('进入函数') return (a,b) print(console(3,'a')) print(console(2,'b')) ...

  5. [转]Python中的str与unicode处理方法

    早上被python的编码搞得抓耳挠腮,在搜资料的时候感觉这篇博文很不错,所以收藏在此. python2.x中处理中文,是一件头疼的事情.网上写这方面的文章,测次不齐,而且都会有点错误,所以在这里打算自 ...

  6. python中的Ellipsis

    ...在python中居然是个常量 print(...) # Ellipsis 看别人怎么装逼 https://www.keakon.net/2014/12/05/Python%E8%A3%85%E9 ...

  7. python中的默认参数

    https://eastlakeside.gitbooks.io/interpy-zh/content/Mutation/ 看下面的代码 def add_to(num, target=[]): tar ...

  8. Python中的类、对象、继承

    类 Python中,类的命名使用帕斯卡命名方式,即首字母大写. Python中定义类的方式如下: class 类名([父类名[,父类名[,...]]]): pass 省略父类名表示该类直接继承自obj ...

  9. python中的TypeError错误解决办法

    新手在学习python时候,会遇到很多的坑,下面来具体说说其中一个. 在使用python编写面向对象的程序时,新手可能遇到TypeError: this constructor takes no ar ...

随机推荐

  1. Flink问题1

    flink问题1 报错: More buffers requested available than totally available 查看源码: /** * This method makes s ...

  2. vue 实现一个商城项目

    在学习了 vue 之后,决定做一个小练习,仿写了一个有关购物商城的小项目.下面就对项目做一个简单的介绍. 项目源码: github 项目的目录结构 -assets 与项目有关的静态资源,包括 css, ...

  3. Websocket---认识篇

    为什么需要 WebSocket ? 了解计算机网络协议的人,应该都知道:HTTP 协议是一种无状态的.无连接的.单向的应用层协议.它采用了请求/响应模型.通信请求只能由客户端发起,服务端对请求做出应答 ...

  4. pygal之掷骰子 - 2颗面数为6的骰子

    python之使用pygal模拟掷两颗面数为6的骰子的直方图,包含三个文件,主文件,die.py,dice_visual.py,20200527.svg.其中最后一个文件为程序运行得到的结果. 1,d ...

  5. 前端可视化开发--liveload

    在前端开发中,我们会频繁的修改html.css.js,然后刷新页面,开效果,再调整,再刷新,不知不觉会浪费掉我们很多时间.有没有什么方法,我在编辑器里面改了代码以后,只要保存,浏览器就能实时刷新.经过 ...

  6. BP暴力破解

    BurpSuite暴力破解 1.设置代理 首先要用phpstudy打开Mysql和Apache,然后将设置浏览器代理,地址127.0.0.1  端口8080 2.进入dvwa靶场 进入dvwa时,要用 ...

  7. 想成为Git大神?从学会reset开始吧

    大家好,今天我们来着重介绍一个非常关键的功能就是reset.在上一篇文章介绍修改历史记录的时候曾经提到过,当我们需要拆分一个历史提交记录的时候需要使用reset.估计很多小伙伴不明白,reset究竟做 ...

  8. VS Code 自动化连接非固定IP地址EC2实例的解决方案

    问题描述 大家可能和我一样,平时在AWS上启动一台安装有Linux EC2实例作为远程开发机. (注:这里的EC2实例是配置用私钥进行登录的) 通常,你可以选择申请一个Elastic IP绑定到这台开 ...

  9. Javascript 获得数组中相同或不同的数组元素   

    Javascript 获得数组中相同或不同的数组元素 在Javascript中,偶尔会用到获取数组中相同或不同的元素值的情况,以下提供了获得数组中相同或不同的 元素函数供参考学习使用. // 数字类型 ...

  10. JAVA基础之this关键之理解

    突然觉得有几个知识点需要先复习一下 1.引用和对象并不一定要同时存在,可以只有引用,没有对象  :比如声明String  a;如果非得提供一个比喻,可以用电视遥控器和电视来做比喻,遥控器比喻引用,电视 ...