引言
第七章走到这里,前八篇已经把文件操作的全部基础装进了你的工具箱:open 的模式与关闭(第 1 篇)、读取与写入 API(第 2 篇)、encoding 与文本/二进制模式(第 3 篇)、pathlib 路径(第 4 篇)、IOError 家族的防御式读写(第 5 篇)、os 模块的目录与环境变量(第 6 篇)、CSV 与 JSON 结构化文件(第 7 篇)、配置文件的读取与校验(第 8 篇)。语法层面你还从第四章第 8 篇掌握了 try/except/finally 的完整流程。本篇是第七章的第 9 篇(实战篇),把这些知识拧成一条真正的生产线:日志文件的增量读取与摘要统计。
要解决的问题很具体:生产服务(Web 服务器、任务队列、定时脚本)会不停地往同一个日志文件里追加内容,文件一天能长几十 MB 甚至几 GB。每次做统计分析都从头把整个文件读一遍,代价随文件增长而增长;而监控程序关心的往往只是”上次检查之后新出现的那些行”。Unix 世界用 tail -f 命令解决这个问题(f 是 follow,跟随),服务端语言则普遍提供”按偏移量读取追加内容”的 API。本篇将用 Python 亲手实现这个能力:用 seek/tell 记录读取位置并只读新增行(增量读取,incremental reading),配合第五章的正则对增量做状态码与错误行摘要,用 time.sleep 循环轮询(polling)并正确处理 Ctrl+C 中断,最后讨论日志轮转(log rotation)对断点机制的冲击与对策。所有代码都在本机 CPython 3.12.10(Windows)实际运行过,输出逐字真实,且每个代码块自包含——日志文件在块内自己创建,复制即跑。
概念与原理
为什么必须增量读取:全量重读的代价
先算一笔账。假设每批新日志约 100 行、每行约 100 字节(约 10 KB),程序每 10 秒轮询一次,一天 8640 次。若每次都从头读整个文件,第 n 次轮询单次要读走当时文件的全量内容:到当天最后一次轮询时,单次读取已达约 86 MB;全天累计读取量是轮询次数与文件增长的叠加,约 373 GB(每次轮询的文件大小从 10 KB 线性涨到 86 MB,累加即 8640 次等差求和)。而增量读取每次只读那一批新增的约 10 KB,一天累计约 86 MB——只比日志本身的总量多一点文件句柄开销。两者相差约 4300 倍,即三个半数量级。这就是”从上次位置继续读”成为监控类程序标配的根本原因。量级直觉:任何”每次轮询都重读全量”的方案,其累计成本与文件总大小成正比,最终会拖垮程序本身。
文件指针:读写位置的本质
第 1 篇讲过,open() 返回一个文件对象,其底层是一个指向操作系统文件描述符(file descriptor)的句柄。要理解增量读取,必须补充一个概念:文件对象内部维护着一个位置游标(position cursor,即文件指针,file pointer)。每次读取会从游标当前位置取出数据,并把游标向后推进;写入则把游标移到末尾并落盘。两个方法控制这个游标:
tell()返回游标当前位置,单位是字节(在文本模式下它是一个”只应回传给它自己的 seek”的不透明整数,但实测中它通常就等于字节偏移,见下);seek(offset)把游标移动到一个绝对字节位置。
增量读取的全部机巧就是一句话:读完一批后记下 tell() 的位置,下次 open 后 seek 到该位置,读到的就正好是新增部分。位置可以存在内存里,也可以落盘(断点续读,后面练习会做)。
一个必须警惕的细节:文本模式下字节数与”行数”没有固定换算关系。本机(Windows)默认换行翻译把写入的 \n 变成 \r\n 两个字节落盘;一行 29 个字符的日志实际占 31 字节。所以永远用 tell() 取位置、把它原样交给 seek(),不要自己心算字节数——下面会实测展示手算偏移一个字节的灾难后果。
轮询模型与 Ctrl+C 的真实行为
“每隔一段时间检查一次有没有新内容”叫轮询(polling)。轮询的优点是实现简单、与日志生产方完全解耦;代价是两件事:其一,存在最长一个间隔的延迟(1 秒间隔意味着最多晚 1 秒发现新日志);其二,忙循环会空耗 CPU,所以必须用 time.sleep() 让出处理器——sleep 期间进程几乎不占 CPU,这是它被写进循环的根本原因。操作系统层面还存在事件驱动方案(如 Linux 的 inotify、Windows 的 ReadDirectoryChangesW),文件一变化就收到通知,但没有延迟也没有轮询开销;作为代价,它们与平台绑定、要维护目录监视句柄。先掌握轮询这一课,事件驱动是它的进阶替代。
轮询程序通常是常驻进程,用户按 Ctrl+C 终止它时,真实发生的事是:操作系统向进程发送 SIGINT 信号(中断信号),Python 解释器把信号翻译成 KeyboardInterrupt 异常,在解释器当前正在执行的语句处抛出。第四章第 8 篇的异常层次图里它直接继承 BaseException 而不是 Exception——所以 except Exception 根本接不住它,要捕获必须写 except KeyboardInterrupt(或裸 except BaseException,不推荐)。不处理它时,解释器打印一个普通 traceback 并退出进程——很多人以为按 Ctrl+C 会”立刻死掉”,其实它走的就是异常传播这条标准路径,只是没人接住。本篇会实测展示这两种情形。
日志轮转:断点机制的天敌
真实环境的日志不会无限膨胀:运维工具 logrotate(Linux 上几乎是标配)会定期把当前日志改名存档(rename),再让程序新建一个空日志继续写;或者用 copytruncate 模式复制内容后原地截断。无论哪种,对增量读取者都是坏消息:你记下的断点位置来自旧文件,而新文件要么不存在该偏移要么远小于它。轮转后的第一件事必须是校验断点是否仍然有效,无效就重置回 0。本篇最后一个实验专门演示这个坑与修复。
操作与实现
第 1 步:最小的 seek/tell 增量读取
先看机制本身。下面代码写入两行日志,第一轮读到末尾并用 tell() 记录位置;随后模拟服务进程追加两行;第二轮 seek 到记录位置,只读出新增的两行:
# 步骤 1:写入前两行日志
with open('demo.log', 'w', encoding='utf-8') as f:
f.write('2026-09-02 10:00:01 INFO boot\n')
f.write('2026-09-02 10:00:02 INFO ready\n')
# 步骤 2:第一轮——从头读到末尾,并用 tell() 记下位置
with open('demo.log', encoding='utf-8') as f:
print('第一轮读到:', [line.rstrip('\n') for line in f])
position = f.tell()
print('第一轮结束时的字节位置:', position)
# 步骤 3:模拟服务进程写入第二批两行
with open('demo.log', 'a', encoding='utf-8') as f:
f.write('2026-09-02 10:00:05 INFO task-1 done\n')
f.write('2026-09-02 10:00:06 INFO task-1 ok\n')
# 步骤 4:第二轮——seek 到上次位置,只读出新增的两行
with open('demo.log', encoding='utf-8') as f:
f.seek(position)
print('第二轮读到(增量):', [line.rstrip('\n') for line in f])
运行结果:
第一轮读到: ['2026-09-02 10:00:01 INFO boot', '2026-09-02 10:00:02 INFO ready']
第一轮结束时的字节位置: 63
第二轮读到(增量): ['2026-09-02 10:00:05 INFO task-1 done', '2026-09-02 10:00:06 INFO task-1 ok']
两个数字可以互相印证:第一行 2026-09-02 10:00:01 INFO boot 是 29 个字符,第二行 30 个字符,Windows 文本模式写入时每行行尾 \r\n 各占 1 字节,29 + 2 + 30 + 2 = 63——tell() 返回的 63 恰好就是下一行日志的起始字节。第二轮 seek(63) 之后读到的内容与新增的两行逐字一致,说明批量读取的边界把握得刚刚好。这个”读写—记录—续读”的闭环就是所有增量读取器的内核。
第 2 步:封装成函数,模拟三批追加
把读日志的动作封装成函数,位置作为参数传入传出(内存断点)。为了模拟真实场景,我们在每次读取之间特意”追加下一批”,共三批,第三批引入一条 ERROR:
import time
path = 'service.log'
# 预置第一批日志(程序启动时服务自己写下的前两行)
with open(path, 'w', encoding='utf-8') as f:
f.write('2026-09-02 10:00:01 INFO service started\n')
f.write('2026-09-02 10:00:02 INFO config loaded\n')
def read_new_lines(path, position):
"""返回 (新增行列表, 新位置);position 为 0 表示从头读。"""
with open(path, encoding='utf-8') as f:
f.seek(position)
lines = [line.rstrip('\n') for line in f]
return lines, f.tell()
position = 0
for batch_no in range(3):
lines, position = read_new_lines(path, position)
print(f'第 {batch_no + 1} 批:新增 {len(lines)} 行,已推进到字节位置 {position}')
for line in lines:
print(' ', line)
# 模拟“服务端”在两次读取之间又写入了下一批日志
if batch_no == 0:
with open(path, 'a', encoding='utf-8') as f:
f.write('2026-09-02 10:00:05 INFO task A done\n')
elif batch_no == 1:
with open(path, 'a', encoding='utf-8') as f:
f.write('2026-09-02 10:00:09 ERROR task B failed\n')
time.sleep(0.3)
运行结果:
第 1 批:新增 2 行,已推进到字节位置 82
2026-09-02 10:00:01 INFO service started
2026-09-02 10:00:02 INFO config loaded
第 2 批:新增 1 行,已推进到字节位置 120
2026-09-02 10:00:05 INFO task A done
第 3 批:新增 1 行,已推进到字节位置 161
2026-09-02 10:00:09 ERROR task B failed
注意第 2、3 批的输出里没有重复第一批的内容:每一轮都恰好只拿到上一轮之后追加的行,位置从 82 一路推进到 120、161。read_new_lines 的逻辑只有四步:open → seek 到断点 → 逐行读到 EOF → tell 记新断点。函数返回新位置而不是内部更新全局变量,是为了让调用方掌握状态(是内存断点还是落盘断点,由调用方决定),这也是工程里”状态外置”的常见姿势。
第 3 步:增量 + 正则摘要:状态码与错误行统计
只读出新行还不够,分析才是目的。日志行格式定为「时间 级别 方法+路径 状态码 耗时ms」,用第五章的正则(compile + match + 分组)逐行提取状态码与级别,维护两个跨批次累计的计数器。第二批故意混入一行坏日志,验证坏行防御(第五章日志解析器的老规矩:坏行跳过、全量结果不受影响):
import re
# 时间 级别 方法+路径 状态码 耗时ms
PAT = re.compile(r'^(\S+ \S+) (\w+) (\S+ \S+) (\d{3}) (\d+)ms$')
with open('api.log', 'w', encoding='utf-8') as f:
f.write('2026-09-02 11:20:01 INFO GET /health 200 5ms\n')
f.write('2026-09-02 11:21:14 INFO GET /api/v1/users 200 42ms\n')
f.write('2026-09-02 11:22:03 ERROR GET /api/v1/stats 500 213ms\n')
f.write('2026-09-02 11:23:40 INFO POST /api/v1/login 200 87ms\n')
def read_new_lines(path, position):
with open(path, encoding='utf-8') as f:
f.seek(position)
lines = [line.rstrip('\n') for line in f]
return lines, f.tell()
status_count, error_count, pos = {}, 0, 0
for batch_no in (1, 2):
lines, pos = read_new_lines('api.log', pos)
for line in lines:
m = PAT.match(line)
if m is None:
print(f'第 {batch_no} 批:跳过坏行 -> {line}')
continue
status = m.group(4)
status_count[status] = status_count.get(status, 0) + 1
if m.group(2) == 'ERROR':
error_count += 1
print(f'第 {batch_no} 批:新增 {len(lines)} 行(有效 {sum(1 for l in lines if PAT.match(l))} 行)')
if batch_no == 1:
with open('api.log', 'a', encoding='utf-8') as f:
f.write('2026-09-02 11:24:05 ERROR POST /api/v1/orders 500 340ms\n')
f.write('2026-09-02 11:25:12 INFO GET /api/v1/timeline 304 8ms\n')
f.write('半行日志,会被跳过\n')
print('最终状态码分布:', dict(sorted(status_count.items())))
print('累计 ERROR 行数:', error_count)
运行结果:
第 1 批:新增 4 行(有效 4 行)
第 2 批:跳过坏行 -> 半行日志,会被跳过
第 2 批:新增 3 行(有效 2 行)
最终状态码分布: {'200': 3, '304': 1, '500': 2}
累计 ERROR 行数: 2
与第五章的完整解析器相比,这里只有一处结构差异:统计对象是”增量行”而不是整份日志。计数器的生命周期独立于批次——status_count 与 error_count 定义在循环外面,跨批次累计;而每次只处理 read_new_lines 返回的新行,统计成本与新增量成正比。读报表:200 出现 3 次、500 出现 2 次(第 1 批的 /api/v1/stats 与第 2 批的 /api/v1/orders),两条 ERROR 全部来自 500——状态码分布与错误行数互相印证。这就是”增量读取 + 正则摘要”组合的完整形态,报警程序(连续 N 个 5xx 触发告警)只需把它接到这组数字上。
第 4 步:循环轮询与 time.sleep
把第 2 步的循环升级成”持续轮询”:用一个没有显式结束条件的循环反复调用 read_new_lines,每轮之间 time.sleep(1) 让出 CPU。演示版限定 5 轮以便观察输出;真实常驻程序中,这个上限应删掉。可以留意到第二轮没有新日志时返回空列表——“没有新行”本身也是一种有效结果:
import time
def read_new_lines(path, position):
with open(path, encoding='utf-8') as f:
f.seek(position)
lines = [line.rstrip('\n') for line in f]
return lines, f.tell()
path = 'watch.log'
with open(path, 'w', encoding='utf-8') as f:
f.write('2026-09-02 08:00:00 INFO watcher started\n')
position = 0
try:
for round_no in range(1, 6): # 为演示限定 5 轮;真实场景没有上限
time.sleep(1) # 轮询间隔:1 秒
lines, position = read_new_lines(path, position)
if lines:
print(f'第 {round_no} 轮:新日志 {len(lines)} 行 ->', ' | '.join(lines))
else:
print(f'第 {round_no} 轮:无新日志')
if round_no == 2: # 第 2 轮结束后,模拟服务端写入两条
with open(path, 'a', encoding='utf-8') as f:
f.write('2026-09-02 08:00:03 INFO task finished\n')
f.write('2026-09-02 08:00:04 WARN timeout retry\n')
except KeyboardInterrupt:
print('捕获 KeyboardInterrupt:用户按了 Ctrl+C,执行清理后优雅退出')
运行结果:
第 1 轮:新日志 1 行 -> 2026-09-02 08:00:00 INFO watcher started
第 2 轮:无新日志
第 3 轮:新日志 2 行 -> 2026-09-02 08:00:03 INFO task finished | 2026-09-02 08:00:04 WARN timeout retry
第 4 轮:无新日志
第 5 轮:无新日志
第 2 轮的”无新日志”正是轮询的常态——绝大多数轮次没有新内容;服务端在第 2 轮结束后写入的两行,于第 3 轮被完整读到。帧与帧之间 time.sleep(1) 的空隙,就是程序把 CPU 还给系统的时刻。
第 5 步:Ctrl+C 的真实行为与优雅退出
先把第 4 步的 try/except 拆掉,让中断无人接住。为让演示可无人值守地复现”按键”效果,本机用 signal.raise_signal(signal.SIGINT) 程序化地向自身进程发送 SIGINT——这是标准库 signal 模块的真实 API,也是测试信号处理程序的惯用手法;真实场景中同样的信号来自用户键盘,且通常落在 time.sleep 正在执行的那一行。实测未捕获的完整 traceback:
Traceback (most recent call last):
File "C:\AI博客\blog-app\_scratch\ctrl_c_demo.py", line 4, in <module>
signal.raise_signal(signal.SIGINT) # 模拟用户按下 Ctrl+C
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt
注意三层信息:异常类型是 KeyboardInterrupt(不是 Exception 的子类);traceback 指向被中断的那条语句;进程随后以非零状态码终止。也就是说,Ctrl+C 走的是标准异常传播路径,只是默认没人接住。
工程上常驻进程必须接住它做清理:保存断点位置、关闭监视器、打印退出说明。下面演示捕获版本(同样用 raise_signal 在第 3 轮模拟按键,真实运行中中断会发生在 time.sleep 处,效果一致):
import signal
import time
def read_new_lines(path, position):
with open(path, encoding='utf-8') as f:
f.seek(position)
lines = [line.rstrip('\n') for line in f]
return lines, f.tell()
path = 'watch.log'
with open(path, 'w', encoding='utf-8') as f:
f.write('2026-09-02 08:00:00 INFO watcher started\n')
position = 0
print('监视器已启动(演示 5 轮),真实使用中按 Ctrl+C 即可中断')
try:
for round_no in range(1, 6):
time.sleep(1)
lines, position = read_new_lines(path, position)
print(f'第 {round_no} 轮:{len(lines)} 行新日志,位置推进到 {position}')
if round_no == 3:
signal.raise_signal(signal.SIGINT) # 演示用:程序化模拟 Ctrl+C 信号
except KeyboardInterrupt:
print('捕获 KeyboardInterrupt:Ctrl+C 中断了 time.sleep')
print('执行清理:保存断点位置、关闭监视器,优雅退出')
运行结果:
监视器已启动(演示 5 轮),真实使用中按 Ctrl+C 即可中断
第 1 轮:1 行新日志,位置推进到 42
第 2 轮:0 行新日志,位置推进到 42
第 3 轮:0 行新日志,位置推进到 42
捕获 KeyboardInterrupt:Ctrl+C 中断了 time.sleep
执行清理:保存断点位置、关闭监视器,优雅退出
程序在第 3 轮被”按键”打断,except KeyboardInterrupt 分支接管,打印清理信息后正常结束——而不是甩出一屏 traceback。看到”第 2、3 轮位置停在 42 不动”了吗?这正是断点的语义:没有新行,位置就保持原样。
第 6 步:日志轮转来了——断点复位
最后面对轮转(log rotation)。logrotate 的典型动作:把当前日志改名备份(如 svc.log → svc.log.1),再创建新的空日志让服务继续写。监视器的断点位置记录的是旧文件的偏移,新文件根本没有那么长。实测全流程:
import os
path = 'svc.log'
# 阶段 1:写入第一批 3 行日志并“读到最后”,用 tell() 记录位置
with open(path, 'w', encoding='utf-8') as f:
f.write('2026-09-02 09:00:01 INFO start\n')
f.write('2026-09-02 09:00:02 INFO ping\n')
f.write('2026-09-02 09:00:03 INFO pong\n')
with open(path, encoding='utf-8') as f:
print('阶段 1 读到:', [line.rstrip('\n') for line in f])
position = f.tell()
print('阶段 1 记录的位置:', position)
# 阶段 2:logrotate 在凌晨执行了轮转——把当前文件改名存档,新文件从空开始
os.replace(path, 'svc.log.1')
with open(path, 'w', encoding='utf-8') as f:
f.write('2026-09-02 09:00:04 INFO restarted\n')
# 阶段 3:监视器仍按旧位置续读——旧位置已超过新文件的长度
with open(path, encoding='utf-8') as f:
f.seek(position)
print('按旧位置读取:', repr(f.readline()))
# 阶段 4:修复——发现新文件比记录位置短,判断发生了轮转,重置位置
if os.path.getsize(path) < position:
position = 0
print('检测到轮转信号(新文件长度小于旧位置),位置重置为 0')
with open(path, encoding='utf-8') as f:
f.seek(position)
print('重置后读取:', [line.rstrip('\n') for line in f])
运行结果:
阶段 1 读到: ['2026-09-02 09:00:01 INFO start', '2026-09-02 09:00:02 INFO ping', '2026-09-02 09:00:03 INFO pong']
阶段 1 记录的位置: 94
按旧位置读取: ''
检测到轮转信号(新文件长度小于旧位置),位置重置为 0
重置后读取: ['2026-09-02 09:00:04 INFO restarted']
阶段 3 展示了两个重要事实:其一,seek 到超出文件末尾的位置不会报错,readline 只会返回空串——所以”读到空”不一定是没新日志,也可能是断点越界;其二,修复手段简单可靠:比较 os.path.getsize(path) < position,成立即说明文件比断点小,把断点重置为 0 从头再读。轮转适配的本质就是给断点加一道”合法性校验”。更严谨的生产实现还会检查轮转后的首行时间戳(如重启标记),判断是否错过了一段日志——那是产品化阶段的事,本篇的原理已经覆盖。
易错点与陷阱
陷阱一:手算字节位置代替 tell()
tell() 存在就是为了把它返回的整数原样交给 seek()。手算位置极易出错:文本模式下 \r\n 占 2 字节、编码不同每字符字节数也不同。实测:在「第 1 步」生成的两行日志(共 63 字节,第三行从字节 63 开始)基础上,seek(64) 会从第三个字符开始读,第一行新日志被吞掉开头:
with open('pos.log', 'w', encoding='utf-8') as f:
f.write('2026-09-02 10:00:01 INFO boot\n')
f.write('2026-09-02 10:00:02 INFO ready\n')
with open('pos.log', 'a', encoding='utf-8') as f:
f.write('2026-09-02 10:00:05 INFO task-1 done\n')
for offset in (63, 64):
with open('pos.log', encoding='utf-8') as f:
f.seek(offset)
line = f.readline().rstrip('\n')
print(f'seek({offset}) 后读到的第一行:{line}')
运行结果(注意第二行输出与第一行相比少了一个字符):
seek(63) 后读到的第一行:2026-09-02 10:00:05 INFO task-1 done
seek(64) 后读到的第一行:026-09-02 10:00:05 INFO task-1 done
63 是对的(等于 tell() 的值),64 则是错位 1 字节的灾难后果。日志监控里这意味着一整条记录被静默截断、统计全部失真。规则:位置只来自 tell(),只交给 seek()。
陷阱二:以空串判断”没有新日志”
f.readline() 在文件末尾返回空串、for line in f 在末尾自然停止,这与”文件中有一行空行”在语义上是两回事。更隐蔽的是第 6 步展示的情形:轮转后断点越界,读取同样返回空——不检查文件大小就误判”没有新日志”,轮转后的所有内容都会被永久跳过。空结果必须与”断点有效”两个条件同时确认,才能下”无新增”的结论。
陷阱三:except Exception 捕获不到 Ctrl+C
第四章的异常层次里 KeyboardInterrupt 是 BaseException 的直接子类,不在 Exception 之下。写 except Exception: 的轮询循环,按 Ctrl+C 时照样甩 traceback 退出。要优雅退出,必须显式 except KeyboardInterrupt;同理,SystemExit 也在此列。把 Ctrl+C 当作普通业务异常来”兜底”,是常驻脚本最常见的错误设计。
陷阱四:轮询间隔与副作用
间隔设得太小(如 0.01 秒)会让 CPU 空转(每秒 100 次文件 open+close,一天的 open 系统调用近 900 万次),太大则延迟报警。经验起点是 1 秒,量级思考:延迟与开销的乘积就是轮询间隔的权衡公式。另外每一轮都 open/close 一次文件是正确且必要的——常驻一个句柄看似省事,却会与 logrotate 的 rename 冲突(句柄仍指向已改名的旧文件),读到的是”僵尸日志”。每次都重新 open,天然跟随最新的文件名。
小结
本篇把「增量读取」这条生产线完整走了一遍:tell() 记录位置、seek() 定位续读,每次只消费新增行,成本与增量成正比而不是与文件总量成正比(第 1-3 步);time.sleep 循环轮询让出 CPU,try/except KeyboardInterrupt 让 Ctrl+C 变成优雅退出而非 traceback(第 4-5 步);os.path.getsize 与断点比对,让日志轮转不再是断点机制的终结者(第 6 步)。配合第五章的正则,增量行可以被实时提炼成状态码分布与错误计数,直接喂给告警或报表。核心结论一句话:位置只来自 tell()、只交给 seek()、每次重开文件、轮转后校验断点。这套机制也出现在标准库的 logging.handlers.WatchedFileHandler 与各类 tail -F 工具中,理解本篇再看它们实现,一目了然。
练习与思考题
- 把内存断点改成落盘断点:每次读取后用文本模式把
position写入svc.log.offset文件,程序启动时先读断点文件再开始增量读取,实现”重启后从上次位置继续”。提示:断点文件自己也可能损坏,读不到时回退为 0。 - 给第 3 步的摘要加一个”5xx 实时告警”:累计 500 状态码达到 3 次时打印一行警告并清零计数,观察两批数据下告警何时触发。
- 思考题:轮询间隔分别取 0.1 秒与 30 秒,各会带来哪些问题?如果日志文件被服务重启时用
'w'模式清空重建(而不是 rename 轮转),第 6 步的getsize判断还能不能生效?此时应该怎样补一个”文件变小即重置”的规则? - 可选挑战:把第 6 步的轮转适配与第 5 步的优雅退出合并成完整的
tail_follow.py(参考tail -F的语义),要求线程级注释说明每段代码在真实运维中的作用。做完可以对照标准库WatchedFileHandler的实现,看它的轮转检测用了什么手段。