Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using ConcurrentExclusiveSchedulerPair as an async ReaderWriterLock equivalent

My application has several async methods that can access files on disk. As I learned the ordinary ReaderWriterLockSlim cannot be used in such case, I went looking for an equivalent. Found the ConcurrentExclusiveSchedulerPair that looked very promising. I read a few enlightening blog posts about it and I started to think this could be the right choice for me.

So I changed all my reading tasks to use the Concurrent scheduler and writing tasks to use the Exclusive one. However, it turned out I was still getting IOExceptions saying the file is in use. Here's a simplified (and still failing) version of my code:

public async Task<IEnumerable<string>> Run()
{
    var schedulers = new ConcurrentExclusiveSchedulerPair();
    var exclusiveFactory = new TaskFactory(schedulers.ExclusiveScheduler);
    var concurrentFactory = new TaskFactory(schedulers.ConcurrentScheduler);

    var tasks = new List<Task>();
    for (var i = 0; i < 40; ++i)
    {
        // Create some readers and (less) writers
        if (i % 4 == 0)
        {
            var writeTask = exclusiveFactory.StartNew(WriteToFile).Unwrap();
            tasks.Add(writeTask);
        }
        else
        {
            var readTask = concurrentFactory.StartNew(ReadFromFile).Unwrap();
            tasks.Add(readTask);
        }
    }

    await Task.WhenAll(tasks);
    return _contents;
}

private async Task ReadFromFile()
{
    using (var fileStream = new FileStream("file.txt", FileMode.OpenOrCreate, FileAccess.Read))
    using (var reader = new StreamReader(fileStream))
    {
        await Task.Delay(500); // some other work
        _contents.Add(await reader.ReadToEndAsync());
    }
}

private async Task WriteToFile()
{
    using (var fileStream = new FileStream("file.txt", FileMode.OpenOrCreate, FileAccess.Write))
    using (var writer = new StreamWriter(fileStream))
    {
        await Task.Delay(500); // some other work
        await writer.WriteLineAsync("Lorem ipsum");
    }
}

Then I found Stephen Cleary's blog post with a warning in a red box:

When an asynchronous method awaits, it returns back to its context. This means that ExclusiveScheduler is perfectly happy to run one task at a time, not one task until it completes. As soon as an asynchronous method awaits, it’s no longer the “owner” of the ExclusiveScheduler. Stephen Toub’s async-friendly primitives like AsyncLock use a different strategy, allowing an asynchronous method to hold the lock while it awaits.

"One task at a time, not one task until it completes" - now this is a bit confusing. Does this mean the ConcurrentExclusiveSchedulerPair is not the right choice for this situation or am I using it incorrectly? Perhaps an AsyncLock should be used here instead (why isn't it a part of the framework)? Or would a plain old Semaphore be enough (I understand I wouldn't get the reader-writer division then, but maybe it's ok)?

like image 552
Michał Dudak Avatar asked Dec 08 '22 15:12

Michał Dudak


1 Answers

One way of thinking of async methods[1] is that they are split into tasks at each await point. To use your example:

private async Task WriteToFile()
{
  using (var fileStream = new FileStream("file.txt", FileMode.OpenOrCreate, FileAccess.Write))
  using (var writer = new StreamWriter(fileStream))
  {
    await Task.Delay(500); // some other work
    await writer.WriteLineAsync("Lorem ipsum");
  }
}

conceptually gets broken into three tasks:

  1. A task that creates the fileStream and writer, and starts the Task.Delay (some other work).
  2. A task that observes the result of Task.Delay and starts WriteLineAsync.
  3. A task that observes the result of WriteLineAsync and disposes writer and fileStream.

ConcurrentExclusiveSchedulerPair is a task scheduler, so its semantics only apply when there is a task running code. When WriteToFile is run with ExclusiveScheduler, it holds the exclusive scheduler lock while task (1) is running. Once the Task.Delay code has started, that task (1) is done, and it releases the exclusive scheduler lock. During the 500 millisecond delay, the exclusive scheduler lock is not held. Once that delay has completed, task (2) is ready and queued to the ExclusiveScheduler. It then takes the exclusive scheduler lock and does its (small) amount of work. When task (2) completes, it also releases the exclusive scheduler lock. Etc.

Task schedulers were designed to work with synchronous tasks. There is some support for them in await (i.e., TaskScheduler.Current is automatically captured and used to resume from an await), but in general they do not have expected semantics when working with asynchronous tasks. Each await is actually telling the task scheduler "this (sub)task is done".

Does this mean the ConcurrentExclusiveSchedulerPair is not the right choice for this situation?

Yes. ConcurrentExclusiveSchedulerPair is not the right choice.

Perhaps an AsyncLock should be used here instead (why isn't it a part of the framework)?

SemaphoreSlim supports asynchronous locking (with a more awkward syntax). But it may not on WP81 - I don't remember (WaitAsync was added later). To support a platform that old, you'd have to either use AsyncEx v4 or copy/paste Stephen Toub's code.

I understand I wouldn't get the reader-writer division then, but maybe it's ok?

It's not only OK, it's almost certainly preferable. Using a reader/writer lock when you really just need a lightweight lock is an extremely common mistake. In particular, just because some code has read semantics and other code has write semantics is not a good enough reason to use a RWL.

[1] For efficiency reasons, async methods are broken into chunks of code at await points, but those individual chunks of code aren't actually wrapped by Task objects unless a TaskScheduler is present.

like image 110
Stephen Cleary Avatar answered Feb 16 '23 08:02

Stephen Cleary