fcntl:Python 标准库最薄的一层 Unix
fcntl 是 Python 对 fcntl(2) 等一批 Unix 系统调用的直接封装:文件锁、fd 标志位、设备控制都从这里进。本文梳理它的常用入口——flock、lockf、F_SETFL、FD_CLOEXEC、ioctl——重点掰清 flock 与 lockf 这对名字最容易混的兄弟,并给出各自最省事的写法。
一层几乎不遮掩的封装
Python 的抽象层层叠叠:open() 返回文件对象,文件对象裹着 fd,fd 后面才是内核。绝大多数场景这个封装够用,但总有些需求会顶到天花板上——给文件上锁、把串口设成非阻塞、查终端窗口大小。这些事内核都有现成的系统调用,Python 不打算重新发明,只是递了个门牌号过来:fcntl 模块,函数签名和 man page 一一对应。
薄有薄的好处,你在 man fcntl(2) 里读到的语义原封不动适用于 Python 侧;坏处是它不做任何跨平台兜底,Windows 上直接 ModuleNotFoundError。所以它天生服务于 Unix 系编程,Linux、macOS 放心用,跨平台产品得绕开。模块里真正常用的就四个函数:flock() 加整文件锁,lockf() 加字节区间锁,fcntl() 读写 fd 与文件状态标志,ioctl() 和设备驱动打交道。一个个说。
先掰清楚:flock 还是 lockf
Unix 在系统调用层面就有两套文件锁:BSD 出身的 flock(2),和 POSIX 的 fcntl(2) 记录锁。Python 把两套都装进了 fcntl 模块,函数名分别叫 fcntl.flock 和 fcntl.lockf——lockf 名字里那个 f 极具迷惑性,它的实现恰恰走的是 fcntl(2) 记录锁那条路。在 Linux 上两把锁互不感知:进程 A 拿 flock 锁住文件,进程 B 用 lockf 照样加锁成功,双方都以为独占,临界区实际裸奔。顺带一个我在 macOS 上实测撞见的差异:BSD 系把 flock 实现在 POSIX 记录锁之上,两把锁在 mac 上反而会互相冲突——同一份代码跨 Linux/macOS 行为不一致,更加说明第一条铁律的必要性:同一组会冲突的文件访问必须统一用同一把锁。两把锁在归属、继承、释放时机上的完整对比,flock 里有整篇展开,这里只讲 Python 侧怎么用。
最经典的场景是单实例守护:cron 起的定时任务上一轮没跑完,下一轮又进来,两个进程同时写一个文件。三行解决:
import fcntl, os, sys
fh = open('/var/run/etl.lock', 'w')
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
sys.exit('已有实例在运行,本轮跳过')
fh.write(str(os.getpid())) # 锁文件内容无所谓,写 pid 纯粹方便排查LOCK_EX 要排他锁,LOCK_NB 拿不到就抛 BlockingIOError(OSError 的子类,EAGAIN 的官方形态)。锁函数接受文件对象也接受裸 int fd,内部只认 fileno()。这套方案最优雅的地方在于锁的生命周期等于 fd 的生命周期:进程正常退出、被 kill -9、机器掉电重启,内核都会关闭 fd、回收锁,不需要任何 finally 清理。对比 PID 文件那套”启动写 pid、退出删 pid、残留了还得手动清”的做法,干净得不像话。
需要阻塞等待的临界区,包一层上下文管理器最顺手:
from contextlib import contextmanager
import fcntl
@contextmanager
def file_lock(path):
with open(path, 'w') as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
yield
with file_lock('/var/run/index.lock'):
rebuild_index() # 出 with 即关文件即释放锁fcntl.lockf 的签名在此基础上多了 len 和 start:不传就锁整个文件,传了就只锁 [start, start+len) 这段字节。适用场景是多个进程分段写同一个文件——进程 A 锁前 4 KB 写索引头,进程 B 同时锁后面 8 MB 写数据段,互不干扰。这个粒度 flock 给不了。
fcntl.lockf(fh, fcntl.LOCK_EX, 4096, 0) # 只锁文件头部 4 KB;注意只收位置参数顺带一个只有实测才会发现的细节:lockf 的 len、start、whence 只接受位置参数,写成 len=4096 会直接 TypeError——CPython 里它不是关键字友好的签名。
lockf 有一个 Python 侧特别容易踩的雷,值得单独记一笔:记录锁挂在进程上,但本进程内任意一个指向该文件的 fd 关闭,这个文件上的所有记录锁就全部释放——哪怕那个 fd 根本没参与过加锁。多线程代码里某个角落 open 了同一文件又随手 close,你精心加好的锁就没了。另外,同一进程对同一区域重复 lockf 是覆盖而非阻塞,想拿它在进程内做线程同步是不成立的,它从头到尾只管进程间的事。
F_GETFL 与 F_SETFL:open 之后改主意
os.open() 的 flags 只在打开那一刻生效,之后想改——比如把管道读端切成非阻塞——就得靠 fcntl.fcntl() 读写状态标志。这是它三参数形态最常用的场景:
import fcntl, os
fd = os.open('/dev/ttyUSB0', os.O_RDWR)
flags = fcntl.fcntl(fd, fcntl.F_GETFL) # 第一步永远是先取现状
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) # 在现状上叠加,再写回固定套路是F_GETFL 取回、位或叠加、F_SETFL 写回,一步都不能省。F_SETFL 是整体覆盖式赋值,直接写 fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK) 会把 O_APPEND 等已有标志一并抹掉——这个坑在串口、socket 编程的旧代码里出现频率极高。还有个边界要知道:F_SETFL 只对 O_NONBLOCK、O_APPEND、O_ASYNC 这几个运行时可变的标志有效,读写模式(O_RDONLY/O_RDWR)改不了,那在 open 时就定死了。
顺带一个高频小需求,判断文件是否非阻塞:F_GETFL 拿到的 int 本质是位掩码,flags & os.O_NONBLOCK 真值判断即可,不需要任何轮询或异常试探。
FD_CLOEXEC:exec 前的门禁
fcntl.fcntl(fd, fcntl.F_SETFD, fcntl.FD_CLOEXEC) 给 fd 打上”exec 时自动关闭”标记,防止 fork + exec 出去的子进程意外继承一堆无关 fd。老代码里这行随处可见,但 Python 3.4 起(PEP 446)新建 fd 默认不可继承,等于 CLOEXEC 成了出厂默认。今天你更常做的反而是反向操作——把某个 fd 特意传给子进程:
os.set_inheritable(fd, True) # Pythonic 写法
fcntl.fcntl(fd, fcntl.F_SETFD, 0) # 等价的裸写法,读旧代码时认得就行或者用 subprocess.run(..., pass_fds=(fd,)),框架会顺手处理好继承标记。这一节真正的价值在排查:看到子进程握着不该有的 fd、上一把锁莫名不释放,得知道去哪查——lsof 看谁开着文件,flock 那篇里专门写过”fd 泄漏等于锁泄漏”的事故模型。
ioctl:什么都能塞的抽屉
fcntl.ioctl() 是内核留给设备驱动的通用后门,request 是什么全凭驱动定义,所以它没有”典型用法”,只有具体案例。最常被抄的一个是取终端窗口大小:
import fcntl, struct, termios
raw = fcntl.ioctl(0, termios.TIOCGWINSZ, b'\x00' * 8) # 0 = stdin
rows, cols, _, _ = struct.unpack('HHHH', raw)两个细节:TIOCGWINSZ 常量放在 termios 模块而不是 fcntl 里,第一次用几乎必踩;缓冲区要按结构体大小给足 8 字节。真要取终端尺寸,日常直接用 shutil.get_terminal_size(),它就是这套 ioctl 的成品包装,还带环境变量兜底。ioctl 值得亲手写的时机,是你对着某个驱动的文档、必须发一个特定 request 的时候。
边界与替代品
能力边界与 flock 那篇的结论一致,这里补 Python 视角的几条。Windows 完全不可用,连 import 都过不去;需要跨平台的锁就用 filelock 或 portalocker,它们在 Unix 下转调 fcntl、在 Windows 下转调 msvcrt.locking,把分歧抹平了。NFS 上别赌,两把锁在网络文件系统上的行为都不可靠,多机互斥直接上 Redis SET NX 或 etcd 租约。至于进程内的同步——线程用 threading,子进程间用 多进程——multiprocessing 的原生原语。文件锁是为”独立启动、互不知情的进程”准备的,别拿它干细粒度调度的活。
收尾
fcntl 模块没有多少”功能”,它只是把 Unix 已有的东西原样递到你手里。大多数 Python 工程师一辈子只会用到 flock() 那三行单实例守护,这完全够用;但 F_SETFL 的取回-叠加-写回、CLOEXEC 的默认值变迁、lockf 的全释放雷区,这几处值得留在脑子里——它们出现在报错现场时往往伪装成别的问题:写文件乱序、锁莫名失效、子进程行为诡异。
选型层面真正要做的判断其实只有一个:flock 还是 lockf。拿 flock 结尾那三个问题——谁持有?何时释放?怎么绕过?——分别去问这两把锁,答案几乎处处相反,而答案的反差本身,就是你的决策依据。