搞懂? Python 多线程 多进程(先吃饭再喝汤?还是吃饭喝汤同时进行?)

4,728 阅读10分钟

如果只能用一只手吃饭+喝汤,吃饭耗时十五分钟,喝汤五分钟,这样肯定耗时。人家还想早点吃完开一把LOL呢,那么很简单这时候,我们就会想到左手喝汤,右手吃饭这样同时进行。这样时间上吃饭就得到了优化。

多线程这个话题自然离不开进程线程这两个keyword

1.进程

首先简单介绍下进程:计算机程序只是存储在磁盘上的可执行二进制(或者其他类型)文件。从硬盘中读取他们到内存中这样才拥有了生命周期。进程或者说重量级进程(同义)是一个执行中的程序,它拥有自己的地址空间、内存、数据栈以及其他用于跟踪执行的辅助数据。我们的操作系统(Operating System,简称OS)管理所有进程的执行,并为这些进程分配合理的地址空间。进程也可以通过fork或者spawn新的进程来执行其他任务,新的进程当然也拥有自己的内存和数据栈,所以只能采用进程间通信(IPC)的方式。

2.线程

线程或轻量级进程(同义)与进程类似,不过它们是在统一进程下执行的,并且共享相同的上下文,可以理解一个进程池中的东西线程共享。

线程包括三部分:开始--执行--结束,指令指针用于记录当前运行的上下文。当其他线程运行时,它可以被抢占(中断)和临时挂起(sleep),这种做法叫做让步(yielding)。

当然线程中也会有主线程,所以说一个进程中的各个线程与主线程共享一片数据空间。如果一顿午饭相当于一个进程的话,你可以吃自己的午饭却不好意思吃别人的午饭(因为吃别人的午饭需要跟他进行沟通(IPC通信),沟通不好还会被锤!!!),吃自己的想怎么吃就怎么吃。线程一般是并发执行的,由于这种并发和数据共享机制,使得多任务间协作成为可能。

在单核的CPU中真正的并发是不可能的,在一段时间内你只可能夹一道菜,吃两口再夹别的菜,之后也可以再回头吃这个菜。 这样的一个随心所欲的夹菜的顺序就成了无言的一个排列。线程也是一样,一个线程运行一段时间然后开始休眠,让步给其他的线程(再次排队等待更多的CPU时间)。在整个进程执行过程中,每个线程执行它自己特定的任务,在必要的时和其他线程进行结果通信。

这样难免会联想出问题,如果共享数据,那么会有风险。

Q1:

如果两个或多个线程访问同一片数据,由于数据访问顺序不同,可能导致结果不一致。这种情况就是“竞态条件”。不过大多数线程库都有一些同步原语,以允许线程管理器控制和访问。

Q2 :

当然啦 吃饭总会有自己爱吃的菜,所以每道菜的摄入量不会公平。也就是说线程无法给予公平的执行时间。如果没有专门的多线程情况进行修改,会导致CPU的时间分配向贪婪函数倾斜。

接下来要谈论的就是正题:

python中线程

1.GIL(全局解释锁):python代码的执行是由Python虚拟机(解释器主循环)进行控制的。在解释器主循环中只能由一个控制线程在执行,就像单核CPU系统的多进程一个道理,内存中可以有很多程序,但是在任意给定的时刻只能有一个程序在运行。尽管python解释器可以运行多个线程,但是在任意给定时刻只有一个线程会被解释器执行。

对python虚拟机的访问是由全局解释锁GIL控制的,这个锁就是用来保证同时只能由一个线程运行。

在python虚拟机中将按照下面的方式来运行。

在Python多线程下,每个线程的执行方式:

1、获取GIL

2、执行代码直到sleep或者是python虚拟机将其挂起。

**3、释放GIL **

可见,某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,并且在一个python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。

在Python2.x里,GIL的释放逻辑是当前线程遇见IO操作或者ticks计数达到100(ticks可以看作是Python自身的一个计数器,专门做用于GIL,每次释放后归零,这个计数可以通过 sys.setcheckinterval 来调整),进行释放。

而每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。并且由于GIL锁存在,python里一个进程永远只能同时执行一个线程(拿到GIL的线程才能执行),这就是为什么在多核CPU上,python的多线程效率并不高。

那么是不是python的多线程就完全没用了呢?
在这里我们进行分类讨论:
1、CPU密集型代码(各种循环处理、计数等等),在这种情况下,由于计算工作多,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。

2、IO密集型代码(文件处理、网络爬虫等),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。

2.退出线程:

当一个线程完成函数执行的时候,它就会退出。还可以通过thread.exit()之类的退出函数,或者sys.exit()之类的退出python进程的标准方法,或者抛出SystemExit异常,来使线程退出。但是你不能直接终止一个线程

主线程:主线程应该是一个好的管理者去管理好调度好好的吃这一顿饭(收集每一个线程的结果,生成一个有意义的最终结果。)

3.python中使用线程:

一般来说现在我们日常使用的电脑都是64位的所以我们安装的python解释器默认都是支持现成的,只需要import好模块即可使用。

thread:这个模块不介绍也不推荐使用,原因是当thread中主线程结束时,所有的其他线程也都强制结束,不会发出警告或者适当的清理。

threading:常用此模块,因为至少threading模块可以确保重要的子线程在进程退出前结束,也成守护线程。

看一个简单的例子

下面的例子是个basic的例子,没有使用线程。吃饭使用5s时间喝汤使用2s时间,是串行的,参数loop是循环次数。

结果:

从结果来看喝汤耗时4s,吃饭耗时10s,总共耗时14秒。

加入threading module

结果:

解析:

创建线程数组,用于线程载入,调用threading module的Thread()方法创建线程,然后调用。

结果显示吃饭喝汤同时进行。start()开始线程活动,join()等待线程终止。如果不使用join()方法对每个线程做等待终止,那么在线程运行过程中会打印吃完XXX

变强的过程在于思考优化,所以我们可以优化线程的创建(线程创建代码重复冗余度很高)

那么我们可以集中遍历创建,然后基于此目的微调下主函数,结果如下:

结果:

有点瑕疵应该在print的部分改为i+1,循环次数也强制写死为2次,当然这只是个demo,如果考虑再加个列表读入times 参数,此时可以添加判断是否大于零,可以自己试一试不再赘述。

在实际开发中,我们这种利用线程的方式肯定不是我们常用的方式,因为自己在用的时候会传入很多参数,所以我们就必须用到继承Threading,来创建我们自用的线程类

结果同之前的就不在展示。

慢慢的你会发现学习一部分慢慢的修改,你的代码也会越来越合理,思考的过程就是这样。

由于目前我们的CPU 都是多核的,所以说每一个进程中都可以有一个线程在执行在python中,那假如我们试一试使用多进程,来观察一下多进程与多线程的区别。

多进程模块 multiprocessing模块

多进程模块与threading 模块用法相似,multiprocessing模块可以提供本地和远程的并发性,有效的通过GIL(全局解释所),来使用进程而不是线程。在多核CPU中使用多线程并不能有效利用CPU的优势。由于每一个CPU中的进程只能在某一时间执行一个线程,所以此时可以考虑多进程来利用多核CPU,UNIX&WIN都是支持的。

将threading修改成multiprocessing即可。

结果:注意为了区别进程号我在结果中加了打印PID number所以显示结果如下。

这样可以明显区别开。

PID就是类似于身份证的一个东西,用来表示进程。一个进程在结束前都为一个不变的PID num,但是同一进程可以有不同的PID num,比如多运行两次此程序你会发现PID num一直在变,或者在自己电脑上运行wechat,反复开关几次你也会发现都是不同的PID num。

由上面的程序我们可以发现,multiprocessing与threading的用法没什么大差别,也有start()、run()、join()等方法。

看一下文档里面multiprocessing的用法,如下图。

target表示调用的对象,args表示调用对象的文职参数元组,kwargs表示调用对象的字典,name为别名,Group基本用不上,为None也无所谓。

如果去掉PID num,那个从res中完全看不出multiprocessing与threading有什么区别。*

multiprocessing 之 Queue与pipe:

由于线程共享数据,所以不需要通信,而这点之前也提到过所以,并发进程的情况下,进程就会需要进程通信(IPC机制)。

常见支持IPC的类 Queue&Pipe传送常见的对象。 (1)pipe是单向的,也可以是双向的。通过multiprocessing.Pipe(duplex=False)创建单向管道(默认为双向,类似于半双工全双工的意思,即单为只允许单向传输,双向则允许双向传输。)

pipe对象默认是双向的。在其建立的时候,返回一个含有两个元素的列表,每个元素代表pipe的一端(Connection对象)。这就成了结果中的对暗号,‘天王盖地虎,小鸡炖蘑菇...不对 宝塔镇河妖。’

send方法发送,另一端用recv方法来接收。

(2)Queue类与Pipe类似,不过学过数据结构,大家都知道队列都是先进先出。Queue允许多个进程放入,多个进程从队列存取。

这里最需要搞懂的地方就是这个锁的用处,就好像把进程们出入的时候带了手铐不让乱跑,进一个出一个明确,这样打印起来就不是很乱。