python 请求 heroku 上的内存使用
python requests memory usage on heroku
关于 Heroku 的一些观察结果与我的心智模型不完全吻合。
我的理解是,一旦 OS 分配了内存,CPython 就永远不会释放内存。所以我们不应该观察到 CPython 进程的驻留内存减少。这实际上是我偶尔在 Heroku 上分析我的 Django 应用程序的观察结果;有时常驻内存会增加,但不会减少。
但是,有时 Heroku 会提醒我我的 worker dyno 使用了超过 100% 的内存配额。当我向外部服务(使用 requests
库)发出的长 运行 响应数据密集型 HTTPS 请求由于服务器端超时而失败时,通常会发生这种情况。在这种情况下,内存使用率将超过 100%,然后在警报停止时逐渐回落到配额的 100% 以下。
我的问题是,这个内存是如何释放回OS的? AFAIK 它不可能是 CPython 发布的。我的猜测是来自 long-运行 TCP 连接的传入字节正在被 OS 缓冲,它具有取消分配的能力。当恰好 "ownership" 的 TCP 字节传输到我的 Django 应用程序时,我感到很困惑。我当然没有明确地从输入流中读取行,我将所有这些委托给 requests
.
CPython 会释放内存,但有点模糊。
CPython 一次分配内存块,我们称它们为字段。
当您实例化一个对象时,CPython 将尽可能使用现有字段中的内存块;可能是因为所述对象有足够的传染性块。
如果没有足够的传染块,它会分配一个新的领域。
这就是它变得模糊的地方。
Field 只有在包含零个对象时才会被释放,虽然 CPython 中有垃圾回收,但没有 "trash compactor"。因此,如果您在几个字段中有几个对象,并且每个字段只有 70% 满,CPython 不会将这些对象一起移动并释放一些字段。
您从 HTTP 调用中提取的大数据块被分配给 "new" 字段似乎很合理,但随后出现了一些问题,对象的引用计数变为零,然后是垃圾回收运行 returns 那些字段到 OS.
显然,CPython 曾一度没有将内存释放回 OS。然后一个patch was introduced in Python 2.5 that allowed memory to be released under certain circumstances, detailed here。因此,说 python 不释放内存不再是正确的;只是 python 不 经常 释放内存,因为它不能很好地处理内存碎片。
在高层次上,python 在称为竞技场的 256K 块中跟踪其内存。对象池保存在这些区域中。 Python 足够聪明,可以在竞技场为空时将竞技场释放回 OS,但它仍然不能很好地处理竞技场之间的碎片。
在我的特殊情况下,我正在读取大量 HTTP 响应。如果您深入挖掘 python 套接字库中以 HttpAdapter.send() in the requests library, you'll eventually find that socket.read() 开头的代码链,则会进行系统调用以从其套接字接收 8192 字节(默认缓冲区大小)的块。这是 OS 将字节从内核复制到进程的点,在那里它们将被 CPython 指定为大小为 8K 的字符串对象并被推入竞技场。请注意,StringIO 是套接字的 python-land 缓冲区对象,它只是保留这些 8K 字符串的列表,而不是将它们组合成一个超字符串对象。
由于 8K 恰好是 256K 的 32 倍,我认为发生的情况是接收到的字节很好地填满了整个区域而没有太多碎片。当删除填充它们的 8K 字符串时,这些竞技场可以释放到 OS。
我好像明白了为什么内存是逐渐释放的(异步垃圾回收?),但是我还是不明白为什么连接出错后要这么久才释放。如果内存释放总是花费这么长时间,我应该一直看到这些内存使用错误,因为只要进行这些调用之一,我的 python 内存使用就会激增。我检查了我的日志,有时我可以看到这些违规行为持续了几分钟。似乎内存释放的时间间隔太长了。
编辑: 我现在对这个问题有了扎实的理论。这个错误是由一个记录系统向我报告的,该系统保留对最后一个回溯的引用。回溯维护对回溯帧中所有变量的引用,包括 StringIO 缓冲区,后者又保存对从套接字读取的所有 8K 字符串的引用。请参阅 sys.exc_clear() 下的注释:只有在少数情况下才需要此功能。其中包括报告上次或当前异常信息的日志记录和错误处理系统。
因此,在异常情况下,8K 字符串的引用计数不会像在快乐路径中那样降为零并立即清空他们的竞技场;我们必须等待后台垃圾收集来检测它们的引用周期。
GC 延迟因以下事实而变得更加复杂:当发生此异常时,会在 5 分钟内分配大量对象直到超时,我猜这对于许多 8K 字符串来说有足够的时间使其进入第二代。使用默认的 GC 阈值 (700, 10, 10),字符串对象需要大约 700*10 次分配才能进入第二代。结果是 7000*8192 ~= 57MB,这意味着在字节流的最后 57MB 之前接收到的所有字符串都进入了第二代,如果流式传输了 570MB,甚至可能是第三代(但这似乎很高)。
对于第二代的垃圾回收来说,几分钟的间隔似乎还是很长,但我想这是可能的。回想一下,GC 不仅仅由分配触发,公式实际上是 trigger == (allocations - deallocations > threshold)
.
TL;DR 大量响应填满了 arena 的套接字缓冲区,没有太多碎片,允许 Python 实际上将它们的内存释放回 OS.在一般情况下,此内存将在引用缓冲区的任何上下文退出时立即释放,因为缓冲区上的引用计数将降至零,从而触发立即回收。在特殊情况下,只要回溯存在,缓冲区仍将被引用,因此我们将不得不等待垃圾收集来回收它们。如果异常发生在连接中间并且已经传输了大量数据,那么在异常发生时许多缓冲区将被归类为老一代的成员,我们将不得不等待更长的时间进行垃圾回收回收它们。
关于 Heroku 的一些观察结果与我的心智模型不完全吻合。
我的理解是,一旦 OS 分配了内存,CPython 就永远不会释放内存。所以我们不应该观察到 CPython 进程的驻留内存减少。这实际上是我偶尔在 Heroku 上分析我的 Django 应用程序的观察结果;有时常驻内存会增加,但不会减少。
但是,有时 Heroku 会提醒我我的 worker dyno 使用了超过 100% 的内存配额。当我向外部服务(使用 requests
库)发出的长 运行 响应数据密集型 HTTPS 请求由于服务器端超时而失败时,通常会发生这种情况。在这种情况下,内存使用率将超过 100%,然后在警报停止时逐渐回落到配额的 100% 以下。
我的问题是,这个内存是如何释放回OS的? AFAIK 它不可能是 CPython 发布的。我的猜测是来自 long-运行 TCP 连接的传入字节正在被 OS 缓冲,它具有取消分配的能力。当恰好 "ownership" 的 TCP 字节传输到我的 Django 应用程序时,我感到很困惑。我当然没有明确地从输入流中读取行,我将所有这些委托给 requests
.
CPython 会释放内存,但有点模糊。
CPython 一次分配内存块,我们称它们为字段。
当您实例化一个对象时,CPython 将尽可能使用现有字段中的内存块;可能是因为所述对象有足够的传染性块。 如果没有足够的传染块,它会分配一个新的领域。
这就是它变得模糊的地方。
Field 只有在包含零个对象时才会被释放,虽然 CPython 中有垃圾回收,但没有 "trash compactor"。因此,如果您在几个字段中有几个对象,并且每个字段只有 70% 满,CPython 不会将这些对象一起移动并释放一些字段。
您从 HTTP 调用中提取的大数据块被分配给 "new" 字段似乎很合理,但随后出现了一些问题,对象的引用计数变为零,然后是垃圾回收运行 returns 那些字段到 OS.
显然,CPython 曾一度没有将内存释放回 OS。然后一个patch was introduced in Python 2.5 that allowed memory to be released under certain circumstances, detailed here。因此,说 python 不释放内存不再是正确的;只是 python 不 经常 释放内存,因为它不能很好地处理内存碎片。
在高层次上,python 在称为竞技场的 256K 块中跟踪其内存。对象池保存在这些区域中。 Python 足够聪明,可以在竞技场为空时将竞技场释放回 OS,但它仍然不能很好地处理竞技场之间的碎片。
在我的特殊情况下,我正在读取大量 HTTP 响应。如果您深入挖掘 python 套接字库中以 HttpAdapter.send() in the requests library, you'll eventually find that socket.read() 开头的代码链,则会进行系统调用以从其套接字接收 8192 字节(默认缓冲区大小)的块。这是 OS 将字节从内核复制到进程的点,在那里它们将被 CPython 指定为大小为 8K 的字符串对象并被推入竞技场。请注意,StringIO 是套接字的 python-land 缓冲区对象,它只是保留这些 8K 字符串的列表,而不是将它们组合成一个超字符串对象。
由于 8K 恰好是 256K 的 32 倍,我认为发生的情况是接收到的字节很好地填满了整个区域而没有太多碎片。当删除填充它们的 8K 字符串时,这些竞技场可以释放到 OS。
我好像明白了为什么内存是逐渐释放的(异步垃圾回收?),但是我还是不明白为什么连接出错后要这么久才释放。如果内存释放总是花费这么长时间,我应该一直看到这些内存使用错误,因为只要进行这些调用之一,我的 python 内存使用就会激增。我检查了我的日志,有时我可以看到这些违规行为持续了几分钟。似乎内存释放的时间间隔太长了。
编辑: 我现在对这个问题有了扎实的理论。这个错误是由一个记录系统向我报告的,该系统保留对最后一个回溯的引用。回溯维护对回溯帧中所有变量的引用,包括 StringIO 缓冲区,后者又保存对从套接字读取的所有 8K 字符串的引用。请参阅 sys.exc_clear() 下的注释:只有在少数情况下才需要此功能。其中包括报告上次或当前异常信息的日志记录和错误处理系统。
因此,在异常情况下,8K 字符串的引用计数不会像在快乐路径中那样降为零并立即清空他们的竞技场;我们必须等待后台垃圾收集来检测它们的引用周期。
GC 延迟因以下事实而变得更加复杂:当发生此异常时,会在 5 分钟内分配大量对象直到超时,我猜这对于许多 8K 字符串来说有足够的时间使其进入第二代。使用默认的 GC 阈值 (700, 10, 10),字符串对象需要大约 700*10 次分配才能进入第二代。结果是 7000*8192 ~= 57MB,这意味着在字节流的最后 57MB 之前接收到的所有字符串都进入了第二代,如果流式传输了 570MB,甚至可能是第三代(但这似乎很高)。
对于第二代的垃圾回收来说,几分钟的间隔似乎还是很长,但我想这是可能的。回想一下,GC 不仅仅由分配触发,公式实际上是 trigger == (allocations - deallocations > threshold)
.
TL;DR 大量响应填满了 arena 的套接字缓冲区,没有太多碎片,允许 Python 实际上将它们的内存释放回 OS.在一般情况下,此内存将在引用缓冲区的任何上下文退出时立即释放,因为缓冲区上的引用计数将降至零,从而触发立即回收。在特殊情况下,只要回溯存在,缓冲区仍将被引用,因此我们将不得不等待垃圾收集来回收它们。如果异常发生在连接中间并且已经传输了大量数据,那么在异常发生时许多缓冲区将被归类为老一代的成员,我们将不得不等待更长的时间进行垃圾回收回收它们。