热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Python:GIL前世今生与核心用法剖析

Python:GIL前世今生与核心用法剖析-1.GIL的前世今生1.1GIL的是什么?    python是解释型语言,不用编译,运行时可以直接通过解释器进行解释执行了。类似l

1.GIL的前世今生

1.1GIL的是什么?

       python是解释型语言,不用编译,运行时可以直接通过解释器进行解释执行了。类似linux中的bash解释器,所以python中也有很多解释器,如cpython(C语言实现),jpython等,只是默认的解释器Cpython,所以大家一般使用的python环境都是基于Cpython的。

        我们所说的Python GIL是Global Interpreter Lock,翻译过来就是: 全局解释器锁,我们从GIL的名字就可看出其是一个解释器锁,针对的主题是解释器。所以GIL并不是Python的特性,它是在实现Python解析器(Cpython)时所引入的一个概念,而同样作为python解释器的Jpython就没有GIL。那么为什么Cpython需要GIL,而Jpython不需要GIL呢? GIL又是干啥的呢?

       查看python官网文档,发现对GIL出现描述如下:

在Cpython中GIL是一个防止解释器多线程并发执行机器码的一个全局互斥锁。其存在主要是因为在代码执行过程中,CPython的内存管理不是线程安全的。

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 mainly 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.)

1.2GIL解决了python中的什么问题?

      玩过C语言的都知道,C语言需要手动进行内存分配,释放,否则会出现内存泄露的问题。cpython中利用引用计数来进行内存管理,这就意味着在Python中创建的对象都有一个引用计数变量来追踪指向该对象的引用数量。当数量为0时,该对象占用的内存即被释放。如下:

>>> import sys
>>> a = [1,2,3]
>>> b = a
>>> sys.getrefcount(a),sys.getrefcount(b)  #查看列表[1,2,3]的引用次数。
(3, 3)
>>> a.append(4) #对列表追加一个元素
>>> a
[1, 2, 3, 4]
>>> sys.getrefcount(a),sys.getrefcount(b)
(4, 4)
>>> del a  #删除a以后,列表的引用减少了1位。
>>> a
Traceback (most recent call last):
  File "", line 1, in 
NameError: name 'a' is not defined
>>> b
[1, 2, 3, 4]
>>> sys.getrefcount(b)
3

如上,对于同一个变量,如果让两个线程同时操作他,那么问题就来了。这个变量的引用计数不能被同时增加或者减少,也就说任意时刻都必须保证这个变量的引用计数的全局一致性。否则变量的引用计数有可能不准确,这样的结果会导致泄露的内存永远不会被释放,抑或更严重的是当一个对象的引用仍然存在的情况下错误地释放内存。这可能会导致Python程序崩溃或带来各种诡异的bug。

那么这个时候怎么办呢?可以通过对跨线程分享的数据结构添加锁定以至于数据不会不一致地被修改,这样做可以很好的保证引用计数变量的安全。但是对每一个对象或者对象组添加锁意味着会存在多个锁,这也就导致了另外一个问题——死锁(只有当存在多个锁时才会发生)。而另一个副作用是由于重复获取和释放锁而导致的性能下降。所以看来使用多锁虽然能解决全局变量的一致性,但是对性能也有很大的影响,怎么办呢?

      这个时候GIL就闪亮登场了。GIL是全局解释器锁是一个单一锁,它增加的一条规则要求任何Python字节码的执行都需要获取解释锁。这有效地防止了死锁(因为只存在一个锁)并且不会带来太多的性能开销。

      此外人们针对于C库中那些被Python所需的功能写了许多扩展,为了防止不一致变化,这些C扩展需要线程安全内存管理,而这些正是GIL所提供的。GIL是非常容易实现而且很容易添加到Python中。因为只需要管理一个锁,所以对于单线程任务来说带来了性能提升。非线程安全的C库变得更容易集成,而这些C扩展则成为Python强大的功能之一。

1.3GIL的出生与发展

          虽然说GIL其最早存在主要是因为在代码执行过程中,CPython的内存管理不是线程安全的。因为随着时代的发展,计算机硬件开始往多核多线程方向发展了,为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即使在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,也不可避免的带来了一定的性能损失。

        Python当然也逃不开,为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 同样还是GIL这把超级自动大锁,让python支持的多线程实现了安全。而当越来越多的代码库开发者接受了这种设定后,他们开始大量依赖这种特性(因为默认加了GIL自动锁后,相当于python中是多线程安全的,这样开发者在实际开发中就不需要关心线程安全和锁的问题了,以至于后来尾大不掉,想删除GIL锁已经很难更改了)。

        查看python官网对于GIL在多线程中的使用说明如下:

        Python解释器(Cpython)不是完全线程安全的。为了支持多线程Python程序,有一个全局锁,称为全局解释器锁或GIL。当前线程必须持有该锁才能允许其访问Python对象。如果没有锁定,即使最简单的操作也可能导致多线程程序出现问题:例如,当两个线程同时递增同一对象的引用计数时,引用计数最终只能递增一次而不是两次。

** 因此规定只有获取GIL的线程可以在Python对象上操作或调用Python / C API函数。为了模拟执行的并发性,解释器会定期尝试切换线程(请参阅参考资料sys.setswitchinterval())。锁也会在读取或写入文件等潜在阻塞I / O操作时释放,以便其他Python线程可以同时运行。**

其实说到底就是一句话,在Cpython解释器的多线程程序中,为了保证线程操作安全,默认使用了一个GIL锁,该锁GIL是一个阻止多线程同时执行的互斥锁,保证任意时刻只有一个线程在正在执行,其余线程处于等待状态,只是不同线程执行时切换的很快,虽然是并发状态,但看上去像是并行。所以说在Cpython中多线程实际来说是“伪多线程”。

1.4GIL锁的释放机制

        Python解释器进程内的多线程是合作多任务方式执行。当一个线程遇到I/O任务时,将释放GIL。计算密集型(CPU-bound)的线程在执行大约100次解释器的计步(ticks)时,将释放GIL。计步(ticks)可粗略看作Python虚拟机的指令。计步实际上与时间片长度无关。可以通过sys.setcheckinterval()设置计步长度。

        Python 3.2开始使用新的GIL。在新的GIL实现中,用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁的时候,当前线程就会在5ms后被强制释放掉这个锁。

2.GIL在多线程中使用与注意事项

2.1python中使用多线程和单线程执行效率分析

****一般来说,比如Java中多线程程序的执行效率一般要比单线程的高,但是在在Cpython中多线程实际上是“伪多线程”,那么其同样一个程序用多线程和单线程执行的结果又如何呢?

A1.单线程执行同一个程序调用,耗时84.12s

import time

def counter1():
    for i in range(100000000):
        i = i + 1
    print("this is i:",i+5)

def counter2():
    for j in range(100000000):
        j = j + 1
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        counter2()
        counter1()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is j: 100000010
this is i: 100000005
this is j: 100000010
this is i: 100000005
Total time: 84.12183594703674
'''

A2.多线程执行同一个程序,耗时89.27s。

from threading import Thread
import time

def counter1():
    for i in range(100000000):
        i = i + 1
    print("this is i:",i+5)

def counter2():
    for j in range(100000000):
        j = j + 1
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        t1 = Thread(target = counter2)
        t2 = Thread(target=counter1)
        t1.start()
        t2.start()
        t2.join()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))

if __name__ == '__main__':
    main()

'''
this is i: 100000005
this is j: 100000010
this is i: 100000005
Total time: 89.27375364303589
this is j: 100000010
'''

尖叫提示1:显然上面两个案例看出同一个程序,在python中 (Cpthon)单线程反而要比多线程执行的快,因为GIL锁的缘故,多线程实际上需要频繁切换进行并发操作,尤其对于多核CPU来说,存在严重的线程颠簸(thrashing)。​​​​尽管如此,那么是不是说python中单线程就一定比多线程效率高呢?请看下面案例。

B1.同样使用单线程执行同一个程序,注意同样是上面的程序,这里在代码中增加了sleep(0.01)耗时操作。结果这个时候单线程 执行完程序耗时:42.91s.

import time

def counter1():
    for i in range(1000):
        i = i + 1
        time.sleep(0.01)
    print("this is i:",i+5)

def counter2():
    for j in range(1000):
        j = j + 1
        time.sleep(0.01)
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        counter2()
        counter1()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is j: 1010
this is i: 1005
this is j: 1010
this is i: 1005
Total time: 42.90534329414368

'''

B2.同样使用多线程执行同一个程序,注意同样是上面的程序,这类在代码中增加了sleep(0.01)耗时操作。结果这个时候多线程 执行完程序耗时:21.78s。

from threading import Thread
import time

def counter1():
    for i in range(1000):
        i = i + 1
        time.sleep(0.01)
    print("this is i:",i+5)

def counter2():
    for j in range(1000):
        j = j + 1
        time.sleep(0.01)
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        t1 = Thread(target = counter1)
        t2 = Thread(target=counter2)
        t1.start()
        t2.start()
        t2.join()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is i: 1005
this is j: 1010
this is j: 1010
Total time: 21.78059458732605
this is i: 1005

'''

尖叫提示2:为什么同样一个程序,增加了sleep耗时操作以后在python中多线程的操作又比单线程执行的更快了呢?这不就和上面的结果矛盾了吗?这其实说到底就是GIL锁的释放机制了。如上:当一个线程遇到I/O任务时,将释放GIL。计算密集型(CPU-bound)的线程在执行大约100次解释器的计步(ticks)时,将释放GIL。所以说我们增加了sleep耗时操作,相当于将计算型的程序变成了耗时等待的I/O程序,这个时候GIL锁遇到I/O任务时,不会继续等待耗时操作,而是立马释放锁,给其他线程去执行,这样的话效率会比单线程高很多(因为单线程需要等待耗时结束才能继续执行)。

2.2python中GIL与多线程的使用总结

很显然通过上满A1,A2,B1,B24个案例的结果我们得出如下结论:

  1. python多线程适合做io密集型程序,因为有延时,可以GIL自动解阻塞,所以效率更高。相反,如果是计算密集型程序,python中单线程因为没有线程切换的延时,效率更高。
  2. 实际开发中,如果是计算密集型程序,一般使用多进程,多进程可以并行适合计算密集型,发挥多核cpu。计算密集型程序来说,进程效率>单线程>多线程。
  3. GIL在较长一段时间内将会继续存在,但是会不断对其进行改进,所以干脆还是使用multiprocessing替代Thread或者使用协程吧。
  4. 协程适合IO密集型,只用单核。效率要比单线程高。
  5. IO密集型:涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。当然我们在Python中可以利用sleep达到IO密集型任务的目的。
  6. 计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。\
     

推荐阅读
  • Ihavetwomethodsofgeneratingmdistinctrandomnumbersintherange[0..n-1]我有两种方法在范围[0.n-1]中生 ... [详细]
  • 零拷贝技术是提高I/O性能的重要手段,常用于Java NIO、Netty、Kafka等框架中。本文将详细解析零拷贝技术的原理及其应用。 ... [详细]
  • Python 程序转换为 EXE 文件:详细解析 .py 脚本打包成独立可执行文件的方法与技巧
    在开发了几个简单的爬虫 Python 程序后,我决定将其封装成独立的可执行文件以便于分发和使用。为了实现这一目标,首先需要解决的是如何将 Python 脚本转换为 EXE 文件。在这个过程中,我选择了 Qt 作为 GUI 框架,因为之前对此并不熟悉,希望通过这个项目进一步学习和掌握 Qt 的基本用法。本文将详细介绍从 .py 脚本到 EXE 文件的整个过程,包括所需工具、具体步骤以及常见问题的解决方案。 ... [详细]
  • 【妙】bug称它为数组越界的妙用
    1、聊一聊首先跟大家推荐一首非常温柔的歌曲,跑步的常听。本文主要把自己对C语言中柔性数组、零数组等等的理解分享给大家,并聊聊如何构建一种统一化的学习思想 ... [详细]
  • 本文详细介绍了Java反射机制的基本概念、获取Class对象的方法、反射的主要功能及其在实际开发中的应用。通过具体示例,帮助读者更好地理解和使用Java反射。 ... [详细]
  • Java高并发与多线程(二):线程的实现方式详解
    本文将深入探讨Java中线程的三种主要实现方式,包括继承Thread类、实现Runnable接口和实现Callable接口,并分析它们之间的异同及其应用场景。 ... [详细]
  • 检查在所有可能的“?”替换中,给定的二进制字符串中是否出现子字符串“10”带 1 或 0 ... [详细]
  • 本文介绍了如何使用Python的Paramiko库批量更新多台服务器的登录密码。通过示例代码展示了具体实现方法,确保了操作的高效性和安全性。Paramiko库提供了强大的SSH2协议支持,使得远程服务器管理变得更加便捷。此外,文章还详细说明了代码的各个部分,帮助读者更好地理解和应用这一技术。 ... [详细]
  • 大类|电阻器_使用Requests、Etree、BeautifulSoup、Pandas和Path库进行数据抓取与处理 | 将指定区域内容保存为HTML和Excel格式
    大类|电阻器_使用Requests、Etree、BeautifulSoup、Pandas和Path库进行数据抓取与处理 | 将指定区域内容保存为HTML和Excel格式 ... [详细]
  • 本文详细解析了客户端与服务器之间的交互过程,重点介绍了Socket通信机制。IP地址由32位的4个8位二进制数组成,分为网络地址和主机地址两部分。通过使用 `ipconfig /all` 命令,用户可以查看详细的IP配置信息。此外,文章还介绍了如何使用 `ping` 命令测试网络连通性,例如 `ping 127.0.0.1` 可以检测本机网络是否正常。这些技术细节对于理解网络通信的基本原理具有重要意义。 ... [详细]
  • 深入解析C语言中结构体的内存对齐机制及其优化方法
    为了提高CPU访问效率,C语言中的结构体成员在内存中遵循特定的对齐规则。本文详细解析了这些对齐机制,并探讨了如何通过合理的布局和编译器选项来优化结构体的内存使用,从而提升程序性能。 ... [详细]
  • Java Socket 关键参数详解与优化建议
    Java Socket 的 API 虽然被广泛使用,但其关键参数的用途却鲜为人知。本文详细解析了 Java Socket 中的重要参数,如 backlog 参数,它用于控制服务器等待连接请求的队列长度。此外,还探讨了其他参数如 SO_TIMEOUT、SO_REUSEADDR 等的配置方法及其对性能的影响,并提供了优化建议,帮助开发者提升网络通信的稳定性和效率。 ... [详细]
  • Python多线程编程技巧与实战应用详解 ... [详细]
  • 本文介绍了如何利用 Delphi 中的 IdTCPServer 和 IdTCPClient 控件实现高效的文件传输。这些控件在默认情况下采用阻塞模式,并且服务器端已经集成了多线程处理,能够支持任意大小的文件传输,无需担心数据包大小的限制。与传统的 ClientSocket 相比,Indy 控件提供了更为简洁和可靠的解决方案,特别适用于开发高性能的网络文件传输应用程序。 ... [详细]
  • 本文深入探讨了Java多线程环境下的同步机制及其应用,重点介绍了`synchronized`关键字的使用方法和原理。`synchronized`关键字主要用于确保多个线程在访问共享资源时的互斥性和原子性。通过具体示例,如在一个类中使用`synchronized`修饰方法,展示了如何实现线程安全的代码块。此外,文章还讨论了`ReentrantLock`等其他同步工具的优缺点,并提供了实际应用场景中的最佳实践。 ... [详细]
author-avatar
mobiledu2502881573
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有