суббота, 14 марта 2015 г.

5 фактов о многопоточности в .Net, которые часто забывают

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

Факт №1:  ThreadPool не всегда лучший выбор.

Есть ряд причин, при которых использование пула потоков не только не лучший выбор, а и вовсе - противопоказан:

  • когда нужно гарантированно выполнить асинхронно что-то тяжёлое; все потоки в пуле фоновые и не смогут воспрепятствовать немедленному завершению процесса приложения, если пользователь решит его закрыть.
  • когда задач много и они могут быть долго заблокированы; у пула потоков есть ограничение максимального числа потоков, в которое можно упереться и вообще никакая работа не будет выполняться (процессор будет простаивать).
  • когда асинхронная задача требует особых настроек (безопасности, CultureInfo и т.д.); т.к. в пуле потоки переиспользуются, то невозможно заранее знать, какой поток будет выделен для обработки запланированного задания, а следовательно присвоение нужного значения свойствам CurrentCulture или CurrentPrincipal становится неподъёмной задачей.

 

Факт №2: ThreadPool.SetMinThreads() и ThreadPool.SetMaxThreads() делают не то, что многие от них ожидают.

Задав значение MinThreads, например, равным 10, кто-то может ожидать появление 10 потоков, расходующих ресурсы в пустую. А задав MaxThreads равным 2000 и добавив столько же заданий в очередь – ждать появление 2000 новых потоков. Но к счастью это не так.

MinThreads определяет лишь минимальный порог, при котором начинают срабатывать специальные проверки, способствующие оптимальной работе пула. Т.е. в нашем примере, первые 10 потоков будут выделены максимально быстро, по мере добавления задач в пул, но когда же в пуле уже будут созданы первые 10 потоков. то для добавления 11-го потока, пул сначала попробует подождать освобождение уже созданных потоков. И только если через 0,5 сек ни один поток не освободиться, то будет создан новый (11-й) поток.

MaxThreads ограничивает максимально возможное число создаваемых потоков. Но в виду всё того же алгоритма оптимизации, при добавлении 2000 простых заданий, пул вероятней всего обойдётся всего лишь 10-ю потоками.

 

Факт № 3: Parallel.ForEach лучше чем “foreach + Task”.

Сначала чуть подробней, что же мы сравниваем:

Parallel.ForEach(items, item => DoSomething(item));            
vs
foreach (var item in items)
{
    Task.Factory.StartNew(() => DoSomething(item));
}

Кроме того, что в первом случае синтаксис чуть более выразительный, так этот код ещё и гораздо эффективней. Достигается это за счёт класса Partitioner<TSource>, который используется внутри класса Parallel. Задача Partitioner'а в том, что бы распределение задач проходило наиболее оптимальным образом. Для уменьшения накладных расходов, он может группировать элементы и выполнять несколько простых задач в рамках одного Task'а (максимально возможное число создаваемых Task’ов в этом случае будет 64). Графически это можно отобразить следующим образом:


image


Факт № 4: i++ не атомарная операция.


С появился в далёком 1972. 

С++  - спустя 11 лет в 1983.

C++++ (он же С#) - в 2000.

Но не смотря на то, что в C# целых четыре плюса и прошло почти 30 лет, с момента появления С, инкремент (++) всё равно не стал атомарной операцией. Строго говоря и декремент (--) тоже.

И это достаточно логично. Иначе зачем бы разработчики дот нета создавали клаcс Interlocked и методы Increment(int) и Decrement(int)?

Что бы убедиться в необходимосте этих методов, достаточно попытаться из разных потоков увеличивать и уменьшать переменную типа Int32 или Int64.
int i = 0;

var task = Task.Run(() =>
{
    for (int j = 0; j < 1000000; j++)
    {
        i++;
        //Interlocked.Increment(ref i);
    }
});

for (int j = 0; j < 1000000; j++)
{
    i--;
    //Interlocked.Decrement(ref i);
}

task.Wait();

Console.WriteLine(i);

На моей машине в 5 из 10 раз на консоль выводилось значение отличное от нуля. Т.е. каждый второй раз программа будет выполняться не правильно!

Интересно отметить:

  • версия с багом (код без Interlocked) на моей машине выполняется за 30 мс.
  • при использовании класса Interlocked, код стал примерно в 3 раза медленней
  • при синхронизации с помощью Monitor'а (конструкция lock), - в 6-9 раз медленней
  • при синхронизации с помощью AutoResetEvent’a (примитив синхронизации ядра)  - в 1000 раз медленней!!!

Факт № 5: Оптимизатор компиляции может менять порядок строк.

Нет, я не говорю про баг компилятора. Это поведение “By design” и как правило улучшает работу программы. Но совместно с многопоточностью может быть непрятным сюрпризом…

Рассмотрим вот такой код

class BadClass
{
    private int _value = 0;
    private int _isInited = 0;

    public void Thread_1_Method()
    {
        _value = 42;
        _isInited = 1;
    }

    public void Thread_2_Method()
    {
        if (_isInited == 1)
            Console.Write(_value); //Разве мы может увидеть тут 0?!
    }
}

Вроде появление нуля на консоли не возможно, но он появляется! И всё это благодаря тому, что компилятор меняет местами строки присвоения значений переменным _v1 и _v2.


Ну и как с этим бороться? А вот как.


Вариант №1 – MemoryBarrier

class MemeryBarrier
{
    private int _value = 0;
    private int _isInited = 0;

    public void Thread_1_Method()
    {
        _value = 42;
        Thread.MemoryBarrier();
        _isInited = 1;
    }
    public void Thread_2_Method()
    {
        if (_isInited == 1)
        {
            Thread.MemoryBarrier();
            Console.Write(_value);
        }
    }
}

Вариант №2 – методы VolatileRead и VolatileWrite()

class VolatileReadWrite
{
    private int _value = 0;
    private int _isInited = 0;

    public void Thread_1_Method()
    {
        _value = 42;
        Thread.VolatileWrite(ref _isInited, 1);
    }
    public void Thread_2_Method()
    {
        if (Thread.VolatileRead(ref _value) == 1)
            Console.Write(_isInited);
    }
}

Вариант №3 – ключевое слово volatile


class Volatile
{
    private volatile int _value = 0;
    private int _isInited = 0;

    public void Thread_1_Method()
    {
        _value = 42;
        _isInited = 1;
    }
    public void Thread_2_Method()
    {
        if (_value == 1)
        {
            Console.Write(_isInited);
        }                
    }
}

Хоть последний вариант является моим любимым, у него есть ряд недостатков:


  • В случае вычисления «х = х + х», если х будет объявлена с volatile, то это будет работать медленнее, чем использование метода VolatileRead (надо вычитать при помощи VolatileRead  во временную переменную и потом это значение использовать для вычисления результирующей суммы).
  • volatile переменные не могут быть переданы по ссылке.

Комментариев нет:

Отправить комментарий