在Linux系统上检查Python多进程中的fork行为

3

我需要从许多进程中访问一组大型且不可选的Python对象。因此,我希望确保这些对象不会完全被复制。

根据这篇这篇文章中的评论,在Unix系统上,对象只有在更改时才会被复制。然而,引用一个对象将改变其引用计数,进而将被复制。

到目前为止,这是正确的吗?由于我担心我的大型对象的大小,如果这些对象的小部分被复制,我不会有问题。

为了确保我理解了所有内容并且不会出现意外情况,我实现了一个小测试程序:

from multiprocessing import Pool

def f(arg):
    print(l, id(l), object.__repr__(l))
    l[arg] = -1
    print(l, id(l), object.__repr__(l))

def test(n):
    global l
    l = list(range(n))
    with Pool() as pool: 
        pool.map(f, range(n))
    print(l, id(l), object.__repr__(l))

if __name__ == '__main__':
    test(5) 

f的第一行,我期望在所有函数调用中,id(l)返回相同的数字,因为在id检查之前,列表没有被改变。
另一方面,在f的第三行,由于在第二行中更改了列表,id(l)在每个方法调用中应返回不同的数字。
然而,程序输出让我感到困惑。
[0, 1, 2, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[-1, 1, 2, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, 2, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, -1, 2, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, 2, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, -1, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, 2, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, 2, -1, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, 2, 3, 4] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, 2, 3, -1] 139778408436488 <list object at 0x7f20b261d308>
[0, 1, 2, 3, 4] 139778408436488

无论在f的所有调用和行中,id都是相同的。即使列表在最后保持不变(正如预期的那样),这也意味着列表已经被复制了。

我该如何查看一个对象是否已被复制?


一旦对象被创建,其ID就无法更改。 - Ross Ridge
@RossRidge:当我复制一个对象时,它不应该在内存中与原始对象位于不同的位置吗?是否有一种方法可以获取此内存地址(如果'id'返回其他内容)? - Samufi
谢谢,@JonathanEunice。我试图纠正我的陈述,现在可以了吗? - Samufi
你没有复制l所引用的对象,而是修改了它。 - Ross Ridge
@RossRidge:我并没有真正修改列表。否则,原始列表(输出的最后一行)将会被更改(或者我错过了什么?)。我认为写时复制意味着在我更改对象时会创建一个副本。这是错误的吗? - Samufi
1个回答

7
您的困惑似乎是由于对进程和fork的工作原理存在误解。每个进程都有自己的地址空间,因此两个进程可以使用相同的地址而不会发生冲突。这也意味着一个进程不能访问另一个进程的内存,除非相同的内存映射到了两个进程。
当一个进程调用fork系统调用时,操作系统会创建一个新的子进程,该子进程是父进程的克隆。这个克隆体,就像任何其他进程一样,有它自己独立的地址空间。然而,地址空间的内容是父进程的完全复制。这曾经通过将父进程的内存复制到为子进程分配的新内存中来完成。这意味着一旦子进程和父进程在fork之后恢复执行,任何进程对自己内存所做的修改都不会影响另一个进程。
然而,复制一个进程的整个地址空间是一项昂贵的操作,通常是浪费的。大多数情况下,新进程会立即执行一个新程序,导致子进程的地址空间完全被替换。因此,现代类Unix操作系统使用“写时复制”(copy-on-write)的fork实现。与其复制父进程的内存,而是将父进程的内存映射到子进程中,以便它们可以共享同一内存。然而,旧的语义仍然得以维护。如果子进程或父进程修改了共享内存,则修改的页面会被复制,以便两个进程不再共享该内存页。
当multiprocessing模块调用您的f函数时,它是在使用fork系统调用创建的一个子进程中执行的。由于该子进程是父进程的克隆,因此它也有一个名为l的全局变量,该变量引用列表,在两个进程中具有相同的ID(地址)和内容。也就是说,在子进程中修改l所引用的列表之前,它们是相同的。ID不会改变,但子进程版本的列表与父进程的版本不再相同。父进程的列表内容不受子进程所做修改的影响。
请注意,上述段落中描述的行为无论fork是否使用写时复制都是正确的。对于multiprocessing模块和Python来说,这只是一些实现细节。结果效果是相同的。这意味着您无法在Python程序中真正测试使用了哪个fork实现。

我经常看到“现代Unix系统”这个短语被提及,但从未真正了解如何检查我的系统是否足够现代以进行写时复制。有没有一种方法可以检查一下? - arash

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接