本文聚焦 C#/.NET 在多线程/多进程同时操作同一文件时的冲突与解决方案,围绕操作系统层的锁语义、.NET 的 FileShare/Lock、跨线程/跨进程同步手段、原子替换与流水线设计,以及我在 Radish 项目中遇到的针对“上传图片:一个方法加水印,另一个方法去除 EXIF”这一典型场景的可复制代码方案展开。
一、基础语义:Windows/Unix 上的文件共享与锁
-
打开共享(open-sharing)
- 在 Windows 上,文件打开时指定访问模式(FileAccess)与共享模式(FileShare)。这不是“可选建议”,而是内核强制的许可表。
- 常用组合:
- 读文件:
FileAccess.Read+FileShare.Read(允许其他读,不允许写) - 写文件(独占):
FileAccess.ReadWrite+FileShare.None(拒绝他人任何访问)
- 读文件:
- 如果现有句柄的共享设置与新打开请求冲突,操作系统拒绝打开,.NET 抛出 IOException(常见消息:进程无法访问文件,因为它正被另一个进程使用)。
-
区域锁(byte-range lock)
- .NET 的
FileStream.Lock/Unlock映射到 OS 的字节区段锁(Windows: LockFile/Ex;Linux: fcntl)。 - 效果:对被锁定的字节范围进行读写会失败(ERROR_LOCK_VIOLATION)。这用于“允许打开但限制某些区域读写”的高阶场景。
- 注意:Unix 上是“协作/建议式”锁(advisory lock):只有遵守 fcntl 锁语义的进程才会被拦截;非配合程序可能绕过。Windows 上为强制。
- .NET 的
-
平台差异
- Windows:open-sharing 与 byte-range lock 都由内核强制。
- Linux/macOS:.NET 尝试模拟 FileShare 的行为,但内核不保证对所有非 .NET 程序强制;byte-range lock 为建议式。更可靠的工程方案是“避免原地写 + 原子替换”。
二、并发冲突的典型症状
- 常见异常
System.IO.IOException: The process cannot access the file '...' because it is being used by another process. (0x80070020)- UnauthorizedAccessException:如果目标文件只读或权限不足。
- FileNotFoundException:并发下重命名/替换产生的路径时序问题导致。
- 触发条件
- 两个方法/进程同时以互斥共享方式打开同一文件(例如一个
FileShare.None,另一个尝试写)。 - 一个线程正在写入,另一个线程读取但未使用合适的共享模式。
- 原地编辑同一文件:A 在写,B 在写/读,导致读写不可用或内容撕裂风险。
- 两个方法/进程同时以互斥共享方式打开同一文件(例如一个
三、工程化方案全景(按可靠度排序)
- 避免原地修改:采用“写入临时文件 + 原子替换”
- 所有变更写到临时文件,完毕后一次性 Replace/Move 覆盖目标文件。
- 既规避读到半成品,也降低加锁范围。
- 管道化/串行化处理(同一进程)
- 通过每文件路径的 SemaphoreSlim/lock 实现同一进程内的串行访问。
- 跨进程互斥
- 使用命名 Mutex(Windows)或基于系统资源的互斥;在 Web 场景可用分布式锁(Redis、SQL sp_getapplock、对象存储租约)。
- 打开共享策略控制
- 读:
FileShare.Read;写:FileShare.None(独占写)。 - 读写皆需,优先规划时序而非并发。
- 读:
- 字节区段锁(可选)
- 在必须共享打开但对特定区域互斥时才考虑。
- 重试与退避
- 处理防病毒、索引器或其他进程短暂占用文件的情况(指数退避 + 上限超时)。
四、同一进程内的协调(线程间)
按文件路径做粒度控制(单机、多线程/Task)。
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
public static class FileGate
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _gates = new(StringComparer.OrdinalIgnoreCase);
public static async Task<IDisposable> EnterAsync(string path, CancellationToken ct = default)
{
var key = System.IO.Path.GetFullPath(path);
var sem = _gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ct).ConfigureAwait(false);
return new Releaser(key, sem);
}
private sealed class Releaser : IDisposable
{
private readonly string _key;
private readonly SemaphoreSlim _sem;
private bool _disposed;
public Releaser(string key, SemaphoreSlim sem) { _key = key; _sem = sem; }
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_sem.Release();
// 可选:清理空闲信号量(需要引用计数,这里略)
}
}
}
用法(同一进程内确保每个文件串行处理):
using var _ = await FileGate.EnterAsync(imagePath, ct);
// 在此范围内对 imagePath 的操作互斥(线程级)
五、跨进程协调(单机)
- 命名 Mutex(Windows)
- 名称建议带前缀与路径哈希,使用 Global\ 前缀跨会话。
- 建议设置获取超时与异常处理,避免死锁与“弃置互斥量”(AbandonedMutexException)。
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
public sealed class NamedFileMutex : IDisposable
{
private readonly Mutex _mutex;
private bool _hasHandle;
public NamedFileMutex(string path)
{
var name = "Global\\ImageProc:" + HashPath(path);
_mutex = new Mutex(false, name);
}
private static string HashPath(string path)
{
var p = System.IO.Path.GetFullPath(path).ToUpperInvariant();
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(p));
return Convert.ToHexString(bytes);
}
public void Acquire(TimeSpan timeout)
{
if (!_mutex.WaitOne(timeout))
throw new TimeoutException("获取文件互斥超时。");
_hasHandle = true;
}
public void Dispose()
{
if (_hasHandle)
{
try { _mutex.ReleaseMutex(); } catch { /* 忽略 */ }
}
_mutex.Dispose();
}
}
用法:
using var m = new NamedFileMutex(imagePath);
m.Acquire(TimeSpan.FromSeconds(15));
// 在此范围内,多个进程对同一文件路径互斥
- 分布式环境(多节点):建议使用 Redis 分布式锁(RedLock)、数据库锁(SQL Server sp_getapplock)、对象存储租约(例如 Azure Blob Lease)。
六、打开共享策略:FileMode/FileAccess/FileShare
- 只读打开(允许其他读,但阻止写):
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
- 独占写(阻止其他任何访问):
new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
- 常见坑
File.OpenWrite(path)相当于FileShare.None,可能与另一方的读/写冲突。File.ReadAllBytes(path)底层是FileShare.Read,若另一方独占写将失败。- 忽视共享模式的库调用(第三方库可能内部用不同共享策略)。
提示:尽量显式使用 FileStream 构造函数并指定 FileShare,避免隐式行为。
七、推荐的核心模式:临时文件 + 原子替换
思路:
- 任何变更都写到 temp 文件。
- 写完后用原子操作替换目标文件:同目录、同卷内进行替换(保证原子性)。
- Windows:
File.Replace(temp, dest, backup, ignoreMetadataErrors: true) - Unix:
File.Move(temp, dest, overwrite: true)(同目录下原子覆盖)。.NET 的File.Replace在 Unix 不可用。
优点:
- 读者始终读到一个“完整版本”的文件。
- 写者不需要长时间持有独占锁,只需在替换瞬间短暂占用目录元数据。
八、面向“加水印 vs 去 EXIF”冲突的可落地方案
思路:两种方法不要同时“原地修改”同一文件。统一到一个流水线,或使用互斥 + 临时文件 + 原子替换,保证最终一致性。
- 设计顺序建议:先去除 EXIF,再加水印(EXIF 操作是元数据层,水印是像素层)
- 如果必须拆成两个独立任务/进程:
- 用命名 Mutex 确保同一路径的互斥窗口;
- 每个任务对文件进行“读取 → 处理 → 写到 temp → 原子替换”的事务式更新;
- 或者将两个步骤合并为同一工作进程中的串行管道。
下面给出一个稳健的处理函数(采用 ImageSharp;如使用 Magick.NET 亦可,接口略有差异)。
// <PackageReference Include="SixLabors.ImageSharp" Version="3.*" />
// <PackageReference Include="SixLabors.Fonts" Version="1.*" />
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.Fonts;
public static class ImageAtomicProcessor
{
public static async Task ProcessImageAtomicAsync(
string imagePath,
bool removeExif,
bool addWatermark,
string? watermarkText,
CancellationToken ct = default)
{
// 跨进程互斥(单机)
using var mutex = new NamedFileMutex(imagePath);
mutex.Acquire(TimeSpan.FromSeconds(30));
// 同一进程内按路径串行(可选,若上层已经保障则可移除)
using var _ = await FileGate.EnterAsync(imagePath, ct);
var fullPath = Path.GetFullPath(imagePath);
var dir = Path.GetDirectoryName(fullPath)!;
var ext = Path.GetExtension(fullPath);
Directory.CreateDirectory(dir);
// 临时输出在同目录,确保原子替换可行
var tempPath = Path.Combine(dir, Path.GetRandomFileName() + ext);
// 读取阶段:只读 + 允许其他读(尽量缩小与读者的冲突)
using (var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var image = await Image.LoadAsync(fs, ct))
{
if (removeExif)
{
// 移除 EXIF
image.Metadata.ExifProfile = null;
}
if (addWatermark && !string.IsNullOrWhiteSpace(watermarkText))
{
// 加水印示例(右下角)
var font = SystemFonts.CreateFont("Arial", 24);
var options = new TextOptions(font)
{
Origin = new PointF(10, 10) // 起点会在下面根据图像大小调整
};
image.Mutate(ctx =>
{
var size = ctx.GetCurrentSize();
var margin = 16;
var pos = new PointF(
size.Width - 200 - margin, // 简约处理:假设文本宽度约 200
size.Height - 40 - margin
);
ctx.DrawText(new DrawingOptions
{
GraphicsOptions = new GraphicsOptions { Antialias = true, AlphaCompositionMode = PixelAlphaCompositionMode.SrcOver, BlendPercentage = 0.6f }
}, watermarkText!, font, Color.White, pos);
});
}
// 将成品写入临时文件(独占写)
await using (var outFs = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
await image.SaveAsJpegAsync(outFs, ct); // 也可按 ext 决定 SaveAsPng/SaveAsJpeg
}
}
// 原子替换
ReplaceAtomically(tempPath, fullPath);
}
private static void ReplaceAtomically(string tempPath, string destPath)
{
// Windows:使用 File.Replace(保留备份以便回滚)
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var backup = destPath + ".bak";
try
{
File.Replace(tempPath, destPath, backup, ignoreMetadataErrors: true);
// 替换成功后清理备份(可选,视恢复策略)
try { File.Delete(backup); } catch { /* 忽略 */ }
}
catch
{
// 出错时尝试回滚
try
{
if (File.Exists(backup))
File.Replace(backup, destPath, null);
}
catch { /* 忽略 */ }
throw;
}
finally
{
// 万一 Replace 抛异常,临时文件清理
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
}
}
else
{
// Unix:File.Replace 不可用,使用同目录的 File.Move 覆盖(同一文件系统内原子)
// .NET 6+ 支持 overwrite: true
File.Move(tempPath, destPath, overwrite: true);
}
}
}
- 调用示例
-
仅去 EXIF:
await ImageAtomicProcessor.ProcessImageAtomicAsync(path, removeExif: true, addWatermark: false, watermarkText: null, ct); -
仅加水印:
await ImageAtomicProcessor.ProcessImageAtomicAsync(path, removeExif: false, addWatermark: true, watermarkText: "© YourBrand", ct); -
两步合一(推荐):
await ImageAtomicProcessor.ProcessImageAtomicAsync(path, removeExif: true, addWatermark: true, watermarkText: "© YourBrand", ct);
-
说明:
- 上述流程通过命名互斥 + 临时文件 + 原子替换,避免两个方法在同一时间原地写同一文件。
- 如果确需拆成两个独立任务,则两个任务都应遵循相同模式,否则仍可能出现冲突。
九、重试与退避策略(可选)
在生产环境(尤其是 Windows 桌面/服务器),防病毒/索引器可能短时间占用文件。建议对独占写的关键点做有限重试:
public static async Task<FileStream> OpenExclusiveWithRetryAsync(string path, int retries, TimeSpan delay, CancellationToken ct)
{
Exception? last = null;
for (int i = 0; i < retries; i++)
{
try
{
return new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
}
catch (IOException ex) when (i < retries - 1)
{
last = ex;
await Task.Delay(delay, ct);
}
}
throw last ?? new IOException("打开文件失败(独占写)。");
}
使用时机:对必须原地独占写的遗留代码,或对 File.Replace 的短暂目录元数据锁失败进行补救。
十、Web/服务端落地:将图片处理串行化为后台工作
- 推荐:入库/落盘后发消息至后台队列(
System.Threading.Channels、队列服务、消息中间件),由单一消费者按文件路径串行化执行“去 EXIF → 加水印 → 原子替换”。 - 单机示例(Channels):
using System.Threading.Channels;
public sealed class ImageJob
{
public required string Path { get; init; }
public bool RemoveExif { get; init; }
public bool AddWatermark { get; init; }
public string? Watermark { get; init; }
}
public static class ImageJobQueue
{
private static readonly Channel<ImageJob> _ch = Channel.CreateUnbounded<ImageJob>();
public static ValueTask EnqueueAsync(ImageJob job) => _ch.Writer.WriteAsync(job);
public static async Task WorkerAsync(CancellationToken ct)
{
await foreach (var job in _ch.Reader.ReadAllAsync(ct))
{
try
{
await ImageAtomicProcessor.ProcessImageAtomicAsync(
job.Path, job.RemoveExif, job.AddWatermark, job.Watermark, ct);
}
catch (Exception ex)
{
// 日志与告警
}
}
}
}
- 多机/多进程:为每个文件路径加分布式锁,或按文件哈希做一致性分片到单一消费者。
十一、记录锁(FileStream.Lock)的边界使用
- 何时需要:
- 允许进程共享打开文件(例如读写日志、索引等),但需要对文件的某一片段进行短期互斥。
- 基本示例(注意:跨平台建议性差异):
using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
// 注意:Lock/Unlock 的范围设计需谨慎,且要 try/finally
fs.Lock(0, Math.Max(1, fs.Length)); // 某些平台允许锁到 EOF 之外,但为兼容,至少锁 1 字节
try
{
// 进行该范围内的安全读写
}
finally
{
fs.Unlock(0, Math.Max(1, fs.Length));
}
- 警示:
- Unix 上属建议式,仅对遵守锁协议的进程生效。
- 锁定范围必须与读写范围一致,否则可能出现“锁未覆盖的写入”。
对于你的图片处理场景,优先用“互斥 + 原子替换”,不要依赖共享打开 + 记录锁。
十二、故障与恢复考量
- 弃置互斥量(AbandonedMutexException):持有者崩溃时,后续 WaitOne 会抛出;可捕获并继续,但要注意数据可能处于中间态(我们的原子替换策略能避免半成品落地)。
- 备份与回滚:Windows 上
File.Replace可提供备份路径;失败时回滚。Unix 使用File.Move时考虑临时文件清理与幂等。 - 一致性与幂等:
- 多次消费同一任务,应确保重复处理结果相同(例如相同水印不会叠加),或先读取判断是否已处理。
- 性能:
- 大图处理建议内存流/管道,尽量减少磁盘 I/O 次数。
- ImageSharp 比
System.Drawing更适合服务端跨平台场景。
十三、常见陷阱清单
- 在写入阶段直接打开目标文件进行覆盖写(原地写),与其他方法竞争同一文件句柄。
- 未显式指定 FileShare,误用
File.OpenWrite导致FileShare.None,增加冲突概率。 - 多线程同时操作同一路径但只用了 C# lock(它只限当前进程内,无效于其他进程/实例)。
- Unix 环境下依赖 File.Replace 或强制锁,导致行为差异。
- 在不同目录/卷之间做移动替换,导致非原子。
- 使用
System.Drawing.Common在非 Windows 平台;或 GDI+ 句柄泄漏导致文件一直被占用(未 Dispose)。 - FileSystemWatcher 触发多次事件,导致并发两次处理;需做去抖/合并。
- 忽视防病毒/备份/索引器对文件短暂占用,未做重试与超时控制。
十四、最简避坑策略(可直接套用)
-
单机场景(你的例子):
- 任何“修改文件”的方法必须按以下模板执行:
- 获取 Named Mutex(基于路径)。
- 读源文件(
FileShare.Read)。 - 在内存或临时文件中完成所有修改。
- 写临时文件(独占写)。
- 原子替换(Windows:
File.Replace;Unix:File.Move覆盖)。
- 如果两种修改(去 EXIF/加水印)都可能对同一文件发生,统一整合到一个处理函数,或至少共享相同互斥策略。
- 任何“修改文件”的方法必须按以下模板执行:
-
分布式/多实例:
- 引入队列 + 单消费者,或使用分布式锁。
- 原子替换依旧是底层落盘策略。
通过以上设计,可以实现“加水印”和“去 EXIF”将不会再因句柄冲突而抛异常,并且读者始终能看到一致的数据版本。