C# Using ReaderWriterLockSlim to Simply Solve Thread Synchronization for Concurrent File Writing with Three Lines of Code

C# Using ReaderWriterLockSlim to Simply Solve Thread Synchronization for Concurrent File Writing with Three Lines of Code

The ReaderWriterLockSlim object is used as the lock to manage resources. Different ReaderWriterLockSlim objects locking the same file are treated as different locks.

Last updated 4/25/2022 8:41 PM
Walter_lee2008
9 min read
Category
.NET
Tags
.NET C#

1. Knowledge Preparation

In the process of developing programs, it is inevitable to include the crucial function of writing error logs. To achieve this, you can choose to use third-party logging plugins, a database, or simply write your own method to record error information to a log file.

When implementing the last approach, if you are not familiar with file operations and thread synchronization, problems may arise. This is because the same file does not allow multiple threads to write simultaneously; otherwise, you'll get the error "The process cannot access the file because it is being used by another process."

This is a file concurrent writing issue that requires thread synchronization. Microsoft provides several classes for thread synchronization to achieve this purpose, and System.Threading.ReaderWriterLockSlim used in this article is one of them.

This class manages the lock state for resource access, enabling multiple threads to read or perform exclusive write access. Using this class, we can avoid the concurrent write problem caused by multiple threads writing to the same file simultaneously.

A reader-writer lock manages resources using a ReaderWriterLockSlim object as the lock. Locking the same file in different ReaderWriterLockSlim objects will be treated as different locks, and this difference may again cause concurrent file write issues. Therefore, ReaderWriterLockSlim should be defined as a read-only static object as much as possible.

ReaderWriterLockSlim has several key methods; this article only discusses the write lock:

  • Calling the EnterWriteLock method enters the write state, blocking the calling thread until it enters the lock state, so it may never return.

  • Calling the TryEnterWriteLock method enters the write state, allowing you to specify a blocking interval. If the calling thread does not enter the write mode within this interval, it returns false.

  • Calling the ExitWriteLock method exits the write state. The ExitWriteLock method should be executed in a finally block to ensure the caller exits the write mode.

2. Multiple Threads Writing to a File Simultaneously

class Program
{
    static int LogCount = 100;
    static int WritedCount = 0;
    static int FailedCount = 0;

    static void Main(string[] args)
    {
        // Iterate to write log records. Since multiple threads write to the same file simultaneously, errors will occur.
        Parallel.For(0, LogCount, e =>
        {
            WriteLog();
        });

        Console.WriteLine(string.Format("\r\nLog Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", LogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
        Console.Read();
    }

    static void WriteLog()
    {
        try
        {
            var logFilePath = "log.txt";
            var now = DateTime.Now;
            var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
            File.AppendAllText(logFilePath, logContent);
            WritedCount++;
        }
        catch (Exception ex)
        {
            FailedCount++;
            Console.WriteLine(ex.Message);
        }
    }
}

3. Using Reader-Writer Lock to Synchronize File Writing in a Multithreaded Environment

class Program
{
    static int LogCount = 100;
    static int WritedCount = 0;
    static int FailedCount = 0;

    static void Main(string[] args)
    {
        // Iterate to write log records
        Parallel.For(0, LogCount, e =>
        {
            WriteLog();
        });

        Console.WriteLine(string.Format("\r\nLog Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", LogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
        Console.Read();
    }

    // Reader-writer lock: when the resource is in write mode, other threads must wait until the current write finishes before they can write.
    static ReaderWriterLockSlim LogWriteLock = new ReaderWriterLockSlim();
    static void WriteLog()
    {
        try
        {
            // Set the reader-writer lock to write mode, exclusive access to the resource. Other write requests must wait until this write finishes.
            // Note: Holding a read lock or write lock for a long time can cause other threads to starve. For best performance, consider restructuring the application to minimize the duration of write access.
            //       From a performance perspective, the request to enter write mode should be placed immediately before the file operation. Entering write mode here is only to reduce code complexity.
            //       Since entering and exiting write mode should be within the same try-finally block, no exception should be thrown before requesting to enter write mode; otherwise, if the number of releases exceeds requests, an exception will be thrown.
            LogWriteLock.EnterWriteLock();

            var logFilePath = "log.txt";
            var now = DateTime.Now;
            var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());

            File.AppendAllText(logFilePath, logContent);
            WritedCount++;
        }
        catch (Exception)
        {
            FailedCount++;
        }
        finally
        {
            // Exit write mode, release resource occupancy.
            // Note: One request corresponds to one release.
            //       If the number of releases exceeds requests, an exception will be thrown [Write lock is being released without being held].
            //       If the request is processed without releasing, an exception will be thrown [Recursive write lock acquisition is not allowed in this mode].
            LogWriteLock.ExitWriteLock();
        }
    }
}

With the reader-writer lock, all logs were successfully written to the log file.

4. Testing the Reader-Writer Lock for Synchronized File Writing in a Complex Multithreaded Environment

class Program
{
    static int LogCount = 1000;
    static int SumLogCount = 0;
    static int WritedCount = 0;
    static int FailedCount = 0;

    static void Main(string[] args)
    {
        // Add a task to the thread pool to iteratively write N logs
        SumLogCount += LogCount;
        ThreadPool.QueueUserWorkItem((obj) =>
        {
            Parallel.For(0, LogCount, e =>
            {
                WriteLog();
            });
        });

        // In a new thread, add N log-writing tasks to the thread pool
        SumLogCount += LogCount;
        var thread1 = new Thread(() =>
        {
            Parallel.For(0, LogCount, e =>
            {
                ThreadPool.QueueUserWorkItem((subObj) =>
                {
                    WriteLog();
                });
            });
        });
        thread1.IsBackground = false;
        thread1.Start();

        // Add N log-writing tasks to the thread pool
        SumLogCount += LogCount;
        Parallel.For(0, LogCount, e =>
        {
            ThreadPool.QueueUserWorkItem((obj) =>
            {
                WriteLog();
            });
        });

        // In a new thread, iteratively write N logs
        SumLogCount += LogCount;
        var thread2 = new Thread(() =>
        {
            Parallel.For(0, LogCount, e =>
            {
                WriteLog();
            });
        });
        thread2.IsBackground = false;
        thread2.Start();

        // In the current thread, iteratively write N logs
        SumLogCount += LogCount;
        Parallel.For(0, LogCount, e =>
        {
            WriteLog();
        });

        Console.WriteLine("Main Thread Processed.\r\n");
        while (true)
        {
            Console.WriteLine(string.Format("Sum Log Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", SumLogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
            Console.ReadLine();
        }
    }

    // Reader-writer lock: when the resource is in write mode, other threads must wait until the current write finishes before they can write.
    static ReaderWriterLockSlim LogWriteLock = new ReaderWriterLockSlim();
    static void WriteLog()
    {
        try
        {
            // Set the reader-writer lock to write mode, exclusive access to the resource. Other write requests must wait until this write finishes.
            // Note: Holding a read lock or write lock for a long time can cause other threads to starve. For best performance, consider restructuring the application to minimize the duration of write access.
            //       From a performance perspective, the request to enter write mode should be placed immediately before the file operation. Entering write mode here is only to reduce code complexity.
            //       Since entering and exiting write mode should be within the same try-finally block, no exception should be thrown before requesting to enter write mode; otherwise, if the number of releases exceeds requests, an exception will be thrown.
            LogWriteLock.EnterWriteLock();

            var logFilePath = "log.txt";
            var now = DateTime.Now;
            var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());

            File.AppendAllText(logFilePath, logContent);
            WritedCount++;
        }
        catch (Exception)
        {
            FailedCount++;
        }
        finally
        {
            // Exit write mode, release resource occupancy.
            // Note: One request corresponds to one release.
            //       If the number of releases exceeds requests, an exception will be thrown [Write lock is being released without being held].
            //       If the request is processed without releasing, an exception will be thrown [Recursive write lock acquisition is not allowed in this mode].
            LogWriteLock.ExitWriteLock();
        }
    }
}

In a complex multithreaded environment using the reader-writer lock, all logs were successfully written to the log file, as evidenced by different ThreadId and DateTime values indicating synchronous writing by different threads.

5. Postscript

Although read/write IO has a shared mode that can also achieve this, it is not recommended.

static void WriteLog()
{
    try
    {
        var logFilePath = "log.txt";
        var now = DateTime.Now;
        var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
        //File.AppendAllText(logFilePath, logContent);

        var logContentBytes = Encoding.Default.GetBytes(logContent);
        using (FileStream logFile = new FileStream(logFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Write))
        {
            logFile.Seek(0, SeekOrigin.End);
            logFile.Write(logContentBytes, 0, logContentBytes.Length);
        }

        WritedCount++;
    }
    catch (Exception ex)
    {
        FailedCount++;
        Console.WriteLine(ex.Message);
    }
}
Keep Exploring

Related Reading

More Articles