10.7 通过缓冲区协议实现零复制

通常程序都需要处理大量的大型字节格式的数组格式的数据。一旦进行复制、分片和修改等操作,以字符串的方式处理如此大量的数据是非常低效的。

设想一个读取二进制数据的大文件的小程序,并将其部分复制到另一个文件中。这里将使用memory_profilerhttps://pypi.python.org/pypi/memory_profiler)衡量内存的使用情况,memory_profiler是一个不错的Python包,可以用来逐行查看程序的内存使用情况。

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

@profile
def read_random():
  with open("/dev/urandom", "rb") as source:
    content = source.read(1024 * 10000)
    content_to_write = content[1024:]
  print("Content length: %d, content to write length %d" %
     (len(content), len(content_to_write)))
  with open("/dev/null", "wb") as target:
    target.write(content_to_write)
if __name__ == '__main__':
  read_random()

接下来使用memory_profiler运行上面的程序:

$ python -m memory_profiler memoryview/copy.py
Content length: 10240000, content to write length 10238976
Filename: memoryview/copy.py

Mem usage  Increment  Line Contents
======================================
              @profile
9.883 MB   0.000 MB  def read_random():
9.887 MB   0.004 MB    with open("/dev/urandom", "rb") as source:
19.65 6 MB   9.770 MB      content = source.read(1024 * 10000)    ①
29.42 2 MB   9.766 MB      content_to_write = content[1024:]    ② 
29.42 2 MB   0.000 MB    print("Content length: %d, content to write length %d" %
29.43 4 MB   0.012 MB       (len(content), len(content_to_write)))
29.43 4 MB   0.000 MB    with open("/dev/null", "wb") as target:
29.43 4 MB   0.000 MB      target.write(content_to_write)

① 从/dev/urandom读取10 MB的数据且没有太多其他操作。Python为此要分配约10 MB的内存以将该数据存储为字符串。

② 复制整块的数据但是减去开始的1 KB,因为我们不想将最开始的1 KB数据写入目标文件中。

这个例子中有意思的地方在于,正如你看到的,内存的使用在构造变量content_to_write时增长到了约10 MB。事实上,分片操作符会复制全部的内容,减去开始的1 KB后写入一个新的字符串对象中。

当处理大量数据时,针对大的字节数组执行此类操作可能会变成一场灾难。如果写过C语言代码,应该知道使用memcpy()的开销是巨大的,无论是内存的占用还是对通常意义上的性能来说,复制内存都是缓慢的。

但是作为C程序员,你应该也知道字符串是字符的数组,完全可以通过基本的指针算法的使用查看数组的某一部分但不复制数组2

在Python中可以使用实现了缓冲区协议的对象。PEP 3118(https://www.python.org/ dev/peps/pep-3118/)定义了缓冲区协议,其中解释了用于为不同数据类型(如字符串类型)提供该协议的C API。

对于实现了该协议的对象,可以使用其memoryview类的构建函数去构造一个新的memoryview对象,它会引用原始的对象内存。

示例如下:

>>> s = b"abcdefgh"
>>> view = memoryview(s)
>>> view[1]
98 ①
>>> limited = view[1:3]
<memory at 0x7fca18b8d460>
>>> bytes(view[1:3])
b'bc'

① 字母b的ASCII码。

在这个例子中,会利用memoryview对象的切片运算符本身返回一个memoryview对象的事实。这意味着它不会复制任何数据,而只是引用了原始数据的一个特定分片,如图10-2所示。

阅读 ‧ 电子书库

图10-2 对memoryview对象使用切片

出于这一点考虑,我们可以重写这个程序,这次对数据的引用将使用memoryview对象。

@profile
def read_random():
  with open("/dev/urandom", "rb") as source:
    content = source.read(1024 * 10000)
    content_to_write = memoryview(content)[1024:]
  print("Content length: %d, content to write length %d" %
     (len(content), len(content_to_write)))
  with open("/dev/null", "wb") as target:
    target.write(content_to_write)

if __name__ == '__main__':
  read_random()

这个程序只使用了第一个版本约一半的内存:

$ python -m memory_profiler memoryview/copy-memoryview.py
Content length: 10240000, content to write length 10238976
Filename: memoryview/copy-memoryview.py

  Mem usage  Increment  Line Contents
======================================
                       @profile
  9.887 MB  0.000 MB  def read_random():
  9.891 MB  0.004 MB  with open("/dev/urandom", "rb") as source:
  19.66 0 MB  9.770 MB  content = source.read(1024 * 10000)    ①
  19.66 0 MB  0.000 MB  content_to_write = memoryview(content)[1024:]    ②
  19.66 0 MB  0.000 MB  print("Content length: %d, content to write length %d" %
  19.67 2 MB  0.012 MB  (len(content), len(content_to_write)))
  19.67 2 MB  0.000 MB  with open("/dev/null", "wb") as target:
  19.67 2 MB  0.000 MB  target.write(content_to_write)

① 从/dev/urandom读取10 MB的数据且没有太多其他操作。Python为此要分配约10 MB的内存以将该数据存储为字符串。

② 直接引用整块数据减去开始的1 KB的数据,因为我们不想将最开始的1 KB数据写入目标文件中。没有复制意味着没有额外的内存开销。

当处理socket(套接字)时这类技巧尤其有用。如你所知,当数据通过socket发送时,它不会在一次调用中发送所有数据。下面是一个简单的实现:

import socket
s = socket.socket(…)
s.connect(…)
data = b"a" * (1024 * 100000)  ①
while data:
  sent = s.send(data)
  data = data[sent:]   ②

① 构建一个字节对象,包含1亿多个字母a。

② 移除前面已经发送的字节。

显然,通过这种机制,需要不断地复制数据,直到socket将所有数据发送完毕。而使用memoryview可以实现同样的功能而无需复制数据,也就是零复制。

import socket
s = socket.socket(…)
s.connect(…)
data = b"a" * (1024 * 100000)     ①
mv = memoryview(data)
while mv:
  sent = s.send(mv)
  mv = mv[sent:]        ②

① 构建一个字节对象,包含1亿多个字母a。

② 构建一个新的memoryview对象,指向剩余将要发送的数据。

这段程序不会复制任何内容,不会使用额外的内存,也就是说只是像开始时那样要给变量分配100 MB内存。

前面已经看到了将memoryview对象用于高效地写数据的场景,同样的方法也可以用在读数据时。Python中的大部分I/O操作知道如何处理实现了缓冲区协议的对象。它们不仅可以从这类对象中读,还可以向其中写。在这个例子中不需要memoryview对象,只需要让I/O函数写入预分配的对象。

>>> ba = bytearray(8)
>>> ba
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00')
>>> with open("/dev/urandom", "rb") as source:
...   source.readinto(ba)
...
8
>>> ba
bytearray(b'`m.z\x8d\x0fp\xa1')

利用这一技术,很容易预先分配一个缓冲区(就像在C语言中一样,以减少对malloc()的调用次数)并在需要时进行填充。使用memoryview甚至可以在内存区域的任何点放入数据。

>>> ba = bytearray(8)
>>> ba_at_4 = memoryview(ba)[4:]     ①           
>>> with open("/dev/urandom", "rb") as source:
...   source.readinto(ba_at_4)       ②         
...
4
>>> ba
bytearray(b'\x00\x00\x00\x00\x0b\x19\xae\xb2')

① 引用bytearray,从其偏移索引4到其结尾。

② 将/dev/urandom的内容写入bytearray中从偏移索引4到结尾的位置,精确且高效地只读了4字节。

 提示

array模块中的对象以及struct模块中的函数都能正确地处理缓冲区协议,因此在需要零复制时都能高效完成。