Python2里print的原子性

这是一段多线程代码,考虑下它执行后结果是什么样的:)如下:

#coding=utf-8

import threading

def test():
    a = 'hello'
    b = 'world'
    print a, b

if __name__ == '__main__':
    threads = list()

    for i in xrange(10):
	t = threading.Thread(target=test)
	threads.append(t)

    for thread in threads:
	thread.start()

    for thread in threads:
	thread.join()

它的本意是让10个线程打印“hello world”的,但是执行后,感觉有点不对劲,看看它的输出:

lu4nx@lx:/tmp$ python test.py
hello world
hello world
hello world
hello hello world
world
hello world
 hello world
 hello world
 hello world
  hello world

打印出来的东西歪歪扭扭的,可读性真差。导致歪歪扭扭产生的原因是发生了线程切换,Python的多线程依赖GIL机制,这方面更详细的你可以去参考别的文档,在这里只用知道:在“当前线程”获得执行时,GIL会锁上,然后其他线程不可打断当前的执行,直到GIL被解锁后方可切换。而解锁的条件大概有两个(还有其他可能性,为了不陷入这个细节,我们只谈论常见的两个):1、发生I/O操作,当前线程需要等待,则解锁GIL,其他线程继续执行;2、通常情况下,GIL执行了N条字节码后(N默认是100,这个数字可以修改的),发生切换。

要想弄明白歪歪扭扭的原因,得先看看test函数对应的字节码。单独把test函数的内容提取到一个新的文件中(多线程的代码不方便取一个函数的字节码),然后查看它对应的字节码:

lu4nx@lx:/tmp$ python -m dis x.py
1           0 LOAD_CONST               0 ('hello')
            3 STORE_NAME               0 (a)


2           6 LOAD_CONST               1 ('world')
            9 STORE_NAME               1 (b)
3           12 LOAD_NAME                0 (a)
            15 PRINT_ITEM
            16 LOAD_NAME                1 (b)
            19 PRINT_ITEM
            20 PRINT_NEWLINE
            21 LOAD_CONST               2 (None)
            24 RETURN_VALUE

PRINTITEM字节码对应的就是print函数,对于print a,b这条语句,其实执行了两次PRINTITEM,也就是说print a,b等于:

print a
print b

并且,print a,b之后还要显示一行换行符,实际上等于:

print a,        # 如不明白为何要在尾巴多个逗号,那么该复习python了
print b,
print '\n'

在上面字节码中,换行对应的字节码是PRINTNEWLINE。就是说这里为了打印a和b变量,大约执行了3条主要的字节码。

所以。

假设当前脚本执行100个字节码后便发生线程切换,而打印a变量的字节码正好是第一百条,在打印了a后,发生了线程切换,待下次该线程获得了执行时间片时,再去打印b,这时看到的格式绝对是混乱的;如果是一次把a和b都打印出来了,但还没来得及打印换行符,又发生了切换,而这个换行符又要等到下次有机会时再打印了,这样打印出来的东西又是错乱的;运气好点的话,a、b和换行是一口气执行完的,而我们需要的就是把这种运气成分变成百分百可行的方法,这种一气呵成干完活的需求便是“保持原子性”,原子性简单说就是保证某些操作是连贯的、不被打断的、一口气干到底干完的。这里的连贯操作就是需要一气呵成完成:1、打印变量a;2、打印变量b;3、打印换行符。

当然,你可能会尝试用锁来解决这个问题,但是锁的开销太大了。

其实要是能让print一口气把a、b和换行符都打印了就可以了嘛。

如果这样写的话:

print '%s %s\n;'%(a, b),

就是可以保证原子性的,因为print最终只打印一个字符串,而不是分成3条打印的。换行符是嵌入在字符串中的,由终端来显示换行,而不是单独执行PRINTNEWLINE字节码。

看看那转换成字节码后:

1           0 LOAD_CONST               0 ('hello')
            3 STORE_NAME               0 (a)


2           6 LOAD_CONST               1 ('world')
            9 STORE_NAME               1 (b)


3          12 LOAD_CONST               2 ('%s %s\n')
           15 LOAD_NAME                0 (a)
           18 LOAD_NAME                1 (b)
           21 BUILD_TUPLE              2
           24 BINARY_MODULO
           25 PRINT_ITEM
           26 LOAD_CONST               3 (None)

可以看到PRINTITEM只执行了一次,所以轮到它执行时,它能保证完成输出这行字符串。而前面把变量拼成字符串虽然花了几条指令,但不影响正常显示,因为无论花费了多少条字节码,这些都不影响最终完整打印的。

另外,使用logging一类的日志模块打印也可以保证原子性的。