Тесты показывают, что "ожидание" значительно медленнее, даже если ожидаемый объект уже завершен

Я хотел протестировать накладные расходы, приписываемые программе, используя await/async.

Чтобы проверить это, я написал следующий тестовый класс:

public class Entity : INotifyCompletion {
    private Action continuation;
    private int i;

    public void OnCompleted(Action continuation) {
        this.continuation = continuation;
    }

    public Entity GetAwaiter() {
        return this;
    }

    public Entity GetResult() {
        return this;
    }

    public bool IsCompleted { get { return true; } }

    public void Execute() {
        if (i > 0) Console.WriteLine("What");
    }
}

И затем я написал тестовую упряжь. Испытательный жгут проходит через TestA и TestB 1600 раз, измеряя последние только 1500 раз (чтобы JIT мог "разогреться" ). set - это набор объектов Entity (но реализация не имеет значения). В комплекте установлено 50 000 объектов. Испытательный жгут использует класс Stopwatch для тестирования.

private static void DoTestA() {
    Entity[] objects = set.GetElements();
    Parallel.For(0, objects.Length, async i => {
        Entity e = objects[i];
        if (e == null) return;

        (await e).Execute();
    });
}

private static void DoTestB() {
    Entity[] objects = set.GetElements();
    Parallel.For(0, objects.Length, i => {
        Entity e = objects[i];
        if (e == null) return;

        e.Execute();
    });
}

Две подпрограммы идентичны, за исключением того, что один ожидает объект перед вызовом Execute() (Execute() ничего не полезен, это просто какой-то немой код, чтобы убедиться, что процессор действительно что-то делает для каждой Entity).


После выполнения моего теста в режиме выпуска таргетинга AnyCPU, я получаю следующий вывод:

>>> 1500 repetitions >>> IN NANOSECONDS (1000ns = 0.001ms)
Method   Avg.         Min.         Max.         Jitter       Total
A        1,301,465ns  1,232,200ns  2,869,000ns  1,567,534ns  ! 1952.199ms
B        130,053ns    116,000ns    711,200ns    581,146ns    ! 195.081ms

Как вы можете видеть, метод с ждут в нем примерно в 10 раз медленнее.

Дело в том, что, насколько мне известно, нет ничего 'ждать' GetResult всегда верно. Означает ли это, что машина состояний выполняется, даже если ожидаемая "вещь" уже готова?

Если так, есть ли способ обойти это? Я хотел бы использовать семантику async/await, но эта служебная информация слишком высока для моего приложения...


EDIT: добавление полного кода теста после запроса:

Program.cs

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace CSharpPerfTest {
    public class Entity : INotifyCompletion {
        private Action continuation;
        private int i;

        public void OnCompleted(Action continuation) {
            this.continuation = continuation;
        }

        public Entity GetAwaiter() {
            return this;
        }

        public Entity GetResult() {
            return this;
        }

        public bool IsCompleted { get { return true; } }

        public void Execute() {
            if (i > 0) Console.WriteLine("What");
        }
    }

    static class Program {
        static ConcurrentSet<Entity> set;
        const int MAX_ELEMENTS = 50000;

        // Called once before all testing begins
        private static void OnceBefore() {
            set = new ConcurrentSet<Entity>();

            Parallel.For(0, MAX_ELEMENTS, i => {
                set.Add(new Entity());
            });
        }

        // Called twice each repetition, once before DoTestA and once before DoTestB
        private static void PreTest() {

        }

        private static void DoTestA() {
            Entity[] objects = set.GetElements();
            Parallel.For(0, objects.Length, async i => {
                Entity e = objects[i];
                if (e == null) return;
                (await e).Execute();
            });
        }

        private static void DoTestB() {
            Entity[] objects = set.GetElements();
            Parallel.For(0, objects.Length, i => {
                Entity e = objects[i];
                if (e == null) return;
                e.Execute();
            });
        }

        private const int REPETITIONS = 1500;
        private const int JIT_WARMUPS = 10;

        #region Test Harness
        private static double[] aTimes = new double[REPETITIONS];
        private static double[] bTimes = new double[REPETITIONS];

        private static void Main(string[] args) {
            Stopwatch stopwatch = new Stopwatch();

            OnceBefore();

            for (int i = JIT_WARMUPS * -1; i < REPETITIONS; ++i) {
                Console.WriteLine("Starting repetition " + i);

                PreTest();
                stopwatch.Restart();
                DoTestA();
                stopwatch.Stop();
                if (i >= 0) aTimes[i] = stopwatch.Elapsed.TotalMilliseconds;

                PreTest();
                stopwatch.Restart();
                DoTestB();
                stopwatch.Stop();
                if (i >= 0) bTimes[i] = stopwatch.Elapsed.TotalMilliseconds;
            }

            DisplayScores();
        }

        private static void DisplayScores() {
            Console.WriteLine();
            Console.WriteLine();

            bool inNanos = false;
            if (aTimes.Average() < 10 || bTimes.Average() < 10) {
                inNanos = true;
                for (int i = 0; i < aTimes.Length; ++i) aTimes[i] *= 1000000;
                for (int i = 0; i < bTimes.Length; ++i) bTimes[i] *= 1000000;
            }

            Console.WriteLine(">>> " + REPETITIONS + " repetitions >>> " + (inNanos ? "IN NANOSECONDS (1000ns = 0.001ms)" : "IN MILLISECONDS (1000ms = 1s)"));
            Console.WriteLine("Method   Avg.         Min.         Max.         Jitter       Total");

            Console.WriteLine(
            "A        "
            + (String.Format("{0:N0}", (long) aTimes.Average()) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + (String.Format("{0:N0}", (long) aTimes.Min()) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + (String.Format("{0:N0}", (long) aTimes.Max()) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + (String.Format("{0:N0}", (long) Math.Max(aTimes.Average() - aTimes.Min(), aTimes.Max() - aTimes.Average())) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + ((long) aTimes.Sum() >= 10000 && inNanos ? "! " + String.Format("{0:f3}", aTimes.Sum() / 1000000) + "ms" : (long) aTimes.Sum() + (inNanos ? "ns" : "ms"))
            );
            Console.WriteLine(
            "B        "
            + (String.Format("{0:N0}", (long) bTimes.Average()) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + (String.Format("{0:N0}", (long) bTimes.Min()) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + (String.Format("{0:N0}", (long) bTimes.Max()) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + (String.Format("{0:N0}", (long) Math.Max(bTimes.Average() - bTimes.Min(), bTimes.Max() - bTimes.Average())) + (inNanos ? "ns" : "ms")).PadRight(13, ' ')
            + ((long) bTimes.Sum() >= 10000 && inNanos ? "! " + String.Format("{0:f3}", bTimes.Sum() / 1000000) + "ms" : (long) bTimes.Sum() + (inNanos ? "ns" : "ms"))
            );

            Console.ReadKey();
        }
        #endregion

    }
}

Ответ 1

Если ваша функция имеет время отклика, что 1 мс на 50 000 вызовов считается значимым, вы не должны ждать этого кода и вместо этого запускать его синхронно.

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

Ответ 2

Преобразован в ответ из комментариев: , по-видимому, это не чистый тестовый тест.

Если вам не требуется асинхронное продолжение, просто не используйте его. Код всегда быстрее без него. Если вам это нужно, тогда ожидайте некоторые накладные расходы. Вы должны понимать, что происходит за сценой, когда вы используете определенную функцию языка/времени выполнения.

Даже если вы удалите Parallel.For, преобразуете lambdas в методы и предотвратите inlining, все равно будет некоторое копирование struct и await закрытия закрытия продолжения, для поддержки функции конечного автомата (пример сгенерированного кода).

Более справедливым эталоном будет тестирование async/await по сравнению с альтернативной реализацией с использованием замыканий обратного вызова и Task.ContinueWith в потоке без контекста синхронизации. Я бы не ожидал существенных различий в этом случае.

На боковой ноте вы передаете async void lambda Action в Parallel.For. Вы должны знать, что элемент управления выполнением вернется к Parallel.For, как только появится 1-й await внутри лямбда, а затем он будет по существу вызовом "огонь и забудем" вне Parallel.For. Я действительно не могу придумать никаких полезных сценариев для этого.