Python之mmap内存映射模块(大文本处理)
背景:
通常在UNIX下面处理文本文件的方法是sed、awk等shell命令,对于处理大文件受CPU,IO等因素影响,对服务器也有一定的压力。关于sed的说明可以看了解sed的工作原理,本文将介绍通过python的mmap模块来实现对大文件的处理,来对比看他们的差异。
说明:
mmap是一种虚拟内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。关于系统中mmap的理论说明可以看百度百科和维基百科说明以及mmap函数介绍,这里的说明是针对在Python下mmap模块的使用说明。
使用:
1,创建:创建并返回一个 mmap 对象m
m=mmap.mmap(fileno, length[, flags[, prot[, access[, offset]]]])
fileno: 文件描述符,可以是file对象的fileno()方法,或者来自os.open(),在调用mmap()之前打开文件,不再需要文件时要关闭。
os.O_RDONLY 以只读的方式打开 Read only os.O_WRONLY 以只写的方式打开 Write only os.O_RDWR 以读写的方式打开 Read and write os.O_APPEND 以追加的方式打开 os.O_CREAT 创建并打开一个新文件 os.O_EXCL os.O_CREAT| os.O_EXCL 如果指定的文件存在,返回错误 os.O_TRUNC 打开一个文件并截断它的长度为零(必须有写权限) os.O_BINARY 以二进制模式打开文件(不转换) os.O_NOINHERIT 阻止创建一个共享的文件描述符 os.O_SHORT_LIVED os.O_TEMPORARY 与O_CREAT一起创建临时文件 os.O_RANDOM 缓存优化,但不限制从磁盘中随机存取 os.O_SEQUENTIAL 缓存优化,但不限制从磁盘中序列存取 os.O_TEXT 以文本的模式打开文件(转换)
length:要映射文件部分的大小(以字节为单位),这个值为0,则映射整个文件,如果大小大于文件当前大小,则扩展这个文件。
flags:MAP_PRIVATE:这段内存映射只有本进程可用;mmap.MAP_SHARED:将内存映射和其他进程共享,所有映射了同一文件的进程,都能够看到其中一个所做的更改;
prot:mmap.PROT_READ, mmap.PROT_WRITE 和 mmap.PROT_WRITE | mmap.PROT_READ。最后一者的含义是同时可读可写。
access:在mmap中有可选参数access的值有
ACCESS_READ:读访问。
ACCESS_WRITE:写访问,默认。
ACCESS_COPY:拷贝访问,不会把更改写入到文件,使用flush把更改写到文件。
2,方法:mmap 对象的方法,对象m
m.close() 关闭 m 对应的文件; m.find(str, start=0) 从 start 下标开始,在 m 中从左往右寻找子串 str 最早出现的下标;
m.flush([offset, n]) 把 m 中从offset开始的n个字节刷到对应的文件中; m.move(dstoff, srcoff, n) 等于 m[dstoff:dstoff+n] = m[srcoff:srcoff+n],把从 srcoff 开始的 n 个字节复制到从 dstoff 开始的n个字节,可能会覆盖重叠的部分。 m.read(n) 返回一个字符串,从 m 对应的文件中最多读取 n 个字节,将会把 m 对应文件的位置指针向后移动; m.read_byte() 返回一个1字节长的字符串,从 m 对应的文件中读1个字节,要是已经到了EOF还调用 read_byte(),则抛出异常 ValueError; m.readline() 返回一个字符串,从 m 对应文件的当前位置到下一个'\n',当调用 readline() 时文件位于 EOF,则返回空字符串; m.resize(n) ***有问题,执行不了*** 把 m 的长度改为 n,m 的长度和 m 对应文件的长度是独立的; m.seek(pos, how=0) 同 file 对象的 seek 操作,改变 m 对应的文件的当前位置; m.size() 返回 m 对应文件的长度(不是 m 对象的长度len(m)); m.tell() 返回 m 对应文件的当前位置; m.write(str) 把 str 写到 m 对应文件的当前位置,如果从 m 对应文件的当前位置到 m 结尾剩余的空间不足len(str),则抛出 ValueError; m.write_byte(byte) 把1个字节(对应一个字符)写到 m 对应文件的当前位置,实际上 m.write_byte(ch) 等于 m.write(ch)。如果 m 对应文件的当前位置在 m 的结尾,也就是 m 对应文件的当前位置到 m 结尾剩余的空间不足1个字节,write() 抛出异常ValueError,而 write_byte() 什么都不做。
方法的使用说明:介绍上面常用的方法
测试文本:test.txt,mmap对象m
-- MySQL dump 10.13 Distrib 5.6.19, for osx10.7 (x86_64) -- -- Host: localhost Database: test -- ------------------------------------------------------ -- Server version 5.6.19 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; /*!40101 SET NAMES utf8 */; /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; /*!40103 SET TIME_ZONE='+00:00' */; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
①: m.close(),关闭对象
>>> import os,mmap >>> m=mmap.mmap(os.open('test.txt',os.O_RDWR),0) #创业内存映射对象, >>> m.read(10) #可以使用方法 '-- MySQL d' >>> m.close() #关闭对象 >>> m.read(10) #方法不可用 Traceback (most recent call last): File "<stdin>", line 1, in <module> ValueError: mmap closed or invalid
②:m.find(str, start=0),从start的位置开始寻找第一次出现的str。
>>> m.find('SET',0) #从头开始查找第一次出现SET的字符串 197
③:m.read(n),返回一个从 m对象文件中读取的n个字节的字符串,将会把 m 对象的位置指针向后移动,后续读取会继续往下读。
>>> m.read(10) #读取10字节的字符串 '-- MySQL d' >>> m.read(10) #读取上面10字节后,再往后的10字节数据 'ump 10.13 '
④:m.read_byte(),返回一个1字节长的字符串,从 m 对应的文件中读1个字节
>>> m.read_byte() #读取第一个字节 '-' >>> m.read_byte() #读取第二个字节 '-' >>> m.read_byte() #读取第三个字节 ' '
⑤:m.readline():返回一个字符串,从 m 对应文件的当前位置到下一个'\n',当调用 readline() 时文件位于 EOF,则返回空字符串
>>> m.readline() #读取一正行 '-- MySQL dump 10.13 Distrib 5.6.19, for osx10.7 (x86_64)\n' >>> m.readline() #读取下一正行 '--\n'
⑥:m.size():返回 m 对应文件的长度(不是 m 对象的长度len(m))
>>> m.size() #整个文件的大小 782
⑦:m.tell():返回 m 对应文件的当前光标位置
>>> m.tell() #当前光标的位置0 0 >>> m.read(10) #读取10个字节 '-- MySQL d' >>> m.tell() #当前光标位置10 10
⑧:m.seek(pos, how=0),改变 m 对应的文件的当前位置
>>> m.seek(10) #当前光标定位到10 >>> m.tell() #读取当前光标的位置 10 >>> m.read(10) #读取当前光标之后的10字节内容 'ump 10.13 '
⑨:m.move(dstoff, srcoff, n):等于 m[dstoff:dstoff+n] = m[srcoff:srcoff+n],把从 srcoff 开始的 n 个字节复制到从 dstoff 开始的n个字节
>>> m[101:108] #切片101到108的值 '-------' >>> m[1:8] #切片1到8的值 '- MySQL' >>> m.move(1,101,8) #从101开始到后面的8字节(108),替换从1开始到后面的8字节(8)效果:m[1:8]=m[101:108] >>> m[1:8] #被替换后 '-------'
⑩:m.write(str):把 str 写到 m 对应文件的当前光标位置(覆盖对应长度),如果从 m 对应文件的当前光标位置到 m 结尾剩余的空间不足len(str),则抛出 ValueError
>>> m.tell() #当前光标位置 0 >>> m.write('zhoujy') #写入str,要是写入的大小大于原本的文件,会报错。m.write_byte(byte)不会报错。
>>> m.tell() #写入后光标位置
6
>>> m.seek(0) #重置,光标从头开始
>>> m.read(10) #查看10个字节,确定是否被修改成功
'zhoujy---d'
⑪:m.flush():把 m 中从offset开始的n个字节刷到对应的文件中
注意:对于m的修改操作,可以当成一个列表进行切片操作,但是对于切片操作的修改需要改成同样长度的字符串,否则都会报错。如m中的10个字符串进行修改,必须改成10个字符的长度。
3,应用说明:
1):读文件,ACCESS_READ
①:读取整个文件
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import contextlib f = open('test.txt', 'r') with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_READ)) as m:
#readline需要循环才能读取整个文件 while True: line = m.readline().strip() print line
#光标到最后位置(读完),就退出 if m.tell()==m.size(): break
效果:
~$ python untitled.py 1 ↵ -- ZHOUJY ---dump 10.13 Distrib 5.6.19, for osx10.7 (x86_64) -- -- Host: localhost Database: test -- ------------------------------------------------------ -- Server version 5.6.19 /*!40101 ZHOUJY SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */ZHOUJY; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; /*!40101 SET NAMES utf8 */; /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; /*!40103 SET TIME_ZONE='+00:00' */ ZHOUJY; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
②:逐步读取指定字节数文件
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import contextlib with open('test.txt', 'r') as f: with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_READ)) as m: print '读取10个字节的字符串 :', m.read(10) print '支持切片,对读取到的字符串进行切片操作:', m[2:10] print '读取之前光标后的10个字符串', m.read(10)
效果:
~$ python untitled.py 读取10个字节的字符串 : -- ZHOUJY 支持切片,对读取到的字符串进行切片操作: ZHOUJY 读取之前光标后的10个字符串 ---dump 1
2):查找文件,ACCESS_READ
①:从整个文件查找所有匹配的
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import contextlib word = 'ZHOUJY' print '查找:', word f = open('test.txt', 'r') with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_READ)) as m:
#也可以通过find(str,pos)来处理 while True: line = m.readline().strip() if line.find(word)>=0: print "结果:" print line elif m.tell()==m.size(): break else: pass
效果:
~$ python untitled.py 查找: ZHOUJY 结果: -- ZHOUJY ---dump 10.13 Distrib 5.6.19, for osx10.7 (x86_64) 结果: /*!40101 ZHOUJY SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */ZHOUJY; 结果: /*!40103 SET TIME_ZONE='+00:00' */ ZHOUJY;
②:从整个文件里查找,找到就退出(确认到底是否存在)
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import contextlib word = 'ZHOUJY' print '查找:', word f = open('test.txt', 'r') with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_READ)) as m:
#不需要循环,只要找到一个就可以了 loc = m.find(word) if loc >= 0: print loc print m[loc:loc+len(word)]
效果:
~$ python untitled.py 查找: ZHOUJY 194 ZHOUJY
③:通过正则查找,(找出40开头的数字)
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import re import contextlib pattern = re.compile(r'(40\d*)') with open('test.txt', 'r') as f: with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_READ)) as m: print pattern.findall(m)
效果:
~$ python untitled.py ['40101', '40101', '40101', '40101', '40103', '40103', '40014', '40014', '40101', '40111']
3):处理文本,只能等长处理(通过上面的查找方法,来替换查找出的内容),模式:ACCESS_WRITE、ACCESS_COPY
经过上面对mmap方法的介绍和使用说明,大致了解了mmap的特点。这里通过对比sed的方法,来看看到底处理大文件使用哪种方法更高效。
①:替换文本中出现一次的内容。比如想把A库的备份文件(9G)还原到B库,需要把里面的USE `A`改成USE `B`。
1> sed处理:时间消耗近105s;磁盘IO几乎跑满;内存几乎没消耗、CPU消耗10~20%之间。
1:替换文本中第一次出现的内容 ~$ date && sed -i '0,/USE `edcba`;/s//USE `ABCDE`;/' test.sql && date 2016年 11月 16日 星期三 12:04:17 CST 2016年 11月 16日 星期三 12:06:02 CST 2:替换文本中指定行的内容 ~$ date && sed -i '24s/USE `ABCDE`;/USE `edcba`;/' test.sql && date 2016年 11月 16日 星期三 12:09:05 CST 2016年 11月 16日 星期三 12:10:50 CST
IO消耗:
Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 1.00 7.00 772.00 105.00 87.22 92.06 418.65 27.90 31.35 2.21 245.56 1.14 100.00 Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 1.00 4.00 778.00 102.00 87.59 90.03 413.36 25.08 30.30 2.59 241.65 1.13 99.60 Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 2.00 5.00 771.00 101.00 87.48 88.04 412.22 29.80 30.24 2.34 243.21 1.14 99.60 Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 1.00 18.00 431.00 137.00 49.08 122.04 616.99 66.20 70.25 3.02 281.75 1.75 99.60 Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 0.00 1.00 1.00 248.00 0.00 177.04 1456.16 105.24 416.53 24.00 418.11 4.02 100.00
2> python处理:时间消耗是毫秒级别的,几乎是秒级别完成,该情况比较特别:搜索的关键词在大文本里比较靠前的位置,这样处理上T的大文件也是非常快的,要是搜索的关键词靠后怎会怎么样呢?后面会说明。
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import contextlib import re word = 'USE `EDCBA`;' replace = 'USE `ABCDE`;' print '查找:', word print'替换:', replace f = open('test.sql', 'r+') with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_WRITE)) as m: loc = m.find(word) if loc >=0: print loc m[loc:loc + len(word)] = replace
执行:
~$ date && python mmap_python.py && date 2016年 11月 16日 星期三 12:14:19 CST 查找: USE `EDCBA`; 替换: USE `ABCDE`; 929 2016年 11月 16日 星期三 12:14:19 CST
②:替换文本中所有匹配的关键词。比如想把备份文件里的ENGINE=MYISAM改成ENGINE=InnoDB,看看性能如何。
1> sed处理:时间消耗110s;磁盘IO几乎跑满(读写IO高);内存几乎没消耗、CPU消耗10~30%之间。
~$ date && sed -i 's/ENGINE=InnoDB/ENGINE=MyISAM/g' test.sql && date 2016年 11月 16日 星期三 12:19:30 CST 2016年 11月 16日 星期三 12:21:20 CST
和①中sed的执行效果差不多,其实对于处理一条还是多条记录,sed都是做同样工作量的事情,至于原因可以看了解sed的工作原理说明,个人理解大致意思就是:sed是1行1行读取(所以内存消耗很小),放入到自己设置的缓冲区里,替换完之后再写入(所以IO很高),处理速度受限于CPU和IO。
2> python处理:时间消耗20多秒,比sed少。因为不用重写所有内容,只需要替换指定的内容即可,并且是在内存中处理的,所以写IO的压力几乎没有。当关键词比较靠后,其读入的数据就比较大,文件需要从磁盘读入到内存,这时磁盘的读IO也很高,写IO还是没有。因为是虚拟内存映射文件,所以占用的物理内存不多,虽然通过TOP看到的内存使用率%mem很高,这里可以不用管,因为大部分都是在SHR列里的消耗,真正使用掉的内存可以通过RES-SHR来计算。关于top中SHR的意思,可以去看相关文章说明。
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import contextlib word = 'ENGINE=MyISAM' replace = 'ENGINE=InnoDB' print '查找:', word print'替换:', replace
loc = 0 f = open('test.sql', 'r+') with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_WRITE)) as m: while True: loc = m.find(word,loc) if loc >=0: print loc m[loc:loc + len(word)] = replace #要是access=mmap.ACCESS_COPY需要执行flush #m.flush() elif loc == -1: break else: pass
效果:
~$ date && python mmap_python.py && date 2016年 11月 16日 星期三 13:19:30 CST 查找: ENGINE=MyISAM 替换: ENGINE=InnoDB 1663 5884938 11941259 12630481 12904261 64852169 64859312 65018692 65179617 65181544 65709930 149571849 3592900115 5874952354 7998151839 2016年 11月 16日 星期三 13:19:55 CST
③:正则匹配修改,这个可以通过上面介绍的查找方法,做下修改即可,就不再做说明。
小结:
对比sed和python处理文件的方法,这里来小结下:对于sed不管修改的关键字在文本中的任意位置、次数,修改的工作量都一样(全文的读写IO),差距不大;对于python mmap的修改,要是关键字出现在比较靠前的地方,修改起来速度非常快,否则修改也会有大量的读IO,写IO没有。通过上面的对比分析来看,mmap的修改要比sed修改性能高。
Python还有另一个读取操作的方法:open中的read、readline、readlines,这个方法是把文件全部载入内存,再进行操作。若内存不足直接用swap或则报错退出,内存消耗和文本大小成正比,而通过mmap模块的方法可以很好的避免了这个问题。
总结:
通过上面的介绍,大致知道如何使用mmap模块了,其大致特点如下:
- 普通文件被映射到虚拟地址空间后,程序可以向访问普通内存一样对文件进行访问,在有些情况下可以提高IO效率。
- 它占用物理内存空间少,可以解决内存空间不足的问题,适合处理超大文件。
- 不同于通常的字符串对象,它是可变的,可以通过切片的方式更改,也可以定位当前文件位置m.tell()或m.seek()定位到文件的指定位置,再进行m.write(str)固定长度的修改操作。
最后,可以把mmap封装起来进行使用了,脚本信息:
#!/usr/bin/python # -*- encoding: utf-8 -*- import mmap import contextlib import time from optparse import OptionParser def calc_time(func): def _deco(*args, **kwargs): begin_time = time.time() func(*args, **kwargs) cost_time = time.time() - begin_time print 'cost time: %s' % (cost_time) return _deco @calc_time def replace_keyword_all(filename,old_word,new_word): if len(old_word) == len(new_word): loc = 0 print "%s 替换成 %s " %(new_word,old_word) with open(filename,'r+') as f: with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_WRITE)) as m: while True: loc = m.find(old_word,loc) if loc >= 0: m[loc:loc+len(old_word)] = new_word elif loc == -1: break else: pass f.close() else: print "替换的词要和被替换的词长度一致!" exit() @calc_time def replace_keyword_once(filename,old_word,new_word): if len(old_word) == len(new_word): print "%s 替换成 %s " %(new_word,old_word) with open(filename,'r+') as f: with contextlib.closing(mmap.mmap(f.fileno(), 0,access=mmap.ACCESS_WRITE)) as m: loc = m.find(old_word) if loc >= 0: m[loc:loc+len(old_word)] = new_word f.close() else: print "替换的词要和被替换的词长度一致!" exit() if __name__ == "__main__": parser = OptionParser() parser.add_option("-f", "--filename", help="Filename for search", dest="filename") parser.add_option("-o", "--oldword", help="the ip to use", dest="old_word") parser.add_option("-n", "--newword", help="the ip to use", dest="new_word") (options, args) = parser.parse_args() if not options.filename: print 'params filename need to apply' exit() if not options.old_word: print 'params oldword need to apply' exit() if not options.new_word: print 'params newword need to apply' exit() # 替换文本中第一次出现的内容(查到一个就处理退出,越靠前越快) # replace_keyword_once(options.filename,options.old_word,options.new_word) # 替换文本中出现的内容(查找处理整个文本) replace_keyword_all(options.filename,options.old_word,options.new_word)
方法:
~$ python mmap_search.py -h Usage: mmap_search.py [options] Options: -h, --help show this help message and exit -f FILENAME, --filename=FILENAME Filename for search -o OLD_WORD, --oldword=OLD_WORD the ip to use -n NEW_WORD, --newword=NEW_WORD the ip to use
脚本处理效果:(40G的文本)
1)sed:替换文本中第一次出现的内容 ~$ date && sed -i '0,/USE `EDCBA`;/s//USE `ABCDE`;/' test.sql && date 2016年 11月 17日 星期四 11:15:33 CST 2016年 11月 17日 星期四 11:21:47 CST 2)mmap:替换文本中第一次出现的内容(使用replace_keyword_once方法,查到一个就处理退出,越靠前越快) ~$ python mmap_search.py --filename='test.sql' --oldword="USE \`EDCBA\`;" --newword="USE \`ABCDE\`;" USE `ABCDE`; 替换成 USE `EDCBA`; cost time: 0.000128984451294 3)sed:替换文本中出现的内容(查找处理整个文本) ~$ date && sed -i 's/ENGINE=InnoDB/ENGINE=MyISAM/g' test.sql && date 2016年 11月 17日 星期四 10:04:49 CST 2016年 11月 17日 星期四 10:11:34 CST 4)mmap:替换文本中出现的内容(使用replace_keyword_all方法,查找处理整个文本) ~$ python mmap_search.py --filename="test.sql" --oldword="ENGINE=MyISAM" --newword="ENGINE=InnoDB" ENGINE=InnoDB 替换成 ENGINE=MyISAM cost time: 198.471223116
结论:修改大文本文件,通过sed处理,不管被修改的词在哪个位置都需要重写整个文件;而mmap修改文本,被修改的词越靠前性能越好,不需要重写整个文本,只要替换被修改词语的长度即可。