File Locks and Concurrent File Access in C

Concurrent file access in C# is primarily governed by how processes configure file sharing modes and handle operating system-level file locks. When multiple threads or processes attempt to interact with the same file simultaneously, developers must balance data integrity against accessibility. This article explains how to manage file locks and sharing modes using the FileStream class, configure the FileShare enumeration, apply byte-range locking, and implement resilient patterns such as retries and inter-process synchronization.

Understanding FileStream and FileShare

In .NET, high-level helper methods like File.ReadAllText or File.WriteAllLines open files using FileShare.Read or FileShare.None. This default behavior often causes an IOException ("The process cannot access the file because it is being used by another process") when another thread or process attempts to access the file at the same time.

To gain explicit control over concurrent access, you must instantiate a FileStream directly and specify FileMode, FileAccess, and FileShare.

using System.IO;

using var stream = new FileStream(
    filePath, 
    FileMode.OpenOrCreate, 
    FileAccess.ReadWrite, 
    FileShare.ReadWrite);

FileShare Enumeration Values

The FileShare enum instructs the operating system on how other handles can open the file while your handle remains active:

  • FileShare.None: Declines sharing of the current file. Any open request from any process (or thread) will fail until the handle is closed. This provides an exclusive lock.
  • FileShare.Read: Allows subsequent opening of the file for reading only. If another process requests write access, the request is denied.
  • FileShare.Write: Allows subsequent opening of the file for writing only.
  • FileShare.ReadWrite: Allows subsequent opening of the file for both reading and writing. Concurrent readers and writers can access the file simultaneously.
  • FileShare.Delete: Allows subsequent deletion or renaming of the file while it remains open.
  • FileShare.Inheritable: Makes the file handle inheritable by child processes.

Both the existing open file handle and any incoming open file requests must have mutually compatible FileAccess and FileShare flags. For example, if Process A opens a file with FileAccess.Read and FileShare.ReadWrite, Process B can open it with FileAccess.Write only if Process B also sets FileShare.Read.

Range Locking with FileStream.Lock

Beyond entire-file locking via FileShare.None, the .NET FileStream class provides byte-range locking via Lock and Unlock. This enables fine-grained concurrency where multiple processes write to different regions of the same file.

using System.IO;

using var stream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);

long position = 0;
long length = 1024; // Lock the first 1 KB of the file

// Acquire the lock on the specified range
stream.Lock(position, length);

try
{
    stream.Seek(position, SeekOrigin.Begin);
    // Perform isolated reads/writes on the locked region
}
finally
{
    // Always unlock in a finally block
    stream.Unlock(position, length);
}

Note: The locked region must be released with the exact same position and length parameters supplied during the Lock call. Byte-range locking is strictly an operating system-level mechanism and behaves differently across platforms (e.g., Windows enforces mandatory byte locks, whereas Unix-based systems often implement advisory locks).

Handling File Lock Collisions

When a file cannot be accessed due to an existing lock, the runtime throws an IOException. To maintain application stability, implement a retry policy with exponential backoff or jitter rather than allowing the application to fail immediately.

using System;
using System.IO;
using System.Threading;

public static FileStream OpenWithRetry(string path, FileMode mode, FileAccess access, FileShare share, int maxRetries = 5, int delayMs = 100)
{
    for (int i = 0; i < maxRetries; i++)
    {
        try
        {
            return new FileStream(path, mode, access, share);
        }
        catch (IOException) when (i < maxRetries - 1)
        {
            Thread.Sleep(delayMs * (int)Math.Pow(2, i));
        }
    }

    return new FileStream(path, mode, access, share);
}

Coordinating Access Using Synchronization Primitives

Relying entirely on file-system exceptions for concurrency control can introduce performance overhead. Depending on the architecture, higher-level synchronization primitives can coordinate file operations before touching disk handles:

  1. In-Process Concurrency (Multi-threading): Use ReaderWriterLockSlim or SemaphoreSlim to synchronize threads within the same application domain before calling FileStream. This avoids throwing repeated IOException instances.
  2. Cross-Process Concurrency (Multi-process): Use a system-wide named Mutex. A named mutex ensures that only one operating system process enters the critical file-writing section at a time.
using System;
using System.IO;
using System.Threading;

bool createdNew;
using var mutex = new Mutex(false, @"Global\MyUniqueFileLockName", out createdNew);

try
{
    // Wait up to 5 seconds to acquire the mutex
    if (mutex.WaitOne(TimeSpan.FromSeconds(5)))
    {
        try
        {
            File.AppendAllText("shared_log.txt", "New log entry\n");
        }
        finally
        {
            mutex.ReleaseMutex();
        }
    }
    else
    {
        throw fresh TimeoutException("Could not acquire file lock within the timeout period.");
    }
}
catch (AbandonedMutexException)
{
    // Handle the case where another process terminated without releasing the mutex
    File.AppendAllText("shared_log.txt", "New log entry after recovery\n");
    mutex.ReleaseMutex();
}

By explicitly configuring FileShare attributes, applying byte-level locks when appropriate, catching IOException through retries, and employing named synchronization primitives, C# applications can safely and reliably handle concurrent file operations across multiple threads and processes.