Python中的内存管理

内存是一本空白的书

可以将计算机的内存想像成一本用来写短篇小说的空书。它的页面上还没有任何的内容,但是到了后面,不同的作者将会出现。每个作者都想要一些空间来书写它们的内容。

由于它们不允许书写在不属于它们的页,因此它们必须小心写内容在页面上,在它们开始写作之前,它们会咨询负责管理这本书的“经理”,然后由经理来决定允许它们写书的位置。

由于这本书已经存在了很长的时间,所以其中许多故事都已经不再适用。当没有人阅读或引用这些故事时,它们会被移除来为新故事腾出空间。

从本质上来讲,计算机内存就像是这本空书。事实上,调用固定长度的连续内存页块是很常见的。

作者就是需要将数据存储在内存中的不同应用程序或者进程。经理,决定作者在书中可以写的内容,扮演各种记忆管理者的角色,删除旧故事以便为新故事腾出空间的人是垃圾收集者。

内存管理:从硬件到软件

内存管理是应用程序读写数据的过程。内存管理器确定应用程序数据的放置位置。由于内存是有限的,就像我们的书中的页面,管理者必须找到一些可用空间并将其提供给应用程序。这种提供存储空间的过程通常称为内存分配

另一方面,当不再需要数据时,可以删除或者释放数据。但释放到哪里?

在计算机的某个地方,有一个物理设备在运行Python程序时存储数据。在对象实际到达硬件之前,Python代码经历了许多抽象层。

硬件上方的主要层之一(例如RAM或硬盘驱动器)是操作系统。它执行(或拒绝)读写内存的请求。

在操作系统之上,有一些应用程序,其中一个是CPython。Python代码的内存管理由Python应用程序处理。Python的程序中用于内存管理的算法和结构是本文的重点。

CPython

默认的Python解释器,CPython,实际上是用C语言编写的。

我们还需要在计算机上执行解释型代码。CPython可以满足这些需求。它将Python代码转换为指令,随后在虚拟机上运行。

  • **注意:**虚拟机就像物理计算机,但它们是用软件实现的。它们通常处理类似于汇编指令基本指令。

Python是一种解释性编程语言。您的Python代码实际上被编译为更多计算机可读的指令,称为字节码。运行代码时,这些指令将由虚拟机解释

见过.pyc文件或__pycache__文件夹吗?这是由虚拟机解释的字节码。

值得注意的是,除了CPython之外还有其他解释器。例如Jython编译为Java字节码以在Java虚拟机上运行。

本文将重点介绍由Python的默认解释器CPython完成的内存管理。

好的,所以CPython是用C语言编写的,它解释了Python字节码。这与内存管理有什么关系?那么,用于内存管理的算法和结构存在于CPython代码中,在C中。要理解Python的内存管理,你必须对CPython本身有一个基本的了解。

CPython是用C语言编写的,它本身不支持面向对象的编程。因此,CPython代码中有相当多的有趣设计。

Python中的所有内容都是一个对象,甚至类似于intstr。这在CPython的实现级别上确实如此。在CPyhton中,有一个自定义的结构体叫做 PyObject,CPython中的每个对象都使用它。

注意: C中的 struct结构体是一种自定义数据类型,它将不同的数据类型组合在一起。要与面向对象的语言进行比较,它就像一个具有属性而没有方法的类。

PyObject是Python中的所有对象的父对象,它只包含两个东西:

  • **ob_refcnt:**引用计数
  • **ob_type:**指向另一种类型的指针

引用计数用于垃圾回收(GC)。然后你有一个指向实际对象类型的指针。该对象类型只是结构体用于描述Python对象(如a dictint)的另一种类型。

每个对象都有自己特定于对象的内存分配器,它知道如何获取内存来存储该对象。每个对象还有一个特定于对象的内存释放器,一旦不再需要就会“释放”内存。

但是,所有关于分配和释放内存的讨论都有一个重要因素。内存是计算机上的共享资源,如果两个不同的进程尝试同时写入同一位置,则可能会发生错误。

垃圾收集

假设现在内存中的一些数据已经存放了过长时间,并有一段时间没有进程或线程去访问并引用了,那么这时候就需要释放它以此来腾出空间。

如果内存中要释放的的是Python中的对象,则代表着这个对象的引用计数已经降至0。还记得上面有说到Python总每个对象都有一个引用计数和一个指向数据类型的指针。

引用计数增加的原因有几个。例如,如果将引用计数分配给另一个变量,则引用计数将会增加:

numbers = [1, 2, 3]
# Reference count = 1
more_numbers = numbers
# Reference count = 2

如果将对象作为参数来传递,引用计数也会增加:

total = sum(numbers)

当将对象放入列表中时,引用变量也会增加:

matrix = [numbers, numbers, numbers]

可以使用sys模块来检查对象当前的引用计数。

import  sys
a = []
b = a
sys.getrefcount(a) # 查看空列表的引用次数。

要注意的是,当使用sys.getrefcount()方法的时候,对象的引用计数也会加1。

在任何情况下,如果仍然需要在代码中挂起对象,则需要其引用计数大于0。一旦它降低到0,该对象就有一个特定的释放函数,它被调用来“释放”内存,以便其他对象可以使用它。

但是“释放”内存是什么意思,其他对象如何使用呢?

CPython的内存管理

如上所述,从物理硬件到CPython都有一些抽象层。操作系统抽象了物理内存并创建应用程序可以访问的虚拟内存层。

特定于操作系统的虚拟内存管理器为Python进程划分出一块内存。下图中较暗的灰色框现在由Python进程拥有。


Python将内存的一部分用于内部使用和非对象内存。另一部分专用于对象存储(intdict等)

CPython有一个对象分配器,负责在对象存储的内存区域内分配内存。这个对象分配器是大多数操作发生的地方。每次新对象需要分配或删除空间时都会调用它。

通常,添加和删除Python对象里的数据,例如listint,一次关于int的操作不涉及太多数据。因此,分配器的设计经过调整,可以同时处理少量数据。它也尝试在不得不需要之前不给其分配内存。

CPython源代码中的注释将对象分配器描述为“一种用于小块的快速专用内存分配器,用于通用malloc之上。”在本例中,malloc是C语言用于内存分配的库函数。

现在来看看CPython的内存分配策略。首先,先看看3个主要部分以及它们如何相互关联。

Arenas是最大的内存块,并在内存中的页面边界上对齐。页面边界是操作系统使用的固定长度连续内存块的边缘。Python假设系统的页面大小为256k字节。

在Arenas内的Pool,它是一个虚拟内存页(4k字节)。这些就像之前类比的书中的页面。这些Pools被分割成较小的内存块。

给定的Pool中的所有块都具有相同的“大小等级”。在给定一定量的请求数据的情况下,一个大小等级定义特定的块大小。

Request in bytes Size of allocated block Size class idx
1-8 8 0
9-16 16 1
17-24 24 2
25-32 32 3
505-512 512 63

举个例子,如果请求的字节数为42个字节,则将数据存入48字节大小的块中。

Pool

Pools由单一大小等级的块组成。每个Pool都为同一大小等级的其他Pool维护一个双向链表。通过这种方式,即使跨不同的Pool,算法也可以轻松找到给定块大小等级的可用空间。

一个usedpools列表跟踪为每个大小等级提供数据空间的所有pool。当请求给定的块大小时,算法会在此usedpools列表中检查该块大小的pool列表。

Pool本身必须在3个状态之一:usedfullempty。一个used状态的Pool有可用的块存储数据。一个full状态的Pool的内存块都被已分配并包含着数据。一个empty的Pool没有存储的数据,并且在需要时可被分配任何大小等级的内存块。

一个freepools列表跟踪的所有Pool的empty状态。但是什么时候空Pool被使用?

假设您的代码需要一个8字节的内存块。如果usedpools8字节等级中没有Pool,freepools则初始化新池以存储8字节块。然后,此新池将添加到usedpools列表中,以便可以将其用于将来的请求。

假设full内存池释放了一些块,因为不再需要内存。该Pool将被添加回usedpools其大小等级的列表中。

现在可以看到内存池如何使用算法自由地在这些状态(甚至内存大小等级)之间移动。

Block

如上图所示,内存池包含指向其“free”内存块的指针。它的工作方式略有细微差别。根据源代码中的注释,这个分配器“在所有级别(Arena,Pool,Block)都不会触及任何内存,直到实际需要它为止”。

这意味着内存池可以在3个区域中拥有块。这些状态可以定义如下:

  • **untouched:**未分配的内存部分
  • **free:**已分配但稍后由CPython“释放”并且不再包含相关数据的内存部分
  • **allocated:**实际包含相关数据的内存的一部分

freeblock指针指向的内存空闲块的单链表。换句话说,就是放置数据的可用位置的列表。如果需要超过可用的空闲块,则分配器将在内存池中获得一些untouched块。

当内存管理器使内存块“free”时,那些现在的free块被添加到freeblock列表的前面。实际的列表可能不是连续的内存块,就像第一个漂亮的图表一样。它可能类似于下图:

Arenas

Arenas包含Pools。这些内存池可以是usedfullempty。虽然Arenas本身没有明确的状态。

相反,Arenas被组织成一个名为usable_arenas的双向链表。该列表按可用空闲内存池的数量排序。空闲的内存池越少,Arenas越接近列表的前面。

这意味着分配器将选择有着最多数据的Arenas来放置新数据。但为什么不相反呢?为什么不将数据放在最多空间的地方?

这带给我们真正释放内存的想法。原因是当一个块被认为是“free”时,该内存实际上并没有释放回操作系统。Python进程依旧保持内存块allocated状态,稍后将用于新数据。真正释放内存会将其返回给操作系统使用。

Arenas是唯一可以真正释放的东西。因此,理所当然地认为那些更接近空的Arenas应该被允许变empty状态。这样做的好处是,可以真正释放那块内存,从而减少Python程序的整体内存占用。