Python 性能提升、多线程相关的最佳实践。如:
gevent:通过 greenlet 实现协程,轻松进行并发同步或异步编程threading:【标准库】基于线程的并行multiprocessing:【标准库】基于进程的并行asyncio:【标准库】异步 IO。new in python 3.4。objgraph:查看内存中对象的数量,定位含有该对象的引用的所有代码的位置。subprocess:调用 shell 命令的神器,创建一个子进程让其执行另外的程序,并与之通信
概述
- 优先考虑数据结构与算法,合适的数据结构与算法能极大地提升性能。
基本概念
系统性能指标
并发数:系统能同时处理的请求/事务数量响应时间:平均处理一个请求/事务所需的时长QPS/TPS:每秒能处理请求/事务数量=并发数/响应时间
线程数
最佳线程数目 = ((线程等待时间 + 线程 CPU 时间)/线程 CPU 时间 )* CPU 数目
- CPU 密集型:操作内存处理的业务,一般线程数设置为:CPU 核数 + 1 或者 CPU 核数*2。核数为 4 的话,一般设置 5 或 8
- IO 密集型:文件操作,网络操作,数据库操作,一般线程设置为:cpu 核数 / (1-0.9),核数为 4 的话,一般设置 40
线程进程协程
进程:实际的运作单位,操作系统资源分配的最小单位,具有独立的内存空间,进程间数据不共享,创建的开销大。可以利用多核。
进程:一个运行的代码就是进程程序:没有运行的代码
- 如微信:整体是一个进程(但其实可能多进程),里面包含对各种资源的调用、内存管理、网络通讯等。
- 多进程:Multiprocessing
线程:操作系统 CPU 调度执行的最小单位,依靠进程存在,是进程中的实际运作单位,一个进程至少一个线程,即主线程,多个线程共享内存(数据共享,共享全局变量),不能使用多核。
同一个进程中的多个线程之间可以并发执行,每个线程并行执行不同的任务
- 单线程: Single Threaded
- 多线程:Multi Threaded
- 线程是一种对于非顺序依赖的多个任务进行解耦的技术。
协程:Coroutine 用户态的轻量级线程,调度由用户控制。
- 具有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下 文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
选择(不成文的准则):
- 多进程:CPU 计算密集的操作:如矩阵计算、解压缩、加密解密
- 多线程:IO 密集的操作:IO 操作不占 CPU,如保存图像、文件处理、网络爬虫、数据库访问等。
- 由于 GIL,对 CPU 密集的操作,由于计算工作多,多线程会触发 GIL 的竞争,消耗资源且没有太大速度提升,甚至可能由于切换任务过多导致减速。
多线程与多进程区别
- 多线程可共享全局变量,共享内存空间,多进程不能,内存是独立的
- 多线程中,所有子线程的进程中相同,多进程的不同
- 同一进程的两线程间可直接交流;两进程通信需要一个中间代理
- 创建新线程简单,创建新进程需要对父进程进行一次克隆
并发并行
并发:Concurrent:Python 由于 GIL 限制单进程只能并发,**基于单核 CPU 的多线程和异步 IO(多任务)同一时间内只能处理一件事件(但是它们有自己独特的机制来加快处理不同事件的能力),这个叫做并发。**包括多线程、异步 IO(多任务)、多进程
并行:Parallesim:Erlang, Golang 是天生支持并行的,只有调用多核 CPU 的多进程 (Multiprocessing) 是用来处理在物理上同时发生的任务的
- 当你吃饭的时候突然有人给你打电话,如果此时你:
- 不接听电话,继续吃饭,等把饭吃完过后再来回电话,这个叫做同步。
- 接听电话后放下筷子停止进食,等通话完毕后再接着吃,这个叫做并发。(宏观上在一段时间内同时运行多个程序)
- 接听电话的同时继续进食,这个叫做并行。(在同一时刻能运行多个指令)
- 并行需要硬件支持,如多流水线、多核处理器或者分布式计算系统。
同步异步
同步:Synchronous:指事务完成的逻辑,顺序执行:先执行第一个,如果阻塞了,会一直等待,直到该事务完成才会执行下一个
异步:Asynchronous:不等待该事务的处理结果,直接处理第二个事务,能过状态、通知、回调来通知调用者处理结果。
gevent
gevent:通过 greenlet 实现协程,轻松进行并发同步或异步编程
- 参考文档:官方文档
- 基于协程的 Python 网络库,通过使用 greenlets 在 libev 上提供了一个高级的同步 API,将 Python 同步代码变成异步协程的库
- 高并发:在同样的时间内执行更多有效的逻辑,减少无用的等待。
- 以微线程 greenlet 为核心
- epoll 事件监听机制
- 每当 greenlet 遇到 IO 操作时,就自动切换到其他 greenlet,等 IO 操作完成,再在适当的时候切换回来继续执行。
- monkey 机制:不修改原来的 Python 代码,在文件开头打一个 patch,它会自动替换原来的 thread、socket、time, multiprocess 等代码,全部变为 gevent 框架。
1 | import gevent |
threading
threading:【标准库】基于线程的并行
- 单进程下的多线程:
- 默认启动的线程看作主线程,主线程可启动新的线程。
- threading 模块具有线程锁、事件、条件变量、信号量等来控制线程对数据资源的操作,但使用不当还是可能产生问题:
- 常用:将对资源的请求集中到一个线程,使用 queue 模块来向该线程应用进行请求,以队列形式进行线程间通信与协调,易设计、易读、可靠
1 | # 返回当前线程的实例,主线程为 MainThread、若未用 LoopThread 命名子线程时,Python 会自动给线程命名为 Thread-1、Thread-2 |
- GIL
- 由于 GIL 锁的存在,任何 Python 线程在执行前需先获得 GIL 锁,每执行 100 条字节码,解释器就自动释放 GIL 锁,让别的线程有机会执行,即给所有线程的执行代码都上了锁,那么多线程在 Python 中只能交替执行,100 个线程跑在 100 核 CPU 上,也只能用到 1 个核。
- 而别的语言如 C、C++、Java,可以把全部核心跑满,即 N 核的 CPU,N 个死循环会把它占满。
- GIL 是 Python 解释器设计的历史遗留问题,通常我们用的解释器是官方实现的 CPython,要真正利用多核,除非重写一个不带 GIL 的解释器。
- SO,Python 中,可以使用多线程,不要指望用多线程有效利用多核,若一定要通过多线程利用多核,只能写 C 扩展 或 转多进程(多个 Python 进程各自有独立的 GIL 锁,互不影响)
- 由于 GIL 锁的存在,任何 Python 线程在执行前需先获得 GIL 锁,每执行 100 条字节码,解释器就自动释放 GIL 锁,让别的线程有机会执行,即给所有线程的执行代码都上了锁,那么多线程在 Python 中只能交替执行,100 个线程跑在 100 核 CPU 上,也只能用到 1 个核。
基本方法
- 启动一个线程就是把一个函数传入并创建
Thread实例,然后调用start()开始执行 setDaemon(True):将子线程变成了主线程的守护线程:当主线程结束后,守护线程会继续执行,只是主线程对其完成信息不再关心。join:
1 | import multiprocessing |
调用
直接调用
1 | import threading |
继承式调用
1 | import threading |
线程池
- 相比于 threading 等模块,该模块通过 submit 返回一个 future 对象
- 主线程可获取某一个线程的状态、返回值
- 当一个线程完成时,主线程能立即知道
- 多线程与多进程的编码接口一致
1 | # 模块,提供了ThreadPoolExecutor与ProcessPoolExecutor |
Lock
- 多线程较为复杂,容易发生冲突,需要用锁来加以隔离,同时避免死锁的发生
- 多个线程同时执行 lock.acquire() 时,只有一个线程能成功获取锁,然后继续执行代码,其他线程需等待直到获得锁为止。
- 锁:
- 好处:确保某段关键代码只由一个线路程从头到尾执行
- 坏处:
- 该段代码只能以单线程运行
- 多个锁时,可能造成死锁,多个线程全部挂起,既不能执行,也无法结束,只能由操作系统强制终止。
1 | threading.Lock() |
ThreadLocal
- 多线程程序,尽量使用局部变量,全局变量的使用必须加锁
- ThreadLocal 变量虽然是全局变量,但每个线程只能读写自己线程的独立副本,解决了参数在一个线程中各个函数之间互相传递的问题。
- 即用一个参数名,在不同的线程中是不同的数据。
1 | import threading |
1 | Hello, Alice (in Thread-A) |
获取线程结果
1 | ```python |
队列
1 | Queue.Queue(maxsize=0) #FIFO, 用来定义队列的长度,如果maxsize小于1就表示队列长度无限, |
multiprocessing
multiprocessing:【标准库】基于进程的并行
- 关于多进程的进程数量,推荐
multiprocessing.cpu_count() * 2 + 1 - 多线程与多进程
- GIL 一定程度地限制了 Threading 多线程,而目前电脑大多数是多核处理器,Multiprocessing 多进程能让电脑更有效率地分配任务给每一个处理器。解决了多线程的弊端,并有效提高效率。
- Process:可分布到多台机器上,相比 Thread 更稳定
- Thread:最多分布到同一台机器,多个 CPU 上
- managers 支持将多进程分布到多台机器上,一个服务进程可以作为调度者,将任务分布到其他多个进程中,依靠网络通信。
- 实现了进程间数据的共享,即可多个进程修改同一份数据
- 封装良好,避免了网络通信细节,容易使用
- 实现分布式计算
multiprocessing.shared_memory:可从进程直接访问的共享内存- 进程间数据传递:Queue、Pipes
- Python 的
multiprocessing模块包装了底层的机制,提供了Queue、Pipes等多种方式来交换数据。 - 在 Unix/Linux 下,
multiprocessing模块封装了fork()调用,使我们不需要关注fork()的细节。由于 Windows 没有fork调用,因此,multiprocessing需要“模拟”出fork的效果,父进程所有 Python 对象都必须通过 pickle 序列化再传到子进程去,所有,如果multiprocessing在 Windows 下调用失败了,要先考虑是不是 pickle 失败了。
- Python 的
- Lock 进程锁,主要用在输出到屏幕的时候独占屏幕,即多份数据不会出现打印一半就去打印别的数据
基本使用
1 | import multiprocessing as mp |
1 | import multiprocessing |
Process
1 | from multiprocessing import Process # 进程类 |
Pool
- 若要启动大量子进程,常采用进程池的方式
对Pool对象调用join()方法会等待所有子进程执行完毕,调用join()之前必须先调用close(),调用close()之后就不能继续添加新的Process了。 - 默认大小为 4,4 个进程,
p = Pool(5)时,就可跑 5 个进程。
Manager
- 用于获取子进程的结果:多进程共享全局变量
- 支持类型:list, dict, Namespace, Lock, RLock, Semaphore, BoundedSemaphore, Condition, Event, Queue, Value 和 Array
- 进程通信:(进程之间传递数据) 用进程队列 (
multiprocessing.Queue(),单向通信),管道 (multiprocessing.Pipe(),双向通信)。
1 | 如果要共享全局变量需要(multiprocessing.Value("d",10.0),数值)(multiprocessing.Array("i",[1,2,3,4,5]),数组)(multiprocessing.Manager().dict(),字典)(multiprocessing.Manager().list(range(5)))。 |
Queue
- 多进
celery
celery:异步库
asyncio
asyncio:【标准库】异步 IO。new in python 3.4。
- 由于 GIL,Python 在多核操作饱受诟病,而对于 IO 密集型,python 的异步处理提升成百上千倍的效率,弥补了 python 性能方面的短板。如最新的微服务框架 japronto,resquests per second 可达百万级。
- event_loop:事件循环,将需要执行的协程放到 EventLoop 中执行
- coroutine 协程:这里指使用 async 定义的函数,调用时不会立即执行函数,而是返回协程对象给 event_loop 调用。
- task 任务:对协程的封装,包含了任务的各种状态。
- future:代表将来执行或没有执行的任务的结果,与 task 一样
- Pending
- Running
- Done
- Cacelled
1 | import asyncio |
aiohttp
aiohttp:并发爬虫,可以看作异步的 requests
objgraph
objgraph:查看内存中对象的数量,定位含有该对象的引用的所有代码的位置。
- 查找内存泄露:由于 Python 的引用计数机制,如果程序中不再被使用的对象的引用一直被占有,就会产生内存泄漏。
1 | import objgraph |
subprocess
subprocess:允许你生成新的进程,连接它们的输入、输出、错误管道,并且获取它们的返回三。
- 调用 shell 命令的神器,创建一个子进程让其执行另外的程序,并与之通信
- 避免使用
os.system,因为它容易受到命令注入攻击 - 不要再用 os.system、os.spawn 了,subprocess 就是为打造一个统一模块来进行进程创建的
- 提供进程创建相关函数的所有功能
- 跨进程异常优化:子进程的异常会在父进程中再次抛出,以便检测子进程执行情况
- 提供用于在 fork 和 exec 之间执行自定义代码的钩子
- 没有隐式调用/bin/sh,这意味着不需要对危险的 shell meta characters 进行转义;
- 支持文件描述符重定向的所有组合;
- 使用 subprocess 模块,可以控制在执行新程序之前是否应关闭所有打开的文件描述符;
- 支持连接多个子进程 ;
- 支持 universal newline;
- 支持 communication() 方法,它使发送 stdin 数据以及读取 stdout 和 stderr 数据变得容易,而没有死锁的风险;
- run 与 Popen
- run 函数在启动子进程后,该函数会一直阻塞,直到命令行执行完毕
Popen启动子进程之后,父进程并不需要阻塞等待子进程结束,而是可以去做别的任务
- Popen 类
1 | import subprocess |
Popen:执行命令并返回进程对象call:执行命令check_output:获取命令输出
Popen
- Popen 对象创建后,主程序不会自动等待子进程完成。我们必须调用对象的 wait() 方法,父进程才会等待 (也就是阻塞 block)
1 | class subprocess.Popen(args, |
- 方法
- args, 调用该进程的参数,同
subprocess.run(args,***)中的 args;
returncode,当值为 0 时,代表子进程执行成功;负值 -N 指示进程被 signal N 所终止 (POSIX only); None 代表未终止;
stdout,stderr ,代表子进程的标准输出和标准错误;
check_returncode(), check 子进程是否执行成功,若执行失败将抛出异常;
1 | import subprocess |
属性
args:shell 命令,可以是字符串或者序列类型(如:list,元组)
bufsize:缓冲区大小。当创建标准流的管道对象时使用,默认 -1。
0:不使用缓冲区
1:表示行缓冲,仅当 universal_newlines=True 时可用,也就是文本模式
正数:表示缓冲区大小
负数:表示使用系统默认的缓冲区大小。stdin, stdout, stderr:分别表示程序的标准输入、输出、错误句柄
preexec_fn:只在 Unix 平台下有效,用于指定一个可执行对象(callable object),它将在子进程运行之前被调用
shell:如果该参数为 True,将通过操作系统的 shell 执行指定的命令。
cwd:用于设置子进程的当前目录。
env:用于指定子进程的环境变量。如果 env = None,子进程的环境变量将从父进程中继承。
方法
poll(): 检查进程是否终止,如果终止返回 returncode,否则返回 None。wait(timeout): 等待子进程终止。communicate(input,timeout): 和子进程交互,发送和读取数据。send_signal(singnal): 发送信号到子进程 。terminate(): 停止子进程,也就是发送 SIGTERM 信号到子进程。kill(): 杀死子进程。发送 SIGKILL 信号到子进程。相当于 kill -9 pid
1 | import time |
Popen
1 | import subprocess |
异常处理
- SubprocessError:基类
- TimeoutExpired:等待子进程超时时抛出
- CalledProcessError:
check_call()或 check_output() 返回非零状态值时抛出
- 属性:
- cmd , 该子进程的命令
- output , 子进程所 capture 的标准输出 (如调用 run() 或则 check_output()),否则为 None
- stdout, output 的别名
- stderr, 子进程所 capture 的标准错误 (如调用 run()) ,否则为 None
- TimeoutExpired 还包括 timeout,指示所设置的 timeout 的值;CalledProcessError 则还包括属性 returncode;
