写在前面作者电脑有 4 个 CPU,因此使用 4 个线程测试是合理的

本文使用的 cpython 版本为 3.6.4

本文使用的 pypy 版本为 5.9.0-beta0,兼容 Python 3.5 语法

本文使用的 jython 版本为 2.7.0,兼容 Python 2.7 语法

若无特殊说明,作语言解时,python 指 Python 语言;作解释器解时,python 指cpython

本文使用的测速函数代码如下:

from __future__ import print_function

import sys

PY2 = sys.version_info[0] == 2

# 因为 Jython 不兼容 Python 3 语法,此处必须 hack 掉 range 以保证都是迭代器版本

if PY2:

range = xrange # noqa

from time import time

from threading import Thread

def spawn_n_threads(n, target):

""" 启动 n 个线程并行执行 target 函数

""" threads = []

for _ in range(n):

thread = Thread(target=target)

thread.start()

threads.append(thread)

for thread in threads:

thread.join()

def test(target, number=10, spawner=spawn_n_threads):

""" 分别启动 1, 2, 3, 4 个控制流,重复 number 次,计算运行耗时

""" for n in (1, 2, 3, 4, ):

start_time = time()

for _ in range(number): # 执行 number 次以减少偶然误差

spawner(n, target)

end_time = time()

print('Time elapsed with {} branch(es): {:.6f} sec(s)'.format(n, end_time - start_time))

并行?伪并行?

学过操作系统的同学都知道,线程是现代操作系统底层一种轻量级的多任务机制。一个进程空间中可以存在多个线程,每个线程代表一条控制流,共享全局进程空间的变量,又有自己私有的内存空间。

多个线程可以同时执行。此处的“同时”,在较早的单核架构中表现为“伪并行”,即让线程以极短的时间间隔交替执行,从人的感觉上看它们就像在同时执行一样。但由于仅有一个运算单元,当线程皆执行计算密集型任务时,多线程可能会出现 1 + 1 > 2 的反效果。

而“真正的并行”只能在多核架构上实现。对于计算密集型任务,巧妙地使用多线程或多进程将其分配至多个 CPU 上,通常可以成倍地缩短运算时间。

作为一门优秀的语言,python 为我们提供了操纵线程的库 threading。使用 threading,我们可以很方便地进行并行编程。但下面的例子可能会让你对“并行”的真实性产生怀疑。

假设我们有一个计算斐波那契数列的函数:

def fib():

a = b = 1

for i in range(100000):

a, b = b, a + b

此处我们不记录其结果,只是为了让它产生一定的计算量,使运算时间开销远大于线程创建、切换的时间开销。现在我们执行 test(fib),尝试在不同数量的线程中执行这个函数。如果线程是“真并行”,时间开销应该不会随线程数大幅上涨。但执行结果却让我们大跌眼镜:

# CPython,fib

Time elapsed with 1 branch(es): 1.246095 sec(s)

Time elapsed with 2 branch(es): 2.535884 sec(s)

Time elapsed with 3 branch(es): 3.837506 sec(s)

Time elapsed with 4 branch(es): 5.107638 sec(s)

从结果中可以发现:时间开销几乎是正比于线程数的!这明显和多核架构的“真并行”相矛盾。这是为什么呢?

一切的罪魁祸首都是一个叫 GIL 的东西。

GIL

GIL 是什么

GIL 的全名是 the Global Interpreter Lock (全局解释锁),是常规 python 解释器(当然,有些解释器没有)的核心部件。我们看看官方的解释:The Python interpreter is not fully thread-safe. In order to support multi-threaded Python programs, there’s a global lock, called the global interpreter lock or GIL, that must be held by the current thread before it can safely access Python objects.

– via Python 3.6.4 Documentation

可见,这是一个用于保护 Python 内部对象的全局锁(在进程空间中唯一),保障了解释器的线程安全。

这里用一个形象的例子来说明 GIL 的必要性(对资源抢占问题非常熟悉的可以跳过不看):

我们把整个进程空间看做一个车间,把线程看成是多条不相交的流水线,把线程控制流中的字节码看作是流水线上待处理的物品。Python 解释器是工人,整个车间仅此一名。操作系统是一只上帝之手,会随时把工人从一条流水线调到另一条——这种“随时”是不由分说的,即不管处理完当前物品与否。

若没有 GIL。假设工人正在流水线 A 处理 A1 物品,根据 A1 的需要将房间温度(一个全局对象)调到了 20 度。这时上帝之手发动了,工人被调到流水线 B 处理 B1 物品,根据 B1 的需要又将房间温度调到了 50 度。这时上帝之手又发动了,工人又调回 A 继续处理 A1。但此时 A1 暴露在了 50 度的环境中,安全问题就此产生了。

而 GIL 相当于一条锁链,一旦工人开始处理某条流水线上的物品,GIL 便会将工人和该流水线锁在一起。而被锁住的工人只会处理该流水线上的物品。就算突然被调到另一条流水线,他也不会干活,而是干等至重新调回原来的流水线。这样每个物品在被处理的过程中便总是能保证全局环境不会突变。

GIL 保证了线程安全性,但很显然也带来了一个问题:每个时刻只有一条线程在执行,即使在多核架构中也是如此——毕竟,解释器只有一个。如此一来,单进程的 Python 程序便无法利用到多核的优势了。

验证

为了验证确实是 GIL 搞的鬼,我们可以用不同的解释器再执行一次。这里使用 pypy(有 GIL)和 jython (无 GIL)作测试:

# PyPy, fib

Time elapsed with 1 branch(es): 0.868052 sec(s)

Time elapsed with 2 branch(es): 1.706454 sec(s)

Time elapsed with 3 branch(es): 2.594260 sec(s)

Time elapsed with 4 branch(es): 3.449946 sec(s)

# Jython, fib

Time elapsed with 1 branch(es): 2.984000 sec(s)

Time elapsed with 2 branch(es): 3.058000 sec(s)

Time elapsed with 3 branch(es): 4.404000 sec(s)

Time elapsed with 4 branch(es): 5.357000 sec(s)

从结果可以看出,用 pypy 执行时,时间开销和线程数也是几乎成正比的;而 jython 的时间开销则是以较为缓慢的速度增长的。jython 由于下面还有一层 JVM,单线程的执行速度很慢,但在线程数达到 4 时,时间开销只有单线程的两倍不到,仅仅稍逊于 cpython 的 4 线程运行结果(5.10 secs)。由此可见,GIL 确实是造成伪并行现象的主要因素。

如何解决?

GIL 是 Python 解释器正确运行的保证,Python 语言本身没有提供任何机制访问它。但在特定场合,我们仍有办法降低它对效率的影响。

使用多进程

线程间会竞争资源是因为它们共享同一个进程空间,但进程的内存空间是独立的,自然也就没有必要使用解释锁了。

许多人非常忌讳使用多进程,理由是进程操作(创建、切换)的时间开销太大了,而且会占用更多的内存。这种担心其实没有必要——除非是对并发量要求很高的应用(如服务器),多进程增加的时空开销其实都在可以接受的范围中。更何况,我们可以使用进程池减少频繁创建进程带来的开销。

下面新建一个 spawner,以演示多进程带来的性能提升:

from multiprocessing import Process

def spawn_n_processes(n, target):

threads = []

for _ in range(n):

thread = Process(target=target)

thread.start()

threads.append(thread)

for thread in threads:

thread.join()

使用 cpython 执行 test(fib, spawner=spawn_n_processes),结果如下:

# CPython, fib, multi-processing

Time elapsed with 1 branch(es): 1.260981 sec(s)

Time elapsed with 2 branch(es): 1.343570 sec(s)

Time elapsed with 3 branch(es): 2.183770 sec(s)

Time elapsed with 4 branch(es): 2.732911 sec(s)

可见这里出现了“真正的并行”,程序效率得到了提升。

使用 C 扩展

GIL 并不是完全的黑箱,CPython 在解释器层提供了控制 GIL 的开关——这就是Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS 宏。这一对宏允许你在自定义的 C 扩展中释放 GIL,从而可以重新利用多核的优势。

沿用上面的例子,自定义的 C 扩展函数好比是流水线上一个特殊的物品。这个物品承诺自己不依赖全局环境,同时也不会要求工人去改变全局环境。同时它带有Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS 两个机关,前者能砍断 GIL 锁链,这样工人被调度走后不需要干等,而是可以直接干活;后者则将锁链重新锁上,保证操作的一致性。

这里同样用一个 C 扩展做演示。由于 C 实现的斐波那契数列计算过快,此处采用另一个计算 PI 的函数:

// cfib.c

#include

static PyObject* fib(PyObject* self, PyObject* args)

{

Py_BEGIN_ALLOW_THREADS

double n = 90000000, i;

double s = 1;

double pi = 3;

for (i = 2; i <= n * 2; i += 2) {

pi = pi + s * (4 / (i * (i + 1) * (i + 2)));

s = -s;

}

Py_END_ALLOW_THREADS

return Py_None;

}

// 模块初始化代码略去

使用 cpython 执行 test(cfib.fib),结果如下:

# CPython, cfib, non-GIL

Time elapsed with 1 branch(es): 1.334247 sec(s)

Time elapsed with 2 branch(es): 1.439759 sec(s)

Time elapsed with 3 branch(es): 1.603779 sec(s)

Time elapsed with 4 branch(es): 1.689330 sec(s)

若注释掉以上两个宏,则结果如下:

# CPython, cfib, with-GIL

Time elapsed with 1 branch(es): 1.331415 sec(s)

Time elapsed with 2 branch(es): 2.671651 sec(s)

Time elapsed with 3 branch(es): 4.022696 sec(s)

Time elapsed with 4 branch(es): 5.337917 sec(s)

可见其中的性能差异。因此当你想做一些计算密集型任务时,不妨尝试用 C 实现,以此规避 GIL。

值得注意的是,一些著名的科学计算库(如 numpy)为了提升性能,其底层也是用 C 实现的,并且会在做一些线程安全操作(如 numpy 的数组操作)时释放 GIL。因此对于这些库,我们可以放心地使用多线程。以下是一个例子:

import numpy

def np_example():

ones = numpy.ones(10000000)

numpy.exp(ones)

用 CPython 执行 test(np_example) 结果如下:

# CPython, np_example

Time elapsed with 1 branch(es): 3.708392 sec(s)

Time elapsed with 2 branch(es): 2.462703 sec(s)

Time elapsed with 3 branch(es): 3.578331 sec(s)

Time elapsed with 4 branch(es): 4.276800 sec(s)

让线程做该做的事

读到这,有同学可能会奇怪了:我在使用 python 多线程写爬虫时可从来没有这种问题啊——用 4 个线程下载 4 个页面的时间与单线程下载一个页面的时间相差无几。

这里就要谈到 GIL 的第二种释放时机了。除了调用 Py_BEGIN_ALLOW_THREADS,解释器还会在发生阻塞 IO(如网络、文件)时释放 GIL。发生阻塞 IO 时,调用方线程会被挂起,无法进行任何操作,直至内核返回;IO 函数一般是原子性的,这确保了调用的线程安全性。因此在大多数阻塞 IO 发生时,解释器没有理由加锁。

以爬虫为例:当 Thread1 发起对 Page1 的请求后,Thread1 会被挂起,此时 GIL 释放。当控制流切换至 Thread2 时,由于没有 GIL,不必干等,而是可以直接请求 Page2……如此一来,四个请求可以认为是几乎同时发起的。时间开销便与单线程请求一次一样。

有人反对使用阻塞 IO,因为若想更好利用阻塞时的时间,必须使用多线程或进程,这样会有很大的上下文切换开销,而非阻塞 IO + 协程显然是更经济的方式。但当若干任务之间没有偏序关系时,一个任务阻塞是可以接受的(毕竟不会影响到其他任务的执行),同时也会简化程序的设计。而在一些通信模型(如 Publisher-Subscriber)中,“阻塞”是必要的语义。

多个阻塞 IO 需要多条非抢占式的控制流来承载,这些工作交给线程再合适不过了。

小结

由于 GIL 的存在,大多数情况下 Python 多线程无法利用多核优势。

C 扩展中可以接触到 GIL 的开关,从而规避 GIL,重新获得多核优势。

IO 阻塞时,GIL 会被释放。作者:thoughts of hsfzxjy

出处:http://t.cn/REkSl1C

小月儿

python 伪多线程_从伪并行的 Python 多线程说起相关推荐

  1. python创建多线程_初学者看过来:Python中多线程和多处理的指南

    使用Python分析数据,如果使用了正确的数据结构和算法,有时可以大量提高程序的速度.实现此目的的一种方法是使用Muiltithreading(多线程)或Multiprocessing(多重处理). ...

  2. scala和python的优缺点_基于Spark环境对比Python和Scala语言利弊

    在数据挖掘中,Python和Scala语言都是极受欢迎的,本文总结两种语言在Spark环境各自特点. 本文翻译自  https://www.dezyre.com/article/Scala-vs-Py ...

  3. 怎么用python自制计算公式_手把手教你用python制作简易计算器,能够记录你使用的情况...

    话不多说,首先先看效果图,它能够记录你在使用过程中的历史,方便你查看是否有错: 接下来就仔细分析一下是如何制作的: 简易计算器 第一步:导入资源库 在过程中使用到了tkinter这个资源库,win+R ...

  4. python做运动控制_第一课:用Python操控小龟小车运动

    欢迎来到小龟的课堂,今天我们讲如何用小龟小车的车载Python控制小车运动. 如果小伙伴还不会使用小龟小车的Python编辑器的话,可以阅读这篇教程<如何使用小龟小车的Python编辑器> ...

  5. print python excel分隔_合并/拆分 Excel?Python、VBA轻松自动化

    作者 | Ryoko 来源 | 凹凸数据 当你收集了 n 个人的 EXCEL 记录表,需要将它们汇成一个总表时你会怎么做呢? 如果不通过技术手段,要一个个打开再复制粘贴也太麻烦了吧! 此时就需要一个通 ...

  6. python深度爬虫_总结:常用的 Python 爬虫技巧

    用python也差不多一年多了,python应用最多的场景还是web快速开发.爬虫.自动化运维:写过简单网站.写过自动发帖脚本.写过收发邮件脚本.写过简单验证码识别脚本. 爬虫在开发过程中也有很多复用 ...

  7. 新手python爬虫代码_新手小白必看 Python爬虫学习路线全面指导

    爬虫是大家公认的入门Python最好方式,没有之一.虽然Python有很多应用的方向,但爬虫对于新手小白而言更友好,原理也更简单,几行代码就能实现基本的爬虫,零基础也能快速入门,让新手小白体会更大的成 ...

  8. python半圆代码_趣味项目:用Python代码做个月饼送给你!

    所用工具 1.Python中的turtle包 2.对Python似火的热情 先来介绍一番 Turtle作图又叫海龟作图,是Python中比较有趣的一个模块,功能强大,使用方便.简单来说,比如有一块空地 ...

  9. 易语言和python混合编程_关于易语言与Python的一点想法

    易语言与python的一点想法">关于易语言与Python的一点想法 小香蕉 2019年7月11日 说在前面 最近吃饭的时候总是会想很多关于易语言的事情.易语言是我学会的第一门语言,虽 ...

  10. python 扒数据_不踩坑的Python爬虫:如何在一个月内学会爬取大规模数据

    Python爬虫为什么受欢迎 如果你仔细观察,就不难发现,懂爬虫.学习爬虫的人越来越多,一方面,互联网可以获取的数据越来越多,另一方面,像 Python这样的编程语言提供越来越多的优秀工具,让爬虫变得 ...

最新文章

  1. leetcode-买卖股票的最佳时机④*
  2. linux ant脚本,linux下ant jmeter自动化测试
  3. 当VS2010安装了Hide Main Menu 插件,发现菜单栏不见了,怎么办?
  4. 《科学+ 预见人工智能》——物理学家的管理方式
  5. Teams Bot的ServiceLevel测试
  6. SVN+AnkhSVN端配置
  7. HBase 手动 flush 机制梳理
  8. 褚时健:现在的年轻人太急了,我快90了还在摸爬滚打
  9. 负载均衡 > 用户指南 > 证书管理 > 证书要求
  10. 2020 年物联网设备达 500 亿台!AI、区块链技术加持,优秀开发者稀缺!
  11. java mina 大文件传输_mina 传输java对象
  12. 空气污染指数的计算公式是什么?(API)
  13. 轻松上手Manjaro之Manjaro常用桌面软件(微信、TIM/QQ、网易云音乐、OneDrive等)安装
  14. DNS与GTM协同工作原理
  15. python回归分析波士顿房价_python 线性回归(Linear Regression)预测波士顿房价
  16. Jquery实现遮罩
  17. 微信小程序之问答论坛(含源码+论文+答辩PPT等)
  18. 2021 CSP-S 初赛知识补天
  19. linux ftok函数
  20. SPA 的 SEO 方案对比、最终实践

热门文章

  1. 【Docker】总集篇
  2. java怎么定义scanner_Java Scanner类的常用方法及用法(很详细)
  3. php字符串操作整理,PHP学习之整理字符串
  4. java序列不存在错误_java.sql.SQLException: ORA-02289: 序列不存在 已解决!
  5. java ocx调用_Javascript调用OCX控件
  6. phpstom可以配置php环境吗_环境配置 · PhpStorm · 看云
  7. java编写一个汽车出租管理程序_初学者,写了一个汽车出租管理程序,请大神解决错误。...
  8. 【转】js如何准确获取当前页面url网址信息
  9. ASP.NET页面生命周期和asp.net应用程序生命周期
  10. 台湾“比基尼登山客”遗体运出 山友接其“回家”