Для тех, кто обладает феноменальной памятью и с первого раза запомнил всё, что дядюшка Рихтер написал про многопоточность в библии дотнетчика, тот может смело пропустить этот пост. Но для большинства людей пару-тройку интересных моментов всё-таки надеюсь найдётся.
Факт №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));
foreach (var item in items)
{
Task.Factory.StartNew(() => DoSomething(item));
}
Кроме того, что в первом случае синтаксис чуть более выразительный, так этот код ещё и гораздо эффективней. Достигается это за счёт класса Partitioner<TSource>, который используется внутри класса Parallel. Задача Partitioner'а в том, что бы распределение задач проходило наиболее оптимальным образом. Для уменьшения накладных расходов, он может группировать элементы и выполнять несколько простых задач в рамках одного Task'а (максимально возможное число создаваемых Task’ов в этом случае будет 64). Графически это можно отобразить следующим образом:
Факт № 4: i++ не атомарная операция.
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 переменные не могут быть переданы по ссылке.
Комментариев нет:
Отправить комментарий