Случай, когда ConfigureAwait(false) вместо взаимоблокировки вызывает ошибку

Предположим, я написал библиотеку, которая использует async методы:

namespace MyLibrary1
{
    public class ClassFromMyLibrary1
    {
        public async Task<string> MethodFromMyLibrary1(string key, Func<string, Task<string>> actionToProcessNewValue)
        {
            var remoteValue = await GetValueByKey(key).ConfigureAwait(false);

            //do some transformations of the value
            var newValue = string.Format("Remote-{0}", remoteValue);

            var processedValue = await actionToProcessNewValue(newValue).ConfigureAwait(false);

            return string.Format("Processed-{0}", processedValue);
        }

        private async Task<string> GetValueByKey(string key)
        {
            //simulate time-consuming operation
            await Task.Delay(500).ConfigureAwait(false);

            return string.Format("ValueFromRemoteLocationBy{0}", key);
        }
    }
}

Я следовал рекомендациям по использованию ConfigureAwait(false) (как в этот пост) везде в моя библиотека. Затем я использую его синхронно из своего тестового приложения и получаю ошибку:

namespace WpfApplication1
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void Button1_OnClick(object sender, RoutedEventArgs e)
        {
            try
            {
                var c = new ClassFromMyLibrary1();

                var v1 = c.MethodFromMyLibrary1("test1", ActionToProcessNewValue).Result;

                Label2.Content = v1;
            }
            catch (Exception ex)
            {
                System.Diagnostics.Trace.TraceError("{0}", ex);
                throw;
            }
        }

        private Task<string> ActionToProcessNewValue(string s)
        {
            Label1.Content = s;
            return Task.FromResult(string.Format("test2{0}", s));
        }
    }
}

Неудача:

WpfApplication1.vshost.exe Ошибка: 0: System.InvalidOperationException: вызывающий поток не может получить доступ к этому объекту, поскольку им владеет другой поток. в System.Windows.Threading.Dispatcher.VerifyAccess() в System.Windows.DependencyObject.SetValue(DependencyProperty dp, значение объекта) в System.Windows.Controls.ContentControl.set_Content(значение объекта) в WpfApplication1.MainWindow.ActionToProcessNewValue(String s ) в C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:строка 56 в MyLibrary1.ClassFromMyLibrary1.d__0.MoveNext() в C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml .cs: ​​строка 77 --- Конец трассировки стека из предыдущего места, где было выдано исключение --- в System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(задача задачи) в System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(задача задачи) в System.Runtime.CompilerServices.TaskAwaiter`1.GetResult() в WpfApplication1.MainWindow.d__1.MoveNext() в C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:строка 39 Исключение: ' System.Invalid OperationException» в WpfApplication1.exe

Очевидно, ошибка возникает из-за того, что ожидающие в моей библиотеке отбрасывают текущий контекст WPF.

С другой стороны, после удаления ConfigureAwait(false) везде в библиотеке я, очевидно, вместо этого получаю тупик.

Есть более подробный пример кода, объясняющий некоторые ограничения, с которыми мне приходится иметь дело.

Итак, как я могу решить эту проблему? Каков наилучший подход здесь? Нужно ли мне по-прежнему следовать рекомендациям в отношении ConfigureAwait?

PS. В реальном сценарии у меня много классов и методов, поэтому в моей библиотеке куча таких асинхронных вызовов. Почти невозможно выяснить, требует ли какой-либо конкретный асинхронный вызов контекст или нет (см. комментарии к ответу @Alisson), чтобы исправить это. Меня не волнует производительность, по крайней мере, на данный момент. Я ищу какой-то общий подход к решению этой проблемы.


person neleus    schedule 23.08.2016    source источник
comment
Вам не нужно удалять ConfigureAwait(false) отовсюду, единственное место, где вам нужно удалить это await GetValueByKey(key).ConfigureAwait(false);, и это будет работать. Полное объяснение причин см. в ответе Allision.   -  person Scott Chamberlain    schedule 23.08.2016
comment
Ваши требования доказуемо невыполнимы. Вы хотите, чтобы ваш поток пользовательского интерфейса сидел там, ничего не делая, запрещая ему делать что-либо, пока ваша операция не будет выполнена, а также иметь ту же операцию, требуемую для выполнения некоторого кода в потоке пользовательского интерфейса, прежде чем он сможет завершиться. Ваши требования задают тупиковую ситуацию. Одно из этих двух требований должно быть устранено, чтобы проблема была решаемой.   -  person Servy    schedule 23.08.2016
comment
Ты прав. Я упростил клиентский код, чтобы продемонстрировать синхронный вызов. В реальном коде я не могу изменить способ вызова библиотечного метода, потому что он вызывается из синхронного метода, который вызывается другим синхронным методом и так далее. В конечном итоге эта цепочка исходит из обработчика событий графического интерфейса.   -  person neleus    schedule 23.08.2016
comment
Единственное, что я могу сделать, это Task.Run(async () => c.MethodFromMyLibrary1) и не ждать завершения. Но это вызывает другие серьезные проблемы, такие как обработка ошибок и возврат результата. Хм, это становится действительно сложным.   -  person neleus    schedule 23.08.2016
comment
Чтобы лучше понять мои требования, взгляните на этот подробный пример кода dotnetfiddle.net/I4VBJs.   -  person neleus    schedule 23.08.2016
comment
@Servy, я опубликовал новый вопрос относительно этой второй проблемы stackoverflow.com/questions/39209885/   -  person neleus    schedule 29.08.2016
comment
@neleus Публикация вопроса во второй раз не меняет мой комментарий. Ваши требования доказуемо невыполнимы. Вам нужно изменить свои требования, а не повторять их.   -  person Servy    schedule 29.08.2016
comment
Я надеюсь, что есть решение, даже для таких требований. Посмотрим.   -  person neleus    schedule 29.08.2016


Ответы (3)


Обычно библиотека будет документировать, если обратный вызов гарантированно будет в том же потоке, который его вызвал, если он не задокументирован, самым безопасным вариантом будет предположить, что это не так. Ваш пример кода (и третья сторона, с которой вы работаете, насколько я могу судить из ваших комментариев) подпадают под категорию «Не гарантируется». В этой ситуации вам просто нужно проверить, нужно ли вам сделать Invoke внутри метода обратного вызова, и сделайте это, вы можете вызвать Dispatcher.CheckAccess(), и он вернет false, если вам нужно вызвать перед использованием элемента управления.

private async Task<string> ActionToProcessNewValue(string s)
{
    //I like to put the work in a delegate so you don't need to type 
    // the same code for both if checks
    Action work = () => Label1.Content = s;
    if(Label1.Dispatcher.CheckAccess())
    {
        work();
    }
    else
    {
        var operation = Label1.Dispatcher.InvokeAsync(work, DispatcherPriority.Send);

        //We likely don't need .ConfigureAwait(false) because we just proved
        // we are not on the UI thread in the if check.
        await operation.Task.ConfigureAwait(false);
    }

    return string.Format("test2{0}", s);
}

Вот альтернативная версия с синхронным обратным вызовом вместо асинхронного.

private string ActionToProcessNewValue(string s)
{
    Action work = () => Label1.Content = s;
    if(Label1.Dispatcher.CheckAccess())
    {
        work();
    }
    else
    {
        Label1.Dispatcher.Invoke(work, DispatcherPriority.Send);
    }

    return string.Format("test2{0}", s);
}

Вот еще одна версия, если вы хотите получить значение из Label1.Content вместо его назначения, для этого также не нужно использовать async/await внутри обратного вызова.

private Task<string> ActionToProcessNewValue(string s)
{
    Func<string> work = () => Label1.Content.ToString();
    if(Label1.Dispatcher.CheckAccess())
    {
        return Task.FromResult(work());
    }
    else
    {
        return Label1.Dispatcher.InvokeAsync(work, DispatcherPriority.Send).Task;
    }
}

ВАЖНОЕ ПРИМЕЧАНИЕ: все эти методы заставят вашу программу зайти в тупик, если вы не избавитесь от .Result в обработчике нажатия кнопки, Dispatcher.Invoke или Dispatcher.InvokeAsync в обратном вызове никогда не запустятся, пока они ожидание возврата .Result, а .Result никогда не вернется, пока он ожидает возврата обратного вызова. Вы должны изменить обработчик щелчка на async void и сделать await вместо .Result.

person Scott Chamberlain    schedule 23.08.2016
comment
Таким образом, решение состоит в том, чтобы изменить клиентский код, чтобы делать обновления в правильном контексте. Я предполагал это. Что непонятно, нужно ли удалять все ConfigureAwait(false) из моей библиотеки? - person neleus; 23.08.2016
comment
Нет, сохраните их, но задокументируйте в библиотеке, что обратные вызовы могут быть или не быть в вызывающем потоке при выполнении. Это предупреждает пользователей библиотеки о том, что они не должны ни выполнять длительную работу в обратном вызове, ни делать предположения о том, что доступ к пользовательскому интерфейсу без проверки безопасен. - person Scott Chamberlain; 23.08.2016
comment
Как только ваша библиотека больше не гарантирует это, я полагаю, что небезопасно обновлять метку в вашем обратном вызове, как вы делаете, но безопасно обновлять ее после вызова, который вы сделали в обработчике события нажатия кнопки (поскольку вы просто удаляете ConfigureAwait там). - person Alisson; 23.08.2016
comment
@neleus другой вариант — сохранить значение SynchronizationContext.Current в качестве первой строки MethodFromMyLibrary1, затем использовать SynchronizationContext.Send, чтобы выполнить обратный вызов, если контекст не равен нулю, и зафиксировать результат, после завершения отправки вы продолжите работу с кодом. - person Scott Chamberlain; 23.08.2016
comment
@ Скотт Чемберлен, захват SynchronizationContext в коде библиотеки - не очень хорошая идея. В идеале разработчик библиотеки не должен заботиться о контексте синхронизации. Мне нравится ваше предложение опубликовать действие в контексте пользовательского интерфейса из клиентского кода. - person neleus; 23.08.2016
comment
@neleus платформа .NET делает это во многих местах, первое, что приходит на ум, это когда вы создаете BackgroundWorker, он фиксирует SynchronizationContext при вызове RunWorkerAsync() с использованием AsyncOperationManager затем он использует этот контекст синхронизации, когда вызывает события (события на самом деле являются просто типом обратного вызова) ProgressChanged и RunWorkerCompleted . - person Scott Chamberlain; 23.08.2016
comment
Разработчики библиотеки @neleus обычно делают это, если библиотека предназначена для работы с пользовательским интерфейсом, но, тем не менее, я согласен, что большинство библиотек не должны и должны полагаться на то, что клиент выполняет вызов или использует что-то вроде IProgress<T>, если вы хотите сообщить о ходе операции (реализация < href="https://msdn.microsoft.com/en-us/library/hh193692(v=vs.110).aspx" rel="nofollow noreferrer">Progress<T>, включенный в .NET, будет захватывать контекст и вызвать для вас). - person Scott Chamberlain; 23.08.2016
comment
@ScottChamberlain, я опубликовал новый вопрос на SO о тупике .Result stackoverflow.com/questions/39209885/ - person neleus; 29.08.2016

Фактически, вы получаете обратный вызов в своем ClassFromMyLibrary1 и не можете предположить, что он будет делать (например, обновлять метку). Вам не нужно ConfigureAwait(false) в вашей библиотеке классов, так как та же ссылка, которую вы предоставили, дает нам такое объяснение:

По мере роста асинхронных приложений с графическим интерфейсом вы можете обнаружить множество небольших частей асинхронных методов, использующих поток графического интерфейса в качестве своего контекста. Это может вызвать медлительность, так как скорость отклика страдает от «тысяч порезов бумаги».

Чтобы смягчить это, ждите результата ConfigureAwait всякий раз, когда это возможно.

Используя ConfigureAwait, вы обеспечиваете небольшой уровень параллелизма: некоторый асинхронный код может выполняться параллельно с потоком графического интерфейса вместо того, чтобы постоянно загружать его какой-то работой.

А теперь почитайте здесь:

Вы не должны использовать ConfigureAwait, если у вас есть код после ожидания в методе, которому нужен контекст. Для приложений с графическим интерфейсом это включает в себя любой код, который манипулирует элементами графического интерфейса, записывает свойства с привязкой к данным или зависит от конкретного типа графического интерфейса, такого как Dispatcher/CoreDispatcher.

Вы делаете ровно наоборот. Вы пытаетесь обновить графический интерфейс в двух точках: одна в вашем методе обратного вызова, а другая здесь:

var c = new ClassFromMyLibrary1();

var v1 = c.MethodFromMyLibrary1("test1", ActionToProcessNewValue).Result;

Label2.Content = v1; // updating GUI...

Вот почему удаление ConfigureAwait(false) решает вашу проблему. Кроме того, вы можете сделать обработчик нажатия кнопки асинхронным и дождаться вызова метода ClassFromMyLibrary1.

person Alisson    schedule 23.08.2016
comment
Вы можете упомянуть, что вам не нужно удалять .ConfigureAwait(false) везде, чтобы решить проблему, единственное место, где вам нужно удалить его, это await GetValueByKey(key).ConfigureAwait(false);, и это будет работать, потому что вам нужен только контекст до точки, где вы вызываете await actionToProcessNewValue(newValue).ConfigureAwait(false);, .ConfigureAwait(false) можно хранить там и во всех других местах. - person Scott Chamberlain; 23.08.2016
comment
@ScottChamberlain, если этот метод библиотеки классов вызывает другой, который вызывает другой и т. д., им самим по себе не нужно фиксировать контекст. Но что, если в какой-то момент внутри этой библиотеки классов один из методов выполнит функцию обратного вызова, которая попытается обновить Label, как это делает он? Я знаю, что манипуляции с графическим интерфейсом после первого вызова без ConfigureAwait в обработчике событий кнопки работают, но этот обратный вызов я точно не знаю. Будет ли это работать? Вы можете улучшить мой ответ, если хотите, я был бы рад. - person Alisson; 23.08.2016
comment
Правильно, корень проблемы заключается в том, что разработчик библиотеки не знает, нужен ли контекст для какого-либо асинхронного вызова или нет. Оба вызова await GetValueByKey(key) и await actionToProcessNewValue(newValue) могут нуждаться или не нуждаться в контексте. - person neleus; 23.08.2016
comment
Если у меня есть много асинхронных вызовов в одном методе, где каждый метод вызывает другой, который вызывает другой и так далее, то один из таких путей кода может привести к обратному вызову в контексте GUI. Часто невозможно рассмотреть все возможные пути кода, чтобы принять решение относительно ConfigureAwait(false) - person neleus; 23.08.2016
comment
@neleus, разработчик библиотеки, мог бы использовать ConfigureAwait, как только он не получил обратный вызов, потому что они уверены, что им не нужен контекст. Поскольку в этом случае они не могут быть уверены, безопаснее не использовать ConfigureAwait, я думаю, что влияние на производительность здесь должно быть небольшим. Если это сильно влияет из-за того, что вы вызываете обратный вызов тысячи раз или из-за слабого оборудования, то вам решать, когда обновлять файл Label. - person Alisson; 23.08.2016
comment
То, что библиотека не использует никаких обратных вызовов, кроме интерфейсов. Реализация интерфейса может обновлять метку. Это вызовет ту же проблему, но на этот раз без обратных вызовов. Я скоро предоставлю код. - person neleus; 23.08.2016
comment
@neleus, вы можете внедрить интерфейс в конструктор с помощью DI, но у вас будет та же проблема, когда вы попытаетесь использовать этот метод интерфейса. Я предлагаю вам пока удалить ConfigureAwait и проверить производительность. - person Alisson; 23.08.2016
comment
Что, если библиотека использует не обратные вызовы, а интерфейсы. Реализация интерфейса может обновлять метку. Это вызовет ту же проблему, но на этот раз без обратных вызовов. См. обновленный код dotnetfiddle.net/A40Zcd. - person neleus; 23.08.2016
comment
Хорошо, я могу удалить его и таким образом исправить конкретный пример. Но мой вопрос более общий, потому что в реальном сценарии у меня много классов и методов, поэтому таких асинхронных вызовов множество. Исправить их все практически невозможно. Меня не волнует производительность, по крайней мере, на данный момент. - person neleus; 23.08.2016
comment
Под исправлением их всех вы подразумеваете удаление ConfigureAwait везде? На самом деле вы спросили об ошибке вместо взаимоблокировки при использовании ConfigureAwait, поэтому я попытался объяснить, что вызывает это и как решить. Я думаю, что если вы хотите, чтобы решение использовало ConfigureAwait, а также могло бы обновлять графический интерфейс в середине вызовов, то это больше похоже на дизайн. Что ты хочешь? Вы должны удалить ConfigureAwait по крайней мере во всех местах, где вы выполняете этот метод обратного вызова, которому нужен контекст, будь то Func<> или интерфейс, внедренный в конструктор. Вам это помогает? - person Alisson; 23.08.2016
comment
@Alisson спасибо за предложения, они помогли определить основную проблему. И пока я не вижу другого выхода, кроме переделки клиентского кода. Посмотрим, что думают другие. - person neleus; 23.08.2016

На мой взгляд, вам следует перепроектировать API вашей библиотеки, чтобы не смешивать API на основе обратного вызова с API на основе задач. По крайней мере, в вашем примере кода нет веских оснований для этого, и вы указали одну причину, по которой этого не делать - трудно контролировать контекст, в котором выполняется ваш обратный вызов.

Я бы изменил API вашей библиотеки так:

namespace MyLibrary1
{
    public class ClassFromMyLibrary1
    {
        public async Task<string> MethodFromMyLibrary1(string key)
        {
            var remoteValue = await GetValueByKey(key).ConfigureAwait(false);
            return remoteValue;
        }

        public string TransformProcessedValue(string processedValue)
        {
            return string.Format("Processed-{0}", processedValue);
        }

        private async Task<string> GetValueByKey(string key)
        {
            //simulate time-consuming operation
            await Task.Delay(500).ConfigureAwait(false);

            return string.Format("ValueFromRemoteLocationBy{0}", key);
        }
    }
}

И назовите это так:

   private async void Button1_OnClick(object sender, RoutedEventArgs e)
    {
        try
        {
            var c = new ClassFromMyLibrary1();

            var v1 = await c.MethodFromMyLibrary1("test1");
            var v2 = await ActionToProcessNewValue(v1);
            var v3 = c.TransformProcessedValue(v2);

            Label2.Content = v3;
        }
        catch (Exception ex)
        {
            System.Diagnostics.Trace.TraceError("{0}", ex);
            throw;
        }
    }

    private Task<string> ActionToProcessNewValue(string s)
    {
        Label1.Content = s;
        return Task.FromResult(string.Format("test2{0}", s));
    }
person Brandon    schedule 23.08.2016
comment
Что, если ActionToProcessNewValue объявлен внутри какого-то интерфейса, а ClassFromMyLibrary1 принимает его в конструкторе? В любом случае код может быть намного сложнее, и обратный вызов может привести к длинной цепочке внедренных классов (например, с использованием DI). Вот почему может быть невозможно перепроектировать его. - person neleus; 23.08.2016
comment
Кстати, я исправил метод Button1_OnClick и сделал его синхронным, потому что это требование. - person neleus; 23.08.2016