Search code examples
c#wpfasync-awaitstreamwriter.net-4.6.1

StreamWriter.WriteLineAsync() does not continue on new context


In my WPF application I have an issue where I am writing numerous times to a text file via a StreamWriter object, primarily using the WriteLineAsync() method. I am confident that I am correctly awaiting all of the tasks. However, the UI thread is being blocked, instead of being allowed to process UI changes. I have written a small application that demonstrates the issue.

public class MainWindowViewModel : INotifyPropertyChanged
{
    private string _theText;
    private string _theText2;

    public MainWindowViewModel()
    {
        TestCommand = new DelegateCommand(TestCommand_Execute, TestCommand_CanExecute);
        TestCommand2 = new DelegateCommand(TestCommand2_Execute, TestCommand2_CanExecute);
    }

    public ICommand TestCommand { get; }

    private bool TestCommand_CanExecute(object parameter)
    {
        return true;
    }

    private async void TestCommand_Execute(object parameter)
    {
        using (StreamWriter writer = new StreamWriter(new MemoryStream()))
        {
            TheText = "Started";
            await DoWork(writer).ConfigureAwait(true);
            TheText = "Complete";
        }
    }

    public ICommand TestCommand2 { get; }

    private bool TestCommand2_CanExecute(object parameter)
    {
        return true;
    }

    private async void TestCommand2_Execute(object parameter)
    {
        using (StreamWriter writer = new StreamWriter(new MemoryStream()))
        {
            TheText2 = "Started";
            await Task.Delay(1).ConfigureAwait(false);
            await DoWork(writer).ConfigureAwait(true);
            TheText2 = "Complete";
        }
    }

    public string TheText
    {
        get => _theText;
        set => SetValue(ref _theText, value);
    }

    public string TheText2
    {
        get => _theText2;
        set => SetValue(ref _theText2, value);
    }

    private bool SetValue<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(storage, value)) return false;
        storage = value;
        OnPropertyChanged(propertyName);
        return true;
    }

    public event PropertyChangedEventHandler PropertyChanged;

    [NotifyPropertyChangedInvocator]
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private async Task DoWork(StreamWriter writer)
    {
        for (int i = 0; i < 100; i++)
        {
            await writer.WriteLineAsync("test" + i).ConfigureAwait(false);
            Thread.Sleep(100);
        }
    }
}

And the XAML

    <Window x:Class="AsyncWpfToy.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:AsyncWpfToy"
    mc:Ignorable="d"
    Title="MainWindow" Height="450" Width="800">
<Window.DataContext>
    <local:MainWindowViewModel />
</Window.DataContext>
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="20" />
        <RowDefinition Height="20" />
        <RowDefinition Height="20" />
        <RowDefinition Height="20" />
    </Grid.RowDefinitions>
    <Button Grid.Row="0" Command="{Binding TestCommand}" Content="Button" />
    <TextBlock Grid.Row="1" Text="{Binding TheText}" />
    <Button Grid.Row="2" Command="{Binding TestCommand2}" Content="Button2" />
    <TextBlock Grid.Row="3" Text="{Binding TheText2}" />
</Grid>

And, for the sake of being complete, a basic implementation of DelegateCommand

public class DelegateCommand : ICommand
{
    private readonly Action<object> _execute;
    private readonly Func<object, bool> _canExecute;

    public DelegateCommand(Action<object> execute, Func<object, bool> canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter)
    {
        return _canExecute?.Invoke(parameter) ?? true;
    }

    public void Execute(object parameter)
    {
        _execute(parameter);
    }

    public event EventHandler CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }
}

When I click the button simply labeled "Button", my UI freezes even though the body of DoWork() has all Async methods awaited with ConfigureAwait(false) set.

When I click Button2, however, I am performing an await Task.Delay(1).ConfigureAwait(false) before awaiting the DoWork() method. This appears to correctly shift processing to another context, allowing the UI to continue. Indeed, if I move the await Task.Delay(1).ConfigureAwait(false) to the DoWork() method and set it up before the for loop, everything runs as one would expect - the UI remains responsive.

It appears that, for whatever reason, StreamWriter.WriteLineAsync() is either not truly async, or the processing is happening so fast that the framework determines that there is no need for a context switch and allows continuation on the captured context, regardless. I have found that if I remove the Thread.Sleep(100) and, instead, iterate with a much higher number (i<10000 or so, though I have not tried to find the threshold), it will lock for a few seconds but eventually switch contexts until it completes. So I'm guessing that the latter explanation is more likely.

Ultimately, the question I have is, "How do I ensure that my StreamWriter.WriteLineAsync() calls continue on another context so that my UI thread can remain responsive?"


Solution

  • It's important to understand how asynchronous methods work. All asynchronous methods start running synchronously. The magic happens at the first await that acts on an incomplete Task, at which point await will return its own incomplete Task up the stack.

    All that means that if a method is doing something synchronous (or CPU-consuming) before whatever asynchronous I/O request it is going to make, then that will still block the thread.

    As to what WriteLineAsync is doing, well, the source code is available, so you can walk through it starting here. If I followed it correctly, I think it ends up calling BeginWrite, which calls BeginWriteInternal with the serializeAsynchronously parameter set to false. That means it ends up calling semaphore.Wait(), a synchronous wait. There's a comment above that:

    // To avoid a race with a stream's position pointer & generating ---- 
    // conditions with internal buffer indexes in our own streams that 
    // don't natively support async IO operations when there are multiple 
    // async requests outstanding, we will block the application's main
    // thread if it does a second IO request until the first one completes.
    

    Now I can't say if that's actually what's holding it up, but it's certainly possible.

    But in any case, the best option if you're seeing this behaviour is to just get it off the UI thread right away by using Task.Run():

    await Task.Run(() => DoWork(writer));