C# 文件锁与并发文件操作

本文聚焦 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 上为强制。
  • 平台差异

    • 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 在写/读,导致读写不可用或内容撕裂风险。

三、工程化方案全景(按可靠度排序)

  1. 避免原地修改:采用“写入临时文件 + 原子替换”
    • 所有变更写到临时文件,完毕后一次性 Replace/Move 覆盖目标文件。
    • 既规避读到半成品,也降低加锁范围。
  2. 管道化/串行化处理(同一进程)
    • 通过每文件路径的 SemaphoreSlim/lock 实现同一进程内的串行访问。
  3. 跨进程互斥
    • 使用命名 Mutex(Windows)或基于系统资源的互斥;在 Web 场景可用分布式锁(Redis、SQL sp_getapplock、对象存储租约)。
  4. 打开共享策略控制
    • 读:FileShare.Read;写:FileShare.None(独占写)。
    • 读写皆需,优先规划时序而非并发。
  5. 字节区段锁(可选)
    • 在必须共享打开但对特定区域互斥时才考虑。
  6. 重试与退避
    • 处理防病毒、索引器或其他进程短暂占用文件的情况(指数退避 + 上限超时)。

四、同一进程内的协调(线程间)

按文件路径做粒度控制(单机、多线程/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 触发多次事件,导致并发两次处理;需做去抖/合并。
  • 忽视防病毒/备份/索引器对文件短暂占用,未做重试与超时控制。

十四、最简避坑策略(可直接套用)

  • 单机场景(你的例子):

    • 任何“修改文件”的方法必须按以下模板执行:
      1. 获取 Named Mutex(基于路径)。
      2. 读源文件(FileShare.Read)。
      3. 在内存或临时文件中完成所有修改。
      4. 写临时文件(独占写)。
      5. 原子替换(Windows: File.Replace;Unix: File.Move 覆盖)。
    • 如果两种修改(去 EXIF/加水印)都可能对同一文件发生,统一整合到一个处理函数,或至少共享相同互斥策略。
  • 分布式/多实例:

    • 引入队列 + 单消费者,或使用分布式锁。
    • 原子替换依旧是底层落盘策略。

通过以上设计,可以实现“加水印”和“去 EXIF”将不会再因句柄冲突而抛异常,并且读者始终能看到一致的数据版本。