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

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

Прежде чем сделать это, я задался вопросом, что обеспечит оптимальную производительность, поэтому я попробовал ConcurrentBag, ConcurrentStack и ConcurrentQueue и измерил время, необходимое для добавления 10000000 элементов.

Я использовал следующую программу, чтобы проверить это:

class Program
{
    static List<int> list = new List<int>();
    static ConcurrentBag<int> bag = new ConcurrentBag<int>();
    static ConcurrentStack<int> stack = new ConcurrentStack<int>();
    static ConcurrentQueue<int> queue = new ConcurrentQueue<int>();
    static void Main(string[] args)
    {
        run(addList);
        run(addBag);
        run(addStack);
        run(addQueue);
        Console.ReadLine();
    }

    private static void addList(int obj) { lock (list) { list.Add(obj); } }

    private static void addStack(int obj) { stack.Push(obj); }

    private static void addQueue(int obj) { queue.Enqueue(obj); }

    private static void addBag(int obj) { bag.Add(obj); }



    private static void run(Action<int> action)
    {
        Stopwatch stopwatch = Stopwatch.StartNew();
        Parallel.For(0, 10000000, new ParallelOptions() { MaxDegreeOfParallelism = # }, action);
        stopwatch.Stop();
        Console.WriteLine(action.Method.Name + " takes " + stopwatch.Elapsed);
    }
}

где # — количество используемых потоков.

но результаты довольно запутанны:

с 8 нитями:

  • addList занимает 00:00:00.8166816
  • addBag занимает 00:00:01.0368712
  • addStack занимает 00:00:01.0902852
  • addQueue занимает 00:00:00.6555039

с 1 нитью:

  • addList занимает 00:00:00.3880958
  • addBag занимает 00:00:01.5850249
  • addStack занимает 00:00:01.2764924
  • addQueue занимает 00:00:00.4409501

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

РЕДАКТИРОВАТЬ: после комментариев ниже о сборке мусора и отладки: да, это влияет на тест. Влияние сборки отладки будет линейным, Мусор будет увеличиваться с увеличением использования памяти.

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

Я переместил инициализацию коллекции прямо перед запуском теста и теперь собираю мусор после запуска, вот так:

        list = new List<int>();
        run(addList);
        list = null;
        GC.Collect();

с MaxDegreeOfParallelism, установленным на 8, я получаю следующие результаты:

  • addList занимает 00:00:7959546
  • addBag занимает 00:00:01.08023823
  • addStack занимает 00:00:01.1354566
  • addQueue занимает 00:00:00.6597145

с отклонением 0,02 секунды каждый раз, когда я запускаю код.


person Stijn Tallon    schedule 11.10.2013    source источник
comment
В чем именно заключается ваш вопрос?   -  person tnw    schedule 12.10.2013
comment
Используйте коллекцию, которая функционально делает то, что вам нужно. Почему вы рассматриваете список, поскольку элементы не будут выходить из списка при его обработке? Почему вы принимаете решение о добавлении 1 миллиона элементов вовремя, когда это только часть того, что вам нужно?   -  person paparazzo    schedule 12.10.2013
comment
Вы делаете стандартные ошибки бенчмарка. Вы запускаете отладочную сборку и измеряете накладные расходы на джитинг и сборку мусора. Запустите сборку Release без отладчика, зациклите только 1000 раз, вызовите GC.Collect() перед каждым вызовом run() и повторите группу тестов 10 раз. Обратите внимание на совершенно разные числа, которые вы получите. И как плохо они повторяются.   -  person Hans Passant    schedule 12.10.2013
comment
Я внес предложенные вами изменения, и факт остается фактом: даже при наличии 1000 элементов сумка работает намного медленнее, чем заблокированный список. список @ 0,0035 сек против 0,0138 для сумки.   -  person Stijn Tallon    schedule 13.10.2013


Ответы (4)


Параллельные коллекции не всегда быстрее. большинство из них видят прирост производительности только на более высоких уровнях конкуренции, и фактическая рабочая нагрузка также оказывает влияние. Ознакомьтесь с этой статьей от команды pfx :)

http://blogs.msdn.com/b/pfxteam/archive/2010/04/26/9997562.aspx

Остерегайтесь преждевременной оптимизации. собрать что-то вместе, что работает, а затем оптимизировать. тем более, что фактическая нагрузка важна. Кроме того, иметь блокировки в качестве узкого места производительности довольно сложно, обычно есть какой-то io или другой алгоритм, который занимает гораздо больше времени :)

person aL3891    schedule 11.10.2013
comment
как уровень конкуренции может быть выше, чем 8 потоков, постоянно пытающихся изменить коллекцию, в то время как нет другой нагрузки, чтобы дать коллекции перерыв? - person Stijn Tallon; 13.10.2013
comment
Ну, у вас может быть более 8 процессоров и больше потоков :) Замедление с блокировками связано с переключением контекста, вызванным блокировкой, и это не связано с аппаратными потоками. с меньшим количеством потоков тот же поток может повезти и снова получить блокировку, кроме того, Paralell.Fore попытается свернуть любые переключения потоков, выполняя несколько итераций цикла за это время. - person aL3891; 13.10.2013
comment
более 8 процессоров в настоящее время редкость. Больше потоков, чем процессор, не увеличивает уровень конкуренции? или это так? Если один поток обращается, другой поток, по крайней мере, ничего не делает, потому что процессоры заняты другими потоками? - person Stijn Tallon; 23.10.2013
comment
серверы обычно имеют более 8 ядер, но, возможно, это не то, на что вы ориентируетесь :) правда, скажем, с одним ядром в данный момент активен только один поток, но все остальные потоки тоже должны быть разбужены и запущены, и это занимает много времени, поэтому вам все равно не нужно слишком много потоков - person aL3891; 25.10.2013
comment
да, переключение контекста не бесплатно, но если количество потоков равно количеству ядер, потерянное время должно быть примерно равно времени, проведенному в заблокированном состоянии? - person Stijn Tallon; 02.11.2013
comment
Absolutley, при условии, что у вас нет операций ввода-вывода или других блокирующих операций :) Если вы это сделаете, вы получите, что ядра ничего не будут делать, пока один из 8 потоков заблокирован. - person aL3891; 02.11.2013
comment
@StijnTallon Threading — это продолжение работы всякий раз, когда вы вынуждены ждать, что часто не имеет ничего общего с ядрами ЦП. Рассмотрим процесс, который извлекает данные с одного сервера базы данных, выполняет на нем сложные вычисления, записывает данные на другой сервер базы данных, записывает в файлы на локальном жестком диске и визуализирует данные для пользователя. Даже с 1 ядром вы можете извлечь выгоду из 12 или более потоков для этого процесса, поскольку вы можете ждать блокировки базы данных, блокировки файлов, сети, процессора, графического процессора, дискового ввода-вывода и т. д., и запросы к/от одного и того же сервера могут также быть асинхронным по тем же причинам! Очень ситуативный. - person Daniel; 07.06.2018

Не забывайте, что вам нужно не только добавлять элементы в коллекцию, но и извлекать их. Таким образом, более справедливо сравнение между монитором на основе Queue‹T› и BlockingCollection‹T›, в каждой из которых 8 производителей и 1 потребитель.

Затем я получаю следующие результаты на своей машине (я увеличил количество итераций в 10 раз):

  • AddQueue1 занимает 00:00:18.0119159
  • AddQueue2 занимает 00:00:13.3665991

Но интересна не только производительность. Взгляните на два подхода: очень сложно проверить правильность Add/ConsumeQueue1, в то время как очень легко увидеть, что Add/ConsumeQueue2 делает именно то, что задумано, благодаря абстракции, предоставляемой BlockingCollection‹T›.


static Queue<int> queue1 = new Queue<int>();
static BlockingCollection<int> queue2 = new BlockingCollection<int>();

static void Main(string[] args)
{
    Run(AddQueue1, ConsumeQueue1);
    Run(AddQueue2, ConsumeQueue2);
    Console.ReadLine();
}

private static void AddQueue1(int obj)
{
    lock (queue1)
    {
        queue1.Enqueue(obj);
        if (queue1.Count == 1)
            Monitor.Pulse(queue1);
    }
}

private static void ConsumeQueue1()
{
    lock (queue1)
    {
        while (true)
        {
            while (queue1.Count == 0)
                Monitor.Wait(queue1);
            var item = queue1.Dequeue();
            // do something with item
        }
    }
}

private static void AddQueue2(int obj)
{
    queue2.TryAdd(obj);
}

private static void ConsumeQueue2()
{
    foreach (var item in queue2.GetConsumingEnumerable())
    {
        // do something with item
    }
}

private static void Run(Action<int> action, ThreadStart consumer)
{
    new Thread(consumer) { IsBackground = true }.Start();
    Stopwatch stopwatch = Stopwatch.StartNew();
    Parallel.For(0, 100000000, new ParallelOptions() { MaxDegreeOfParallelism = 8 }, action);
    stopwatch.Stop();
    Console.WriteLine(action.Method.Name + " takes " + stopwatch.Elapsed);
}
person dtb    schedule 11.10.2013
comment
предполагая, что потоки работают полностью отдельно друг от друга, это верно. Но я забыл упомянуть, что мой потребительский поток синхронизирован с потоками производителей, под этим я подразумеваю, что пока мои производители работают, потребитель гарантированно спит, и наоборот. Так что на самом деле я беспокоюсь только о том, чтобы все предметы были в сумке после того, как производители закончили работу. И чтобы предметы добавлялись в корзину как можно быстрее. Порядок не имеет значения. - person Stijn Tallon; 13.10.2013

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

class Program
{
    static List<int> list = new List<int>();
    static ConcurrentBag<int> bag = new ConcurrentBag<int>();
    static ConcurrentStack<int> stack = new ConcurrentStack<int>();
    static ConcurrentQueue<int> queue = new ConcurrentQueue<int>();
    static void Main(string[] args)
    {
        list = new List<int>();
        run(addList);
        run(takeList);

        list = null;
        GC.Collect();

        bag = new ConcurrentBag<int>();
        run(addBag);
        run(takeBag);

        bag = null;
        GC.Collect();

        stack = new ConcurrentStack<int>();
        run(addStack);
        run(takeStack);

        stack = null;
        GC.Collect();

        queue = new ConcurrentQueue<int>();
        run(addQueue);
        run(takeQueue);

        queue = null;
        GC.Collect();

        Console.ReadLine();
    }

    private static void takeList(int obj)
    {
        lock (list)
        {
            if (list.Count == 0)
                return;

            int output = list[obj];
        }
    }

    private static void takeStack(int obj)
    {
        stack.TryPop(out int output);
    }

    private static void takeQueue(int obj)
    {
        queue.TryDequeue(out int output);
    }

    private static void takeBag(int obj)
    {
        bag.TryTake(out int output);
    }

    private static void addList(int obj) { lock (list) { list.Add(obj); } }

    private static void addStack(int obj) { stack.Push(obj); }

    private static void addQueue(int obj) { queue.Enqueue(obj); }

    private static void addBag(int obj) { bag.Add(obj); }



    private static void run(Action<int> action)
    {
        Stopwatch stopwatch = Stopwatch.StartNew();
        Parallel.For(0, 10000000, new ParallelOptions()
        {
            MaxDegreeOfParallelism = 8
        }, action);
        stopwatch.Stop();
        Console.WriteLine(action.Method.Name + " takes " + stopwatch.Elapsed);
    }
}

И вывод:

  • addList занимает 00:00:00.8875893
  • takeList занимает 00:00:00.7500289
  • addBag занимает 00:00:01.8651759
  • takeBag занимает 00:00:00.5749322
  • addStack занимает 00:00:01.5565545
  • takeStack занимает 00:00:00.3838718
  • addQueue занимает 00:00:00.8861318
  • takeQueue занимает 00:00:01.0510706
person Community    schedule 23.08.2017
comment
По порядку: takeStack 0,384, takeBag 0,575, takeList 0,750, addQueue 0,886, addList 0,888, takeQueue 1,051, addStack 1,557, addBag 1,865. - person Patrick; 17.06.2019

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

person Ian McInnes    schedule 11.07.2018