为什么在 C# 中异步 IO 会阻塞?

Why does async IO block in C#?

我创建了一个 WPF 应用程序,它针对 fun/practice 的本地文档数据库。这个想法是实体的文档是一个位于磁盘上的 .json 文件,文件夹充当集合。在此实现中,我有一堆 .json 文档提供有关视频的数据以创建一种 IMDB 克隆。

我有这个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.
}

在调用堆栈中,通过 API 层并进入 UI 层,我在 ViewModel 中有一个 ICommand:

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.";
    }

目标是在处理查询时向用户显示消息 'Querying...'。但是,该消息永远不会显示,并且 UI 会锁定,直到查询完成,此时会显示结果消息。

在 VideoRepository 中,如果我注释掉 foreach 循环并取消注释下面的两行,UI 不会锁定并且 'Querying...' 消息会显示 5 秒。

为什么会这样?有没有办法在不锁定 UI/blocking 的情况下进行 IO?

幸运的是,如果这是在网络 API 之后访问真实数据库,我可能不会看到这个问题。不过,我仍然希望 UI 不要锁定此实现。


编辑:

的骗局

事实证明,Microsoft 并没有使他们的异步方法非常异步。更改 IO 行可以解决所有问题:

//var json = await File.ReadAllTextAsync(file.FullName); // Bad
var json = await Task.Run(() => File.ReadAllText(file.FullName)); // Good

您的目标可能是早于 .NET 6 的 .NET 版本。在这些旧版本中,file-system API 没有有效地实现,. Things have been improved 在 .NET 6 中,但仍然是同步的file-system API 的性能比异步 API 更高。您的问题可以简单地通过切换来解决:

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

对此:

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

如果你想花哨一点,你也可以在 UI 层解决问题,方法是使用像这样的自定义 LINQ 运算符:

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);
    }
}

此运算符将与枚举 IAsyncEnumerable<T> 相关的所有操作卸载到 ThreadPool。可以这样使用:

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