Python进程和线程
文章目录
- Python进程和线程
- 什么是进程
- 多进程的优点
- 怎么实现多进程
- Pool 类
- pool.apply_async
- pool.apply
- apply 和 apply_async 的区别
- 什么是线程
- 多线程的优点
- 怎么编写多线程程序
- 线程锁
- 互斥锁
- 多线程的全局变量同步问题
- Thread-local 对象
- 多线程和多进程的优缺点和应用场景
- 多线程比多进程性能高?
- 线程和进程的优缺点
- 线程切换
- 线程和进程的应用场景
什么是进程
什么是进程? 最直观的就是一个个pid
,进程是程序在计算机上的一次执行活动。
说得简单点,进入main
函数,这就是一个进程,进程pid
会打印出来;再运行到return
,该函数就退出;然后,由于该函数是该进程的唯一的一次执行,所以return
后,该进程也会退出。
多进程类似于下图的安检窗口一样,每一个窗口都类似于一个进程。
多进程的优点
只要你不是整天都写那种int main()
代码的人,那么或多或少你会遇到代码响应不够用的情况,也应该有尝过多进程编程的甜头。就像一个快餐点的服务员,既要在前台接待客户点餐,又要接电话送外卖,没有分身术,肯定会忙得你焦头烂额的。幸运的是确实有这么一种技术,让你可以像孙悟空一样分身,灵魂出窍,乐哉乐哉地轻松应付一切状况,这就是多进程技术。
多进程技术,就是可以让你在同一时间同时执行多条任务的技术。你的代码将不仅仅是从上到下,从左到右这样规规矩矩的一条线执行。你可以一条线跟你的客户交流,另一条线,你早就把你外卖送到了其他客户的手里。
所以,为何需要多进程?因为我们需要更强大的功能,提供更多的服务,所以多进程,必不可少。
怎么实现多进程
Python
是跨平台的,自然也应该提供一个跨平台的多进程支持。multiprocessing
模块就是跨平台版本的多进程模块。
实现多进程可以使用Python
官方提供的一个类Process
。
Process
类用来描述一个进程对象。创建子进程的时候,只需要传入一个执行函数和函数的参数,即可完成Process
示例的创建。 •start()
方法启动进程; •join()
方法实现进程间的同步,等待所有进程退出; •close()
用来阻止多余的进程涌入进程池 Pool
造成进程阻塞;
•target
是函数名字,需要调用的函数; •args
函数需要的参数,以tuple
的形式传入。
首先我们来写一个简单的多进程程序!
示例:
结果:
Pool 类
如果进程数太多了,我一个人根本搞不过来,怎么办呢?还好有进程池Pool
这个好基友啊,简单来说,Pool
可以提供指定数量的进程,供用户调用。当有新的请求提交到Pool
中时,如果池还没有满,那么就会创建一个新的进程,用来执行该请求;但如果池中的进程数,已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程。
Pool
可以提供指定数量的进程供用户使用,默认是 CPU
核数。当有新的请求提交到Pool
的时候,如果池子没有满,会创建一个进程来执行,否则就会让该请求等待;
-
Pool
对象调用join
方法,会等待所有的子进程执行完毕; - 调用
join
方法之前,必须调用close
; - 调用
close
之后,就不能继续添加新的Process
了。
pool.apply_async
apply_async
方法用来同步执行进程,允许多个进程同时进入池子。 apply_async
是异步非阻塞的。 异步处理就是,你现在问我问题,我可以不回答你,等我有时间了,再处理你这个问题。同步就反其道而行之,同步信息会立即被处理。
例如:
结果:
完全没有等待子进程执行完毕,主进程就已经执行完毕,并退出程序。
pool.apply
该方法只能允许一个进程进入池子,在一个进程结束之后,另外一个进程才可以进入池子。
apply
方法是阻塞的。
意思就是等待当前子进程执行完毕后,再执行下一个进程。
因为apply
是阻塞的,所以进入子进程执行后,等待当前子进程执行完毕,再继续执行下一个进程。
例如:
结果:
这样好像跟单进程串行执行没什么区别了。
apply 和 apply_async 的区别
apply
是阻塞式的。 首先主进程开始运行,碰到子进程,操作系统切换到子进程,等待子进程运行结束后,再切换到另外一个子进程,直到所有子进程运行完毕。然后,再切换到主进程,运行剩余的部分。
apply_async
是异步非阻塞式的。 首先主进程开始运行,碰到子进程后,主进程说:让我先运行个够,等到操作系统进行进程切换的时候,再交给子进程运行。因为我们的程序太短,然而还没等到操作系统进行进程切换,主进程就运行完毕了。想要子进程执行,就告诉主进程:你等着所有子进程执行完毕后,再运行剩余部分。
一般建议:废弃apply
,使用apply_async
什么是线程
线程:简单来说,一个进程中包含多个线程,比如打开一个 QQ(进程),然后你一边聊 QQ(一个线程),一边用 QQ 传送文件(一个线程),等等。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程( Thread )。
多线程的优点
- 使用线程可以把占据长时间的程序任务放到后台去处理;
- 用户界面可以更加吸引人,比如用户点击了一个按钮,去触发某些事件的处理,可以弹出一个进度条,来显示处理的进度;
- 程序的运行速度可能加快;
- 在一些等待的任务实现上,如用户输入、文件读写和网络收发数据等,线程就比较有用了。在这种情况下,我们可以释放一些珍贵的资源,如内存占用等等。
怎么编写多线程程序
Python 的标准库提供了两个模块:_thread
和threading
,_thread
是低级模块,threading
是高级模块,对_thread
进行了封装。绝大多数情况下,我们只需要使用threading
这个高级模块。
启动一个线程就是,把一个函数传入,并创建Thread
实例,然后调用start()
开始执行:
一个简单的多线程程序:
它的运行结果如下:
线程锁
由于线程之间是进行随机调度,当多个线程同时修改同一条数据时,可能会出现脏数据。所以,出现了线程锁,即同一时刻,允许一个线程执行操作。线程锁用于锁定资源,你可以定义多个锁, 像下面的代码, 当你需要独占某一资源时,任何一个锁都可以锁这个资源。就好比你用不同的锁,都可以把相同的一个门,锁住是一个道理。
由于线程之间是进行随机调度,如果有多个线程同时操作一个对象,如果没有很好地保护该对象,会造成程序结果的不可预期,我们也称此为“线程不安全”。
就比如下面这段代码,会产生和预期不一样的输出。
互斥锁
python 提供了一种 “相互排斥”的方法(互斥锁即由此得名)。两个线程不能同时对同一个互斥对象加锁。
互斥锁是这样工作的。如果线程 a 试图锁定一个互斥对象,而此时线程 b 已锁定了同一个互斥对象时,线程 a 就将进入睡眠状态。一旦线程 b 释放了互斥锁,线程 a 就能够锁定这个互斥对象。同样地,当线程 a 正锁定互斥对象时,如果线程 c 试图锁定互斥对象的话,线程 c 也将临时进入睡眠状态。对已锁定的互斥对象上,调用互斥锁的所有线程都将进入睡眠状态,这些睡眠的线程将“排队”访问这个互斥对象。
那么上面那一个问题,就可以使用互斥锁来解决。
我们知道多线程环境下,每一个线程均可以使用所属进程的全局变量。如果一个线程对全局变量进行了修改,将会影响到其他所有的线程。为了避免多个线程,同时对变量进行修改,引入了线程同步机制,通过互斥锁,条件变量或者读写锁来控制对全局变量的访问。
全局变量并不能满足多线程环境的需求,很多时候线程还需要拥有自己的私有数据,这些数据对于其他线程来说不可见。因此,线程中也可以使用局部变量,局部变量只有线程自身可以访问,同一个进程下的其他线程不可访问。
有时候使用局部变量不太方便,因此 Python 提供了 ThreadLocal
变量,它本身是一个全局变量,但是每个线程却可以利用它,来保存属于自己的私有数据,这些私有数据对其他线程也是不可见的。
多线程的全局变量同步问题
首先,借助一个小程序来看看,多线程环境下全局变量的同步问题。
这里我们创建了 10 个线程,每个线程均对全局变量global_num
进行 1000 次的加 1 操作(循环 1000 次加 1 是为了延长单个线程执行时间,使线程执行时被中断切换),当 10 个线程执行完毕时,全局变量的值是多少呢?答案是不确定。简单来说,是因为 global_num += 1
并不是一个原子操作,因此执行过程可能被其他线程中断,导致其他线程读到一个脏值。
多线程中使用全局变量时,普遍存在这个问题,解决办法也很简单,可以使用互斥锁、条件变量或者是读写锁。下面考虑用互斥锁,来解决上面代码的问题。只需要在进行+1
运算前加锁,运算完毕释放锁即可,这样就可以保证运算的原子性。
在线程中,使用局部变量,则不存在这个问题。因为每个线程的局部变量,不能被其他线程访问。下面我们用 10 个线程,分别对各自的局部变量进行 1000 次加 1 操作,每个线程结束时,打印一共执行的操作次数(每个线程均为 1000 ):
可以看出,这里每个线程都有自己的 local_num
,各个线程之间互不干涉。
Thread-local 对象
上面程序中,我们需要给 show
函数传递 local_num
局部变量,并没有什么不妥。不过考虑在实际生产环境中,我们可能会调用很多函数,每个函数都需要很多局部变量,这时候用传递参数的方法会很不友好。
为了解决这个问题,一个直观的的方法就是建立一个全局字典,保存进程 ID
到该进程局部变量的映射关系,运行中的线程可以根据自己的 ID
,来获取本身拥有的数据。这样,就可以避免在函数调用中,传递参数,如下示例:
保存一个全局字典,然后将线程标识符作为key
,相应线程的局部数据作为 value
,这种做法并不完美。首先,每个函数在需要线程局部数据时,都需要先取得自己的线程ID
,略显繁琐。更糟糕的是,这里并没有真正做到线程之间数据的隔离,因为每个线程都可以读取到全局的字典,每个线程都可以对字典内容进行更改。
为了更好解决这个问题,Python 线程库实现了 ThreadLocal
变量(很多语言都有类似的实现,比如 Java)。ThreadLocal
真正做到了线程之间的数据隔离,并且使用时,不需要手动获取自己的线程 ID
,如下示例:
上面示例中,每个线程都可以通过 global_data.num
, 获得自己独有的数据,并且每个线程读取到的 global_data
都不同,真正做到线程之间的隔离
多线程和多进程的优缺点和应用场景
多线程比多进程性能高?
误导!
应该说,多线程比多进程成本低,但性能更低。
在 UNIX 环境,多进程调度的开销和多线程调度的开销没有显著区别。就是说, UNIX 进程调度效率是很高的。内存消耗方面,二者只差全局数据区,现在内存都很便宜,服务器内存动辄若干 G,根本不是问题。
- 多进程是立体交通系统,虽然造价高,上坡下坡多耗点油,但是不堵车;
- 多线程是平面交通系统,造价低,但红绿灯太多,老堵车。
我们现在都开跑车,油(主频)有的是,不怕上坡下坡,就怕堵车。
线程和进程的优缺点
我们介绍了多进程和多线程,这是实现多任务最常用的两种方式。现在,我们来讨论一下这两种方式的优缺点。
多进程:
多进程优点:
- 每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系;
- 通过增加 CPU,就很容易扩充性能;
- 可以尽量减少线程加锁/解锁的影响,极大提高性能,就算是线程运行的模块算法效率低也没关系;
- 每个子进程都有 2GB 地址空间和相关资源,总体能够达到的性能上限非常大。
多进程缺点:
- 逻辑控制复杂,需要和主程序交互;
- 需要跨进程边界,如果有大量数据需要传送,就不太好,适合少量数据传送、密集运算,多进程调度开销比较大;
- 最好是多进程和多线程结合,即根据实际的需要,每个 CPU 开启一个子进程,这个子进程开启多线程,可以为若干同类型的数据进行处理。当然你也可以利用多线程 + 多 CPU + 轮询方式来解决问题……;
- 方法和手段是多样的,关键是自己看起来,实现方便又能够满足要求,代价也合适。
多线程:
多线程的优点:
- 无需跨进程边界;
- 程序逻辑和控制方式简单;
- 所有线程可以直接共享内存和变量等;
- 线程方式消耗的总资源比进程方式好。
多线程缺点:
- 每个线程与主程序共用地址空间,受限于 2GB 地址空间;
- 线程之间的同步和加锁控制比较麻烦;
- 一个线程的崩溃可能影响到整个程序的稳定性;
- 到达一定的线程数程度后,即使再增加 CPU 也无法提高性能,例如 Windows Server 2003,大约是 1500 个左右的线程数就快到极限了(线程堆栈设定为 1M ),如果设定线程堆栈为 2M ,还达不到 1500 个线程总数;
- 线程能够提高的总性能有限,而且线程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的 CPU。
线程切换
无论是多进程还是多线程,只要数量一多,效率肯定上不去,为什么呢?
我们打个比方,假设你不幸正在准备中考,每天晚上需要做语文、数学、英语、物理、化学这 5 科的作业,每项作业耗时 1 小时。
如果你先花 1 小时做语文作业,做完了,再花 1 小时做数学作业,这样,依次全部做完,一共花 5 小时,这种方式称为单任务模型,或者批处理任务模型。
假设你打算切换到多任务模型,可以先做 1 分钟语文,再切换到数学作业,做 1 分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核 CPU 执行多任务是一样的了,以幼儿园小朋友的眼光来看,你就正在同时写 5 科作业。
但是,切换作业是有代价的,比如从语文切到数学,要先收拾桌子上的语文书本、钢笔(这叫保存现场),然后,打开数学课本、找出圆规直尺(这叫准备新环境),才能开始做数学作业。操作系统在切换进程或者线程时也是一样的,它需要先保存当前执行的现场环境(CPU 寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。
所以,多任务一旦多到一个限度,就会消耗掉系统所有的资源,结果效率急剧下降,所有任务都做不好。
线程和进程的应用场景
是否采用多任务的第二个考虑是任务的类型。我们可以把任务分为计算密集型和 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 语言并不能提升运行效率。对于 IO 密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C 语言最差