Подтвердить что ты не робот

Task.Factory.StartNew против Parallel.Invoke

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

Какой подход был бы наиболее оптимальным:

  • Использование Task.Factory.StartNew в цикле foreach, итерации по массиву Action (Action[])

    Task.Factory.StartNew(() => someAction());

  • Использование класса Parallel, где actions - массив Action (Action[])

    Parallel.Invoke(actions);

Являются ли эти два подхода эквивалентными? Существуют ли какие-либо последствия для производительности?

ИЗМЕНИТЬ

Я выполнил некоторые тесты производительности, и на моей машине (2 процессора 2 ядра каждый) результаты, похоже, очень похожи. Я не уверен, как это будет выглядеть на других машинах, таких как 1 процессор. Также я не уверен (не знаю, как проверить его очень точный способ), что такое потребление памяти.

4b9b3361

Ответ 1

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

Это семантическое различие должно быть вашим первым (и, вероятно, единственным) рассмотрением. Но для информационных целей, здесь контрольный показатель:

/* This is a benchmarking template I use in LINQPad when I want to do a
 * quick performance test. Just give it a couple of actions to test and
 * it will give you a pretty good idea of how long they take compared
 * to one another. It not perfect: You can expect a 3% error margin
 * under ideal circumstances. But if you're not going to improve
 * performance by more than 3%, you probably don't care anyway.*/
void Main()
{
    // Enter setup code here
    var actions2 =
    (from i in Enumerable.Range(1, 10000)
    select (Action)(() => {})).ToArray();

    var awaitList = new Task[actions2.Length];
    var actions = new[]
    {
        new TimedAction("Task.Factory.StartNew", () =>
        {
            // Enter code to test here
            int j = 0;
            foreach(var action in actions2)
            {
                awaitList[j++] = Task.Factory.StartNew(action);
            }
            Task.WaitAll(awaitList);
        }),
        new TimedAction("Parallel.Invoke", () =>
        {
            // Enter code to test here
            Parallel.Invoke(actions2);
        }),
    };
    const int TimesToRun = 100; // Tweak this as necessary
    TimeActions(TimesToRun, actions);
}


#region timer helper methods
// Define other methods and classes here
public void TimeActions(int iterations, params TimedAction[] actions)
{
    Stopwatch s = new Stopwatch();
    int length = actions.Length;
    var results = new ActionResult[actions.Length];
    // Perform the actions in their initial order.
    for(int i = 0; i < length; i++)
    {
        var action = actions[i];
        var result = results[i] = new ActionResult{Message = action.Message};
        // Do a dry run to get things ramped up/cached
        result.DryRun1 = s.Time(action.Action, 10);
        result.FullRun1 = s.Time(action.Action, iterations);
    }
    // Perform the actions in reverse order.
    for(int i = length - 1; i >= 0; i--)
    {
        var action = actions[i];
        var result = results[i];
        // Do a dry run to get things ramped up/cached
        result.DryRun2 = s.Time(action.Action, 10);
        result.FullRun2 = s.Time(action.Action, iterations);
    }
    results.Dump();
}

public class ActionResult
{
    public string Message {get;set;}
    public double DryRun1 {get;set;}
    public double DryRun2 {get;set;}
    public double FullRun1 {get;set;}
    public double FullRun2 {get;set;}
}

public class TimedAction
{
    public TimedAction(string message, Action action)
    {
        Message = message;
        Action = action;
    }
    public string Message {get;private set;}
    public Action Action {get;private set;}
}

public static class StopwatchExtensions
{
    public static double Time(this Stopwatch sw, Action action, int iterations)
    {
        sw.Restart();
        for (int i = 0; i < iterations; i++)
        {
            action();
        }
        sw.Stop();

        return sw.Elapsed.TotalMilliseconds;
    }
}
#endregion

Результаты:

Message               | DryRun1 | DryRun2 | FullRun1 | FullRun2
----------------------------------------------------------------
Task.Factory.StartNew | 43.0592 | 50.847  | 452.2637 | 463.2310
Parallel.Invoke       | 10.5717 |  9.948  | 102.7767 | 101.1158 

Как вы можете видеть, использование Parallel.Invoke может быть примерно в 4,5 раза быстрее, чем ждать завершения набора новых задач. Конечно, когда твои действия ничего не делают. Чем больше каждое действие, тем меньше различий вы заметите.

Ответ 2

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

Parallel.Invoke в основном выполняет Task.Factory.StartNew() для вас. Итак, я бы сказал, что читаемость здесь важнее.

Кроме того, как упоминает StriplingWarrior, Parallel.Invoke выполняет WaitAll (блокирует код до тех пор, пока все задачи не будут завершены) для вас, так что вам тоже не нужно это делать. Если вы хотите, чтобы задачи выполнялись в фоновом режиме, не заботясь о их завершении, вы хотите Task.Factory.StartNew().

Ответ 3

Я использовал тесты из StriplingWarror, чтобы узнать, откуда эта разница. Я сделал это, потому что, когда я смотрю с Reflector в коде, класс Parallel не имеет ничего общего с созданием множества задач и позволяет им запускать.

С теоретической точки зрения оба подхода должны быть эквивалентны по времени выполнения. Но поскольку (не очень реалистичные) тесты с пустым действием действительно показали, что класс Parallel намного быстрее.

Реализация задачи практически полностью посвящена созданию новых задач, которые приводят ко многим сборщикам мусора. Разница в скорости, которую вы видите, объясняется исключительно тем, что вы создаете много задач, которые быстро превращаются в мусор.

Вместо этого класс Parallel создает собственный производный класс, который запускается одновременно на всех процессорах. На всех ядрах работает только одна фискальная задача. Синхронизация происходит внутри делегата задачи, что объясняет гораздо более быструю скорость класса Parallel.

ParallelForReplicatingTask task2 = new ParallelForReplicatingTask(parallelOptions, delegate {
        for (int k = Interlocked.Increment(ref actionIndex); k <= actionsCopy.Length; k = Interlocked.Increment(ref actionIndex))
        {
            actionsCopy[k - 1]();
        }
    }, TaskCreationOptions.None, InternalTaskOptions.SelfReplicating);
task2.RunSynchronously(parallelOptions.EffectiveTaskScheduler);
task2.Wait();

Так что же лучше? Лучшая задача - это задача, которая никогда не запускается. Если вам нужно создать так много задач, что они станут бременем для сборщика мусора, вы должны держаться подальше от API задач и придерживаться класса Parallel, который дает вам прямое параллельное выполнение на всех ядрах без новых задач.

Если вам нужно стать еще быстрее, возможно, что создание потоков вручную и использование оптимизированных вручную структур данных, чтобы дать вам максимальную скорость для вашего шаблона доступа, является наиболее эффективным решением. Но маловероятно, что вам это удастся, потому что TPL и Parallel API уже настроены. Обычно вам нужно использовать одну из многих перегрузок для настройки ваших запущенных задач или класса Parallel, чтобы добиться того же самого с гораздо меньшим количеством кода.

Но если у вас есть нестандартный шаблон нитей, возможно, вам лучше не использовать TPL, чтобы получить максимальную отдачу от ваших ядер. Даже Стивен Туб упомянул, что API-интерфейсы TPL не были разработаны для сверхбыстрых результатов, но главная цель заключалась в том, чтобы упростить прошивку для "среднего" программиста. Чтобы победить TPL в определенных случаях, вам нужно быть значительно выше среднего, и вам нужно знать много вещей о линиях кэша CPU, планировании потоков, моделях памяти, генерации кода JIT,... выходить в вашем конкретном сценарии с чем-то лучше.