用python写了个程序,结果运行了一天,这个速度可让人发愁,怎么优化交作业。发现可以用并行计算来最大化压榨电脑的CPU,提升计算效率,而且python里有multiprocessing这个库可以提供并行计算接口。然后大致弄清了GPU、CPU、进程、线程、并行计算、分布式计算等概念
1 大数据时代的现状
当前我们正处于大数据时代,每天我们会通过手机、电脑等设备不断的将自己的数据传到互联网上。据统计,YouTube上每分钟就会增加500多小时的视频,面对如此海量的数据,如何高效的存储与处理它们就成了当前最大的挑战。
但在这个对硬件要求越来越高的时代,CPU却似乎并不这么给力了。自2013年以来,处理器频率的增长速度逐渐放缓了,目前CPU的频率主要分布在3到4GHz。这个也是可以理解的,毕竟摩尔定律都生效了50年了,如果它老人家还如此给力,那我们以后就只要静等处理器频率提升,什么计算问题在未来那都不在话下了。实际上CPU与频率是与能耗密切相关的,我们之前可以通过加电压来提升频率,但当能耗太大,散热问题就无法解决了,所以频率就逐渐稳定下来了,而Intel与AMD等大制造商也将目标转向了多核芯片,目前普通桌面PC也达到了4到8核。
2 面对挑战的方法
咱们有了多核CPU,以及大量计算设备,那我们怎么来用它们应对大数据时代的挑战了,就要提到下面的方法了。
2.1 并行计算
并行(parallelism)是指程序运行时的状态,如果在同时刻有多个“工作单位”运行,则所运行的程序处于并行状态。
2.1.1 多线程并行parallelism
图一、多线程并行
图一是并行程序的示例,开始并行后,程序从主线程分出许多小的线程并同步执行,此时每个线程在各个独立的CPU进行运行,在所有线程都运行完成之后,它们会重新合并为主线程,而运行结果也会进行合并,并交给主线程继续处理。
2.1.2 多线程并发concurrency
图二、多线程并发
图二是一个多线程的任务(沿线为线程时间),但它不是并行任务。这是因为task1与task2总是不在同一时刻执行,这个情况下单核CPU完全可以同时执行task1与task2。方法是在task1不执行的时候立即将CPU资源给task2用,task2空闲的时候CPU给task1用,这样通过时间窗调整任务,即可实现多线程程序,但task1与task2并没有同时执行过,所以不能称为并行。我们可以称它为并发(concurrency)程序,这个程序一定意义上提升了单个CPU的使用率,所以效率也相对较高。
(1)并行编程模型:
数据并行(Data Parallel)模型:将相同的操作同时作用于不同数据,只需要简单地指明执行什么并行操作以及并行操作对象。该模型反映在图一中即是,并行同时在主线程中拿取数据进行处理,并行线程执行相同的操作,然后计算完成后合并结果。各个并行线程在执行时互不干扰。
(2)消息传递(Message Passing)模型:各个并行执行部分之间传递消息,相互通讯。消息传递模型的并行线程在执行时会传递数据,可能一个线程运行到一半的时候,它所占用的数据或处理结果就要交给另一个线程处理,这样,在设计并行程序时会给我们带来一定麻烦。该模型一般是分布式内存并行计算机所采用方法,但是也可以适用于共享式内存的并行计算机。
2.1.3 何时用并行计算
(1)多核CPU——计算密集型任务
尽量使用并行计算,可以提高任务执行效率。计算密集型任务会持续地将CPU占满,此时有越多CPU来分担任务,计算速度就会越快,这种情况才是并行程序的用武之地。
(2)单核CPU——计算密集型任务
此时的任务已经把CPU资源100%消耗了,就没必要使用并行计算,毕竟硬件障碍摆在那里。
(3)单核CPU——I/O密集型任务
I/O密集型任务在任务执行时需要经常调用磁盘、屏幕、键盘等外设,由于调用外设时CPU会空闲,所以CPU的利用率并不高,此时使用多线程程序,只是便于人机交互。计算效率提升不大。
(4)多核CPU——I/O密集型任务
I/O密集型任务在任务执行时需要经常调用磁盘、屏幕、键盘等外设,由于调用外设时CPU会空闲,所以CPU的利用率并不高,此时使用多线程程序,只是便于人机交互。计算效率提升不大。
2.2 改用GPU处理计算密集型程序
GPU即图形处理器核心(Graphics Processing Unit),它是显卡的心脏,显卡上还有显存,GPU与显存类似于CPU与内存。
GPU与CPU有不同的设计目标,CPU需要处理所有的计算指令,所以它的单元设计得相当复杂;而GPU主要为了图形“渲染”而设计,渲染即进行数据的列处理,所以GPU天生就会为了更快速地执行复杂算术运算和几何运算的。
GPU相比与CPU有如下优势:
(1)强大的浮点数计算速度。
(2)大量的计算核心,可以进行大型并行计算。一个普通的GPU也有数千个计算核心。
(3)强大的数据吞吐量,GPU的吞吐量是CPU的数十倍,这意味着GPU有适合的处理大数据。
(4)GPU目前在处理深度学习上用得十分多,英伟达(NVIDIA)目前也花大精力去开发适合深度学习的GPU。现在上百层的神经网络已经很常见了,面对如此庞大的计算量,CPU可能需要运算几天,而GPU却可以在几小时内算完。
2.3 分布式计算
说到分布式计算,我们就先说下下Google的3篇论文:
(1)GFS(The Google File System):解决数据存储的问题。采用N多台廉价的电脑,使用冗余的方式,来取得读写速度与数据安全并存的结果。
(2)MapReduce(Simplified Data Processing on Large Clusters):函数式编程,把所有的操作都分成两类,map与reduce,map用来将数据分成多份,分开处理,reduce将处理后的结果进行归并,得到最终的结果。
(3)BigTable(Bigtable: A Distributed Storage System for Structured Data):在分布式系统上存储结构化数据的一个解决方案,解决了巨大的Table的管理、负载均衡的问题.
Google在2003~2006年发表了这三篇论文之后,一时之间引起了轰动,但是Google并没有将MapReduce开源。在这种情况下Hadoop就出现了,Doug Cutting在Google的3篇论文的理论基础上开发了Hadoop,此后Hadoop不断走向成熟,目前Facebook、IBM、ImageShack等知名公司都在使用Hadoop运行他们的程序。
分布式计算的优势:
可以集成诸多低配的计算机(成千上万台)进行高并发的储存与计算,从而达到与超级计算机媲美的处理能力。
3 用python写并行程序
在介绍如何使用python写并行程序之前,我们需要先补充几个概念,分别是进程、线程与全局解释器锁(Global Interpreter Lock, GIL)。
3.1 进程与线程
一、进程(process)
在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器。
进程拥有自己独立的内存空间,所属线程可以访问进程的空间。
程序本身只是指令、数据及其组织形式的描述,进程才是程序的真正运行实例。 例如,Visual Studio开发环境就是利用一个进程编辑源文件,并利用另一个进程完成编译工作的应用程序。
二、线程(threading)
线程有自己的一组CPU指令、寄存器与私有数据区,线程的数据可以与同一进程的线程共享。
当前的操作系统是面向线程的,即以线程为基本运行单位,并按线程分配CPU。
进程与线程有两个主要的不同点:
其一是进程包含线程,线程使用进程的内存空间,当然线程也有自己的私有空间,但容量小;
其二是进程有各自独立的内存空间,互不干扰,而线程是共享内存空间。
图三、进程、线程与CPU关系
图三展示了进程、线程与CPU之间的关系。进程一与进程二都含有3个线程,CPU会按照线程来分配任务,如图中4个CPU同时执行前4个线程,后两个标红线程处于等待状态,在CPU运行完当前线程时,等待的线程会被唤醒并进入CPU执行。通常,进程含有的线程数越多,则它占用CPU的时间会越长。
3.2 全局解释器锁GIL
GIL是计算机程序设计语言解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行。即便在多核心处理器上,使用GIL的解释器也只允许同一时间执行一个线程。
Python的Cpython解释器(普遍使用的解释器)使用GIL,在一个Python解释器进程内可以执行多线程程序,但每次一个线程执行时就会获得全局解释器锁,使得别的线程只能等待,由于GIL几乎释放的同时就会被原线程马上获得,那些等待线程可能刚唤醒,所以经常造成线程不平衡享受CPU资源,此时多线程的效率比单线程还要低下。在python的官方文档里,它是这样解释GIL的:
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.)
可以说它的初衷是很好的,为了保证线程间的数据安全性;但是随着时代的发展,GIL却成为了python并行计算的最大障碍,但这个时候GIL已经遍布CPython的各个角落,修改它的工作量太大,特别是对这种开源性的语音来说。但幸好GIL只锁了线程,我们可以再新建解释器进程来实现并行,那这就是multiprocessing的工作了。
3.3 multiprocessing
multiprocessing是python里的多进程包,通过它我们可以在python程序里建立多进程来执行任务,从而进行并行计算。官方文档如下所述:
The multiprocessing package offers both local and remote concurrency, effectively side-stepping the Global Interpreter Lock by using subprocesses instead of threads.
我们接下来介绍下multiprocessing的各个接口:
3.3.1 进程multiprocessing.Process
multiprocessing.Process(target=None, args=())
参数target: 可以被run()调用的函数,简单来说就是进程中运行的函数
参数args: 是target的参数
process的方法:
(1)函数start(): 开始启动进程,在创建process之后执行
(2)函数join([timeout]):阻塞目前父进程,
直到调用join方法的进程执行完或超时(timeout),才继续执行父进程。
如果不需要,启动进程后可以不使用join。
p.join() # 阻塞主线程,直至p进程执行结束
(3)函数terminate():终止进程,不论进程有没有执行完,尽量少用。
示例如下:
from multiprocessing import Process
def fun(name):
print('hello', name)
if __name__ == '__main__':
# p进程执行f函数,参数为'lucy',注意后面的','号
p = Process(target=fun, args=('lucy',))
p.start() # 进程开始
3.3.2 进程池multiprocessing.Pool
class multiprocessing.Pool([processes])
processes是进程池中的进程数,默认是本机的cpu数量
方法:
apply(func[, args[, kwds]])进程池中的进程进行func函数操作,操作时会阻塞进程,直至生成结果。
apply_async(func[, args[, kwds[, callback]]])与apply类似,但是不会阻塞进程
map(func, iterable[, chunksize])进程池中的进程进行映射操作
map_async(func, iterable[, chunksize[, callback]])
imap(func, iterable[, chunksize]):返回有序迭代器
imap_unordered(func, iterable[, chunsize]):返回无序迭代器
close():禁止进程池再接收任务
terminate():强行终止进程池,不论是否有任务在执行
join():在close()或terminate()之后进行,等待进程退出
示例
from multiprocessing import Pool
def fun(x):
return x*x
if __name__ == '__main__':
p = Pool(5) # 创建有5个进程的进程池
print(p.map(fun, [1, 2, 3])) # 将fun函数的操作给进程池
3.3.3 管道队列multiprocessing.Pipes和Queues
multiprocessing.Pipe([duplex])
返回两个连接对象(conn1, conn2),两个连接对象分别访问pipe的头和尾,进行读写操作
Duplex: True(default),创建的pipe是双向的,也即两端都可以进行读写;若为False,则pipe是单向的,仅可以在一端读,另一端写,此时与Queue类似。
multiprocessing.Queue([maxsize])
qsize():返回queue中member数量
empty():如果queue是空的,则返回true
full():如果queue中member数量达到maxsize,则返回true
put(obj):将一个object放入到queue中
get():从队列中取出一个object并将它从queue中移除,FIFO原则
close():关闭队列,并将缓存的object写入pipe
示例
from multiprocessing import Pool
import time
def fun(x):
return x*x
if __name__ == '__main__':
# 启动4个进程
pool = Pool(processes=4)
# 在单进程中异步计算fun(10)
result = pool.apply_async(fun, (10,))
print(result.get(timeout=1))
# 进程池中进行映射操作
print(pool.map(fun, range(10)))
# 返回有序迭代器
it = pool.imap(fun, range(10))
print(it.next())
print(it.next())
print(it.next(timeout=1))
# 异步计算
result = pool.apply_async(time.sleep, (10,))
print(result.get(timeout=1)) # 超时未返回报异常
3.3.4 进程锁multiprocessing.Lock
当一个进程获得(acquire)锁之后,其它进程在想获得锁就会被禁止,可以保护数据,进行同步处理。
acquire(block=True, timeout=None):尝试获取一个锁,如果block为true,则会在获得锁之后阻止其它进程再获取锁。
release():释放锁
3.3.5 共享内存multiprocessing.Value和Array
共享内存通常需要配合进程锁来处理,保证处理的顺序相同。
multiprocessing.Value(typecode_or_type, *args[, lock])
返回一个ctype对象,
创建c = Value(‘d’, 3.14),调用c.value()
multiprocessing.Array(typecode_or_type, size_or_initializer, *, lock=True)
返回一个ctype数组,只能是一维的
Array(‘i’, [1, 2, 3, 4])
3.3.6 其它方法和注意事项
multiprocessing.active_children():返回当前进程的所有子进程
multiprocessing.cpu_count():返回本计算机的cpu数量
multiprocessing.current_process():返回当前进程
注意事项
(1)尽量避免共享数据。
(2)所有对象都尽量是可以pickle的。
(3)避免使用terminate强行终止进程,以造成不可预料的后果。
(4)有队列的进程在终止前队列中的数据需要清空,join操作应放到queue清空后。
(5)明确给子进程传递资源、参数。
windows平台另需注意:
(1)注意跨模块全局变量的使用,可能被各个进程修改造成结果不统一。
(2)主模块需要加上if name == ‘main’:来提高它的安全性,如果有交互界面,需要加上freeze_support()。
4 multiprocessing实战
4.1 process、lock与value尝试
4.1.1 加锁
import multiprocessing as mp
import time
def job(v, num, l):
l.acquire() # 锁住
for _ in range(5):
time.sleep(0.1)
v.value += num # 获取共享内存
print(mp.current_process(),v.value)
l.release() # 释放
def multicore():
l = mp.Lock() # 初始化,定义一个进程锁
v = mp.Value('i', 0) # 初始化,定义共享内存
p1 = mp.Process(target=job, args=(v,1,l)) # 需要将lock传入
p2 = mp.Process(target=job, args=(v,3,l))
p1.start()
p2.start()
if __name__=='__main__':
multicore()
上述代码即对共享内存叠加5次,p1进程每次叠加1,p2进程每次叠加3,为了避免p1与p2在运行时抢夺共享数据v,在进程执行时锁住了该进程,从而保证了执行的顺序。
4.1.2 去锁
在4.1.1的基础上注释掉锁(上述注释了三行),在没有锁的情况下。
import multiprocessing as mp
import time
def job(v, num, l):
# l.acquire() # 锁住
for _ in range(5):
time.sleep(0.1)
v.value += num # 获取共享内存
print(mp.current_process(),v.value)
# l.release() # 释放
def multicore():
l = mp.Lock() # 初始化,定义一个进程锁
v = mp.Value('i', 0) # 初始化,定义共享内存
p1 = mp.Process(target=job, args=(v,1,l)) # 需要将lock传入
p2 = mp.Process(target=job, args=(v,3,l))
p1.start()
p2.start()
if __name__=='__main__':
multicore()
如上述代码所示,这时如果没锁的话p1与p2就是并行了,运行时间就是一半,但因为它们争抢共享变量,所以输出就变得不确定了。
4.1.3 调整join顺序
在4.1.2的基础上将p1.join()调到p2.start()前面。
import multiprocessing as mp
import time
def job(v, num, l):
# l.acquire() # 锁住
for _ in range(5):
time.sleep(0.1)
v.value += num # 获取共享内存
print(mp.current_process(),v.value)
# l.release() # 释放
def multicore():
l = mp.Lock() # 初始化,定义一个进程锁
v = mp.Value('i', 0) # 初始化,定义共享内存
p1 = mp.Process(target=job, args=(v,1,l)) # 需要将lock传入
p2 = mp.Process(target=job, args=(v,3,l))
p1.start()
p1.join()
p2.start()
if __name__=='__main__':
multicore()
可以发现,没锁的情况下调整join可以取得与加锁类似的结果,这是因为join即是阻塞主进程,直至当前子进程结束才回到主进程,若将p1.join()放到p1.start()后面,则会马上阻塞主进程,使得p2要稍后才开始,这与锁的效果一样。
4.2 pool
import multiprocessing as mp
def fun(i):
return i*i
def multicore():
pool = mp.Pool()
res = pool.map(fun, range(10))
print(res)
res = pool.apply_async(fun, (2,))
print(res.get()) # 用get获得结果
# 迭代器,i=0时apply一次,i=1时apply一次等等
multi_res = [pool.apply_async(fun, (i,)) for i in range(10)]
# 从迭代器中取出
print([res.get() for res in multi_res])
if __name__=='__main__':
multicore()
pool其实非常好用,特别是map与apply_async。通过pool这个接口,我们只有指定可以并行的函数与函数参数列表,它就可以自动帮我们创建多进程池进行并行计算,真的不要太方便。pool特别适用于数据并行模型,假如是消息传递模型那还是建议自己通过process来创立进程吧。
5 python线程安全Lock同步锁
线程安全问题,经典的“银行取钱”问题。
基本流程:
(1)判断账户、密码是否匹配。
(2)输入取款金额。
(3)判断余额是否大于取款金额。
若余额>取款金额,则取款成功;
若余额<取款金额,则取款失败。
5.1 未加锁的问题
日常个人操作,这个流程没有问题。但将这个流程放在多线程并发的场景下,就有可能出现问题。使用两个线程来模拟两个人使用同一个账户井发取钱。
import threading
import time
class Account:
# 定义构造器
def __init__(self, account, balance):
self.account = account # 账户
self.balance = balance # 余额
# 取钱操作函数
def draw(account, money):
name = threading.current_thread().name # 线程名
# 余额>=取钱金额
if account.balance >= money:
print(name,"成功取钱:" + str(money))
time.sleep(2) # 取完钱还没来得及修改金额
account.balance -= money # 修改余额
print(name,"余额为:" + str(account.balance))
else:
print(name,"余额不足,取钱失败")
if __name__ == "__main__":
act = Account("lucy" , 1000) # 创建一个账户
# 模拟两个线程对同一个账户取钱
threading.Thread(target=draw , args=(act , 800)).start()
threading.Thread(target=draw , args=(act , 800)).start()
输出
Thread-1 成功取钱:800
Thread-2 成功取钱:800
Thread-2 余额为:200
Thread-1 余额为:-600
原因:程序中有两个并发线程在修改 Account 对象,而且系统恰好在time.sleep(2)执行线程切换,切换到另一个修改 Account 对象的线程,所以就出现了问题。
5.2 加锁解决问题
Python 的 threading 模块引入了锁(Lock)。
提供了如下两个方法来加锁和释放锁:
(1)acquire(blocking=True, timeout=-1)
请求对 Lock 加锁,其中 timeout 参数指定加锁多少秒。
(2)release():释放锁。
threading.Lock:它是一个基本的锁对象,每次只能锁定一次,其余的锁请求,需等待锁释放后才能获取。
Lock 是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程在开始访问共享资源之前应先请求获得 Lock 对象。当对共享资源访问完成后,程序释放对 Lock 对象的锁定。
import threading
import time
class Account:
# 定义构造器
def __init__(self, account, balance):
self.account = account # 账户
self.balance = balance # 余额
self.lock = threading.Lock() # 锁
# 取钱操作函数
def draw(self, money):
name = threading.current_thread().name # 线程名
self.lock.acquire() # 加锁
try:
# 余额>=取钱金额
if self.balance >= money:
print(name,"成功取钱:" + str(money))
time.sleep(2) # 取完钱还没来得及修改金额
self.balance -= money # 修改余额
print(name,"余额为:" + str(self.balance))
else:
print(name,"余额不足,取钱失败")
finally:
self.lock.release() # 保证锁被释放
# 定义函数模拟取钱操作
def draw_money(account, money):
account.draw(money)
if __name__ == "__main__":
act = Account("lucy", 1000) # 创建一个账户
# 模拟两个线程对同一个账户取钱
threading.Thread(target=draw_money, args=(act, 800)).start()
threading.Thread(target=draw_money, args=(act, 800)).start()
输出:
Thread-1 成功取钱:800
Thread-1 余额为:200
Thread-2 余额不足,取钱失败