python多进程编程之pickle的实现原理和底层机制

       pickle是python的一个标准模块,其作用是将python对象进行序列化,序列化的目的在于对一个对象的状态进行编码以进行更好的保存和通信。python里面,pickle作为一种序列化的方式,具有较大的用处,特别是在多进程编程中,理解pickle的机制对正确的实现多进程是尤为必要的。

       对于pickle的运用,主要有序列化和反序列化两个方向,对应的接口为dump和load,这是针对文件IO的,即保存或者读取的参数是一个文件对象,或者更准确的说应该具有read()和write()类似的接口的对象;还有dumps和loads接口,这两个接口不再有文件对象作为参数,dumps是直接将对象序列化后返回一个字节码对象,同样的,loads是直接以被序列化后的字节码对象为参数,对其反序列化,而不再是进行外部的文件类型的保存。

       对于pickle的理解,更重要的是在于pickle序列化和反序列化这两个过程的较为底层的实现机制。

       对于序列化过程,即dump和dumps接口。当我们在脚本中dump一个对象的时候,对这个对象具有一些要求,并不是任何的对象都是可以被dump的,比如如果是个函数或者类的话,那么要求这个函数或者类必须是模块内top-level定义的,不一定是主模块,也可以是被import的模块,但必须是top-level的,即通过module.attribute的方式可以直接访问到的,具体更多的信息可以参考官方文档。

       重点在于,dump一个对象的时候,这个对象所在的模块的名称是很重要的,因为其在反序列的时候,对应的模块需要被import从而以重构对象,因此在dump时,不仅仅要pickle这个对象的名称,也要pickle这个对象所在模块的名称。当我们dump的对象是自定义的时候,即是在主模块里面自己定义,既不是内置对象也不是导入模块中的对象时,由于模块的名称是由一个原生的变量__name__保存的,主模块的__name__的值默认为'__main__',所以如果直接是dump主模块的自定义对象,那么在被序列化之后,这个对象所对应的模块名称就是'__main__',这样的话,如果我们要跨模块使用序列化后的对象,比如我要在另一个脚本中使用这个序列化后的对象,那么由于主模块变成了新脚本, 而新脚本中并没有定义这个对象,就会出现反序列化错误。这时,我们的方法是在dump时通过修改主模块的__name__的值,修改为脚本名,这样在反序列化的时候就可以通过import这个脚本来成功反序列化。

       要特别注意的是,这个修改的名字并不是可以任意的,实际上也只能是修改为脚本的名称,因为这和dump的实现机制有关。看如下示例代码,并假设该代码所在的脚本名称为test.py,在dump函数func时,python会到该函数定义的位置去寻找模块名,所以修改__name__属性的语句必须在定义函数的语句之前,如代码所示;实际上,对于函数和类,修改__name__属性的语句都必须在定义的位置前面,对于实例,需要在实例创建的语句前面。问题在于,如果dump语句是在主模块中,那么该模块中函数定义所在位置对应的模块__name__属性不是__main__的话,那么python会导入该名称所对应的模块(示例中为test),然后dump该模块中的func;但是如果是在被import的模块中,则函数定义位置所在的模块名称应该和被导入时的一致,不然也是dump重新导入的模块中的函数的;之所以要这样实现,是为了保证在反序列化unpickle时,该__name__对应的模块中确实是有对应的函数对象的;所以,实际上dump的是被重新导入的test脚本中的func函数,所以如果运行下面的代码,会打印两次的'renamed'。由于这时dump的func函数不再是主模块中的func函数,这时会抛出一个异常,异常信息类似:Can't pickle : it's not the same object as test.func,这个异常是提醒此时dump的对象不再是主模块中的函数func,而是被导入模块test中的func,虽然这个异常会中断程序运行,但是也会成功dump函数func到data.pkl文件中,在这里,我们只要将这个异常捕捉就不会退出程序,这就是为什么示例代码中要捕捉这个异常的原因。

import pickle

__name__='test'
print('renamed')
def func():
    print('unpickled')
try:
    with open('data.pkl','wb')  as f:        
        pickle.dump(func,f)
except Exception as e:
    print(e)
    if 'it's not the same object as test.func' in str(e):
        print('pickled success')

       上面例子中,细心的读者可能会有一个疑问,那就是由于func函数对应的模块的__name__属性不是__main__,所以test会被import,也就是test.py脚本会再被运行一次,这样的话,运行到pickle.dump(func,f)语句的时候,会不会又需要再次import?这样不就无休止的下去,造成程序卡死?实际上并不会这样,因为被import而运行的时候,函数定义位置所在的模块__name__属性的值就是和被import的模块名一样的,所以这样就不会再重新导入,而是直接dump被导入模块中的func函数。所以实际上,data.pkl文件中dump的是被import的test.py中的func函数。那么为什么会跑出上述的异常呢?更底层的原因和pickle的另外一个机制有关,那就是pickle会跟踪内存中被dump的对应模块名称对应对象名的对象,如果后面有再次对其dump的语句,便会直接引用内存中已经dump过的,而不会重复的dump,但是前提是得同一个对象,而在上例中,python对func和test.func的识别是略有不同的,但是其模块名和模块中的函数名是一样的,但在内存中,一个是func一个是test.func,两个又不一样,从而会抛出异常,这实际上是python中pickle的一个很怪异的地方,相当于即先通过模块名和模块中的函数名来认是不是一家人,通过这个识别,就会先认为是一家人,断了人家再去认别人的后路,但是后面还要再看内存中名称是否一致,要是不一致,就不管了,抛出异常;而不是刚开始就先两步判断一起,也不会断了后路,抛出异常才能解决,这看起来像是pickle的一个bug。但是没关系,我们这里加个异常捕捉就好。

       上述的关于dump的,关于load,其实是一个相反的过程,unpickle的过程,实际上是反序列化出对象名和模块名,然后通过导入相应模块,引用模块中对象名,内存中重构对象。所以这就是为什么需要函数或者类是在模块的top-level定义,以便可以直接通过被导入的模块访问到,以重构对象。除非被load的对象对应的module的名称是__main__,这样不会导入,而是在自己所在的主模块中寻找。或者是下面的这种情况,也不需要被导入。

       其实前面已经说过了,即pickle不仅会跟踪已经被dump过的对象,也会跟踪被load过,即被反序列化过的对象,然后如果后面还有相同的load语句,即load相同模块名下的相同属性,便会直接引用,不再重复反序列化。因此,如下的代码中,第二个with语句下面的load语句不再导入test.py这个模块,因为在dump语句时,已经导入过,所以已经执行过load语句,而在内存中也已经有了被反序列化的相同对象,因此主模块中的load语句将直接引用,不会再导入模块,重复unpickle。这种机制也很好的避免了无休止的迭代import问题。

import pickle

__name__='test'
print('renamed')
def func():
    print('unpickled')
try:
    with open('data.pkl','wb')  as f:
        pickle.dump(func,f)
except Exception as e:
    print(e)
    if 'same' in str(e):
        print('pickled success')

with open('data.pkl','rb') as ff:
    fun=pickle.load(ff)
    fun()

 

你可能感兴趣的:(python编程,多进程和多线程)