SDL_BlitSurface 在 PySDL2 中导致较大表面上的段错误

SDL_BlitSurface in PySDL2 causing segfault on larger surfaces

背景

我正在使用 pysdl2 创建一个 window 并使用 SDL_Blit_Surface 在这个 window 中嵌入一个 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 使用几乎没有任何变化。

问题

问题是,一旦我将 window 设为大于 690 x 549(或宽度和高度乘积相同的任何其他尺寸),我就会得到 segfault (core dumped) 和 CPU 使用率上升到 100%,RAM 使用率没有变化。

我已经有了tried/know

我知道问题出在 SDL_BlitSurface 上,正如 python 中的 faulthandler 模块和经典的 print("here") 行所报告的那样。

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

正如SDL_CreateRGBSurfaceFrom documentation所说,它不为像素数据分配内存,而是获取传递给它的外部内存缓冲区。虽然完全没有复制操作有好处,但它会影响生命周期 - 注意“你必须在释放像素数据之前释放表面”。

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

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

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

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

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

正如我之前所说,您只有 mmap 整个页面,但是 python(这只是一个例子,您稍后会看到)有很多较小的对象(整数,比页面小 很多 的浮点数、较小的字符串、短数组...)。为每一个分配整个页面将是内存的巨大浪费(还有其他问题,例如由于缓存不良而导致的性能降低)。为了解决这个问题,我们所做的('we' 是每个需要较小分配的程序,即您每天使用的 99% 的程序)分配更大的内存块并跟踪该块的哪些部分是 allocated/freed完全在用户空间中(与 OS 内核跟踪的页面相反 - 在内核空间中)。这样你就可以在没有太多开销的情况下非常紧密地打包小分配,但缺点是这种分配在 OS 级别上无法区分。当你 'free' 一些小的分配被放置在那种预分配块中时,你只是在内部将这个区域标记为未使用,下次你的程序的其他部分请求一些内存你开始搜索你的地方可以放。这也意味着您通常不会 return(取消映射)内存到 OS,因为如果至少有一个字节仍在使用,您将无法归还该块。

Python internally manages small objects (<512b) itself, by allocating 256kb blocks and placing objects in that blocks. If larger allocation is required - it passes it to libc malloc (python itself is written in C and uses libc; the most popular libc for linux is glibc). And malloc documentation 对于 glibc 表示如下:

When allocating blocks of memory larger than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as a private anonymous mapping using mmap(2). MMAP_THRESHOLD is 128 kB by default, but is adjustable using mallopt(3)

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

M_MMAP_THRESHOLD is the request size threshold for using mmap()
to service a request. Requests of at least this size that cannot be allocated using already-existing space will be serviced via mmap.
(If enough normal freed space already exists it is used instead.)

...

The implementation works with a sliding threshold, which is by default limited to go between 128Kb and 32Mb (64Mb for 64 bitmachines) and starts out at 128Kb as per the 2001 default.

...

The threshold goes up in value when the application frees memory that was allocated with the mmap allocator. The idea is that once the application starts freeing memory of a certain size, it's highly probable that this is a size the application uses for transient allocations.

因此,它会尝试适应您的分配行为以平衡性能与将内存释放回 OS。

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

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