解决fcntl偶发的加锁失败问题

之前有个使用 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 加锁的句柄一定不能是操作目标,而应该引入额外的文件。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值