Python 多线程、多进程 (一)之 源码执行流程、GIL
Python 多线程、多进程 (二)之 多线程、同步、通信
Python 多线程、多进程 (三)之 线程进程对比、多线程

一、python程序的运行原理

许多时候,在执行一个python文件的时候,会发现在同一目录下会出现一个__pyc__文件夹(python3)或者.pyc后缀(python2)的文件
Python在执行时,首先会将.py文件中的源代码编译成Python的byte code(字节码),然后再由Python Virtual Machine(Python虚拟机)来执行这些编译好的byte code。

1、执行流程

源代码.py ——(编译处理)——>字节码.pyc ———>python虚拟机——(编译)——>程序

2、编译

执行 python demo.py 后,将会启动 Python 的解释器,然后将 demo.py 编译成一个字节码对象 PyCodeObject。

在运行期间,编译结果也就是 PyCodeObject 对象,只会存在于内存中,而当这个模块的 Python 代码执行完后,就会将编译结果保存到了 pyc 文件中,这样下次就不用编译,直接加载到内存中。pyc 文件只是 PyCodeObject 对象在硬盘上的表现形式。
这个 PyCodeObject 对象包含了 Python 源代码中的字符串,常量值,以及通过语法解析后编译生成的字节码指令。PyCodeObject 对象还会存储这些字节码指令与原始代码行号的对应关系,这样当出现异常时,就能指明位于哪一行的代码。

3、pyc文件

一个 pyc 文件包含了三部分信息:Python 的 magic number、pyc 文件创建的时间信息,以及 PyCodeObject 对象。

magic number 是 Python 定义的一个整数值。一般来说,不同版本的 Python 实现都会定义不同的 magic number,这个值是用来保证 Python 兼容性的。比如要限制由低版本编译的 pyc 文件不能让高版本的 Python 程序来执行,只需要检查 magic number 不同就可以了。由于不同版本的 Python 定义的字节码指令可能会不同,如果不做检查,执行的时候就可能出错。

4、字节码指令

为什么 pyc 文件也称作字节码文件?因为这些文件存储的都是一些二进制的字节数据,而不是能让人直观查看的文本数据。
Python 标准库提供了用来生成代码对应字节码的工具 dis。dis 提供一个名为 dis 的方法,这个方法接收一个 code 对象,然后会输出 code 对象里的字节码指令信息。

# test1.py

import dis

def add(a):
    a = a+1
    return a

print(dis.dis(add))

# 输出
 10           0 LOAD_FAST                0 (a)
              3 LOAD_CONST               1 (1)
              6 BINARY_ADD
              7 STORE_FAST               0 (a)

 11          10 LOAD_FAST                0 (a)
             13 RETURN_VALUE

5、python虚拟机

demo.py 被编译后,接下来的工作就交由 Python 虚拟机来执行字节码指令了。Python 虚拟机会从编译得到的 PyCodeObject 对象中依次读入每一条字节码指令,并在当前的上下文环境中执行这条字节码指令。我们的程序就是通过这样循环往复的过程才得以执行。

二、进程线程

1、进程

程序仅仅只是一堆代码而已,而进程指的是程序的运行过程。需要强调的是:同一个程序执行两次,那也是两个进程。
进程:资源管理单位(容器)。
线程:最小执行单位,管理线程的是进程。

进程就是一个程序在一个数据集上的一次动态执行过程。进程一般由程序、数据集、进程控制块三部分组成。我们编写的程序用来描述进程要完成哪些功能以及如何完成;数据集则是程序在执行过程中所需要使用的资源;进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。

2、线程

线程的出现是为了降低上下文切换的消耗,提高系统的并发性,并突破一个进程只能干一样事的缺陷,使到进程内并发成为可能。
线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元,由线程ID、程序计数器、寄存器集合和堆栈共同组成。线程的引入减小了程序并发执行时的开销,提高了操作系统的并发性能。线程没有自己的系统资源。

3、线程与进程关系

在传统操作系统中,每个进程有一个地址空间,而且默认就有一个控制线程。
多线程(即多个控制线程)的概念是,在一个进程中存在多个控制线程,控制该进程的地址空间。
进程只是用来把资源集中到一起(进程只是一个资源单位,或者说资源集合),而线程才是cpu上的执行单位。

进程和线程的关系:
(1)一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。
(2)资源分配给进程,同一进程的所有线程共享该进程的所有资源。
(3)CPU分给线程,即真正在CPU上运行的是线程。

4、串行,并行与并发

比较重要的就是,无论是并行还是并发,在用户看来都是'同时'运行的,而一个cpu同一时刻只能执行一个任务。
并行:同时运行,只有具备多个cpu才能实现并行。
并发:是伪并行,即看起来是同时运行,单个cpu+多道技术。
多道技术:内存中同时存入多道(多个)程序,cpu从一个进程快速切换到另外一个,并且切换时间十分短暂,所以给人的感觉是我可以边打游戏边听歌。多个程序并行执行,其实是伪并行即并发。

Python 多线程、多进程 (一)之 源码执行流程、GIL-LMLPHP

阮一峰老师关于线程进程更形象介绍[传送门]

5、同步与异步

同步就是指一个进程在执行某个请求的时候,若该请求需要一段时间才能返回信息,那么这个进程将会一直等待下去,直到收到返回信息才继续执行下去;异步是指进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回时系统会通知进程进行处理,这样可以提高执行的效率。

打电话时就是同步通信,发短息时就是异步通信。

6、生产者与消费者

生产者消费者模式:

优点:

  • 解耦:由于两个对象之间的方法独立,数据的获取只需要通过接口的调用,所以两者的依赖性低,可重用性高
  • 平衡了生产力与消费力,就是生产者一直不停的生产,消费者可以不停的消费,因为二者不再是直接沟通的,而是通过数据缓冲区沟通的。生产者的数据直接丢入缓冲区,消费者直接从缓冲区那数据,就不会造成因为数据因为过剩造成生产者阻塞,或者数据过少消费者阻塞的问题

举例

从上面可以抽象出三个对象,生产者(男生),消费者(女生),数据(钱),而数据暂存到哪,一般是为了解决加锁问题,放到队列而不是简单的容器类型。

三、全局解释器锁

全局解释器锁(Global Interpreter Lock):简称GIL,多进程(mutilprocess) 和 多线程(threading)的目的是用来被多颗CPU进行访问, 提高程序的执行效率。 但是多线程之间数据完整性和状态同步是一个很大的问题,所以在python内部存在一种机制(GIL),在多线程时同一时刻只允许一个线程来访问CPU,也就是不同线程对共享资源的互斥。 在一个线程拥有了解释器的访问权之后,其他的所有线程都必须等待它释放解释器的访问权,即使这些线程的下一条指令并不会互相影响。GIL 并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。因为CPython是大部分环境下默认的Python执行环境。所以在把GIL之殇归结给Python是不对的。GIL并不是Python的特性,Python完全可以不依赖于GIL。例如Jython(java编写的python解释器)就不会存在GIL。

  • python中一个线程对应于c语言中的一个线程
  • GIL使得同一个时刻只有一个线程在一个CPU上执行字节码, 无法将多个线程映射到多个cpu上执行,因此python是无法利用多核CPU实现多线程的
  • 大量的第三方包都是基于CPython编写的,所以短期内想把GIL去掉不太可能

1、GIL优缺点

缺点:多处理器退化为单处理器;
优点:避免大量的加锁解锁操作

2、GIL释放

要实现python的多线程就需要借助标准库threading

# test2.py

import threading

total = 0

def add():
    # 连续执行total的加操作
    global total
    for i in range(1000000):
        total += 1

def reduce():
    # 连续执行total的减操作
    global total
    for i in range(1000000):
        total -= 1

# 创建两个线程
thread1 = threading.Thread(target=add)
thread2 = threading.Thread(target=desc)

# 线程开始
thread1.start()
thread2.start()

# 线程结束
thread1.join()
thread2.join()
print(total)

使用total作为标志,通过total的值判断线程的实现。
如果实现GIL没有释放的的话,那么两个线程先后完成,打印结果应该是0,而实际打印结果却不是0,并且每次打印结果也都不一致,说明实现了GIL主动释放掉了。
total变量是一个全局变量,其实在add与reduce内部的赋值语句total+=1与total-=1时,高级语言每一条语句在CPU上执行的时候又被对应成许多语句,比如total+=1对应成x1=total+1,total=x1,而total-=1被对应成x2=toal-1,total=x2,每一个x都是函数内部的局部变量。
可以对应字节码指令来理解,可以参照上面GIL中的实例使用dis模块获取字节码查看,PVM(python虚拟机)其实执行的也就是字节码指令。。
正常执行:

初始total=0

add:
x1 = total +1  # x1 = 1
total = x1
total = 1

reduce:
x2 = total-1  # x2 = 0
total = x2
total = 0

最终循环一次结果0
正常应该是无论多少次循环结果total都是0

多线程共享变量,两个线程交替占用cpu,:

total=0

add:

x1 = total + 1  # x1 = 1

reduce:
x2 = total - 1  # x2 = -1
total = x2 # total = -1

add:
total = x1
total =1

最终循环结果为1
只要进行足够多的循环,total的值就会出现不可预计的结果

所以,在修改total值的时候,需要多条语句。所以我觉得上面的例子可以这么理解:就是当一个线程在执行的时候也就是PVM在执行字节码指令,当字节码指令到达一定数目(ticks专门计数),此线程不再拥有GIL(释放GIL,release)并且释放CPU资源,但是其他的线程又过来抢,这个线程没抢过它,GIL就这样别抢走了,CPU资源就暂时交给其他的线程了(嗯,天道有轮回,下次我还会抢回来的)。因此,线程之间共享数据最大的危险在于多个线程同时改一个变量。所以在进行python多线程变成的时候,一般会进行细粒度的自定义加锁,以保证安全性。

问题:GIL什么时候会释放?

  • 执行的字节码行数到达一定阈值
  • 通过时间片划分,到达一定时间阈值
  • 在遇到IO操作时,主动释放

关于GIL,强烈推荐参阅Understand GIL:[传送门],在Understand中作者在Python2.X的环境中队多核CPU,单核CPU上,多线程以及单线程做了详细对比,并且对CPython的线程执行做了详细的跟踪,从根本上解释了GIL对python 多线程编程的影响和GIL的趋势。虽然英文原版,但是除了一些英文术语词汇,没有太难的句子,对英语渣还是很友好的。

有了预备知识来看下一篇吧,Python 多线程、多进程 (二)之 多线程、同步、通信

11-24 11:43