
先说一句可能让很多人破防的话你用time.time()相减算出来的“耗时”大概率是不准的。Python、代码块、执行计时这三个关键词放在一起看起来是个很基础的问题但社区里大量新人写出的第一版测速代码都是start time.time()然后拿end - start做差这种写法在毫秒级片段上经常被系统校时、垃圾回收、线程调度坑得怀疑人生。所以我把 Python 里给代码块计时的常见方式完整过一遍time模块里到底该用哪个 API、官方timeit的三种正确调用姿势、上下文管理器和装饰器怎么封装成工程级工具、Jupyter 单元格里怎么一键测速、以及整段脚本该不该直接上cProfile。刚配好 VS Code 或 PyCharm 环境、准备写爬虫或量化回测脚本的初学者可以放心看写了几年代码但一直没认真研究过计时的朋友也能在这轮梳理里找到自己缺的那一块。1. 先分清三种时间再谈代码块计时1.1 墙钟时间time.time() 和它的问题time.time()返回从 Unix Epoch 到当前时刻的秒数类型是 float对应的是“墙钟时间”wall clock time也就是墙上一块普通挂钟走过的绝对时间。问题在于这个时间会受系统校时影响NTP 同步、系统管理员手动改时间、夏令时切换都可能让时间前后跳动。虽然在实际测量短代码块时这些扰动不常发生但一旦发生你算出来的耗时可能是负数也可能是离谱的大数而且很难复现。更麻烦的是如果测量区间比较长比如量化回测跑几十分钟墙钟时间的一次跳变会直接污染整段结果。Python 3.7 之后还提供了time.time_ns()返回纳秒级整数精度比time.time()高但它依然是墙钟底层还是同一套系统时间该跳变照样跳变。datetime.datetime.now()也属于这一类而且受限于datetime的微秒精度连分辨率和time.time_ns()都差一个量级。所以我的建议很明确除非你想把“当前时刻”打印成人类可读的时间戳比如写日志时记录“任务在几点几分执行”否则不要拿time.time()或者datetime.now()来给代码块测速。1.2 单调时钟time.perf_counter() 为什么是对的time.perf_counter()返回一个单调递增的时钟读数它不关心系统时间被调成了几点几分只保证“两次读数之间的差值”代表真实经过的时间间隔而且它被设计成系统里可用的最高分辨率时钟。这是官方文档和大量基准测试工具都推荐它作为默认计时时钟的根本原因。它的纳秒版time.perf_counter_ns()返回整数纳秒避免了浮点数在长时间累计上的精度损失写出来的代码也更直观。time.monotonic()同样是一个单调时钟但不同平台的底层实现精度不一致有的平台拿到的分辨率很高有的平台可能只有毫秒级表现不如perf_counter稳定。所以为了少踩坑我自己所有手写计时的默认选择都是time.perf_counter()或它的纳秒版本time.perf_counter_ns()只在极少数老代码里才用time.monotonic()。1.3 CPU 时间process_time() 和 thread_time()如果说perf_counter测量的是墙上的真实流逝time.process_time()测量的则是当前进程消耗的 CPU 时间它不包含睡眠等待、网络等待、磁盘 I/O 等待。这个 API 适合判断一段纯计算代码到底烧了多少 CPU。比如你要评估一个算法是不是真的把 CPU 吃满了用它更公平。但反过来如果你测的是爬虫请求、数据库查询这类 I/O 密集操作process_time几乎不计入等待时间你会得到“明明等了 2 秒CPU 时间却只有 0.01 秒”的反直觉结果这不是数有问题而是尺子量错对象了。time.thread_time()则更进一步只统计当前线程的 CPU 时间适合在多线程程序里分别观察每个线程的算力消耗。理解这一层之后你就能明白为什么很多性能分析文章会强调“先想清楚你要测的是墙钟时间还是 CPU 时间”因为这两套数据的背后是两套完全不同的工具和优化策略。下面这张表把几个常用 API 的差异整理出来建议直接保存API是否单调是否包含睡眠/I/O等待典型用途time.time()否是记录当前时间戳time.time_ns()否是高精度时间戳time.perf_counter()是是代码块耗时time.perf_counter_ns()是是更精确的代码块耗时time.monotonic()是是单调时长不求最高精度time.process_time()不适用否CPU 密集代码time.thread_time()不适用否线程内 CPU 时间2. time 模块三种 API 的实测与翻车现场2.1 一个最常见的翻车写法放手写的最自然写法就是time.time()相减下面这段代码在很多教程里出现过import time start time.time() result [fitem-{i} for i in range(100_000)] end time.time() print(fcost: {end - start:.6f}s)这段代码在绝大多数机器上能跑出0.005s到0.02s的结果看起来没什么问题。但你要知道time.time()是墙钟时间如果测量区间里刚好碰到一次系统校时哪怕只是毫秒级的偏移都会让结果彻底失真。更常见的干扰来自 Python 的垃圾回收列表推导式创建了大量对象GC 可能刚好在计时中间启动把结果从0.008s拉到0.15s而你完全无法解释这个波动只能怀疑电脑有问题。实际排查性能问题时最可怕的就是这种无法复现的异常。你今天跑出来8ms明天跑出来12ms换个系统环境又变成80ms你根本不知道哪个结果才是这段代码的真实性能。所以从这一刻起请把time.time()从你的性能计时代码里划掉。2.2 改用 perf_counter/perf_counter_ns 后的对比把同一段逻辑换成perf_counter之后结果会稳定很多import time start time.perf_counter() result [fitem-{i} for i in range(100_000)] elapsed time.perf_counter() - start print(fcost: {elapsed * 1000:.3f} ms)如果想让精度更稳直接用整数纳秒版本。perf_counter_ns返回的是一个整数不会像 float 一样在大数和小数之间来回换算start_ns time.perf_counter_ns() result [fitem-{i} for i in range(100_000)] elapsed_ms (time.perf_counter_ns() - start_ns) / 1e6 print(fcost: {elapsed_ms:.3f} ms)用整数版本可以避免浮点数在极短耗时上可能出现的精度损失。比如单次操作只有 0.3 微秒时float 秒和 int 纳秒之间虽然也能换算但在长脚本里持续做浮点减法、乘法、格式化累计误差和代码可读性都不如直接用整奈秒干净。做基准测试时我几乎全用perf_counter_ns只在最后输出时才转成 ms 或 s。2.3 process_time 的典型边界测试测一段纯 CPU 循环时process_time的输出和perf_counter差不多import time start time.process_time() for _ in range(10_000_000): pass elapsed time.process_time() - start print(fcpu time: {elapsed:.4f}s)但一旦遇到time.sleep两者立刻拉开差距import time start time.process_time() time.sleep(1) elapsed time.process_time() - start print(fcpu time: {elapsed:.6f}s) # 接近 0看到第二个结果你就会明白如果某个功能是“发一个请求再等响应”process_time永远测不出用户等了多久必须用perf_counter。反过来如果你想评估自己的算法在纯计算上是不是拖后腿process_time比墙钟公平得多因为它把线程切换、系统其它进程抢占带来的干扰基本隔离了。极端情况下你的代码可能因为锁竞争被挂起很久perf_counter会无辜地报出一个天文数字而process_time才能告诉你真实烧掉的 CPU 是多少。3. timeit官方钦定的稳定秒表3.1 为什么 timeit 比手写 start/end 可靠timeit是标准库里专门为测量代码执行时间设计的模块。它会把你要执行的语句打包成一个字符串或可调用对象然后在隔离的命名空间里反复执行。这个模块最让我放心的两点是第一它默认情况下会暂时关闭垃圾回收避免 GC 在测量中途启动扰动结果第二它会自动处理循环次数、计时时钟选择这些细节你只需要告诉它“测什么、跑几轮”。不过要理解它的定位timeit适合的是“同一小段代码跑 N 次算出单次性能”也就是微基准测试不适合直接拿来测一个爬虫请求的总耗时或者一个数据分析脚本的整体时间。默认参数number1000000意味着如果你不传number它可能会跑一百万次对于稍微复杂的代码块这会让测试时间变得很长。所以我的习惯是永远显式传入number用命令行时则让timeit自己决定循环次数。3.2 三种正确调用方式第一种timeit.timeit一行式适合表达式比较短的场景import timeit elapsed timeit.timeit( [x ** 2 for x in range(1000)], number10000, ) print(ftotal: {elapsed:.6f}s, per loop: {elapsed / 10000:.9f}s)注意这里返回的是 10000 次的总耗时不是单次耗时想得到单次结果需要自己除一下。第二种timeit.Timer处理多行代码块。字符串里可以放心写循环、if、中间变量赋值这是手写start/end很难优雅做到的import timeit code s 0 for i in range(10000): if i % 2 0: s i ** 2 t timeit.Timer(code) print(t.timeit(number1000))如果你想要更稳定的统计可以在Timer上调用repeat(repeat5, number1000)得到一个包含多次运行总耗时的列表再取min()。这个最小值往往比平均值更接近这段代码在理想环境下的真实性能因为它把某一次 GC 或系统中断带来的噪声踢掉了。第三种是命令行版本适合快速验证某个写法的性能完全不需要打开编辑器python -m timeit -s import re re.findall(r\\d, abc123def456)命令行输出的形式大概是200000 loops, best of 5: 2.94 usec per loop它会根据被测代码的耗时自动调整循环次数省去了手动改number的过程。3.3 globals 参数解决 NameError 的经典坑很多人第一次用timeit.timeit时都会碰上NameError: name xxx is not defined。原因很简单timeit在自己的隔离命名空间里执行你传入的字符串你的全局变量、函数它根本看不见。解决办法是把当前全局命名空间传进去def compute_ma(prices): return sum(prices) / len(prices) prices [i for i in range(1000)] elapsed timeit.timeit( compute_ma(prices), globalsglobals(), number10000, ) print(ftotal: {elapsed:.6f}s)如果你不想把代码写成字符串也可以直接传可调用对象这样就不存在命名空间问题了elapsed timeit.timeit( lambda: compute_ma(prices), number10000, )这个写法更干净尤其当你已经在一个函数内部、不想纠结字符串作用域的时候。要记住传 callable 的量测目标是“调用这个 callable 一次需要多久”所以 callable 内部逻辑就是你想要测的代码块。4. 工程化封装上下文管理器与装饰器4.1 自定义上下文管理器手写start/end每处都复制粘贴时间一长就想封装。最简单的是写一个可以配合with使用的类把计时逻辑集中到__enter__和__exit__里import time class CodeTimer: def __init__(self, nameblock): self.name name self.elapsed None def __enter__(self): self._start time.perf_counter() return self def __exit__(self, exc_type, exc_value, traceback): self.elapsed time.perf_counter() - self._start print(f[{self.name}] elapsed: {self.elapsed:.4f}s) return False用起来非常直观with CodeTimer(初始化大列表): data [i ** 2 for i in range(1_000_000)]__exit__最后返回False的意思是异常照常抛出不会被你吃掉。这个类的额外好处是self.elapsed被保留下来你可以在with块外继续读取比如写入日志或者做多次统计。如果你希望即使代码块里抛异常也一定打印耗时只要把展示逻辑放在__exit__里即可因为这正好是无论正常结束还是异常退出都会被调用的地方。4.2 用 contextmanager 写更短的版本用contextlib.contextmanager可以把上面的类压缩成一个生成器函数代码量直接减半from contextlib import contextmanager import time contextmanager def timing(descblock, clocktime.perf_counter): start clock() try: yield finally: print(f{desc} took {clock() - start:.4f}s)使用方法with timing(拉取行情): resp requests.get(https://example.com/api/quotes)这里try/finally保证了无论正常结束还是抛异常耗时都会被打印。clock参数留了一个口子你可以传入time.process_time让同一个封装既能测墙钟又能测 CPU 时间只需要在调用处换一个参数。这样封装最大的价值不在于省几行代码而在于所有计时逻辑有统一的出口日志格式也一致团队协作时不容易出现一个人用time.time()、另一个人用datetime.now()的混乱。4.3 装饰器计时如果你不是要测某个代码块而是要测“每次调用某个函数花多久”装饰器比上下文管理器更顺手import time from functools import wraps def timed(func): wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() try: return func(*args, **kwargs) finally: elapsed time.perf_counter() - start print(f{func.__name__} took {elapsed:.4f}s) return wrapper timed def backtest(params): return [param * 2 for param in params] backtest([1, 2, 3, 4])functools.wraps会把函数的__name__和__doc__保留下来避免调试工具和日志系统把它识别成一个叫wrapper的匿名包装函数。用装饰器的好处是侵入性低你可以在不修改函数体的情况下给一个复杂函数加上耗时日志后续想拿掉装饰器直接把那一行注释掉就行不影响业务逻辑。4.4 多线程和异步里容易忽略的事用上下文管理器或装饰器计时时如果程序跑多线程perf_counter测的是墙钟时间它包含了线程等待、锁竞争、其它线程运行的时间。对用户来说这通常是合理的总耗时但如果你想聚焦当前线程本身的 CPU 消耗就要换成time.thread_time()。在asyncio协程里也类似perf_counter会包含 event loop 中其它协程执行的时间如果你只想统计当前协程的纯执行时间process_time或更细的手段才能做到但多数业务场景我们关心的恰恰是包含等待在内的总耗时所以默认还是perf_counter更贴近真实体感。5. Jupyter / IPython 里的单元格级计时5.1 %time、%%time、%%timeit 怎么分在 Notebook 里写数据分析和量化回测的人几乎每天都在跟这三个魔术命令打交道。%time只测一行代码执行一次适合“我想看看这一行到底跑了多久”。%%time必须放在单元格第一行测整个单元格执行一次会输出CPU times和Wall time两行信息CPU 时间对应进程时间Wall time 对应墙钟时间。%%timeit更严格它把整个单元格当成待测代码块反复执行然后给出平均耗时和标准差。简单记一个%管一行两个%%管整个单元格后面带it就代表要反复跑多轮。举例说明先看%%time%%time total 0 for i in range(1_000_000): total i输出大概长这样CPU times: user 69.4 ms, sys: 1.2 ms, total: 70.6 ms Wall time: 72.9 ms再换成%%timeit%%timeit -n 50 -r 5 total sum(range(1_000_000))输出大概是50 loops, best of 5: 19.8 ms per loop-n 50指定每个批次跑 50 轮-r 5表示重复 5 个批次最后取 best of 5比只跑一次的%%time稳定得多。这个命令行风格和标准库timeit的 repeat 参数是完全对应的。5.2 爬虫、量化回测和数据分析场景的用法差异在爬虫脚本里%%time可以快速告诉你某个页面抓取加解析一共耗了多少秒这是包含网络等待的墙钟时间符合用户感知。在量化回测里策略循环可能跑几十次迭代用%%timeit测纯计算部分比如因子计算非常合适因为它能跑多轮取最优值屏蔽垃圾回收和线程调度的干扰。但要注意如果你的单元格里有网络请求、文件写入等副作用%%timeit会反复执行这些副作用结果既不真实也可能污染数据。这时应该用%%time或者把 I/O 部分拆出去单独测。5.3 使用上的几个细节%%time和%%timeit必须写在单元格第一行否则会被当作普通代码直接报语法错误。%%timeit默认会自动选择一个合理的循环次数但对特别短的小片段它可能会跑好几万次请耐心等它输出对特别长的重计算直接给它-n 1 -r 1关闭自动循环效果和%%time类似但日志输出更规整。还有一点容易被忽略%%timeit在 Notebook 环境里能看到当前内核的全局变量这是它比标准库timeit对新手友好的地方因为少了一个globalsglobals()的坑。6. 整段脚本的耗时分析cProfile 与 pstats6.1 先跑一遍 cProfile 看总览timeit只能告诉你“这段代码慢”不能告诉你“哪里慢”。当整个脚本跑到几十秒甚至几分钟热点可能藏在某个你根本没注意的小函数里这时候就要用cProfile。最简单的用法是不改代码直接命令行跑python -m cProfile -s cumulative demo.py输出会是一张按累积耗时排序的表格每一行是一个函数的调用次数ncalls、自身耗时tottime、单次自身耗时percall、包含子调用的累积耗时cumtime等。你第一眼应该看cumtime也就是它自己加它调用的所有子函数总共花了多少时间这能帮你找到真正的“瓶颈入口”。如果只看tottime你可能只会注意到一个被频繁调用的底层小函数却忽略了真正把时间大量花在子调用里的顶层函数。6.2 在代码里只输出热点函数如果你不想整个脚本都铺满 profile 输出只想观察某一段代码可以在代码里用Profile对象手动圈定范围import cProfile import pstats def run_strategy(): total 0 for i in range(100_000): total i ** 2 return total prof cProfile.Profile() prof.enable() result run_strategy() prof.disable() stats pstats.Stats(prof) stats.sort_stats(cumtime).print_stats(10)sort_stats(cumtime)表示按累积时间排序print_stats(10)只打印前 10 个热点避免刷屏。如果你用的是 Python 3.8 以上版本也可以直接写with cProfile.Profile() as prof:来圈定范围效果是一样的。想知道谁调用了一个函数可以用stats.print_callers()对理解调用链非常有帮助尤其在重构一个大函数时。6.3 该不该用 cProfile 代替 timeit我的看法是它们是不同层面的工具。timeit适合微基准cProfile适合宏观热点分析后者的开销比较大会拖慢程序本身所以从它那里得到的应该是相对占比而不是绝对值。什么时候该切到cProfile一句话单段代码耗时超过几百毫秒、且你已经确认不是 I/O 等待却始终找不到慢在哪那就用cProfile。如果是网络请求类的等待cProfile帮不上太大忙应该先把请求改成并发或异步再回来测优化效果。7. 选型建议与我的实操习惯7.1 一张表帮你在 10 秒内做选择场景推荐方案一句话理由毫秒级以内的纯计算语句timeit.timeit(...)自动循环、默认关 GC结果稳定不方便转成字符串的现有函数传 callable 给timeit.timeit不用碰字符串作用域一段多行代码块想测一次上下文管理器with timing(...)结构清晰还能留日志每次函数调用都要留耗时记录装饰器timed零侵入地包住整个函数爬虫、请求、数据库查询总耗时time.perf_counter()要的是真实墙钟只关心 CPU 算力的算法评估time.process_time()把 I/O 等待排除掉Notebook 单元格粗看%%time一行命令输出 CPU 和墙钟Notebook 单元格稳定反复测%%timeit多轮跑取 best of整段脚本找热点python -m cProfile -s cumulative全函数视角快速定位只测脚本内某段找热点cProfile.Profile()pstats围住目标段不打扰其它部分7.2 我自己的几条习惯第一默认计时 API 永远是time.perf_counter_ns()。它单调、高分辨率、不会因为系统校时跳变即使只是随手打印一个耗时我也用整奈秒来算只在最终显示时换算成 ms 或 s。第二做真实基准时用timeit.repeat多跑几轮取最小值不要取平均值。平均会被某一次 GC 或系统中断污染最小值反而更接近这段代码在“理想环境”下的真实耗时。第三测网络请求或文件 I/O 时别只测一次多跑几次看分布否则一次慢请求就会误导你优化错了方向。第四把上下文管理器版计时器放进你自己的工具模块里默认clocktime.perf_counter遇到需要输出 CPU 时间的场景再传time.process_time这样团队里的其他同事拿到就能直接用。最后分享一个我踩过多次的坑优化前先测基准优化后必须再测一次而且最好在相同的机器状态和相同的数据量下对比。不然你会发现“优化”了半天唯一变化的是电脑今天心情好。计时本身不是目的它只是帮你把注意力放到真正值得优化的代码块上。希望这篇梳理能让你少走一点当年我走过的弯路。