使用mmap改进文件I / O

2个月前 147次点击 来自 其他

收录专题: Python边学边译

标签: Python

原文链接:Python mmap: Improved File I/O With Memory Mapping

Python的 mmap 提供了基于内存映射的文件输入和输出。它使您可以利用较低级别的操作系统功能来读取文件,就好像是一个大字符串或数组一样。使得需要大量文件I / O中大幅提高性能。

在本教程中,您将学习:

  • 存在哪些类型的计算机内存
  • mmap解决什么问题
  • 如何使用内存映射更快地读取大文件
  • 如何在不重写整个文件的情况下更改文件的一部分
  • 如何使用mmap在多个进程之间共享信息

了解计算机内存

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

计算机内存是一个大而复杂的主题,但是本教程仅侧重于使用mmap模块所需的知识。在本教程中,术语“内存”是指随机存取内存或RAM。

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

  1. 物理Physical
  2. 虚拟Virtual
  3. 共享Shared

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

mmap使用共享内存在多个Python进程,线程和任务之间有效地共享大量数据。

深入研究文件I / O

现在,我们来了解一下什么是内存映射以及它可以解决什么问题了。内存映射是执行文件I / O的另一种方法,可以提高性能和内存效率。

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

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

上述代码会将整个文件读入物理内存,然后将其打印到屏幕上。

系统调用

实际上,read()信号通知操作系统要完成许多复杂的工作。幸运的是,操作系统提供了一种通过系统调用将每个硬件设备的特定详细信息从程序中抽象出来的方法。每个操作系统将以不同的方式实现此功能,但是至少read()必须执行几个系统调用才能从文件中检索数据。

对物理硬件的所有访问,都必须在称为内核空间的受保护环境中进行。系统调用是操作系统提供的API,它允许您的程序从用户空间进入内核空间,在内核空间中管理物理硬件的低级详细信息。为了调用read(),操作系统需要几个系统调用才能与物理存储设备进行交互并返回数据。

但是,您无需完全了解系统调用和计算机体系结构的详细信息即可了解内存映射。只需知道,从计算的角度来讲,系统调用相对昂贵,因此执行的系统调用越少,代码执行的速度就越快。

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

通常,这一切发生得如此之快以至于无法观察。但是所有这些层都会增加延迟,并可能减慢您的程序速度。这是内存映射起作用的地方。

内存映射优化

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

内存映射文件I / O方法会牺牲内存使用量来提高速度,这通常称为时空权衡。但是,内存映射不必使用比传统方法更多的内存。它将按要求延迟加载数据,类似于Python生成器的工作方式。

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

使用mmap读取内存映射文件

这是您之前看到的文件I / O代码的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:
            text = mmap_obj.read()
            print(text)

该代码将整个文件作为字符串读取到内存中,然后将其打印到屏幕上,就像以前使用常规文件I / O的方法一样。

简而言之,使用mmap与读取文件的传统方式非常相似,但有一些小的更改:

  1. 使用打开文件open()还不够,还需使用mmap.mmap()向操作系统发送信号,告知您要将文件映射到RAM。

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

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

创建mmap对象

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

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

mmap的第二个参数是length=0。这是内存映射的字节长度。0是一个特殊值,指示系统应创建足够大的内存映射以容纳整个文件。

access参数告诉操作系统您将如何与映射的内存进行交互。选项包括ACCESS_READ,ACCESS_WRITE,ACCESS_COPY,和ACCESS_DEFAULT:

  • ACCESS_READ 创建一个只读的内存映射。
  • ACCESS_DEFAULT为prot可选参数中指定的默认模式,该模式用于内存保护。
  • ACCESS_WRITE和ACCESS_COPY是两种写入模式,您将在下面了解这些模式。

mmap字符串对象

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

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模块还允许其他类似字符串的行为,例如使用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]

同样,仅需对代码进行一些小调整,您的内存映射方法就会更快。

使用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)

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

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()

写模式

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

  1. ACCESS_WRITE 数据将通过内存写入并保留在磁盘上。
  2. ACCESS_COPY 即使调用flush()更改,也不会将更改写入磁盘。

换句话说,ACCESS_WRITE既写入内存又写入文件,而ACCESS_COPY仅写入内存而不写入底层文件。

搜索和替换文字

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

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]

在这种基本的搜索和替换方案中,内存映射会导致代码更简洁一些,但并不总是会大大提高速度。

使用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.StringIO或io.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字节缓冲区,并允许从两个进程读取和写入该缓冲区。如果您想节省内存并仍然在多个进程之间共享大量数据,则此方法很有用。

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

  • 不必在进程之间复制数据。
  • 操作系统透明地处理内存。
  • 不必在进程之间对数据进行picke,从而节省了CPU时间。

说到 picke,值得指出的是,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])

在这里,您尝试创建一个新进程,并将其传递给内存映射的缓冲区。这段代码将立即引发TypeError,因为mmap对象无法picke,这是将数据传递给第二个进程所必需的。因此,要与内存映射共享数据,您需要坚持使用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个字符的列表,并修改了另一个process的前50个字符。

请注意,只有缓冲区的名称传递给第二个进程后,第二个进程才可以使用唯一名称检索相同的内存块。这是由mmap提供支持的shared_memory模块的一项特殊功能。在后台,该shared_memory模块使用不同操作系统的特定API为您创建已命名的内存映射。

结论

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

Card image cap
开发者雷

尘世间一个小小的开发者,每天增加一些无聊的知识,就不会无聊了

要加油~~~

技术文档 >> 系列应用 >>
热推应用
Let'sLearnSwift
学习Swift的入门教程
PyPie
Python is as good as Pie
标签