登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python mmap 怎样分段处理超过内存的大文件

来源:17golang原创

时间:2026-10-09 07:49:12 146浏览 收藏

用 Python mmap 处理超过物理内存的大文件时,不要把“映射整个文件”和“把整个文件读入 Python 堆”混为一谈。映射主要占用虚拟地址空间,文件页由操作系统按需调入;但在 32 位进程、地址空间紧张或文件特别大时,整文件映射仍可能失败。更稳妥的做法是:每次只映射一个固定大小的窗口,映射起点按 mmap.ALLOCATIONGRANULARITY 对齐,再把逻辑窗口交给解析器。

如果记录可能跨窗口,还要保留窗口末尾的未完成字节,并与下一段开头拼接。下面给出的实现以换行分隔记录为例,每轮只保留当前映射和一条未完成记录,不会把完整文件复制到内存。

官方文档:https://docs.python.org/3/library/mmap.html

分段 mmap 实现方案
  1. 先取得文件真实大小,空文件直接结束。
  2. 用逻辑偏移计算对齐偏移和页内差值。
  3. 映射“页内差值 + 当前窗口长度”,但只解析逻辑窗口。
  4. 用 find 在限定范围内找分隔符,避免复制整个窗口。
  5. 用 carry 保存跨窗尾部,并设置最大记录长度。
  6. 每段使用上下文管理器关闭,提前停止时显式关闭生成器。

为什么不能只写 mmap(fileno, 0)

在 Unix 上,length=0 表示映射调用时的当前文件大小;Windows 也支持用 0 表示当前大小,但空文件不能建立映射。对 64 位进程中的普通大文件,整文件映射往往可行,而且不等于立刻占用同等大小的物理内存。

问题在于,映射仍要保留连续虚拟地址范围。几十 GB、数百 GB 文件,或同时存在多个大映射时,整文件方案可能碰到地址空间、平台限制或资源管理难题。分段映射把最大映射范围固定在一个可控窗口内,适合批量日志、CSV、JSON Lines 和变长二进制记录。

最小配方:先对齐,再限定逻辑窗口

mmap 的 offset 不能随意取值。Python 官方文档要求它必须是 ALLOCATIONGRANULARITY 的倍数;Unix 上这个值等于页大小,Windows 上通常是系统分配粒度。业务窗口的逻辑起点不一定对齐,因此需要向下取整:

import mmap

logical_offset = 70_000_000
window_size = 64 * 1024 * 1024
granularity = mmap.ALLOCATIONGRANULARITY

# 映射起点向下对齐到系统要求的粒度
aligned_offset = logical_offset - (logical_offset % granularity)

# delta 表示逻辑窗口在本次映射中的起始位置
delta = logical_offset - aligned_offset
mapping_length = delta + window_size

真正需要处理的是 [delta, delta + window_size),不是整个映射区。最后一个窗口还要用剩余文件长度收窄,保证 aligned_offset + mapping_length 不超过文件末尾。

Python mmap 源文件、对齐偏移、逻辑窗口与跨窗记录缓存的静态关系图
图1:对齐映射、逻辑窗口与记录拼接的静态关系。该图是结构说明图,不是运行截图。

跨窗口记录怎么拼起来

如果每条记录固定长度,窗口按记录宽度对齐即可。换行文本更常见的情况是:窗口 A 的末尾只包含一条记录的前半部分,窗口 B 才出现换行符。直接逐段调用 splitlines() 会把这条记录错误拆成两条,而且会为整个窗口创建大量新 bytes 对象。

更节省的做法是用 mmap.find(b"\n", start, end) 在逻辑窗口内定位换行符。每找到一条完整记录就立即交给消费者,最后未匹配的尾部存入 carry。进入下个窗口后,只在第一条完整记录前拼接 carry。

carry 的大小必须有上限。否则一个损坏文件里数 GB 都没有换行符,程序仍会不断扩张 Python 堆,失去分段处理的意义。上限应按业务协议设置,例如日志单行允许 8 MiB,超过就报告异常数据。

完整片段:逐段产出字节行

下面的生成器返回不含换行符的 bytes。它使用 ACCESS_READ 保持只读语义,按系统粒度对齐 offset,并用上下文管理器在每个窗口完成后立即关闭映射。

from __future__ import annotations

import mmap
import os
from collections.abc import Iterator


def iter_mmap_lines(
    path: str | os.PathLike[str],
    *,
    window_size: int = 64 * 1024 * 1024,
    max_record_size: int = 8 * 1024 * 1024,
) -> Iterator[bytes]:
    """按窗口读取换行分隔记录,返回不含换行符的字节串。"""
    if window_size  max_record_size:
                        # 防止异常超长记录持续扩大 Python 堆
                        raise ValueError("记录长度超过 max_record_size")

                    yield record
                    carry = b""
                    cursor = newline + 1

                # 只复制当前窗口最后一条未完成记录
                tail = region[cursor:window_end]
                if len(carry) + len(tail) > max_record_size:
                    raise ValueError("跨窗口记录长度超过 max_record_size")
                carry += tail

            logical_offset += payload_size

        if carry:
            # 文件末尾没有换行符时,仍产出最后一条记录
            yield carry

这里没有使用 memoryview 跨过 with mmap.mmap(...) 的边界。若活动视图仍引用映射,关闭 mmap 可能失败;直接在窗口内切出当前记录的 bytes,生命周期更清晰。代价是每条完整记录会复制一次,这通常正是下游解析所需要的独立对象。

如何消费并在提前停止时释放资源

普通 for 循环读到结尾时,生成器会自然退出,当前 mmap 和文件都会关闭。如果消费者可能提前 break,可用 contextlib.closing 保证生成器收到 close(),从而立即展开内部上下文管理器:

from contextlib import closing

with closing(iter_mmap_lines("events.log")) as records:
    for raw_record in records:
        # 完整记录形成后再解码,避免拆断 UTF-8 多字节字符
        text = raw_record.decode("utf-8")
        if "FATAL" in text:
            print(text)
            # 提前停止时 closing 会关闭生成器及当前映射
            break

如果是 CSV 或 JSON Lines,建议先在字节层完成跨窗拼接,再进行解码与结构化解析。这样 UTF-8 多字节字符即使落在窗口边缘,也会随整条记录一起进入解码器。

变体:什么时候映射整个文件更简单

当文件规模可控、运行在 64 位进程、需要大量随机定位,而且文件在映射期间保持稳定时,整文件映射通常更简洁。此时可使用 length=0,通过 find 或 readline 扫描,但仍要先排除空文件,并用上下文管理器关闭。

import mmap
import os

with open("index.bin", "rb") as source:
    size = os.fstat(source.fileno()).st_size
    if size:
        # length=0 表示映射当前完整文件;只读访问禁止误写
        with mmap.mmap(source.fileno(), 0, access=mmap.ACCESS_READ) as region:
            marker = region.find(b"INDEX-V1")
            print(marker)

若任务只是从头到尾读取一次,普通带缓冲文件迭代器也可能更快、更简单。是否使用 mmap 应在真实文件大小、存储设备和访问模式下测量,不要仅凭“零拷贝”字样下结论。

兼容坑:空文件、粒度和生命周期

Python mmap 文件描述符、映射粒度、访问模式和资源状态的静态关系图
图2:mmap 构造参数、平台约束与资源状态的静态关系。该图不表示真实运行结果。
问题安全处理
空文件先用 fstat 判断大小;Windows 不允许空映射。
offset 未对齐向下对齐到 ALLOCATIONGRANULARITY,用 delta 恢复逻辑起点。
误修改源文件读取任务显式使用 ACCESS_READ,写入会抛 TypeError。
映射关闭后继续访问不要把 region 或它的活动视图带出 with;关闭后方法会抛 ValueError。
处理期间文件被截断让上游先写临时文件再原子替换,或建立外部协调,避免原文件尺寸变化。
WASI 环境官方文档标明 mmap 不可用,改用普通分块读取。
写映射需要落盘另行使用合适访问模式并调用 flush();范围 offset 也要满足页粒度要求。

常见问题

mmap 会不会把整个文件都占进内存?

不会像 read() 那样立即返回同等大小的 bytes。它建立虚拟内存映射,页面按访问情况进入物理内存;但映射范围仍占虚拟地址空间,因此极大文件适合分段。

窗口越大越快吗?

不一定。大窗口减少映射次数,小窗口降低地址空间占用并缩短单段生命周期。应按存储设备、记录分布、并发任务和解析成本实测。

为什么不直接对每段调用 splitlines?

splitlines() 会为整段产生许多对象,而且会把跨窗记录拆错。用 find 限定搜索区间,再只复制完整记录和尾部残片,更容易控制内存。

文件被另一个进程追加可以继续读吗?

当前映射长度不会自动代表一个稳定的增长快照。持续追加日志更适合轮询文件大小并建立新窗口,同时处理轮转和截断;不要让映射期间的尺寸变化处于无协调状态。

Python mmap 分段处理的关键不是把窗口设得多小,而是分清三组边界:系统要求的对齐边界、当前业务窗口的逻辑边界,以及记录本身的分隔边界。对齐 offset、限制解析范围、保留 carry、及时关闭映射,这四点组合起来,才能稳定处理真正超过内存的大文件。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>