1. Python为什么慢?
编程语言的效率:
(1) 开发效率(程序员完成编码的时间);
(2) 运行效率(计算机完成计算任务的时间)。
1.1 Python是动态语言
动态语言是指程序运行时可以根据某些条件改变自身结构,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化。 运行时结构不可变的语言就是静态语言。
在程序执行时,解释器并不知道变量的类型,只知道该变量是某种Python对象。因此解释器必须检查每个变量的PyObject_HEAD才能知道变量类型,然后执行对应的数据操作,最后要创建一个新的Python对象来保存返回值。
(1) 计算 $a+b$ 的 C++ 命令
int a = 1
int b = 2
int c = a + b
编译器始终知道a和b是整型,在执行相加运算时,流程如下:
(a) 首先把1赋值给a,把2赋值给b;
(b) 然后调用 binary_add(a,b);
(c) 最后把结果赋值给c。
(2) 实现同样功能的Python命令如下:
a = 1
b = 2
c = a + b
编译器始终不知道 a 和 b 的数据类型,在执行相加运算时,流程如下:
(a) 首先把1赋值给a。
- 设置a->PyObject_HEAD->typecode为整型;
- 设置a->val = 1。
(b) 接着把2赋值给b。
(c) 然后调用binary_add(a, b)。
- a->PyObject_HEAD获取类型编码,a为整型;值为a->val。
- 同理b。
- 调用binary_add(a->val,b->val),结果为整型并存在result中。
(c) 最后创建对象c。
- 设c->PyObject_HEAD->typecode为整型。
- 设置c->val为result。
动态类型意味着任何操作都会涉及更多的步骤,每一个简单的操作都需要大量的指令才能完成。这也是Python等动态语言对数值操作比C语言慢的主要原因。
1.2 Python中一切都是对象
Python的对象模型会导致内存效率较低。
最简单的NumPy数组是根据C语言的数据结构创建的Python对象,它有一个指向连续数据缓存区的指针。而Python的list虽然具有指向连续的指针缓冲区的指针,但是每一个指针都指向一个整数类型的Python对象。如上图所示,如果正在执行按顺序逐步完成数据的操作,numpy的内存布局比Python的内存布局更为高效,因为存储成本和访问的时间成本都更低。
1.3 Python全局解释器锁
全局解释器锁(Global Interpreter Lock, GIL)并不是Python的特性,它是在实现Python解析器(CPython)时所引入的概念,Python完全可以不依赖于GIL。每个线程在执行的过程都需要先获取GIL,保证同一时刻只有一个线程对共享资源进行存取,使得CPython中的多线程并不能真正的并发。
首先了解一下并发和并行的概念:什么是并发什么是并行,他们的区别是什么?
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。你吃饭吃到一半,电话来了,你停了下来接了电话,接完后电话以后继续吃饭,这说明你支持并发。你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发:交替处理多个任务的能力。并发的关键是你有处理多个任务的能力,不一定要同时。
并行:同时处理多个任务的能力。并行的关键是你有同时处理多个任务的能力,强调的是同时。
所以它们最大的区别就是:是否是同时处理任务。对于一个多核cpu来说并行显然要比并发快的多,使用多线程和多进程写程序的目的是为了让多核cup发挥最大的功效实现并行处理。
为了更有效的利用多核处理器的性能,多线程编码方式应运而生。解决多线程之间数据完整性和状态同步困难问题的最简单方法自然就是加锁。全局解释器锁GIL来控制线程的执行,每一个时刻只允许一个线程执行。Python中并没有实现线程调度,其多线程调度完全依赖于操作系统。所以python多线程编程中没有线程优先级等概念。
尽量使用线程进行并发I/O操作,在进程中进行并行计算。
(1)在处理像科学计算等需要持续使用cpu的任务时,单线程会比多线程快;
(2)在处理像IO操作等可能引起阻塞的任务时,多线程会比单线程快;
import time
from threading import Thread
from multiprocessing import Process
from concurrent import futures
# CPU密集型程序
def func(number):
while(number>0):
number -= 1
print(number)
def multi_thread(number_thread, function, params):
thread_set = {}
for i in range(number_thread):
t = Thread(target=function, args=(params,))
t.start()
thread_set[i] = t
for j in range(number_thread):
thread_set[j].join()
def multi_process(number_process, function, params):
process_set = {}
for i in range(number_process):
p = Process(target=function, args=(params,))
p.start()
process_set[i] = p
for j in range(number_process):
process_set[j].join()
def thread_pool(number_works, function, params):
with futures.ThreadPoolExecutor(number_works) as executor:
executor.map(function, params)
# with futures.ProcessPoolExecutor(number_works) as executor:
# executor.map(function, params)
if __name__ == "__main__":
number = 10000000
number_thread = 2
number_process = 2
number_work = 2
time_start_1 = time.time()
func(number)
time_1 = time.time() - time_start_1
multi_thread(number_thread, func, number)
time_2 = time.time() - time_start_1
multi_process(number_process, func, number)
time_3 = time.time() - time_start_1 - time_start_2
multi_process(number_process, func, number)
time_4 = time.time() - time_start_1 - time_start_2 - time_start_3
print('Time of func_1 is {0:.4f} seconds'.format(time_1))
print('Time of func_2 is {0:.4f} seconds'.format(time_2))
print('Time of func_3 is {0:.4f} seconds'.format(time_3))
print('Time of func_4 is {0:.4f} seconds'.format(time_4))
2. Python性能分析方法
虽然运行速度慢是 Python 与生俱来的特点,大多数时候我们用 Python 就意味着放弃对性能的追求。很多时候,我们将自己的代码运行缓慢地原因
归结于python本来就很慢,从而心安理得地放弃深入探究。但是,事实真的是这样吗?面对python代码,你有分析下面这些问题吗:
程序运行的速度如何?
程序运行时间的瓶颈在哪里?
能否稍加改进以提高运行速度呢?
为了更好了解python程序,我们需要一套工具和方法,方便彻底了解代码,能够记录代码的运行时间,生成性能分析报告,从而对代码进行针对性的优化。
2.1 什么是性能分析
性能分析就是分析代码和它正在使用的资源之间有着怎样的关系。例如,性能分析可以告诉你一个指令占用了多少CPU时间,或者整个程序消耗了多少内存。
性能分析是通过使用一种被称为性能分析器(profiler)的工具,对程序或者二进制可执行文件的源代码进行调整来完成的。
性能分析软件有两类方法论:基于事件的性能分析(event-based profiling)和统计式性能分析(statistical profiling)。
基于事件的性能分析器(也称为轨迹性能分析器,tracing profiler)是通过收集程序执行过程中的具体事件进行工作的。性能分析器会产生大量的数据,导致其不太实用,在开始对程序进行性能分析时也不是首选。但是,当其他性能分析方法不够用或者不够精确时,它们可以作为最后的选择。
统计式性能分析器以固定的时间间隔对程序计数器(program counter)进行抽样统计,这样做可以让开发者掌握目标程序在每个函数上消耗的时间。由于它对程序计数器进行抽样,所以数据结果是对真实值的统计近似,不仅能够分析程序的性能细节,查出性能的瓶颈所在,而且分析的数据更少,
对性能造成的影响更小。
性能分析并不是每个程序都要做的事情,因为其需要花费时间,而且只有在程序中发现了错误的时候才有用。但是,在执行程序之前进行性能分析,可以捕获潜在的bug,为后续的程序调试节省时间。
2.2 性能分析的内容
程序的西能分析可以归纳为四个基本问题:
(1)它运行的有多块?
(2)哪里是速度的瓶颈?
(3)它使用了多少内存?
(4)哪里发生了内存泄漏?
2.2.1 运行时间
2.2.1.1 使用 profile 进行时间分析
对代码优化的前提是需要了解性能瓶颈在什么地方,程序运行的主要时间是消耗在哪里,对于比较复杂的代码可以借助一些工具来定位,python内置了丰富的性能分析工具,如profile,cProfile与hotshot等。其中Profiler是python自带的一组程序,能够描述程序运行时候的性能,并提供各种统计帮助用户定位程序的性能瓶颈。使用非常简单,只需要在使用之前进行 import 即可。
def profile_test():
value_list = []
total = 1
for i in range(10):
total = total * (i + 1)
value_list.append(total)
return value_list
if __name__ == "__main__":
import cProfile
cProfile.run('profile_test()', 'profile_test.txt')
import pstats
p = pstats.Stats('profile_test.txt')
p.sort_stats('time').print_stats()
其中输出每列的具体解释如下:
ncalls:表示函数调用的次数;
tottime:表示指定函数的总的运行时间,除掉函数中调用子函数的运行时间;
percall:(第一个 percall)等于tottime/ncalls;
cumtime:表示该函数及其所有子函数的调用运行的时间,即函数开始调用到返回的时间;
percall:(第二个 percall)即函数运行一次的平均时间,等于 cumtime/ncalls;
filename:lineno(function):每个函数调用的具体信息;
如果需要将输出以日志的形式保存,只需要在调用的时候加入另外一个参数。如 profile.run(“profileTest()”,”testprof”),调用 pstats 模块即可读取日志。
2.2.1.2 使用 line_profile 进行时间分析
开源工具line_profiler可以统计脚本中每行代码的运行时间和执行次数。通过pip安装该python包:
$ pip install line_profiler
安装完成之后得到名为 line_profiler
的新模组和 kernprof.py
可执行脚本。使用时不需要导入任何模组,只需要在源代码中被测量的函数上装饰@profile装饰器。kernprof.py脚本将会在执行的时候将模组自动注入到运行脚本中。
@profile
def primes(n):
if n == 2:
return [2]
elif n < 2:
return []
s = list(range(3, n + 1, 2))
m_root = n ** 0.5
half = (n + 1) / 2 - 1
i = 0
m = 3
while m <= m_root:
if s[i]:
j = int((m * m - 3) / 2)
s[j] = 0
while j < half:
s[j] = 0
j += m
i = i + 1
m = 2 * i + 3
return [2] + [x for x in s if x]
$ kernprof -l -v primes.py
-l
选项通知 kernprof 注入 @profile 装饰器到执行脚本,-v
选项通知kernprof在脚本执行完毕的时候显示统计信息。输出每列的含义如下:
Line: 行号
Hits: 当前行执行的次数
Time: 当前行执行耗费的时间
Per Hit: 平均执行一次耗费的时间
%Time: 当前行执行时间占总时间的比例
Line Contents: 当前行的代码
具有高Hits值或高Time值的行就是可以通过优化带来最大性能改善的地方。
2.2.2 内存资源
除了运行时间之外,程序所消耗的内存资源也是性能分析需要考虑的问题。内存消耗不仅仅是关注程序使用了多少内存,还应该考虑控制程序使用内存的数量。跟踪程序内存的消耗情况比较简单。最基本的方法就是使用操作系统的任务管理器。它会显示很多信息,包括程序占用的内存数量或者占用总内存的百分比。任务管理器也是检查CPU时间使用情况的好工具。
2.2.2.1 使用 memory_profile 进行内存分析
开源工具memory_profiler可以统计脚本所占用的内存以及每行代码所增加的占用内存。通过pip安装该python包:
$ pip install memory_profiler
安装完成之后得到名为 memory_profiler
的新模组和 memory_profiler.py
可执行脚本。
安装psutil包:pip install psutil
,因为它可以大大改善 memory_profiler
的性能使用方法和 line_profiler
类似,只需要在感兴趣的函数上面添加@profile装饰器:
@profile
def primes(n):
pass
2.2.2.2 使用 objgraph 分析内存泄漏
cPython解释器使用引用计数做为记录内存使用的主要方法。这意味着每个对象都包含一个计数器,当某处对该对象的引用被存储时计数器增加,当引用被删除时计数器递减。当计数器到达零时,cPython解释器认为该对象不再被使用,就会删除对象,释放所占用的内存。如果程序中不再
被使用的对象的引用一直被占有,那么就可能会发生内存泄漏。
开源工具objgraph可以有效查找“内存泄漏”,它允许查看内存中对象的数量,定位含有该对象的引用的所有代码的位置。
a. 显示占据python程序内存的头N个对象
b. 显示一段时间以后哪些对象被删除,哪些对象增加了
c. 显示脚本中某个给定对象的所有引用
$ pip install objgraph
import objgraph
if __name__ == "__main__":
x = ['a', '1', [2, 3]]
objgraph.show_refs([x], filename='test.png')
objgraph.show_most_common_types()
3. 实用优化技巧
3.1 编码规范
3.1.1 了解代码优化的基本原则
优先保证代码是可以工作的
过早优化是编程中一切“罪恶”的根源,过早优化可能会忽视对总体性能指标的把握,忽略可移植性、可读性等权衡优化的代价(质量、时间和成本)
优化是有代价的,想解决所有性能问题几乎是不可能的定义性能指标,集中力量解决首要问题
在进行优化之前,针对问题进行主次排列,集中力量解决主要问题不要忽略可读性
实际应用中,经常运行的代码可能只占很少部分,但是几乎所有代码都需要维护,因此优化时需要考虑可读性和可维护性。
3.1.2 编写函数的四个原则
- 函数设计要尽量短小,嵌套层次不宜过深
- 函数申明应该做到合理、简单、易于使用
- 函数参数设计应该考虑向下兼容
- 一个函数制作一件事,尽量保证函数语句粒度的一致性
3.1.3 在代码中适当添加注释
- 使用块注释或者行注释的时候只注释复杂的操作和算法
- 注释和代码隔开一定的距离
- 给外部可访问的函数和方法添加文档注释
- 在文件开头包含版权申明、模块描述和变更记录等信息
3.2 语法技巧
3.2.1 数据交换值不推荐使用中间变量
Python表达式赋值的时候右边操作数先于左边的进行计算,首先创建元组(y, x),x和y初始化已在内存中,然后通过解压缩将元组依次分配给左边的标识符。
from timeit import Timer
time_1 = Timer('temp=x; x=y; y=temp', 'x=2; y=3').timeit()
time_2 = Timer('x, y = y, x', 'x=2; y=3').timeit()
print((time_1-time_2)/time_1)
3.2.2 充分利用 Lazy Evaluation 的特性
延迟计算仅仅在真正需要执行的时候才计算表达式的值
(a) 避免不必要的计算,带来性能上的提升
import time
def method(word_list, word_set):
time_start = time.time()
for i in range(1000000):
for word in word_list:
if word in word_set:
pass
time_1 = time.time() - time_start
for i in range(1000000):
for word in word_list:
if word[-1] == '.' and word in word_set:
pass
time_2 = time.time() - time_start - time_1
return (time_1 - time_2) / time_1
if __name__ == "__main__":
word_set = ['aa.', 'bb.', 'cc.', 'dd.', 'ee.', 'ff.', 'gg.', 'hh.', 'ii.', 'jj.']
word_list = ['aa', 'bb.', 'cc', 'dd.', 'ee', 'ff.', 'gg']
print(method(word_list, word_set))
(b) 节省空间,使得无限循环成为可能
from itertools import islice
# 斐波那契数列
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a+b
if __name__ == "__main__":
print(list(islice(fibonacci(), 5)))
3.2.3 有节制地使用 from…import 语句
- 尽量优先使用
import a
形式,使用 a.B 访问模块中的对象 - 有节制地使用
from a import B
形式,直接访问 B - 避免使用
from a import *
,会污染命名空间,无法清晰地显示导入对象
3.2.4 使用 with 自动关闭资源
对文件的操作完成后应该立即关闭文件
f = open('test.txt', 'w')
f.write('test')
with open('test.txt', 'w') as f:
f.write('test')
3.2.5 连接字符串应该优先使用 join 而不是 +
python字符串为不可变对象,使用 +
连接字符串时会复制原有的字符串,从而直接导致链接效率降低。
def func_string(string, string_list):
new_string = string
time_start = time.time()
for i in range(10000):
for sub_string in string_list:
new_string += sub_string
time_1 = time.time() - time_start
for i in range(10000):
new_string += ''.join(string_list)
time_2 = time.time() - time_start - time_1
return ((time_1-time_2)/time_1)
if __name__ == "__main__":
string = ""
string_list = ['aa.', 'bb.', 'cc.', 'dd.', 'ee.', 'ff.', 'gg.', 'hh.', 'ii.', 'jj.']
print(func_string(string, string_list))
操作符'+'连接字符串示意图
3.2.6 格式化字符串时尽量使用 .format 而不是 %
格式化字符串四指根据所规定的转换说明符返回格式化后的字符串
- format 方式参数的顺序与格式化的顺序不必完全相同
- format 方式可以方便地作为参数传递
- % 最终会被 .format 方式所取代
# 在 Pycharm 控制台中执行 string_1 = 'xxx' string_2 = 'yyy' %timeit -n 10000 ('abc%s%s' % (string_1, string_2)) %timeit -n 10000 ('abc{0}{1}'.format(string_1, string_2))
3.3 库
3.3.1 使用 copy 模块拷贝对象
浅拷贝:构造一个新的符合对象并将从原对象中发现的引用插入该对象中。
深拷贝:构造一个新的符合对象,但是遇到引用会继续递归拷贝其所指向的具体内容。
# 在 Pycharm 控制台中执行
import copy
a = range(100000)
%timeit -n 100 copy.copy(a)
%timeit -n 100 copy.deepcopy(a)
对象copy示意图
3.3.2 使用 Counter 进行计数统计
Counter 类属于字典类的子类,是一个容器对象,主要用来统计散列对象,支持集合操作 + - & |
import time
from collections import Counter
def count_frequency(data_list):
time_start = time.time()
for i in range(10000):
count_dict = dict()
for item in data_list:
if item in count_dict:
count_dict[item] += 1
else:
count_dict[item] = 1
time_dict = time.time() - time_start
for i in range(10000):
count_set = set(data_list)
count_list = []
for item in count_set:
count_list.append((item, data_list.count(item)))
time_set = time.time() - time_start - time_dict
for i in range(10000):
Counter(data_list)
time_counter = time.time() - time_start - time_dict - time_set
print('time_dict: {dict:.6}\ntime_set: {set:.6}\ntime_counter: {counter:.6}'.format(
dict=time_dict, set=time_set, counter=time_counter))
if __name__ == '__main__':
data_list = ['a', '2', 2, 4, 5, '2', 'b', 4, 7, 'a', 5, 'd', 'a', 'z']
count_frequency(data_list)
3.3.3 使用 argparse 处理命令行参数
相比于参数配置文件,命令行参数更加灵活,用户的学习成本更低。Python标准库中有 getopt、optparse 和 argparse 三个模块实现命令行参数。
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('-o', '--output')
parser.add_argument('-v', dest='verbose', action='store_true')
args = parser.parse_args()
3.3.4 使用 pandas 处理大型 CSV 文件
csv模块无法处理大型 CSV 文件,而 pandas 提供了丰富的数据模型,支持多种文件格式处理,包括 CSV、HDF5 和 HTML 等,能够提供高效的大数据处理能力
import csv
f = open('large.csv', 'w')
f.seek(2**30 - 1)
f.write("\0")
f.close()
with open('large.csv', 'r') as csv_file:
my_csv = csv.reader(csv_file)
for row in my_csv:
pass
上述代码会报错,因为csv无力处理大数据,使用pandas处理则不会报错。
import pandas as pd
df = pd.read_csv('large.csv')
3.3.5 使用 ElementTree 解析 XML 文件
xml.dom.minidom
和 xml.sax
作为解析 XML 文件的两种实现,DOM 需要将整个XML文件加载到内存中解析,占用内存多,性能不占优势; SAX 不需要全部载入 XML 文件,但处理过程较为复杂。
import xml.etree.ElementTree as ET
tree = ET.ElementTree(file='test.xml')
root = tree.getroot()
print(root.tag)
3.3.6 使用 pickle 和 JSON 模块进行序列化
序列化是指把内存中的数据结构在不丢失其身份和类型信息的情况下转换成对象文本或二进制表示的过程。Python中的序列化模块包括 pickle、json、marshal 和 shelve 等。
pickle 是最通用的序列化模块
a. 接口简单,通过 dump() 和 load() 即可轻易实现序列化和反序列化
b. pickle 存储格式可以跨平台通用
c. 支持的数据类型广泛相比于 pickle 模块,JSON 具有如下优势:
a. 文档构成简单,仅存在键值对集合和值的有序列表两种数据结构
b. 存储格式可读性更为友好,容易修改
c. 支持跨平台,同时也可被其他语言解析
d. 用户可以对默认不支持的序列化类型进行扩展
Python 中标准模块 JSON 的性能弱于 pickle 模块
# 在 Pycharm 控制台中执行
import json
import pickle
s_pickle = pickle.dump(range(10000))
s_json = json.dump(list(range(10000)))
%timeit -n 100 x=pickle.load(s_pickle)
%timeit -n 100 x=json.loads(s_json)
3.4 循环和数据结构
3.4.1 掌握循环优化的基本技巧
- 尽量减少循环过程中的计算量,多重循环时尽量将内层计算提到上一层
```python
import math
import time
def func_1(iter, number):
sum = 0
for i in range(iter):
d = math.sqrt(number)
sum = i + d
return sum
def func_2(iter, number):
sum = 0
d = math.sqrt(number)
for i in range(iter):
sum = i + d
return sum
if name == “main“:
iter = 100000
number = 100
time_start = time.time()
func_1(iter, number)
time_1 = time.time() - time_start()
func_2(iter, number)
time_2 = time.time() - time_start - time_1
print((time_1 - time_2) / time_1)
2. 将显示循环改为隐式循环,需要添加恰当的注释保持代码的可读性
```python
import math
import time
def func_1(number):
sum = 0
for i in range(number+1):
sum += i
return sum
def func_2(number):
sum = number * (number+1) / 2
return sum
if __name__ == "__main__":
iter = 1000000
time_start = time.time()
func_1(number)
time_1 = time.time() - time_start()
func_2(number)
time_2 = time.time() - time_start - time_1
print((time_1 - time_2) / time_1)
- 在循环中尽量引用局部变量,命名空间中搜索局部变量比全局变量更快
```python
import math
import time
def func_1(number, list):
for i in range(number+1):
[math.sin(k) for k in list]
def func_2(number, list):
local_sin = math.sin
for i in range(number+1):
[local.sin(k) for k in list]
if name == “main“:
iter = 10000
data_list = range(100)
time_start = time.time()
func_1(number, data_list)
time_1 = time.time() - time_start()
func_2(number, data_list)
time_2 = time.time() - time_start - time_1
print((time_1 - time_2) / time_1)
### 3.4.2 选择合适的数据结构
<div style="text-align:center">
<img src="coding-effective-python/1_10.png" alt="数据结构时间复杂度" width="600" height="300">
<p style="color:green">Python数据结构常见操作的时间复杂度</p>
</div>
1. 将列表的交集、并集或者差集等问题转换为集合再运算
```python
import time
def func_1(list_1, list_2):
intersection = []
for i in range(100000):
for a in list_1:
for b in list_2:
if a == b:
intersection.append(a)
return intersection
def func_2(list_1, list_2):
intersection = []
for i in range(100000):
intersection = list(set(list_1) & set(list_2))
return intersectiion
if __name__ == "__main__":
list_1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 34, 53, 42, 44]
list_2 = [2, 4, 6, 9, 23]
time_start = time.time()
func_1(list_1, list_2)
time_1 = time.time() - time_start()
func_2(list_1, list_2)
time_2 = time.time() - time_start - time_1
print((time_1 - time_2) / time_1)
- 使用字典或者集合进行查找
```python在 Pycharm 控制台中执行
data_list = range(10000)
data_set = set(data_list)
data_dict = dict((i, 1) for i in data_list)
%timeit -n 10000 100 in data_list
%timeit -n 10000 100 in data_set
%timeit -n 10000 100 in data_dict
### 3.4.4 使用列表解析和生成器表达式
列表解析比在列表循环更高效,将列表解析式中 [] 替换成 () 即为生成器表达式
```python
# 列表循环
def func_1(list):
new_list = []
for i in range(100000):
for w in list:
new_list.append(w)
# 列表解析
def dunc_2(list):
for i in range(100000):
new_list = [w for w in list]
# 生成器表达式
def func_3(list):
for i in range(100000):
new_list = (w for w in list)
if __name__ == "__main__":
data_list = range(100)
time_start = time.time()
func_1(data_list)
time_1 = time.time() - time_start()
func_2(data_list)
time_2 = time.time() - time_start - time_1
func_3(data_list)
time_3 = time.time() - time_start - time_2 - time_1
print('func_1: {0:.4f}, func_2: {0:.4f}, func_3: {0:.4f}'.format(
time_1, time_2, time_3))
3.4.5 使用多进程或者线程池克服 GIL 的缺陷
因为GIL的存在,Python很难充分利用多核CPU的优势。但是,可以通过内置的模块multiprocessing实现下面几种并行模式:
- 多进程:对于CPU密集型的程序,可以使用multiprocessing的Process,Pool等封装好的类,通过多进程的方式实现并行计算。但是因为进程中的通信成本比较大,对于进程之间需要大量数据交互的程序效率未必有大的提高。
- 多线程:对于IO密集型的程序,multiprocessing.dummy模块使用multiprocessing的接口封装threading,使得多线程编程也变得非常轻松(比如可以使用Pool的map接口,简洁高效)。
- 线程池和进程池
- 分布式:multiprocessing中的Managers类提供了可以在不同进程之共享数据的方式,可以在此基础上开发出分布式的程序。
不同的业务场景可以选择其中的一种或几种的组合实现程序性能的优化。
import time
from threading import Thread
from multiprocessing import Process
from concurrent import futures
# CPU密集型程序
def func(number):
while(number>0):
number -= 1
print(number)
def multi_thread(number_thread, function, params):
thread_set = {}
for i in range(number_thread):
t = Thread(target=function, args=(params,))
t.start()
thread_set[i] = t
for j in range(number_thread):
thread_set[j].join()
def multi_process(number_process, function, params):
process_set = {}
for i in range(number_process):
p = Process(target=function, args=(params,))
p.start()
process_set[i] = p
for j in range(number_process):
process_set[j].join()
def thread_pool(number_works, function, params):
# with futures.ThreadPoolExecutor(number_works) as executor:
# executor.map(function, params)
with futures.ProcessPoolExecutor(number_works) as executor:
executor.map(function, params)
if __name__ == '__main__':
number = 10000000
number_thread = 2
number_process = 2
multi_work = 2
time_start_1 = time.time()
func(number)
time_1 = time.time() - time_start_1
time_start_2 = time.time()
multi_thread(number_thread, func, number)
time_2 = time.time() - time_start_2
time_start_3 = time.time()
multi_process(number_process, func, number)
time_3 = time.time() - time_start_3
time_start_4 = time.time()
multi_process(number_process, func, number)
time_4 = time.time() - time_start_4
print('Time of func_1 is {0:.4f} seconds'.format(time_1))
print('Time of func_2 is {0:.4f} seconds'.format(time_2))
print('Time of func_3 is {0:.4f} seconds'.format(time_3))
print('Time of func_4 is {0:.4f} seconds'.format(time_4))
3.5 使用C扩展
Ctypes
对于关键的性能代码,Python本身也提供了一个API来调用C方法,主要通过 ctypes来实现,因为默认情况下python提供了预编译的标准c库。通常用于封装(wrap)C程序,让纯Python程序调用动态链接库(Windows中的dll或Unix中的so文件)中的函数。如果想要在python中使用已经有C类库,使用ctypes是很好的选择。Cython
Cython 是python的一个超集,用于简化编写C扩展的过程,允许通过调用C函数以及声明变量来提高性能。它将类Python代码编译成可以在python文件中调用的C库,后缀使用.pyx替代。 Cython的优点是语法简洁,可以很好地兼容numpy等包含大量C扩展的库。Cython的使得场景一般是针对项目中某个算法或过程的优化。PyPy
PyPy是用RPython(CPython的子集)实现的Python,根据官网的基准测试数据,它比CPython实现的Python要快6倍以上。快的原因是使用了Just-in-Time(JIT)编译器,即动态编译器,与静态编译器(如gcc,javac等)不同,它是利用程序运行的过程的数据进行优化。它的运行方式是立即可用的,因此没有疯狂的bash或者运行脚本,只需下载然后运行即可。
Python不同实现的性能比较
import time
import random
from ctypes import cdll
def func_1(num):
while num:
yield random.randint(1, 10)
num -= 1
def func_2(num):
libc = cdll.msvcrt #windows
while num:
yield libc.rand(1, 10)
num -= 1
if __name__ == "__main__":
numbers = 1000000
func_list = [func_1, func_2]
for func_name in func_list:
t_start = time.clock()
new_list = sum(func_name(numbers))
t_end = time.clock()
print("函数 %s 耗费时间 %.4f 秒" % (
str(func_name).split(' ')[1], (t_end-t_start)))
4. 总结
- Python程序相对较慢是由其语言自身的特性所决定。
- 利用Python的语言特性及其优化库中提供的功能可以对程序进行优化
- 程序80%的运算往往在20%的代码中,有针对性地优化该部分代码可以大大提高程序运行效率,例如将关键代码通过Cython或Numba等项目转换成C程序。
思考
- 优化你最贵的资源
- 把事情做完比快速地做事更加重要
- 任何除了瓶颈之外的改进都是错觉。
过早优化是万恶之源。
只是因为“快速”而选择语言是过早优化的最终形式。
- 选择一种语言/框架/架构来帮助你快速开发。不要仅仅因为它们的运行效率高。
- 当遇到性能问题时,请找到瓶颈所在。
- 你的瓶颈很可能不是 CPU 或者 Python 本身。
- 如果 Python 成为你的瓶颈,尝试优化你的算法,或者转向热门的 Cython 或者 C语言。
- 尽情享受可以快速做完事情的乐趣。