Python GIL

简介: 在这篇博文中,我们将介绍Python GIL, Threads, Processes和AsyncIO。

在这篇博文中,我们将介绍Python GIL, Threads, Processes和AsyncIO

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

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 Threadfrom 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

image

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

image

但是,删除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

在我的机器上,使用参数谷歌调用一次此函数需要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。还有一些其他的实现,像Jpython和IronPython没有GIL,所以可以直接使用线程。如果你不知道Jpython或者IronPython是什么,请自行GOOGLE,这里有一篇博文仅供参考:What is Python?
http://rahmonov.me/posts/what-is-python/

原文发布时间为:2018-08-01
本文作者: Tonner
本文来自云栖社区合作伙伴“ Python爱好者社区”,了解相关信息可以关注“ Python爱好者社区

相关文章
|
3月前
|
安全 Java C++
解释Python中的全局解释器锁(GIL)和线程安全的概念。
解释Python中的全局解释器锁(GIL)和线程安全的概念。
27 0
|
2月前
|
Rust 并行计算 Python
|
3月前
|
安全 Java 测试技术
Python 官方研讨会:彻底移除 GIL 真的可行么?
Python 官方研讨会:彻底移除 GIL 真的可行么?
41 0
|
3月前
|
并行计算 安全 Python
深入理解Python多线程:GIL全局解释器锁的影响
深入理解Python多线程:GIL全局解释器锁的影响
|
3月前
|
分布式计算 安全 Python
解释Python中的GIL(全局解释器锁)对多线程编程的影响。
解释Python中的GIL(全局解释器锁)对多线程编程的影响。
26 0
|
5月前
|
安全 C语言 C++
Python 基础知识: 解释 Python 中的 GIL 是什么?有什么影响?
Python 基础知识: 解释 Python 中的 GIL 是什么?有什么影响?
|
5月前
|
Java Python
描述一下Python中的GIL(全局解释器锁)。
描述一下Python中的GIL(全局解释器锁)。