Python mmap:使用内存映射改进文件 I/O

目录

Python提供了很多智慧。一个特别有用的想法是“应该有一种——最好只有一种——明显的方法来做到这一点。” 然而,在 Python 中有多种方法可以做大多数事情,而且通常是有充分理由的。例如,在 Python 中多种读取文件的方法,包括很少使用的mmap模块。

Pythonmmap提供内存映射文件输入和输出 (I/O)。它允许您利用较低级别的操作系统功能来读取文件,就好像它们是一个大字符串数组一样。这可以在需要大量文件 I/O 的代码中提供显着的性能改进。

在本教程中,您将学习:

  • 计算机内存有哪些种类

  • 你可以解决什么问题 mmap

  • 如何使用内存映射更快读取大文件

  • 如何在不重写整个文件的情况下更改文件一部分

  • 如何使用mmap共享信息的多个进程之间

了解计算机内存

内存映射是一种使用较低级别的操作系统 API 将文件直接加载到计算机内存中的技术。它可以显着提高程序中的文件 I/O 性能。为了更好地了解内存映射如何提高性能,以及如何以及何时可以使用该mmap模块来利用这些性能优势,首先了解一下计算机内存是很有用的。

计算机内存是一个大而复杂的话题,但本教程只关注mmap有效使用该模块所需的知识。在本教程中,术语内存是指随机存取内存或 RAM。

有几种类型的计算机内存:

  1. 身体的

  2. 虚拟的

  3. 共享

当您使用内存映射时,每种类型的内存都可以发挥作用,所以让我们从高层次来回顾每一种。


物理内存

物理内存是最容易理解的内存类型,因为它通常是与您的计算机相关的营销的一部分。(您可能还记得,当您购买计算机时,它宣传了大约 8 GB 的 RAM。)物理内存通常位于连接到计算机主板的卡上。

物理内存是可供程序在运行时使用的易失性内存量。物理内存不应与存储混淆,例如硬盘驱动器或固态磁盘。

虚拟内存

虚拟内存是处理内存管理的一种方式。操作系统使用虚拟内存使您看起来拥有比实际更多的内存,让您不必担心在任何给定时间有多少内存可用于您的程序。在幕后,您的操作系统使用部分非易失性存储(例如固态磁盘)来模拟额外的 RAM。

为此,您的操作系统必须维护物理内存和虚拟内存之间的映射。每个操作系统都使用自己复杂的算法,使用称为页表的数据结构将虚拟内存地址映射到物理内存地址。

幸运的是,大多数这种复杂情况都隐藏在您的程序中。您无需了解页表或逻辑到物理映射即可在 Python 中编写高性能 I/O 代码。但是,了解一点内存可以让您更好地了解计算机和图书馆正在为您处理什么。

mmap 使用虚拟内存使您看起来好像已将一个非常大的文件加载到内存中,即使该文件的内容太大而无法放入您的物理内存中。

共享内存

共享内存是操作系统提供的另一种技术,它允许多个程序同时访问相同的数据。共享内存是在使用并发的程序中处理数据的一种非常有效的方式。

Pythonmmap使用共享内存在多个 Python 进程、线程和同时发生的任务之间有效地共享大量数据。

深入了解文件 I/O

既然您对不同类型的内存有了一个高层次的了解,是时候了解什么是内存映射以及它解决了什么问题。内存映射是另一种执行文件 I/O 的方法,可以提高性能和内存效率。

为了充分理解内存映射的作用,从较低级别的角度考虑常规文件 I/O 很有用。读取文件时在幕后发生了很多事情:

  1. 通过系统调用控制权转移到内核或核心操作系统代码

  2. 与文件所在的物理磁盘交互

  3. 数据复制用户空间内核空间之间的不同缓冲区中

考虑以下代码,它执行常规 Python 文件 I/O:

def regular_io(filename):
    with open(filename, mode="r", encoding="utf8") as file_obj:
        text = file_obj.read()
        print(text)

如果在运行时有足够的可用空间,此代码会将整个文件读入物理内存,并将其打印到屏幕上。

这种类型的文件 I/O 可能是您在 Python 之旅的早期学习过的。代码不是很密集或复杂。然而,在函数调用的掩护下发生的事情read()非常复杂。请记住,Python 是一种高级编程语言,因此可以对程序员隐藏很多复杂性。

系统调用

在现实中,对read()操作系统的调用标志着操作系统要做很多复杂的工作。幸运的是,操作系统提供了一种通过系统调用将每个硬件设备的特定细节从程序中抽象出来的方法。每个操作系统都会以不同的方式实现此功能,但至少read()必须执行多个系统调用才能从文件中检索数据。

对物理硬件的所有访问都必须在称为内核空间的受保护环境中进行。系统调用是操作系统提供的 API,允许您的程序从用户空间转到内核空间,在内核空间管理物理硬件的低级细节。

在这种情况下read(),操作系统需要多次系统调用才能与物理存储设备交互并返回数据。

同样,您不需要牢牢掌握系统调用和计算机体系结构的细节来理解内存映射。要记住的最重要的事情是系统调用在计算上相对昂贵,因此您执行的系统调用越少,您的代码执行的速度就越快。

除了系统调用之外,在数据完全返回到您的程序之前,对 的调用read()还涉及在多个数据缓冲区之间进行大量潜在的不必要的数据复制。

通常,这一切都发生得如此之快,以至于不会引起注意。但是所有这些层都会增加延迟并会减慢您的程序速度。这就是内存映射发挥作用的地方。

内存映射优化

避免这种开销的一种方法是使用内存映射文件。您可以将内存映射描述为一个过程,在该过程中,读写操作会跳过上面提到的许多层,并将请求的数据直接映射到物理内存中。

内存映射文件 I/O 方法为了速度牺牲了内存使用量,这在传统上称为时空权衡。但是,内存映射不必使用比传统方法更多的内存。操作系统非常聪明。它将根据请求延迟加载数据,类似于Python 生成器的工作方式。

此外,借助虚拟内存,您可以加载比物理内存大的文件。但是,当文件没有足够的物理内存时,您不会看到内存映射带来的巨大性能改进,因为操作系统将使用较慢的物理存储介质(如固态磁盘)来模拟它缺少的物理内存.

使用 Python 读取内存映射文件 mmap

现在,抛开所有这些理论,您可能会问自己:“我如何使用 Pythonmmap创建内存映射文件?”

这是您之前看到的文件 I/O 代码的内存映射等效项:

import mmap

def mmap_io(filename):
    with open(filename, mode="r", encoding="utf8") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_READ) as mmap_obj:
            text = mmap_obj.read()
            print(text)

这段代码将整个文件作为字符串读入内存并将其打印到屏幕上,就像早期的常规文件 I/O 方法所做的那样。

简而言之, usingmmap与传统的读取文件的方式非常相似,只是有一些小的变化:

  1. 打开文件open()是不够的。您还需要使用mmap.mmap()向操作系统发出信号,表明您希望将文件映射到 RAM 中。

  2. 您需要确保您使用的模式open()mmap.mmap(). 的默认模式open()是读取,但默认模式mmap.mmap()是读取写入。因此,您在打开文件时必须明确。

  3. 您需要使用mmap对象而不是由返回的标准文件对象来执行所有读取和写入操作open()

性能影响

内存映射方法比典型的文件 I/O 稍微复杂一些,因为它需要创建另一个对象。然而,在读取只有几兆字节的文件时,这种微小的变化可以带来巨大的性能优势。这是阅读著名小说《堂吉诃德的历史》的原始文本的比较,大约2.4兆字节:

>>>

>>> import timeit
>>> timeit.repeat(
...     "regular_io(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import regular_io, filename")
[0.02022400000000002, 0.01988580000000001, 0.020257300000000006]
>>> timeit.repeat(
...     "mmap_io(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import mmap_io, filename")
[0.006156499999999981, 0.004843099999999989, 0.004868600000000001]

这测量使用常规文件 I/O 和内存映射文件 I/O 读取整个 2.4 兆字节文件的时间量。如您所见,内存映射方法大约需要 0.005 秒,而常规方法几乎需要 0.02 秒。在读取较大的文件时,这种性能改进可能会更大。

注意:这些结果是使用 Windows 10 和 Python 3.8 收集的。由于内存映射非常依赖于操作系统的实现,因此您的结果可能会有所不同。

Python 的mmap文件对象提供的 API与传统的文件对象非常相似,除了一个额外的超能力:Python 的mmap文件对象可以像字符串对象一样切片

mmap 对象创建

mmap对象创建过程中的一些微妙之处值得更仔细地研究:

mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_READ)

mmap需要一个文件描述符,它来自fileno()一个普通文件对象的方法。文件描述符是一个内部标识符,通常是一个整数,操作系统使用它来跟踪打开的文件。

的第二个参数mmaplength=0。这是内存映射的字节长度。0是一个特殊值,表示系统应该创建一个足够大的内存映射来保存整个文件。

access参数告诉操作系统您将如何与映射内存进行交互。选项包括ACCESS_READACCESS_WRITEACCESS_COPY,和ACCESS_DEFAULT。这些有点类似于modebuilt-in的参数open()

  • ACCESS_READ 创建只读内存映射。

  • ACCESS_DEFAULT默认为可选prot参数中指定的模式,用于内存保护

  • ACCESS_WRITEACCESS_COPY是两种写入模式,您将在下面了解。

文件描述符、lengthaccess参数表示创建可在 Windows、Linux 和 macOS 等操作系统上运行的内存映射文件所需的最低限度。上面的代码是跨平台的,这意味着它将通过所有操作系统上的内存映射接口读取文件,而无需知道代码在哪个操作系统上运行。

另一个有用的参数是offset,它可以是一种节省内存的技术。这指示mmap从文件中的指定偏移量开始创建内存映射。

mmap 作为字符串的对象

如前所述,内存映射透明地将文件内容作为字符串加载到内存中。因此,一旦打开文件,您就可以执行许多与字符串相同的操作,例如切片

import mmap

def mmap_io(filename):
    with open(filename, mode="r", encoding="utf8") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_READ) as mmap_obj:
            print(mmap_obj[10:20])

此代码将十个字符打印mmap_obj到屏幕上,并将这十个字符读入物理内存。同样,数据是惰性读取的。

切片不会推进内部文件位置。因此,如果您要read()在切片之后调用,那么您仍然会从文件的开头读取。

搜索内存映射文件

除了切片之外,该mmap模块还允许其他类似字符串的行为,例如使用find()rfind()搜索特定文本的文件。例如,这里有两种方法可以找到" the "文件中第一次出现的:

import mmap

def regular_io_find(filename):
    with open(filename, mode="r", encoding="utf-8") as file_obj:
        text = file_obj.read()
        print(text.find(" the "))

def mmap_io_find(filename):
    with open(filename, mode="r", encoding="utf-8") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_READ) as mmap_obj:
            print(mmap_obj.find(b" the "))

这两个函数都在文件中搜索第一次出现的" the "。它们之间的主要区别在于,第一个用于find()字符串对象,而第二个用于find()内存映射文件对象。

注意: mmap操作字节,而不是字符串。

这是性能差异:

>>>

>>> import timeit
>>> timeit.repeat(
...     "regular_io_find(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import regular_io_find, filename")
[0.01919180000000001, 0.01940510000000001, 0.019157700000000027]
>>> timeit.repeat(
...     "mmap_io_find(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import mmap_io_find, filename")
[0.0009397999999999906, 0.0018005999999999855, 0.000826699999999958]

这是几个数量级的差异!同样,您的结果可能会因您的操作系统而异。

内存映射文件也可以直接与正则表达式一起使用。考虑以下示例,该示例查找并打印出所有五个字母的单词:

import re
import mmap

def mmap_io_re(filename):
    five_letter_word = re.compile(rb"\b[a-zA-Z]{5}\b")

    with open(filename, mode="r", encoding="utf-8") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_READ) as mmap_obj:
            for word in five_letter_word.findall(mmap_obj):
                print(word)

此代码读取整个文件并打印出其中恰好包含五个字母的每个单词。请记住,内存映射文件使用字节字符串,因此正则表达式也必须使用字节字符串。

这是使用常规文件 I/O 的等效代码:

import re

def regular_io_re(filename):
    five_letter_word = re.compile(r"\b[a-zA-Z]{5}\b")

    with open(filename, mode="r", encoding="utf-8") as file_obj:
        for word in five_letter_word.findall(file_obj.read()):
            print(word)

这段代码也打印出文件中所有五个字符的单词,但它使用传统的文件 I/O 机制而不是内存映射文件。和以前一样,两种方法的性能不同:

>>>

>>> import timeit
>>> timeit.repeat(
...     "regular_io_re(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import regular_io_re, filename")
[0.10474110000000003, 0.10358619999999996, 0.10347820000000002]
>>> timeit.repeat(
...     "mmap_io_re(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import mmap_io_re, filename")
[0.0740976000000001, 0.07362639999999998, 0.07380980000000004]

内存映射方法仍然快一个数量级。

作为文件的内存映射对象

内存映射文件是部分字符串和部分文件,因此mmap,您还可以执行常见的文件操作,如seek()tell()readline()。这些函数的工作方式与它们的常规文件对象对应物完全一样。

例如,以下是如何搜索文件中的特定位置,然后执行单词搜索:

import mmap

def mmap_io_find_and_seek(filename):
    with open(filename, mode="r", encoding="utf-8") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_READ) as mmap_obj:
            mmap_obj.seek(10000)
            mmap_obj.find(b" the ")

此代码将寻找10000文件中的位置,然后找到第一次出现的位置" the "

seek() 在内存映射文件上的工作方式与在常规文件上的工作方式完全相同:

def regular_io_find_and_seek(filename):
    with open(filename, mode="r", encoding="utf-8") as file_obj:
        file_obj.seek(10000)
        text = file_obj.read()
        text.find(" the ")

这两种方法的代码非常相似。让我们看看它们的性能比较:

>>>

>>> import timeit
>>> timeit.repeat(
...     "regular_io_find_and_seek(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import regular_io_find_and_seek, filename")
[0.019396099999999916, 0.01936059999999995, 0.019192100000000045]
>>> timeit.repeat(
...     "mmap_io_find_and_seek(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import mmap_io_find_and_seek, filename")
[0.000925100000000012, 0.000788299999999964, 0.0007854999999999945]

同样,在对代码进行一些小的调整之后,您的内存映射方法要快得多。

用 Python 编写内存映射文件 mmap

内存映射对于读取文件最有用,但您也可以使用它来写入文件。mmap除了一些不同之外,用于写入文件的API 与常规文件 I/O 非常相似。

这是将文本写入内存映射文件的示例:

import mmap

def mmap_io_write(filename, text):
    with open(filename, mode="w", encoding="utf-8") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_WRITE) as mmap_obj:
            mmap_obj.write(text)

此代码将文本写入内存映射文件。但是,ValueError如果创建mmap对象时文件为空,则会引发异常。

Python 的mmap模块不允许空文件的内存映射。这是合理的,因为从概念上讲,一个空的内存映射文件只是一个内存缓冲区,因此不需要内存映射对象。

通常,内存映射用于读或读/写模式。例如,以下代码演示了如何快速读取文件并仅修改其中的一部分:

import mmap

def mmap_io_write(filename):
    with open(filename, mode="r+") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_WRITE) as mmap_obj:
            mmap_obj[10:16] = b"python"
            mmap_obj.flush()

此函数将打开一个已包含至少 16 个字符的文件,并将字符 10 到 15 更改为"python".

写入的更改mmap_obj在磁盘和内存中的文件中都是可见的。Python 官方文档建议始终调用flush()以确保将数据写回磁盘。

写入模式

写操作的语义由access参数控制。写入内存映射文件和常规文件的区别之一是access参数的选项。有两个选项可以控制如何将数据写入内存映射文件:

  1. ACCESS_WRITE 指定直写语义,这意味着数据将通过内存写入并保留在磁盘上。

  2. ACCESS_COPY不将更改写入磁盘,即使flush()被调用。

换句话说,ACCESS_WRITE写入内存和文件,而ACCESS_COPY只写入内存而不写入底层文件。

搜索和替换文本

内存映射文件将数据作为字节串公开,但与常规字符串相比,该字节串还有另一个重要优势。内存映射文件数据是一串可变字节。这意味着编写代码来搜索和替换文件中的数据会更加直接和高效:

import mmap
import os
import shutil

def regular_io_find_and_replace(filename):
    with open(filename, "r", encoding="utf-8") as orig_file_obj:
        with open("tmp.txt", "w", encoding="utf-8") as new_file_obj:
            orig_text = orig_file_obj.read()
            new_text = orig_text.replace(" the ", " eht ")
            new_file_obj.write(new_text)

    shutil.copyfile("tmp.txt", filename)
    os.remove("tmp.txt")

def mmap_io_find_and_replace(filename):
    with open(filename, mode="r+", encoding="utf-8") as file_obj:
        with mmap.mmap(file_obj.fileno(), length=0, access=mmap.ACCESS_WRITE) as mmap_obj:
            orig_text = mmap_obj.read()
            new_text = orig_text.replace(b" the ", b" eht ")
            mmap_obj[:] = new_text
            mmap_obj.flush()

这两个函数的字改" the "" eht "指定的文件英寸 如您所见,内存映射方法大致相同,但不需要手动跟踪额外的临时文件以进行适当的替换。

在这种情况下,对于这个文件长度,内存映射方法实际上稍微慢一些。因此,对内存映射文件进行完整的搜索和替换可能是也可能不是最有效的方法。这可能取决于许多因素,例如文件长度、机器的 RAM 速度等。也可能存在一些操作系统缓存会导致时间偏差。如您所见,常规 IO 方法会加快每次调用的速度。

>>>

>>> import timeit
>>> timeit.repeat(
...     "regular_io_find_and_replace(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import regular_io_find_and_replace, filename")
[0.031016973999996367, 0.019185273000005054, 0.019321329999996806]
>>> timeit.repeat(
...     "mmap_io_find_and_replace(filename)",
...     repeat=3,
...     number=1,
...     setup="from __main__ import mmap_io_find_and_replace, filename")
[0.026475408999999672, 0.030173652999998524, 0.029132930999999473]

在这个基本的搜索和替换场景中,内存映射会生成稍微简洁的代码,但并不总是能大幅提高速度。正如他们所说,“您的里程可能会有所不同。”

使用 Python 在进程之间共享数据 mmap

到目前为止,您只将内存映射文件用于磁盘上的数据。但是,您也可以创建没有物理存储的匿名内存映射。这可以通过-1作为文件描述符传递来完成:

import mmap

with mmap.mmap(-1, length=100, access=mmap.ACCESS_WRITE) as mmap_obj:
    mmap_obj[0:100] = b"a" * 100
    print(mmap_obj[0:100])

这会在 RAM 中创建一个匿名内存映射对象,其中包含100字母 的副本"a"

匿名内存映射对象本质上是length内存中由参数指定的特定大小的缓冲区。缓冲区类似于io.StringIOio.BytesIO来自标准库。但是,匿名内存映射对象支持跨多个进程共享,这既io.StringIO不允许也io.BytesIO不允许。

这意味着您可以使用匿名内存映射对象在进程之间交换数据,即使这些进程具有完全独立的内存和堆栈。这是创建匿名内存映射对象以共享可以从两个进程写入和读取的数据的示例:

import mmap

def sharing_with_mmap():
    BUF = mmap.mmap(-1, length=100, access=mmap.ACCESS_WRITE)

    pid = os.fork()
    if pid == 0:
        # Child process
        BUF[0:100] = b"a" * 100
    else:
        time.sleep(2)
        print(BUF[0:100])

使用此代码,您可以创建一个内存映射100字节缓冲区,并允许从两个进程读取和写入该缓冲区。如果您想节省内存并仍然跨多个进程共享大量数据,则此方法很有用。

使用内存映射共享内存有几个优点:

  • 不必在进程之间复制数据。

  • 操作系统透明地处理内存。

  • 数据不必在进程之间进行腌制,从而节省了 CPU 时间。

说到pickling,值得指出的mmap是,它与内置multiprocessing模块等更高级别、功能更全的 API 不兼容。该multiprocessing模块需要在进程之间传递的数据来支持pickle 协议,但mmap它不支持。

您可能想使用multiprocessing代替os.fork(),如下所示:

from multiprocessing import Process

def modify(buf):
    buf[0:100] = b"xy" * 50

if __name__ == "__main__":
    BUF = mmap.mmap(-1, length=100, access=mmap.ACCESS_WRITE)
    BUF[0:100] = b"a" * 100
    p = Process(target=modify, args=(BUF,))
    p.start()
    p.join()
    print(BUF[0:100])

在这里,您尝试创建一个新进程并将其传递给内存映射缓冲区。这段代码会立即引发a,TypeError因为mmap对象不能被pickle,这是将数据传递给第二个进程所必需的。因此,要与内存映射共享数据,您需要坚持使用较低级别的os.fork().

如果您使用的是 Python 3.8 或更新版本,那么您可以使用新shared_memory模块更有效地跨 Python 进程共享数据:

from multiprocessing import Process
from multiprocessing import shared_memory

def modify(buf_name):
    shm = shared_memory.SharedMemory(buf_name)
    shm.buf[0:50] = b"b" * 50
    shm.close()

if __name__ == "__main__":
    shm = shared_memory.SharedMemory(create=True, size=100)

    try:
        shm.buf[0:100] = b"a" * 100
        proc = Process(target=modify, args=(shm.name,))
        proc.start()
        proc.join()
        print(bytes(shm.buf[:100]))
    finally:
        shm.close()
        shm.unlink()

这个小程序创建一个100字符列表并修改另一个进程的前 50 个字符。

请注意,只有缓冲区的名称会传递给第二个进程。然后第二个进程可以使用唯一名称检索相同的内存块。这是shared_memorymmap. 在幕后,该shared_memory模块使用每个操作系统的独特 API 为您创建命名内存映射。

现在你知道了Python 3.8新特性的一些底层实现细节以及如何mmap直接使用!


结论

内存映射是 Python 程序可通过mmap模块使用的文件 I/O 的替代方法。内存映射使用较低级别的操作系统 API 将文件内容直接存储在物理内存中。这种方法通常会提高 I/O 性能,因为它避免了许多昂贵的系统调用并减少了昂贵的数据缓冲区传输。

在本教程中,您学习了:

  • 物理内存虚拟内存共享内存之间的区别是什么?

  • 如何通过内存映射优化内存使用

  • 如何使用 Python 的mmap模块在代码中实现内存映射

mmapAPI 类似于常规文件 I/O API,因此测试起来相当简单。在您自己的代码中试一试,看看您的程序是否可以从内存映射提供的性能改进中受益。


(完)