.Net: Затраты на многопоточность

    Недавно получил простую задачу: написать windows-сервис для обработки пользовательских запросов. Вопрос про то, какие эти запросы и по какому протоколу работает сервис, выходит за рамки этой статьи. Более интересным мне показался другой фактор, многопоточную ли делать обработку запросов. С одной стороны — последовательное выполнение тормозит процес обработки информации. С другой стороны могут быть не оправданы затраты на создание и запуск потока.
    Итак, исходные данные: 20 простейших запросов в секунду (1200 запросов в минуту) в пиковое время. Тестовый «сервер»: Celeron, 3ГЦ, 1Гб (свободно 70%).

    Однопоточная система


    Сначала напишем класс-базу для однопоточного выполнения запросов.
    1. using System;
    2. using System.Diagnostics;
    3. using System.Threading;
    4.  
    5. namespace TestConsoleApplication
    6. {
    7.  
    8.   class mockClass
    9.   {
    10.     private readonly Int32 incriment_speed;
    11.     private Int32 inc;
    12.  
    13.     public mockClass(int incriment_speed)
    14.     {
    15.       this.incriment_speed = incriment_speed;
    16.       inc = 0;      
    17.     }
    18.  
    19.     public Int32 incriment()
    20.     {
    21.       Thread.Sleep(incriment_speed);
    22.       return inc++;
    23.     }
    24.  
    25.     public Int32 getIncriment()
    26.     {
    27.       return inc;
    28.     }
    29.  
    30.   }
    31.  
    32.   class TestConsoleApplication
    33.   {    
    34.  
    35.     static void Main(string[] args)
    36.     {
    37.       if (args.Length<1) return;
    38.  
    39.       Int32 mockSpeed = 0;
    40.       if (!Int32.TryParse(args[0], out mockSpeed)) return;
    41.       var mock = new mockClass(mockSpeed);
    42.  
    43.       int beginTick = Environment.TickCount;
    44.       for (int j = 0; j < 1200; j++)
    45.       {
    46.         mock.incriment();
    47.       }
    48.       int endTick = Environment.TickCount;
    49.  
    50.       var performance = new PerformanceCounter("Process", "Private Bytes", Process.GetCurrentProcess().ProcessName);
    51.       Console.WriteLine(mock.getIncriment());
    52.       Console.WriteLine("tick: {0}", endTick - beginTick);
    53.       Console.WriteLine("memory: {0:N0}K", (performance.RawValue/1024));
    54.       Console.ReadLine();
    55.     }
    56.   }
    57. }
    * This source code was highlighted with Source Code Highlighter.

    Запустим программу с несколькими параметрами задержки выполнения запроса: 2, 5, 10
    2 5 10
    tick memory tick memory tick memory
    3688 10 792K 7281 10 780K 13125 10 792K

    Как видим память практически не страдает, а время примерно равно (mockSpeed+1)*1200. Дополнительную миллисекунду спишем на накладные расходы.

    Многопоточная система


    Перепишем программу для работы с многопоточностью, оптимизируем ее и сверим результаты:
    1. using System;
    2. using System.Diagnostics;
    3. using System.Threading;
    4.  
    5. namespace TestConsoleApplication
    6. {
    7.  
    8.   class mockClass
    9.   {
    10.     private readonly Int32 incriment_speed;
    11.     private Int32 inc;
    12.  
    13.     public mockClass(int incriment_speed)
    14.     {
    15.       this.incriment_speed = incriment_speed;
    16.       inc = 0;      
    17.     }
    18.  
    19.     public Int32 incriment()
    20.     {
    21.       Thread.Sleep(incriment_speed);
    22.       return inc++;
    23.     }
    24.  
    25.     public Int32 getIncriment()
    26.     {
    27.       return inc;
    28.     }
    29.  
    30.   }
    31.  
    32.   class TestConsoleApplication
    33.   {    
    34.     private static mockClass mock = null;
    35.  
    36.     static void threadmethod()
    37.     {
    38.       lock (mock)
    39.       {
    40.         mock.incriment(); 
    41.       }      
    42.     }
    43.  
    44.     static void Main(string[] args)
    45.     {
    46.       if (args.Length<1) return;
    47.  
    48.       Int32 mockSpeed = 0;
    49.       if (!Int32.TryParse(args[0], out mockSpeed)) return;
    50.       mock = new mockClass(mockSpeed);
    51.  
    52.       var performance = new PerformanceCounter("Process", "Private Bytes", Process.GetCurrentProcess().ProcessName);
    53.       long performance_RawValue = 0;
    54.       int beginTick = Environment.TickCount;
    55.       lock (mock)
    56.       {
    57.         for (int j = 0; j < 1200; j++)
    58.         {
    59.           var trd = new Thread(threadmethod, 65536); //выделяем 1 страницу под стек
    60.           trd.Start();
    61.         }
    62.         performance_RawValue = performance.RawValue;
    63.       }
    64.       int end1Tick = Environment.TickCount;
    65.       while(mock.getIncriment()<1200)
    66.       {
    67.         Thread.Sleep(2);
    68.       }
    69.       int end2Tick = Environment.TickCount;
    70.       
    71.       Console.WriteLine("starttick: {0}", end1Tick - beginTick);
    72.       Console.WriteLine("alltick: {0}", end2Tick - beginTick);
    73.       Console.WriteLine("memory: {0:N0}K", (performance_RawValue / 1024));
    74.       Console.ReadLine();
    75.     }
    76.   }
    77. }
    * This source code was highlighted with Source Code Highlighter.


    - 2 5 10
    - start tick all tick memory start tick all tick memory start tick all tick memory
    Однопоточная - 3688 10 792K - 7281 10 780K - 13125 10 792K
    Многопоточная 656 4234 323 508K 625 7719 323 508K 750 13735 323 508K

    При тестировании производительности многопоточности появилась новая величина время запуска процессов. Именно на эту величину вырастает общая продолжительность выполнения программы. Примерный запуск процесса равен 0,5миллисекундам. Также мы видим, значительно выросший объем используемой памяти, которая расходуется на стек запускаемых потоков.

    Итоги


    Выделим все сравниваемые величины в таблицу.
    - Однопоточная Многопоточная
    Общее время Общее время основного потока зависит от времени выполнения всех запросов Время работы основного потока зависит только от количества запросов
    Общее процессорное время Низкие паразитные нагрузки Паразитные нагрузки в 2 раза выше
    Память Невысокие запросы к памяти, независящие от количества запросов На каждый запрос расходуется не менее 256Кб памяти на стек потока


    Вот такая «студенческая лабораторная работа» вышла при изучении такого вопроса. Прошу не кидать камни :)
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 18

      +1
      «На каждый запрос расходуется не менее 256Кб памяти на стек процесса»
      что такое стек процесса? откуда вообще этот вывод?

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

      «Паразитные нагрузки в 2 раза выше»
      почему в два? не три, не четыре? выше чем что?
        +1
        1. Спасибо конечно же стек потоков. Подправил. Стек потоку необходим для передачи параметров в методы, сохранение состояний объектов для возврата из других методов, их виртуальных таблиц.
        2. Как я и сделал чтобы получить общее время, с так называемыми паразитными нагрузками, которые расходуются на запуск-завершение потоков, синхронизацию между ними.
        3. Как вы видите при однопотоковом формируется формула (mockSpeed+1)*N, где +1 можно отнести к паразитным нагрузкам, тогда же как в многопоточном эта формула 0.5*N+(mockSpeed+1.5)*N или (mockSpeed+2)*N, где уже +2 — значение паразитных нагрузок.
          0
          Кстати забыл заострить внимание на одном-единственном комментарии в программах. Хоть мы и выделили всего 64Кб памяти, поток сожрал 256Кб. Я где-то читал что 256Кб минимальный возможный объем памяти выделяемый под стек в Vista. В .Net такая же фигня твориться и на XP :)
            +1
            я немного переписал ваш тест. добавил Interlocked.increment и сделал несколько итераций сразу, убрал counter.

            сам код здесь: codepaste.ru/2018/
            результат здесь: codepaste.ru/2019/

            получилось по ~180 байт на поток. явное указание максимального размера стека для потока никак на результате не отразилось. меряется общее время прогона, отдельно для инициализации потоков — нет. время работы в MT не зависит от количества запросов при num_of_threads >> mockSpeed.

            ну и естественно все это мало имеет смысла при количестве процессоров равным 2.

            Kernel version: Microsoft Windows XP, Multiprocessor Free
            Service pack: 3
            Processors: 2
            Processor speed: 2.9 GHz
            Processor type: Intel® Pentium® 4 CPU
            Physical memory: 2046 MB
              0
              явное указание максимального размера стека для потока никак на результате не отразилось
              Все дело в том, что вы смотрите на изменение размера кучи, а не на изменение объема Private Bytes текущего процесса.
              время работы в MT не зависит от количества запросов при num_of_threads >> mockSpeed. А вот тут поподробнее, у меня «паразитные нагрузки» вашего кода — огромные…
                0
                в случае виртуальной машины не так важно Private Bytes. джава, например, освобождает память только при необходимости.

                сложно говорить о паразитных нагрузках на тестовом примере. в реальности я бы использовал тредпул. время работы можно посмотреть в выложенном логе.
                взять хотя бы вашу формулу: 0.5*N+(mockSpeed+1.5)*N. если num_of_threads >> mockSpeed (кстати, тут неточность, вы ждете инкремента в 1200, тогда num_of_treads > 1200), то достаточно одного вызова в каждой нитке, а тогда формула должна превращается в С*N + mockSpeed, где C — какая-то константа.
                  0
                  Вот как раз в случае с виртуальной машиной Private Bytes очень важно. Виртуальная машина работает не с памятю, а кучей, размер которой вы получили при помощи GC.GetTotalMemory. Разница же между Private Bytes и GC.GetTotalMemory и есть затраты виртуальной машины. Вас не смутило то, что при МТ я performance.RawValue получал в залоченной области, иначе бы могло быть закрыто несколько потоков и соответственно освобождена память мгновенно, на уровне менеджера памяти защищенного режима самой ОС.

                  О, я понял о чем вы. У нас разное понятие производительности. Вы оцениваете затраты физического (астрономического) времени, я же оцениваю затраты в «астрономических» тактах процессора. Т.е. на вашей машинке бы «астрономические» паразитные затраты были бы в 2 раза меньше, т.к. у вас 2 3хГц процессора(ядра). Здесь я наверное допустил самую большую ошибку. Мне стоило писать сразу в тактах процессора… Хорошая мысля как говориться…
        0
        Как насчет использовать Erlang или GHC для таких целей? У них обоих хорошо с concurrency. И даже лучше с абстракцией, поэтому вам не придется даже ничего сильно переписывать.
          +2
          Зачем нужно 1200 потоков, синхронизация и переключения сожрут производительность.
          Создайте 2*кол-во ядер и подкидывайте им задачи по мере освобождения.
            +3
            Не проще ли использовать ThreadPool?
              –1
              ThreadPool это некий компромис. Он хорош тогда когда потоки в основном живут в sleep. Когда же их основная задача — обработать запрос и умереть, то ThreadPool можно рассматривать как несколько однопоточных обработчиков, синхронизированных между собой общей очередью. При этом мы выигрываем в производительности и несильно проигрываем в памяти. Но от основной проблемы однопоточного обработчика, зависимости общего времени основных потоков от времени выполнения всех запросов, мы не избавляемся.
                +1
                Так не должны они умирать.

                Обработал запрос и заснул. Ждём следующего.

                Пул потоков сам доктор прописал.
                  0
                  Каждый поток пула находится в спящем состоянии до тех пор, пока очередь задач пула пуста. В этом случае процессорное время не расходуется. Как только в пул поступает задача, один из потоков просыпается и начинает её обрабатывать. Если во время обработки поступила ещё одна задача, она передаётся следующему потоку и т.д. Если все потоки заняты, задача ставится в очередь, и первый освободившийся поток без промедления забирает её себе. Т.о. если у вас будет большой поток запросов, то оверхеда практически нет. Ну а если поток запросов маленький, то и волноваться не стоит, что потоки переходят в спящий режим.
                    0
                    Кстати, помимо Interlocked-операций советую покопать в сторону атрибута [ThreadStatic].
                      0
                      Смотрите. Есть у нас 1200 запросов и 4 обработчика в ThreadPool. Легкой операцией деления мы получаем по 300 запросов на поток. Увеличим поток запросов в 4 раза и получаем 4 засинхронизированных однопоточных обработчика, рассмотренных в первой части поста. Единственное что мы получим — полная загрузка всех ядер и процессоров системы при минимальных затратах памяти…
                  0
                  У меня сейчас на работе аналогичная задача, пишу сервис. Но я решил каждую обработку выполнять асинхронно. Естесственно потоки будут браться из .Net ThreadPool. Не знаю, как это скажется на производительности. Но уже был опыт аналогичный, писал web handler (IAsyncHttpHandler). По сравнению с однопоточной страницей производительность на той же машине выросла на 15-20%. Использование памяти смотрел правда по диспетчеру задач. Время замерял немного по другому. сначала перед созданием объекта ответа, конечная точка — полностью сформированный объект ответа, готовый для выплёвывания Response.Write(). Время на обработку запроса уменьшилось.
                    0
                    выиграл в производительности — проиграл в памяти, все ок)
                      0
                      Именно. При этом не всегда алгоритм распараллеливания обработчиков может привести к увеличению производительности, т.к. простои из-за паразитного времени на выделение потоков бывает больше, чем сама обработка потока.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое