Python进程和线程

文章目录

  • ​​Python进程和线程​​
  • ​​什么是进程​​
  • ​​多进程的优点​​
  • ​​怎么实现多进程​​
  • ​​Pool 类​​
  • ​​pool.apply_async​​
  • ​​pool.apply​​
  • ​​apply 和 apply_async 的区别​​
  • ​​什么是线程​​
  • ​​多线程的优点​​
  • ​​怎么编写多线程程序​​
  • ​​线程锁​​
  • ​​互斥锁​​
  • ​​多线程的全局变量同步问题​​
  • ​​Thread-local 对象​​
  • ​​多线程和多进程的优缺点和应用场景​​
  • ​​多线程比多进程性能高?​​
  • ​​线程和进程的优缺点​​
  • ​​线程切换​​
  • ​​线程和进程的应用场景​​
什么是进程

什么是进程? 最直观的就是一个个​​pid​​,进程是程序在计算机上的一次执行活动。

说得简单点,进入​​main​​​函数,这就是一个进程,进程​​pid​​​会打印出来;再运行到​​return​​​,该函数就退出;然后,由于该函数是该进程的唯一的一次执行,所以​​return​​后,该进程也会退出。

多进程类似于下图的安检窗口一样,每一个窗口都类似于一个进程。

Python进程和线程_多进程

多进程的优点

只要你不是整天都写那种​​int main()​​ 代码的人,那么或多或少你会遇到代码响应不够用的情况,也应该有尝过多进程编程的甜头。就像一个快餐点的服务员,既要在前台接待客户点餐,又要接电话送外卖,没有分身术,肯定会忙得你焦头烂额的。幸运的是确实有这么一种技术,让你可以像孙悟空一样分身,灵魂出窍,乐哉乐哉地轻松应付一切状况,这就是多进程技术。

多进程技术,就是可以让你在同一时间同时执行多条任务的技术。你的代码将不仅仅是从上到下,从左到右这样规规矩矩的一条线执行。你可以一条线跟你的客户交流,另一条线,你早就把你外卖送到了其他客户的手里。

所以,为何需要多进程?因为我们需要更强大的功能,提供更多的服务,所以多进程,必不可少。

怎么实现多进程

​Python​​​是跨平台的,自然也应该提供一个跨平台的多进程支持。​​multiprocessing​​模块就是跨平台版本的多进程模块。

实现多进程可以使用​​Python​​​官方提供的一个类​​Process​​。

​Process​​​类用来描述一个进程对象。创建子进程的时候,只需要传入一个执行函数和函数的参数,即可完成​​Process​​​示例的创建。 •​​start()​​​方法启动进程; •​​join()​​​方法实现进程间的同步,等待所有进程退出; •​​close()​​​用来阻止多余的进程涌入进程池 ​​Pool​​造成进程阻塞;

multiprocessing.Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

•​​target​​​是函数名字,需要调用的函数; •​​args​​​函数需要的参数,以​​tuple​​的形式传入。

首先我们来写一个简单的多进程程序!

示例:

import multiprocessing
import os
def run_proc(name):
print('Child process {0} {1} Running '.format(name, os.getpid()))
if __name__ == '__main__':
print('Parent process {0} is Running'.format(os.getpid()))
for i in range(5):
p = multiprocessing.Process(target=run_proc, args=(str(i),))
print('process start')
p.start()
p.join()
print('Process close')

结果:

Parent process 809 is Running
process start
process start
process start
process start
process start
Child process 0 810 Running
Child process 1 811 Running
Child process 2 812 Running
Child process 3 813 Running
Child process 4 814 Running
Process close
Pool 类

如果进程数太多了,我一个人根本搞不过来,怎么办呢?还好有进程池​​Pool​​​这个好基友啊,简单来说,​​Pool​​​可以提供指定数量的进程,供用户调用。当有新的请求提交到​​Pool​​中时,如果池还没有满,那么就会创建一个新的进程,用来执行该请求;但如果池中的进程数,已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程。

​Pool​​​可以提供指定数量的进程供用户使用,默认是 ​​CPU​​​ 核数。当有新的请求提交到​​Pool​​的时候,如果池子没有满,会创建一个进程来执行,否则就会让该请求等待;

  • ​Pool​​​对象调用​​join​​方法,会等待所有的子进程执行完毕;
  • 调用​​join​​​方法之前,必须调用​​close​​;
  • 调用​​close​​​之后,就不能继续添加新的​​Process​​了。
pool.apply_async

​apply_async​​​方法用来同步执行进程,允许多个进程同时进入池子。 ​​apply_async​​是异步非阻塞的。 异步处理就是,你现在问我问题,我可以不回答你,等我有时间了,再处理你这个问题。同步就反其道而行之,同步信息会立即被处理。

例如:

import multiprocessing
import os
import time
def run_task(name):
print('Task {0} pid {1} is running, parent id is {2}'.format(name, os.getpid(), os.getppid()))
time.sleep(1)
print('Task {0} end.'.format(name))
if __name__ == '__main__':
print('current process {0}'.format(os.getpid()))
p = multiprocessing.Pool(processes=3)
for i in range(6):
p.apply_async(run_task, args=(i,))
print('Waiting for all subprocesses done...')
p.close()
p.join()
print('All processes done!')

结果:

current process 921
Waiting for all subprocesses done...
Task 0 pid 922 is running, parent id is 921
Task 1 pid 923 is running, parent id is 921
Task 2 pid 924 is running, parent id is 921
Task 0 end.
Task 3 pid 922 is running, parent id is 921
Task 1 end.
Task 4 pid 923 is running, parent id is 921
Task 2 end.
Task 5 pid 924 is running, parent id is 921
Task 3 end.
Task 4 end.
Task 5 end.
All processes done!

完全没有等待子进程执行完毕,主进程就已经执行完毕,并退出程序。

pool.apply
apply(func[, args[, kwds]])

该方法只能允许一个进程进入池子,在一个进程结束之后,另外一个进程才可以进入池子。

​apply​​方法是阻塞的。

意思就是等待当前子进程执行完毕后,再执行下一个进程。

因为​​apply​​是阻塞的,所以进入子进程执行后,等待当前子进程执行完毕,再继续执行下一个进程。

例如:

import multiprocessing
import os
import time
def run_task(name):
print('Task {0} pid {1} is running, parent id is {2}'.format(name, os.getpid(), os.getppid()))
time.sleep(1)
print('Task {0} end.'.format(name))
if __name__ == '__main__':
print('current process {0}'.format(os.getpid()))
p = multiprocessing.Pool(processes=3)
for i in range(6):
p.apply(run_task, args=(i,))
print('Waiting for all subprocesses done...')
p.close()
p.join()
print('All processes done!')

结果:

Task 0 pid 928 is running, parent id is 927
Task 0 end.
Task 1 pid 929 is running, parent id is 927
Task 1 end.
Task 2 pid 930 is running, parent id is 927
Task 2 end.
Task 3 pid 928 is running, parent id is 927
Task 3 end.
Task 4 pid 929 is running, parent id is 927
Task 4 end.
Task 5 pid 930 is running, parent id is 927
Task 5 end.
Waiting for all subprocesses done...
All processes done!

这样好像跟单进程串行执行没什么区别了。

apply 和 apply_async 的区别

​apply​​是阻塞式的。 首先主进程开始运行,碰到子进程,操作系统切换到子进程,等待子进程运行结束后,再切换到另外一个子进程,直到所有子进程运行完毕。然后,再切换到主进程,运行剩余的部分。

​apply_async​​是异步非阻塞式的。 首先主进程开始运行,碰到子进程后,主进程说:让我先运行个够,等到操作系统进行进程切换的时候,再交给子进程运行。因为我们的程序太短,然而还没等到操作系统进行进程切换,主进程就运行完毕了。想要子进程执行,就告诉主进程:你等着所有子进程执行完毕后,再运行剩余部分。

一般建议:废弃apply,使用​apply_async​

什么是线程

线程:简单来说,一个进程中包含多个线程,比如打开一个 QQ(进程),然后你一边聊 QQ(一个线程),一边用 QQ 传送文件(一个线程),等等。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程( Thread )。

多线程的优点
  • 使用线程可以把占据长时间的程序任务放到后台去处理;
  • 用户界面可以更加吸引人,比如用户点击了一个按钮,去触发某些事件的处理,可以弹出一个进度条,来显示处理的进度;
  • 程序的运行速度可能加快;
  • 在一些等待的任务实现上,如用户输入、文件读写和网络收发数据等,线程就比较有用了。在这种情况下,我们可以释放一些珍贵的资源,如内存占用等等。
怎么编写多线程程序

Python 的标准库提供了两个模块:​​_thread​​​和​​threading​​​,​​_thread​​​是低级模块,​​threading​​​是高级模块,对​​_thread​​​进行了封装。绝大多数情况下,我们只需要使用​​threading​​这个高级模块。

启动一个线程就是,把一个函数传入,并创建​​Thread​​​实例,然后调用​​start()​​开始执行:

一个简单的多线程程序:

import time, threading
# 新线程执行的代码:
def loop():
print('thread %s is running...' % threading.current_thread().name)
n = 0
while n < 5:
n = n + 1
print('thread %s >>> %s' % (threading.current_thread().name, n))
time.sleep(1)
print('thread %s ended.' % threading.current_thread().name)
print('thread %s is running...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread')
t.start()
t.join()
print('thread %s ended.' % threading.current_thread().name)

它的运行结果如下:

thread MainThread is running...
thread LoopThread is running...
thread LoopThread >>> 1
thread LoopThread >>> 2
thread LoopThread >>> 3
thread LoopThread >>> 4
thread LoopThread >>> 5
thread LoopThread ended.
thread MainThread ended.
线程锁

由于线程之间是进行随机调度,当多个线程同时修改同一条数据时,可能会出现脏数据。所以,出现了线程锁,即同一时刻,允许一个线程执行操作。线程锁用于锁定资源,你可以定义多个锁, 像下面的代码, 当你需要独占某一资源时,任何一个锁都可以锁这个资源。就好比你用不同的锁,都可以把相同的一个门,锁住是一个道理。

由于线程之间是进行随机调度,如果有多个线程同时操作一个对象,如果没有很好地保护该对象,会造成程序结果的不可预期,我们也称此为“线程不安全”。

就比如下面这段代码,会产生和预期不一样的输出。

#实测:在python2.7、mac os下,运行以下代码可能会产生脏数据。但是在python3中就不一定会出现下面的问题。
import threading
import time
def run(n):
global num
num += 1
num = 0
t_obj = []
for i in range(20000):
t = threading.Thread(target=run, args=("t-%s" % i,))
t.start()
t_obj.append(t)
for t in t_obj:
t.join()
print "num:", num
"""
产生脏数据后的运行结果:
num: 19999
"""
互斥锁

python 提供了一种 “相互排斥”的方法(互斥锁即由此得名)。两个线程不能同时对同一个互斥对象加锁

互斥锁是这样工作的。如果线程 a 试图锁定一个互斥对象,而此时线程 b 已锁定了同一个互斥对象时,线程 a 就将进入睡眠状态。一旦线程 b 释放了互斥锁,线程 a 就能够锁定这个互斥对象。同样地,当线程 a 正锁定互斥对象时,如果线程 c 试图锁定互斥对象的话,线程 c 也将临时进入睡眠状态。对已锁定的互斥对象上,调用互斥锁的所有线程都将进入睡眠状态,这些睡眠的线程将“排队”访问这个互斥对象。

那么上面那一个问题,就可以使用互斥锁来解决。

import threading
import time
def run(n):
lock.acquire() #获取锁
global num
num += 1
lock.release() #释放锁
lock = threading.Lock() #实例化一个锁对象
num = 0
t_obj = []
for i in range(20000):
t = threading.Thread(target=run, args=("t-%s" % i,))
t.start()
t_obj.append(t)
for t in t_obj:
t.join()
print "num:", num

我们知道多线程环境下,每一个线程均可以使用所属进程的全局变量。如果一个线程对全局变量进行了修改,将会影响到其他所有的线程。为了避免多个线程,同时对变量进行修改,引入了线程同步机制,通过互斥锁,条件变量或者读写锁来控制对全局变量的访问。

全局变量并不能满足多线程环境的需求,很多时候线程还需要拥有自己的私有数据,这些数据对于其他线程来说不可见。因此,线程中也可以使用局部变量,局部变量只有线程自身可以访问,同一个进程下的其他线程不可访问。

有时候使用局部变量不太方便,因此 Python 提供了 ​​ThreadLocal​​ 变量,它本身是一个全局变量,但是每个线程却可以利用它,来保存属于自己的私有数据,这些私有数据对其他线程也是不可见的。

多线程的全局变量同步问题

首先,借助一个小程序来看看,多线程环境下全局变量的同步问题。

import threading
global_num = 0
def thread_cal():
global global_num
for i in xrange(1000):
global_num += 1
threads = []
for i in range(10):
threads.append(threading.Thread(target=thread_cal))
threads[i].start()
for i in range(10):
threads[i].join()
print global_num

这里我们创建了 10 个线程,每个线程均对全局变量​​global_num​​​进行 1000 次的加 1 操作(循环 1000 次加 1 是为了延长单个线程执行时间,使线程执行时被中断切换),当 10 个线程执行完毕时,全局变量的值是多少呢?答案是不确定。简单来说,是因为 ​​global_num += 1​​ 并不是一个原子操作,因此执行过程可能被其他线程中断,导致其他线程读到一个脏值。

多线程中使用全局变量时,普遍存在这个问题,解决办法也很简单,可以使用互斥锁、条件变量或者是读写锁。下面考虑用互斥锁,来解决上面代码的问题。只需要在进行​​+1​​运算前加锁,运算完毕释放锁即可,这样就可以保证运算的原子性。

l = threading.Lock()
...
l.acquire()
global_num += 1
l.release()

在线程中,使用局部变量,则不存在这个问题。因为每个线程的局部变量,不能被其他线程访问。下面我们用 10 个线程,分别对各自的局部变量进行 1000 次加 1 操作,每个线程结束时,打印一共执行的操作次数(每个线程均为 1000 ):

def show(num):
print threading.current_thread().getName(), num
def thread_cal():
local_num = 0
for _ in xrange(1000):
local_num += 1
show(local_num)
threads = []
for i in range(10):
threads.append(threading.Thread(target=thread_cal))
threads[i].start()

可以看出,这里每个线程都有自己的 ​​local_num​​,各个线程之间互不干涉。

Thread-local 对象

上面程序中,我们需要给 ​​show​​​ 函数传递 ​​local_num​​ 局部变量,并没有什么不妥。不过考虑在实际生产环境中,我们可能会调用很多函数,每个函数都需要很多局部变量,这时候用传递参数的方法会很不友好。

为了解决这个问题,一个直观的的方法就是建立一个全局字典,保存进程 ​​ID​​​ 到该进程局部变量的映射关系,运行中的线程可以根据自己的 ​​ID​​ ,来获取本身拥有的数据。这样,就可以避免在函数调用中,传递参数,如下示例:

global_data = {}
def show():
cur_thread = threading.current_thread()
print cur_thread.getName(), global_data[cur_thread]
def thread_cal():
cur_thread = threading.current_thread()
global_data[cur_thread] = 0
for _ in xrange(1000):
global_data[cur_thread] += 1
show() # Need no local variable. Looks good.

保存一个全局字典,然后将线程标识符作为​​key​​​,相应线程的局部数据作为 ​​value​​​,这种做法并不完美。首先,每个函数在需要线程局部数据时,都需要先取得自己的线程​​ID​​,略显繁琐。更糟糕的是,这里并没有真正做到线程之间数据的隔离,因为每个线程都可以读取到全局的字典,每个线程都可以对字典内容进行更改。

为了更好解决这个问题,Python 线程库实现了 ​​ThreadLocal​​​ 变量(很多语言都有类似的实现,比如 Java)。​​ThreadLocal​​​ 真正做到了线程之间的数据隔离,并且使用时,不需要手动获取自己的线程 ​​ID​​,如下示例:

global_data = threading.local()
def show():
print threading.current_thread().getName(), global_data.num
def thread_cal():
global_data.num = 0
for _ in xrange(1000):
global_data.num += 1
show()
threads = []
...
print "Main thread: ", global_data.__dict__ # {}

上面示例中,每个线程都可以通过 ​​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 语言最差