Python mmap:内存映射文件原理与大文件处理实战

发布时间:2026/10/16 6:37:26
Python mmap:内存映射文件原理与大文件处理实战 聊到 Python 处理大文件很多人第一反应是open().read()但文件一上 GB这条路就不太好走了。mmap也就是 memory-mapped file能让你把文件的一块区域直接映射到进程的虚拟内存空间后续对这块内存的读写等价于对文件的读写。最早接触这个概念时我也被“文件怎么变成内存”这个问题卡了很久后来真正用它处理一个几 GB 的索引文件才彻底明白这块工具的价值。这篇文章会围绕 Python 的mmap模块把底层原理、常用 API、真实场景以及我踩过的坑一次说清楚尤其适合做后端、做数据处理或者想了解系统编程的读者参考。1. mmap 到底在解决什么问题1.1 先从一次普通的文件读取说起我一直觉得搞懂一个技术最好的方式是先看它替换掉的是什么。常规读文件是这么干的open()拿到文件描述符然后read(size)把数据读进 Python 字节串。这个过程里数据在底层会被拷贝一次磁盘数据先进入内核的文件缓存再从内核缓存复制到用户态内存最后变成 Python 的bytes对象。数据量小的时候无所谓但如果文件有 10 GBread()一次性读进来光内存就扛不住就算分段读每次调用都有系统调用和拷贝开销代码也会变得很别扭。mmap换了一个思路。它不再帮你把数据“搬运”到内存里而是把文件的某个区域映射到进程的虚拟地址空间让进程可以像访问内存一样访问文件。你可以理解成操作系统给文件和一块内存区域牵了一根管子你在这头读写字节那头就会对应地读取或修改文件。第一次看到这个描述的人可能会担心“那是不是文件全部加载到内存了”其实不是它只是建立了映射关系被访问到的页才会真正加载到物理内存里。1.2 mmap 的底层工作方式要理解 Python 里怎么用mmap我觉得有必要先简单说清底层的三个概念虚拟内存、分页和缺页中断。操作系统为每个进程维护一张页表把虚拟地址映射到物理内存当你访问一块已经映射但还没载入物理内存的页面时CPU 会触发缺页异常操作系统再去磁盘读取相应内容放进页缓存然后更新页表。对普通文件read()来说这个流程也会发生但多了一步把页缓存里的数据复制到用户缓冲区。mmap省掉的正是这一步复制。文件内容被映射之后进程地址空间的这段区域直接对应着页缓存里的页面程序员用指针或下标访问它时本质上就是访问页缓存中的数据。没有多一次内存拷贝也没有额外的read()系统调用性能自然有优势。也因此多个进程如果映射同一个文件它们看到的是同一份物理页面天然就实现了共享内存。这是mmap与普通文件 IO 最大的一个差异点也是很多服务端程序拿它来做进程间通信的原因。1.3 Python 的 mmap 和普通文件 IO 怎么选Python 的mmap模块不是一个新的文件对象它更像给文件开的一扇直接通往内存的“窗口”。用一个简单的对比比较维度mmap普通 read / write用户态拷贝一般没有直接访问页缓存每次 IO 都有一次数据拷贝大文件读取适合按需分页访问内存占用低一次性读取容易爆内存随机访问随机偏移访问非常快每次 seek read 都是系统调用进程间共享映射同一文件天然共享内存需要管道、队列等额外拷贝顺序小数据建立映射开销大不划算简单直接效率高对截断等错误可能触发 SIGBUS风险更高以普通异常暴露我现在的选择标准很简单小文件、一次性读完、纯顺序处理用read()需要频繁随机访问大文件、或者要在多进程之间共享一块内存优先考虑mmap。后面四个大节会围绕这些使用场景展开。2. 核心 API 与使用方法拆解2.1 创建映射mmap.mmap()Python 中创建一个内存映射非常简单import mmap with open(demo.bin, rb) as fp: mm mmap.mmap(fp.fileno(), 0, accessmmap.ACCESS_WRITE) # 使用 mm 对象 ... mm.close()这里有两个高频参数需要理解。第一个是length。如果传0表示映射从当前偏移开始一直到文件结尾的整个文件如果传具体的整数就是映射固定长度。注意不管哪种方式底层都有“页”的单位映射长度最终会按系统页大小对齐页大小通常可以通过mmap.PAGESIZE拿到。第二个是offset。它表示从文件哪个位置开始映射默认是文件开头。但这里有个非常容易踩的坑offset必须是系统页大小的整数倍。我之前在某台 Linux 机器上传了个普通偏移量结果直接抛了ValueError。如果你确实要映射一个任意位置可以先做一个页对齐page mmap.PAGESIZE aligned_offset (desired_offset // page) * page mm mmap.mmap(fp.fileno(), length, accessmmap.ACCESS_READ, offsetaligned_offset)另外mmap对象支持上下文管理器日常推荐这么写with open(demo.bin, rb) as fp: with mmap.mmap(fp.fileno(), 0, accessmmap.ACCESS_WRITE) as mm: mm[0:4] bDATA这样能省去手动close()的麻烦映射关闭后文件对象也不需要立刻关但在多进程场景里要保持一致。2.2 三种访问模式ACCESS_READ、ACCESS_WRITE、ACCESS_COPY这是 Pythonmmap最容易让人困惑的地方尤其是ACCESS_COPY很多人用错了还会怀疑是系统问题。ACCESS_READ只读映射。修改数据时操作会直接抛异常适合配置文件、只读索引等场景。ACCESS_WRITE读写映射。修改内容最终会写回文件是默认且最常用的模式。ACCESS_COPY“写时复制”映射。修改只对当前进程的内存视图生效不会落到文件上。适合临时给只读文件打补丁、做测试。我举个例子说明三者的差异。假设文件里原本是AAAAwith open(demo.bin, rb) as fp: with mmap.mmap(fp.fileno(), 0, accessmmap.ACCESS_COPY) as mm: mm[0:4] bBBBB print(mm[0:4]) # 输出 bBBBB with open(demo.bin, rb) as fp: print(fp.read()) # 文件内容仍然是 bAAAA如果用ACCESS_WRITE同样的代码跑完文件内容就会变成BBBB。这个差异在调试时非常坑我曾在某个脚本里用ACCESS_COPY写数据程序跑完数据却“消失”了排查半天才发现是访问模式选错。2.3 常用方法read、find、切片与二进制写入创建一个mmap对象后它有很多类似文件对象的方法但也有一些专有的操作。我挑几个真正会高频用到的read(size)从当前位置读size个字节返回bytes。seek(pos)和tell()移动/查看当前位置和普通文件对象一致。find(sub[, start[, end]])在映射区域里搜索子串返回起始下标找不到返回-1。这在分析二进制文件或日志时很有用。move(dest, src, count)在映射区域内部复制一段数据类似memmove注意源和目的可以重叠。flush([offset, size])把映射区域里的脏页写回磁盘。虽然进程退出时系统也会回写但为了数据安全建议显式调用。切片是mmap非常好用的特性可以直接按字节数组来读写# 读取前 8 个字节 header mm[0:8] # 修改第 4 到第 8 个字节 mm[4:8] bNEW! # 单字节下标返回整数 print(mm[0]) # 类似 bytes 操作需要注意切片赋值的右侧长度必须和左侧一致否则会抛ValueError。比如mm[0:4] bAB是不允许的需要先补齐长度。处理二进制数据时配合struct模块会很顺手import struct # 从偏移 48 处读取一个无符号 32 位整数 value struct.unpack_from(I, mm, 48)[0] # 在偏移 48 处写入新的值 struct.pack_into(I, mm, 48, 1024)使用unpack_from和pack_into比先seek再read/write更简洁也不会污染文件指针位置。2.4 修改、刷新与关闭的顺序不少新手会在 IO 结束时问“要不要先关文件对象再关 mmap”我的建议是先关闭mmap再关闭文件对象。虽然映射建立后继续持有文件对象不是必需但在跨平台开发时保持这个顺序最稳妥。写完数据后最好调用flush()。不用它修改通常也会在关闭映射或进程退出时写回但那依赖操作系统的回写时机万一进程中途崩溃数据可能丢。在需要强一致性的业务数据场景写完立刻flush()是基本习惯。flush()也可以只刷一部分区域用法是传offset和size。不过这个offset同样是页对齐到映射起点不是文件偏移调用前最好先确认。如果只是简单场景无参数调用最省心。3. 典型场景实战3.1 随机读取大文件的部分内容我以前处理过一个场景磁盘上有一个几十 GB 的二进制特征文件每条记录是固定长度程序需要根据索引随机读取某几条记录。用普通read()写的话每次都要seek()到指定偏移再read(size)相当于连续两次系统调用如果请求量大系统调用开销非常明显。改用mmap后的代码非常短import mmap def read_record(file_path, offset, record_size): with open(file_path, rb) as fp: with mmap.mmap(fp.fileno(), 0, accessmmap.ACCESS_READ) as mm: return mm[offset:offset record_size]这种方式每次打开映射、建立映射关系也有成本所以真正的线上代码不会这么频繁开关。更好的做法是在服务启动时持有一个mmap对象查询时直接用切片访问。映射只建立了虚拟地址空间和文件关系并不会一下子把大文件全部读进内存所以持有它并不代表占用大块物理内存。如果目标是文本文件、想找某些关键字find()比逐行readline()再判断前缀更高效。例如循环查找某个标记pos 0 while True: idx mm.find(bMARK, pos) if idx -1: break # 处理这一块 print(idx) pos idx 1文件里的数据永远不会被整体复制成 Python 对象遍历时只产生必要的小切片。对大日志、大索引这类只读场景性能提升是实打实的。3.2 多个进程通过 mmap 共享数据如果说随机访问只是“自己方便”那多进程共享才是mmap的价值高光。父进程先创建一个固定大小的文件多个子进程各自打开这个文件并映射到内存然后通过约定好的区域分工读写。下面是一个简单的思路假设有 4 个 worker每个 worker 只处理自己的槽位。import mmap import multiprocessing as mp def worker(worker_id, file_path, slot_size): with open(file_path, rb) as fp: with mmap.mmap(fp.fileno(), 0, accessmmap.ACCESS_READ) as mm: start worker_id * slot_size chunk mm[start:start slot_size] # 这里解析并处理 chunk print(worker_id, chunk[:4]) if __name__ __main__: path shared.bin slot_size 1024 worker_count 4 with open(path, wb) as fp: fp.write(b\x00 * (slot_size * worker_count)) with mp.Pool(worker_count) as pool: pool.starmap(worker, [(i, path, slot_size) for i in range(worker_count)])注意这里的写法是让每个子进程自己去open()然后创建映射。为什么不直接把父进程的mmap对象传进子进程因为在multiprocessing默认的启动方式下文件描述符和映射对象并不一定能被安全地继承。最稳妥的跨平台做法是传文件路径让每个进程独立创建映射。多进程共享文件还要解决同步问题。如果两个进程同时写同一片区域读写方可能看到中间状态。常见方案有每个进程只写自己的独立槽位互不干扰。使用multiprocessing.Lock保护关键区域。通过一个槽位专门做状态标记其他进程读它时校验版本号。mmap本身没有任何锁机制它只是共享内存的载体业务同步逻辑必须自己在外面做。3.3 用 mmap 实现配置热更新还有一个很有意思的用法配置热更新。服务进程只需要持有一个固定长度的配置区域管理进程修改同一个文件后服务进程在下次读取时就能看到新内容。配置文件的长度如果固定可以把整个配置区域映射成ACCESS_READ# 服务端 with open(config.bin, rb) as fp: mm mmap.mmap(fp.fileno(), 0, accessmmap.ACCESS_READ) # 周期性读取 while True: config_raw mm[0:64] # 解析 config_raw如果版本号变了就 reload time.sleep(1)管理端用ACCESS_WRITE打开同一文件写入新内容后flush()。只要写入时先更新版本号再更新本体的顺序不对读取端就能判断出当前配置是否完整。常用协议是在头部放一个长度或版本读取端先看版本号再读配置内容如果版本更新但内容解析失败就继续用旧配置。这样既避免了重启进程还能做到灰度更新。这种方案比用独立配置文件加 watch 更轻量尤其是配置频繁变化、又不希望引入额外守护进程时非常合适。4. 常见问题与排查经验实录4.1 映射空文件或文件长度变化导致报错用mmap(f.fileno(), 0)映射整个文件时如果文件是空的会在不同平台上报出不同类型的异常常见的包括ValueError或OSError。这是因为操作系统无法把一个长度为 0 的区域映射成非空地址空间。解决办法也很直接创建文件时就写入足够长度的内容或者显式指定一个大于 0 的length。更隐蔽的是“映射后文件被截断”。假设映射了 100 MB 文件另一个进程把文件truncate成 10 MB这时代码再去访问映射区域中超出文件大小的部分在类 Unix 系统上有可能直接触发SIGBUS。这个信号不是普通的 Python 异常进程可能直接崩溃连try/except都拦不住。我的经验是如果业务需要动态扩展或截断文件尽量在修改文件前重新创建映射。读写过程里也尽量避免对文件做不可控的truncate如果必须做务必先协调好所有使用方先解除映射再改文件。4.2 Windows 上 offset 必须按页对齐offset必须是页大小整数倍这个问题我在 Linux 和 Windows 上都碰到过。只写“固定偏移”的代码在 Linux 上可能正常运行换到 Windows 就抛ValueError原因就是页大小或默认映射规则不同。实际操作中我习惯自己做一个页对齐aligned (origin // mmap.PAGESIZE) * mmap.PAGESIZE mm mmap.mmap(fp.fileno(), length, accessmmap.ACCESS_READ, offsetaligned)这样无论跑在哪个平台都能避免因为页对齐而翻车。如果origin本身已经对齐这个公式也不会改变它的值多写一步并不亏。另外在 Windows 上如果通过文件对象创建映射文件对象应该以二进制模式打开并且必须有适当的读写权限。权限不匹配时ACCESS_READ映射到一个只读打开的文件上通常会失败反之也可能报权限错误。4.3 ACCESS_COPY 数据不落盘ACCESS_COPY是我见过使用错误率最高的一种模式。很多初学者会想“我映射了文件并修改它应该会写回原文件。”但ACCESS_COPY默认就是为了让修改不影响原文件。它很适合做只读快照的临时 patch却不适合作为真实数据写入。如果你发现“内存里改了文件上没变”先检查这两点是不是用了ACCESS_COPY是不是调用了flush()但没注意flush()在ACCESS_COPY下不产生落盘效果真正的写入应该选择ACCESS_WRITE并且在关键节点手动flush()。在调试跨进程共享数据时这个坑会伪装成“另一个进程没看到改动”实际上只是当前进程写入了自己的私有副本。4.4 多进程共享时的同步问题mmap在多进程下共享数据虽然高效但并发写冲突依然存在。如果两个进程同时修改同一个槽位读到中间状态是常有的事。例如一个进程先写长度字段再写数据另一个进程在两步之间读取就可能拿到不完整的数据。常用的规避手段有三个槽位隔离每个进程固定写自己的区域读别人的区域只做只读访问。锁用multiprocessing.Lock保护每次读写过程。版本号每个槽位放一个“写入中/已写完”的标记读取方先看标记。我自己最常用的是“槽位 版本号”组合虽然多个进程会竞争同一片页面但只需要用一个 64 位的版本字段区分状态基本不会出现脏读。当然如果业务需要非常严格的一致性最好还是引入专门的消息队列或数据库mmap不提供事务能力这一点不能硬扛。5. 我踩过的坑和一些小习惯聊到这里核心内容差不多讲完了。最后分享几个我实际项目里踩出来的经验。第一凡是二进制记录固定长度的场景我倾向于在文件创建阶段就预留好完整空间而不是让文件长度动态变化。mmap对“已经存在的长度”最友好动态伸缩会带来很多边界问题比如前面说的SIGBUS真碰上一次你就会记住。第二读取二进制数据时我会用struct.pack_into和struct.unpack_from而不是反复seekread。少移动文件指针的另一个好处是代码可读性更高格式定义也更容易对齐。如果记录结构很复杂可以先定义一个Struct对象一次搞定多个字段比手动切字节片段清爽不少。第三顺序读完整个大文件时我不一定选mmap。虽然它省拷贝但缺页中断数量可能很高普通read()的流式读取有很好的预读优化两者整体速度不一定差太多。mmap真正占优的是“随机访问 多次访问同一区域 多进程共享”这几个组合条件不要觉得它一定快。最后一个小技巧调试时直接用print(mm[:64])查看映射区域头部或mm[-64:]查看尾部比cat和hexdump来得快。尤其是当你有多个进程同时操作同一个映射文件时这个快速预览可以帮你快速确认内存视图是否和预期一致。mmap是那种一旦用顺了就会反复使用的工具。它不是银弹但理解了底层原理、选对场景、避开几个经典坑之后确实能让 Python 程序在处理大文件和进程通信时省下很多内存和 CPU 资源。希望这篇内容能帮你少走一些弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询