深入理解GIL:如何写出高性能及线程安全的Python代码

6岁时,我有一个音乐盒。我上紧发条,音乐盒顶上的芭蕾舞女演员就会旋转起来,同时,内部装置发出“一闪一闪亮晶晶,满天都是小星星”的叮铃声。那玩意儿肯定俗气透了,但我喜欢那个音乐盒,我想知道它的工作原理是什么。后来我拆开了,才看到它里面一个简单的装置,机身内部镶嵌着一个拇指大小的金属圆筒,当它转动时会拨弄钢制的梳齿,从而发出这些音符。

在一个程序员具备的所有特性中,想探究事物运转规律的这种好奇心必不可少。当我打开音乐盒,观察内部装置,可以看出即使我没有成长为一个卓越的程序员,至少也是有好奇心的一个。

奇怪的是,我写 Python
程序多年,一直对全局解释器锁(GIL)持有错误的观念,因为我从未对它的运作机理产生足够好奇。我遇到其他对此同样犹豫和无知的人。是时候让我们来打开这个盒子一窥究竟了。让我们解读
CPython 解释器源码,找出 GIL 究竟是什么,为什么它存在于 Python
中,它又是怎么影响多线程程序的。我将通过举例帮助你深入理解 GIL 。你将会学到如何写出快速运行和线程安全的 Python
代码,以及如何在线程和进程中做选择。

(我在本文中只描述 CPython,而不是 Jython、PyPy 或 IronPython。因为目前绝大多数程序员还是使用 CPython 实现 Python 。)

瞧,全局解释器锁(GIL)

这里:


  1. static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */ 

这一行代码摘自 ceval.c —— CPython 2.7 解释器的源代码,Guido van Rossum 的注释”This is
the GIL“ 添加于2003 年,但这个锁本身可以追溯到1997年他的第一个多线程 Python 解释器。在
Unix系统中,PyThread_type_lock 是标准 C mutex_t 锁的别名。当 Python 解释器启动时它初始化:


  1. void 
  2.  
  3. PyEval_InitThreads(void) 
  4.  
  5.  
  6.     interpreter_lock = PyThread_allocate_lock(); 
  7.  
  8.     PyThread_acquire_lock(interpreter_lock); 
  9.  
  10. }  

解释器中的所有 C 代码在执行 Python 时必须保持这个锁。Guido 最初加这个锁是因为它使用起来简单。而且每次从 CPython
中去除 GIL 的尝试会耗费单线程程序太多性能,尽管去除 GIL 会带来多线程程序性能的提升,但仍是不值得的。(前者是Guido最为关切的,
也是不去除 GIL 最重要的原因, 一个简单的尝试是在1999年, 最终的结果是导致单线程的程序速度下降了几乎2倍.)

GIL 对程序中线程的影响足够简单,你可以在手背上写下这个原则:“一个线程运行 Python ,而其他 N 个睡眠或者等待
I/O.”(即保证同一时刻只有一个线程对共享资源进行存取) Python
线程也可以等待threading.Lock或者线程模块中的其他同步对象;线程处于这种状态也称之为”睡眠“。

线程何时切换?一个线程无论何时开始睡眠或等待网络 I/O,其他线程总有机会获取 GIL 执行 Python
代码。这是协同式多任务处理。CPython 也还有抢占式多任务处理。如果一个线程不间断地在 Python 2 中运行 1000
字节码指令,或者不间断地在 Python 3 运行15 毫秒,那么它便会放弃 GIL,而其他线程可以运行。把这想象成旧日有多个线程但只有一个
CPU 时的时间片。我将具体讨论这两种多任务处理。

把 Python 看作是旧时的大型主机,多个任务共用一个CPU。

协同式多任务处理

当一项任务比如网络 I/O启动,而在长的或不确定的时间,没有运行任何 Python 代码的需要,一个线程便会让出GIL,从而其他线程可以获取 GIL 而运行 Python。这种礼貌行为称为协同式多任务处理,它允许并发;多个线程同时等待不同事件。

也就是说两个线程各自分别连接一个套接字:


  1. def do_connect(): 
  2.  
  3.     s = socket.socket() 
  4.  
  5.     s.connect(('python.org', 80))  # drop the GIL 
  6.  
  7.   
  8.  
  9. for i in range(2): 
  10.  
  11.     t = threading.Thread(target=do_connect) 
  12.  
  13.     t.start()  

两个线程在同一时刻只能有一个执行 Python ,但一旦线程开始连接,它就会放弃 GIL ,这样其他线程就可以运行。这意味着两个线程可以并发等待套接字连接,这是一件好事。在同样的时间内它们可以做更多的工作。

让我们打开盒子,看看一个线程在连接建立时实际是如何放弃 GIL 的,在 socketmodule.c 中:


  1. /* s.connect((host, port)) method */ 
  2.  
  3. static PyObject * 
  4.  
  5. sock_connect(PySocketSockObject *s, PyObject *addro) 
  6.  
  7.  
  8.     sock_addr_t addrbuf; 
  9.  
  10.     int addrlen; 
  11.  
  12.     int res; 
  13.  
  14.   
  15.  
  16.     /* convert (host, port) tuple to C address */ 
  17.  
  18.     getsockaddrarg(s, addro, SAS2SA(&addrbuf), &addrlen); 
  19.  
  20.   
  21.  
  22.     Py_BEGIN_ALLOW_THREADS 
  23.  
  24.     res = connect(s->sock_fd, addr, addrlen); 
  25.  
  26.     Py_END_ALLOW_THREADS 
  27.  
  28.   
  29.  
  30.     /* error handling and so on .... */ 
  31.  
  32. }  

线程正是在Py_BEGIN_ALLOW_THREADS 宏处放弃 GIL;它被简单定义为:


  1. PyThread_release_lock(interpreter_lock); 

当然 Py_END_ALLOW_THREADS
重新获取锁。一个线程可能会在这个位置堵塞,等待另一个线程释放锁;一旦这种情况发生,等待的线程会抢夺回锁,并恢复执行你的Python代码。简而言之:当N个线程在网络
I/O 堵塞,或等待重新获取GIL,而一个线程运行Python。

下面来看一个使用协同式多任务处理快速抓取许多 URL 的完整例子。但在此之前,先对比下协同式多任务处理和其他形式的多任务处理。

抢占式多任务处理

Python线程可以主动释放 GIL,也可以先发制人抓取 GIL 。

让我们回顾下 Python
是如何运行的。你的程序分两个阶段运行。首先,Python文本被编译成一个名为字节码的简单二进制格式。第二,Python解释器的主回路,一个名叫
pyeval_evalframeex() 的函数,流畅地读取字节码,逐个执行其中的指令。

当解释器通过字节码时,它会定期放弃GIL,而不需要经过正在执行代码的线程允许,这样其他线程便能运行:


  1. for (;;) { 
  2.  
  3.     if (--ticker < 0) { 
  4.  
  5.         ticker = check_interval; 
  6.  
  7.   
  8.  
  9.         /* Give another thread a chance */ 
  10.  
  11.         PyThread_release_lock(interpreter_lock); 
  12.  
  13.   
  14.  
  15.         /* Other threads may run now */ 
  16.  
  17.   
  18.  
  19.         PyThread_acquire_lock(interpreter_lock, 1); 
  20.  
  21.     } 
  22.  
  23.   
  24.  
  25.     bytecode = *next_instr++; 
  26.  
  27.     switch (bytecode) { 
  28.  
  29.         /* execute the next instruction ... */ 
  30.  
  31.     } 
  32.  
  33. }  

默认情况下,检测间隔是1000 字节码。所有线程都运行相同的代码,并以相同的方式定期从他们的锁中抽出。在 Python 3 GIL 的实施更加复杂,检测间隔不是一个固定数目的字节码,而是15 毫秒。然而,对于你的代码,这些差异并不显著。

Python中的线程安全

将多个线状物编织在一起,需要技能。

如果一个线程可以随时失去 GIL,你必须使让代码线程安全。 然而 Python 程序员对线程安全的看法大不同于 C 或者 Java 程序员,因为许多 Python 操作是原子的。

在列表中调用
sort(),就是原子操作的例子。线程不能在排序期间被打断,其他线程从来看不到列表排序的部分,也不会在列表排序之前看到过期的数据。原子操作简化了我们的生活,但也有意外。例如,+
= 似乎比 sort() 函数简单,但+ =不是原子操作。你怎么知道哪些操作是原子的,哪些不是?

看看这个代码:


  1. n = 0 
  2.  
  3. def foo(): 
  4.  
  5.     global n 
  6.  
  7.     n += 1  

我们可以看到这个函数用 Python 的标准 dis 模块编译的字节码:


  1. >>> import dis 
  2.  
  3. >>> dis.dis(foo) 
  4.  
  5. LOAD_GLOBAL              0 (n) 
  6.  
  7. LOAD_CONST               1 (1) 
  8.  
  9. INPLACE_ADD 
  10.  
  11. STORE_GLOBAL             0 (n)  

代码的一行中, n += 1,被编译成 4 个字节码,进行 4 个基本操作:

  1. 将 n 值加载到堆栈上
  2. 将常数 1 加载到堆栈上
  3. 将堆栈顶部的两个值相加
  4. 将总和存储回 n

记住,一个线程每运行 1000 字节码,就会被解释器打断夺走 GIL 。如果运气不好,这(打断)可能发生在线程加载 n 值到堆栈期间,以及把它存储回 n 期间。很容易可以看到这个过程会如何导致更新丢失:


  1. threads = [] 
  2.  
  3. for i in range(100): 
  4.  
  5.     t = threading.Thread(target=foo) 
  6.  
  7.     threads.append(t) 
  8.  
  9. for t in threads: 
  10.  
  11.     t.start() 
  12.  
  13. for t in threads: 
  14.  
  15.     t.join() 
  16.  
  17. print(n)  

通常这个代码输出 100,因为 100 个线程每个都递增 n 。但有时你会看到 99 或 98 ,如果一个线程的更新被另一个覆盖。

所以,尽管有 GIL,你仍然需要加锁来保护共享的可变状态:


  1. n = 0 
  2.  
  3. lock = threading.Lock() 
  4.  
  5. def foo(): 
  6.  
  7.     global n 
  8.  
  9.     with lock: 
  10.  
  11.         n += 1  

如果我们使用一个原子操作比如 sort() 函数会如何呢?:


  1. lst = [4, 1, 3, 2] 
  2.  
  3. def foo(): 
  4.  
  5.     lst.sort()  

这个函数的字节码显示 sort() 函数不能被中断,因为它是原子的:


  1. >>> dis.dis(foo) 
  2.  
  3. LOAD_GLOBAL              0 (lst) 
  4.  
  5. LOAD_ATTR                1 (sort) 
  6.  
  7. CALL_FUNCTION            0  

一行被编译成 3 个字节码:

  1. 将 lst 值加载到堆栈上
  2. 将其排序方法加载到堆栈上
  3. 调用排序方法

即使这一行 lst.sort() 分几个步骤,调用 sort 自身是单个字节码,因此线程没有机会在调用期间抓取 GIL 。我们可以总结为在
sort() 不需要加锁。或者,为了避免担心哪个操作是原子的,遵循一个简单的原则:始终围绕共享可变状态的读取和写入加锁。毕竟,在
Python 中获取一个 threading.Lock 是廉价的。

尽管 GIL 不能免除我们加锁的需要,但它确实意味着没有加细粒度的锁的需要(所谓细粒度是指程序员需要自行加、解锁来保证线程安全,典型代表是
Java , 而 CPthon 中是粗粒度的锁,即语言层面本身维护着一个全局的锁机制,用来保证线程安全)。在线程自由的语言比如
Java,程序员努力在尽可能短的时间内加锁存取共享数据,减轻线程争夺,实现最大并行。然而因为在 Python
中线程无法并行运行,细粒度锁没有任何优势。只要没有线程保持这个锁,比如在睡眠,等待I/O, 或者一些其他失去 GIL
操作,你应该使用尽可能粗粒度的,简单的锁。其他线程无论如何无法并行运行。

并发可以完成更快

我敢打赌你真正为的是通过多线程来优化你的程序。通过同时等待许多网络操作,你的任务将更快完成,那么多线程会起到帮助,即使在同一时间只有一个线程可以执行 Python 。这就是并发,线程在这种情况下工作良好。

线程中代码运行更快


  1. import threading 
  2.  
  3. import requests 
  4.  
  5. urls = [...] 
  6.  
  7. def worker(): 
  8.  
  9.     while True: 
  10.  
  11.         try: 
  12.  
  13.             url = urls.pop() 
  14.  
  15.         except IndexError: 
  16.  
  17.             break  # Done. 
  18.  
  19.         requests.get(url) 
  20.  
  21. for _ in range(10): 
  22.  
  23.     t = threading.Thread(target=worker) 
  24.  
  25.     t.start()  

正如我们所看到的,在 HTTP上面获取一个URL中,这些线程在等待每个套接字操作时放弃 GIL,所以他们比一个线程更快完成工作。

Parallelism 并行

如果想只通过同时运行 Python 代码,而使任务完成更快怎么办?这种方式称为并行,这种情况 GIL 是禁止的。你必须使用多个进程,这种情况比线程更复杂,需要更多的内存,但它可以更好利用多个 CPU。

这个例子 fork 出 10 个进程,比只有 1 个进程要完成更快,因为进程在多核中并行运行。但是 10 个线程与 1 个线程相比,并不会完成更快,因为在一个时间点只有 1 个线程可以执行 Python:


  1. import os 
  2.  
  3. import sys 
  4.  
  5. nums =[1 for _ in range(1000000)] 
  6.  
  7. chunk_size = len(nums) // 10 
  8.  
  9. readers = [] 
  10.  
  11. while nums: 
  12.  
  13.     chunk, nums = nums[:chunk_size], nums[chunk_size:] 
  14.  
  15.     reader, writer = os.pipe() 
  16.  
  17.     if os.fork(): 
  18.  
  19.         readers.append(reader)  # Parent. 
  20.  
  21.     else: 
  22.  
  23.         subtotal = 0 
  24.  
  25.         for i in chunk: # Intentionally slow code. 
  26.  
  27.             subtotal += i 
  28.  
  29.         print('subtotal %d' % subtotal) 
  30.  
  31.         os.write(writer, str(subtotal).encode()) 
  32.  
  33.         sys.exit(0) 
  34.  
  35. # Parent. 
  36.  
  37. total = 0 
  38.  
  39. for reader in readers: 
  40.  
  41.     subtotal = int(os.read(reader, 1000).decode()) 
  42.  
  43.     total += subtotal 
  44.  
  45. print("Total: %d" % total)  

因为每个 fork 的进程有一个单独的 GIL,这个程序可以把工作分派出去,并一次运行多个计算。

(Jython 和 IronPython 提供单进程的并行,但它们远没有充分实现 CPython 的兼容性。有软件事务内存的 PyPy 有朝一日可以运行更快。如果你对此好奇,试试这些解释器。)

结语

既然你已经打开了音乐盒,看到了它简单的装置,你明白所有你需要知道的如何写出快速运行,线程安全的 Python 代码。使用线程进行并发 I/O 操作,在进程中进行并行计算。这个原则足够简单,你甚至不需要把它写在你的手上。

作者:伯乐专栏/郑芸

来源:51CTO

时间: 2024-10-31 04:09:40

深入理解GIL:如何写出高性能及线程安全的Python代码的相关文章

写出高性能SQL语句的35条方法分析_数据库其它

(1)整合简单,无关联的数据库访问: 如果你有几个简单的数据库查询语句,你可以把它们整合到一个查询中(即使它们之间没有关系) (2)删除重复记录: 最高效的删除重复记录方法 ( 因为使用了ROWID)例子: DELETE FROM EMP E WHERE E.ROWID > (SELECT MIN(X.ROWID) FROM EMP X WHERE X.EMP_NO = E.EMP_NO); (3)用TRUNCATE替代DELETE: 当删除表中的记录时,在通常情况下, 回滚段(rollback

怎样用 Android Annotations 写出高性能代码

上一篇博文中简单介绍了 Android Annotations 的基本用法,顺便扯了一下概念 - 契约编程,阅读量少的可怜,看来并没有多少人对此感兴趣,今天再来一篇,介绍几个稍微高级点的用法,我就不信弄不出一个大新闻. 本篇将要介绍的几个也许并不常用,但是逼格是有保证的,它们是 Thread Annotations CheckResult Annotations CallSuper Annotations Enumerated Annotations Thread Annotations Thr

优化SQL查询:如何写出高性能SQL语句

1. 首先要搞明白什么叫执行计划? 执行计划是数据库根据SQL语句和相关表的统计信息作出的一个查询方案,这个方案是由查询优化器自动分析产生的,比如一条SQL语句如果用来从一个 10万条记录的表中查1条记录,那查询优化器会选择"索引查找"方式,如果该表进行了归档,当前只剩下5000条记录了,那查询优化器就会改变方案,采用 "全表扫描"方式. 可见,执行计划并不是固定的,它是"个性化的".产生一个正确的"执行计划"有两点很重要:

【高质量代码】如何写出更高质量的C/C++代码(1):内存管理

内存的管理是C/C++开发程序过程中的一个比较麻烦的问题.对于经验不是足够丰富的程序员来说,开发比较复杂的程序的时候几乎肯定会遇到内存管理方面的bug.对C/C++语言以及编译机制深入的理解和养成良好的编程习惯可以尽量减少这类bug产生的几率. 1.C/C++程序运行时内存结构简介 一个典型的C/C++编译的进程所占用的内存空间通常分为5个部分,由低地址到高地址分别为: 代码段(Code/Text Segment):保存可执行程序运行的二进制代码段. 数据段(Data Segment):保存进程

【高质量代码】如何写出更高质量的C/C++代码(2):函数设计

函数是组成C/C++程序的基本元素,是将一段执行某项功能的代码进行了封装的代码段.为了实现设计的功能,函数的功能正确性是首要的前提,但是仅仅是正确还不够,其设计的科学性和合理性也是影响函数使用的重要因素.本文简要讨论C/C++函数设计和实现的一些基本规则. 1.引言: 每一个完整的C/C++函数都至少包含三个部分:返回值.函数名和参数.函数参数和返回值承担了调用者与被调用函数之间数据传递的功能,主要方式有三种:值传递.指针传递和引用传递,前两者为C标准,引用传递为C++标准.其中引用传递的性质类

请写出对以下三组CSS选择符的理解:#test, .test, .test.subTest.

问题描述 请写出对以下三组CSS选择符的理解:#test, .test, .test.subTest. 解决方案 #test: id选择器 应用到id为test元素上.test:类选择器 通过指定元素class名称为test,应用该样式.test .subTest 表示一种样式的层次结构 eg<div class="test"> <div class="subTest"></div></div> 亲,看看中意否?

[译] SQL 指引:如何写出更好的查询

本文讲的是[译] SQL 指引:如何写出更好的查询, 原文地址:SQL Tutorial: How To Write Better Queries 原文作者:Karlijn Willems 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m- 译者:临书 校对者:steinliber, xiaoyusilen SQL 指引:如何写出更好的查询 结构化查询语言(SQL)是数据科学行业的一种不可或缺的技能,一般来说,学习这项技能是相当简单的.然而大多数人都忘记 SQL

如何正确地写出单例模式

单例模式算是设计模式中最容易理解,也是最容易手写代码的模式了吧.但是其中的坑却不少,所以也常作为面试题来考.本文主要对几种单例写法的整理,并分析其优缺点.很多都是一些老生常谈的问题,但如果你不知道如何创建一个线程安全的单例,不知道什么是双检锁,那这篇文章可能会帮助到你. 懒汉式,线程不安全 当被问到要实现一个单例模式时,很多人的第一反应是写出如下的代码,包括教科书上也是这样教我们的. public class Singleton { private static Singleton instan

写出好的CSS规则:完美的使用:nth

文章简介:当我想要完美的使用:nth-child或者:nth-of-type的时候有点儿头晕.你越理解它们,就能写出越好的CSS规则! 译自:Useful :nth-child Recipies中文:有用的:nth-child秘方请尊重版权,转载请注明来源,多谢! 当我想要完美的使用:nth-child或者:nth-of-type的时候有点儿头晕.你越理解它们,就能写出越好的CSS规则! 在这些简单的"秘方"(实际上是表达式)中我将重复的使用一个简单的列表并随即选择数字.但是很明显很容