Давайте посмотрим как устроено конкурентное и параллельное программирование в .Net, на примере проблемы обедающих философов. План такой, от синхронизации потоков/процессов, до модели акторов (в следующих частях). Статья может быть полезна для первого знакомства или для того, чтобы освежить свои знания. Зачем вообще уметь это? Транзисторы достигают своего минимального размера, закон Мура упирается в ограничение скорости света и поэтому рост наблюдается в количестве, транзисторов можно делать больше. При этом количество данных растет, а пользователи ожидают немедленной реакции систем. В такой ситуации "обычное" программирование, когда у нас один выполняющий поток, уже не эффективно. Нужно как-то решать проблему одновременного или конкурентного выполнения. Причем, проблема эта существует на разных уровнях: на уровне потоков, на уровне процессов, на уровне машин в сети (распределенные системы). В .NET есть качественные, проверенные временем, технологии для быстрого и эффективного решения таких задач. Эдсгер Дейкстра задавал эту проблему своим ученикам еще в 1965. Устоявшаяся формулировка такая. Есть некоторое (обычно пять) количество философов и столько же вилок. Они сидят за круглым столом, вилки между ними. Философы могут есть из своих тарелок с бесконечной пищей, думать или ждать. Чтобы поесть философу, нужно взять две вилки (последний делит вилку с первым). Взять и положить вилку — два раздельных действия. Все философы безмолвные. Задача найти такой алгоритм, чтобы все они думали и были сыты спустя даже 54 года. Сначала попробуем решить эту задачу через использование разделяемого места. Вилки лежат на общем столе и философы просто их берут, когда они есть, и кладут обратно. Здесь появляются проблемы с синхронизацией, когда именно брать вилки? что делать если вилки нет? и др. Но сначала давайте запустим философов. Для запуска потоков используем пул потоков через Task.Run метод: Пул потоков создан для оптимизации создания и удаления потоков. У этого пула есть очередь с задачами и CLR создает или удаляет потоки в зависимости от количества этих задач. Один пул на все AppDomain'ы. Этот пул стоит использовать почти всегда, т.к. не нужно заморачиваться с созданием, удалением потоков, их очередями и пр. Можно и без пула, но тогда придется напрямую использовать Thread, это целесообразно для случаев, когда нужно поменять приоритет потоку, когда у нас долгая операция, для Foreground потока и др.  А System.Threading.Tasks.Task класс, как раз позволяет легко работать с этим пулом потоков (или вообще обходиться без него). Он представляет собой асинхронную операцию. Грубо говоря, это тот же Thread, но со всякими удобствами: возможность запускать таск после блока других тасков, возвращать их из функций, удобно их прерывать и мн. др. Они нужны для поддержки async/await конструкций (Task-based Asynchronous Pattern, синтаксический сахар для ожидания IO операции). Об этом еще поговорим. CancelationTokenSource здесь нужен, чтобы поток мог сам завершится по сигналу вызывающего потока. Хорошо, мы умеем создавать потоки, давайте попробуем пообедать: Здесь мы сначала пробуем взять левую, а потом правую вилки и если получилось, то едим и кладем их обратно. Взятие одной вилки атомарно, т.е. два потока не могут взять одну одновременно (неверно: первый читает, что вилка свободна, второй — тоже, первый берет, второй берет). Для этого Interlocked.CompareExchange, который должен быть реализован с помощью инструкции процессора (TSL, XCHG), которая блокирует участок памяти для атомарного последовательного чтения и записи. А SpinWait эквивалентно конструкции while(true) только с небольшой "магией" — поток занимает процессор (Thread.SpinWait), но иногда передает управление другому потоку (Thread.Yeild) или засыпает (Thread.Sleep). Но это решение не работает, т.к. потоки скоро (у меня в течении секунды) блокируются: все философы берут свою левую вилку, а правой нет. Массив forks тогда имеет значения: 1 2 3 4 5.   На рисунке, блокирование потоков (deadlock). Зеленым цветом — выполнение, красным — синхронизация, серым — поток спит. Ромбиками обозначено время запуска Task'ов. Хотя чтобы мыслить особенно много еды не нужно, но голод кого угодно заставить бросить философию. Попробуем смоделировать ситуацию голодания потоков в нашей задаче. Голодание — это когда поток работает, но без существенной работы, другими словами это тот же дедлок, только теперь поток не спит, а активно ищет как бы поесть, но еды нет. Для того, чтобы избежать частую блокировку будем класть вилку назад, если не смогли взять другую. В этом коде важно то, что два из четырех философа забывают положить свою левую вилку. И получается, что они едят больше еды, а другие начинают голодать, хотя у потоков одинаковый приоритет. Здесь они не совсем голодают, т.к. плохие философы кладут свои вилки иногда назад. У меня получается, что хорошие едят где-то в 5 раз меньше, чем плохие. Так небольшая ошибка в коде приводит к тому, что падает производительность. Здесь еще стоит заметить, что возможна редкая ситуация, когда все философы берут левую вилку, правой нет, они кладут левую, ждут, опять берут левую и т.д. Эта ситуация тоже голодание, больше похожая на взаимную блокировку. Повторить ее у меня не получилось. Ниже картинка для ситуации, когда два плохих философа забрали обе вилки, а два хороших голодают.  Здесь видно, что потоки просыпаются иногда и пробуют получить ресурс. Два ядра из четырех ничего не делают (зеленый график вверху).  Ну и еще одна проблема, которая может прервать славный обед философов — это если один из них внезапно умрёт с вилками в руках (и его так и похоронят). Тогда соседи останутся без обеда. Пример кода для этого случая вы можете придумать и сами, например выбрасывается NullReferenceException после того, как философ берет вилки. И, между прочим, исключение будет не обработанным и вызывающий код его просто так не поймает (для этого AppDomain.CurrentDomain.UnhandledException и др.). Поэтому обработчики ошибок необходимы в самих потоках и с корректным завершением. Хорошо, как нам решить эту проблему с взаимными блокировками, голоданием и смертями? Будем допускать только одного философа до вилок, добавим взаимное исключение (mutual exclusion) потоков для этого места. Как это сделать? Предположим, что рядом с философами стоит официант, который дает разрешение какому-нибудь одному философу взять вилки. Как нам сделать этого официанта и как философы будут просить его, вопросы интересные.  Простейший способ — это когда философы будут просто постоянно просить официанта дать доступ к вилкам. Т.е. теперь философы будут не ждать вилку рядом, а ждать или просить официанта. Сначала используем для этого только User Space, в нем мы не используем прерывания для вызова каких-нибудь процедур из ядра (о них ниже). Здесь будем делать тоже, что раньше делали с одной вилкой и двумя философами, будем крутиться в цикле и ждать. Но теперь это будут все философы и как бы только одна вилка, т.е. можно сказать будет есть только тот философ, который взял эту "золотую вилку" у официанта. Для этого используем SpinLock. SpinLock это блокировщик, с, грубо говоря, тем же while(true) { if (!lock) break; }, но с еще большей "магией", чем в SpinWait (который там используется). Теперь он умеет считать ожидающих, немного усыплять их и мн. др. В общем, делает всё возможное для оптимизации. Но надо помнить, что это всё тот же активный цикл, который ест ресурсы процессора и держит поток, который может привести к голоданию, если один из философов становится приоритетнее других, но не имеет золотой вилки (Priority Inversion problem). Поэтому используем его только для очень очень коротких изменений в общей памяти, без всяких сторонних вызовов, вложенных блокировок и пр. сюрпризов.  Рисунок для SpinLock. Потоки постоянно "воюют" за золотую вилку. Случаются провалы — на рисунке выделенная область. Ядра используются не полностью: только около 2/3 этими четырьмя потоками. Другое решение здесь было бы использовать только Interlocked.CompareExchange с тем же активным ожиданием, как показано в коде выше (в голодающих философах), но это, как было уже сказано, теоретически может привести к блокировке.  Про Interlocked стоит сказать, что там не только CompareExchange, но и другие методы для атомарного чтения И записи. А через повтор изменения в случае, если другой поток успевает внести свои изменения (чтение 1, чтение 2, запись 2, запись 1 плохая), он может использоваться для сложных изменений одного значения (Interlocked Anything паттерн). Чтобы избежать потери ресурсов в цикле, посмотрим как можно блокировать поток. Другими словами, продолжая наш пример, посмотрим, как официант усыпит философа и разбудит его только тогда, когда надо. Сначала рассмотрим, как это сделать через режим ядра операционной системы. Все структуры там часто оказываются медленнее, чем те, что в пространстве пользователя. Медленнее в несколько раз, например AutoResetEvent может быть в 53 раза медленнее SpinLock [Рихтер]. Но с их помощью можно синхронизировать процессы по всей системе, управляемые или нет. Основная конструкция здесь это семафор, предложенный Дейкстрой более полувека назад. Семафор это, упрощенно говоря, положительное целое число, управляемое системой, и две операции на нем, — увеличить и уменьшить. Если уменьшить не получается, ноль, то вызывающий поток блокируется. Когда число увеличивается каким-нибудь другим активным потоком/процессом, тогда потоки пропускаются, а семафор опять уменьшается на число прошедших. Можно представить поезда в узком месте с семафором. .NET предлагает несколько конструкций с подобными функциями: AutoResetEvent, ManualResetEvent, Mutex и сам Semaphore. Мы будем использовать AutoResetEvent, это самая простая из этих конструкций: только два значения 0 и 1 (false, true). Ее метод WaitOne() блокирует вызывающий поток, если значение было 0, а если 1, то понижает до 0 и пропускает его. А метод Set() повышает до 1 и пропускает одного ожидающего, который опять понижает до 0. Действует, как турникет в метро. Усложним решение и будем использовать блокировку для каждого философа, а не для всех сразу. Т.е. теперь есть могут сразу несколько философов, а не один. Но мы опять блокируем доступ к столу, для того чтобы корректно, избегая гонок (race conditions), взять вилки.  Чтобы понять, что тут происходит, рассмотрим случай, когда философу не удалось взять вилки, тогда его действия будут такими. Он ждет доступа к столу. Получив его он пробует взять вилки. Не получилось. Он отдает доступ к столу (взаимное исключение). И проходит свой "турникет" (AutoResetEvent) (вначале они открыты). Попадает опять в цикл, т.к. у него нет вилок. Пробует взять их и останавливается у своего "турникета". Какой-нибудь более удачливый сосед справа или слева, закончив есть, разблокирует нашего философа, "открывая его турникет". Наш философ проходит его (и он закрывается за ним) во второй раз. Пробует в третий раз взять вилки. Удачно. И проходит свой турникет, чтобы отобедать. Когда в таком коде будут случайные ошибки (они всегда есть), например будет неверно указан сосед или создан один и тот же объект AutoResetEvent для всех (Enumerable.Repeat), тогда философы будут ждать уже разработчиков, т.к. поиск ошибок в таком коде довольно сложное занятие. Ещё одна проблема с этим решением в том, что оно не гарантирует, что какой-нибудь философ не начнет голодать. Мы рассмотрели два подхода к синхронизации, когда мы остаемся в режиме пользователя и крутимся в цикле и, когда мы блокируем поток через ядро. Первый метод хорош для кратких блокировок, второй для длительных. Часто требуется сначала кратко ожидать изменения переменной в цикле, а потом заблокировать поток, когда ожидание долгое. Этот подход реализован в т.н. гибридных конструкциях. Здесь есть те же конструкции, что были для режима ядра, но теперь с циклом в режиме пользователя: SemaphorSlim, ManualResetEventSlim и др. Самая популярная конструкция здесь это Monitor, т.к. в C# есть известный всем lock синтаксис. Monitor это тот же семафор с максимальным значением 1 (мютекс), но с поддержкой ожидания в цикле, рекурсии, Condition Variable паттерна (о нем ниже) и др. Посмотрим на решение с ним. Здесь мы опять блокируем весь стол для доступа к вилкам, но теперь мы разблокируем все сразу потоки, а не соседей, когда кто-нибудь заканчивает есть. Т.е. сначала, кто-нибудь ест и блокирует соседей, а когда этот кто-нибудь заканчивает, но хочет сразу опять есть, он уходит в блокировку и будит своих соседей, т.к. его время ожидания меньше.  Так мы избегаем дедлоков и голодания какого-нибудь философа. Используем цикл для краткого ожидания и блокируем поток для долгого. Разблокировка сразу всех работает медленнее, чем если бы была разблокировка только соседа, как в решении с AutoResetEvent, но разница не должна быть большой, т.к. потоки должны остаться в режиме пользователя сначала.  У lock синтаксиса есть неприятные сюрпризы. Рекомендуют использовать Monitor напрямую [Рихтер] [Эрик Липперт]. Один из них в том, что lock всегда выходит из Monitor, даже если было исключение, и тогда другой поток может изменить состояние общей памяти. В таких случаях чаще лучше уходить в дедлок или как-то безопасно завершать программу. Другой сюрприз в том, что Monitor использует блоки синхронизации (SyncBlock), которые есть во всех объектах. Поэтому, если выбран неподходящий объект, можно легко получить дедлок (например, если сделать лок на интернированную строку). Используем всегда скрытый объект для этого. Condition Variable паттерн позволяет более кратко реализовать ожидание какого-нибудь сложного условия. В .NET он неполный, на мой взгляд, т.к. по идее там должны быть несколько очередей на нескольких переменных (как в Posix Threads), а не на одном локе. Тогда можно было бы сделать их для всех философов. Но и в таком виде он позволяет сократить код. Хорошо, теперь мы умеем эффективно блокировать потоки. Но, а если у нас становится много философов? 100? 10000? Например, нам пришло 100000 запросов на веб-сервер. Создавать для каждого запроса поток будет накладным, т.к. столько потоков параллельно не будут выполняться. Будут выполняться только столько, сколько логических ядер (у меня 4). А все остальные будут просто отнимать ресурсы. Одно из решений этой проблемы async / await паттерн. Идея его в том, что функция не держит поток, если для её продолжения нужно что-то подождать. А когда, она это что-то происходит, она возобновляет свое выполнение (но не обязательно в том же потоке!). В нашем случае, мы будем ждать вилки.  SemaphoreSlim имеет для этого WaitAsync() метод. Вот реализация с использованием этого паттерна.  Метод с async / await транслируется в хитрый конечный автомат, который сразу возвращает свой внутренний Task. Через него можно ждать завершения метода, отменять его и всё прочее, что можно делать с Task. Внутри метода, конечный автомат, контролирует выполнение. Суть в том, что если нет задержки, то выполнение синхронное, а если есть, то поток освобождается. Для лучшего понимания этого лучше посмотреть этот конечный автомат. Можно создавать цепочки из этих async / await методов.  Потестируем. Работа 100 философов на машине с 4 логическими ядрами, 8 секунд. Предыдущее решение с Monitor выполняло только 4 первых потока, а остальные вообще не выполнялись. Каждый из этих 4 потоков простаивал около 2мс. А решение с async / await выполняло все 100, при этом в среднем каждый ждал 6.8 секунд. Конечно, в реальных системах простаивание по 6 секунд неприемлемо и лучше не обрабатывать столько запросов так. Решение же с Monitor оказалось не масштабируемым вообще. Как видно из этих небольших примеров, .NET поддерживает много конструкций синхронизации. Не всегда, впрочем, очевидно, как их использовать. Надеюсь эта статья оказалась полезной. Пока на этом завершаем, но осталось еще много интересного, например потокобезопасные коллекции, TPL Dataflow, Reactive программирование, Software Transaction модель и др.  Разработчик