本文主要在python代码的性能分析优化方面进行讨论,旨在解决一些语言层面比较常见的性能瓶颈,是在平时工做中的一些积累和总结,会比较基础和全面,顺便也会介绍一些在服务架构上的优化经验。java
python简单易学以及在数据计算分析方面优异的特色催生了庞大的用户群体和活跃社区性,使得它在数据分析、机器学习等领域有着先天的优点, 同时因为其协程特性和普遍的第三方支持,python在在线服务上也有普遍的使用。可是python在性能问题上有全部动态解释型高级语言的通病,也是制约python进一步普遍应用的重要因素。这也是这类解释型脚本语言的通病:python
单独脱离具体的业务应用场景来看性能问题是比较片面的。下面以咱们当前的后端架构来看下python性能瓶颈在业务应用上的具体表现。该系统是基于大数据和机器学习模型的风在线风控系统,它为大量金融机构提供风控服务,基于大量结构化和非结构化数据、外部数据源、超万维的特征、以及复杂的建模技术。些也致使咱们基于python的服务性能面临着严峻考验 。下图是架构简图:nginx
对代码优化的前提是须要了解性能瓶颈在什么地方,程序运行的主要时间是消耗在哪里,常见的能够在日志中打点来统计运行时间,对于比较复杂的代码也能够借助一些工具来定位,python 内置了丰富的性能分析工具,可以描述程序运行时候的性能,并提供各类统计帮助用户定位程序的性能瓶颈。常见的 profilers:cProfile,profile,line_profile,pprofile 以及 hotshot等,固然一些IDE好比pycharm中也继承了完善的profiling。这里咱们只介绍有表明性的几种性能分析方法:git
装饰器就是经过闭包来给原有函数增长新功能,python能够用装饰器这种语法糖来给函数进行耗时统计,可是这仅限于通常的同步方法,在协程中,更通常地说在生成器函数中,由于yield会释放当前线程,耗时统计执行到yield处就会中断返回,致使统计的失效。
以下是一个包含两层闭包(由于要给装饰器传参)的装饰器:github
def time_consumer(module_name='public_module'): def time_cost(func): #获取调用装饰器的函数路径 filepath =sys._getframe(1).f_code.co_filename @wraps(func) def warpper(*args,**kwargs): t1 = time.time() res = func(*args,**kwargs) t2 = time.time() content={} try: content['time_cost'] = round(float(t2-t1),3) content['method'] = func.__name__ content['file'] = filepath.split(os.sep)[-1] content['module'] = module_name content_res = json.dumps(content) time_cost_logger.info(content_res) except Exception as e: time_cost_logger.warning('%s detail: %s' % (str(e), traceback.format_exc())) return res return warpper return time_cost
cProfile自python2.5以来就是标准版Python解释器默认的性能分析器,它是一种肯定性分析器,只测量CPU时间,并不关心内存消耗和其余与内存相关联的信息。golang
ncalls:函数被调用的次数。
tottime:函数内部消耗总时间。
percall:每次调用平均消耗时间。
cumtime:消费时间的累计和。
filename:lineno(function):被分析函数所在文件名、行号、函数名。算法
一、针对单个文件的性能分析:json
python -m cProfile -s tottime test.py
二、针对某个方法的性能分析:flask
import cProfile def function(): pass if __name__ == '__main__': cProfile.run("function()")
三、项目中针对实时服务的性能分析:后端
# 通常须要绑定在服务框架的钩子函数中来实现,以下两个方法分别放在入口和出口钩子中;pstats格式化统计信息,并根据须要作排序分析处理。 def _start_profile(self): import cProfile self.pr = cProfile.Profile() self.pr.enable() def _print_profile_result(self): if not self.pr: return self.pr.disable() import pstats import StringIO s = StringIO.StringIO() stats = pstats.Stats(self.pr, stream=s).strip_dirs().sort_stats('tottime') stats.print_stats(50)
使用line_profile须要引入_kernprof__,所以咱们这里选用pprofile,虽然pprofile的效率没有line_profile高,但在作性能分析时这是能够忽略的。pprofile的用法和cprofile的用法三彻底一致。
Line:行号
Hits:该行代码执行次数
Time:总执行耗时
Time per hit:单次执行耗时
%:耗时占比
咱们在作性能分析时,能够挑选任何方便易用的方法或工具进行分析。但整体的思路是由总体到具体的。例如能够经过cprofile寻找整个代码执行过程当中的耗时较长的函数,而后再经过pprofile对这些函数进行逐行分析,最终将代码的性能瓶颈精确到行级。
Python的性能优化方式有不少,能够从不一样的角度出发考虑具体问题具体分析,但能够归结为两个思路:从服务架构和CPU效率层面,将CPU密集型向IO密集型优化。从代码执行和cpu利用率层面,要提升代码性能以及多核利用率。好比,基于此,python在线服务的优化思路能够从这几方面考虑:
经常使用操做:检索、去重、交集、并集、差集
一、在字典/集合中查找(如下代码中均省略记时部分)
dic = {str(k):1 for k in xrange(1000000)} if 'qth' in dic.keys(): pass if 'qth' in dic: pass
耗时:
0.0590000152588
0.0
二、使用集合求交集
list1=list(range(10000)) list2=[i*2 for i in list1] s1=set(list1) s2=set(list2) list3 = [] # 列表求交集 for k in list1: if k in list2: list3.append(k) # 集合求交集 s3 = s1&s2
耗时:
0.819999933243
0.001000165939
Ps:集合操做在去重、交并差集等方面性能突出:
节省内存和计算资源,不须要计算整个可迭代对象,只计算须要循环的部分。
一、使用xrange而不是range(python3中无区别)
for i in range(1000000): pass for i in xrange(1000000): pass
耗时:
0.0829999446869
0.0320000648499
二、列表推导使用生成器
dic = {str(k):1 for k in xrange(100000)} list1 = [k for k in dic.keys()] list1 = (k for k in dic.keys())
耗时:
0.0130000114441
0.00300002098083
三、复杂逻辑产生的迭代对象使用生成器函数来代替
def fib(max): n,a,b =0,0,1 list = [] while n < max: a,b =b,a+b n = n+1 list.append(b) return list # 迭代列表 for i in fib(100000): pass def fib2(max): n, a, b = 0, 0, 1 while n < max: yield b a, b = b, a + b n = n + 1 # 迭代生成器 for i in fib2(100000): pass
耗时:
0.713000059128
0.138999938965
这部分比较容易理解就再也不附上示例了。
i) 在循环中不要作和迭代对象无关的事。将无关代码提到循环上层。
ii) 使用列表解析和生成器表达式
iii) 对于and,应该把知足条件少的放在前面,对于or,把知足条件多的放在前面。
iv) 迭代器中的字符串操做:是有join不要使用+。
v) 尽可能减小嵌套循环,不要超过三层,不然考虑优化代码逻辑、优化数据格式、使用dataframe代替循环等方式。
一个NumPy数组基本上是由元数据(维数、形状、数据类型等)和实际数据构成。数据存储在一个均匀连续的内存块中,该内存在系统内存(随机存取存储器,或RAM)的一个特定地址处,被称为数据缓冲区。这是和list等纯Python结构的主要区别,list的元素在系统内存中是分散存储的。这是使NumPy数组如此高效的决定性因素。
import numpy as np def pySum(n): a=list(range(n)) b=list(range(0,5*n,5)) c=[] for i in range(len(a)): c.append(a[i]**2+b[i]**3) return c def npSum(n): a=np.arange(n) b=np.arange(0,5*n,5) c=a**2+b**3 return c a=pySum(100000) b=npSum(100000)
耗时:
0.138999891281
0.007123823012
python多进程multiprocessing的目的是为了提升多核利用率,适用于cpu密集的代码。须要注意的两点是,Pytho的普通变量不是进程安全的,考虑同步互斥时,要使用共享变量类型;协程中能够包含多进程,可是多进程中不能包含协程,由于多进程中协程会在yield处释放cpu直接返回,致使该进程没法再恢复。从另外一个角度理解,协程自己的特色也是在单进程中实现cpu调度。
一、进程通讯、共享变量
python多进程提供了基本全部的共享变量类型,经常使用的包括:共享队列、共享字典、共享列表、简单变量等,所以也提供了锁机制。具体不在这里赘述,相关模块:from multiprocessing import Process,Manager,Queue
二、分片与合并
多进程在优化cpu密集的操做时,通常须要将列表、字典等进行分片操做,在多进程里分别处理,再经过共享变量merge到一块儿,达到利用多核的目的,注意根据具体逻辑来判断是否须要加锁。这里的处理其实相似于golang中的协程并发,只是它的协程能够分配到多核,一样也须要channel来进行通讯 。
from multiprocessing import Pool p = Pool(4) # 对循环传入的参数作分片处理 for i in range(5): p.apply_async(long_time_task, args=(i,)) p.close() p.join()
Python多线程通常适用于IO密集型的代码,IO阻塞能够释放GIL锁,其余线程能够继续执行,而且线程切换代价要小于进程切换。要注意的是python中time.sleep()能够阻塞进程,但不会阻塞线程。
class ThreadObj(): executor = ThreadPoolExecutor(16) @run_on_executor def function(self): # 模拟IO操做, time.sleep不会阻塞多线程,线程会发生切换 time.sleep(5)
协程能够简单地理解为一种特殊的程序调用,特殊的是在执行过程当中,在子程序内部可中断,而后转而执行别的子程序,在适当的时候再返回来接着执行。若是熟知了python生成器,其实能够知道协程也是由生成器实现的,所以也能够将协程理解为生成器+调度策略。经过调度策略来驱动生成器的执行和调度,达到协程的目的。这里的调度策略可能有不少种,简单的例如忙轮循:while True,更简单的甚至是一个for循环。复杂的多是基于epoll的事件循环。在python2的tornado中,以及python3的asyncio中,都对协程的用法作了更好的封装,经过yield和await就可使用协程。但其基本实现仍然是这种生成器+调度策略的模式。使用协程能够在单线程内实现cpu的释放和调度,再也不须要进程或线程切换,只是函数调用的消耗。在这里咱们举一个简单的生产消费例子:
def consumer(): r = '' while True: n = yield r if not n: return print('[CONSUMER] Consuming %s...' % n) r = '200 OK' def produce(c): r=c.send(None) print r n = 0 while n<5: n = n + 1 print('[PRODUCER] Producing %s...' % n) r = c.send(n) print('[PRODUCER] Consumer return: %s' % r) c.close() c = consumer() produce(c)
CPython:是用C语言实现Pyhon,是目前应用最普遍的解释器。最新的语言特性都是在这个上面先实现,基本包含了全部第三方库支持,可是CPython有几个缺陷,一是全局锁使Python在多线程效能上表现不佳,二是CPython没法支持JIT(即时编译),致使其执行速度不及Java和Javascipt等语言。因而出现了Pypy。
Pypy:是用Python自身实现的解释器。针对CPython的缺点进行了各方面的改良,性能获得很大的提高。最重要的一点就是Pypy集成了JIT。可是,Pypy没法支持官方的C/Python API,致使没法使用例如Numpy,Scipy等重要的第三方库。这也是如今Pypy没有被普遍使用的缘由吧。
Jython:Jython是将python code在JVM上面跑和调用java code的解释器。
合理使用copy与deepcopy
使用 join 合并迭代器中的字符串
使用最佳的反序列化方式 json>cPickle>eval。
不借助中间变量交换两个变量的值(有循环引用形成内存泄露的风险)。
不局限于python内置函数,一些状况下,内置函数的性能,远远不如本身写的。好比python的strptime方法,会生成一个9位的时间元祖,常常须要根据此元祖计算时间戳,该方法性能不好。咱们彻底能够本身将时间字符串转成split成须要的时间元祖。
用生成器改写直接返回列表的复杂函数,用列表推导替代简单函数,可是列表推导不要超过两个表达式。生成器> 列表推导>map/filter。
关键代码能够依赖于高性能的扩展包,所以有时候须要牺牲一些可移植性换取性能; 敢于尝试python新版本。
考虑优化的成本,通常先从数据结构和算法上优化,改善时间/空间复杂度,好比使用集合、分治、贪心、动态规划等,最后再从架构和总体框架上考虑。
Python代码的优化也须要具体问题具体分析,不局限于以上方式,但只要可以分析出性能瓶颈,问题就解决了一半。《约束理论与企业优化》中指出:“除了瓶颈以外,任何改进都是幻觉”。
将无关代码提到循环上层
去掉冗余循环
平均耗时由2.0239s提高到0.7896s,性能提高了61%
采用多进程将无关主进程的函数放到后台执行:
将列表分片到多进程中执行:
如图,1s内返回的请求比例提高了十个百分点,性能提高200ms左右,但不建议代码中过多使用,在业务高峰时会对机器负载形成压力。
如图,模块平均耗时由123ms提高到79ms,提高35.7%,而且对一些badcase优化效果会更明显:
将复杂字典转成md5的可hash的字符串后,经过集合去重,性能提高60%以上。数据量越大,优化效果越好。
将特征计算做为分布式微服务,实现IO与计算解耦,将cpu密集型转为IO密集,在框架和服务选用方面,咱们分别测试了tornado协程、uwsgi多进程、import代码库、celery分布式计算等多种方式,在性能及可用性上tornado都有必定优点,上层nginx来代理作端口转发和负载均衡:
ab压测先后性能对比,虽然在单条请求上并无优点,可是对高并发系统来讲,并发量明显提高:
ab压测先后性能对比,虽然在单条请求上并无优点,可是对高并发系统来讲,并发量明显提高:
命中pipeline实时特征后的性能提高:
虽然python的语言特性致使它在cpu密集型的代码中性能堪忧,可是python却很适合IO密集型的网络应用,加上它优异的数据分析处理能力以及普遍的第三方支持,python在服务框架上也应用普遍。
例如Django、flask、Tornado,若是考虑性能优先,就要选择高性能的服务框架。Python的高性能服务基本都是协程和基于epoll的事件循环实现的IO多路复用框架。tornado依靠强大的ioloop事件循环和gen封装的协程,让咱们能够用yield关键字同步式地写出异步代码。
在python3.5+中,python引入原生的异步网络库asyncio,提供了原生的事件循环get_event_loop来支持协程。并用async/await对协程作了更好的封装。在tornado6.0中,ioloop已经已经实现了对asyncio事件循环的封装。除了标准库asyncio的事件循环,社区使用Cython实现了另一个事件循环uvloop。用来取代标准库。号称是性能最好的python异步IO库。以前提到python的高性能服务实现都是基于协程和事件循环,所以咱们能够尝试不一样的协程和事件循环组合,对tornado服务进行改造,实现最优的性能搭配。
篇幅缘由这里不详细展开,咱们能够简单看下在python2和python3中异步服务框架的性能表现,发如今服务端的事件循环中,python3优点明显。并且在三方库的兼容,其余异步性能库的支持上,以及在协程循环及关键字支持等语法上,仍是推荐使用python3,在更加复杂的项目中,新版的优点会显而易见。但不论新旧版本的python,协程+事件循环的效率都要比多进程或线程高的多。这里顺便贴一个python3支持协程的异步IO库,基本支持了常见的中间件:https://github.com/aio-libs?p...