在 Ruby 和 Python 中可视化垃圾收集

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...点击查看项目介绍 ;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;

截止目前, 星球 内专栏累计输出 54w+ 字,讲解图 2476+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 1900+ 小伙伴加入学习 ,欢迎点击围观

这是 Pat Shaughnessy 根据他在布达佩斯 RuPy 上的演讲写的一篇文章。您还可以观看 演示文稿的录像 。此摘要最初发布 在他的个人博客上 ,我们将在他的许可下重新发布在 Codeship 上。


如果您的算法和业务逻辑
是你的应用程序的大脑,它
垃圾收集器会是什么器官?

由于这是“Ruby Python”会议,我认为比较垃圾收集在 Ruby 和 Python 内部的工作方式会很有趣。

但在我们开始之前,为什么要谈论垃圾收集呢?毕竟,这不是最迷人、最令人兴奋的话题,不是吗?你们中有多少人对垃圾收集感到兴奋? [ 许多 RuPy 与会者实际上举起了手! ]

最近在 Ruby 社区中有一篇关于如何 通过更改 Ruby GC 设置来加速单元测试的 博客文章。我认为那很好。更快地运行您的测试并以更少的 GC 暂停运行您的应用程序是件好事,但不知何故 GC 并没有真正让我兴奋。乍一看,这似乎是一个枯燥、枯燥、技术性的话题。

但实际上,垃圾收集是一个引人入胜的话题:GC 算法既是计算机科学史上的重要组成部分,也是前沿研究课题。例如,MRI Ruby 使用的 Mark and Sweep 算法已有 50 多年的历史,而 Rubinius 内部使用的一种 GC 算法(Ruby 的替代实现)是 2008 年才发明的。

然而,“垃圾收集”这个名称实际上用词不当。

您的应用程序的跳动心脏

GC 系统所做的不仅仅是“收集垃圾”。事实上,他们执行三项重要任务。他们:

  • 为新对象分配内存
  • 识别垃圾对象
  • 从垃圾对象中回收内存

想象一下,如果您的应用程序是人体:您编写的所有优雅代码、您的业务逻辑、您的算法,都将是应用程序中的大脑或智能。

按照这个类比,您认为垃圾收集器应该是身体的哪一部分? [我从 RuPy 听众那里得到了很多有趣的答案:肾脏、白细胞等。]

我认为垃圾收集器是您应用程序的核心。正如您的心脏为身体的其他部分提供血液和营养一样,垃圾收集器为您的应用程序提供内存和对象以供使用。

如果你的心脏停止跳动,你会在几秒钟内死去。如果垃圾收集器停止或运行缓慢——如果它堵塞了动脉——你的应用程序就会变慢并最终死掉!


垃圾回收可能听起来并不令人兴奋,但它可以防止您的应用程序缓慢死亡。

点击鸣叫

一个简单的例子

使用示例来研究理论总是有帮助的。这是一个用 Python 和 Ruby 编写的简单类,我们今天可以将其用作示例:

顺便说一下,这段代码在两种语言中的相似程度让我感到惊讶。 Ruby 和 Python 实际上只是表达同一事物的方式略有不同。但是这些语言在内部也以类似的方式实现吗?

免费清单

当我们在上面调用 Node.new(1) 时,Ruby 到底做了什么? Ruby 如何为我们创建一个新对象?

令人惊讶的是,它做的很少!事实上,早在你的代码开始运行之前,Ruby 就提前创建了数千个对象并将它们放在一个称为 自由列表 的链表中。以下是空闲列表在概念上的样子:

假设上面的每个白色方块都是一个未使用的、预先创建的 Ruby 对象。当我们调用 Node.new 时,Ruby 只需获取这些对象之一并将其交给我们:

在上图中,左侧的灰色方块代表我们在代码中使用的活动 Ruby 对象,而其余的白色方块是未使用的对象。当然,我的图表是现实的简化版本。事实上,Ruby 会使用另一个对象来保存字符串“ABC”,第三个对象来保存 Node 的类定义,还有其他对象来保存我的代码的已解析抽象语法树 (AST) 表示,等等。

如果我们再次调用 Node.new ,Ruby 只会给我们另一个对象:


约翰·麦卡锡 1960 年的实施
Lisp 包含第一个垃圾
集电极。 (由麻省理工学院博物馆提供)

这种使用预创建对象链表的简单算法是 50 多年前由一位名叫约翰麦卡锡的传奇计算机科学家发明的,当时他正在研究 Lisp 的原始实现。

Lisp 不仅是最早的函数式编程语言之一,而且还包含了计算机科学领域的许多其他突破性进展。

其中之一是使用垃圾收集自动管理应用程序内存的概念。

Ruby 的标准版本,也称为“Matz 的 Ruby 解释器”(MRI),使用的 GC 算法类似于 McCarthy 在 1960 年实现 Lisp 时使用的算法。无论好坏,Ruby 使用了 53 年历史的算法垃圾收集。正如 Lisp 所做的那样,Ruby 会提前创建对象,并在您分配新对象或值时将它们交给您的代码。


“你知道 Ruby 使用了 53 年历史的垃圾回收算法吗?” – 通过@pat_shaughnessy

点击鸣叫

在 Python 中分配对象

我们已经看到 Ruby 提前创建对象并将它们保存在空闲列表中。蟒蛇呢?

虽然 Python 出于各种原因在内部也使用空闲列表(它回收某些对象,例如列表),但它通常为新对象和值分配内存,这与 Ruby 不同。

假设我们使用 Python 创建一个 Node 对象:

Python 与 Ruby 不同,它会在您创建对象时立即向操作系统请求内存。 (Python 实际上实现了它自己的内存分配系统,该系统在操作系统堆之上提供了一个额外的抽象层。但我今天没有时间深入了解这些细节。)

当我们创建第二个对象时,Python 将再次向操作系统请求更多内存:

看起来很简单;在我们创建对象的那一刻,Python 会花时间为我们查找和分配内存。


Ruby 留下未使用的对象
躺在记忆中
直到下一个 GC 进程运行。

Ruby 开发者住在一个凌乱的房子里

回到红宝石。

随着我们分配越来越多的对象,Ruby 将继续从空闲列表中为我们提供预创建的对象。

当它这样做时,空闲列表将变得更短:

…更短:

请注意,当我继续为 n1 分配新值时,Ruby 会保留旧值。 ABC、JKL 和 MNO 节点保留在内存中。 Ruby 不会立即清理我的代码不再使用的旧对象!

作为一名 Ruby 开发人员工作就像住在一个凌乱的房子里,衣服躺在地板上或厨房水槽里有脏盘子。作为一名 Ruby 开发人员,您必须处理周围未使用的垃圾对象。


Python清理垃圾对象
在你的代码之后
使用它们完成。

Python 开发人员生活在整洁的家庭中

垃圾收集在 Python 中的工作方式与在 Ruby 中完全不同。

让我们回到之前的三个 Python Node 对象:

在内部,每当我们创建一个对象时,Python 都会在对象的 C 结构中保存一个整数,称为 引用计数 。最初,Python 将此值设置为 1:

值 1 表示三个对象中的每一个都有一个指针或引用。现在假设我们创建一个新节点 JKL:

和以前一样,Python 将 JKL 中的引用计数设置为 1。但是,还要注意,由于我们将 n1 更改为指向 JKL,它不再引用 ABC,并且 Python 将其引用计数递减为 0。

此时,Python 垃圾收集器立即开始行动!每当对象的引用计数达到零时,Python 立即释放它,将其内存返回给操作系统:

Python 回收 ABC 节点使用的内存。请记住,Ruby 只是将旧对象留在周围,不会释放它们的内存。

这种垃圾收集算法称为 引用计数 。它是 George Collins 于 1960 年发明的——并非巧合,同一年 John McCarthy 发明了空闲列表算法。

正如 Mike Bernstein Gotham Ruby 会议 关于垃圾收集的精彩演讲 中所说:“1960 年是垃圾收集器的好年头……”

作为 Python 开发人员工作就像住在整洁的房子里;你知道,那种你的室友经常在你身后打扫卫生的地方。一旦您放下脏盘子或玻璃杯,就会有人将其放入洗碗机中!

现在举第二个例子。假设我们将 n2 设置为引用与 n1 相同的节点:

您可以看到 Python 已经减少了 DEF 的引用计数,并将立即对 DEF 节点进行垃圾回收。另请注意,JKL 现在的引用计数为 2,因为 n1 n2 都指向它。

标记和扫描

最终,凌乱的房子里堆满了垃圾,生活无法像往常一样继续。在你的 Ruby 程序运行一段时间后,空闲列表最终将被完全用完:

所有预先创建的 Ruby 对象都已被我们的应用程序使用(它们都是灰色的),并且没有对象留在空闲列表中(没有留下白色方块)。

此时,Ruby 使用麦卡锡发明的另一种算法,称为 Mark and Sweep 。首先 Ruby 停止你的应用程序; Ruby 使用“停止世界垃圾收集”。然后,Ruby 遍历我们的代码对对象和其他值所做的所有指针、变量和其他引用。 Ruby 还遍历其虚拟机使用的内部指针。它 标记了 它能够使用这些指针到达的每个对象。我在这里使用字母 M 表示这些标记:

标有“M”的三个对象是我们的应用程序仍在使用的活动对象。在内部,Ruby 实际上使用一系列称为 自由位图的 位来跟踪哪些对象被标记或未标记:

Ruby 将空闲位图保存在单独的内存位置,以便充分利用 Unix 的写时复制优化。有关这方面的更多信息,请参阅我的文章 “为什么你应该对 Ruby 2.0 中的垃圾收集感到兴奋”。

如果标记的对象是活动的,则其余未标记的对象必须是垃圾,这意味着我们的代码不再使用它们。我将垃圾对象显示为下面的白色方块:

接下来 Ruby 将未使用的垃圾对象清扫 回空闲列表:

在内部,这发生得相当快,因为​​ Ruby 实际上并不将对象从一个地方复制到另一个地方。相反,Ruby 通过调整内部指针以形成新的链表,将垃圾对象放回空闲列表。

现在 Ruby 可以在我们下次创建新的 Ruby 对象时将这些垃圾对象还给我们。在Ruby中,对象轮回,享受多重生命!

标记清除与引用计数

乍一看,Python 的 GC 算法似乎远远优于 Ruby 的:既然可以住在整洁的房子里,为什么还要住在凌乱的房子里?为什么 Ruby 会在每次清理时强制您的应用程序定期停止运行,而不是使用 Python 的算法?

然而,引用计数并不像乍看起来那么简单。许多语言不像 Python 那样使用引用计数 GC 算法的原因有很多:

  • 一是实施难度大。 Python 必须在每个对象内部留出空间来保存引用计数。这有一个小的空间损失。但更糟糕的是,更改变量或引用等简单操作会变成更复杂的操作,因为 Python 需要递增一个计数器,递减另一个计数器,并可能释放对象。
  • 其次,它可以更慢。尽管 Python 在您的应用程序运行时顺利执行 GC 工作(将脏盘子放入水槽后立即清洗),但这并不一定更快。 Python 不断更新引用计数值。当您停止使用大型数据结构时,例如包含许多元素的列表,Python 可能不得不一次释放许多对象。递减引用计数可能是一个复杂的递归过程。
  • 最后,它并不总是有效。正如我们将在包含本演示文稿其余部分注释的下一篇文章中看到的那样,引用计数无法处理循环数据结构——包含循环引用的数据结构。

直到下一次…

下周我将把 演示文稿的其余部分 打出来。我将讨论 Python 如何处理循环数据结构,以及 GC 在 Ruby 2.1 版本中的工作方式。