之前有个使用 fcntl
加锁的问题挺有意思的,在这里回顾一下。背景是这样的,服务需要加载数据库的配置,但是配置的变更很少。服务是基于 flask
框架的多进程服务,因此使用了本地文件去做缓存,使用定时任务去更新本地缓存,为了避免读写冲突,这里使用了 fcntl
锁定文件,刚开始读写的操作是这样的:
def read():
with open(cache_file, 'r') as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
data = json.load(f)
return data
def write():
with open(cache_file, 'w') as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
json.dump(data)
后来某一日在 json.load()
出现了问题,json 解码失败:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
通过查看日志出现的时间点,发现存在一定的规律,出现的时间间隔恰好是五分钟的倍数,秒级别是相等的,毫秒级别是几乎一样的,因此锁定到了定时任务导致读写冲突的问题。因此,写了一个demo来复现该问题:
# readfile.py
import fcntl
import time
import random
import json
filename = "demo.json"
for i in range(1000):
time.sleep(random.randint(0, 100) * 0.001)
with open(filename, "r") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
data = json.load(f)
print("read ok")
# writefile.py
# writefile.py
import fcntl
import time
import random
import json
data = {"x": "x", "y": "y"}
filename = "demo.json"
for i in range(1000):
time.sleep(random.randint(0, 100) * 0.001)
with open(filename, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
json.dump(data, f)
print("write ok")
果然,在 ·readfile.py
的程序中出现了异常:
Traceback (most recent call last):
File "/data/speech/aitts-paas/access_control_service/readfile.py", line 13, in <module>
data = json.load(f)
File "/usr/local/lib/python3.9/json/__init__.py", line 293, in load
return loads(fp.read(),
File "/usr/local/lib/python3.9/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
File "/usr/local/lib/python3.9/json/decoder.py", line 337, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
File "/usr/local/lib/python3.9/json/decoder.py", line 355, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
有意思的是,从代码的commit发现了有实习生尝试通过对 json.load()
进行 try-except
进行异常捕获然后重试,但显然是治标不治本,而且并不能降低异常触发的概率,因为写入是 io 操作,占用句柄的时间长度是足够长的。
后续通过查询 fcntl
的使用方法,发现确实是可以对文件进行加锁的,那为什么这里会失效呢。后面重复执行上面的测试,出现的异常都是:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
这里面的 char 0
值得关注,会不会是读操作拿到的文件句柄是空的?哦!如果写操作使用的是截断模式,是有可能直接清空文件的。然后发现上面的代码,不管是读还是写,都是先获取句柄,然后再使用 fcntl
加锁,这样可能会出现下面的时间序:
1. writefile: with open(filename, 'w') as f
2. readfile: with open(filename, 'r') as f
3. readfile: fcntl.flock(f, fcntl.LOCK_EX)
4. readfile: data = json.load(f)
这个时候 json.load(f)
读取的文件句柄就一定是空的,因此会出现上面的问题。那么问题发现了,如何解决呢?事实上如果我们需要使用 fcntl
进行加锁,那么我们必须先拿到文件句柄,就一定是先拿句柄再进行加锁,但势必会出现上面的偶发加锁失败的情况。这个时候需要引入外部因素来打破这个局面,我们引入额外的一个文件 lockfile
,这个文件只用来加锁,这个时候,读写操作应该是这样的:
# readfile.py
import fcntl
import time
import random
import json
filename = "demo.json"
for i in range(1000):
time.sleep(random.randint(0, 100) * 0.001)
with open("lockfile", "r") as lockf:
fcntl.flock(lockf, fcntl.LOCK_EX)
with open(filename, "r") as f:
data = json.load(f)
print("read ok")
# writefile.py
import fcntl
import time
import random
import json
data = {"x": "x", "y": "y"}
filename = "demo.json"
for i in range(1000):
time.sleep(random.randint(0, 100) * 0.001)
with open("lockfile", "w") as lockf:
fcntl.flock(lockf, fcntl.LOCK_EX)
with open(filename, "w") as f:
json.dump(data, f)
print("write ok")
注意,这里使用阻塞模式下的锁,不然没有获取到锁会出现 IOError
,我们只是读写文件,阻塞时间一般能够容忍。另外,如果句柄释放的话,持有该句柄的锁也会释放,因此可以使用 with
语法糖来实现释放锁。
总结
在使用 fcntl
加锁时,必须考虑获取句柄的时间顺序是否会产生非预期的结果,虽然 fcntl
能够建立后续操作的保护区间,但是获取句柄的时间顺序是无法保证的,因此使用 fcntl
加锁的句柄一定不能是操作目标,而应该引入额外的文件。