1. GIL是什么
  1.1 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又是干啥的呢?
  1.2 玩过C语言的都知道,C语言需要手动进行内存分配,释放,否则会出现内存泄露的问题。cpython中利用引用计数来进行内存管理,这就意味着在Python中创建的对象都有一个引用计数变量来追踪指向该对象的引用数量。当数量为0时,该对象占用的内存即被释放。如下:

import sys

a = [1]
print(sys.getrefcount(a))  # 此时的引用计数为2 不应该是1吗?为什么是2那?因为调用函数getrefcount的时候传入a,这会让a的引用计数+1

a.append(2)
print(sys.getrefcount(a))

b = a # 此时,创建b, b指向a, b的引用计数减一
print(sys.getrefcount(a))

del b # 此时,b指向a,将b删除,a的引用计数减一, 当a的引用计数为0时,a将被回收
print(sys.getrefcount(a))

执行结果:

2
2
3
2

  如上,对于同一个变量,如果让两个线程同时操作他,那么问题就来了。这个变量的引用计数不能被同时增加或者减少,也就说任意时刻都必须保证这个变量的引用计数的全局一致性。否则变量的引用计数有可能不准确,这样的结果会导致泄露的内存永远不会被释放,抑或更严重的是当一个对象的引用仍然存在的情况下错误地释放内存。这可能会导致Python程序崩溃或带来各种诡异的bug。
  那么这个时候怎么办呢?可以通过对跨线程分享的数据结构添加锁定以至于数据不会不一致地被修改,这样做可以很好的保证引用计数变量的安全。但是对每一个对象或者对象组添加锁意味着会存在多个锁,这也就导致了另外一个问题——死锁(只有当存在多个锁时才会发生)。而另一个副作用是由于重复获取和释放锁而导致的性能下降。所以看来使用多锁虽然能解决全局变量的一致性,但是对性能也有很大的影响,怎么办呢?
  这个时候GIL就闪亮登场了。GIL是全局解释器锁是一个单一锁,它增加的一条规则要求任何Python字节码的执行都需要获取解释锁。这有效地防止了死锁(因为只存在一个锁)并且不会带来太多的性能开销。
  此外人们针对于C库中那些被Python所需的功能写了许多扩展,为了防止不一致变化,这些C扩展需要线程安全内存管理,而这些正是GIL所提供的。GIL是非常容易实现而且很容易添加到Python中。因为只需要管理一个锁,所以对于单线程任务来说带来了性能提升。非线程安全的C库变得更容易集成,而这些C扩展则成为Python强大的功能之一。
2 GIL的出生与发展
  虽然说GIL其最早存在主要是因为在代码执行过程中,CPython的内存管理不是线程安全的。因为随着时代的发展,计算机硬件开始往多核多线程方向发展了,为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即使在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,也不可避免的带来了一定的性能损失。
   Python当然也逃不开,为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 同样还是GIL这把超级自动大锁,让python支持的多线程实现了安全。而当越来越多的代码库开发者接受了这种设定后,他们开始大量依赖这种特性(因为默认加了GIL自动锁后,相当于python中是多线程安全的,这样开发者在实际开发中就不需要关心线程安全和锁的问题了,以至于后来尾大不掉,想删除GIL锁已经很难更改了)
  查看python官网对于GIL在多线程中的使用说明如下:

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

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

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

3. GIL锁的释放机制
  Python解释器进程内的多线程是合作多任务方式执行。当一个线程遇到I/O任务时,将释放GIL。计算密集型(CPU-bound)的线程在执行大约100次解释器的计步(ticks)时,将释放GIL。计步(ticks)可粗略看作Python虚拟机的指令。计步实际上与时间片长度无关。可以通过sys.setcheckinterval()设置计步长度。
   Python 3.2开始使用新的GIL。在新的GIL实现中,用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁的时候,当前线程就会在5ms后被强制释放掉这个锁
4. python中使用多线程和单线程执行效率分析
  4.1 一般来说,比如Java中多线程程序的执行效率一般要比单线程的高,但是在在Cpython中多线程实际上是“伪多线程”,那么其同样一个程序用多线程和单线程执行的结果又如何呢?
A1.单线程执行同一个程序调用,耗时163.93595623970032s

import time


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


def counter2():
    for j in range(1000000000):
        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: 1000000010
this is i: 1000000005
this is j: 1000000010
this is i: 1000000005
Total time: 163.93595623970032

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

from threading import Thread
import time
 
def counter1():
    for i in range(1000000000):
        i = i + 1
    print("this is i:",i+5)
 
def counter2():
    for j in range(1000000000):
        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 j: 1000000010
this is i: 1000000005
this is j: 1000000010
this is i: 1000000005
Total time: 170.96073985099792

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

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

import time


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


def counter2():
    for j in range(10000):
        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: 10010
this is i: 10005
this is j: 10010
this is i: 10005
Total time: 402.3864288330078

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

from threading import Thread
import time


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


def counter2():
    for j in range(10000):
        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 j: 10010
this is i: 10005
this is i: 10005
this is j: 10010
Total time: 201.2095627784729

为什么同样一个程序,增加了sleep耗时操作以后在python中多线程的操作又比单线程执行的更快了呢?(几乎相差一倍)这不就和上面的结果矛盾了吗?这其实说到底就是GIL锁的释放机制了。如上:当一个线程遇到I/O任务时,将释放GIL。计算密集型(CPU-bound)的线程在执行大约100次解释器的计步(ticks)时,将释放GIL。所以说我们增加了sleep耗时操作,相当于将计算型的程序变成了耗时等待的I/O程序,这个时候GIL锁遇到I/O任务时,不会继续等待耗时操作,而是立马释放锁,给其他线程去执行,这样的话效率会比单线程高很多(因为单线程需要等待耗时结束才能继续执行)
5. CPU密集型、IO密集型
  5.1 CPU密集型(CPU-bound)
  CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。

  在多重程序系统中,大部份时间用来做计算、逻辑判断等CPU动作的程序称之CPU bound。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,便是属于CPU bound的程序。

  CPU bound的程序一般而言CPU占用率相当高。这可能是因为任务本身不太需要访问I/O设备,也可能是因为程序是多线程实现因此屏蔽掉了等待I/O的时间
  5.2 IO密集型(I/O bound)
  IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。

  I/O bound的程序一般在达到性能极限时,CPU占用率仍然较低。这可能是因为任务本身需要大量I/O操作,而pipeline做得不是很好,没有充分利用处理器能力。
  5.3 CPU密集型 vs IO密集型
  我们可以把任务分为计算密集型和IO密集型。

  计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。

  计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。

  第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。

  io密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。
6. 总结
  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的核心数。