Python GIL

综合编程 2018-07-23 阅读原文

在这篇博文中,我们将介绍 Python GIL , Threads , ProcessesAsyncIO

假设我们想要编写一个函数,该函数将数字作为参数并简单的倒计时,挺容易:

def count_down(n):
    while n > 0:
        n -= 1

让我们用一个大数字来调用这个函数并测量函数耗时:

from time import time

before = time()
count_down(100000000)
after = time()
print(after - before)

在我的机器上,需要5.62秒。现在,让我们调用它两次并测量耗时

from time import time

before = time()
count_down(100000000)
count_down(100000000)
after = time()
print(after - before)

在我的机器上,需要11.58秒

它正常工作,但是我们的老板不满意,他希望执行的更快。我们如何让它更快?我们使用多线程使这两次函数调用并行运行。理论上,并行运行函数两次应该花费函数一次执行的时间,因为函数的调用是并行执行的。让我们通过在不同的线程中调用这个函数两次:

from threading import Thread
from time import time

before = time()
threads = []

for item in range(1, 3):
    t = Thread(target=count_down, args=(100000000,))
    t.start()
    threads.append(t)
[thread.join() for thread in threads]

after = time()

print(after - before)

我的机器大约需要11.2秒。等等,什么?它不仅没有之前的快,但实际上它甚至比不使用多线程更慢。使用Python2.7你会得到更糟糕的结果。在一个着名的软件开发人员David Beazly报告( inside the Python GIl )中提到:它甚至花费几乎两倍的时间

But, WHY?

请欢迎臭名昭著的GIL(Global Interpreter Lock). 这家伙应该为Python世界上所有的事情负责。GIL在同一时间只允许一个线程运行。在上面的例子中,当我们认为我们使用了两个线程,我们实际只是用了一个,因为GIL不允许我们这么做。这就是它无法运行得更快的原因。但是,为什么它甚至更慢?因为Python试图不停的切换线程让两个线程同时工作。GIL不允许这么做。然而,无用的切换会带来额外的开销。因此,最终结果甚至更慢。

But, WHY?

我们都知道多线程是个好东西,可以帮我们让程序运行更快。 为什么有GIL的存在呢?该死的,GIL就像行尸走肉中的Negan

negan.jpg

好吧,事实证明GIL并不是一个恶棍。实际上,GIL是一个好东西。实际上Python的内存管理不是线程安全的!也就是说,如果你同时运行多个线程,你的程序会出现各种莫名其妙的现象甚至是灾难性的。GIL可以帮我们摆脱这种情况。

superman.jpg

但是,删除Thread类不是更容易吗?

很好的问题!既然想到要编写一个额外的工具来防止 Threads 对我们的程序产生不好影响,为什么不直接删除 Threads 类,让程序员根本无法使用它们呢?

实际上,有些情况 Thread 很有用。以上的示例都是CPU密集型计算,意味着它们只需在CPU上运行并且在CPU上等待。但是,如果你的代码是IO密集型计算或者是图像处理或者是NumPy数字运算,GIL将不会妨碍,因为这些操作发生在GIL之外。在这些地方你可以安全有效的使用 Thread

让我们编写一个IO密集型计算的函数,函数完成请求一个url并将结果作为文本返回。

import requests

def get_content(url):
    response = requests.get(url)
    return response.text

在我的机器上,使用参数 https://google.com 调用一次此函数需要1.1 秒,调用两次需要2.1秒。现在让我们在不同的线程中调用此函数两次,看看 Thread 的效果。

urls = ['https://google.com'] * 2
before = time()

threads = []
for url in urls:
    thread1 = Thread(target=get_content, args=(url,))
    thread1.start()
    threads.append(thread1)

[thread.join() for thread in threads]

after = time()
print(after - before)

只需要1.15秒,万岁!在不同的线程中调用两次相当于调用一次的时间。因为我们的函数是IO密集型计算。当我们的函数是CPU密集型计算(之前的示例中函数完成递减就是CPU密集型计算)时同样的事情就不会发生。

还有一个问题

每一个线程需要一些额外的内存,线程切换需要一些时间。虽然不是很多,但是当你运行数千个线程的时候资源的消耗就会增加。想象一下数千兆字节的额外RAM和至少5%的CPU时间仅用于上下文切换。

为了解决这个问题,Python开发人员提供了一个 asyncio 库。它有自己的事件循环来控制单个线程内异步方式的函数执行。如果线程被底层的操作系统控制, asyncio 知道何时借助于开发人员自己编写的关键词切换任务。没错!你来决定何时切换。让我们将上面的示例转换成 asyncio 实现:

import asyncio
import aiohttp

loop = asyncio.get_event_loop()
session = aiohttp.ClientSession(loop=loop)


async def get_content(pid, url):
    async with session.get(url) as response:
        content = await response.read()
        print(pid, content)

loop.create_task(get_content(1, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(2, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(3, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(4, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(5, 'http://asyncio.readthedocs.io/'))

loop.run_forever()

注意我们使用了 aiohttp 代替了 requests ,因为它是一个异步的HTTP包。在不涉及细节的情况下,这段代码做所的是定义一个异步函数(aka协成)并使用不同的ID(仅仅是为了说明其异步性)调用五次,输出应该是这样的:

1 b'n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
3 b'n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
4 b'n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
5 b'n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
2 b'n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0

请注意,ID不是按照他们的调用顺序来显示的,也就是说当遇到关键词 await 时任务被切换了。我们可以使用 Thread 得到相同的结果,但是 asyncio 使用了较少的开销并且您控制切换过程。

虽然这个示例没有解释 asyncio 的细节, 但我希望它至少能解释为什么它是有用的。

好了,我们现在处理了IO密集型任务,让我们回到我们第一个CPU密集型计算的问题。

CPU密集型计算任务的解决方式

Python 中, multiprocessing.Process 提供了和 Thread 相似的功能和接口。区别是它使用了子进程而不是线程。这就是它不会被GIL阻塞的原因。太棒了!让我们使用 Process 重写上面的示例。正如我所说, Process 提供了和 Thread 形似的接口,我们只需要把 Thread 替换成 Process 即可完成这个示例:

from multiprocessing import Process
from time import time

def count_down(n):
    while n > 0:
        n -= 1

before = time()

processes = []
for item in range(1, 2):
    process1 = Process(target=count_down, args=(100000000,))
    process1.start()
    processes.append(process1)
[process.join() for process in processes]

after = time()
print(after - before)

我的机器花了11秒。这是我们之前调用一次函数的时间。酷!试着再添加一个进程仍然需要同样的时间。太棒了!但是,请记住,进程在一个单独的内容空间运行,因此不能彼此共享数据,而线程可以。

我们这里谈的是 Cpython 。还有一些其他的实现,像 JpythonIronPython 没有 GIL ,所以可以直接使用线程。如果你不知道 Jpython 或者 IronPython 是什么,请自行GOOGLE,这里有一篇博文仅供参考: What is Python?

感谢阅读,如果有任何问题,请留下评论。

Fight on!

简书

责编内容by:简书阅读原文】。感谢您的支持!

您可能感兴趣的

A Python Script to Download Thousands of Wallpaper... Wallhaven-dl UPDATE ###The script now comes with a serach functionality, you...
Collision detection Chipmunk Cocos2d v3 & amp; chipmunk collision detection probl...
python pandas 实战 百度音乐歌单 数据分析... 1.播放次数分析 chart1 = df3.sort_values('playCount', ascending=False).drop_dupl...
Python3——根据m3u8下载视频(上)之urllib.request... 干活干活,区区懒癌已经阻挡不了澎湃的洪荒之力了...... 运行环境:Windows基于python3.6 -------------------...
盘点一下不到100行的给力代码 参考资料: towardsdatascience.com 只需10行Python代码,我们就能实现计算机视觉中目标检测。 from imageai....