先说一个我踩过的坑。某次给一套后台任务系统加并发日志,10个线程同时处理任务,每处理完一条就往同一个文本文件末尾追加一行。跑到两百多条时,程序突然抛了 IOException:文件正由另一进程使用。当时我的第一反应是“文件被占用了”,于是随手加了 lock,问题确实不再抛了,但整个系统的写入吞吐跟着掉了一截。后来换成 C# 的 ReaderWriterLockSlim(读写锁)才平衡下来——它既能保证多线程并发写入文件时不互相打架,又不会把明明是读操作的任务也一起锁死。
这篇文章就把这件事从头到尾讲透:为什么多线程写文件会乱、为什么我会选读写锁而不是一把大锁、接入读写锁时要注意哪些细节,以及什么情况下应该考虑别的方案。如果你是正在写日志组件、状态落盘或者多线程文件写入功能的开发者,这篇应该能帮你少走不少弯路。
1. 并发写入文件为什么会乱:先搞清楚问题根源
1.1 两个线程同时写同一个文件,底层到底发生了什么
很多人以为“写文件”就是简单的:打开文件、写字符串、关闭文件。但放到多线程环境下,这里藏着两个层面的问题。
第一层是内存态的问题。FileStream 本身不是线程安全的,它内部维护着一个文件位置指针Position。假如两个线程共享同一个 FileStream 实例,线程 A 读了一下当前 Position,准备从这个位置写数据;线程 B 也读了一下 Position,两个线程拿到同一个位置,于是后写的数据把先写的数据覆盖了一部分。即使文件是以 Append 模式打开的,Write 操作本身也涉及内部状态维护、缓冲区操作、底层系统调用,多个线程并发调用同一个 FileStream 的 Write 方法,根本不能保证每次写入的内容完整、有序地落盘。结果是内容串行交叉,或者行被截断。
第二层是系统态的问题。如果每个线程为了“省事”,每次都自己 new 一个 FileStream 去打开同一个文件,那更麻烦。Windows 上的文件句柄默认有共享模式限制,一个句柄打开文件时如果没有声明FileShare.ReadWrite,其他句柄要么只能读,要么根本无法打开。最典型的结果就是第二个线程打开文件时直接抛异常,程序在中途崩溃。
这其实属于线程同步问题,而不是“文件写坏了”这么简单。你需要的不是疯狂重试,而是一个能让所有线程“排队进入”的协调机制,同时对读操作保持一定并发度。读写锁就是专门为这种场景设计的。
1.2 实测复现:你大概率先遇到“文件正被占用”异常
我先把一个非常典型的失败复现贴出来,这段代码模拟的就是“多个工作线程同时往同一个日志文件追加内容”:
static async Task Main(string[] args) { string filePath = Path.Combine(AppContext.BaseDirectory, "concurrent.log"); // 清掉旧文件 File.Delete(filePath); var tasks = new List<Task>(); for (int k = 0; k < 10; k++) { int threadNo = k; tasks.Add(Task.Run(() => { for (int i = 0; i < 200; i++) { File.AppendAllText(filePath, $"Thread {threadNo} | Line {i} | {DateTime.Now:HH:mm:ss.fff}\n"); } })); } await Task.WhenAll(tasks); var lines = File.ReadAllLines(filePath); Console.WriteLine($"Total lines = {lines.Length}"); for (int i = 0; i < Math.Min(30, lines.Length); i++) { Console.WriteLine(lines[i]); } }这段代码看起来人畜无害,实际跑起来的时候,我在 Windows 上遇到最多的情况有两种。
第一种是直接抛IOException: The process cannot access the file ... because it is being used by another process.原因就是File.AppendAllText内部采用默认的FileShare.Read,同一时间只允许一个写句柄存在,其他线程并发打开文件时直接被拒。
第二种情况更隐蔽:当你手动把每个线程的 FileStream 打开方式统一改成FileShare.ReadWrite后,异常确实消失了,但最终日志文件的行数少于预期,或者某一行只写了一部分,紧接着就被另一线程的写入内容“拦腰截断”。因为多个文件句柄同时拥有写权限,在没有同步机制的情况下,每次写操作的开始和结束位置都是独立的,数据在磁盘上就会交错。
这里的结论很明确:多线程并发写入文件,如果不做线程同步,结果要么是运行期异常,要么是静默的数据损坏,而且后者比异常更危险,因为你可能很久之后才会发现日志少了一段。
2. 为什么选读写锁而不是一把大锁
2.1 读写锁的加锁模型:读可以搭伴,写必须独占
在说读写锁之前,先说说为什么不能无脑用 lock。lock 在 C# 里本质上是 Monitor 的语法糖,它实现的是“互斥”:同一时刻只允许一个线程进入临界区。在这个场景里,如果只是“写文件”这一个操作,那 lock 确实能解决问题——所有写操作排队来,永不并发。
但它的问题也很明显:它不区分读和写。
假设你的日志文件不只是被写入,还有一个后台监控线程每隔几秒就会读一下文件行数,或者看下日志尾部有没有报错关键字。如果用 lock,那么“写一行日志”和“读一次文件”会互相阻塞。读操作明明是可以并行的,但被一把不加区分的大锁全部串行化,读多写多的时候吞吐量会很难看。
读写锁则把临界区访问分成两种模式:
- 读锁:在没有写者持有锁时,多个读者可以同时进入临界区;
- 写锁:写者进入时必须是独占的,所有读者和其他写者都必须等待;
- 当有写者在等待时,新的读者会被拦下来,避免写者“饿死”。
你可以把读写锁想象成一个跨江大桥的管理员:读的人们可以三五个结伴同时上桥,只要大家承诺只看看风景不去动桥上的设施;但写的人必须等桥上所有人都下来才能上桥,并且一旦有写者在桥头排队,后到的人也不能再抢先上桥。这个模型天然契合“配置文件、日志文件、状态文件”一类读多写少、但写入必须串行的场景。
2.2 用表格对比 lock 与 ReaderWriterLockSlim
C# 里直接可用的读写锁有两个历史版本:老旧的ReaderWriterLock和新版的ReaderWriterLockSlim。老版本由于内部算法复杂、性能差,微软官方都建议新代码直接使用ReaderWriterLockSlim,所以我下面说的都是后者。
先看一张对比表:
| 对比项 | lock(Monitor) | ReaderWriterLockSlim |
|---|---|---|
| 读读并发 | 互斥,串行 | 共享,可并行 |
| 读写并发 | 互斥 | 互斥 |
| 写写并发 | 互斥 | 互斥 |
| 适合场景 | 读写比例接近、临界区极短 | 读多写少,如日志文件、配置读取 |
| 写锁成本 | 较低 | 略高,因为它要维护读者计数和等待队列 |
| 可重入性 | 支持 | 默认不支持,可配置,但一般不推荐 |
从这个表能看出来的关键结论是:如果你的场景是“几乎所有线程都要写,几乎没有并发读”,读写锁回报不高,直接用 lock 反而更简洁、更快;如果你有大量读取动作,或者写入和读取交错频繁,读写锁能明显降低无谓的阻塞。
在我那个日志模块的场景里,除了10个工作线程要写日志,还有两个监控任务会定期读日志文件做统计,这时候 lock 会让监控任务和工作线程互相卡顿,而ReaderWriterLockSlim能把读操作并行起来,整个系统的日志写入不再成为瓶颈。
3. 用ReaderWriterLockSlim守护文件写入的完整实践
3.1 一个最小可用的写锁保护封装
下面这个封装是我在实际项目里比较常用的写法,先把核心代码贴出来:
public sealed class ThreadSafeFileWriter { private readonly string _filePath; // 注意:锁必须是一个共享的单例字段,绝不能每次 new 一个 private readonly ReaderWriterLockSlim _rwLock = new(); public ThreadSafeFileWriter(string filePath) { _filePath = filePath; } public void AppendLine(string text) { _rwLock.EnterWriteLock(); try { using var writer = new StreamWriter(_filePath, append: true, Encoding.UTF8); writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {text}"); } finally { _rwLock.ExitWriteLock(); } } }使用起来很简单,所有要写日志的线程都调用同一个ThreadSafeFileWriter实例的AppendLine方法即可。
这里有两个容易踩的点值得展开。
第一个是锁实例必须是所有线程共享的同一个对象。如果你在每个线程里各自创建了一个ThreadSafeFileWriter,那它们各自的_rwLock也是不同的锁实例,锁完全不生效。这个错误我见过不止一次,尤其是当代码被封装到类里之后,使用者往往不知道这个类应该被单例使用。
第二个是每次写入都打开、关闭一次 StreamWriter,看起来有点浪费,但实际上这是故意的。跨线程共享同一个 FileStream 才是更大的麻烦,因为 FileStream 的 Position、缓冲区、句柄状态都要在线程间协调,一不小心就会出问题。每次打开文件、写一行、关闭文件,配合锁的粒度恰好就是“一次写入操作”,逻辑边界最清晰。在日志行这种小尺寸写入场景下,这种开销完全可以接受。
3.2 用读锁接住“读日志”的监控线程
写入逻辑解决了,读取逻辑也要用对应的锁。如果你有多个监控线程会同时读这个文件,可以在读路径上使用EnterReadLock:
public long GetLineCount() { _rwLock.EnterReadLock(); try { using var fs = new FileStream(_filePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(fs); long count = 0; while (reader.ReadLine() is not null) { count++; } return count; } finally { _rwLock.ExitReadLock(); } }关键点是读操作和写操作必须共用同一把锁。如果写线程用_rwLock的写锁,而监控线程用 lock 或者干脆不加锁,那么“读文件”和“写文件”还是可能同时发生,锁就形同虚设。
另外注意,这里读取文件时打开句柄要指定FileShare.ReadWrite。否则即使你的读写锁再完善,系统层面的文件共享模式不兼容,照样会抛异常。锁解决的是逻辑上的同步顺序,FileShare 解决的是系统句柄层面的共存权限,两者必须配合使用。
3.3 锁的粒度和FileStream生命周期要一起设计
我见过一种挺尴尬的写法:锁加了,但是每次加锁的粒度是“用一个共享 FileStream 写一行”,FileStream 本身是在类构造时打开的,写入完成后既不关闭也不刷新。表面上看所有线程都排队了,但实际上 FileStream 内部缓冲区可能会把未写入的数据“憋”在内存里,程序异常退出时这部分日志全部丢失。
锁的粒度要和 FileStream 的生命周期一起设计。如果你选择“常驻一个 FileStream 实例,所有线程共用”,那么你必须保证:
- 锁的覆盖范围是所有对这个 FileStream 的操作;
- 每次写入后决定是否 Flush,明确丢数据窗口;
- 关闭流和锁的释放顺序必须正确,否则会留下一个已经关闭的句柄让后续线程继续使用。
我个人的习惯是:在不确定写入频率的场景下,优先采用“每次写一行就开关文件”的简单模型。等以后通过压测发现有性能瓶颈时,再改成常驻 FileStream 并配合更细粒度的批量刷新。过早优化反而会让代码复杂化。
4. 上线前必须处理好的细节:超时、异常与文件共享模式
4.1 ExitWriteLock必须挂在finally里
这是我认为整个读写锁使用中最重要的一条经验:EnterWriteLock之后,所有可能抛异常的操作都必须被try-finally包住,保证ExitWriteLock一定会执行。
原因很简单。如果写文件过程中抛了异常,而你没有finally释放锁,这把写锁就会被“永远占用”。从那一刻开始,任何其他线程调用EnterWriteLock都会无限期阻塞,程序呈现出一种“卡死但又不完全卡死”的诡异状态——任务还在跑,但所有涉及日志写入的线程全部挂起。
我曾在某个项目里排查过一个“日志模块把整个进程卡死”的问题,折腾了很久才发现是一个文件写入异常把锁“吞”了,而锁一直被某个线程持有。从那以后,我对锁的释放逻辑就一条标准:凡是EnterWriteLock,后面必须有finally配对。
_rwLock.EnterWriteLock(); try { // 真正写文件的逻辑 } finally { _rwLock.ExitWriteLock(); }这看起来是常识,但在真实代码里经常被忽略,尤其是当 try 代码块比较长、嵌套比较多的时候。
4.2 用TryEnterWriteLock给无限等待加一道保险
EnterWriteLock默认是无限期等待的。如果代码里有某个线程死锁了或者长时间不释放锁,其他线程会一直堵在入口处,最后整个应用像被点了穴一样。
更稳妥的做法是给加锁操作设置超时时间,用TryEnterWriteLock:
if (_rwLock.TryEnterWriteLock(TimeSpan.FromSeconds(5))) { try { File.AppendAllText(filePath, text); } finally { _rwLock.ExitWriteLock(); } } else { // 锁等待超时,说明可能存在死锁或长时间持锁 // 这里应该记录一条错误日志,或者把数据放回重试队列 }注意:超时返回 false 不代表你的文件写入失败,而是说“在 5 秒内没有拿到锁”。真实的业务场景里,日志通常是不能直接丢的,所以超时后的处理策略应该是“重试”或“暂时缓存”,而不是静默跳过。
我在实际项目里会把超时时间设置成一个明显偏大的值,比如 5 秒,因为正常情况下写一行日志根本不会超过几十毫秒。一旦真的出现超时,说明系统已经有严重的持锁异常,此时立刻报警比反复重试更有价值。
4.3 FileShare不配合,锁也救不了你
读写锁只在你加锁的代码路径里有效。假如有某一段代码绕过锁,直接new FileStream去打开同一个文件,那么:
- 如果这个文件流以
FileShare.None打开,它会独占文件,你锁保护内的其他写入会全部抛“正由另一进程使用”; - 如果锁保护内的写入流用了
FileShare.None,那么外部直连的读取也会被打断。
所以我在写文件工具类时,会统一约定打开方式。写路径用:
using var fs = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);读路径也是同样:
using var fs = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite);FileShare.ReadWrite的意思是这个句柄允许其他句柄同时读或写这个文件。读写锁负责协调“逻辑顺序”,FileShare.ReadWrite负责让多个句柄在系统层面能共存。两者缺一不可。
5. 读写锁的隐藏坑:重入、死锁与性能误判
5.1 默认不允许重入,别被名字误导
很多刚接触ReaderWriterLockSlim的程序员会有一个直觉:“Slim 嘛,轻量级,那重入应该没问题吧?”事实恰恰相反:new ReaderWriterLockSlim()默认采用LockRecursionPolicy.NoRecursion,也就是说,如果你已经持有写锁,在内部方法里又调用了一次EnterReadLock或EnterWriteLock,会直接抛LockRecursionException。
举个例子:
_rwLock.EnterWriteLock(); try { DoSomething(); } finally { _rwLock.ExitWriteLock(); } void DoSomething() { _rwLock.EnterReadLock(); // 这里会抛 LockRecursionException try { // ... } finally { _rwLock.ExitReadLock(); } }要允许这种情况,必须在构造时显式传LockRecursionPolicy.SupportsRecursion:
var rwLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);但我个人不推荐开递归。原因很简单:递归锁会让“当前线程到底持有什么锁”变得难以跟踪,尤其是在调用层级较深的工程里。更干净的做法是把方法拆成两层,一层是不加锁的内部实现,另一层是给外部调用的加锁版本:
private void WriteCore(string text) { // 真正写文件的逻辑 } public void AppendLine(string text) { _rwLock.EnterWriteLock(); try { WriteCore(text); } finally { _rwLock.ExitWriteLock(); } }这样每个公开方法加锁边界清楚,内部方法不需要担心锁状态。
5.2 用读锁包写操作是最容易犯的语义错误
其实这是我在审核别人代码时多次遇到的一个错误,写下来提醒大家:读锁只保证“多个读者可以同时进入”,它不保护写入。
我曾经见过一段代码:
_rwLock.EnterReadLock(); try { File.AppendAllText(filePath, text); } finally { _rwLock.ExitReadLock(); }写的人觉得“追加一行文件,不就是把内容读到内存、再写回去吗?加读锁应该可以吧”。但两个线程同时拿到读锁进入临界区后,它们会同时执行File.AppendAllText,这就回到了最初的问题——多个写操作并发执行,内容照样交叉,异常照样出现。
判断该用读锁还是写锁,依据只有一个:你有没有修改文件内容。只要修改了,不管叫 Append 还是 Replace,都必须用写锁。读锁的作用是保护一段“只读不写”的逻辑。
5.3 性能要靠压测,不能凭感觉加锁
读写锁在“读多写少”场景下表现优秀,但这不代表它是个万能加速器。锁本身有开销,如果临界区太小、操作太快,频繁获取和释放锁的成本甚至会超过锁帮你省下的冲突成本。
比如你把“每次写入一个字符”都包一次写锁,那锁的消耗可能比实际写入还高。更好的做法是一次写入一个完整的数据块,让锁的粒度跟业务操作的粒度一致。
我在本地写过一个小压测,大致思路是用Stopwatch分别测量同一批写入任务在无锁、lock、ReaderWriterLockSlim 写锁三种情况下的耗时。结论比较符合预期:当只有写操作没有读操作时,ReaderWriterLockSlim和lock差距很小,甚至可能因为锁结构更复杂而略慢;但一旦加入并发读取的监控线程,ReaderWriterLockSlim的并发读优势立刻体现出来。
所以别凭感觉选方案,你的目标场景里有“并发读”吗?有,可以考虑读写锁;没有,那就老老实实一把 lock 说不定更简单。
6. 从一把锁到一套方案:读写锁的适用边界与更优替代
6.1 高频日志为什么应该走向队列写
读写锁不是银弹。当写入频率高到一定程度,比如每秒钟数万条日志,哪怕读锁能并行,频繁获取写锁也会成为瓶颈。这种场景下,更合理的架构是“生产者-消费者”:
- 所有工作线程只负责往内存队列里丢日志;
- 后台单独开一个消费者线程,统一从队列取出日志并批量写入文件。
因为写入动作收敛到了唯一一个线程,天然就是串行的,连锁都不需要。C# 里可以用System.Threading.Channels实现得非常清爽:
using System.Threading.Channels; // 全局唯一的 Channel var channel = Channel.CreateUnbounded<string>(); // 启动唯一一个消费者线程 var worker = Task.Run(async () => { await foreach (var line in channel.Reader.ReadAllAsync()) { await File.AppendAllTextAsync(filePath, line); } }); // 任意工作线程投递日志,Channel 内部自己保证线程安全 channel.Writer.TryWrite("hello, world"); // 程序退出前完成并等待消费者处理完 channel.Writer.TryComplete(); await worker;这个方案的优点是吞吐量极高,日志线程几乎不会被阻塞;缺点是内存缓冲有丢失风险——如果进程中途崩溃,队列里还没落盘的那些日志就没了。所以它适合“允许丢少量日志也不致命”的高频监控场景,不适合“一条都不能丢”的强一致场景。
如果既要求高吞吐,又要求不丢数据,那就得引入双缓冲、定时落盘、备份文件一类的复杂设计,但这就超出“一把读写锁能解决的范畴”了。
6.2 场景选型参考表
我把自己在实际工程里常用的选型思路整理成了一张表,方便直接对着选:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 低频日志 / 状态文件,每秒几次写入,另有监控线程读取 | ReaderWriterLockSlim | 读多写少,读写锁能并行读,逻辑简单可靠 |
| 高频日志 / 指标上报,每秒数万条写入 | Channel + 单消费者 | 写动作收敛为单线程,避免锁竞争,吞吐高 |
| 配置文件,几乎不变,但很多线程同时读 | ReaderWriterLockSlim 读锁,或干脆缓存 | 读并发对系统价值大,写几乎不存在 |
| 强一致,要求每一条都落盘才能返回 | 单写者线程 + 同步写 | 必须保证写入顺序和持久化窗口 |
注意,这张表是“推荐起点”,不是终点。具体业务还要结合文件大小、I/O 类型(SSD 还是机械盘)、是否需要跨进程访问等因素去实测调整。
6.3 我的一些实践习惯
聊到这儿,我再分享几个自己操作层面的习惯,算是给读者一个可以直接照做的收尾。
第一,能用一个类把锁封装起来,就不要在业务代码里到处EnterWriteLock/ExitWriteLock。封装之后,锁的获取和释放只出现在一个地方,代码审查和排查问题都轻松很多。
第二,尽量用 Scope 风格去管理锁的释放。比如简单封装一个WriteLockScope,Dispose的时候自动ExitWriteLock,配合using语句使用,能省掉很多手写try-finally的重复代码,也降低漏释放的概率。
第三,如果你发现日志模块已经成为整个系统的瓶颈,先不要急着上各种花哨方案。先用压测量出瓶颈在哪里,到底是锁冲突,还是文件 I/O 本身太慢,还是 GC 压力太大。不同瓶颈需要完全不同的处理方式。
回到开头那个场景:那个后台任务系统的并发写日志问题,最终就是用ReaderWriterLockSlim在“写入”和“读取”之间找到了平衡,后续压测确认写入频率上涨后,我又把它平滑迁移到了 Channel 方案。真正踩过那几次坑之后,你会明白一把锁不是银弹,但用对场合,它确实能把并发写文件这件事平稳落地。