目录

文章目录

  • 目录
  • Python GIL
  • Python GIL 对线程并发性能的影响
  • 保留 GIL 的历史原因
    • 为了兼顾解释型语言的简单
    • 为了兼顾 C 程序库的安全
  • Python 的多线程编程要点
    • Python 提供的原子性操作
    • Python 的线程库锁
  • GIL 的后续

Python GIL

在多处理器时代,程序要想充分的利用计算平台的性能,就必须按照并发方式进行设计。但是很遗憾,对于 Python 程序而言,不管你的服务器拥有多少个处理器,任何时候总是有且只能有一个线程在运行。这就是 GIL(Global Interpreter Lock,全局解释器锁)为 Python 带来的最困难的问题。并且目前看来短时间内这个问题是难以得到解决的,以至于 Python 专家们通常会建议你 “不要使用多线程,请使用多进程”。

Python 的垃圾回收主要是采用了简单明了的 “引用计数法”,为每个对象都创建一个 “引用计数” 字段,每次有新的变量指向了该对象,该对象的引用计数就会 +1,当变量指向了别的对象,则 -1,当对象的 “引用计数” 为 0,就意味着对象可以被回收了。

看似简单,实际上一点都不简单,每次遇到变量的赋值操作时,都得增加新对象的引用计数,还得减少老对象的引用计数,更要命的是循环引用问题。所以,Python 还使用了 “标记-清除”、“分代回收” 等算法作为辅助。

但最核心的问题是,Python 要怎么处理多个线程同时修改一个对象的引用计数问题?如果引用计数被错误地修改,就很可能会导致一个对象一直不被回收,或者回收了一个不能被回收对象。

对此,通常的做法是通过 “加锁” 来保证多线程场景中的 “引用计数” 数据一致性。即:给每个对象上都加了一把锁,只让一个线程进入修改。而 Python 的做法是使用了一把超级大锁,即 GIL,这把超级大锁只允许同一时刻只有一个线程可以获得 Python 解释器的控制权, 即同一时刻,只有一个线程能够运行。

Python GIL 对线程并发性能的影响

Python GIL 的好处是避免了在每个对象上都加锁,每次访问都加锁/解锁,开销太大。而坏处就是在多核硬件平台上,只有一个线程执行,造成 “一核工作,多核围观” 的浪费,而且线程切换的时候还得释放 GIL,竞争 GIL,多线程可能跑得比单线程还慢。

你可能会感到奇怪:即便 Python 多线程没有完成真正的并行,那也应该和串行的单线程差不太多才是啊?实际情况可以比你想象的更加糟糕,Python 的多线程在多核平台场景中会比单线程的效率下降 45%。这是由于 GIL 的设计缺陷导致的。

Python 社区认为操作系统的调度器已经非常成熟,可以直接使用,所以 Python 的线程实际上是 C 语言的一个 pthread,并交由系统调度器根据调度算法和策略进行调度。同时,为了让各线程能够平均的获得 CPU 时间片,Python 会自己维护一个微代码(字节码指令)执行计数器(Python2:1000 字节码指令,Python3:15 毫秒),达到一定的计数阈值后就会强制当前线程释放 GIL,让其他线程得到进入 CPU 的机会,这意味着 GIL 的释放与获取是伴随着操作系统线程切换一起进行的。

这样的模式在单处理器计算平台中是没有问题的,每触发一次线程切换,当前线程都能够如愿获取 GIL 并执行字节码指令,所以单个处理器始终是忙碌的。

但在多处理器计算平台中这样的模式会发生什么呢?GIL 只有一个,给了在 CPU1 的当前线程,就不能给 CPU2 的当前线程,所以 CPU2 的当前线程只能白白浪费 CPU 执行时间(线程只有获取了 GIL 才能执行字节码指令)。而且在多处理器计算平台中还平添了线程切换甚至是进程切换的各种开销,赔了夫人又折兵。

  • 绿色:CPU 的有效执行时间
  • 红色:线程因为没拿到 GIL 白白浪费的 CPU 时间

另外,在多核情况下,被分配到其他核的线程由于需要等待信号,唤醒以后才能竞争。那么,就有可能存在一个线程 A 会经常抢先,以至于 “打压” 其他线程,让它们难以运行。

所以,对于 Python 程序而言,如果真想利用多核的特性,还想避开 GIL,建议还是用多进程吧。

保留 GIL 的历史原因

为了兼顾解释型语言的简单

Python 是解释型语言,程序代码被编译成二进制格式的字节码,然后再由 Python 解释器的主回路 pyeval_evalframeex() 边读取字节码,边逐一执行其中的指令。显然,解释器在程序运行之前对程序本身并不是完全了解的,解释器只知道 Python 既定的规则以及在执行过程中怎样动态的去遵守这些规则。

Python 解释器无法像 C/C++ 编译器那般在程序进入到处理器运行之前就已经对程序代码拥有了全局的语义分析和理解能力。作为解释型语言,Python 解释器无法在程序真正运行之前就告诉你,你的多线程代码实现到底有多糟糕(隐含的逻辑错误要到真正运行时才会触发)。

为了兼顾 C 程序库的安全

从上文中我们了解到,同一进程中的多个线程间存在数据共享,为了避免内存可见性导致的并发安全问题,编程语言大多会提供用户可控的数据的保护机制,也就是线程同步功能。使用线程同步功能,可以控制程序流以及安全访问共享数据,从而并发执行多个线程。常见的同步模型大致有以下四种:

  1. 互斥锁:仅允许每次使用一个线程来执行特定代码块或者访问特定的共享数据。
  2. 读写锁:允许对受保护的共享数据进行并发读取和独占写入(多读单写)。要修改共享数据,线程必须首先获取互斥写锁。只有释放所有的读锁之后,才允许使用互斥写锁。
  3. 条件变量:一直阻塞线程,直到特定的条件为真。
  4. 计数信号量:通常用来协调对共享数据的访问。使用计数,可以限制访问某个信号的线程数量。达到计数阈值时,信号被阻塞,直至线程执行接收,计数减少

而 Python 诞生于上世纪 90 年代初,基于很多 C 语言的扩展库来进行开发。当初引入 GIL 的主要原因是为了让 这些 C 扩展库都能够不必考虑线程的安全问题(thread-safe),使其很容易地被集成进来。而 C 扩展库也极大地丰富了 Python 的功能,促进了 Python 的发展。GIL 解决的问题本质就是 Python 多线程的线程安全问题。

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

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary primarily because CPython’s memory management is not thread-safe. (However, since the GIL exists, other Features have grown to depend on the guarantees that it enforces.)

在 CPython(最常用的 Python 解释器实现)中,全局解释器锁(GIL)是一个全局的互斥锁,它可以防止多线程同时执行 Python 程序的字节码。这种锁是必要的,主要因为 CPython 的内存管理不是线程安全的。

Python 的多线程编程要点

那么,Python 的多线程到底还能不能用?

就结果而言,如果业务系统中存在任意一个 CPU 密集型的任务,那么我会告诉你 “多进程或者协程都是不错的选择”。如果业务系统中全都是 I/O 密集型任务,那么恭喜你,多线程将会起到积极的作用。

在 I/O 密集型场景中,程序的性能瓶颈通常不在 CPU,而在于 I/O,例如:用户输入,数据库查询,网络访问等。当执行线程触发 I/O 操作时,可以考虑立即放弃 GIL 这个超级大锁,转而让其他线程执行。

所以,Python 多线程在 I/O 密集型场景中可以实现真正的并发,是因为一个等待 I/O 的当前线程会在长的或者不确定的一段时间内,可能并没有任何 Python 代码会被执行,那么该线程就会将 GIL 让出给其他处理器上的当前线程使用(一个在等 I/O,一个在执行 Python 代码)。这种礼貌行为称为 “协同式多任务处理”,它允许并发。不同的线程在等待不同的事件。

综上,对于复杂的 Python 业务系统而言,分布式架构(解耦 CPU 密集型业务和 I/O 密集型业务并分别部署到不同的服务器上进行调优)是一个不错选择。

值的注意的是,当我们在 I/O 密集型场景中使用多线程时,依旧要严格遵守多线程的线程安全问题,Python 提供了下列 2 种常见的实现:

  1. 原子性操作
  2. 线程库锁(e.g. threading.Lock)

Python 提供的原子性操作

Python 提供的许多内置函数都是具有原子性的,例如排序函数 sort()

>>> lst = [4, 1, 3, 2]
>>> def foo():
...     lst.sort()
...
>>> import dis
>>> dis.dis(foo)2           0 LOAD_GLOBAL              0 (lst)3 LOAD_ATTR                1 (sort)6 CALL_FUNCTION            09 POP_TOP10 LOAD_CONST               0 (None)13 RETURN_VALUE

我们使用 dis 模块来编译出上述代码的字节码,最关键的字节码指令为:

  1. LOAD_GLOBAL:将全局变量 lst 的数据 load 到堆栈
  2. LOAD_ATTR:将 sort 的实现 load 到堆栈
  3. CALL_FUNCTION:调用 sort 对 lst 的数据进行排序

真正执行排序的只有 CALL_FUNCTION 一条指令,所以说该操作具有原子性。

Python 的线程库锁

我们再举个例子看看非原子操作下,怎么保证线程安全。

>>> n = 0
>>> def foo():
...     global n
...     n += 1
...
>>> import dis
>>> dis.dis(foo)3           0 LOAD_GLOBAL              0 (n)3 LOAD_CONST               1 (1)6 INPLACE_ADD7 STORE_GLOBAL             0 (n)10 LOAD_CONST               0 (None)13 RETURN_VALUE

代码编译后的字节码指令:

  1. 将全局变量 n 的值 load 到堆栈
  2. 将常数 1 的值 load 到堆栈
  3. 在堆栈顶部将两个数值相加
  4. 将相加结果存储回全局变量 n 的地址
  5. 将常数 0(None) 的值 load 到堆栈
  6. 从堆栈顶部返回常数 0 给函数调用者

语句 n += 1 被编译成了前 4 个字节码,后两个字节码是 foo 函数的 return 操作,解释器自动添加。

我们在上文提到,Python2 的线程每执行 1000 个字节码就会被动的让出 GIL。现在假如字节码指令 INPLACE_ADD 就是那第 1000 条指令,这时本应该继续执行 STORE_GLOBAL 0 (n) 存储到 n 地址的数据就被驻留在了堆栈中。如果同一时刻,变量 n 被别的处理器当前线程中的代码调用了。那么请问现在的 n 还是 +=1 之后的 n 吗?答案是此时的 n 发生了更新丢失,在两个当前线程中的 n 已经不是同一个 “n” 了。这就是上面我们提到过的内存可见性数据安全问题的又一个佐证。

下面的代码正确输出为 100,但在 Python 多线程多处理器场景中,可能会得到 99 或 98 的结果。

import threadingn = 0
threads = []def foo():global nn += 1for i in range(100):t = threading.Thread(target=foo)threads.append(t)for t in threads:t.start()for t in threads:t.join()print(n)

此时,Python 程序员应该要想到使用 Python 线程库的锁来解决为。

import threadingn = 0
lock = threading.Lock()
threads = []def foo():global nwith lock:n += 1for i in range(100):t = threading.Thread(target=foo)threads.append(t)for t in threads:t.start()for t in threads:t.join()print(n)

显然,即便 Python 已经存在了 GIL,但依旧要求程序员坚持 “始终为共享可变状态的读写上锁”。

GIL 的后续

当然也有人尝试过将 GIL 改废,Greg Stein 在 1999 年提出的 “Free Threading” patch 中移除了 GIL。但结果就是单线程执行性能下降了 40%,同时多线程的性能提升也未能达到线性增长标准。

至今为止有许多乐于挑战的开发者们在尝试解决这一难题,甚至发布了多种没有 GIL 的 Python 解释器实现(e.g. JPython、IronPython)。不过很可惜的是,由于这些 “特殊” 解释器不属于 C 语言生态圈,所以没能享受到社区众多优秀 C 语言模块的福利,也就注定无法成为主流,只能在特定的场景中发挥着属于自己的特长。

无论如何,GIL 作为 Python 的文化基因,深远的影响了每一位 Pythoner,但却并不完全是正面的影响。例如:Python 程序员对多线程安全问题的理解与任何 C 或 Java 程序员都是大相径庭的。GIL 和 Python 原子性操作的 “溺爱” 让大多数 Python 程序员产生了 “Python 是原生线程安全的编程语言” 的幻觉,并最终在大规模并发应用场景中屡屡受挫。或许真是应了那一句 “Python 的门很好进,但进了门之后才发现 Python 的殿堂在天上”。

那么 GIL 是万恶之源吗?也不尽然,编程的世界永远是「时间和空间」的权衡,简单优雅或许才是真正的 Python 之美。

面向 CPython GIL 的多线程编程要点相关推荐

  1. Win32多线程编程(2) — 线程控制

    Win32线程控制只有是围绕线程这一内核对象的创建.挂起.恢复.终结以及通信等操作,这些操作都依赖于Win32操作系统提供的一组API和具体编译器的C运行时库函数.本篇围绕这些操作接口介绍在Windo ...

  2. gil php,网络编程之多线程——GIL全局解释器锁

    网络编程之多线程--GIL全局解释器锁 一.引子 定义: In CPython, the global interpreter lock, or GIL, is a mutex that preven ...

  3. 学习笔记(27):Python网络编程并发编程-GIL与多线程

    立即学习:https://edu.csdn.net/course/play/24458/296444?utm_source=blogtoedu GIL与多线程 1.须知: 1)cpu主要是为了提升计算 ...

  4. python3多线程编程_Python 3多线程编程学习笔记-基础篇

    本文是学习<Python核心编程>的学习笔记,介绍了Python中的全局解释器锁和常用的两个线程模块:thread, threading,并对比他们的优缺点和给出简单的列子. 全局解释器锁 ...

  5. Python3 多线程编程

    一.线程的基本概念 引入进程的目的,是为了使多道程序并发执行,以提高资源利用率和系统吞吐量:而引入线程,则是为了减小程序在并发执行时所付出的时空开销,提高操作系统的并发性能. 线程最直接的理解就是&q ...

  6. 【python第三方库】python多线程编程---threading库

    文章目录 一.python多线程 1. GIL 二.threading库使用介绍 1. 创建多线程 2. 线程合并 3. 线程同步与互斥锁Lock 4. 死锁与可重入锁(递归锁)RLock 5. 守护 ...

  7. [转]Linux 的多线程编程的高效开发经验

    Linux 平台上的多线程程序开发相对应其他平台(比如 Windows)的多线程 API 有一些细微和隐晦的差别.不注意这些 Linux 上的一些开发陷阱,常常会导致程序问题不穷,死锁不断.本文中我们 ...

  8. Java多线程编程实战:模拟大量数据同步

    背景 最近对于 Java 多线程做了一段时间的学习,笔者一直认为,学习东西就是要应用到实际的业务需求中的.否则要么无法深入理解,要么硬生生地套用技术只是达到炫技的效果. 不过笔者仍旧认为自己对于多线程 ...

  9. Linux下的多线程编程

    1 引言 线程(thread)技术早在60年代就被提出,但真正应用多线程到操作系统中去,是在80年代中期,solaris是这方面的佼佼者.传统的Unix也支持线程的概念,但是在一个进程(process ...

最新文章

  1. linux安装emc的多路径软件,linux (centos)安装EMCPower多路径软件
  2. 多维多重背包问题_满满干货!背包问题全总结(带c++源码)
  3. wxWidgets:TextCtrl示例
  4. mongoDB 删除集合后,空间不释放
  5. 线上服务CPU100%问题快速定位实战
  6. mysql 屏蔽索引_mysql强制索引和禁止某个索引
  7. 一像素约束(选中所需约束,切换到类处,选择此类,运行时就是0.5的约束)
  8. linux下网络监听与发送数据包的方法(即libpcap、libnet两种类库的使用方法)
  9. 正则表达式2-测试代码
  10. JBOSS最大连接数配置和jvm内存配置
  11. jQuery特效:实现微博发布界面
  12. Search Engine -垂直搜索小汇总
  13. DVWA之SQL注入代码审计
  14. Xcode 模拟器Simulator手动下载(iOS 8 - iOS 12)
  15. 电路设计_MOS管导通条件
  16. 服务器是如何被入侵的
  17. 致加西亚的信观后有感
  18. 双11为什么成了传统电商的流量批发市场?
  19. 扫雷小游戏 纯C语言/C++开发分享成果和记录
  20. 使用链表进行奇偶分排 c语言

热门文章

  1. centos php 安装mysql_CentOS 7 安装MySQL+PHP环境
  2. c4d完全学习手册_动态视觉设计就业班,全商业项目实训,一线制作团队10人小班授课,持续提升学习...
  3. ​利用卷积神经网络学习脑电地形图表示进行分类
  4. Vertebrae 发布了新的SDK!
  5. 2021未来科学大奖揭晓:SARS病原发现者、上海交大张杰教授等4人获得百万奖金...
  6. 放张载玻片就能放大一万倍,普通光学显微镜都馋哭了 | Nature子刊
  7. 云计算的未来,就是“打车模式” | CCF C³@亚马逊云科技
  8. 明抢华为市场,宣战苹果三星,这家创业公司胆子不小
  9. 挖矿让英伟达多赚了近3亿美元,老黄:又创纪录了
  10. 腾讯员工中66%是研发,用C++最多,去年新写12.9亿行代码