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:
- In-Process Concurrency (Multi-threading): Use
ReaderWriterLockSlimorSemaphoreSlimto synchronize threads within the same application domain before callingFileStream. This avoids throwing repeatedIOExceptioninstances. - 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.