栄養豊富な哲学者または競争力のある .NET プログラミング

栄養豊富な哲学者または競争力のある .NET プログラミング

哲学者の食事問題を例として、.Net で同時プログラミングと並列プログラミングがどのように機能するかを見てみましょう。 スレッド/プロセスの同期からアクター モデルまでの計画は次のとおりです (次の部分)。 この記事は、初めて知り合う人や、知識を新たにするために役立つかもしれません。

そもそもなぜそれをするのでしょうか? トランジスタは最小サイズに達し、ムーアの法則は光速度の制限に基づいているため、数の増加が観察され、より多くのトランジスタを作成できます。 同時に、データ量は増加しており、ユーザーはシステムからの即時応答を期待しています。 このような状況では、実行スレッドが XNUMX つあるときの「通常の」プログラミングはもはや効果的ではありません。 同時実行の問題を何らかの方法で解決する必要があります。 さらに、この問題は、スレッド レベル、プロセス レベル、ネットワーク (分散システム) 内のマシン レベルなど、さまざまなレベルで存在します。 .NET には、このような問題を迅速かつ効率的に解決するための、実績のある高品質のテクノロジが備わっています。

タスク

エドガー・ダイクストラは、1965 年にはすでにこの問題を学生たちに提起しました。確立された定式化は次のとおりです。 特定の数 (通常は 54 人) の哲学者と同じ数のフォークが存在します。 彼らは円卓に座り、フォークを挟みます。 哲学者は、無限に盛られた食べ物を皿から食べたり、考えたり、待ったりすることができます。 哲学者を食べるには、XNUMX つのフォークを取る必要があります (最後のフォークは最初のフォークと共有します)。 フォークを持ち上げるのと置くのは XNUMX つの別々の動作です。 哲学者は皆沈黙している。 課題は、XNUMX 年後でも全員が考えて満足できるようなアルゴリズムを見つけることです。

まずは共有スペースを活用してこの問題を解決してみましょう。 フォークは共通のテーブルの上に置かれており、哲学者たちはフォークが置かれているときにそれを取り出して元に戻すだけです。 ここでは同期に問題があります。正確にいつ確実に賭けるべきでしょうか? フォークがない場合はどうなりますか? などですが、まずは哲学者から始めましょう。

スレッドを開始するには、スレッド プールを使用します。 Task.Run 方法:

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 に対して XNUMX つのプール。 このプールはほぼ常に使用する必要があるためです。 スレッドやそのキューなどの作成、削除をわざわざ行う必要はありません。プールがなくても可能ですが、プールを直接使用する必要があります。 Thread、これは、スレッドの優先順位を変更する必要がある場合、長い操作がある場合、フォアグラウンド スレッドなどの場合に便利です。

言い換えれば、 System.Threading.Tasks.Task クラスは同じです Threadただし、他のタスクのブロックの後にタスクを実行したり、関数からタスクを返したり、それらを簡単に中断したりする機能など、あらゆる種類の便利さが備わっています。 これらは、async / await 構造 (タスクベースの非同期パターン、IO 操作を待機するための糖衣構文) をサポートするために必要です。 これについては後で説明します。

CancelationTokenSource ここでは、呼び出しスレッドのシグナルでスレッドが自身を終了できるようにするために必要です。

同期の問題

ブロックされた哲学者

さて、スレッドの作成方法はわかったので、昼食をとりましょう。

// Кто какие вилки взял. К примеру: 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();
    }
}

ここでは、まず左のフォークを取り、次に右のフォークを取り、うまくいったら食べて元に戻します。 XNUMX つのフォークの取得はアトミックです。 XNUMX つのスレッドが同時に XNUMX つを取得することはできません (誤: 最初のスレッドはフォークが空いていることを読み取り、XNUMX 番目のスレッドも同様に、最初のスレッドが取得し、XNUMX 番目のスレッドが取得します)。 このため Interlocked.CompareExchange、プロセッサ命令 (TSL, XCHG)、アトミックなシーケンシャル読み取りおよび書き込みのためにメモリの一部をロックします。 そして、SpinWait はコンストラクトと同等です while(true) ちょっとした「魔法」を使うだけで、スレッドがプロセッサを占有します(Thread.SpinWait) ですが、場合によっては制御を別のスレッドに転送します (Thread.Yeild) または眠ってしまう (Thread.Sleep).

しかし、この解決策は機能しません。 流れはすぐに(私にとっては 1 秒以内に)ブロックされます。すべての哲学者は左のフォークを選択しますが、右のフォークは選択しません。 フォーク配列の値は 2 3 4 5 XNUMX になります。

栄養豊富な哲学者または競争力のある .NET プログラミング

図では、スレッドをブロックしています (デッドロック)。 緑 - 実行、赤 - 同期、灰色 - スレッドはスリープ中です。 ひし形はタスクの開始時刻を示します。

哲学者の飢え

食べ物について特別に考える必要はありませんが、空腹は誰でも哲学を諦めさせます。 この問題でスレッドが枯渇する状況をシミュレートしてみましょう。 飢餓状態とは、スレッドが実行中であるが、重要な作業が行われていない状態です。つまり、これは同じデッドロックであり、スレッドはスリープ状態ではなく、アクティブに食べるものを探していますが、食べ物がありません。 頻繁なブロックを避けるために、次のフォークを取れなかった場合はフォークを戻します。

// То же что и в 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 人中 XNUMX 人が左フォークを置くのを忘れているということです。 そして、スレッドの優先順位は同じであるにもかかわらず、彼らはより多くの食べ物を食べる一方、他の人たちは飢え始めていることが判明しました。 ここでは彼らは完全に飢えているわけではないからです。 悪い哲学者は時々フォークを元に戻します。 良い人は悪い人に比べて食事の量が約XNUMX倍少ないことがわかりました。 そのため、コード内の小さなエラーがパフォーマンスの低下につながります。 ここで、すべての哲学者が左のフォークを選択し、右のフォークが存在せず、左に置き、待って、再び左に進むなど、まれな状況が発生する可能性があることにも注目してください。 この状況も飢餓、というより行き詰まりに近い。 それを繰り返すのに失敗しました。 以下は、XNUMX 人の悪い哲学者が両方のフォークを取得し、XNUMX 人の良い哲学者が飢えている状況の図です。

栄養豊富な哲学者または競争力のある .NET プログラミング

ここでは、スレッドが時々起動してリソースを取得しようとしていることがわかります。 XNUMX つのコアのうち XNUMX つは何も行いません (上の緑色のグラフ)。

哲学者の死

さて、哲学者たちの輝かしい晩餐会を中断する可能性があるもう一つの問題は、哲学者の一人がフォークを手にしたまま突然亡くなった場合です(そして彼らはそのように彼を埋葬するでしょう)。 そうなると、近所の人たちは昼食を食べずに取り残されてしまいます。 この場合のコード例を自分で考え出すことができます。たとえば、次のようになります。 NullReferenceException 哲学者がフォークを取った後。 ちなみに、例外は処理されず、呼び出し元のコードはそれをキャッチするだけではありません (この場合、 AppDomain.CurrentDomain.UnhandledException や。。など。)。 したがって、スレッド自体に、正常に終了するエラー ハンドラーが必要です。

ウェイター

さて、この行き詰まり、飢餓、死の問題をどうやって解決すればよいでしょうか? XNUMX 人の哲学者のみが分岐点に到達できるようにし、この場所にスレッドの相互排除を追加します。 どうやってするの? 哲学者の隣にウェイターがいて、一人の哲学者にフォークを取る許可を与えたとします。 このウェイターをどのように作るのか、哲学者は彼にどのように尋ねるのか、その質問は興味深いです。

最も単純な方法は、哲学者がウェイターにフォークへのアクセスを常に尋ねるだけの場合です。 それらの。 今、哲学者は近くの分岐点を待つのではなく、待つかウェイターに尋ねます。 最初は、このためにユーザー空間のみを使用します。そこでは、カーネルからプロシージャを呼び出すための割り込みは使用しません (それらについては後述します)。

ユーザー空間におけるソリューション

ここでは、XNUMX つのフォークと XNUMX 人の哲学者で行ったのと同じことを行い、サイクルで回転して待ちます。 しかし今では、それはすべて哲学者になり、いわばフォークはXNUMXつだけになります。 ウェイターからこの「黄金のフォーク」を受け取った哲学者だけが食べることができると言えます。 これには SpinLock を使用します。

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 (そこで使用されています)。 今では、待っている人を数えたり、少し寝かせたりする方法を知っています。 など。一般に、最適化のために可能な限りのあらゆることを行います。 しかし、これは、プロセッサのリソースを消費してフローを維持する同じアクティブなサイクルであることに注意する必要があります。哲学者の XNUMX 人が他の哲学者よりも優先されても、ゴールデン フォークを持たない場合 (優先順位逆転問題)、飢餓につながる可能性があります。 。 したがって、サードパーティの呼び出し、ネストされたロック、その他の予期せぬことをせずに、共有メモリ内の非常に短い変更にのみこれを使用します。

栄養豊富な哲学者または競争力のある .NET プログラミング

絵を描く SpinLock。 ストリームはゴールデンフォークをめぐって常に「争っている」。 障害があります - 図の選択された領域です。 コアは完全には利用されておらず、これら 2 つのスレッドによって約 3/XNUMX しか利用されていません。

ここでの別の解決策は、のみを使用することです Interlocked.CompareExchange 上記のコード (飢えた哲学者) に示されているのと同じアクティブな待機を使用しますが、すでに述べたように、これは理論的にはブロッキングにつながる可能性があります。

オン Interlocked だけではないことに注意してください。 CompareExchangeだけでなく、アトミックな読み取りと書き込みのための他のメソッドも使用できます。 また、変更を繰り返すことで、別のスレッドが変更を行う時間がある場合 (読み取り 1、読み取り 2、書き込み 2、書き込み 1 は不良)、単一の値への複雑な変更に使用できます (Interlocked Anything パターン)。

カーネルモードソリューション

ループ内でリソースを無駄にしないように、スレッドをブロックする方法を見てみましょう。 言い換えれば、例を続けて、ウェイターがどのようにして哲学者を眠らせ、必要な場合にのみ彼を起こすかを見てみましょう。 まず、オペレーティング システムのカーネル モードを通じてこれを行う方法を見てみましょう。 そこにあるすべての構造は、多くの場合、ユーザー空間にあるものよりも遅いです。 たとえば、数倍遅くなります AutoResetEvent おそらく53倍遅い SpinLock [リヒター]。 しかし、彼らの助けを借りれば、管理されているかどうかに関係なく、システム全体でプロセスを同期できます。

ここでの基本的な構造は、半世紀以上前にダイクストラによって提案されたセマフォです。 セマフォは、簡単に言えば、システムによって管理される正の整数であり、それに対する XNUMX つの操作 (インクリメントとデクリメント) です。 ゼロに減らすことができなかった場合、呼び出し元のスレッドはブロックされます。 他のアクティブなスレッド/プロセスによって数値が増加すると、スレッドはスキップされ、セマフォは渡された数値だけ再び減少します。 ボトルネックにある電車が手木信号を備えていることを想像することができます。 .NET は、同様の機能を持ついくつかの構造を提供します。 AutoResetEvent, ManualResetEvent, Mutex そして私自身 Semaphore。 我々は使用するだろう AutoResetEvent、これはこれらの構造の中で最も単純です。値は 0 と 1 (false、true) の XNUMX つだけです。 彼女のメソッド WaitOne() 値が 0 の場合は呼び出しスレッドをブロックし、1 の場合は値を 0 に下げてスキップします。 方法 Set() 1 に上げて 0 人のウェイターを通過させ、そのウェイターは再び XNUMX に下げます。地下鉄の回転式改札口のように機能します。

解決策を複雑にして、一度に全員ではなく、哲学者ごとにロックを使用しましょう。 それらの。 今では、哲学者は XNUMX 人ではなく、同時に複数人存在することができます。 しかし、レース (競合状態) を正確に回避し、確実にベットするために、テーブルへのアクセスを再びブロックします。

// Для блокирования отдельного философа.
// Инициализируется: 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)、その後、哲学者は開発者を待っているでしょう。 このようなコード内のエラーを見つけるのは非常に困難な作業です。 この解決策のもう XNUMX つの問題は、哲学者が餓死しないという保証がないことです。

ハイブリッド ソリューション

ユーザー モードに留まってループする場合と、カーネルを介してスレッドをブロックする場合の、タイミングに対する XNUMX つのアプローチを検討しました。 最初の方法は短いロックに適しており、XNUMX 番目の方法は長いロックに適しています。 多くの場合、ループ内で変数が変更されるまで最初に短時間待機し、待機時間が長い場合にはスレッドをブロックする必要があります。 このアプローチは、いわゆるで実装されます。 ハイブリッド構造。 以下はカーネル モードと同じ構成ですが、ユーザー モード ループが追加されています。 SemaphorSlim, ManualResetEventSlim など。ここで最も人気のあるデザインは Monitor、 なぜならC# にはよく知られているものがあります lock 構文。 Monitor これは最大値 1 (ミューテックス) を持つ同じセマフォですが、ループ内での待機、再帰、条件変数パターン (詳細は後述) などがサポートされています。これを使用した解決策を見てみましょう。

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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 直接[リヒター][エリック・リッパート]。 そのうちのXNUMXつは、 lock いつも外に出ている Monitor例外が発生した場合でも、別のスレッドが共有メモリの状態を変更する可能性があります。 このような場合、多くの場合、デッドロックに陥るか、何らかの方法でプログラムを安全に終了する方が良いでしょう。 もう XNUMX つの驚きは、Monitor が同期ブロックを使用していることです (SyncBlock)、すべてのオブジェクトに存在します。 したがって、不適切なオブジェクトが選択されると、簡単にデッドロックが発生する可能性があります (たとえば、インターンされた文字列をロックした場合)。 これには、常に非表示のオブジェクトを使用します。

条件変数パターンを使用すると、複雑な条件の予想をより簡潔に実装できます。 私の意見では、.NET ではそれは不完全です。 理論的には、XNUMX つのロックではなく、複数の変数 (Posix スレッドのように) に複数のキューが存在する必要があります。 そうすれば、すべての哲学者のためにそれらを作成できるでしょう。 ただし、この形式でもコードを減らすことができます。

多くの哲学者や async / await

さて、これでスレッドを効果的にブロックできるようになりました。 しかし、哲学者がたくさんいる場合はどうなるでしょうか? 100? 10000? たとえば、Web サーバーに対して 100000 件のリクエストを受け取りました。 リクエストごとにスレッドを作成するとオーバーヘッドが発生します。 非常に多くのスレッドは並行して実行されません。 論理コアの数だけ実行されます (私は 4 つあります)。 そして他の人はリソースを奪うだけです。 この問題に対する XNUMX つの解決策は、async / await パターンです。 その考え方は、何かが継続するのを待つ必要がある場合、関数はスレッドを保持しないということです。 そして、何かを実行すると、その実行が再開されます (ただし、必ずしも同じスレッド上で実行される必要はありません)。 私たちの場合は、フォークを待ちます。

SemaphoreSlim このために持っています WaitAsync() 方法。 このパターンを使用した実装を次に示します。

// Запуск такой же, как раньше. Где-нибудь в программе:
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 ミリ秒アイドル状態でした。 また、非同期 / 待機ソリューションは 100 件すべて実行され、それぞれの平均待機時間は 6.8 秒でした。 もちろん、実際のシステムでは 6 秒間のアイドル状態は許容できず、このように多くのリクエストを処理しない方がよいでしょう。 Monitor を使用したソリューションは、まったく拡張性がないことが判明しました。

まとめ

これらの小さな例からわかるように、.NET は多くの同期構造をサポートしています。 ただし、それらの使用方法が必ずしも明らかであるとは限りません。 この記事がお役に立てば幸いです。 とりあえずこれで終わりですが、スレッドセーフなコレクション、TPL データフロー、リアクティブ プログラミング、ソフトウェア トランザクション モデルなど、興味深いものがまだたくさん残っています。

ソース

出所: habr.com

コメントを追加します