Pull to refresh

.NET: Инструменты для работы с многопоточностью и асинхронностью. Часть 2

Reading time 13 min
Views 70K
Публикую на Хабр оригинал статьи, перевод которой размещен в блоге Codingsight.

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

Содержание





О разделяемых ресурсах


Невозможно написать программу, которая бы работала в несколько потоком, но при этом не имела бы ни одного общего ресурса: т.к. даже если это получится на вашем уровне абстракции, то спустившись на один или несколько уровней ниже окажется, что общий ресурс все-таки есть. Приведу несколько примеров:

Пример#1:

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

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

Пример#2:

Прочитав пример#1, вы решили разместить файлы на двух разных удаленных машинах с двумя физически разными железяками и операционными системами. Держим 2 разных соединения по FTP или NFS.

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

Пример#3:

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

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

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

Возможные проблемы при работе в многопоточной среде


Ошибки в работе ПО можно разбить на несколько групп:

  1. Программа не выдает результат. Падает или зависает.
  2. Программа выдает неверный результат.
  3. Программа выдает верный результат, но не удовлетворяет тому или иному не функциональному требованию. Работает слишком долго или потребляет слишком много ресурсов.

В многопоточной среде двумя основными проблемами, порождающими ошибки 1 и 2 являются deadlock и race condition.

Deadlock


Deadlock — взаимная блокировка. Существует много разных вариаций. Наиболее частой можно считать следующую:



Пока Thread#1 что-то делал, Thread#2 заблокировал ресурс B, немного позднее Thread#1 заблокировал ресурс A и пытается заблокировать ресурс B, к сожалению это никогда не произойдет, т.к. Thread#2 освободит ресурс B лишь после того как заблокирует ресурс А.

Race-Condition


Race-Condition — состояние гонки. Ситуация, в которой поведение и результат вычислений, выполняемых программой, зависит от работы планировщика потоков среды выполнения.
Неприятность этой ситуации заключается как раз в том, что ваша программа может не работать лишь один раз из ста или даже из миллиона.

Усугубляются ситуация тем, что проблемы могут идти вместе, например: при определенном поведении планировщика потоков возникает взаимная блокировка.

Кроме этих двух проблем приводящих к явным ошибкам в работе программы есть еще те, что, возможно, и не приведут к некорректному результату вычислений, но для его получения будет потрачено больше времени или вычислительной мощности. Двумя такими проблемами являются: Busy Wait и Thread Starvation.

Busy-Wait


Busy-Wait — проблема, при которой программа потребляет ресурсы процессора не для вычислений, а для ожидания.

Часто такая проблема в коде выглядит примерно так:

while(!hasSomethingHappened)
    ;

Это пример крайне плохого кода, т.к. Такой код полностью занимает одно ядро вашего процессора при этом не делая ровным счетом ничего полезного. Он может быть оправдан тогда и только тогда, когда критически важно обработать изменение какого-то значения в другом потоке. И говоря быстро я говорю, о случае, когда вы не можете ждать даже нескольких наносекунд. В остальных случаях, то есть во всех, что может породить здоровый мозг, разумнее использовать разновидности ResetEvent и их Slim версии. О них ниже.

Возможно, кто-то из читателей предложит решить проблему с полной загрузкой одного ядра бесполезным ожиданием добавлением в цикл конструкции вроде Thread.Sleep(1). Это действительно решит проблему, но создаст другую: время реагирование на изменение будет в среднем равен половине миллисекунды, что может и не много, но катастрофически больше, чем могло бы быть используй вы примитивы синхронизации семейства ResetEvent.

Thread Starvation


Thread-Starvation — проблема, при которой в программе слишком много одновременно работающих потоков. При чем речь именно о тех потоках, что заняты расчетами, а не просто ожиданием ответа от какого-либо IO. При этой проблеме теряется весь возможный выигрыш в производительности от использования потоков, т.к. Процессор тратит очень много времени на переключение контекстов.
Такие проблемы удобно искать с помощью различных профилировщиков, ниже пример скриншота из профилировщика dotTrace запущенного в режиме Timeline.


(Картинка кликабельна)

В программе что не страдает от потокового голода, розового цвета на графиках, отражающих потоки не будет. Кроме этого, в категории Subsystems видно, что программа 30.6% ждала CPU.

Когда такая проблема диагностирована, решается она довольно просто: вы запустили слишком много потоков в один момент времени, запустите меньше или не все сразу.

Средства синхронизации



Interlocked


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

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

Класс Interlocked предоставляет методы Increment/Decrement, нетрудно догадаться, что они делают. Их удобно использовать, если вы обрабатываете данные в несколько потоков и что-то считаете. Такой код будет работать значительно быстрее классического lock. Если для ситуации описанной в прошлом абзаце использовать Interlocked, то программа в любой ситуации будет стабильно выдавать значение 10 миллионов.

Метод CompareExchange выполняет, на первый взгляд довольно неочевидную функцию, но все ее наличие позволяет реализовывать множество интересных алгоритмов, в первую очередь семейства lock-free.

public static int CompareExchange (ref int location1, int value, int comparand);

Метод принимает три значения: первое передается по ссылке и это то значение, которое будет изменено на второе, если в момент сравнения location1 совпадает с comparand, то оригинальное значение location1 будет возвращено. Звучит довольно запутано, потому проще написать код, который выполняет те же операции, что и CompareExchange:

var original = location1;
if (location1 == comparand)
    location1 = value;
return original;

Только реализация в классе Interlocked будет атомарной. То есть, напиши мы такой код сами, могла бы произойти ситуация, когда условие location1 == comparand уже выполнилось, но к моменту выполнения выражения location1 = value другой поток изменил значение location1 и оно будет утеряно.

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

Напишем простой класс с одним событием MyEvent:

class MyClass {
    public event EventHandler MyEvent;
}

Соберем проект в конфигурации Release и откроем сборку при помощи dotPeek c включенной опцией Show Compiler Generated Code:

[CompilerGenerated]
private EventHandler MyEvent;
public event EventHandler MyEvent
{
  [CompilerGenerated] add
  {
    EventHandler eventHandler = this.MyEvent;
    EventHandler comparand;
    do
    {
      comparand = eventHandler;
      eventHandler = Interlocked.CompareExchange<EventHandler>(ref this.MyEvent, (EventHandler) Delegate.Combine((Delegate) comparand, (Delegate) value), comparand);
    }
    while (eventHandler != comparand);
  }
  [CompilerGenerated] remove
  {
    // The same algorithm but with Delegate.Remove
  }
}

Здесь видно, что за кулисами, компилятор сгенерировал довольно изощренный алгоритм. Этот алгоритм защищает от ситуации потери подписки на событие, когда несколько потоков одновременно на это событие подписываются. Давайте распишем метод add подробнее, вспомнив что за кулисами делает метод CompareExchange

EventHandler eventHandler = this.MyEvent;
EventHandler comparand;
do
{
    comparand = eventHandler;
    // Begin Atomic Operation
    if (MyEvent == comparand)
    {
        eventHandler = MyEvent;
        MyEvent = Delegate.Combine(MyEvent, value);
    }
    // End Atomic Operation
}
while (eventHandler != comparand);

Так уже немного понятнее, хотя, наверное, все еще нуждается в объяснении. Словами бы я описал этот алгоритм следующим образом:

Если MyEvent все еще такой как был на момент как мы начали выполнять Delegate.Combine, то запиши в него то, что вернет Delegate.Combine, а если нет, то не беда, давай попробуем еще раз и будем повторять пока не выйдет.


Так ни одна подписка на событие не будет потеряна. Подобную проблему вам придется решать, если вдруг захотите реализовать динамический потокобезопасный lock-free массив. Если несколько потоков рвануться добавлять в него элементы, то важно чтобы все они в итоге были добавлены.

Monitor.Enter, Monitor.Exit, lock


Это самые часто используемые конструкции для синхронизации потоков. Реализуют идею критической секции: то есть код, написанный между вызовами Monitor.Enter, Monitor.Exit на одном ресурсе может быть выполнен в один момент времени лишь одним потоком. Оператор lock является синтаксическим сахаром вокруг вызовов Enter/Exit обернутых в try-finally. Приятной особенностью реализации критической секции в .NET является возможность повторного входа в нее для одного и того же потока. Это значит, что подобный код выполнится без проблем:

lock(a) {
  lock (a) {
    ...
  }
}

Вряд ли, конечно, кто-то будет писать именно так, но, если размазать этот код на несколько методов по глубине call-stack эта особенность может сэкономить вам несколько if-ов. Для того, чтобы такой трюк стал возможным разработчикам .NET пришлось добавить ограничение — в качестве объекта синхронизации может использоваться лишь экземпляр ссылочного типа, а в каждый объект неявно добавить несколько байт, куда будет записан идентификатор потока.

Такая особенность работы критической секции в c# накладывает одно интересное ограничение на работу оператора lock: внутри оператора lock нельзя использовать оператор await. Сначала это вызвало у меня удивление, ведь аналогичная конструкция try-finally Monitor.Enter/Exit компилируется. В чем же дело? Здесь необходимо еще раз внимательно перечитать прошлый абзац, а затем добавить к нему некоторые знания о принципе работы async/await: код после await совершенно не обязательно будет выполнен на том же потоке, что и код до await, это зависит от контекста синхронизации и наличия или отсутствия вызова ConfigureAwait. Из этого следует, что Monitor.Exit может выполниться на потоке отличном от Monitor.Enter, что приведет к выбросу SynchronizationLockException. Если не верите, то можете выполнить в консольном приложении следующий код: он сгенерирует SynchronizationLockException.

var syncObject = new Object();
Monitor.Enter(syncObject);
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);

await Task.Delay(1000);

Monitor.Exit(syncObject);
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);

Примечательно, что в WinForms или WPF приложении этот код отработает корректно, если его вызвать из главного потока Т.к. там будет контекст синхронизации, который реализует возврат в UI-Thread после выполнения await. В любом случае не стоит играться с критической секцией в контексте кода, содержащего оператор await. В этих случаях лучше использовать примитивы синхронизации, что будут рассмотрены позднее.

Рассказывая о работе критической секции в .NET, стоит упомянуть про еще одну особенность ее реализации. Критическая секция в .NET работает в двух режимах: режиме spin-wait и режиме ядра. Алгоритм spin-wait удобно представить в виде следующего псевдокода:

while(!TryEnter(syncObject))
    ;

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

SpinLock, SpinWait


Раз уж упомянул про алгоритм циклического ожидания (spin-wait), то стоит упомянуть и про структуры SpinLock и SpinWait из BCL. Их стоит использовать, если есть основания полагать, что всегда будет возможность очень быстро взять блокировку. С другой стороны вряд ли стоит про них вспоминать прежде чем результаты профилировки покажут, что именно использование других примитивов синхронизации является узким местом вашей программы.

Monitor.Wait, Monitor.Pulse[All]


Эту пару методов стоит рассматривать вместе. С их помощью, можно реализовывать различные Producer-Consumer сценарии.

Producer-Consumer — паттерн многопроцессного/многопоточного проектирования предполагающий наличие одного или нескольких потоков/процессов, производящих данные и один или несколько процессов/потоков эти данные обрабатывающие. Как правило использует общую коллекцию.

Оба эти метода могут быть вызваны лишь при наличии у вызывающего их потока в данный момент блокировки. Метод Wait отпустит блокировку и повиснет до тех пор, пока другой поток не вызовет Pulse.

Для демонстрации работы я написал небольшой пример:

object syncObject = new object();
Thread t1 = new Thread(T1);
t1.Start();

Thread.Sleep(100);
Thread t2 = new Thread(T2);
t2.Start();

(Использовал именно изображение, а не текст, чтобы наглядно показать порядок выполнения инструкций)

Разбор:Установил задержку в 100мс при старте второго потока, специально чтобы гарантировать, что его выполнение начнется позднее.
— T1:Line#2 поток стартует
— T1:Line#3 поток входит в критическую секцию
— T1:Line#6 поток засыпает
— T2:Line#3 поток стартует
— T2:Line#4 зависает в ожидании критической секции
— T1:Line#7 отпускает критическую секцию и зависает в ожидании выхода Pulse
— T2:Line#8 входит в критическую секцию
— T2:Line#11 оповещает T1 при помощи метода Pulse
— T2:Line#14 выходит из критической секции. До этих пор T1 не может продолжить выполнение.
— T1:Line#15 выходит из ожидания
— T1:Line#16 выходит из критической секции

В MSDN есть важная ремарка, касательно использования методов Pulse/Wait, а именно: Monitor не хранит информацию о состоянии, а значит, если вызов метода Pulse до вызова метода Wait может привести к дедлоку. Если такая ситуация возможна, то лучше использовать один из классов семейства ResetEvent.

Прошлый пример наглядно демонстрирует принцип работы методов Wait/Pulse класса Monitor, но все еще оставляет вопросы по поводу случаев, когда его стоит применять. Неплохим примером может быть такая реализация BlockingQueue<T>, с другой стороны реализация BlockingCollection<T> из System.Collections.Concurrent использует для синхронизации SemaphoreSlim.

ReaderWriterLockSlim


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

Идея: много потоков может читать, лишь один писать. Как только поток заявляет о желании писать, новые чтения не могут быть начаты, а будут ожидать завершения записи. Так же есть понятие upgradeable-read-lock, который можно использовать если в процессе чтения понимаете, о необходимости что-то записать, такой lock будет преобразован в write-lock за одну атомарную операцию.

В пространстве имен System.Threading так же есть класс ReadWriteLock, но он крайне не рекомендован для новой разработки. Slim версия позволит избежать ряда случаев, приводящих к дедлокам, к тому же позволяет быстро захватить блокировку, т.к. поддерживает синхронизацию в режиме spin-wait перед уходом в режим ядра.

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

Интерфейс класса ReaderWriterLockSlim прост и понятен, но его использование вряд ли можно назвать удобным:

var @lock = new ReaderWriterLockSlim();

@lock.EnterReadLock();
try
{
    // ...
}
finally
{
    @lock.ExitReadLock();
}

Мне нравится оборачивать его использование в класс, что позволяет использовать его куда удобнее.
Идея: сделать методы Read/WriteLock что возвращают объект с методом Dispose, тогда это позволит использовать их в using и по количеству строчек это вряд ли будет отличаться от обычного lock.

class RWLock : IDisposable
{
    public struct WriteLockToken : IDisposable
    {
        private readonly ReaderWriterLockSlim @lock;
        public WriteLockToken(ReaderWriterLockSlim @lock)
        {
            this.@lock = @lock;
            @lock.EnterWriteLock();
        }
        public void Dispose() => @lock.ExitWriteLock();
    }

    public struct ReadLockToken : IDisposable
    {
        private readonly ReaderWriterLockSlim @lock;
        public ReadLockToken(ReaderWriterLockSlim @lock)
        {
            this.@lock = @lock;
            @lock.EnterReadLock();
        }
        public void Dispose() => @lock.ExitReadLock();
    }

    private readonly ReaderWriterLockSlim @lock = new ReaderWriterLockSlim();
    
    public ReadLockToken ReadLock() => new ReadLockToken(@lock);
    public WriteLockToken WriteLock() => new WriteLockToken(@lock);

    public void Dispose() => @lock.Dispose();
}

Такой трюк позволит далее просто писать:

var rwLock = new RWLock();
// ...
using(rwLock.ReadLock())
{
    // ...
}


Семейство ResetEvent


К этому семейству я отношу классы ManualResetEvent, ManualResetEventSlim, AutoResetEvent.
Классы ManualResetEvent, его Slim версия и класс AutoResetEvent могут находится в двух состояниях:
— Взведенное (non-signaled), в этом состоянии все потоки, вызвавшие WaitOne, зависают, до перехода события в спущенное(signaled) состояние.
— Спущенное состояние(signaled), в этом состоянии отпускаются все потоки, зависшие на вызове WaitOne. Все новые вызовы WaitOne на событии в спущенном состоянии проходят условно-мгновенно.

Класс AutoResetEvent от класса ManualResetEvent отличает то, что он автоматически переходит во взведенное состояние, после того как отпустит ровно один поток. Если несколько потоков будут висеть в ожидании AutoResetEvent, то вызов Set отпустит лишь один произвольный, в отличии от ManualResetEvent. ManualResetEvent отпустит все потоки.

Рассмотрим пример работы AutoResetEvent:
AutoResetEvent evt = new AutoResetEvent(false);

Thread t1 = new Thread(T1);
t1.Start();
Thread.Sleep(100);

Thread t2 = new Thread(T2);
t2.Start();


В примере видно, что событие переходит во взведенное состояние(non-signaled) автоматически лишь отпустив поток, зависший на вызове WaitOne.

Класс ManualResetEvent, в отличии от ReaderWriterLock, не помечен как устаревший и не рекомендованный к использованию после появления своей Slim версии. Slim версию этого класса эффективно использовать для коротких ожиданий, т.к. оно происходит в режиме Spin-Wait, обычная версия подходит для долгих.

Кроме классов ManualResetEvent и AutoResetEvent также существует класс CountdownEvent. Этот класс удобен для реализации алгоритмов, где за частью, что удалось распараллелить, следует часть сведения результатов воедино. Такой подход известен как fork-join. Работе этого класса посвящена отличная статья, потому не буду здесь разбирать его подробно.

Выводы


  • При работе с потоками двумя проблемами, приводящими к неверным результатам или их отсутствию, являются race condition и deadlock
  • Проблемами, что заставляют программу тратить больше времени или ресурсов — thread starvation и busy wait
  • .NET богат на средства синхронизации потоков
  • Существует 2 режимы ожидания блокировки — Spin Wait, Core Wait. Часть примитивов синхронизации потоков .NET использует оба
  • Interlocked представляет собой набор атомарных операций, используется в lock-free алгоритмах, является самым быстрым примитивом синхронизации
  • Оператор lock и Monitor.Enter/Exit реализуют идею критической секции — фрагменте кода, что может быть выполнен лишь одним потоком в один момент времени
  • Методы Monitor.Pulse/Wait удобны для реализации Producer-Consumer сценариев
  • ReaderWriterLockSlim может оказаться эффективнее обычного lock в сценариях, где допустимо параллельное чтение
  • Семейство классов ResetEvent может пригодится для синхронизации потоков
Tags:
Hubs:
+10
Comments 6
Comments Comments 6

Articles