Windows 缺失 fcntl 报错排查与跨平台文件锁实现
1. 先搞清楚 fcntl 到底是个什么东西ModuleNotFoundError: No module named fcntl这个报错几乎每一个在 Windows 上跑过跨平台 Python 项目的人都会撞上一次。它的典型现场是代码在 Linux 服务器上跑得好好的同事把仓库 clone 到自己的 Windows 笔记本上pip install一切顺利python main.py一敲下去控制台第一行就甩出这句话后面跟着一长串 import 栈。第一次遇到的人往往会以为是自己装漏了包于是pip install fcntl、pip install python-fcntl、pip download fcntl各种试结果全是Could not find a version that satisfies the requirement越试越怀疑人生。先把结论摆在前面fcntl不是第三方库它是 Python 标准库里一个只面向 POSIX 系统的 C 扩展模块。你在 PyPI 上找不到它是因为它从来就不打算跨平台。它背后包的是 Unix 系统调用fcntl(2)、flock(2)、ioctl(2)这一套东西而 Windows 的 Win32 API 里根本没有对应的函数名和语义。所以这不是环境配错了也不是版本装错了而是这条路在 Windows 上天然不通必须换一种走法。这个坑的杀伤力被严重低估了。它不会在你写业务代码的时候出现而是藏在某个第三方依赖的深层 import 里等你以为自己项目结构没问题的时候突然炸出来。更麻烦的是它有时候不报错、只是锁不生效导致两个进程同时写同一个文件数据静默损坏这比直接抛异常要恶劣得多。所以我写这篇东西就是想把这个报错背后的一整套逻辑拆开它为什么会发生、怎么快速定位是谁引用的、三种解决路线分别适合什么场景、以及一个能直接抄走的跨平台文件锁实现。这篇内容适合三类人看一是刚把项目从 Linux 迁移到 Windows、被这个报错卡住的开发者二是正在写跨平台库、需要处理文件锁的作者三是打包 Windows 桌面程序、被 PyInstaller 的各种缺失模块折腾过的同学。哪怕你只是想让手上那个脚本在 Windows 上跑起来后面的速查表也能直接对症。2. 报错现场与 fcntl 的真实职责2.1 这个模块平时都用来干什么要理解为什么 Windows 没有就得先知道fcntl在 Unix 世界里承担了哪些活儿。它其实是个杂货铺主要干三件事。第一件是文件锁。fcntl.flock(fd, fcntl.LOCK_EX)给一个已打开的文件描述符加排他锁LOCK_SH加共享锁配合LOCK_NB做非阻塞尝试。这是多进程程序里最常用的互斥手段比如两个 worker 抢同一个任务队列文件、一个定时任务防止自己重复启动、日志轮转时防止写到一半被别的进程截断。第二件是文件描述符属性控制。fcntl.fcntl(fd, F_SETFL, flags | os.O_NONBLOCK)可以给已经打开的描述符追加非阻塞标志这是很多异步 IO 框架和网络库的底层操作。还有F_DUPFD复制描述符、F_GETFD查询 close-on-exec 标志等等。第三件是设备与终端控制。fcntl.ioctl()用来给设备驱动下发控制指令比如查询网卡信息、设置串口参数、读取终端窗口大小。这部分在 Windows 上对应的是DeviceIoControl调用约定和参数传递方式完全是另一套体系。三件事里普通业务项目九成以上只用得上第一件——文件锁。这一点很关键因为它意味着大部分情况下你不需要完整复刻 fcntl只需要替换掉 flock 这一个小功能就能把问题解决掉。如果一上来就想着找Windows 版 fcntl思路就跑偏了。注意fcntl和termios、pwd、grp、resource、syslog属于同一家族都是只存在于 POSIX 平台的标准库模块。在 Windows 上看到其中任何一个缺失处理思路是一致的。2.2 报错栈通常会给你什么线索这个报错的完整形态一般是这样Traceback (most recent call last): File D:\work\project\main.py, line 3, in module from app.runner import start File D:\work\project\app\runner.py, line 11, in module from vendor.taskqueue import Worker File D:\work\project\vendor\taskqueue\__init__.py, line 5, in module from .lockfile import FileLock File D:\work\project\vendor\taskqueue\lockfile.py, line 2, in module import fcntl ModuleNotFoundError: No module named fcntl栈的最后一层才是真正 import 的地方往上倒推就是调用链。初学者常见的错误是只盯着第一行的main.py看然后在自己代码里全局搜索fcntl搜不到就懵了。其实真正的问题在vendor/taskqueue/lockfile.py里而你自己的代码一行都没写错——你只是用了一个不做 Windows 适配的依赖。还有一种更隐蔽的形态报错根本没出现在启动阶段而是出现在某个特定分支。比如只有在多进程模式启动、或者只有当日志需要轮转、或者只有调用了某个with lock:上下文时才会触发 import。这时候用python -c import xxx是复现不出来的必须完整跑一遍业务流程才能触发。我在排查这类问题时习惯先用最小复现路径把触发条件锁死再去动代码否则很容易陷入改了十几处、最后发现改错地方的循环。2.3 为什么它在 Linux 上从来不出事很多人会有一个疑问既然这段代码用了import fcntl为什么在 Linux 上跑了两年都没暴露过答案很简单——在 Linux 上这个 import 永远成功失败路径根本没被执行过。更值得警惕的是那种带 try/except 的兼容代码try: import fcntl HAS_FCNTL True except ImportError: HAS_FCNTL False这种写法看起来很稳妥实际上是真正的定时炸弹。因为HAS_FCNTL False之后后面的逻辑要么直接跳过加锁并发安全直接没了要么在某个角落抛出一个和原始问题毫无关系的异常让你排查半天找不到源头。所以在 Windows 上做适配时看见这种模式一定要单独拎出来审一遍确认降级路径的行为是可接受的而不是没报错就等于没问题。3. 为什么 Windows 上就是没有系统调用模型差异3.1 POSIX 的文件描述符与 Win32 的句柄不是一回事要讲清楚这个问题得从两套系统的抽象说起。在类 Unix 系统里一切皆文件所有 IO 对象都映射成一个非负整数叫文件描述符 fd。fcntl就是对这个整数做操作的接口它不关心背后是普通文件、管道、socket 还是设备节点统一入口。Windows 走的是另一条路。它用的是内核句柄 HANDLE文件、进程、线程、事件、注册表键、管道全是句柄但这些句柄的类型是严格区分的。文件相关的操作集中在kernel32.dll的ReadFile、WriteFile、SetFilePointer和LockFileEx上句柄不能像 fd 那样随便传给一个通用控制函数。Python 在 Windows 上通过msvcrt模块暴露了一部分 C 运行时层面的文件操作通过pywin32暴露了 Win32 原生 API但这两套接口的形状和fcntl都对不上。具体到最常用的 flock差异更明显。fcntl.flock的第一个参数是整数 fd直接来自os.open或file.fileno()而 Windows 的LockFileEx要的是HANDLE需要从 fd 用msvcrt.get_osfhandle(fd)转换过去还要构造一个OVERLAPPED结构体来描述锁定区域的偏移。这两者之间的鸿沟不是加个别名能填平的只能在封装层里各写一套。3.2 Windows 侧能用的原生替代品Windows 上真正能拿来解决文件锁需求的有两个层次。第一个层次是msvcrt.locking()标准库自带零依赖这是最轻的方案。它的签名是msvcrt.locking(fd, mode, nbytes)从文件当前位置开始锁nbytes个字节。mode 有四个取值LK_LOCK阻塞式独占锁、LK_NBLCK非阻塞独占锁、LK_RLCK阻塞式读锁、LK_NBRLCK非阻塞读锁解锁用LK_UNLCK。第二个层次是Win32 原生LockFileEx通过pywin32的win32file.LockFileEx(handle, flags, reserved, nbytesLow, nbytesHigh, overlapped)调用。它支持真正的共享锁LOCKFILE_EXCLUSIVE_LOCK标志不设置就是共享锁、支持锁定文件末尾之外的区域、支持异步等待。功能上更完整代价是多一个第三方依赖。大多数项目用第一个就够了。我自己的判断标准很简单如果只是想在多进程之间做互斥msvcrt.locking完全够用如果要做读写分离的并发控制或者需要锁超出文件末尾的区域做命名锁才值得引入pywin32。能力fcntl.flock(POSIX)msvcrt.locking(Windows)pywin32 LockFileEx独占锁LOCK_EXLK_NBLCK/LK_LOCK不设 EXCLUSIVE 标志共享锁LOCK_SHLK_NBRLCK/LK_RLCK设 EXCLUSIVE 标志非阻塞尝试加LOCK_NB用LK_NB*系列配合 OVERLAPPED 与错误码解锁LOCK_UNLK_UNLCKUnlockFileEx锁粒度整个文件指定字节区间指定字节区间可超 EOF额外依赖无无标准库需要pywin323.3 一个容易被忽略的语义鸿沟劝告锁与强制锁即使把 API 对上了行为也不完全一样这是最容易翻车的地方。POSIX 的flock是劝告锁advisory lock。它只对也主动去加锁的进程生效。如果另一个进程压根没打算加锁直接open()然后write()flock是拦不住的数据照样被覆盖。这是设计如此锁只是一个协作约定。而 Windows 的LockFileEx/msvcrt.locking更接近强制锁mandatory lock。一旦某个字节区间被锁住其他进程对这些字节的ReadFile/WriteFile会直接失败返回ERROR_LOCK_VIOLATION。也就是说Windows 上的锁管得更宽这既带来了更强的保护也带来了新的麻烦如果有第三方工具比如杀毒软件的实时扫描、文本编辑器、备份程序试图读取这个被锁的文件读写会报错或者反过来它先占着文件导致你锁不上。我在一个数据采集项目里就遇到过这个现象程序在 Windows 上加锁正常但只要打开那个锁文件所在的目录、资源管理器生成缩略图加锁就会随机失败。排查了半天才意识到是强制锁和文件扫描的冲突。所以锁文件一定要放在专门的运行时目录里命名用.lock后缀并尽量隐藏不要和用户会去浏览的目录混在一起。这个经验在文档里基本找不到但踩过一次就记住了。4. 定位真凶到底是谁在 import fcntl4.1 从异常栈反推别靠猜最简单也最有效的办法就是用完整的 traceback。运行时加上-X faulthandler意义不大关键是不要用try/except ImportError把栈吃掉。如果你在项目入口或者框架的启动逻辑里看到有兜底的异常处理先临时把它注释掉让完整栈打出来。栈出来之后从上往下读最上面是你的入口最下面那个File就是真正 import 的地方。以第 2.2 节那个栈为例问题在vendor/taskqueue/lockfile.py第 2 行对应的是某个第三方包。这时候处理方式就清楚了——去看看这个包有没有新版本做了 Windows 适配或者有没有替代包而不是在自己的代码里瞎改。如果那个 import 发生在 C 扩展编译期比如安装某个包时setup.py报错那就需要看pip install的完整输出而不是只看最后一行。用pip install -v 包名能看到构建过程的所有细节包括它在哪一步去找fcntl.h。Windows 上没有fcntl.h这个头文件所以凡是setup.py里写死了#include fcntl.h的包在 Windows 上源码安装基本必挂这时候只能找预编译的 wheel。4.2 写个钩子把 import 现场抓下来有时候栈是打不出来的比如 import 发生在一个被exec动态加载的字符串里或者发生在多进程子进程里、日志没配好直接被吞掉。这时候可以上一段 import 钩子直接在解释器层面拦截。# sitecustomize.py —— 放在项目根目录或加进 PYTHONPATH import builtins import sys import traceback _real_import builtins.__import__ def _traced_import(name, globalsNone, localsNone, fromlist(), level0): if name fcntl or name.startswith(fcntl.): sys.stderr.write(\n fcntl imported by \n) traceback.print_stack(filesys.stderr) sys.stderr.write(\n) sys.stderr.flush() return _real_import(name, globals, locals, fromlist, level) builtins.__import__ _traced_import把它命名为sitecustomize.py放到项目根目录或者放在PYTHONPATH能覆盖到的位置Python 启动时会自动加载它之后每一次import fcntl都会把完整调用栈打到 stderr包括线程名和所在文件行号。注意这个钩子会全局替换__import__有轻微性能开销生产环境记得移除。另外它对 C 扩展内部的 import 无效只对 Python 层的 import 生效。4.3 静态扫描一次找出所有嫌疑文件如果依赖树很大一路跑一路报错太慢了。我习惯先做一次静态扫描把所有含import fcntl的 Python 文件全部列出来心里有个数。import pathlib import re root pathlib.Path(.venv/Lib/site-packages) # 也可以换成项目目录 pattern re.compile(rb^\s*(?:import\sfcntl\b|from\sfcntl\simport), re.M) hits [] for path in root.rglob(*.py): try: data path.read_bytes() except OSError: continue if pattern.search(data): hits.append(path) for p in sorted(hits): print(p) print(f\n共命中 {len(hits)} 个文件)这段代码的关键是用了re.M多行模式配合行首锚点避免匹配到字符串里的注释文本。扫出来的结果里通常只有一到两个是真正的问题源头其余的可能是包里的if sys.platform ! win32:分支内部属于安全引用不需要处理。这时候再用python -X importtime看模块加载耗时基本就能锁定到底是哪个包在你启动时拖进来的。5. 三条解决路线怎么选5.1 路线一换掉依赖优先找跨平台库最省事、长期维护成本最低的做法是把那个不支持 Windows 的依赖换掉或者升级掉。判断顺序是先看这个包有没有新版本加了 Windows 支持很多包在 issue 区挂了好几年某天突然就被 PR 修了再看有没有官方推荐的替代品。如果只是要一个跨平台文件锁社区里有两个成熟度很高的选择。portalocker在 Windows 上用msvcrt或pywin32在 POSIX 上用fcntl接口统一成with portalocker.Lock(path, modew) as fh:这种形式还支持超时参数。filelock用纯 Python 实现基于创建锁文件 原子操作的思路完全绕开了系统调用差异语义在哪个平台上都一样缺点是它不是严格意义上的系统级互斥极端情况下进程被杀、锁文件残留需要靠超时和过期清理兜底。选哪个取决于你的需求强度。portalocker更贴近操作系统的真实锁语义适合对一致性要求高的场景filelock跨平台行为最一致适合那些宁可锁得弱一点也不能在不同平台上表现不一样的场景。两者我都在生产环境用过都没出过大问题。5.2 路线二条件导入加一层自写适配层如果触发报错的是你自己写的代码那最好的方案就是老老实实写一个适配层把平台差异隔离在一个文件里上层业务代码完全无感。# compat/lock.py import os import time if os.name nt: import msvcrt else: import fcntl def _acquire(fh): 尝试加一次非阻塞排他锁失败抛 OSError。 fh.seek(0) if os.name nt: msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) else: fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) def _release(fh): 释放锁注意 Windows 下解锁必须落在和加锁相同的字节位置。 fh.seek(0) if os.name nt: msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) else: fcntl.flock(fh.fileno(), fcntl.LOCK_UN)这段代码有几个地方值得单独说。用os.name nt而不是sys.platform win32。两者在 Windows 上等价但os.name判断的是系统接口族语义更贴合这里的意图而且它对 Cygwin 之类的环境处理得更稳妥。Windows 分支一定要先write至少一个字节。这是最关键的一个坑msvcrt.locking锁的是从当前文件位置开始的nbytes个字节如果这个区间超出了文件当前长度调用会直接失败抛OSError: [Errno 13] Permission denied。空文件、长度为 0 的文件在 Windows 上就是锁不住。解决办法很简单——打开文件后先检查长度为 0 就写入一个字节比如b\0并 flush。在 POSIX 上完全不需要这一步因为flock锁的是整个文件对象跟长度无关。解锁时seek(0)不能省。msvcrt.locking的解锁是按区间匹配的位置不对就解不掉锁会一直挂着直到进程退出。这个 bug 极其隐蔽因为返回值没有、没报错表现就是下次启动锁不上。强制使用二进制模式打开。在 Windows 上文本模式会对\n做\r\n转换导致文件的实际字节偏移和你的预期对不上。锁一个字节的时候看起来没事但如果锁多个字节加锁和解锁的偏移就可能错位。所以打开锁文件永远用ab或者rb永远用os.SEEK_SET定位这条我吃过亏写进团队规范了。5.3 路线三垫片模块的适用边界与风险还有一条路很多人试过自己在项目里建一个fcntl.py冒充标准库让那些第三方依赖的import fcntl能通过。# fcntl.py —— 放在 sys.path 最前面冒充标准库 import errno import os LOCK_SH 1 LOCK_EX 2 LOCK_NB 4 LOCK_UN 8 if os.name nt: import msvcrt def flock(fd, operation): if operation LOCK_UN: os.lseek(fd, 0, os.SEEK_SET) msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) return mode msvcrt.LK_NBLCK if operation LOCK_NB else msvcrt.LK_LOCK os.lseek(fd, 0, os.SEEK_SET) try: msvcrt.locking(fd, mode, 1) except OSError as exc: if operation LOCK_NB: raise BlockingIOError(errno.EAGAIN, resource temporarily unavailable) from exc raise else: import fcntl as _fcntl flock _fcntl.flock这个方案的吸引力在于改动量最小一行依赖代码都不用动。但它的风险必须讲清楚我在实际项目里只会在一种情况下用它确认那个依赖库只调用了flock而且调用参数只用了LOCK_EX/LOCK_NB/LOCK_UN这几种组合。风险点有三个。第一LOCK_SH在 Windows 上无法用msvcrt.locking真实实现因为LK_RLCK是读锁但语义和共享锁不完全一致多个进程同时持有LK_RLCK可以但和其他读写模式的交叉行为有差别如果依赖库用了共享锁垫片就是错的。第二fcntl.fcntl、fcntl.ioctl、fcntl.lockf这些函数一旦被调用垫片必须抛NotImplementedError不能假装成功否则问题会延后到更难排查的位置。第三垫片文件名可能和真实的标准库重名在某些 Python 版本或打包工具下会产生到底导入了谁的混乱PyInstaller 尤其容易在这里出问题。所以我的态度是垫片是应急手段不是长期方案。用它的同时必须在代码里留 TODO 和 issue 链接推动上游解决或者规划替换。6. 完整可落地的跨平台锁从封装到压测6.1 把超时、重试和清理都收进上下文管理器把前面那些碎片拼成一个能直接用在生产里的类。设计目标是调用方只写with FileLock(path, timeout5):不关心平台不关心重试细节。# compat/filelock.py import os import time if os.name nt: import msvcrt else: import fcntl class LockTimeout(Exception): 获取锁超时。 class FileLock: def __init__(self, path, timeout10.0, poll0.05): self.path os.fspath(path) self.timeout timeout self.poll poll self._fh None def __enter__(self): self.acquire() return self def __exit__(self, exc_type, exc, tb): self.release() return False def acquire(self): # ab 保证文件不存在时会被创建二进制模式避免换行符转换 self._fh open(self.path, ab) if os.name nt: self._fh.seek(0, os.SEEK_END) if self._fh.tell() 0: self._fh.write(b\0) self._fh.flush() os.fsync(self._fh.fileno()) deadline time.monotonic() self.timeout while True: try: self._try_lock() return except OSError: if time.monotonic() deadline: self._fh.close() self._fh None raise LockTimeout(facquire {self.path} timeout after {self.timeout}s) time.sleep(self.poll) def _try_lock(self): self._fh.seek(0) if os.name nt: msvcrt.locking(self._fh.fileno(), msvcrt.LK_NBLCK, 1) else: fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) def release(self): if self._fh is None: return try: self._fh.seek(0) if os.name nt: msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1) else: fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) finally: self._fh.close() self._fh None几个设计决策的理由值得说明。为什么不用LK_LOCK而是自己循环LK_NBLCK。msvcrt.LK_LOCK文档里的行为是尝试 10 次每次间隔 1 秒也就是说它的最长等待时间是固定的 10 秒你没法控制而且这 10 秒里它是真的在阻塞。用LK_NBLCK加自己的time.sleep(poll)循环超时时间可控、轮询间隔可调行为在 Linux 和 Windows 上完全一致。这一点在做跨平台测试的时候特别重要——否则同一个测试用例在两个平台上跑出来的耗时差十倍。为什么poll默认是 0.05 秒而不是 0.5 秒。锁的竞争通常持续几毫秒到几百毫秒轮询太慢会让等在后面的进程吃满延迟。0.05 秒是一个经验值在几十个进程抢锁的场景下平均等待时间只比理想值多 25 毫秒左右CPU 开销可以忽略。为什么加了os.fsync。Windows 上写入的字节可能还在系统缓存里虽然LockFileEx锁的是逻辑字节区间、不依赖数据是否落盘但如果你把这个文件当作存在性标记给其他工具看不 fsync 有可能出现文件存在但长度为 0 的窗口期。加一次成本很低。为什么__exit__里return False。保证业务代码抛出的异常不会被吞掉这一条在写上下文管理器时是标配但经常被忘。6.2 用多进程压测验证锁真的生效跨平台锁写完不测等于没写。测试的核心不是能不能锁上而是竞争的时候有没有出现两个进程同时进入临界区。用一个共享的计数文件来验证# stress_test.py import multiprocessing as mp import os import sys from compat.filelock import FileLock COUNTER counter.txt LOCKFILE counter.lock def worker(rounds): for _ in range(rounds): with FileLock(LOCKFILE, timeout30): with open(COUNTER, r) as fh: value int(fh.read().strip() or 0) fh.seek(0) fh.truncate() fh.write(str(value 1)) fh.flush() os.fsync(fh.fileno()) if __name__ __main__: procs, rounds 8, 200 open(COUNTER, w).write(0) ctx mp.get_context(spawn if sys.platform win32 else fork) jobs [ctx.Process(targetworker, args(rounds,)) for _ in range(procs)] for p in jobs: p.start() for p in jobs: p.join() final int(open(COUNTER).read().strip()) expected procs * rounds print(f期望 {expected}实际 {final}{通过 if final expected else 失败})跑这个脚本的时候有两个细节必须注意。第一Windows 上必须用spawn启动方式fork在 Windows 上不存在而且spawn会重新 import 主模块所以if __name__ __main__保护不能少否则会无限递归启进程。第二计数文件的读写要在一个独立的open里完成不要和锁文件共用同一个句柄否则 Windows 的强制锁会把你自己挡住。我自己跑这套测试的经验是Linux 上 8 进程 × 200 轮大约 3 秒跑完Windows 上要 8 到 12 秒主要差在msvcrt.locking每次调用的系统开销和文件元数据刷新上。这个差距是正常的别以为是锁没生效。7. 打包与部署阶段的新坑7.1 PyInstaller 的隐藏导入与排除清单把项目打成 exe 之后ModuleNotFoundError会以另一种面目出现——这次它来自打包阶段的分析器。PyInstaller 会静态扫描所有 import看到条件分支里的import fcntl就试图把它打进包里然后在 Windows 上找不到报ModuleNotFoundError或者打出一个运行时才崩的包。处理方式是双管齐下。在 spec 文件里显式排除不可能用到的 POSIX 模块# build.spec a Analysis( [main.py], pathex[.], hiddenimports[], excludes[ fcntl, termios, pwd, grp, resource, posix, syslog, ], )同时把compat/lock.py里的条件导入改造成 PyInstaller 能静态识别的形式也就是不要写成importlib.import_module(fcntl)这种动态形式因为它扫描不到。用最朴素的if os.name nt: import msvcrt else: import fcntl分析器能正确地把msvcrt收进 Windows 包、把fcntl收进 Linux 包。如果用了pywin32的LockFileEx还要在hiddenimports里加上win32file、pywintypes、win32timezone这几个因为pywin32的很多子模块是动态加载的PyInstaller 经常漏。7.2 依赖标记让不同平台装不同的包现代 Python 打包体系支持环境标记可以精确控制这个依赖只在 Windows 上装。在pyproject.toml里写[project] dependencies [ portalocker2.8, pywin32306; sys_platform win32, ] [project.optional-dependencies] posix-lock [fcntl-helper0.1; sys_platform ! win32]sys_platform win32是 PEP 508 里的标准标记pip在解析依赖时会先求值再决定装不装。这个机制的价值不只是省一个包更重要的是避免在不支持它的平台上触发编译。很多包含 C 扩展的包在 Windows 上装不上原因就是它的setup.py里硬引用了平台头文件用标记把它挡掉安装过程就干净了。顺带说一个反过来的坑如果你在 Linux 上生成了requirements.txt里面带着 Linux-only 的包然后拿到 Windows 上用pip install -r会试图装那些包然后失败。正确做法是用pip freeze --exclude-editable配合pip-compile --generate-hashes生成带标记的锁定文件或者干脆按平台拆成两份。7.3 网络盘、容器挂载和锁失效这一条是纯粹的实战经验文档里几乎不会提。网络映射盘SMB上的锁行为不可靠。Windows 的LockFileEx在本地 NTFS 上工作正常一旦文件放在网络共享上能不能锁取决于服务端实现。有些实现会直接拒绝锁定请求返回ERROR_NOT_SUPPORTED有些会假装成功但其实不互斥。如果你的锁文件路径来自配置、用户可能指向网络盘一定要在启动时做一次自检用一个临时文件试加一次锁失败就降级或者直接报错退出别让程序带着假锁跑下去。容器挂载卷上的锁也类似。Windows 主机挂载目录进容器时底层文件系统转换可能导致锁语义丢失。跨容器互斥更稳妥的做法是用文件系统之外的机制而不是靠文件锁。还有一个 Windows 特有的行为被打开的文件不能删除。Linux 上你可以os.unlink一个还有进程持有的锁文件文件会从目录消失但句柄继续有效Windows 上删不掉会抛PermissionError: [WinError 32] 另一个程序正在使用此文件。所以用完就删锁文件这种清理逻辑在 Windows 上要格外小心最好干脆不删——锁文件留着下次复用反而更稳。8. 常见问题速查与几个印象深刻的坑8.1 症状到原因的对照表症状大概率原因处理办法启动即报No module named fcntl直接依赖或间接依赖做了无条件 import看 traceback 定位升级依赖或写适配层pip install 某个包时构建失败提示找不到fcntl.h该包只提供源码分发setup.py硬引用 POSIX 头文件找预编译 wheel或换用跨平台替代包Windows 上加锁抛OSError: [Errno 13]锁文件长度为 0锁定区间超出文件末尾打开后写入一个字节再锁解锁调用没报错但下次仍然锁不上解锁时文件位置和加锁时不一致加解锁前都seek(0)打成的 exe 启动崩溃源码运行正常PyInstaller 把平台相关的模块打混了spec 里加excludes条件导入改成静态可识别形式锁看起来加了并发仍然出错数据文件是用文本模式打开的偏移错位所有文件操作统一用二进制模式加锁随机失败重试就好杀毒软件或资源管理器在扫描锁文件锁文件放到专用隐藏目录避开用户浏览路径程序正常退出后锁文件残留报 WinError 32Windows 上打开中的文件无法删除不要删锁文件改为复用以清空内容8.2 几个花了不少时间才想明白的点第一个坑fcntl.flock在同一进程内的行为差异。在 Linux 上同一个进程对同一个文件用两个不同 fd 加排他锁第二次会阻塞——因为flock锁是挂在 open file description 上的不同open就是不同的描述。而在 Windows 上同一进程对同一文件重复LockFileEx的行为又有自己的一套逻辑容易报错或者出现意料之外的累积。所以千万不要在同一个进程里嵌套获取同一个锁不管是哪个平台。我现在的做法是在FileLock里加一个可重入检测记录已持有的路径集合重入时直接放行并计数避免自己把自己锁死。这个改动只有十几行但避免了一次线上事故。第二个坑tempfile家族的连锁反应。这个问题和fcntl属于同一家族。Windows 上tempfile.NamedTemporaryFile(deleteTrue)创建的文件在被打开期间不能被其他代码再次打开tempfile.TemporaryFile()在某些场景下也会出问题。如果你的代码在 Linux 上习惯了创建临时文件、关掉、再打开、处理完删除这套流程迁到 Windows 上会连环报错。统一改成NamedTemporaryFile(deleteFalse)配合try/finally手动删是最稳的写法。第三个坑别把锁当成分布式方案。文件锁只在同一台机器的同一个文件系统上有效。多台机器共享一个网络目录然后靠文件锁做互斥在 Linux 的 NFS 上就已经不可靠在 Windows 的 SMB 上更不可靠。如果业务真的跨机器要么上真正的协调服务要么用带过期时间的锁文件 心跳续期这种应用层方案别指望系统调用能救你。第四个坑日志会骗你。排查这类问题时我一度以为锁完全没生效因为日志里两个进程的进入临界区时间戳有重叠。后来发现是日志写入本身有缓冲延迟时间戳记录的是logging调用时刻而实际写盘更晚。判断锁是否生效永远用共享状态的最终值去验证就像 6.2 节那个计数器不要用日志时间戳。最后分享一个我现在的习惯凡是新项目引入任何涉及文件锁、进程间通信、信号处理的依赖我都会先在 Windows 上跑一遍最小用例五个平台的坑当场就能暴露出来不用等到交付前一周才发现整个模块跑不起来。这个成本大概是半小时但省下的返工时间往往是几天。至于那个fcntl报错本身拆到底无非就是系统调用模型不同这一句话真正花时间的从来不是理解它而是定位到是哪一层依赖把它引进来的——所以那把 import 钩子和那段静态扫描脚本建议你现在就收进工具箱里。