并行系统工具
概述
当一个系统运行着多个软件的时候,由于软件本身内建了很过延迟的机制,如访问硬盘,网络通信,数据库查询等,
实际上CPU常常处于空闲状态,更快的芯片只能提高峰值负荷下的处理速度,其本身的计算利用率非常有限,所以为
了尽量提高软件的运行速度,可将一个程序的任务分解成多个子任务并行运行,提高CPU的利用率。
同时在某些应用运行场景下,不能因为单个耗时的任务导致程序卡顿或停滞,这时候需要将一些耗时的任务使用单独
的进程进行处理,而其他任务和页面正常响应用户的请求。
实现并行任务运行的方法
进程分支 os.fork,
线程派生 threading
特点
优点:并行处理有利于提高程序的CPU利用率,降低I/O导致的软件运行阻塞时间,这对于I/O密集型应用效果明显。
缺点:系统资源消耗会多一些,譬如内存空间,CPU使用率,网络资源等,控制难度较大,编写较为复杂。
一、进程分支
调用分支进程产生的动作
1、创建程序和复制其在内存中的进程副本,复制了包含全局变量和模块等。
2、与源程序并行的运行下去,互补影响和干扰。
import os import time a = 10 def child(): global a a = 20 time.sleep(5) print('Hello from child', os.getpid(), a) os._exit(0) def parent(): a = 30 b = 1 while True: newpid = os.fork() if newpid == 0: child() else: print('Hello from parent', os.getpid(), newpid, a) if input() == 'q': break parent()
释义:
(1)执行os.fork()代码之后主进程会fork出进程,此时子进程和父进程会同时存在,分别拥有单独的内存空间。
(2)fork()它会返回2个值,一个值为0,表示在子进程返回;另外一个值为非0,表示在父进程中返回子进程ID。
(3)父进程的newpid为子进程ID的值,会接着执行print函数和input部分代码,并尝试捕获用户标准输入。
(4)子进程从if部分开始执行,由于newpid在子进程中为0,所以会执行child()部分,最后运行os._exit退出。
(5)上面两个过程是并行的,子进程执行child函数之后并不会阻塞父进程的print和input函数,父进程在子进程执行time.sleep时,并不影响父进程接收q输入之后退出,子进程接着运行。
(6)子进程和父进程的变量空间是完全独立的互不影响,子进程可以获取父进程的全局变量值,譬如a的值,即使a在parent函数中被变更为30,子进程获取到的a的值仍旧是10.
(7)如果child函数中没有os._exit代码的话,子进程在运行完child函数之后,会接着执行input函数,条件满足时会接着执行while True(实际情况是input运行貌似有些异常,子进程无法捕
捉input输入,进程也不退出)。
fork与exec的结合
上例中的进程分支只是在python程序中运行一个函数就退出了,当os.fork与os.exec*结合使用之后,程序会分支出
一个新的程序,而不是作为父进程的副本独立运行。
示例
import os parm = 0 while True: parm += 1 pid = os.fork() if pid == 0: os.execlp(os.execlp(('python', 'python', 'child.py', str(parm)) assert Flase, 'error starting program' else: print('Child is', pid) if input() == 'q': break
释义:
(1)os.execlp会覆盖当前正在运行的程序,意味着分支出的子进程将是一个新的进程。
(2)os.execlp调用之后没有返回对象,这意味着后面不会由代码需要执行,如果有代码被执行了就说明os.execlp调用出现了异常。
二、线程
同一时间启动其他操作(函数执行)的另一种方法,所有线程均同一进程中运行,常用于非阻塞的输入调用和GUI中长时间运行的任务。
线程的优点
性能改善
由于在同一进程中运行,不会产生启动的消耗,且不需要像分支进程那样复制进程数据。
简单易用
相比分支进程在某些方面的使用上要简单易用,如运行退出,数据通信,僵尸进程等。
共享全局内存
由于线程本身共享一个全局作用域变量,所以在线程之间会与主程序之间提供了最简单的通信方案,但需要注意同步化访问的问题。
可移植性
比进程拥有更良好的可移植性,譬如进程模型中的os.fork在windows下无法运行使用。
线程的短板
函数调用和程序
线程并非启动新程序的方法(至少不是直接的),用来并行调用函数(可执行对象)。
线程同步化和队列
在利用进程中的全局变量时,如果出现并行更新操作需要做同步化访问处理,否则会导致异常。
全局解释器锁
由于GIL的存在同一时间只有一个线程在运行,不能在多个CPU之间进行分配。
_thread模块
示例
import _thread def child(tid): print('Hello from thread', tid) def parent(): i = 0 while True: i += 1 _thread.start_new_thread(child, (i,)) #接收一个可调用对象和一个元组参数 if input() == 'q': break parent()
(1)传入线程中的可调用对象可以是函数,类绑定方法等。
(2)被传入的可调用对象引用的是原始的实例对象。
多线程运行
当使用多线程的时候,一般情况下主线程会在退出之前终止子线程的运行,也可以通过join方法等待子线程执行完毕。
多线程运行并都对标准输出进行操作时,会导致输出乱序问题。
同步访问共享对象和名称
由于线程共享主线程的全局作用域,故像标准流,管道等这些对象都属于共享对象,当对这些对象进行并行访问的时候需要使用锁对象,保证访问的互斥性,条件是出现并行变更操作,
如多个线程对一个变量进行修改(原处赋值的不算)。
使用with语句控制锁对象的获取与释放。
with作用在锁对象的时候,执行with里面的语句无论失败或成功都会释放锁。
注意
(1)python一些内置数据结构是线程安全的吗?,如list等。
在可变对象中会存在线程安全的问题,是否出现线程安全问题得看具体的操作是否具有原子性,当需要对该对象进行读取处理和写入的话,这时需要进行互斥访问控制,
原处赋值这种原子操作就不用。
等待派生线程的退出
可使用锁对象或list数据结构来捕捉线程的退出情况。
threading模块
编码线程的3中方式
threading.Tread(target=(lambda: action(2))) #lamdba函数
threading.Tread(target=action, args=(2,)).start() #普通函数,不保留一定的参数状态
threading.start_new_thread(target=action, (2,)) #所有函数通用的调用方法
queue模块
提供标准的队列数据结构(FIFO),可以包含任意类型的python对象(字符串,列表,类,函数等),队列对象自动有线程锁获取和释放操作控制。
关于全局解释器锁的说明
GIL确保了同一时间点只有一个线程在运行,这是为了避免多个线程同时更新python系统数据可能导致的一些问题,但这仅是在底层做了一些同步化访问的情况,并没有涉及较高层面的同步化问题。
线程切换周期
可由sys.setcheckinterval(N)设置字节码指令的数量,以此调试线程的性能。
原子操作
GIL同步化线程对虚拟机访问的方式,list.append、变量、列表成员,字典键等属性的复制和提取是原子操作,一般需要读取、修改和写回的操作则不是。
程序退出
sys模块退出
使用sys.exit()触发SystemExit异常,和raise语句显示地抛出该异常效果一样。
os模块退出
一般在分支进程中使用,该异常不可被捕捉,不输出流缓存和运行清理处理器。
shell命令退出状态代码
当进程退出时,当前的shell环境的变量会保存该进程退出的状态,$status。
输出缓冲流
由于操作系统普遍采用流缓存,所以在使用os.popen或subprocess.popen时可以传入模式和缓存参数以指定行缓存,但只是针对当前主线程缓存,同时当作用于管道的时候,
对输出端也是无效的。
进程的退出状态和共享状态
分支进程通过os.wait调用返回子进程退出状态os._exit(x),但这个状态信息是无法在父进程中反应出来。
线程退出和共享状态
线程一般不利用其退出状态,而是在模块水平的全局对象赋值或在原位修改共享的可变对象以发送信号。
不要在线程函数里调用os._exit(),这样容易导致整个进程的终止。
三、进程
进程间的通信一般需要依赖更底层面的环境或平行的第三方环境来实现,如
(1)文件,普通文件可实现数据双向传输。
(2)命令行参数,为程序提供输入。
(3)程序退出状态码,获取程序的输出信息。
(4)shell环境变量,为程序提供运行环境,程序也能通过模块获取到当前的shell环境信息。如sys.argv。
(5)标准流重定向,程序的默认流配置,均使用标准流,可通过重定向改变流的使用。
(6)os.popen和subprocess管理的流管道,模块的一些功能。
进程通信的一些工具
(1)信号,允许向其他程序发送简单的事件通知
(2)匿名管道,允许共享文件描述符的线程及相关进程传递数据
(3)命名管道,该管道映射到系统的文件系统
(4)套接字映射到系统级别的端口
匿名管道
作为程序之间的通信手段,有操作系统实现,是单向的管道,仅在进程内部使用,通过共享管道描述符而使用。
示例
import os,time def child(pipeout): zzz = 0 while True: time.sleep(zzz) msg = ('Spam %03d' % zzz).encode() os.write(pipeout, msg) zzz = (zzz+1) % 5 def parent(): pipein, pipeout = os.pipe() if os.fork() == 0: child(pipeout) else: while True: line = os.read(pipein, 32) print('Parent %d got [%s] at %s' % (os.getpid(), line, time.sleep())) parent()
(1)管道文件在父进程中被提前创建
(2)管道数据为空的情况下会阻塞程序的调用
(3)管道对象为字节对象
(4)使用管道的时候,未使用管道的一端应关闭。
使用匿名管道进行双向的IPC
由于管道是单向的,所以要实现双向通信的话,可使用两个匿名管道实现,将父进程的输入端对应到子程序的输出端,子程序的输入端对应到父进程的输出端即可,注意在进行
数据操作时,调用flush函数来冲洗缓冲流。
这其中涉及标准流的输入和输出使用。
输出流缓冲:死锁和flush
行缓冲和全缓冲,行缓冲由于输入提供了\n,所以能够实现。
使用一下几种方式解决由流缓存引起的死锁问题
(1)使用sys.stdout.flush进行缓冲清洗
(2)执行python时使用-u进行全局控制
(3)open模式下指定缓冲模式的参数
(4)命令管道,有相关的参数控制
(5)套接字,接收缓冲模式参数
(6)工具
命名管道
FIFO与计算机上的真实文件相映射,且操作系统会自动同步化FIFO的访问,以达到IPC的目的,局限于本机使用。
初始套接字
python中的套接字由socket模块实现,是更为泛化的IPC工具。
与FIFO的比较
(1)都是机器水平,满足进程之间或线程之间的通信需求。
(2)套接字基于端口号识别,非文件系统路径。
(3)套机制使用发送和接收而非读写调用来传输字节字符串。
(4)套接字主要用于网络进程之间通信。
套接字可借助于pickle传输任何python对象
信号
python提供signal模块向进程发送信号,程序可将信号登记成事件。
使用
signal.signal
接收信号编号和函数对象
signal.pause
使进程休眠,知道捕捉下一个信号
os.kill
发送信号
四、multiprocessing模块
该模块旨在获取进程和线程的优点,允许像threading模块那样派生进程,兼容性移植性强,且能发挥出进程并行处理的优势。
可以将该模块视为python使用多进程和相关的通信方法的整体解决方案,它提供了消息传递和共享内存工具的任务。
基本操作:进程和锁
进程之间的同步化访问管道,管道支持将对象序列化(pickle)之后放入队列中。
multiprocessing模块的IPC工具
pipe对象
提供了一个可连接两个进程的匿名管道,可接收/发送python对象。
value,Array对象
实现了共享进程/线程安全的内存以用于进程间通信。
Queue对象
可用于python对象的一个先进先出的列表,其实现逻辑为在一个管道加上用来协调随机访问的锁机制。
队列和子类
允许模块的process类创建子类,并提供架构和状态保留。
提供进程安全的Queue对象。
局限性
在windows下,利用lambda调用添加数据中中常见的模式不被支持。
子进程不被标记为daemon,那么会阻塞shell窗口,也就是阻塞系统的shell进程。
重点
(1)面向对象的封装性原则,对象的数据只能通过对象自身的公共接口来访问。
(2)进程分支,线程模块的特点。
(3)IPC工具的用法和特点。
(4)multiprocess的用法,集大成者。