Производительность параллелизма .NET на стороне клиента


Я пишу клиентское приложение .NET, которое, как ожидается, будет использовать много потоков. Меня предупреждали, что производительность .NET очень плоха, когда речь заходит о параллелизме. Хотя я не пишу приложение реального времени, я хочу убедиться, что мое приложение масштабируемо (т. е. позволяет много потоков) и каким-то образом сопоставимо с эквивалентным приложением C++.

Каков ваш опыт? Что такое релевантный ориентир?

5 4

5 ответов:

Я собрал быстрый и грязный бенчмарк в C#, используя в качестве теста генератор простых чисел. Тест генерирует простые числа с постоянным пределом (я выбрал 500000), используя простое сито реализации Eratosthenes, и повторяет тест 800 раз, распараллеленный по определенному числу потоков, либо используя .NET ThreadPool, либо автономные потоки.

Тест был выполнен на четырехъядерном процессоре Q6600 под управлением Windows Vista (x64). Это не использование параллельной библиотеки задач, а просто простые потоки. Это было выполните следующие сценарии:
  • последовательное выполнение (без резьбы)
  • 4 потока (то есть по одному на ядро), используя ThreadPool
  • 40 потоков, использующих ThreadPool (для проверки эффективности самого пула)
  • 4 автономных потока
  • 40 автономных потоков, имитирующих давление переключения контекста

Результаты были следующими:

Test | Threads | ThreadPool | Time
-----+---------+------------+--------
1    | 1       | False      | 00:00:17.9508817
2    | 4       | True       | 00:00:05.1382026
3    | 40      | True       | 00:00:05.3699521
4    | 4       | False      | 00:00:05.2591492
5    | 40      | False      | 00:00:05.0976274

Из этого можно сделать следующие выводы:

  • Распараллеливание не идеально (как и ожидалось - это никогда не происходит, независимо от окружающей среды), но разделение нагрузки на 4 ядра приводит к увеличению пропускной способности примерно в 3,5 раза, на что вряд ли стоит жаловаться.

  • Была незначительная разница между 4 и 40 потоками, использующими ThreadPool, Что означает, что никаких значительных расходов не происходит с пулом, даже когда вы бомбардируете его запросами.

  • Была незначительная разница между ThreadPool и свободнопоточными версиями, что означает, что ThreadPool не имеет каких-либо существенных "постоянных" расходов;

  • Была незначительная разница между 4-потоковыми и 40-потоковыми свободнопоточными версиями, что означает, что .NET работает не хуже, чем можно было бы ожидать при интенсивном переключении контекста.

Нужен ли нам вообще бенчмарк C++ для сравнения? Результаты довольно ясны: потоки в .NET не являются медленными. Если толькоВы , программист, не напишете плохой многопоточный код и не получите истощение ресурсов или блокирование конвоев, вам действительно не нужно беспокоиться.

С .NET 4.0 и TPL и улучшениями ThreadPool, очередями воровства работы и всем этим классным материалом, у вас есть еще больше свободы для написания "сомнительного" кода и все еще эффективно его запускать. Вы вообще не получаете эти функции от C++.

Для справки, вот тестовый код:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;

namespace ThreadingTest
{
    class Program
    {
        private static int PrimeMax = 500000;
        private static int TestRunCount = 800;

        static void Main(string[] args)
        {
            Console.WriteLine("Test | Threads | ThreadPool | Time");
            Console.WriteLine("-----+---------+------------+--------");
            RunTest(1, 1, false);
            RunTest(2, 4, true);
            RunTest(3, 40, true);
            RunTest(4, 4, false);
            RunTest(5, 40, false);
            Console.WriteLine("Done!");
            Console.ReadLine();
        }

        static void RunTest(int sequence, int threadCount, bool useThreadPool)
        {
            TimeSpan duration = Time(() => GeneratePrimes(threadCount, useThreadPool));
            Console.WriteLine("{0} | {1} | {2} | {3}",
                sequence.ToString().PadRight(4),
                threadCount.ToString().PadRight(7),
                useThreadPool.ToString().PadRight(10),
                duration);
        }

        static TimeSpan Time(Action action)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            action();
            sw.Stop();
            return sw.Elapsed;
        }

        static void GeneratePrimes(int threadCount, bool useThreadPool)
        {
            if (threadCount == 1)
            {
                TestPrimes(TestRunCount);
                return;
            }

            int testsPerThread = TestRunCount / threadCount;
            int remaining = threadCount;
            using (ManualResetEvent finishedEvent = new ManualResetEvent(false))
            {
                for (int i = 0; i < threadCount; i++)
                {
                    Action testAction = () =>
                    {
                        TestPrimes(testsPerThread);
                        if (Interlocked.Decrement(ref remaining) == 0)
                        {
                            finishedEvent.Set();
                        }
                    };

                    if (useThreadPool)
                    {
                        ThreadPool.QueueUserWorkItem(s => testAction());
                    }
                    else
                    {
                        ThreadStart ts = new ThreadStart(testAction);
                        Thread th = new Thread(ts);
                        th.Start();
                    }
                }
                finishedEvent.WaitOne();
            }
        }

        [MethodImpl(MethodImplOptions.NoOptimization)]
        static void IteratePrimes(IEnumerable<int> primes)
        {
            int count = 0;
            foreach (int prime in primes) { count++; }
        }

        static void TestPrimes(int testRuns)
        {
            for (int t = 0; t < testRuns; t++)
            {
                var primes = Primes.GenerateUpTo(PrimeMax);
                IteratePrimes(primes);
            }
        }
    }
}

А вот простой генератор:

using System;
using System.Collections.Generic;
using System.Linq;

namespace ThreadingTest
{
    public class Primes
    {
        public static IEnumerable<int> GenerateUpTo(int maxValue)
        {
            if (maxValue < 2)
                return Enumerable.Empty<int>();

            bool[] primes = new bool[maxValue + 1];
            for (int i = 2; i <= maxValue; i++)
                primes[i] = true;

            for (int i = 2; i < Math.Sqrt(maxValue + 1) + 1; i++)
            {
                if (primes[i])
                {
                    for (int j = i * i; j <= maxValue; j += i)
                        primes[j] = false;
                }
            }

            return Enumerable.Range(2, maxValue - 1).Where(i => primes[i]);
        }
    }
}

Если вы видите какие-либо очевидные недостатки в тесте, позвольте мне знать. За исключением каких-либо серьезных проблем с самим тестом, я думаю, что результаты говорят сами за себя, и сообщение ясно:

Не слушайте тех, кто делает слишком широкие и неквалифицированные заявления о том, что производительность .NET или любого другого языка/среды "плоха" в какой-то конкретной области, потому что они, вероятно, говорят из своей... задняя часть.

Возможно, вы захотите взглянуть на System.Threading.Tasks, представленную в .NET 4.

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

Кстати, я не знаю, кто сказал вам, что .NET не был хорош с параллелизмом. Все мои приложения используют потоки в какой-то момент другого, но не забывайте, что наличие 10 потоков на 2-ядерном процессоре является своего рода контрпродуктивным (в зависимости от типа задачи, которую вы заставляете их выполнять. Если это задачи, которые ждем сетевых ресурсов, тогда это может иметь смысл).

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

Это миф. .NET очень хорошо справляется с управлением параллелизмом и очень масштабируема.

Если вы можете, я бы рекомендовал использовать .NET 4 и библиотеку параллельных задач. Это упрощает многие проблемы параллелизма. Для получения более подробной информации я бы рекомендовал обратиться к центру MSDN для параллельных вычислений с управляемым кодом.

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

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

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

Если анекдотические свидетельства помогут, на моей последней работе мы написали: сильно параллельное торговое приложение, которое обрабатывало более 20 000 событий рыночных данных в секунду и обновляло массивную сетку "основной формы" с соответствующими данными, все через довольно массивную потоковую архитектуру и все в C# и VB.NET из-за сложности приложения мы оптимизировали многие области, но никогда не видели преимущества в переписывании потокового кода на родном языке C++.

Во-первых, вы должны серьезно пересмотреть, нужно ли вам много потоков или только некоторые. Дело не в том, что .NET-потоки медленные. Потоки идут медленно. Переключение задач-дорогостоящая операция, независимо от того, кто написал алгоритм.

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

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