在Python多线程程序中,如果多个工作线程共同向同一个日志文件或数据文件执行写入,往往会得到残缺不全的结果。这并不是Python本身有bug,而是因为标准库里的文件对象没有内置线程安全保护,当操作系统在两个线程的写调用之间发生上下文切换,字节流就会互相穿插。

一、为什么并发写文件会出问题
CPython的文件写操作通常分为两步:先把用户缓冲区的数据准备好,再调用系统调用write落到内核。线程A刚把一半内容送进缓冲区,时间片用完,线程B抢到CPU并把另一段内容写入,最终文件里两行日志交错在一起。更隐蔽的情况是,某些平台上的write并不保证原子性,尤其是写入长度超过PIPE_BUF或者使用了带缓冲的TextIOWrapper时。
下面这段示例代码模拟了两个线程不加保护地写同一个文件,运行几次就能看到内容混乱:
import threading
def bad_writer(name, f):
for i in range(100):
f.write(f"线程{name}写入第{i}行n")
with open("test_bad.log", "w", encoding="utf-8") as f:
t1 = threading.Thread(target=bad_writer, args=(1, f))
t2 = threading.Thread(target=bad_writer, args=(2, f))
t1.start()
t2.start()
t1.join()
t2.join()
上述代码里两个线程共用同一个文件句柄f,没有任何同步,最终test_bad.log里经常出现“线程1写入第线程2写入第”这样的夹杂行。这种问题在压力小的时候可能很久才出现一次,一旦上线就很难排查。
二、使用Lock互斥锁做同步
threading.Lock是最直接的方案。它只有两个状态:锁定和未锁定。哪个线程先调用acquire拿到锁,其他线程就必须阻塞等待,直到release释放。把写文件的逻辑放进锁保护的临界区,就能保证同一时刻只有一个线程在写。
改写后的安全版本如下:
import threading
lock = threading.Lock()
def safe_writer(name, f):
for i in range(100):
with lock:
f.write(f"线程{name}写入第{i}行n")
f.flush()
with open("test_safe.log", "w", encoding="utf-8") as f:
t1 = threading.Thread(target=safe_writer, args=(1, f))
t2 = threading.Thread(target=safe_writer, args=(2, f))
t1.start()
t2.start()
t1.join()
t2.join()
这里用with lock自动管理锁的获取和释放,即使写的过程中抛异常也不会死锁。flush确保数据及时刷盘,避免进程退出时缓冲丢失。Lock的优点是简单直观,缺点是所有写操作串行,高并发下可能成为瓶颈。
如果同一个线程可能嵌套调用写函数,应该用threading.RLock代替Lock。RLock允许持有锁的线程重复acquire,每加一次锁计数加一,release对应减一,直到计数归零才真正释放,可避免自己锁死自己。
三、用队列解耦生产与消费
另一种更优雅的思路是:工作线程只往queue.Queue里塞数据,单独启动一个writer线程从队列取数据并写文件。这样文件句柄只被一个线程接触,从根本上消灭竞争,也不用给每次写加锁。
import threading
import queue
q = queue.Queue()
def producer(name):
for i in range(100):
q.put(f"线程{name}写入第{i}行n")
def consumer(f):
while True:
line = q.get()
if line is None:
break
f.write(line)
f.flush()
q.task_done()
with open("test_queue.log", "w", encoding="utf-8") as f:
c = threading.Thread(target=consumer, args=(f,))
c.start()
p1 = threading.Thread(target=producer, args=(1,))
p2 = threading.Thread(target=producer, args=(2,))
p1.start()
p2.start()
p1.join()
p2.join()
q.put(None)
c.join()
队列模型把“产生日志”和“落盘”分开,生产者之间互不影响,消费者单线程顺序写,既安全又容易扩展成多文件或者网络发送。代价是多了一个常驻线程和一层内存缓冲,需要处理好程序退出时的收尾,比如放一个None作为结束信号。
四、方案对比与选择建议
三种常见做法的特点可以归纳如下:
| 方案 | 实现复杂度 | 性能表现 | 适用场景 |
|---|---|---|---|
| 无锁共用句柄 | 最低 | 看似最快实则数据错乱 | 绝不推荐 |
| Lock/RLock保护 | 低 | 写操作串行,中等吞吐 | 线程少、偶尔写 |
| Queue单线程写 | 中 | 生产者并行,吞吐高 | 高频日志、多生产者 |
实际项目里,如果只是在几个线程里偶尔记点状态,加把Lock足够。如果是Web服务里几十个线程打日志,用队列或者直接用logging模块的内置QueueHandler更稳妥。无论选哪种,记住文件对象本身不帮你想好同步,责任永远在写代码的人身上。
五、小结
并发文件写入的核心矛盾是“共享资源加非原子操作”。理解线程切换导致的交错,才能明白锁不是多余的动作。Lock解决有无问题,RLock解决重入问题,Queue解决架构隔离问题。把示例跑一遍,观察加锁前后文件内容的差异,比单纯记结论更有用。