Python的多进程和多线程
前言:为什么有人说 Python 的多线程是鸡肋,不是真正意义上的多线程?
看到这里,也许你会疑惑。这很正常,所以让我们带着问题来阅读本文章吧。问题:
1.什么是python的多进程和多线程,有什么区别,哪个更好?
2.为什么要进行上下文切换?什么是上下文切换(进程切换,线程切换)
3.Python多线程为什么耗时更长?GIL是什么?为什么jpython没有这个限制?
4.线程安全是什么?
5.为什么在Python里面推荐使用多进程而不是多线程?
1.基础知识
1.1 进程
我们都知道计算机是由硬件和软件组成的。硬件中的CPU是计算机的核心,它承担计算机的所有任务。 操作系统是运行在硬件之上的软件,是计算机的管理者,它负责资源的管理和分配、任务的调度。 程序是运行在系统上的具有某种功能的软件,比如说浏览器,音乐播放器等。程序并不能单独运行,只有将程序装载到内存中,系统为它分配资源才能运行,而这种执行的程序就称之为进程。程序和进程的区别就在于:程序是指令的集合,它是进程运行的静态描述文本;进程是程序的一次执行活动,属于动态概念。比如当我们要运行音乐播放器的时候,系统就会为该程序的运行的创建一个独立的运行空间(包括文本区域(text region)、数据区域(data region)和堆栈(stack region)等),在这个独立的空间里完成关于某程序的数据集合的运行活动。
因为每个进程都有各自的一块独立的内存,保证进程彼此间的内存地址空间的隔离。所以,在多道编程中,我们允许多个程序同时加载到内存中(多进程就意味着需要在内存中为进程开辟多个相互独立的地址空间。那么就会占据较大的内存空间),在操作系统的调度下,可以实现并发地执行。这是这样的设计,大大提高了CPU的利用率。进程的出现让每个用户感觉到自己独享CPU,因此,进程就是为了在CPU上实现多道编程而提出的。
专业点来讲:进程是一个具有一定功能的程序在一个数据集上的一次动态执行过程。进程由程序,数据集合和进程控制块三部分组成。程序用于描述进程要完成的功能,是控制进程执行的指令集;数据集合是程序在执行时需要的数据和工作区;程序控制块(PCB)包含程序的描述信息和控制信息,是进程存在的唯一标志
进程是指一个程序在给定数据集合上的一次执行过程,是系统进行资源分配和运行调用的独立单位。可以简单地理解为操作系统中正在执行的程序。也就说,每个应用程序都有一个自己的进程。
每一个进程启动时都会最先产生一个线程,即主线程。然后主线程会再创建其他的子线程。
有了进程为什么还要线程?
进程有很多优点,它提供了多道编程,让我们感觉我们每个人都拥有自己的CPU和其他资源,可以提高计算机的利用率。很多人就不理解了,既然进程这么优秀,为什么还要线程呢?其实,仔细观察就会发现进程还是有很多缺陷的,主要体现在两点上:
- 进程只能在一个时间干一件事,如果想同时干两件事或多件事,进程就无能为力了。
- 进程在执行的过程中如果阻塞,例如等待输入,整个进程就会挂起,即使进程中有些工作不依赖于输入的数据,也将无法执行。
例如,我们在使用qq聊天, qq做为一个独立进程如果同一时间只能干一件事,那他如何实现在同一时刻 即能监听键盘输入、又能监听其它人给你发的消息、同时还能把别人发的消息显示在屏幕上呢?你会说,操作系统不是有分时么?但我的亲,分时是指在不同进程间的分时呀, 即操作系统处理一会你的qq任务,又切换到word文档任务上了,每个cpu时间片分给你的qq程序时,你的qq还是只能同时干一件事呀。
再直白一点, 一个操作系统就像是一个工厂,工厂里面有很多个生产车间,不同的车间生产不同的产品,每个车间就相当于一个进程,且你的工厂又穷,供电不足,同一时间只能给一个车间供电,为了能让所有车间都能同时生产,你的工厂的电工只能给不同的车间分时供电,但是轮到你的qq车间时,发现只有一个干活的工人,结果生产效率极低,为了解决这个问题,应该怎么办呢?。。。。没错,你肯定想到了,就是多加几个工人,让几个人工人并行工作,这每个工人,就是线程!
1.2 线程
在很早的时候计算机并没有线程这个概念,但是随着时代的发展,只用进程来处理程序出现很多的不足。如当一个进程堵塞时,整个程序会停止在堵塞处,并且如果频繁的切换进程,会浪费系统资源。所以线程出现了
线程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。一个进程可以拥有多个线程,而且属于同一个进程的多个线程间会共享该进行的资源。
由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效地提高系统内多个程序间并发执行的程度,从而显著提高系统资源的利用率和吞吐量。
1.3 多进程
多进程是由父进程克隆生成子进程,子进程将会拥有和父进程相同的资源,生成的子进程之间以及与父进程都是相互独立的。 所以,多进程的创建会消耗系统大量的资源。
因为进程间是相互独立的,所以想要实现进程间的通信,必须使用中间进程
1.4 多线程
当我们要运行某个程序时,操作系统首先会为其创建至少一个进程(资源分配的基本单元),进程又会为自己创建一个主线程(线程才是指令执行的实体)。当需要多线程的时候,将会由主线程创建出子线程,创建出的子线程将独立执行任务,但将同主线程和其他子线程,共同享用进程资源。
因线程间是在一个进程实体中,同时共享进程资源,所以线程间可以直接交流。
1.5 两者的区别
进程(process)和线程(thread)是操作系统的基本概念,但是它们比较抽象,不容易掌握。关于多进程和多线程,教科书上最经典的一句话是“进程是资源分配的最小单位,线程是CPU调度的最小单位”。线程是程序中一个单一的顺序控制流程。进程内一个相对独立的、可调度的执行单元,是系统独立调度和分派CPU的基本单位指运行中的程序的调度单位。在单个程序中同时运行多个线程完成不同的工作,称为多线程。
进程是资源分配的基本单位。所有与该进程有关的资源,都被记录在进程控制块PCB中。以表示该进程拥有这些资源或正在使用它们。另外,进程也是抢占处理机的调度单位,它拥有一个完整的虚拟地址空间。当进程发生调度时,不同的进程拥有不同的虚拟地址空间,而同一进程内的不同线程共享同一地址空间。
与进程相对应,线程与资源分配无关,它属于某一个进程,并与进程内的其他线程一起共享进程的资源。线程只由相关堆栈(系统栈或用户栈)寄存器和线程控制表TCB组成。寄存器可被用来存储线程内的局部变量,但不能存储其他线程的相关变量。
通常在一个进程中可以包含若干个线程,它们可以利用进程所拥有的资源。在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位。
由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效的提高系统内多个程序间并发执行的程度,从而显著提高系统资源的利用率和吞吐量。
因而近年来推出的通用操作系统都引入了线程,以便进一步提高系统的并发性,并把它视为现代操作系统的一个重要指标。
线程与进程的区别可以归纳为以下:
- 地址空间和其它资源(如打开文件):进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见。
- 通信:进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
- 在多线程OS中,进程不是一个可执行的实体。
- 线程必须在某个进程中执行。
- 一个进程可包含多个线程,其中有且只有一个主线程。
- 多线程共享同个地址空间、打开的文件以及其他资源。
- 多进程共享物理内存、磁盘、打印机以及其他资源。
- 进程之间相互独立,而同一个进程下的线程共享程序的内存空间(如代码段,数据集,堆栈等)。某进程内的线程在其他进程不可见。换言之,线程共享同一片内存空间,而进程各有独立的内存空间
1.6 多进程和多线程的比较
总结,进程和线程还可以类比为火车和车厢:
- 线程在进程下行进(单纯的车厢无法运行)
- 一个进程可以包含多个线程(一辆火车可以有多个车厢)
- 不同进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘)
- 同一进程下不同线程间数据很易共享(A车厢换到B车厢很容易)
- 进程要比线程消耗更多的计算机资源(采用多列火车相比多个车厢更耗资源)
- 进程间不会相互影响,一个线程挂掉将导致整个进程挂掉(一列火车不会影响到另外一列火车,但是如果一列火车上中间的一节车厢着火了,将影响到该趟火车的所有车厢)
- 进程可以拓展到多机,进程最多适合多核(不同火车可以开在多个轨道上,同一火车的车厢不能在行进的不同的轨道上)
- 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-”互斥锁(mutex)”
- 进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多只允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)-“信号量(semaphore)”
1.7 上下文的切换
为什么要进行上下文切换
参考博客更加详细:
1.什么是上下文切换
3.一文搞懂什么是CPU上下文?为什么要切换?如何减少切换?
上下文的切换:分为进程的上下文切换,线程的上下文的切换,用户态与内核态之间的上下文切换(当用户程序需要调用硬件设备的时候,内核就需要将用户程序切换成系统调用)
独享cpu的效果的实现,是因为任务调度采用时间片轮转的抢占式方式进行进程调度,简称为进程的上下文切换,进程的切换实质上就是被中止运行进程与待运行进程上下文的切换。举例:这就像多个同学要分时使用同一张课桌一样,说是要收回正在使用课桌同学的课桌使用权,实质上就是让他把属于他的东西拿走;而赋予某个同学课桌使用权,就是让他把他的东西放到课桌上。
进程切换分两步
1.切换页目录以使用新的地址空间
2.切换内核栈和硬件上下文。
对于linux来说,线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的
对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是:将寄存器中的内容切换出。
从时间占比上来看,进程的切换需要的时间相对于cpu而言却是很耗时的,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,这可能是操作系统中时间消耗最大的操作。
上下文切换瓶颈判断:
1.如果CPU在满负荷运行,应该符合下列分布,
a) User Time:65%~70%
b) System Time:30%~35% 若该指标过高,基本可以判断为上下文切换过于频繁。
c) Idle:0%~5%
2.对于上下文切换要结合CPU使用率来看,如果CPU使用满足上述分布,大量的上下文切换也是可以接受的。
2.GIL锁
这里GIL锁单独拿出来讲解,是因为实在是很多文章讲的不清不楚,可能本人理解能力有限吧,这里还是贴出来一些文章的内容以及课堂上老师通俗易懂的讲解方式。
其他语言,CPU是多核时是支持多个线程同时执行。但在Python中,无论是单核还是多核,同时只能由一个线程在执行。其根源是GIL的存在。GIL的全称是Global Interpreter Lock(全局解释器锁),来源是Python设计之初的考虑,为了数据安全所做的决定。某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,并且在一个Python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。
而目前Python的解释器有多种,例如:
CPython:CPython是用C语言实现的Python解释器。 作为官方实现,它是最广泛使用的Python解释器。
PyPy:PyPy是用RPython实现的解释器。RPython是Python的子集, 具有静态类型。这个解释器的特点是即时编译,支持多重后端(C, CLI, JVM)。PyPy旨在提高性能,同时保持最大兼容性(参考CPython的实现)。
Jython:Jython是一个将Python代码编译成Java字节码的实现,运行在JVM (Java Virtual Machine) 上。另外,它可以像是用Python模块一样,导入并使用任何Java类。
IronPython:IronPython是一个针对 .NET 框架的Python实现。它可以用Python和 .NET framework的库,也能将Python代码暴露给 .NET框架中的其他语言。
GIL只在CPython中才有,而在PyPy和Jython中是没有GIL的。
每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。这就导致打印线程执行时长,会发现耗时更长的原因。
并且由于GIL锁存在,Python里一个进程永远只能同时执行一个线程(拿到GIL的线程才能执行),这就是为什么在多核CPU上,Python 的多线程效率并不高的根本原因。
下面看一下官方的说法
定义:
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、GIL 不是 Python 的特点,而是 CPython 解释器的特点;
2、在 CPython 解释器中,GIL 是一把互斥锁,用来阻止同一个进程下多个线程的同时执行
3、因为 CPython 解释器的内存管理并不安全( 内存管理—垃圾回收机制)
稍加解释:在没有 GIL 锁的情况下,有可能多线程在执行一个代码的同时,垃圾回收机制线程对所执行代码的变量直接回收,导致运行报错;
重点:
1、GIL 不是 Python 的特点,而是 CPython 解释器的特点;
2、GIL 锁是加在 CPython 解释器上的,是保证解释器级别的数据的安全;
3、GIL 锁会导致同一个进程下多个线程的不能同时执行
4、不同的数据除了 GIL 锁,还需要一把互斥锁,来保证数据处理不会错乱
注意:GIL 锁是加在 CPython 解释器上的,进程先获取 GIL 锁,在获取 CPython 解释器
以上,大家大概对GIL锁有了大概的了解,所以接下来有几个问题我们要处理
1.GIL锁有什么好处和坏处?
2.为什么会cpython有GIL锁,jpython没有?
3.为什么cpython要加一把锁?怎么去加锁的?
4.第一个问题GIL锁里面说的线程安全是什么意思?
5.为什么python多线程是伪多线程,还要使用?或者说速度比单线程快?
6.为什么GIL锁还没有删除
2.1 GIL锁有什么好处和坏处
简单来说,它在单线程的情况更快,并且在和 C 库结合时更方便,而且不用考虑线程安全问题,这也是早期 Python 最常见的应用场景和优势。另外,GIL的设计简化了CPython的实现,使得对象模型,包括关键的内建类型如字典,都是隐含可以并发访问的。锁住全局解释器使得比较容易的实现对多线程的支持,但也损失了多处理器主机的并行计算能力。
在多线程环境中,Python 虚拟机按以下方式执行:
1.设置GIL
2.切换到一个线程去运行
3.运行直至指定数量的字节码指令,或者线程主动让出控制(可以调用sleep(0))
4.把线程设置为睡眠状态
5.解锁GIL
6.再次重复以上所有步骤
Python3.2前,GIL的释放逻辑是当前线程遇见IO操作或者ticks计数达到100(ticks可以看作是python自身的一个计数器,专门做用于GIL,每次释放后归零,这个计数可以通过 sys.setcheckinterval 来调整),进行释放。因为计算密集型线程在释放GIL之后又会立即去申请GIL,并且通常在其它线程还没有调度完之前它就已经重新获取到了GIL,就会导致一旦计算密集型线程获得了GIL,那么它在很长一段时间内都将占据GIL,甚至一直到该线程执行结束。
Python 3.2开始使用新的GIL。新的GIL实现中用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁时,当前线程就会在5毫秒后被强制释放该锁。该改进在单核的情况下,对于单个线程长期占用GIL的情况有所好转。
在单核CPU上,数百次的间隔检查才会导致一次线程切换。在多核CPU上,存在严重的线程颠簸(thrashing)。而每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低。
另外,从上面的实现机制可以推导出,Python的多线程对IO密集型代码要比CPU密集型代码更加友好。
针对GIL的应对措施:
- 使用更高版本Python(对GIL机制进行了优化)
- 使用多进程替换多线程(多进程之间没有GIL,但是进程本身的资源消耗较多)
- 指定cpu运行线程(使用affinity模块)
- 使用Jython、IronPython等无GIL解释器
- 全IO密集型任务时才使用多线程
- 使用协程(高效的单线程模式,也称微线程;通常与多进程配合使用)
- 将关键组件用C/C++编写为Python扩展,通过ctypes使Python程序直接调用C语言编译的动态链接库的导出函数。(with nogil调出GIL限制)
以下就简单总结一下
好处:
1.保护共享数据:GIL 锁可以保护共享数据,防止多个线程同时对共享数据进行修改,从而避免数据不一致的问题。
2.提高性能:GIL 锁可以提高性能,因为它可以防止多个线程同时运行,从而减少了线程切换的开销。
3.避免死锁:GIL 锁可以避免死锁,因为它可以防止多个线程同时运行,从而避免了线程之间的竞争。
坏处:
1.单个进程下,开启多个线程,无法实现并行,只能实现并发,牺牲执行效率。
2.由于GIL锁的限制,所以多线程不适合计算密集型任务,更适合IO密集型任务
(常见IO密集型任务:网络IO(抓取网页数据)、磁盘操作(读写文件)、键盘输入)
2.2 为什么会cpython有GIL锁,jpython没有?
Python 是一门解释型的语言,这就意味着代码是解释一行,运行一行,它并不清楚代码全局;
因此,每个线程在调用 cpython 解释器 在运行之前,需要先抢到 GIL 锁,然后才能运行。
编译型的语言就不会存在 GIL 锁,编译型的语言会直接编译所有代码,就不会出现这种问题。
Java是个混合型的语言,所以在jpython上就没有GIL锁
额外扩展
编译型语言
定义:在程序运行之前,通过编译器将源程序编译成机器码(可运行的二进制代码),以后执行这个程序时,就不用再进行编译了。
优点:编译器一般会有预编译的过程对代码进行优化。因为编译只做一次,运行时不需要编译,所以编译型语言的程序执行效率高,可以脱离语言环境独立运行。
缺点:编译之后如果需要修改就需要整个模块重新编译。编译的时候根据对应的运行环境生成机器码,不同的操作系统之间移植就会有问题,需要根据运行的操作系统环境编译不同的可执行文件。
总结:执行速度快、效率高;依靠编译器、跨平台性差些。
代表语言:C、C++、Pascal、Object-C以及Swift。
解释型语言
定义:解释型语言的源代码不是直接翻译成机器码,而是先翻译成中间代码,再由解释器对中间代码进行解释运行。在运行的时候才将源程序翻译成机器码,翻译一句,然后执行一句,直至结束。
优点:有良好的平台兼容性,在任何环境中都可以运行,前提是安装了解释器(虚拟机)。灵活,修改代码的时候直接修改就可以,可以快速部署,不用停机维护。
缺点:每次运行的时候都要解释一遍,性能上不如编译型语言。
总结:执行速度慢、效率低;依靠解释器、跨平台性好。
代表语言:JavaScript、Python、Erlang、PHP、Perl、Ruby。
混合型语言
定义:既然编译型和解释型各有缺点就会有人想到把两种类型整合起来,取其精华去其糟粕,就出现了半编译,半解释型语言。
比如C#,C#在编译的时候不是直接编译成机器码而是中间码,.NET平台提供了中间语言运行库运行中间码,中间语言运行库类似于Java虚拟机。.NET在编译成IL代码后,保存在dll中,首次运行时由JIT在编译成机器码缓存在内存中,下次直接执行。严格来说混合型语言属于解释型语言,C#更接近编译型语言。
Java即是编译型的,也是解释型语言,总的来说Java更接近解释型语言。
可以说它是编译型的。因为所有的Java代码都是要编译的,.java不经过编译就什么用都没有。同时围绕JVM的效率问题,会涉及一些如JIT、AOT等优化技术,例如JIT技术,会将热点代码编译成机器码。而AOT技术,是在运行前,通过工具直接将字节码转换为机器码。
可以说它是解释型的。因为Java代码编译后不能直接运行,它是解释运行在JVM上的,所以它是解释运行的。
2.3 为什么cpython要加一把锁?怎么去加锁的?
这里就需要使用到老师课堂上讲解的案例了,其实以下的笔记总结一句话,分为2部分,并发和并行是2回事,python的并发并没有问题,到了c解析器那边的时候,因为GIL的原因,就不予许并行了。
(重点:Python是可以并发的,C解析器因为GIL原因不予许并行,跟python毫无关系)
本篇章就用课堂上通俗易懂的语句来讲解一下。这里就直接把笔记贴出来吧。
这里用老师课上的一张图来讲解一下。
1.为什么每次final num :0的值都不是一样的?
这里首先讲解一下python自己的解释器环境。
比如 num(图片上为A,老师标错了)=10。
假设有这种奇葩的情况
这里A函数调用num-1,那么等于9,这时候还没把结果9返回给全局变量num。然后有个切换时间,这时候轮到B函数调用num-1,那么等于9,那么b返回全局变量num,那么num这时候等于9,然后这时候A函数同时也返回全局变量num等于9,这时候,就把B函数返回的结果给冲掉了。如果c这时候调用num-1,返回,那么等于8.
所以说为什么很奇葩,这是一个概率问题,如果运气好,全部调用就返回,按照顺序快速执行,那么结果肯定正确。但是肯定不能保证都是上一个函数执行好并返回数据,下一个接着执行。更多的还是如同上面讲解的情况,很多次函数的值被覆盖,所以就出现了num的值不正常的
2.不是说python受GIL锁的影响,不会出现这种非线程安全的问题吗?
首先需要明确的一点是GIL
并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。就好比C++是一套语言(语法)标准,但是可以用不同的编译器来编译成可执行代码。有名的编译器例如GCC,INTEL C++,Visual
C++等。Python也一样,同样一段代码可以通过CPython,PyPy,Psyco等不同的Python执行环境来执行。像其中的JPython就没有GIL。然而因为CPython是大部分环境下默认的Python执行环境。所以在很多人的概念里CPython就是Python,也就想当然的把GIL
归结为Python语言的缺陷。所以这里要先明确一点:GIL并不是Python的特性,Python完全可以不依赖于GIL。
简单来说,python调用c的原生线程去执行,那么是c的特性上了锁,python完全不依赖,只是默认我们用的都是cpython,所以受了c的锁的影响.既然有锁,为什么num的结果不同。
这里请出上面的图。
下面的大绿框里面,就是c语言环境,都是有一把锁的,为什么把上图分为2部分.因为C语言的特性给原生线程上了锁.那是C不予许并行执行,但是python那部分的并发是没问题的(再次强调,并发和并行是两回事),只是2个不是一个系统,python的并发执行了,到了C那边,因为C语言的GIL,不予许并行。虽然同一时间只有一个线程在运行,但是,python A线程返回的结果是9,B线程返回的结果也是9,(因为python的线程是并发执行的,大家同时获取的num都是10,串行等待执行的时候,大家要执行的结果都是10-1这个概念)这样还是冲掉了,因为python上面的线程是直接丢出去并行执行的,python内存的数据是没有保护的,所以,这里还差一把锁,锁住的就是上面框(python那边线程)的数据不被线程胡乱的修改。
当然,这样加了锁,串行就变成并行了,就没有多线程了,如果要执行任务,不需要确定结果,就可以使用多线程不加锁。如果要计算结果,大家还是慎重
本章第一次觉得老师讲解的不错,时间久了有遗忘,建议返回来在看视频
补充:以下是老师博客的补充,但是本人觉得,视频已经讲解的很清楚了,这里还是贴出来一下。
机智的同学可能会问到这个问题,就是既然你之前说过了,Python已经有一个GIL来保证同一时间只能有一个线程来执行了,为什么这里还需要lock? 注意啦,这里的lock是用户级的lock,跟那个GIL没关系 ,具体我们通过下图来看一下+配合我现场讲给大家,就明白了。
那你又问了, 既然用户程序已经自己有锁了,那为什么C python还需要GIL呢?加入GIL主要的原因是为了降低程序的开发的复杂度,比如现在的你写python不需要关心内存回收的问题,因为Python解释器帮你自动定期进行内存回收,你可以理解为python解释器里有一个独立的线程,每过一段时间它起wake up做一次全局轮询看看哪些内存数据是可以被清空的,此时你自己的程序 里的线程和 py解释器自己的线程是并发运行的,假设你的线程删除了一个变量,py解释器的垃圾回收线程在清空这个变量的过程中的clearing时刻,可能一个其它线程正好又重新给这个还没来及得清空的内存空间赋值了,结果就有可能新赋值的数据被删除了,为了解决类似的问题,python解释器简单粗暴的加了锁,即当一个线程运行时,其它人都不能动,这样就解决了上述的问题, 这可以说是Python早期版本的遗留问题。
2.4 第一个问题GIL锁里面说的线程安全是什么意思?
线程安全指的是内存的安全,在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。
所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。
2.5 为什么python多线程是伪多线程,还要使用?或者说速度比单线程快?
这个问题前面4个问题提了很多次,python说是伪多线程的原因是因为默认大家使用的都是cpython,而c解释器有GIL锁。所以可以说他是伪多线程。
但是还是那句话,并发和并行是2回事,python的并发多线程受到了cpython的影响,导致大家认为他是伪多线程,可以这么说但是不绝对,因为python的并发是可以执行的,但是到了c解析器那边,只允许并行一个任务,拖了python的后腿,就比如jpython就是没有这个GIL锁的。
至于第二个问题,为什么还要使用?因为python多并发是可以执行的,只是到了C解析器的时候被Gil拦住了,只予许执行一个线程
2.6 为什么GIL锁还没有删除
2.1里面也带过一笔,有一些历史原因在里面在Cpython虚拟机(解释器),难以移除GIL,同时删除GIL会使得Python 3在处理单线程任务方面比Python 2慢,可以想像会产生什么结果。你不能否认GIL带来的单线程性能优势,这也就是为什么Python 3中仍然还有GIL。
小提示(GIL锁的历史原因):
简单的来说:当初Python语言在设计的时候,市面上并没有多核CPU,因此线程都是单线程的。随着时间的推移、科技的发展,逐渐出现多核CPU,各CPU厂商在核心频率上的比赛已经被多核所取代,为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。
Python为了利用多核CPU,开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁,于是有了GIL这把超级大锁。因为有了GIL,所以我们的Python可以实现多进程,但是这是一个假的多进程,虽然它会利用多个CPU共同协作,但实则是利用一个CPU的资源。
但是这种GIL导致我们的多进程并不是真正的多进程,所以它的效率很低。但当大家试图去拆分和去除GIL的时候,发现大量库代码开发者已经重度依赖GIL而非常难以去除了。如果推到重来,多线程的问题依然还是要面对,但是至少会比目前GIL这种方式会更优雅。所以简单的说:GIL的存在更多的是历史原因。
3.Python多线程
3.1 创建多线程
Python提供两个模块进行多线程的操作,分别是thread和threading,前者是比较低级的模块,用于更底层的操作,一般应用级别的开发不常用。
- 方法1:直接使用threading.Thread()
import threading # 这个函数名可随便定义 def run(n): print("current task:", n) if __name__ == "__main__": t1 = threading.Thread(target=run, args=("thread 1",)) t2 = threading.Thread(target=run, args=("thread 2",)) t1.start() t2.start()
- 方法2:继承threading.Thread来自定义线程类,重写run方法
import threading class MyThread(threading.Thread): def __init__(self, n): super(MyThread, self).__init__() # 重构run函数必须要写 self.n = n def run(self): print("current task:", n) if __name__ == "__main__": t1 = MyThread("thread 1") t2 = MyThread("thread 2") t1.start() t2.start()
3.2 线程合并join
join函数执行顺序是逐个执行每个线程,执行完毕后继续往下执行。主线程结束后,子线程还在运行,join函数使得主线程等到子线程结束时才退出。不然一般情况下,主线程执行完成,程序退出了,都没有管子进程有没有执行完成
import threading def count(n): while n > 0: n -= 1 if __name__ == "__main__": t1 = threading.Thread(target=count, args=("100000",)) t2 = threading.Thread(target=count, args=("100000",)) t1.start() t2.start() # 将 t1 和 t2 加入到主线程中 t1.join() t2.join()
3.3 线程同步与互斥锁
如果多个线程共同对某个数据修改,则可能出现不可预料的结果,为了保证数据的正确性,需要对多个线程进行同步。
使用 Thread 对象的 Lock 和 Rlock 可以实现简单的线程同步,这两个对象都有 acquire 方法和 release 方法,对于那些需要每次只允许一个线程操作的数据,可以将其操作放到 acquire 和 release 方法之间。如下:
多线程的优势在于可以同时运行多个任务(至少感觉起来是这样)。但是当线程需要共享数据时,可能存在数据不同步的问题。
考虑这样一种情况:一个列表里所有元素都是0,线程"set"从后向前把所有元素改成1,而线程"print"负责从前往后读取列表并打印。
那么,可能线程"set"开始改的时候,线程"print"便来打印列表了,输出就成了一半0一半1,这就是数据的不同步。为了避免这种情况,引入了锁的概念。
锁有两种状态——锁定和未锁定。每当一个线程比如"set"要访问共享数据时,必须先获得锁定;如果已经有别的线程比如"print"获得锁定了,那么就让线程"set"暂停,也就是同步阻塞;等到线程"print"访问完毕,释放锁以后,再让线程"set"继续。
经过这样的处理,打印列表时要么全部输出0,要么全部输出1,不会再出现一半0一半1的尴尬场面。
用法的基本步骤:
#创建锁 mutex = threading.Lock() #锁定 mutex.acquire([timeout]) #释放 mutex.release()
其中,锁定方法acquire可以有一个超时时间的可选参数timeout。如果设定了timeout,则在超时后通过返回值可以判断是否得到了锁,从而可以进行一些其他的处理。具体用法见示例代码:
import threading import time num = 0 mutex = threading.Lock() class MyThread(threading.Thread): def run(self): global num time.sleep(1) if mutex.acquire(1): num = num + 1 msg = self.name + ': num value is ' + str(num) print(msg) mutex.release() if __name__ == '__main__': for i in range(5): t = MyThread() t.start()
案例代码2:课程案例,可以参考更加详细的课程笔记
3.4 可重入锁(递归锁)
为了满足在同一线程中多次请求同一资源的需求,Python提供了可重入锁(RLock)。RLock内部维护着一个Lock和一个counter变量,counter记录了acquire 的次数,从而使得资源可以被多次require。直到一个线程所有的acquire都被release,其他的线程才能获得资源。具体用法如下:
简单来说,就是一个大锁中还要再包含子锁
import threading, time def run1(): print("grab the first part data") lock.acquire() global num num += 1 lock.release() return num def run2(): print("grab the second part data") lock.acquire() global num2 num2 += 1 lock.release() return num2 def run3(): lock.acquire() res = run1() print('--------between run1 and run2-----') res2 = run2() lock.release() print(res, res2) if __name__ == '__main__': num, num2 = 0, 0 ''' 如果不加入Rock,那么这么多锁,锁就混乱了,不知道谁是谁的锁,输出的结果一定是一个死循环 rlock可以理解成为一个锁关系的记录表,让每一层知道谁是谁的锁。 ''' lock = threading.RLock() for i in range(10): t = threading.Thread(target=run3) t.start() while threading.active_count() != 1: print(threading.active_count()) else: print('----all threads done---') print(num, num2)
3.5 信号量
互斥锁 同时只允许一个线程更改数据,而Semaphore是同时允许一定数量的线程更改数据 ,比如厕所有3个坑,那最多只允许3个人上厕所,后面的人只能等里面有人出来了才能再进去。
3.6 线程同步与交互
本章主要内容是让线程产生因果关系,比如车因为红绿灯而走或者停,怎么实现呢?
通过Event来实现两个或多个线程间的交互,下面是一个红绿灯的例子,即起动一个线程做交通指挥灯,生成几个线程做车辆,车辆行驶按红灯停,绿灯行的规则。
上面的内容很简单,比较绕的地方在于:event.set()和event.wait,这里要反着想,event.set()有了这个,就是指示可以通行,意思如果没有设置event.set(),那么就是默认event.wait(),所有车都得等着,记住这一条在去看代码,就会清晰很多。
3.7 守护线程
如果希望主线程执行完毕之后,不管子线程是否执行完毕都随着主线程一起结束。我们可以使用setDaemon(bool)函数,它跟join函数是相反的。它的作用是设置子线程是否随主线程一起结束,必须在start() 之前调用,默认为False。
3.8 定时器
如果需要规定函数在多少秒后执行某个操作,需要用到Timer类。具体用法如下:
from threading import Timer def show(): print("Pyhton") # 指定一秒钟之后执行 show 函数 t = Timer(1, hello) t.start()
3.9 线程池
一般都是第三方模块,用的比较少,需要的时候在去查吧。
4.Python 多进程
4.1 创建多进程
Python要进行多进程操作,需要用到muiltprocessing库,其中的Process类跟threading模块的Thread类很相似。所以直接看代码熟悉多进程。包括什么信号量,多进程里面也有,用法和多线程类似,这里就不做过多解释了
- 方法1:直接使用Process, 代码如下:
from multiprocessing import Process def show(name): print("Process name is " + name) if __name__ == "__main__": proc = Process(target=show, args=('subprocess',)) proc.start() proc.join()
- 方法2:继承Process来自定义进程类,重写run方法, 代码如下:
from multiprocessing import Process import time class MyProcess(Process): def __init__(self, name): super(MyProcess, self).__init__() self.name = name def run(self): print('process name :' + str(self.name)) time.sleep(1) if __name__ == '__main__': for i in range(3): p = MyProcess(i) p.start() for i in range(3): p.join()
4.2 多进程通信
进程之间不共享数据的。如果进程之间需要进行通信,则要用到Queue模块或者Pipe模块来实现。
- Queue
Queue是多进程安全的队列,可以实现多进程之间的数据传递。它主要有两个函数put和get。
put() 用以插入数据到队列中,put还有两个可选参数:blocked 和timeout。如果blocked为 True(默认值),并且timeout为正值,该方法会阻塞timeout指定的时间,直到该队列有剩余的空间。如果超时,会抛出 Queue.Full异常。如果blocked为False,但该Queue已满,会立即抛出Queue.Full异常。
get()可以从队列读取并且删除一个元素。同样get有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且 timeout为正值,那么在等待时间内没有取到任何元素,会抛出Queue.Empty异常。如果blocked为False,有两种情况存在,如果Queue有一个值可用,则立即返回该值,否则,如果队列为空,则立即抛出Queue.Empty异常。
Queue 模块中的常用方法:
-
- Queue.qsize() 返回队列的大小
- Queue.empty() 如果队列为空,返回True,反之False
- Queue.full() 如果队列满了,返回True,反之False
- Queue.full 与 maxsize 大小对应
- Queue.get([block[, timeout]])获取队列,timeout等待时间
- Queue.get_nowait() 相当Queue.get(False)
- Queue.put(item) 写入队列,timeout等待时间
- Queue.put_nowait(item) 相当Queue.put(item, False)
- Queue.task_done() 在完成一项工作之后,Queue.task_done()函数向任务已经完成的队列发送一个信号
- Queue.join() 实际上意味着等到队列为空,再执行别的操作
具体用法如下:
from multiprocessing import Process, Queue def put(queue): queue.put('Queue 用法') if __name__ == '__main__': queue = Queue() pro = Process(target=put, args=(queue,)) pro.start() print(queue.get()) pro.join()
- Pipe
Pipe的本质是进程之间的用管道数据传递,而不是数据共享,这和socket有点像。pipe() 返回两个连接对象分别表示管道的两端,每端都有send()和recv()函数。如果两个进程试图在同一时间的同一端进行读取和写入那么,这可能会损坏管道中的数据,具体用法如下:
from multiprocessing import Process, Pipe def show(conn): conn.send('Pipe 用法') conn.close() if __name__ == '__main__': parent_conn, child_conn = Pipe() pro = Process(target=show, args=(child_conn,)) pro.start() print(parent_conn.recv()) pro.join()
4.3 多进程数据共享
上面也说了,这里更适合叫做进程间的数据传递,但是也有可以类似进程间数据共享的方式,那什么叫做进程间数据共享?那么就是说2个进程都可以修改这个数据,比如一个字典,2个进程都可以同时往里面放数据,见以下代码。
通过manages实现
manager()返回的管理器对象控制一个服务器进程,该进程持有Python对象,并允许其他进程使用代理操作它们。
由manager()返回的管理器将支持列表、字典、命名空间、锁、RLock、信号量、BoundedSemaphore、条件、事件、Barrier、队列、值和数组类型。例如,
(这章还是有点晦涩难懂,还是需要多看)
4.4 进程池
创建多个进程,我们不用傻傻地一个个去创建。我们可以使用Pool模块来搞定。Pool 常用的方法如下:
具体用法见示例代码:
#coding: utf-8 import multiprocessing import time def func(msg): print("msg:", msg) time.sleep(3) print("end") if __name__ == "__main__": # 维持执行的进程总数为processes,当一个进程执行完毕后会添加新的进程进去 pool = multiprocessing.Pool(processes = 3) for i in range(5): msg = "hello %d" %(i) # 非阻塞式,子进程不影响主进程的执行,会直接运行到 pool.join() pool.apply_async(func, (msg, )) # 阻塞式,先执行完子进程,再执行主进程 # pool.apply(func, (msg, )) print("Mark~ Mark~ Mark~~~~~~~~~~~~~~~~~~~~~~") # 调用join之前,先调用close函数,否则会出错。 pool.close() # 执行完close后不会有新的进程加入到pool,join函数等待所有子进程结束 pool.join() print("Sub-process(es) done.")
如上,进程池Pool被创建出来后,即使实际需要创建的进程数远远大于进程池的最大上限,p.apply_async(test)代码依旧会不停的执行,并不会停下等待;相当于向进程池提交了10个请求,会被放到一个队列中;
当执行完p1 = Pool(5)这条代码后,5条进程已经被创建出来了,只是还没有为他们各自分配任务,也就是说,无论有多少任务,实际的进程数只有5条,计算机每次最多5条进程并行。
当Pool中有进程任务执行完毕后,这条进程资源会被释放,pool会按先进先出的原则取出一个新的请求给空闲的进程继续执行;
当Pool所有的进程任务完成后,会产生5个僵尸进程,如果主线程不结束,系统不会自动回收资源,需要调用join函数去回收。
join函数是主进程等待子进程结束回收系统资源的,如果没有join,主程序退出后不管子进程有没有结束都会被强制杀死;
创建Pool池时,如果不指定进程最大数量,默认创建的进程数为系统的内核数量.
5.选择多线程还是多进程?
在这个问题上,首先要看下你的程序是属于哪种类型的。一般分为两种:CPU密集型和I/O密集型。
- CPU 密集型:程序比较偏重于计算,需要经常使用CPU来运算。例如科学计算的程序,机器学习的程序等。
- I/O 密集型:顾名思义就是程序需要频繁进行输入输出操作。爬虫程序就是典型的I/O密集型程序。
如果程序是属于CPU密集型,建议使用多进程。而多线程就更适合应用于I/O密集型程序。
简单来说就是这样,但是只能说:没有最好,只有更好。根据实际情况来判断,哪个更加合适就是哪个好。
我们按照多个不同的维度,来看看多线程和多进程的对比(注:因为是感性的比较,因此都是相对的,不是说一个好得不得了,另外一个差的无法忍受)。
看起来比较简单,优势对比上是“线程 3.5 v 2.5 进程”,我们只管选线程就是了?
呵呵,有这么简单我就不用在这里浪费口舌了,还是那句话,没有绝对的好与坏,只有哪个更加合适的问题。我们来看实际应用中究竟如何判断更加合适。
1)需要频繁创建销毁的优先用线程
原因请看上面的对比。
这种原则最常见的应用就是Web服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的
2)需要进行大量计算的优先使用线程
所谓大量计算,当然就是要耗费很多CPU,切换频繁了,这种情况下线程是最合适的。
这种原则最常见的是图像处理、算法处理。
3)强相关的处理用线程,弱相关的处理用进程
什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。
一般的Server需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。
当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。
4)可能要扩展到多机分布的用进程,多核分布的用线程
原因请看上面对比。
5)都满足需求的情况下,用你最熟悉、最拿手的方式
至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,我只能说:没有明确的选择方法。但我可以告诉你一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。
需要提醒的是:虽然我给了这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。
6.面试常见
描述Python GIL的概念, 以及它对Python多线程的影响?
编写一个多线程抓取网页的程序,并阐明多线程抓取程序是否可比单线程性能有提升,并解释原因。
参考答案:
GIL:全局解释器锁。每个线程在执行的过程都需要先获取GIL,保证同一时刻只有一个线程可以执行代码。
Python语言和GIL没有任何关系。仅仅是由于历史原因在Cpython虚拟机(解释器),难以移除GIL。
线程释放GIL锁的情况: 在IO操作等可能会引起阻塞的system call之前,可以暂时释放GIL,但在执行完毕后,必须重新获取GIL
Python使用多进程是可以利用多核的CPU资源的。
多线程爬取比单线程性能有提升,因为遇到IO阻塞会自动释放GIL锁。