0

背景

我正在使用 pysdl2 创建一个窗口,并使用 SDL_Blit_Surface 在该窗口内嵌入一个skia-python表面,代码如下:

import skia
import sdl2 as sdl
from ctypes import byref as pointer


class Window:
    DEFAULT_FLAGS = sdl.SDL_WINDOW_SHOWN
    BYTE_ORDER = {
        # ---------- ->   RED        GREEN       BLUE        ALPHA
        "BIG_ENDIAN": (0xff000000, 0x00ff0000, 0x0000ff00, 0x000000ff),
        "LIL_ENDIAN": (0x000000ff, 0x0000ff00, 0x00ff0000, 0xff000000)
    }

    PIXEL_DEPTH = 32  # BITS PER PIXEL
    PIXEL_PITCH_FACTOR = 4  # Multiplied by Width to get BYTES PER ROW

    def __init__(self, title, width, height, x=None, y=None, flags=None, handlers=None):
        self.title = bytes(title, "utf8")
        self.width = width
        self.height = height

        # Center Window By default
        self.x, self.y = x, y
        if x is None:
            self.x = sdl.SDL_WINDOWPOS_CENTERED
        if y is None:
            self.y = sdl.SDL_WINDOWPOS_CENTERED

        # Override flags
        self.flags = flags
        if flags is None:
            self.flags = self.DEFAULT_FLAGS

        # Handlers
        self.handlers = handlers
        if self.handlers is None:
            self.handlers = {}

        # SET RGBA MASKS BASED ON BYTE_ORDER
        is_big_endian = sdl.SDL_BYTEORDER == sdl.SDL_BIG_ENDIAN
        self.RGBA_MASKS = self.BYTE_ORDER["BIG_ENDIAN" if is_big_endian else "LIL_ENDIAN"]

        # CALCULATE PIXEL PITCH
        self.PIXEL_PITCH = self.PIXEL_PITCH_FACTOR * self.width

        # SKIA INIT
        self.skia_surface = self.__create_skia_surface()

        # SDL INIT
        sdl.SDL_Init(sdl.SDL_INIT_EVENTS)  # INITIALIZE SDL EVENTS
        self.sdl_window = self.__create_SDL_Window()

    def __create_SDL_Window(self):
        window = sdl.SDL_CreateWindow(
            self.title,
            self.x, self.y,
            self.width, self.height,
            self.flags
        )
        return window

    def __create_skia_surface(self):
        """
        Initializes the main skia surface that will be drawn upon,
        creates a raster surface.
        """
        surface_blueprint = skia.ImageInfo.Make(
            self.width, self.height,
            ct=skia.kRGBA_8888_ColorType,
            at=skia.kUnpremul_AlphaType
        )
        # noinspection PyArgumentList
        surface = skia.Surface.MakeRaster(surface_blueprint)
        return surface

    def __pixels_from_skia_surface(self):
        """
        Converts Skia Surface into a bytes object containing pixel data
        """
        image = self.skia_surface.makeImageSnapshot()
        pixels = image.tobytes()
        return pixels

    def __transform_skia_surface_to_SDL_surface(self):
        """
        Converts Skia Surface to an SDL surface by first converting
        Skia Surface to Pixel Data using .__pixels_from_skia_surface()
        """
        pixels = self.__pixels_from_skia_surface()
        sdl_surface = sdl.SDL_CreateRGBSurfaceFrom(
            pixels,
            self.width, self.height,
            self.PIXEL_DEPTH, self.PIXEL_PITCH,
            *self.RGBA_MASKS
        )
        return sdl_surface

    def update(self):
        window_surface = sdl.SDL_GetWindowSurface(self.sdl_window)  # the SDL surface associated with the window
        transformed_skia_surface = self.__transform_skia_surface_to_SDL_surface()
        # Transfer skia surface to SDL window's surface
        sdl.SDL_BlitSurface(
            transformed_skia_surface, None,
            window_surface, None
        )

        # Update window with new copied data
        sdl.SDL_UpdateWindowSurface(self.sdl_window)

    def event_loop(self):
        handled_events = self.handlers.keys()
        event = sdl.SDL_Event()

        while True:
            sdl.SDL_WaitEvent(pointer(event))

            if event.type == sdl.SDL_QUIT:
                break

            elif event.type in handled_events:
                self.handlers[event.type](event)


if __name__ == "__main__":
    skiaSDLWindow = Window("Browser Test", 500, 500, flags=sdl.SDL_WINDOW_SHOWN | sdl.SDL_WINDOW_RESIZABLE)
    skiaSDLWindow.event_loop()

我监控上面代码的 CPU 使用率,它保持在 20% 以下,RAM 使用率几乎没有任何变化。

问题

问题是,只要我制作一个大于 690 x 549 的窗口(或宽度和高度产品相同的任何其他尺寸),我的segfault (core dumped)CPU 使用率就会上升到 100%,而 RAM 使用率没有变化。

我已经尝试过/知道的

我知道故障是SDL_BlitSurfacefaulthandlerpython中的模块和经典print("here")线路报告的。

我不熟悉像 c 这样的语言,所以根据我对段错误的基本理解,我尝试将 with 返回的字节字符串的大小Window.__pixels_from_skia_surfacesys.getsizeofC 数据类型相匹配,以查看它是否接近任何大小,因为我怀疑存在溢出。(如果这是你见过的最愚蠢的调试方法,请原谅我)。但是大小并没有接近任何 c 数据类型。

4

1 回答 1

1

正如SDL_CreateRGBSurfaceFrom 文档所说,它不会为像素数据分配内存,而是将外部内存缓冲区传递给它。虽然根本没有复制操作有好处,但它会影响终身 - 请注意“您必须在释放像素数据之前释放表面”。

Python 跟踪其对象的引用,并在对象的引用计数达到 0 时自动销毁对象(即不可能对该对象进行引用 - 立即将其删除)。但是 SDL 和 skia 都不是 python 库,它们在本机代码中保留的任何引用都不会暴露给 python。因此,python 自动内存管理在这里对您没有帮助。

发生的事情是你从skia获取像素数据作为字节数组(python对象,不再引用时自动释放),然后将它传递给SDL_CreateRGBSurfaceFrom(本机代码,python不知道它会保留内部引用),然后你pixels去超出范围,python 删除它们。您有表面,但 SDL 说您创建像素的方式不能被破坏(还有其他方式,例如SDL_CreateRGBSurface,实际上分配自己的内存)。然后你尝试对它进行 blit,表面仍然指向像素所在的位置,但该数组不再存在。

[接下来的所有内容都解释了为什么它没有在较小的表面尺寸下崩溃,结果证明需要的单词比我想象的要多得多。对不起。如果你对这些东西不感兴趣,请不要继续阅读]

接下来发生的事情完全取决于 python 使用的内存分配器。首先,分段错误是操作系统向您的程序发送的一个关键信号,当您以不应该的方式访问内存页面时会发生这种情况 - 例如,读取没有映射页面的内存或写入已映射的页面作为只读。所有这些以及映射/取消映射页面的方式都由您的操作系统内核提供(例如,在 linux 中它由mmap/munmap调用处理),但操作系统内核仅在页面级别运行;你不能请求半页,但你可以有 N 页支持的大块。对于当前大多数操作系统,最小页面大小为 4kb;一些操作系统支持 2Mb 甚至更大的“巨大”页面。

因此,当表面较大时会出现分段错误,但在表面较小时不会出现分段错误。对于更大的表面意味着您的BlitSurface命中内存已经未映射并且操作系统会礼貌地向您的程序发送“抱歉不能允许,请立即纠正自己,否则您将失败”。但是当表面较小时,pixels保留在仍然映射的内存;这并不一定意味着它仍然包含相同的数据(例如,python 可能在那里放置了一些其他对象),但就操作系统而言,这个内存区域仍然是“你的”来读取的。并且该行为的差异确实是由分配的缓冲区的大小引起的(但当然,您不能依赖该行为保留在其他操作系统、其他 python 版本,甚至其他具有不同环境变量集的其他操作系统上)。

正如我之前所说,您只有mmap整个页面,但是 python(这只是一个示例,稍后您将看到)有很多较小的对象(整数、浮点数、较小的字符串、短数组,......)很多小于一页。为每个页面分配整个页面将浪费大量内存(还有其他问题,例如由于缓存不良导致性能下降)。为了处理我们所做的(“我们”是需要较小分配的每个程序,即您每天使用的 99% 的程序)是分配更大的内存块并跟踪该块的哪些部分在用户空间中分配/释放(如完全反对操作系统内核正在跟踪的页面 - 在内核空间中)。这样你就可以在没有太多开销的情况下对小分配进行非常紧凑的打包,但缺点是这种分配在操作系统级别上是不可区分的。当您“释放”一些放置在那种预分配块中的小分配时,您只是在内部将此区域标记为未使用,并且下次程序的某些其他部分请求一些内存时,您开始搜索可以放置它的位置。这也意味着您通常不会将内存返回(取消映射)到操作系统,因为如果至少有一个字节仍在使用中,则无法归还该块。

Python通过分配 256kb 块并将对象放置在该块中来内部管理小对象 (<512b)。如果需要更大的分配 - 它会将其传递给libc malloc(python 本身是用 C 编写的并使用 libc;Linux 上最流行的 libc 是glibc)。glibc的malloc 文档说明如下:

当分配大于 MMAP_THRESHOLD 字节的内存块时,glibc malloc() 实现使用 mmap(2) 将内存分配为私有匿名映射。MMAP_THRESHOLD 默认为 128 kB,但可使用 mallopt(3) 进行调整

因此,较大对象的分配应该转到mmap/ munmap,并且释放该页面应该使它们无法访问(如果您尝试访问它会导致段错误,而不是静默读取潜在的垃圾数据;如果您尝试写入它,则会获得奖励 - 所谓内存踩踏,覆盖其他内容,甚至可能是用于跟踪使用了哪些内存的内部 libc 标记;之后任何事情都可能发生)。虽然 next 仍有可能将下一页mmap随机放置在同一地址上,但我将忽略这一点。不幸的是,这是一个非常古老的文档,虽然解释了基本意图,但不再反映 glibc 现在的行为方式。看看glibc 源代码中的评论(重点是我的):

M_MMAP_THRESHOLD 是使用 mmap()
服务请求的请求大小阈值。无法使用现有空间分配的至少此大小的请求将通过 mmap 提供服务。
如果已经存在足够的正常释放空间,则使用它。

...

该实现使用一个滑动阈值,默认情况下限制在 128Kb 和 32Mb 之间(64 位机为 64Mb),并且根据 2001 年的默认值从 128Kb 开始。

...

当应用程序释放使用 mmap 分配器分配的内存时,阈值会上升。这个想法是,一旦应用程序开始释放一定大小的内存,这很可能是应用程序用于临时分配的大小。

因此,它会尝试适应您的分配行为,以平衡性能和将内存释放回操作系统。

但是,不同的操作系统会有不同的行为,即使只有 linux,我们也有多个 libc 实现(例如 musl),它们会以不同的方式实现 malloc,以及许多不同的内存分配器(jemalloc、tcmalloc、dlmalloc,你的名字)可以注入viaLD_PRELOAD并且您的程序(例如在这种情况下为 python 本身)将使用具有不同mmap使用规则的不同分配器。甚至有调试分配器在每个分配周围注入“保护”页面,根本没有任何访问权限(无法读取、写入或执行),以捕获与内存相关的常见编程错误,代价是大量更大的内存使用量。

总而言之-您的代码中有一个生命周期管理错误,不幸的是,由于 libc 内存分配方案的内部结构,它并没有立即崩溃,但是当表面大小变大并且 libc 决定为此分配独占页面时,它确实崩溃了缓冲。这是没有自动内存管理的语言所暴露的不幸事件,并且由于使用python C绑定,您的python程序在某种程度上也暴露了。

于 2022-01-15T16:56:20.493 回答