0

I've created a WPF app that targets a local document database for fun/practice. The idea is the document for an entity is a .json file that lives on disk and folders act as collections. In this implementation, I have a bunch of .json documents that provide data about a Video to create a sort of an IMDB clone.

I have this class:

public class VideoRepository : IVideoRepository
{
    public async IAsyncEnumerable<Video> EnumerateEntities()
    {
        foreach (var file in new DirectoryInfo(Constants.JsonDatabaseVideoCollectionPath).GetFiles())
        {
            var json = await File.ReadAllTextAsync(file.FullName); // This blocks
            var document = JsonConvert.DeserializeObject<VideoDocument>(json); // Newtonsoft
            var domainObject = VideoMapper.Map(document); // A mapper to go from the document type to the domain type
            yield return domainObject;
        }
        // Uncommenting the below lines and commenting out the above foreach loop doesn't lock up the UI.
        //await Task.Delay(5000);
        //yield return new Video();
    }

    // Rest of class.
}

Way up the call stack, though the API layer and into the UI layer, I have an ICommand in a ViewModel:

QueryCommand = new RelayCommand(async (query) => await SendQuery((string)query));

private async Task SendQuery(string query)
    {
        QueryStatus = "Querying...";
        QueryResult.Clear();

        await foreach (var video in _videoEndpoints.QueryOnTags(query))
            QueryResult.Add(_mapperService.Map(video));

        QueryStatus = $"{QueryResult.Count()} videos found.";
    }

The goal is to show the user a message 'Querying...' while the query is being processed. However, that message is never shown and the UI locks up until the query is complete, at which point the result message shows.

In VideoRepository, if I comment out the foreach loop and uncomment the two lines below it, the UI doesn't lock up and the 'Querying...' message gets shown for 5 seconds.

Why does that happen? Is there a way to do IO without locking up the UI/blocking?

Fortunately, if this were behind a web API and hit a real database, I probably wouldn't see this issue. I'd still like the UI to not lock up with this implementation though.


EDIT: Dupe of Why File.ReadAllLinesAsync() blocks the UI thread?

Turns out Microsoft didn't make their async method very async. Changing the IO line fixes everything:

//var json = await File.ReadAllTextAsync(file.FullName); // Bad
var json = await Task.Run(() => File.ReadAllText(file.FullName)); // Good
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
eriyg
  • 99
  • 1
  • 12
  • 1
    Might be related to this: [Why File.ReadAllLinesAsync() blocks the UI thread?](https://stackoverflow.com/questions/63217657/why-c-sharp-file-readalllinesasync-blocks-ui-thread) – Theodor Zoulias Mar 20 '22 at 18:31
  • 1
    @TheodorZoulias Classic Microsoft! Changing the IO line to var json = await Task.Run(() => File.ReadAllText(file.FullName)); fixes everything. Thank you for pointing this out. – eriyg Mar 20 '22 at 18:43

1 Answers1

2

You are probably targeting a .NET version older than .NET 6. In these old versions the file-system APIs were not implemented efficiently, and were not even truly asynchronous. Things have been improved in .NET 6, but still the synchronous file-system APIs are more performant than their asynchronous counterparts. Your problem can be solved simply by switching from this:

var json = await File.ReadAllTextAsync(file.FullName);

to this:

var json = await Task.Run(() => File.ReadAllText(file.FullName));

If you want to get fancy, you could also solve the problem in the UI layer, by using a custom LINQ operator like this:

public static async IAsyncEnumerable<T> OnThreadPool<T>(
    this IAsyncEnumerable<T> source,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    var enumerator = await Task.Run(() => source
        .GetAsyncEnumerator(cancellationToken)).ConfigureAwait(false);
    try
    {
        while (true)
        {
            var (moved, current) = await Task.Run(async () =>
            {
                if (await enumerator.MoveNextAsync())
                    return (true, enumerator.Current);
                else
                    return (false, default);
            }).ConfigureAwait(false);
            if (!moved) break;
            yield return current;
        }
    }
    finally
    {
        await Task.Run(async () => await enumerator
            .DisposeAsync()).ConfigureAwait(false);
    }
}

This operator offloads to the ThreadPool all the operations associated with enumerating an IAsyncEnumerable<T>. It can be used like this:

await foreach (var video in _videoEndpoints.QueryOnTags(query).OnThreadPool())
    QueryResult.Add(_mapperService.Map(video));
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • 1
    Thanks. I definitely won't do the UI layer solution, much better to solve things upstream. It's a good trick to hold on to though. Some black box I can't change might have the same problem someday. – eriyg Mar 20 '22 at 19:33
  • @eriyg I don't think that I would use it either, although, according to [this](https://devblogs.microsoft.com/pfxteam/should-i-expose-asynchronous-wrappers-for-synchronous-methods/ "Should I expose asynchronous wrappers for synchronous methods?") article, it might be the proper thing to do. :-) – Theodor Zoulias Mar 20 '22 at 20:11