Почему ConcurrentBag <T> настолько медленный в .Net(4.0)? Я делаю это неправильно?

Прежде чем я начал проект, я написал простой тест, чтобы сравнить производительность ConcurrentBag от (System.Collections.Concurrent) относительно блокировки и списков. Я очень удивлен, что ConcurrentBag более чем в 10 раз медленнее блокировки с помощью простого списка. Насколько я понимаю, ConcurrentBag работает лучше всего, когда читатель и писатель - это один и тот же поток. Однако я не думал, что производительность будет намного хуже, чем традиционные блокировки.

Я проверил тест с двумя Parallel для записи петель и чтения из списка/пакета. Однако сама запись показывает огромную разницу:

private static void ConcurrentBagTest()
   {
        int collSize = 10000000;
        Stopwatch stopWatch = new Stopwatch();
        ConcurrentBag<int> bag1 = new ConcurrentBag<int>();

        stopWatch.Start();


        Parallel.For(0, collSize, delegate(int i)
        {
            bag1.Add(i);
        });


        stopWatch.Stop();
        Console.WriteLine("Elapsed Time = {0}", 
                          stopWatch.Elapsed.TotalSeconds);
 }

В моем поле это занимает от 3-4 секунд до версии 0.5 - 0.9 сек. этого кода:

       private static void LockCollTest()
       {
        int collSize = 10000000;
        object list1_lock=new object();
        List<int> lst1 = new List<int>(collSize);

        Stopwatch stopWatch = new Stopwatch();
        stopWatch.Start();


        Parallel.For(0, collSize, delegate(int i)
            {
                lock(list1_lock)
                {
                    lst1.Add(i);
                }
            });

        stopWatch.Stop();
        Console.WriteLine("Elapsed = {0}", 
                          stopWatch.Elapsed.TotalSeconds);
       }

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

[EDIT] - Я удалил Задачи, потому что я не нуждаюсь в них здесь (у полного кода было другое задание)

[EDIT] Большое спасибо за ответы. Мне сложно выбрать "правильный ответ", поскольку, похоже, это сочетание нескольких ответов.

Как отметил Майкл Голдштейн, скорость действительно зависит от данных. Дарин отметил, что для ConcurrentBag должно быть больше споров, и Parallel.For не обязательно запускает одинаковое количество потоков. Один момент, который нужно убрать, - не делать ничего, что вам не нужно для внутри замка. В приведенном выше случае я не вижу, что я что-то делаю внутри блокировки, за исключением того, что присваиваю значение переменной temp.

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

Я провел несколько тестов с запуском 15 Заданий, и результаты зависели от размера коллекции, среди прочего. Однако ConcurrentBag выполнялся почти так же, как и лучше, чем блокировка списка, до 1 миллиона вставок. Свыше 1 миллиона, блокировка, казалось, была намного быстрее иногда, но у меня, вероятно, никогда не будет более масштабной структуры данных для моего проекта. Вот код, который я запускал:

        int collSize = 1000000;
        object list1_lock=new object();
        List<int> lst1 = new List<int>();
        ConcurrentBag<int> concBag = new ConcurrentBag<int>();
        int numTasks = 15;

        int i = 0;

        Stopwatch sWatch = new Stopwatch();
        sWatch.Start();
         //First, try locks
        Task.WaitAll(Enumerable.Range(1, numTasks)
           .Select(x => Task.Factory.StartNew(() =>
            {
                for (i = 0; i < collSize / numTasks; i++)
                {
                    lock (list1_lock)
                    {
                        lst1.Add(x);
                    }
                }
            })).ToArray());

        sWatch.Stop();
        Console.WriteLine("lock test. Elapsed = {0}", 
            sWatch.Elapsed.TotalSeconds);

        // now try concurrentBag
        sWatch.Restart();
        Task.WaitAll(Enumerable.Range(1, numTasks).
                Select(x => Task.Factory.StartNew(() =>
            {
                for (i = 0; i < collSize / numTasks; i++)
                {
                    concBag.Add(x);
                }
            })).ToArray());

        sWatch.Stop();
        Console.WriteLine("Conc Bag test. Elapsed = {0}",
               sWatch.Elapsed.TotalSeconds);

Ответ 1

Позвольте мне спросить вас: насколько реалистично, что у вас будет приложение, которое постоянно добавляет в коллекцию и никогда не читает из нее? Какая польза от такой коллекции? (Это не чисто риторический вопрос.Я могу представить, что там, где, например, вы читаете только из коллекции при завершении работы (для ведения журнала) или по запросу пользователя. Я считаю, что эти сценарии довольно редки.)

Это то, что имитирует ваш код. Вызов List<T>.Add будет молниеносно во всех случаях, кроме случайного случая, когда список должен изменить его внутренний массив; но это сглаживается всеми другими добавками, которые происходят довольно быстро. Таким образом, вы вряд ли увидите значительное количество конфликтов в этом контексте, особенно тестирование на персональном компьютере с, например, даже 8 ядрами (как вы заявили, что находитесь где-то в комментариях). Возможно, вы можете увидеть больше споров о чем-то вроде 24-ядерной машины, где многие ядра могут пытаться добавить в список буквально в одно и то же время.

Контекст гораздо более вероятен, когда вы читаете из своей коллекции, особенно. в циклах foreach (или запросы LINQ, которые равны foreach) под капотом), которые требуют блокировки всей операции, так что вы не изменяете свою коллекцию, итерации по ней.

Если вы можете реально воспроизвести этот сценарий, я уверен, вы увидите масштаб ConcurrentBag<T> намного лучше, чем показывает текущий тест.


Обновить: Здесь - это программа, которую я написал, чтобы сравнить эти коллекции в описанном выше сценарии (несколько авторов, многие читатели). Запустив 25 проб с размером коллекции 10000 и 8 потоков читателей, я получил следующие результаты:

Took 529.0095 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 39.5237 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
Took 309.4475 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 81.1967 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
Took 228.7669 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 164.8376 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
[ ... ]
Average list time: 176.072456 ms.
Average bag time: 59.603656 ms.

Так ясно, что это зависит от того, что вы делаете с этими коллекциями.

Ответ 2

Кажется, что ошибка в .NET Framework 4, установленная Microsoft в 4.5, кажется, что они не ожидали, что ConcurrentBag будет использоваться много.

Для получения дополнительной информации см. следующий пост Айенде

http://ayende.com/blog/156097/the-high-cost-of-concurrentbag-in-net-4-0

Ответ 3

В качестве общего ответа:

  • Параллельные коллекции, использующие блокировку, могут быть очень быстрыми, если для их данных (т.е. блокировок) мало или нет. Это связано с тем, что такие классы коллекций часто строятся с использованием очень недорогих фиксирующих примитивов, особенно когда они не защищены.
  • Коллекции Lockless могут быть более медленными из-за трюков, используемых для предотвращения блокировок и из-за других узких мест, таких как ложное совместное использование, сложности, требуемой для реализации их беззаботного характера, приводящего к промахам кэш-памяти и т.д.

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

В вашем конкретном примере есть очень высокая степень раздора, поэтому я должен сказать, что меня удивляет поведение. С другой стороны, объем работы, выполняемой при сохранении блокировки, очень мал, поэтому, возможно, в самом замке мало споров. Также могут быть недостатки в реализации обработки ConcurrentBag concurrency, которая делает ваш конкретный пример (с частыми вставками и без чтения) плохим вариантом использования.

Ответ 4

Глядя на программу с помощью MS contention visualizer, мы видим, что ConcurrentBag<T> имеет гораздо более высокую стоимость, связанную с параллельной вставкой, чем просто блокировка на List<T>. Одна вещь, которую я заметил, - это, по-видимому, затраты, связанные с вращением 6 потоков (используемых на моей машине), чтобы начать первый запуск ConcurrentBag<T> (холодный прогон). Затем используются 5 или 6 потоков с кодом List<T>, который быстрее (теплый пробег). Добавление другого ConcurrentBag<T> запуска после списка показывает, что это занимает меньше времени, чем первый (теплый прогон).

Из того, что я вижу в конфликте, много времени тратится на выделение памяти ConcurrentBag<T>. Удаление явного выделения размера из кода List<T> замедляет его, но недостаточно для изменения.

EDIT:, похоже, что ConcurrentBag<T> внутренне сохраняет список за Thread.CurrentThread, блокирует 2-4 раза в зависимости от того, работает ли он на новом потоке и выполняет, по меньшей мере, один Interlocked.Exchange. Как отмечено в MSDN: "оптимизировано для сценариев, в которых один и тот же поток будет производить и потреблять данные, хранящиеся в сумке". Это наиболее вероятное объяснение снижения производительности по сравнению с необработанным списком.

Ответ 5

Это уже разрешено в .NET 4.5. Основная проблема заключалась в том, что ThreadLocal, который использует ConcurrentBag, не ожидал иметь много экземпляров. Это исправлено и теперь может работать довольно быстро.

source - высокая стоимость ConcurrentBag в .NET 4.0

Ответ 6

Как сказал Дарин-Димитров, я подозреваю, что ваш Parallel.For на самом деле не порождает столько же потоков в каждом из двух результатов. Попробуйте вручную создать N потоков, чтобы убедиться, что на самом деле вы видите конфликт потоков в обоих случаях.

Ответ 7

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

class Program
{
    private static object list1_lock = new object();
    private const int collSize = 1000;

    static void Main()
    {
        ConcurrentBagTest();
        LockCollTest();
    }

    private static void ConcurrentBagTest()
    {
        var bag1 = new ConcurrentBag<int>();
        var stopWatch = Stopwatch.StartNew();
        Task.WaitAll(Enumerable.Range(1, collSize).Select(x => Task.Factory.StartNew(() =>
        {
            Thread.Sleep(5);
            bag1.Add(x);
        })).ToArray());
        stopWatch.Stop();
        Console.WriteLine("Elapsed Time = {0}", stopWatch.Elapsed.TotalSeconds);
    }

    private static void LockCollTest()
    {
        var lst1 = new List<int>(collSize);
        var stopWatch = Stopwatch.StartNew();
        Task.WaitAll(Enumerable.Range(1, collSize).Select(x => Task.Factory.StartNew(() =>
        {
            lock (list1_lock)
            {
                Thread.Sleep(5);
                lst1.Add(x);
            }
        })).ToArray());
        stopWatch.Stop();
        Console.WriteLine("Elapsed = {0}", stopWatch.Elapsed.TotalSeconds);
    }
}

Ответ 8

Я предполагаю, что блокировки не испытывают большого разногласия. Я бы рекомендовал прочитать следующую статью: Теория и практика Java: анатомия ошибочного микрообъектива. В статье рассматривается блокировка микрозахвата. Как указано в статье, в таких ситуациях необходимо учитывать много вещей.

Ответ 9

Было бы интересно увидеть масштабирование между ними.

Два вопроса

1), как быстро сумка против списка для чтения, не забудьте зафиксировать в списке

2), как быстро сумка против списка для чтения, когда другой поток пишет

Ответ 10

Поскольку тело цикла мало, вы можете попробовать использовать метод Create Partitioner...

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

Практическое руководство. Ускорьте мелкие петлевые тела

Ответ 11

Похоже, что ConcurrentBag работает медленнее, чем другие параллельные коллекции.

Я думаю, что это проблема реализации. ANTS Profiler показывает, что он завязывается в нескольких местах - включая копию массива.

Использование параллельного словаря в тысячи раз быстрее.