聊聊Python中的多进程和多线程
今天,想谈一下Python中的进程和线程。
最近在学习Django的时候,涉及到了多进程和多线程的知识点,所以想着一下把Python中的这块知识进行总结,所以系统地学习了一遍,将知识梳理如下。
1. 进程和线程的关系
既然谈论到进程和线程,当然要老生常谈一个问题,那就是什么是进程,什么又是线程呢?
用最简单的话解释就是一台电脑能同时运行多个QQ就是进程,每个QQ你打开不同窗口聊天,发图片,发视频就是线程。再比如Linux系统中我们通过ps -ef查看所有进程,每个进程都有一个pid,且唯一,其中pid=1的进程是用来回收所有孤儿进程的,一旦kill掉,系统就关闭了。
我们看一下定义:
- 进程是系统进行资源分配和调度的一个独立单位。
- 线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
而它们有以下4点主要区别:
- 一个程序至少有一个进程,一个进程至少有一个线程。
- 线程的划分尺度小于进程(资源比进程少),使得多线程程序的并发性高。
- 进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
- 线线程不能够独立执行,必须依存在进程中。
线程执行开销小,但不利于资源的管理和保护,而进程正相反。而且同一进程多个线程之间是数据共享的,多个进程之间数据相互独立,但是可以采取一些方式进行信息交互(比如队列)。
2. Python中的进程
用过Java的人都知道,Java每个JVM都是一个进程,在其中通过多线程进行开发,我们有两种方式创建线程——继承Thread类或者实现Runnable接口,想学习Java多线程详见:https://www.cnblogs.com/wxd0108/p/5479442.html
当然Java也提供了多进程方式,一般是通过Runtime,详见:https://www.cnblogs.com/xing901022/p/5568419.html
说的远了,我们这里谈的Python中的多进程和多线程,首先我们说一下多进程。
2.1 多进程创建方式
可以归纳为三种:fork,multiprocessing以及进程池Pool。
(1) fork方式
Python的os模块封装了常见的系统调用,其中就包括fork,可以在Python程序中轻松创建子进程:
import os
# 注意,fork函数,只在Unix/Linux/Mac上运行,windows不可以
pid = os.fork()
if pid == 0:
print('哈哈1')
else:
print('哈哈2')
注意:fork()函数只能在Unix/Linux/Mac上面运行,不可以在Windows上面运行。
说明:
- 程序执行到os.fork()时,操作系统会创建一个新的进程(子进程),然后复制父进程的所有信息到子进程中
- 然后父进程和子进程都会从fork()函数中得到一个返回值,在子进程中这个值一定是0,而父进程中是子进程的 id号
在Unix/Linux操作系统中,提供了一个fork()系统函数,它非常特殊。
普通的函数调用,调用一次,返回一次,但是fork()调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。
子进程永远返回0,而父进程返回子进程的ID。
这样做的理由是,一个父进程可以fork出很多子进程,所以,父进程要记下每个子进程的ID,而子进程只需要调用getppid()就可以拿到父进程的ID。我们可以通过os.getpid()获取当前进程ID,通过os.getppid()获取父进程ID。
那么,父子进程之间的执行有顺序吗?
答案是没有!这完全取决于操作系统的调度算法。
而多次fork()就会产生一个树的结构:
(2)multiprocessing方式
如果你打算编写多进程的服务程序,Unix/Linux无疑是正确的选择。由于Windows没有fork调用,难道在Windows上无法用Python编写多进程的程序?当然可以!由于Python是跨平台的,自然也应该提供一个跨平台的多进程支持。multiprocessing模块就是跨平台版本的多进程模块。
#coding=utf-8
from multiprocessing import Process
import os
# 子进程要执行的代码
def run_proc(name):
print('子进程运行中,name= %s ,pid=%d...' % (name, os.getpid()))
import time
time.sleep(10)
print('子进程已结束')
if __name__=='__main__':
print('父进程 %d.' % os.getpid())
p = Process(target=run_proc, args=('test',))
print('子进程将要执行')
p.start()
从结果我们看出,只要通过start()开启了子进程之后,主进程会等待子进程执行完才结束!
Process的语法结构如下:
Process([group [, target [, name [, args [, kwargs]]]]])
target:表示这个进程实例所调用对象;
args:表示调用对象的位置参数元组;
kwargs:表示调用对象的关键字参数字典;
name:为当前进程实例的别名;
group:大多数情况下用不到;
Process类常用方法:
is_alive():判断进程实例是否还在执行;
join([timeout]):是否等待进程实例执行结束,或等待多少秒;
start():启动进程实例(创建子进程);
run():如果没有给定target参数,对这个对象调用start()方法时,就将执行对象中的run()方法;
terminate():不管任务是否完成,立即终止;
Process类常用属性:
name:当前进程实例别名,默认为Process-N,N为从1开始递增的整数;
pid:当前进程实例的PID值;
当然我们还可以通过继承的方式来创建进程。
#coding=utf-8
from multiprocessing import Process
import os
class MyProcess(Process):
# 因为Process类本身也有__init__方法,这个子类相当于重写了这个方法,
# 但这样就会带来一个问题,我们并没有完全的初始化一个Process类,所以就不能使用从这个类继承的一些方法和属性,
# 最好的方法就是将继承类本身传递给Process.__init__方法,完成这些初始化操作
def __init__(self, name):
Process.__init__(self)
self.name = name
def run(self):
print('子进程运行中,name= %s ,pid=%d...' % (self.name, os.getpid()))
import time
time.sleep(10)
print('子进程已结束')
if __name__=='__main__':
print('父进程 %d.' % os.getpid())
p = MyProcess('test')
print('子进程将要执行')
p.start()
执行效果同上。
(3)Pool方式
当需要创建的子进程数量不多时,可以直接利用multiprocessing中的Process动态成生多个进程,但如果是上百甚至上千个目标,手动的去创建进程的工作量巨大,此时就可以用到multiprocessing模块提供的Pool方法。
初始化Pool时,可以指定一个最大进程数,当有新的请求提交到Pool中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到指定的最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程来执行。
#coding=utf-8
from multiprocessing import Pool
import os, time, random
def worker(msg):
print("%s开始执行,进程号为%d"%(msg, os.getpid()))
time.sleep(1)
print "%s执行完毕"%(msg)
if __name__ == '__main__':
po = Pool(3) # 定义一个进程池,最大进程数3
for i in range(10):
# Pool.apply_async(要调用的目标,(传递给目标的参数元祖,))
# 每次循环将会用空闲出来的子进程去调用目标
po.apply_async(worker, (i,))
print("----start----")
po.close() # 关闭进程池,关闭后po不再接收新的请求
po.join() # 等待po中所有子进程执行完成,必须放在close语句之后
print("-----end-----")
这里有一点需要注意:一定要写po.join(),否则主进程不会等着子进程执行完再结束的!
执行结果:
----start----
0开始执行,进程号为13404
1开始执行,进程号为13304
2开始执行,进程号为11692
0执行完毕
3开始执行,进程号为13404
2执行完毕
1执行完毕
4开始执行,进程号为11692
5开始执行,进程号为13304
3执行完毕
6开始执行,进程号为13404
4执行完毕
7开始执行,进程号为11692
5执行完毕
8开始执行,进程号为13304
6执行完毕
9开始执行,进程号为13404
7执行完毕
8执行完毕
9执行完毕
-----end-----
如果换成apply,那么结果如下:
0开始执行,进程号为8624
0执行完毕
1开始执行,进程号为12956
1执行完毕
2开始执行,进程号为8168
2执行完毕
3开始执行,进程号为8624
3执行完毕
4开始执行,进程号为12956
4执行完毕
5开始执行,进程号为8168
5执行完毕
6开始执行,进程号为8624
6执行完毕
7开始执行,进程号为12956
7执行完毕
8开始执行,进程号为8168
8执行完毕
9开始执行,进程号为8624
9执行完毕
----start----
-----end-----
看完程序,我们研究一下其函数:
multiprocessing.Pool常用函数解析:
apply_async(func[, args[, kwds]]) :使用非阻塞方式调用func(并行执行,堵塞方式必须等待上一个进程退出才能执行下一个进程),args为传递给func的参数列表,kwds为传递给func的关键字参数列表;
apply(func[, args[, kwds]]):使用阻塞方式调用func
close():关闭Pool,使其不再接受新的任务;
terminate():不管任务是否完成,立即终止;
join():主进程阻塞,等待子进程的退出, 必须在close或terminate之后使用;
实现一个多进程下的文件夹复制功能:
#coding=utf-8
import os
from multiprocessing import Pool
def copyFileTask(name, oldFolderName, newFolderName):
# 完成copy一个文件的功能
fr = open(oldFolderName+"/"+name, 'rb+')
fw = open(newFolderName+"/"+name, 'wb+')
str = fr.read(1024 * 5)
while (str != ''):
fw.write(str)
str = fr.read(1024 * 5)
fr.close()
fw.close()
def main():
# 获取要copy的文件夹名字
oldFolderName = raw_input('请输入文件夹名字:')
# 创建一个文件夹
newFolderName = oldFolderName+'-复件'.decode('utf-8').encode('gbk')
os.mkdir(newFolderName)
#获取old文件夹里面所有文件的名字
fileNames = os.listdir(oldFolderName)
#使用多进程的方式copy原文件夹所有内容到新的文件夹中
pool = Pool(5)
for name in fileNames:
pool.apply_async(copyFileTask, (name, oldFolderName, newFolderName))
pool.close()
pool.join()
if __name__ == '__main__':
main()
2.2 进程间通信
我们之前已经说过,进程间通信比较麻烦,但是也不是不可以哦,我们这里通过其中的一种message queue的方式进行进程间通信。
初始化Queue()对象时(例如:q=Queue()),若括号中没有指定最大可接收的消息数量,或数量为负值,那么就代表可接受的消息数量没有上限(直到内存的尽头);
- Queue.qsize():返回当前队列包含的消息数量;
- Queue.empty():如果队列为空,返回True,反之False ;
- Queue.full():如果队列满了,返回True,反之False;
- Queue.get([block[, timeout]]):获取队列中的一条消息,然后将其从列队中移除,block默认值为True;
1)如果block使用默认值,且没有设置timeout(单位秒),消息列队如果为空,此时程序将被阻塞(停在读取状态),直到从消息列队读到消息为止,如果设置了timeout,则会等待timeout秒,若还没读取到任何消息,则抛出"Queue.Empty"异常;
2)如果block值为False,消息列队如果为空,则会立刻抛出"Queue.Empty"异常;
- Queue.get_nowait():相当Queue.get(False);
- Queue.put(item,[block[, timeout]]):将item消息写入队列,block默认值为True;
如果block使用默认值,且没有设置timeout(单位秒),消息列队如果已经没有空间可写入,此时程序将被阻塞(停在写入状态),直到从消息列队腾出空间为止,如果设置了timeout,则会等待timeout秒,若还没空间,则抛出"Queue.Full"异常;如果block值为False,消息列队如果没有空间可写入,则会立刻抛出"Queue.Full"异常;
- Queue.put_nowait(item):相当Queue.put(item, False);
注意:如果以multiprocessing的方式创建进程,使用multiprocessing.Queue()创建队列;如果以进程池的方式创建进程,以multiprocessing.Manager()中的Queue()创建队列,否则会报错!
下面是一个多进程文件复制实例:
#coding=utf-8
import shutil
from multiprocessing import Process, Pool, Manager, Queue
import os
from time import sleep
def read(q):
f = open('C:\Users\Think\Pictures\头像.jpg'.decode('utf-8').encode('gbk'), 'rb+')
# for line in f.readlines():
# q.put(line)
# shutil.copy('C:\Users\Think\Pictures\头像.jpg'.decode('utf-8').encode('gbk'), 'C:\Users\Think\Pictures\头像2.jpg'.decode('utf-8').encode('gbk'))
str = f.read(5)
while (str != ''):
q.put(str)
str = f.read(5)
f.close()
def write(q):
f = open('C:\Users\Think\Pictures\头像2.jpg'.decode('utf-8').encode('gbk'), 'ab+')
for i in range(q.qsize()):
f.write(q.get())
f.close()
if __name__ == '__main__':
po = Pool(2)
q = Manager().Queue()
# q = Queue() # 这种方式不适合进程池
po.apply(read, (q,))
po.apply(write, (q,))
po.close()
po.join()
3. Python中的线程
3.1 GIL
在介绍Python中的线程之前,先明确一个问题,Python中的多线程是假的多线程! 为什么这么说,我们先明确一个概念,全局解释器锁(GIL)。
Python代码的执行由Python虚拟机(解释器)来控制。Python在设计之初就考虑要在主循环中,同时只有一个线程在执行,就像单CPU的系统中运行多个进程那样,内存中可以存放多个程序,但任意时刻,只有一个程序在CPU中运行。同样地,虽然Python解释器可以运行多个线程,只有一个线程在解释器中运行。
对Python虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同时只有一个线程在运行。在多线程环境中,Python虚拟机按照以下方式执行。
1.设置GIL。
2.切换到一个线程去执行。
3.运行。
4.把线程设置为睡眠状态。
5.解锁GIL。
6.再次重复以上步骤。
对所有面向I/O的(会调用内建的操作系统C代码的)程序来说,GIL会在这个I/O调用之前被释放,以允许其他线程在这个线程等待I/O的时候运行。如果某线程并未使用很多I/O操作,它会在自己的时间片内一直占用处理器和GIL。也就是说,I/O密集型的Python程序比计算密集型的Python程序更能充分利用多线程的好处。
我们都知道,比方我有一个4核的CPU,那么这样一来,在单位时间内每个核只能跑一个线程,然后时间片轮转切换。但是Python不一样,它不管你有几个核,单位时间多个核只能跑一个线程,然后时间片轮转。看起来很不可思议?但是这就是GIL搞的鬼。任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。
我们不妨做个试验:
#coding=utf-8
from multiprocessing import Pool
from threading import Thread
from multiprocessing import Process
def loop():
while True:
pass
if __name__ == '__main__':
for i in range(3):
t = Thread(target=loop)
t.start()
while True:
pass
我的电脑是4核,所以我开了4个线程,看一下CPU资源占有率:
我们发现CPU利用率并没有占满,大致相当于单核水平。
而如果我们变成进程呢?
我们改一下代码:
#coding=utf-8
from multiprocessing import Pool
from threading import Thread
from multiprocessing import Process
def loop():
while True:
pass
if __name__ == '__main__':
for i in range(3):
t = Process(target=loop)
t.start()
while True:
pass
结果直接飙到了100%,说明进程是可以利用多核的!
为了验证这是Python中的GIL搞得鬼,我试着用Java写相同的代码,开启线程,我们观察一下:
package com.darrenchan.thread;
public class TestThread {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
@Override
public void run() {
while (true) {
}
}
}).start();
}
while(true){
}
}
}
由此可见,Java中的多线程是可以利用多核的,这是真正的多线程!而Python中的多线程只能利用单核,这是假的多线程!
难道就如此?我们没有办法在Python中利用多核?当然可以!刚才的多进程算是一种解决方案,还有一种就是调用C语言的链接库。对所有面向I/O的(会调用内建的操作系统C代码的)程序来说,GIL会在这个I/O调用之前被释放,以允许其他线程在这个线程等待I/O的时候运行。我们可以把一些 计算密集型任务用C语言编写,然后把.so链接库内容加载到Python中,因为执行C代码,GIL锁会释放,这样一来,就可以做到每个核都跑一个线程的目的!
可能有的小伙伴不太理解什么是计算密集型任务,什么是I/O密集型任务?
计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。
计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。
第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。
IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。
综上,Python多线程相当于单核多线程,多线程有两个好处:CPU并行,IO并行,单核多线程相当于自断一臂。所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。
分享一个视频教程:https://pan.baidu.com/s/1i4I9je9
分享廖雪峰的博客:廖雪峰博客
分享魏印福的博客:魏印福博客
3.2 多线程创建方式
Python的标准库提供了两个模块:thread和threading,thread是低级模块,threading是高级模块,对thread进行了封装。绝大多数情况下,我们只需要使用threading这个高级模块。
创建方式很简单,就和Process差不多,刚才那个例子就说明问题了:
#coding=utf-8
from multiprocessing import Pool
from threading import Thread
from multiprocessing import Process
def loop():
while True:
pass
if __name__ == '__main__':
for i in range(3):
t = Thread(target=loop)
t.start()
while True:
pass
注意:同刚才进程这种创建方式一样,主线程会等待所有的子线程结束后才结束!
查看线程数量,可以通过
len(threading.enumerate())
查看线程名字,可以通过
threading.currentThread().name
同样也可以通过继承的方式创建(重写run方法):
#coding=utf-8
from multiprocessing import Pool
from threading import Thread
import threading
from multiprocessing import Process
class MyThread(Thread):
def run(self):
while True:
pass
if __name__ == '__main__':
for i in range(3):
t = MyThread()
t.start()
while True:
# print len(threading.enumerate())
pass
多线程的执行顺序也是不固定的!
线程的几种运行状态:
3.3 进程和线程全局变量共享比较
先看一下进程:
from multiprocessing import Process
import time
from threading import Thread
m = 5
def func1():
global m
m += 1
print m
def func2():
global m
time.sleep(3)
print m
if __name__ == '__main__':
p1 = Process(target=func1)
p2 = Process(target=func2)
p1.start()
p2.start()
执行结果是6和5。
如果是线程呢?
from multiprocessing import Process
import time
from threading import Thread
m = 5
def func1():
global m
m += 1
print m
def func2():
global m
time.sleep(3)
print m
if __name__ == '__main__':
t1 = Thread(target=func1)
t2 = Thread(target=func2)
t1.start()
t2.start()
执行结果是6和6。
由此说明,
- 多进程中,每个进程中所有数据(包括全局变量)都各有拥有一份,互不影响
- 在一个进程内的所有线程共享全局变量,能够在不适用其他方式的前提下完成多线程之间的数据共享(但是局部变量是自己的)
3.4 同步和互斥锁
同步就是协同步调,按预定的先后次序进行运行。通过一个最简单的例子说明:
#coding=utf-8
from threading import Thread
import time
g_num = 0
def test1():
global g_num
for i in range(1000000):
g_num += 1
print("---test1---g_num=%d"%g_num)
def test2():
global g_num
for i in range(1000000):
g_num += 1
print("---test2---g_num=%d"%g_num)
p1 = Thread(target=test1)
p1.start()
# time.sleep(3) #取消屏蔽之后 再次运行程序,结果会不一样,,,为啥呢?
p2 = Thread(target=test2)
p2.start()
print("---g_num=%d---"%g_num)
加了屏蔽之后,运行结果如下:
---g_num=2880---
---test1---g_num=1374568
---test2---g_num=1376569
不加屏蔽,运行结果如下:
---test1---g_num=1000000
---g_num=1000034---
---test2---g_num=2000000
问题产生的原因就是没有控制多个线程对同一资源的访问,对数据造成破坏,使得线程运行的结果不可预期。如果多个线程共同访问同一片数据,则由于数据访问的顺序不一样,有可能导致数据结果不一致的问题,这种现象称为“竞态条件”。
解决办法?上锁!Java中我们一般通过synchronized关键字或者Lock类,可以参考我以前的博客:http://www.cnblogs.com/DarrenChan/p/6528578.html
在Python里面呢?
#创建锁
mutex = threading.Lock()
#锁定
mutex.acquire([blocking])
#释放
mutex.release()
其中,锁定方法acquire可以有一个blocking参数。
如果设定blocking为True,则当前线程会堵塞,直到获取到这个锁为止(如果没有指定,那么默认为True)
如果设定blocking为False,则当前线程不会堵塞
刚才那个例子:
#coding=utf-8
from threading import Thread, Lock
import time
g_num = 0
def test1():
global g_num
for i in range(1000000):
if mutex.acquire():
g_num += 1
mutex.release()
print("---test1---g_num=%d"%g_num)
def test2():
global g_num
for i in range(1000000):
if mutex.acquire():
g_num += 1
mutex.release()
print("---test2---g_num=%d"%g_num)
mutex = Lock()
p1 = Thread(target=test1)
p1.start()
p2 = Thread(target=test2)
p2.start()
print("---g_num=%d---"%g_num)
---g_num=10---
---test1---g_num=1999392
---test2---g_num=2000000
执行结果最后2000000,符合预期。当一个线程调用锁的acquire()方法获得锁时,锁就进入“locked”状态。每次只有一个线程可以获得锁。如果此时另一个线程试图获得这个锁,该线程就会变为“blocked”状态,称为“阻塞”,直到拥有锁的线程调用锁的release()方法释放锁之后,锁进入“unlocked”状态。线程调度程序从处于同步阻塞状态的线程中选择一个来获得锁,并使得该线程进入运行(running)状态。
锁的好处:
- 确保了某段关键代码只能由一个线程从头到尾完整地执行
锁的坏处:
- 阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了
- 由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁(死锁可以通过银行家算法解决)
那,如果我想让多个线程按照一定顺序执行呢?
#coding=utf-8
from threading import Thread,Lock
from time import sleep
class Task1(Thread):
def run(self):
while True:
if lock1.acquire():
print("------Task 1 -----")
sleep(0.5)
lock2.release()
class Task2(Thread):
def run(self):
while True:
if lock2.acquire():
print("------Task 2 -----")
sleep(0.5)
lock3.release()
class Task3(Thread):
def run(self):
while True:
if lock3.acquire():
print("------Task 3 -----")
sleep(0.5)
lock1.release()
#使用Lock创建出的锁默认没有“锁上”
lock1 = Lock()
#创建另外一把锁,并且“锁上”
lock2 = Lock()
lock2.acquire()
#创建另外一把锁,并且“锁上”
lock3 = Lock()
lock3.acquire()
t1 = Task1()
t2 = Task2()
t3 = Task3()
t1.start()
t2.start()
t3.start()
执行结果如下:
------Task 1 -----
------Task 2 -----
------Task 3 -----
------Task 1 -----
------Task 2 -----
------Task 3 -----
------Task 1 -----
------Task 2 -----
------Task 3 -----
......
3.5 生产者与消费者模式
为什么要使用生产者和消费者模式?
在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发当中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这个问题于是引入了生产者和消费者模式。
什么是生产者消费者模式?
生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。纵观大多数设计模式,都会找一个第三者出来进行解耦。
#encoding=utf-8
import threading
import time
#python2中
from Queue import Queue
#python3中
# from queue import Queue
class Producer(threading.Thread):
def run(self):
global queue
count = 0
while True:
if queue.qsize() < 1000:
for i in range(100):
count = count +1
msg = '生成产品'+str(count)
queue.put(msg)
print(msg)
time.sleep(0.5)
class Consumer(threading.Thread):
def run(self):
global queue
while True:
if queue.qsize() > 100:
for i in range(3):
msg = self.name + '消费了 '+queue.get()
print(msg)
time.sleep(1)
if __name__ == '__main__':
queue = Queue()
for i in range(500):
queue.put('初始产品'+str(i))
for i in range(2):
p = Producer()
p.start()
for i in range(5):
c = Consumer()
c.start()
3.6 ThreadLocal
在多线程环境下,每个线程都有自己的数据。一个线程使用自己的局部变量比使用全局变量好,因为局部变量只有线程自己能看见,不会影响其他线程,而全局变量的修改必须加锁。
但是局部变量函数调用很麻烦,需要一层一层传递参数。
def process_student(name):
std = Student(name)
# std是局部变量,但是每个函数都要用它,因此必须传进去:
do_task_1(std)
do_task_2(std)
def do_task_1(std):
do_subtask_1(std)
do_subtask_2(std)
def do_task_2(std):
do_subtask_2(std)
do_subtask_2(std)
那我们可以采用全局变量吗?不行,因为每个线程处理不同对象,不能共享!
我们可以采用全局字典的办法:
global_dict = {}
def std_thread(name):
std = Student(name)
# 把std放到全局变量global_dict中:
global_dict[threading.current_thread()] = std
do_task_1()
do_task_2()
def do_task_1():
# 不传入std,而是根据当前线程查找:
std = global_dict[threading.current_thread()]
...
def do_task_2():
# 任何函数都可以查找出当前线程的std变量:
std = global_dict[threading.current_thread()]
...
ThreadLocal就是帮我们干了这样一件事!全局变量local_school就是一个ThreadLocal对象,每个Thread对它都可以读写student属性,但互不影响。你可以把local_school看成全局变量,但每个属性如local_school.student都是线程的局部变量,可以任意读写而互不干扰,也不用管理锁的问题,ThreadLocal内部会处理。可以理解为全局变量local_school是一个dict,不但可以用local_school.student,还可以绑定其他变量,如local_school.teacher等等。ThreadLocal最常用的地方就是为每个线程绑定一个数据库连接,HTTP请求,用户身份信息等,这样一个线程的所有调用到的处理函数都可以非常方便地访问这些资源。
#coding=utf-8
import threading
# 创建全局ThreadLocal对象:
local_school = threading.local()
def process_student():
# 获取当前线程关联的student:
std = local_school.student
print('Hello, %s (in %s)' % (std, threading.current_thread().name))
def process_thread(name):
# 绑定ThreadLocal的student:
local_school.student = name
process_student()
if __name__ == '__main__':
t1 = threading.Thread(target= process_thread, args=('陈驰',), name='Thread-A')
t2 = threading.Thread(target= process_thread, args=('刘卓',), name='Thread-B')
t1.start()
t2.start()
Hello, 陈驰 (in Thread-A)
Hello, 刘卓 (in Thread-B)
3.7 异步
类似于Ajax的回调,python中也可以。
#coding=utf-8
from multiprocessing import Pool
import time
import os
def test111():
print ("---进程池中的进程---pid=%d,ppid=%d--"%(os.getpid(), os.getppid()))
for i in range(3):
print("----%d---"%i)
time.sleep(1)
return "hahah"
def test222(args):
print("---callback func--pid=%d"%os.getpid())
print("---callback func--args=%s"%args)
if __name__ == '__main__':
pool = Pool(3)
pool.apply_async(func=test111, callback=test222)
pool.close()
pool.join()
time.sleep(5)
print("----主进程-pid=%d----" % os.getpid())
在Python下运行,刚开始执行test111里面的内容,等执行完,会通知主进程进行回调函数,执行test222。
---进程池中的进程---pid=8044,ppid=5988--
----0---
----1---
----2---
---callback func--pid=5988
---callback func--args=hahah
----主进程-pid=5988----
4. 总结
首先,要实现多任务,通常我们会设计Master-Worker模式,Master负责分配任务,Worker负责执行任务,因此,多任务环境下,通常是一个Master,多个Worker。
如果用多进程实现Master-Worker,主进程就是Master,其他进程就是Worker。
如果用多线程实现Master-Worker,主线程就是Master,其他线程就是Worker。
多进程模式最大的优点就是稳定性高,因为一个子进程崩溃了,不会影响主进程和其他子进程。(当然主进程挂了所有进程就全挂了,但是Master进程只负责分配任务,挂掉的概率低)著名的Apache最早就是采用多进程模式。
多进程模式的缺点是创建进程的代价大,在Unix/Linux系统下,用fork
调用还行,在Windows下创建进程开销巨大。另外,操作系统能同时运行的进程数也是有限的,在内存和CPU的限制下,如果有几千个进程同时运行,操作系统连调度都会成问题。
多线程模式通常比多进程快一点,但是也快不到哪去,而且,多线程模式致命的缺点就是任何一个线程挂掉都可能直接造成整个进程崩溃,因为所有线程共享进程的内存。在Windows上,如果一个线程执行的代码出了问题,你经常可以看到这样的提示:“该程序执行了非法操作,即将关闭”,其实往往是某个线程出了问题,但是操作系统会强制结束整个进程。
在Windows下,多线程的效率比多进程要高,所以微软的IIS服务器默认采用多线程模式。由于多线程存在稳定性的问题,IIS的稳定性就不如Apache。为了缓解这个问题,IIS和Apache现在又有多进程+多线程的混合模式,真是把问题越搞越复杂。
4.1 线程切换
无论是多进程还是多线程,只要数量一多,效率肯定上不去,为什么呢?
我们打个比方,假设你不幸正在准备中考,每天晚上需要做语文、数学、英语、物理、化学这5科的作业,每项作业耗时1小时。
如果你先花1小时做语文作业,做完了,再花1小时做数学作业,这样,依次全部做完,一共花5小时,这种方式称为单任务模型,或者批处理任务模型。
假设你打算切换到多任务模型,可以先做1分钟语文,再切换到数学作业,做1分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核CPU执行多任务是一样的了,以幼儿园小朋友的眼光来看,你就正在同时写5科作业。
但是,切换作业是有代价的,比如从语文切到数学,要先收拾桌子上的语文书本、钢笔(这叫保存现场),然后,打开数学课本、找出圆规直尺(这叫准备新环境),才能开始做数学作业。操作系统在切换进程或者线程时也是一样的,它需要先保存当前执行的现场环境(CPU寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。
所以,多任务一旦多到一个限度,就会消耗掉系统所有的资源,结果效率急剧下降,所有任务都做不好。
4.2 异步IO
考虑到CPU和IO之间巨大的速度差异,一个任务在执行的过程中大部分时间都在等待IO操作,单进程单线程模型会导致别的任务无法并行执行,因此,我们才需要多进程模型或者多线程模型来支持多任务并发执行。
现代操作系统对IO操作已经做了巨大的改进,最大的特点就是支持异步IO。如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,Nginx就是支持异步IO的Web服务器,它在单核CPU上采用单进程模型就可以高效地支持多任务。在多核CPU上,可以运行多个进程(数量与CPU核心数相同),充分利用多核CPU。由于系统总的进程数量十分有限,因此操作系统调度非常高效。用异步IO编程模型来实现多任务是一个主要的趋势。
对应到Python语言,单进程的异步编程模型称为协程,有了协程的支持,就可以基于事件驱动编写高效的多任务程序。我们会在后面讨论如何编写协程。
参考:廖雪峰博客
《Python核心编程》