Python 小记:给定时脚本加一把文件锁

老K 11 0

[aru_34]上一篇把 AI 整理好的文章送进了 WordPress 草稿箱。接着折腾定时执行时,我想先把另一个问题弄明白:如果上一轮还没结束,下一轮又启动了,怎么办?

比如计划任务已经在跑,我又手动点了一次;或者任务每分钟触发,实际却要两分钟才能完成。两份脚本一起请求接口、一起写文件,后面的重复数据就不奇怪了。

我先拿 Python 做了个小实验:同一台机器上,同一份任务一次只允许一个进程进入。用标准库的 fcntl.flock 就能把这一步做出来。

Python 小记:给定时脚本加一把文件锁

示意图:上下两行的 job.lock 指同一个锁文件路径,A 持锁时 B 跳过;非终端截图。

先别用“文件存在”来判断

很容易想到一个办法:启动时判断有没有 run.lock,没有就创建,结束时删掉。

问题出在“先判断,再创建”是两步。两个进程有可能都看见文件不存在,然后都开始工作。再加上程序异常后文件没删掉,后面还得自己判断这把“锁”到底是不是过期了。

这次我让操作系统管理锁:脚本打开一个固定文件,申请独占锁;拿到锁才工作,拿不到就直接跳过。判断依据是锁是否可获取,文件留在磁盘上也没关系。

先放一份可以直接跑的代码

保存为 single_job.py。本次验证用的是 macOS 和 Python 3.9.6;示例使用 Unix 的 fcntl 接口,不适用于原生 Windows Python。我也核对了 Linux 的接口说明,但这轮没有在 Linux 上实际运行。

import argparse
import errno
import fcntl
import os
from pathlib import Path
import time


def run_job(lock_path, seconds):
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    with lock_path.open("a", encoding="utf-8") as lock_file:
        try:
            fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError as error:
            if error.errno in (errno.EACCES, errno.EAGAIN):
                print("SKIP: 已有实例运行,本次跳过", flush=True)
                return
            raise
        try:
            print(f"RUN: pid={os.getpid()},开始执行", flush=True)
            # 演示耗时任务;实际使用时换成自己的业务代码。
            time.sleep(seconds)
            print("DONE: 本次执行结束", flush=True)
        finally:
            fcntl.flock(lock_file, fcntl.LOCK_UN)
    # 保留锁文件,不要在这里 unlink。


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--seconds", type=int, default=8)
    parser.add_argument("--lock-file", default=str(
        Path.home() / ".local/state/demo-job/run.lock"
    ))
    args = parser.parse_args()
    if args.seconds < 0:
        parser.error("--seconds 不能小于 0")
    run_job(Path(args.lock_file).expanduser().resolve(), args.seconds)

这里我只放了一个 time.sleep() 模拟耗时操作。实际使用时,把它换成读取资料、处理文件或请求接口的代码,保持这些操作都在持锁范围里面。

几个参数顺手记一下:

  • LOCK_EX:申请独占锁。
  • LOCK_NB:拿不到就立即返回,不排队等上一轮。
  • LOCK_UN:释放当前锁。

我这里只把 EACCES 和 EAGAIN 当作锁竞争来处理,其他 OSError 继续抛出来。代码按这个规则检查错误号;目录创建、打开文件这类操作失败,也不会被一句“已有实例运行”掩盖。

开两个终端试一下

终端 A 先运行,让任务占着锁等 30 秒:

python3 single_job.py --seconds 30

它会打印 RUN 和进程 ID。保持这个终端运行,马上在终端 B 执行:

python3 single_job.py --seconds 0

终端 B 应该立即输出:

SKIP: 已有实例运行,本次跳过

等 A 结束,再执行一次 B 的命令,就能正常运行。我在本机用临时目录做了同样的双进程验证,下面是其中一组实际输出,PID 每次运行会变:

RUN: pid=51014,开始执行
SKIP: 已有实例运行,本次跳过
DONE: 本次执行结束

RUN: pid=51017,开始执行
DONE: 本次执行结束

这一组自动验证把首个任务的等待时间缩成了 1 秒;上面两条手动命令用 30 秒,是为了留出切换终端的时间。

默认锁文件在 ~/.local/state/demo-job/run.lock。也可以给一份任务明确指定路径:

python3 single_job.py --lock-file "$HOME/.local/state/my-job/run.lock" --seconds 30

同一份任务的所有入口都要使用同一个锁路径。手动执行和计划任务如果用不同用户,HOME 也可能不同,最后拿到的是两把锁。把路径设为任务的固定配置,比每次临时拼一个文件名靠谱。

锁文件别顺手删掉

写到 finally 时,很容易想顺手补一句 lock_path.unlink(),清理干净。我专门试了一下,这句反而可能把保护拆了。

假设 A 已经打开并锁住文件,文件被删除后,A 仍然握着原来那个文件对象。B 再按原路径打开,会新建另一个文件对象,申请的是另一把锁。这时两边就都能运行了。

我在临时目录里保持 A 持锁,删除目录项,再启动 B,确实复现了这个现象:文件的 inode 已经变了,B 也打印出了 RUN。所以代码结尾只释放锁、关闭文件,保留 run.lock。

这也说明,锁文件存在并不表示任务还活着。我验证了正常结束、业务代码抛异常,以及直接终止持锁进程这几种情况,之后都能再次启动。这个示例不把文件描述符传给子进程;如果自己扩展成多进程程序,还需要注意描述符是否被继承。

我这次具体检查了什么

  • 同一个锁路径,A 运行时 B 会跳过。
  • A 正常结束后,B 能运行,锁文件仍然保留。
  • 持锁进程被终止后,新的进程能获取锁。
  • 模拟业务异常,finally 执行后可以重新获取锁。
  • 不同锁文件的任务互不阻塞。
  • 持锁时删除文件,会换 inode,并复现绕过原锁的问题。

这些验证都在本机临时目录里完成,没有连接生产接口,也没有拿线上文章做重复写入实验。示例把 SKIP 当作正常跳过,返回码是 0;如果接监控,要从运行记录里区分“执行完成”和“本次跳过”。

它挡住并发,发文去重还得另外做

这把锁只适合我这里讨论的同机、本地文件系统场景。所有执行入口都要主动遵守同一套加锁约定;文件锁不会替我拦住绕开这段代码的程序。多台服务器、容器各自的文件系统或者网络共享盘,也不能直接照搬这个结论。

还有一点和上一篇有关:就算两次发文严格一前一后执行,也可能把同一篇内容创建两遍。文件锁管的是“现在能不能一起跑”,文章去重管的是“这件事是不是已经做过”。

所以后面接发文任务时,我还会记录返回的文章 ID,提交前检查已有草稿;请求超时则先查后台结果,不会因为加了锁就直接重发。

先把单机任务重叠这个问题解决,代码不长,也方便自己以后复制到别的脚本里。真正换成业务代码后,再补上对应的超时、错误记录和去重判断。

这次查的两份资料留在这里:

发表评论 取消回复
OwO 图片 链接 代码

分享