Метод FromEvent общего назначения

Используя новую модель async/await, довольно просто сгенерировать Task, которое завершается при срабатывании события; вам просто нужно следовать этому шаблону:

public class MyClass
{
    public event Action OnCompletion;
}

public static Task FromEvent(MyClass obj)
{
    TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();

    obj.OnCompletion += () =>
        {
            tcs.SetResult(null);
        };

    return tcs.Task;
}

Затем это позволяет:

await FromEvent(new MyClass());

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

В идеале я хотел бы иметь возможность сделать что-то вроде этого:

await FromEvent(new MyClass().OnCompletion);

Затем я мог бы повторно использовать один и тот же метод FromEvent для любого события в любом экземпляре. Я потратил некоторое время, пытаясь создать такой метод, и есть несколько загвоздок. Для приведенного выше кода будет выдана следующая ошибка:

Событие «Namespace.MyClass.OnCompletion» может отображаться только слева от += или -=

Насколько я могу судить, никогда не будет способа передать такое событие через код.

Итак, следующая лучшая вещь, похоже, — попытаться передать имя события в виде строки:

await FromEvent(new MyClass(), "OnCompletion");

Это не так идеально; вы не получаете intellisense и получите ошибку времени выполнения, если событие не существует для этого типа, но оно все же может быть более полезным, чем множество методов FromEvent.

Так что достаточно просто использовать отражение и GetEvent(eventName), чтобы получить объект EventInfo. Следующая проблема заключается в том, что делегат этого события неизвестен (и должен иметь возможность изменяться) во время выполнения. Это затрудняет добавление обработчика событий, потому что нам нужно динамически создавать метод во время выполнения, соответствующий заданной сигнатуре (но игнорирующий все параметры), который обращается к уже имеющемуся TaskCompletionSource и устанавливает его результат.

К счастью, я нашел эту ссылку, содержащую инструкции о том, как сделать [почти] именно так через Reflection.Emit. Теперь проблема в том, что нам нужно сгенерировать IL, и я понятия не имею, как получить доступ к экземпляру tcs, который у меня есть.

Ниже показан прогресс, которого я добился в завершении этого:

public static Task FromEvent<T>(this T obj, string eventName)
{
    var tcs = new TaskCompletionSource<object>();
    var eventInfo = obj.GetType().GetEvent(eventName);

    Type eventDelegate = eventInfo.EventHandlerType;

    Type[] parameterTypes = GetDelegateParameterTypes(eventDelegate);
    DynamicMethod handler = new DynamicMethod("unnamed", null, parameterTypes);

    ILGenerator ilgen = handler.GetILGenerator();

    //TODO ilgen.Emit calls go here

    Delegate dEmitted = handler.CreateDelegate(eventDelegate);

    eventInfo.AddEventHandler(obj, dEmitted);

    return tcs.Task;
}

Какой IL я мог бы сгенерировать, что позволило бы мне установить результат TaskCompletionSource? Или, как вариант, есть другой подход к созданию метода, возвращающего Task для любого произвольного события из произвольного типа?


person Servy    schedule 12.10.2012    source источник
comment
Обратите внимание, что в BCL есть TaskFactory.FromAsync для простого преобразования из APM в TAP. Не существует простого и универсального способа перевода из EAP в TAP, поэтому я думаю, что именно поэтому MS не включила подобное решение. Я считаю, что Rx (или поток данных TPL) в любом случае лучше соответствует семантике событий, и Rx действительно имеет метод типа FromEvent.   -  person Stephen Cleary    schedule 13.10.2012
comment
Я также хотел сделать общий FromEvent<>, и это близко, поскольку я мог добраться до этого без использования отражения.   -  person noseratio    schedule 24.07.2015


Ответы (4)


Ну вот:

internal class TaskCompletionSourceHolder
{
    private readonly TaskCompletionSource<object[]> m_tcs;

    internal object Target { get; set; }
    internal EventInfo EventInfo { get; set; }
    internal Delegate Delegate { get; set; }

    internal TaskCompletionSourceHolder(TaskCompletionSource<object[]> tsc)
    {
        m_tcs = tsc;
    }

    private void SetResult(params object[] args)
    {
        // this method will be called from emitted IL
        // so we can set result here, unsubscribe from the event
        // or do whatever we want.

        // object[] args will contain arguments
        // passed to the event handler
        m_tcs.SetResult(args);
        EventInfo.RemoveEventHandler(Target, Delegate);
    }
}

public static class ExtensionMethods
{
    private static Dictionary<Type, DynamicMethod> s_emittedHandlers =
        new Dictionary<Type, DynamicMethod>();

    private static void GetDelegateParameterAndReturnTypes(Type delegateType,
        out List<Type> parameterTypes, out Type returnType)
    {
        if (delegateType.BaseType != typeof(MulticastDelegate))
            throw new ArgumentException("delegateType is not a delegate");

        MethodInfo invoke = delegateType.GetMethod("Invoke");
        if (invoke == null)
            throw new ArgumentException("delegateType is not a delegate.");

        ParameterInfo[] parameters = invoke.GetParameters();
        parameterTypes = new List<Type>(parameters.Length);
        for (int i = 0; i < parameters.Length; i++)
            parameterTypes.Add(parameters[i].ParameterType);

        returnType = invoke.ReturnType;
    }

    public static Task<object[]> FromEvent<T>(this T obj, string eventName)
    {
        var tcs = new TaskCompletionSource<object[]>();
        var tcsh = new TaskCompletionSourceHolder(tcs);

        EventInfo eventInfo = obj.GetType().GetEvent(eventName);
        Type eventDelegateType = eventInfo.EventHandlerType;

        DynamicMethod handler;
        if (!s_emittedHandlers.TryGetValue(eventDelegateType, out handler))
        {
            Type returnType;
            List<Type> parameterTypes;
            GetDelegateParameterAndReturnTypes(eventDelegateType,
                out parameterTypes, out returnType);

            if (returnType != typeof(void))
                throw new NotSupportedException();

            Type tcshType = tcsh.GetType();
            MethodInfo setResultMethodInfo = tcshType.GetMethod(
                "SetResult", BindingFlags.NonPublic | BindingFlags.Instance);

            // I'm going to create an instance-like method
            // so, first argument must an instance itself
            // i.e. TaskCompletionSourceHolder *this*
            parameterTypes.Insert(0, tcshType);
            Type[] parameterTypesAr = parameterTypes.ToArray();

            handler = new DynamicMethod("unnamed",
                returnType, parameterTypesAr, tcshType);

            ILGenerator ilgen = handler.GetILGenerator();

            // declare local variable of type object[]
            LocalBuilder arr = ilgen.DeclareLocal(typeof(object[]));
            // push array's size onto the stack 
            ilgen.Emit(OpCodes.Ldc_I4, parameterTypesAr.Length - 1);
            // create an object array of the given size
            ilgen.Emit(OpCodes.Newarr, typeof(object));
            // and store it in the local variable
            ilgen.Emit(OpCodes.Stloc, arr);

            // iterate thru all arguments except the zero one (i.e. *this*)
            // and store them to the array
            for (int i = 1; i < parameterTypesAr.Length; i++)
            {
                // push the array onto the stack
                ilgen.Emit(OpCodes.Ldloc, arr);
                // push the argument's index onto the stack
                ilgen.Emit(OpCodes.Ldc_I4, i - 1);
                // push the argument onto the stack
                ilgen.Emit(OpCodes.Ldarg, i);

                // check if it is of a value type
                // and perform boxing if necessary
                if (parameterTypesAr[i].IsValueType)
                    ilgen.Emit(OpCodes.Box, parameterTypesAr[i]);

                // store the value to the argument's array
                ilgen.Emit(OpCodes.Stelem, typeof(object));
            }

            // load zero-argument (i.e. *this*) onto the stack
            ilgen.Emit(OpCodes.Ldarg_0);
            // load the array onto the stack
            ilgen.Emit(OpCodes.Ldloc, arr);
            // call this.SetResult(arr);
            ilgen.Emit(OpCodes.Call, setResultMethodInfo);
            // and return
            ilgen.Emit(OpCodes.Ret);

            s_emittedHandlers.Add(eventDelegateType, handler);
        }

        Delegate dEmitted = handler.CreateDelegate(eventDelegateType, tcsh);
        tcsh.Target = obj;
        tcsh.EventInfo = eventInfo;
        tcsh.Delegate = dEmitted;

        eventInfo.AddEventHandler(obj, dEmitted);
        return tcs.Task;
    }
}

Этот код будет работать практически для всех событий, возвращающих void (независимо от списка параметров).

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

Вы можете увидеть разницу между методами Дакса и моими ниже:

static async void Run() {
    object[] result = await new MyClass().FromEvent("Fired");
    Console.WriteLine(string.Join(", ", result.Select(arg =>
        arg.ToString()).ToArray())); // 123, abcd
}

public class MyClass {
    public delegate void TwoThings(int x, string y);

    public MyClass() {
        new Thread(() => {
                Thread.Sleep(1000);
                Fired(123, "abcd");
            }).Start();
    }

    public event TwoThings Fired;
}

Вкратце, мой код поддерживает действительно любой тип делегата. Вы не должны (и не должны) указывать его явно, как TaskFromEvent<int, string>.

person Nikolay Khil    schedule 12.10.2012
comment
Я только что закончил просматривать ваше обновление и немного поиграть с ним. Мне это очень нравится. Обработчик событий отписывается, что очень приятно. Различные обработчики событий кэшируются, поэтому IL не генерируется повторно для одних и тех же типов, и, в отличие от других решений, нет необходимости указывать типы аргументов для обработчика событий. - person Servy; 15.10.2012
comment
Мне не удалось заставить код работать на Windows Phone, не знаю, проблема ли это в безопасности. Но не сработало. Исключение: {Попытка доступа к методу не удалась: System.Reflection.Emit.DynamicMethod..ctor(System.String, System.Type, System.Type[], System.Type)} - person J. Lennon; 18.10.2012
comment
@ J.Lennon К сожалению, я не могу проверить это на Windows Phone. Поэтому я буду очень благодарен, если вы попытаетесь использовать эту обновленную версию и дайте мне знать, если это поможет. Заранее спасибо. - person Nikolay Khil; 18.10.2012
comment
@ J.Lennon Я думаю, что Серви рассказал об этом в своем последующем комментарии. Вероятно, есть и некоторые различия в производительности (не знаю, что было бы быстрее в каких сценариях без профилирования), но подписка на события или триггеры вряд ли будут узким местом в любом случае. - person Dax Fohl; 19.10.2012
comment
Вы знаете, можно ли упростить это с помощью деревьев выражений, или вам нужен низкоуровневый ilgen? - person Dax Fohl; 03.05.2014

Это даст вам то, что вам нужно, без необходимости делать какие-либо ilgen и намного проще. Он работает с любыми делегатами событий; вам просто нужно создать другой обработчик для каждого количества параметров в вашем делегате события. Ниже приведены обработчики, которые вам понадобятся для 0..2, которые должны быть в подавляющем большинстве ваших случаев использования. Расширение до 3 и выше — это простое копирование и вставка из двухпараметрического метода.

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

// Empty events (Action style)
static Task TaskFromEvent(object target, string eventName) {
    var addMethod = target.GetType().GetEvent(eventName).GetAddMethod();
    var delegateType = addMethod.GetParameters()[0].ParameterType;
    var tcs = new TaskCompletionSource<object>();
    var resultSetter = (Action)(() => tcs.SetResult(null));
    var d = Delegate.CreateDelegate(delegateType, resultSetter, "Invoke");
    addMethod.Invoke(target, new object[] { d });
    return tcs.Task;
}

// One-value events (Action<T> style)
static Task<T> TaskFromEvent<T>(object target, string eventName) {
    var addMethod = target.GetType().GetEvent(eventName).GetAddMethod();
    var delegateType = addMethod.GetParameters()[0].ParameterType;
    var tcs = new TaskCompletionSource<T>();
    var resultSetter = (Action<T>)tcs.SetResult;
    var d = Delegate.CreateDelegate(delegateType, resultSetter, "Invoke");
    addMethod.Invoke(target, new object[] { d });
    return tcs.Task;
}

// Two-value events (Action<T1, T2> or EventHandler style)
static Task<Tuple<T1, T2>> TaskFromEvent<T1, T2>(object target, string eventName) {
    var addMethod = target.GetType().GetEvent(eventName).GetAddMethod();
    var delegateType = addMethod.GetParameters()[0].ParameterType;
    var tcs = new TaskCompletionSource<Tuple<T1, T2>>();
    var resultSetter = (Action<T1, T2>)((t1, t2) => tcs.SetResult(Tuple.Create(t1, t2)));
    var d = Delegate.CreateDelegate(delegateType, resultSetter, "Invoke");
    addMethod.Invoke(target, new object[] { d });
    return tcs.Task;
}

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

static async void Run() {
    var result = await TaskFromEvent<int, string>(new MyClass(), "Fired");
    Console.WriteLine(result); // (123, "abcd")
}

public class MyClass {
    public delegate void TwoThings(int x, string y);

    public MyClass() {
        new Thread(() => {
            Thread.Sleep(1000);
            Fired(123, "abcd");
        }).Start();
    }

    public event TwoThings Fired;
}

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

person Community    schedule 12.10.2012
comment
Спасибо большое!!! Для Windows Phone эту строку необходимо изменить: var parameters = methodInfo.GetParameters().Select(a => System.Linq.Expressions.Expression.Parameter(a.ParameterType, a.Name)).ToArray(); - person J. Lennon; 18.10.2012

Если вы хотите иметь один метод для каждого типа делегата, вы можете сделать что-то вроде:

Task FromEvent(Action<Action> add)
{
    var tcs = new TaskCompletionSource<bool>();

    add(() => tcs.SetResult(true));

    return tcs.Task;
}

Вы бы использовали его как:

await FromEvent(x => new MyClass().OnCompletion += x);

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

Если вы используете универсальные делегаты, достаточно одного метода для каждого универсального типа, вам не нужен один для каждого конкретного типа:

Task<T> FromEvent<T>(Action<Action<T>> add)
{
    var tcs = new TaskCompletionSource<T>();

    add(x => tcs.SetResult(x));

    return tcs.Task;
}

Хотя вывод типа с этим не работает, вы должны явно указать параметр типа (при условии, что тип OnCompletion здесь равен Action<string>):

string s = await FromEvent<string>(x => c.OnCompletion += x);
person svick    schedule 12.10.2012
comment
Основная проблема заключается в том, что очень многие UI-фреймворки создают свои собственные типы делегатов для каждого события (вместо использования Action<T>/EventHandler<T>), и именно здесь что-то подобное было бы наиболее полезным, поэтому создание метода FromEvent для каждого типа делегата было бы быть лучше, но все же не идеальным. Тем не менее, вы можете просто использовать первый созданный вами метод: await FromEvent(x => new MyClass().OnCompletion += (a,b)=> x()); для любого события. Это своего рода половинчатое решение. - person Servy; 13.10.2012
comment
@Servy Да, я тоже думал об этом, но я не упомянул об этом, потому что считаю это уродливым (то есть слишком шаблонным). - person svick; 13.10.2012

Я столкнулся с этой проблемой, пытаясь написать метод расширения GetAwaiter для System.Action, забыв, что System.Action неизменяем, и передав его в качестве аргумента, вы делаете копию. Однако вы не сделаете копию, если передадите ее с ключевым словом ref, таким образом:

public static class AwaitExtensions
{ 
    public static Task FromEvent(ref Action action)
    {
        TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();
        action += () => tcs.SetResult(null);
        return tcs.Task;
    }
}

Применение:

await AwaitExtensions.FromEvent(ref OnActionFinished);

Примечание: прослушиватель TCS остается подписанным

person Brackets    schedule 24.03.2020
comment
Этот метод нельзя использовать для ожидания событий других классов. Его можно использовать только внутри текущего класса. В противном случае вы получите ошибку времени компиляции: Событие 'MyClass.OnActionFinished' может появиться только слева от += или -= (за исключением случаев, когда оно используется внутри типа 'MyClass') - person Theodor Zoulias; 25.03.2020
comment
К сожалению так. В моем случае было удобно добавить строку кода otherClass.OnAction += () => OnActionFinished?.Invoke(); перед await. - person Brackets; 25.03.2020