Python2 里 print 的原子性

这是一段多线程代码,考虑下执行后会输出什么样的内容:

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”的,但是执行后,感觉有点不对劲:

$ 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 函数的内容提取到一个新的文件中(多线程的代码不方便取一个函数的字节码),然后查看它对应的字节码:

$ 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

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

print a
print b

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

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

在上面字节码中,换行对应的字节码是 PRINT_NEWLINE。就是说这里为了打印 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 条打印的。换行符是嵌入在字符串中的,由终端来显示换行,而不是单独执行 PRINT_NEWLINE 字节码:

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)

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

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