Сытые философы или конкурентное программирование на .NET

Давайте посмотрим как устроено конкурентное и параллельное программирование в .Net, на примере проблемы обедающих философов. План такой, от синхронизации потоков/процессов, до модели акторов (в следующих частях). Статья может быть полезна для первого знакомства или для того, чтобы освежить свои знания.

Зачем вообще уметь это? Транзисторы достигают своего минимального размера, закон Мура упирается в ограничение скорости света и поэтому рост наблюдается в количестве, транзисторов можно делать больше. При этом количество данных растет, а пользователи ожидают немедленной реакции систем. В такой ситуации “обычное” программирование, когда у нас один выполняющий поток, уже не эффективно. Нужно как-то решать проблему одновременного или конкурентного выполнения. Причем, проблема эта существует на разных уровнях: на уровне потоков, на уровне процессов, на уровне машин в сети (распределенные системы). В .NET есть качественные, проверенные временем, технологии для быстрого и эффективного решения таких задач.

Задача

Эдсгер Дейкстра задавал эту проблему своим ученикам еще в 1965. Устоявшаяся формулировка такая. Есть некоторое (обычно пять) количество философов и столько же вилок. Они сидят за круглым столом, вилки между ними. Философы могут есть из своих тарелок с бесконечной пищей, думать или ждать. Чтобы поесть философу, нужно взять две вилки (последний делит вилку с первым). Взять и положить вилку - два раздельных действия. Все философы безмолвные. Задача найти такой алгоритм, чтобы все они думали и были сыты спустя даже 54 года.

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

Для запуска потоков используем пул потоков через Task.Run метод:

1
2
3
4
5
6
7
8
9
10

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++)
{
int icopy = i;
// Поместить задачу в очередь пула потоков. Метод RunDeadlock не запускаеться
// сразу, а ждет своего потока. Асинхронный запуск.
philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Пул потоков создан для оптимизации создания и удаления потоков. У этого пула есть очередь с задачами и CLR создает или удаляет потоки в зависимости от количества этих задач. Один пул на все AppDomain’ы. Этот пул стоит использовать почти всегда, т.к. не нужно заморачиваться с созданием, удалением потоков, их очередями и пр. Можно и без пула, но тогда придется напрямую использовать Thread, это целесообразно для случаев, когда нужно поменять приоритет потоку, когда у нас долгая операция, для Foreground потока и др.

А System.Threading.Tasks.Task класс, как раз позволяет легко работать с этим пулом потоков (или вообще обходиться без него). Он представляет собой асинхронную операцию. Грубо говоря, это тот же Thread, но со всякими удобствами: возможность запускать таск после блока других тасков, возвращать их из функций, удобно их прерывать и мн. др. Они нужны для поддержки async/await конструкций (Task-based Asynchronous Pattern, синтаксический сахар для ожидания IO операции). Об этом еще поговорим.

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

Проблемы с синхронизацией

Блокированные философы

Хорошо, мы умеем создавать потоки, давайте попробуем пообедать:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

// Кто какие вилки взял. К примеру: 1 1 3 3 - 1й и 3й взяли первые две пары.
private int[] forks = Enumerable.Repeat(0, philosophersAmount).ToArray();

// То же, что RunPhilosopher()
private void RunDeadlock(int i, CancellationToken token)
{
// Ждать вилку, взять её. Эквивалентно:
// while(true)
// if forks[fork] == 0
// forks[fork] = i+1
// break
// Thread.Sleep() или Yield() или SpinWait()
void TakeFork(int fork) =>
SpinWait.SpinUntil(() =>
Interlocked.CompareExchange(ref forks[fork], i+1, 0) == 0);

// Для простоты, но можно с Interlocked.Exchange:
void PutFork(int fork) => forks[fork] = 0;

while (true)
{
TakeFork(Left(i));
TakeFork(Right(i));
eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
PutFork(Left(i));
PutFork(Right(i));
Think(i);

// Завершить работу по-хорошему.
token.ThrowIfCancellationRequested();
}
}

Здесь мы сначала пробуем взять левую, а потом правую вилки и если получилось, то едим и кладем их обратно. Взятие одной вилки атомарно, т.е. два потока не могут взять одну одновременно (неверно: первый читает, что вилка свободна, второй – тоже, первый берет, второй берет). Для этого Interlocked.CompareExchange, который должен быть реализован с помощью инструкции процессора (TSL, XCHG), которая блокирует участок памяти для атомарного последовательного чтения и записи. А SpinWait эквивалентно конструкции while(true) только с небольшой “магией” – поток занимает процессор (Thread.SpinWait), но иногда передает управление другому потоку (Thread.Yeild) или засыпает (Thread.Sleep).

Но это решение не работает, т.к. потоки скоро (у меня в течении секунды) блокируются: все философы берут свою левую вилку, а правой нет. Массив forks тогда имеет значения: 1 2 3 4 5.

На рисунке, блокирование потоков (deadlock). Зеленым цветом – выполнение, красным – синхронизация, серым – поток спит. Ромбиками обозначено время запуска Task’ов.

Голод философов

Хотя чтобы мыслить особенно много еды не нужно, но голод кого угодно заставить бросить философию. Попробуем смоделировать ситуацию голодания потоков в нашей задаче. Голодание – это когда поток работает, но без существенной работы, другими словами это тот же дедлок, только теперь поток не спит, а активно ищет как бы поесть, но еды нет. Для того, чтобы избежать частую блокировку будем класть вилку назад, если не смогли взять другую.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

// То же что и в RunDeadlock, но теперь кладем вилку назад и добавляем плохих философов.
private void RunStarvation(int i, CancellationToken token)
{
while (true)
{
bool hasTwoForks = false;
var waitTime = TimeSpan.FromMilliseconds(50);
// Плохой философов может уже иметь вилку:
bool hasLeft = forks[Left(i)] == i + 1;
if (hasLeft || TakeFork(Left(i), i + 1, waitTime))
{
if (TakeFork(Right(i), i + 1, TimeSpan.Zero))
hasTwoForks = true;
else
PutFork(Left(i)); // Иногда плохой философ отдает вилку назад.
}
if (!hasTwoForks)
{
if (token.IsCancellationRequested) break;
continue;
}
eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
bool goodPhilosopher = i % 2 == 0;
// А плохой философ забывает положить свою вилку обратно:
if (goodPhilosopher)
PutFork(Left(i));
// А если и правую не положит, то хорошие будут вообще без еды.
PutFork(Right(i));

Think(i);

if (token.IsCancellationRequested)
break;
}
}

// Теперь можно ждать определенное время.
bool TakeFork(int fork, int philosopher, TimeSpan? waitTime = null)
{
return SpinWait.SpinUntil(
() => Interlocked.CompareExchange(ref forks[fork], philosopher, 0) == 0,
waitTime ?? TimeSpan.FromMilliseconds(-1)
);
}

В этом коде важно то, что два из четырех философа забывают положить свою левую вилку. И получается, что они едят больше еды, а другие начинают голодать, хотя у потоков одинаковый приоритет. Здесь они не совсем голодают, т.к. плохие философы кладут свои вилки иногда назад. У меня получается, что хорошие едят где-то в 5 раз меньше, чем плохие. Так небольшая ошибка в коде приводит к тому, что падает производительность. Здесь еще стоит заметить, что возможна редкая ситуация, когда все философы берут левую вилку, правой нет, они кладут левую, ждут, опять берут левую и т.д. Эта ситуация тоже голодание, больше похожая на взаимную блокировку. Повторить ее у меня не получилось. Ниже картинка для ситуации, когда два плохих философа забрали обе вилки, а два хороших голодают.

Здесь видно, что потоки просыпаются иногда и пробуют получить ресурс. Два ядра из четырех ничего не делают (зеленый график вверху).

Смерть философа

Ну и еще одна проблема, которая может прервать славный обед философов – это если один из них внезапно умрёт с вилками в руках (и его так и похоронят). Тогда соседи останутся без обеда. Пример кода для этого случая вы можете придумать и сами, например выбрасывается NullReferenceException после того, как философ берет вилки. И, между прочим, исключение будет не обработанным и вызывающий код его просто так не поймает (для этого AppDomain.CurrentDomain.UnhandledException и др.). Поэтому обработчики ошибок необходимы в самих потоках и с корректным завершением.

Официант

Хорошо, как нам решить эту проблему с взаимными блокировками, голоданием и смертями? Будем допускать только одного философа до вилок, добавим взаимное исключение (mutual exclusion) потоков для этого места. Как это сделать? Предположим, что рядом с философами стоит официант, который дает разрешение какому-нибудь одному философу взять вилки. Как нам сделать этого официанта и как философы будут просить его, вопросы интересные.

Простейший способ – это когда философы будут просто постоянно просить официанта дать доступ к вилкам. Т.е. теперь философы будут не ждать вилку рядом, а ждать или просить официанта. Сначала используем для этого только User Space, в нем мы не используем прерывания для вызова каких-нибудь процедур из ядра (о них ниже).

Решения в пространстве пользователя

Здесь будем делать тоже, что раньше делали с одной вилкой и двумя философами, будем крутиться в цикле и ждать. Но теперь это будут все философы и как бы только одна вилка, т.е. можно сказать будет есть только тот философ, который взял эту “золотую вилку” у официанта. Для этого используем SpinLock.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

private static SpinLock spinLock = new SpinLock(); // Наш "официант"
private void RunSpinLock(int i, CancellationToken token)
{
while (true)
{
// Взаимная блокировка через busy waiting. Вызываем до try, чтобы
// выбрасить исключение в случае ошибки в самом SpinLock.
bool hasLock = false;
spinLock.Enter(ref hasLock);
try
{
// Здесь может быть только один поток (mutual exclusion).
forks[Left(i)] = i + 1; // Берем вилку сразу, без ожидания.
forks[Right(i)] = i + 1;
eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
forks[Left(i)] = 0;
forks[Right(i)] = 0;
}
finally
{
if(hasLock) spinLock.Exit(); // Избегаем проблемы со смертью философа.
}

Think(i);

if (token.IsCancellationRequested)
break;
}
}

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), взять вилки.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

// Для блокирования отдельного философа.
// Инициализируется: new AutoResetEvent(true) для каждого.
private AutoResetEvent[] philosopherEvents;

// Для доступа к вилкам / доступ к столу.
private AutoResetEvent tableEvent = new AutoResetEvent(true);

// Рождение философа.
public void Run(int i, CancellationToken token)
{
while (true)
{
TakeForks(i); // Ждет вилки.
// Обед. Может быть и дольше.
eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
PutForks(i); // Отдать вилки и разблокировать соседей.
Think(i);
if (token.IsCancellationRequested) break;
}
}

// Ожидать вилки в блокировке.
void TakeForks(int i)
{
bool hasForks = false;
while (!hasForks) // Попробовать еще раз (блокировка не здесь).
{
// Исключающий доступ к столу, без гонок за вилками.
tableEvent.WaitOne();
if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
forks[Left(i)] = forks[Right(i)] = i + 1;
hasForks = forks[Left(i)] == i + 1 && forks[Right(i)] == i + 1;
if (hasForks)
// Теперь философ поест, выйдет из цикла. Если Set
// вызван дважды, то значение true.
philosopherEvents[i].Set();
// Разблокировать одного ожидающего. После него значение tableEvent в false.
tableEvent.Set();
// Если имеет true, не блокируется, а если false, то будет ждать Set от соседа.
philosopherEvents[i].WaitOne();
}
}

// Отдать вилки и разблокировать соседей.
void PutForks(int i)
{
tableEvent.WaitOne(); // Без гонок за вилками.
forks[Left(i)] = 0;
// Пробудить левого, а потом и правого соседа, либо AutoResetEvent в true.
philosopherEvents[LeftPhilosopher(i)].Set();
forks[Right(i)] = 0;
philosopherEvents[RightPhilosopher(i)].Set();
tableEvent.Set();
}

Чтобы понять, что тут происходит, рассмотрим случай, когда философу не удалось взять вилки, тогда его действия будут такими. Он ждет доступа к столу. Получив его он пробует взять вилки. Не получилось. Он отдает доступ к столу (взаимное исключение). И проходит свой “турникет” (AutoResetEvent) (вначале они открыты). Попадает опять в цикл, т.к. у него нет вилок. Пробует взять их и останавливается у своего “турникета”. Какой-нибудь более удачливый сосед справа или слева, закончив есть, разблокирует нашего философа, “открывая его турникет”. Наш философ проходит его (и он закрывается за ним) во второй раз. Пробует в третий раз взять вилки. Удачно. И проходит свой турникет, чтобы отобедать.

Когда в таком коде будут случайные ошибки (они всегда есть), например будет неверно указан сосед или создан один и тот же объект AutoResetEvent для всех (Enumerable.Repeat), тогда философы будут ждать уже разработчиков, т.к. поиск ошибок в таком коде довольно сложное занятие. Ещё одна проблема с этим решением в том, что оно не гарантирует, что какой-нибудь философ не начнет голодать.

Гибридные решения

Мы рассмотрели два подхода к синхронизации, когда мы остаемся в режиме пользователя и крутимся в цикле и, когда мы блокируем поток через ядро. Первый метод хорош для кратких блокировок, второй для длительных. Часто требуется сначала кратко ожидать изменения переменной в цикле, а потом заблокировать поток, когда ожидание долгое. Этот подход реализован в т.н. гибридных конструкциях. Здесь есть те же конструкции, что были для режима ядра, но теперь с циклом в режиме пользователя: SemaphorSlim, ManualResetEventSlim и др. Самая популярная конструкция здесь это Monitor, т.к. в C# есть известный всем lock синтаксис. Monitor это тот же семафор с максимальным значением 1 (мютекс), но с поддержкой ожидания в цикле, рекурсии, Condition Variable паттерна (о нем ниже) и др. Посмотрим на решение с ним.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
private readonly object _lock = new object();
// Время ожидания потока.
private DateTime?[] _waitTimes = new DateTime?[philosophersAmount];

public void Run(int i, CancellationToken token)
{
while (true)
{
TakeForks(i);
eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
PutForks(i);
Think(i);
if (token.IsCancellationRequested) break;
}
}

// Наше сложное условие для Condition Variable паттерна.
bool CanIEat(int i)
{
// Если есть вилки:
if (forks[Left(i)] != 0 && forks[Right(i)] != 0)
return false;
var now = DateTime.Now;
// Может, если соседи не более голодные, чем текущий.
foreach(var p in new int[] {LeftPhilosopher(i), RightPhilosopher(i)})
if (_waitTimes[p] != null && now - _waitTimes[p] > now - _waitTimes[i])
return false;
return true;
}

void TakeForks(int i)
{
// Зайти в Монитор. То же самое: lock(_lock) {..}.
// Вызываем вне try, чтобы возможное исключение выбрасывалось выше.
bool lockTaken = false;
Monitor.Enter(_lock, ref lockTaken);
try
{
_waitTimes[i] = DateTime.Now;
// Condition Variable паттерн. Освобождаем лок, если не выполненно
// сложное условие. И ждем пока кто-нибудь сделает Pulse / PulseAll.
while (!CanIEat(i))
Monitor.Wait(_lock);
forks[Left(i)] = i + 1;
forks[Right(i)] = i + 1;
_waitTimes[i] = null;
}
finally
{
if (lockTaken) Monitor.Exit(_lock);
}
}

void PutForks(int i)
{
// То же самое: lock (_lock) {..}.
bool lockTaken = false;
Monitor.Enter(_lock, ref lockTaken);
try
{
forks[Left(i)] = 0;
forks[Right(i)] = 0;
// Освободить все потоки в очереди ПОСЛЕ вызова Monitor.Exit.
Monitor.PulseAll(_lock);
}
finally
{
if (lockTaken) Monitor.Exit(_lock);
}
}

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

Так мы избегаем дедлоков и голодания какого-нибудь философа. Используем цикл для краткого ожидания и блокируем поток для долгого. Разблокировка сразу всех работает медленнее, чем если бы была разблокировка только соседа, как в решении с AutoResetEvent, но разница не должна быть большой, т.к. потоки должны остаться в режиме пользователя сначала.

У lock синтаксиса есть неприятные сюрпризы. Рекомендуют использовать Monitor напрямую [Рихтер] [Эрик Липперт]. Один из них в том, что lock всегда выходит из Monitor, даже если было исключение, и тогда другой поток может изменить состояние общей памяти. В таких случаях чаще лучше уходить в дедлок или как-то безопасно завершать программу. Другой сюрприз в том, что Monitor использует блоки синхронизации (SyncBlock), которые есть во всех объектах. Поэтому, если выбран неподходящий объект, можно легко получить дедлок (например, если сделать лок на интернированную строку). Используем всегда скрытый объект для этого.

Condition Variable паттерн позволяет более кратко реализовать ожидание какого-нибудь сложного условия. В .NET он неполный, на мой взгляд, т.к. по идее там должны быть несколько очередей на нескольких переменных (как в Posix Threads), а не на одном локе. Тогда можно было бы сделать их для всех философов. Но и в таком виде он позволяет сократить код.

Много философов или async / await

Хорошо, теперь мы умеем эффективно блокировать потоки. Но, а если у нас становится много философов? 100? 10000? Например, нам пришло 100000 запросов на веб-сервер. Создавать для каждого запроса поток будет накладным, т.к. столько потоков параллельно не будут выполняться. Будут выполняться только столько, сколько логических ядер (у меня 4). А все остальные будут просто отнимать ресурсы. Одно из решений этой проблемы async / await паттерн. Идея его в том, что функция не держит поток, если для её продолжения нужно что-то подождать. А когда, она это что-то происходит, она возобновляет свое выполнение (но не обязательно в том же потоке!). В нашем случае, мы будем ждать вилки.

SemaphoreSlim имеет для этого WaitAsync() метод. Вот реализация с использованием этого паттерна.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

// Запуск такой же, как раньше. Где-нибудь в программе:
Task.Run(() => Run(i, cancelTokenSource.Token));

// Запуск философа.
// Ключевое слово async -- компилятор транслирует этот метот в асинхронный.
public async Task Run(int i, CancellationToken token)
{
while (true)
{
// await -- будем ожидать какого-то события.
await TakeForks(i);
// После await, продолжение возможно в другом потоке.
eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
// Может быть несколько событий для ожидания.
await PutForks(i);

Think(i);

if (token.IsCancellationRequested) break;
}
}

async Task TakeForks(int i)
{
bool hasForks = false;
while (!hasForks)
{
// Взаимоисключающий доступ к столу:
await _tableSemaphore.WaitAsync();
if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
{
forks[Left(i)] = i+1;
forks[Right(i)] = i+1;
hasForks = true;
}
_tableSemaphore.Release();
// Будем ожидать, чтобы сосед положил вилки:
if (!hasForks)
await _philosopherSemaphores[i].WaitAsync();
}
}

// Ждем доступа к столу и кладем вилки.
async Task PutForks(int i)
{
await _tableSemaphore.WaitAsync();
forks[Left(i)] = 0;
// "Пробудить" соседей, если они "спали".
_philosopherSemaphores[LeftPhilosopher(i)].Release();
forks[Right(i)] = 0;
_philosopherSemaphores[RightPhilosopher(i)].Release();
_tableSemaphore.Release();
}

Метод с 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 модель и др.

Источники

Публикация на Хабре: https://habr.com/ru/post/447898/