Các nhà triết học được nuôi dưỡng tốt hoặc lập trình cạnh tranh trong .NET

Các nhà triết học được nuôi dưỡng tốt hoặc lập trình cạnh tranh trong .NET

Chúng ta hãy xem cách lập trình đồng thời và song song hoạt động trong .Net, sử dụng ví dụ về bài toán triết gia ăn trưa. Kế hoạch như sau, từ đồng bộ hóa luồng/quy trình đến mô hình tác nhân (trong các phần sau). Bài viết có thể hữu ích cho lần đầu làm quen hoặc giúp bạn làm mới kiến ​​thức.

Tại sao thậm chí biết làm thế nào để làm điều này? Các bóng bán dẫn đang đạt đến kích thước tối thiểu, định luật Moore đang đạt đến giới hạn tốc độ ánh sáng, và do đó sự tăng trưởng được quan sát thấy về số lượng; có thể tạo ra nhiều bóng bán dẫn hơn. Đồng thời, lượng dữ liệu ngày càng tăng và người dùng mong đợi phản hồi ngay lập tức từ hệ thống. Trong tình huống như vậy, việc lập trình “bình thường” khi chúng ta có một luồng thực thi sẽ không còn hiệu quả nữa. Chúng ta cần bằng cách nào đó giải quyết vấn đề thực thi đồng thời hoặc đồng thời. Hơn nữa, vấn đề này tồn tại ở các cấp độ khác nhau: ở cấp độ luồng, ở cấp độ quy trình, ở cấp độ máy trên mạng (hệ thống phân tán). .NET có các công nghệ chất lượng cao, đã được kiểm nghiệm theo thời gian để giải quyết các vấn đề như vậy một cách nhanh chóng và hiệu quả.

Nhiệm vụ

Edsger Dijkstra đã hỏi các sinh viên của mình vấn đề này vào năm 1965. Công thức đã được thiết lập như sau. Có một số lượng nhất định (thường là năm) nhà triết học và cùng số lượng nĩa. Họ ngồi ở một chiếc bàn tròn, chĩa nĩa vào giữa. Các triết gia có thể ăn từ đĩa thức ăn vô tận của mình, suy nghĩ hoặc chờ đợi. Để ăn, triết gia cần dùng hai chiếc nĩa (người sau dùng chung nĩa với người trước). Nhấc và đặt nĩa xuống là hai hành động riêng biệt. Tất cả các triết gia đều im lặng. Nhiệm vụ là tìm ra một thuật toán như vậy để tất cả họ đều có thể suy nghĩ và ăn uống đầy đủ ngay cả sau 54 năm.

Trước tiên, hãy thử giải quyết vấn đề này bằng cách sử dụng không gian chung. Những chiếc nĩa nằm trên bàn chung và các nhà triết học chỉ cần lấy chúng khi chúng còn ở đó và đặt chúng trở lại. Đây là nơi phát sinh vấn đề về đồng bộ hóa, chính xác khi nào thì thực hiện phân nhánh? phải làm gì nếu không có phích cắm? v.v. Nhưng trước tiên, hãy bắt đầu với các triết gia.

Để bắt đầu chủ đề, chúng tôi sử dụng nhóm chủ đề thông qua Task.Run phương pháp:

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);
}

Nhóm luồng được thiết kế để tối ưu hóa việc tạo và loại bỏ các luồng. Nhóm này có một hàng nhiệm vụ và CLR tạo hoặc xóa các luồng tùy thuộc vào số lượng các tác vụ này. Một nhóm cho tất cả các Miền ứng dụng. Hồ bơi này hầu như nên được sử dụng thường xuyên, bởi vì... không cần phải bận tâm đến việc tạo và xóa các chủ đề, hàng đợi của chúng, v.v. Bạn có thể làm điều đó mà không cần có nhóm, nhưng sau đó bạn sẽ phải sử dụng nó trực tiếp Thread, điều này rất hữu ích cho các trường hợp khi chúng ta cần thay đổi mức độ ưu tiên của một luồng, khi chúng ta có một thao tác dài, đối với một luồng Foreground, v.v.

Nói cách khác, System.Threading.Tasks.Task lớp học giống nhau Thread, nhưng với đủ loại tiện ích: khả năng khởi chạy một tác vụ sau một khối tác vụ khác, trả về chúng từ các hàm, ngắt chúng một cách thuận tiện, v.v. v.v. Chúng cần thiết để hỗ trợ các cấu trúc async/await (Mẫu không đồng bộ dựa trên nhiệm vụ, đường cú pháp để chờ hoạt động IO). Chúng ta sẽ nói về điều này sau.

CancelationTokenSource ở đây điều cần thiết là luồng có thể tự kết thúc theo tín hiệu từ luồng đang gọi.

Vấn đề đồng bộ hóa

Nhà triết học bị chặn

Được rồi, chúng ta đã biết cách tạo chủ đề, hãy thử ăn trưa nào:

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

Ở đây trước tiên chúng tôi thử lấy nĩa bên trái rồi đến nĩa bên phải, nếu nó hoạt động, chúng tôi ăn và đặt chúng trở lại. Lấy một cái nĩa là nguyên tử, tức là. hai luồng không thể lấy một luồng cùng một lúc (sai: luồng đầu tiên đọc rằng fork là miễn phí, luồng thứ hai cũng làm như vậy, luồng đầu tiên lấy, luồng thứ hai lấy). Vì điều này Interlocked.CompareExchange, phải được thực hiện bằng lệnh của bộ xử lý (TSL, XCHG), khóa một phần bộ nhớ để đọc và ghi tuần tự nguyên tử. Và SpinWait tương đương với việc xây dựng while(true) chỉ với một chút “ma thuật” - luồng sẽ chiếm bộ xử lý (Thread.SpinWait), nhưng đôi khi chuyển quyền điều khiển sang một luồng khác (Thread.Yeild) hoặc ngủ quên (Thread.Sleep).

Nhưng giải pháp này không hiệu quả, bởi vì... các chủ đề sẽ sớm bị chặn (trong vòng một giây đối với tôi): tất cả các triết gia đều dùng ngã ba bên trái của họ, nhưng không có ngã ba bên phải. Mảng fork khi đó có các giá trị: 1 2 3 4 5.

Các nhà triết học được nuôi dưỡng tốt hoặc lập trình cạnh tranh trong .NET

Trong hình chặn thread (bế tắc). Màu xanh biểu thị việc thực thi, màu đỏ biểu thị đồng bộ hóa và màu xám biểu thị luồng đang ngủ. Kim cương biểu thị thời gian khởi động Nhiệm vụ.

Cơn đói của các triết gia

Dù bạn không cần nhiều thức ăn để suy nghĩ nhưng cơn đói có thể buộc bất cứ ai phải từ bỏ triết học. Hãy thử mô phỏng tình trạng thiếu luồng trong bài toán của chúng ta. Đói là khi một thread hoạt động, nhưng không có công việc đáng kể, nói cách khác, đó là sự bế tắc tương tự, chỉ có điều lúc này thread không ngủ mà đang tích cực tìm thứ gì đó để ăn nhưng không có thức ăn. Để tránh bị chặn thường xuyên, chúng tôi sẽ đặt nĩa trở lại nếu không thể lấy cái khác.

// То же что и в 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)
    );
}

Điều quan trọng ở quy tắc này là cứ bốn nhà triết học thì có hai người quên đặt chiếc nĩa bên trái của mình xuống. Và hóa ra là họ ăn nhiều thức ăn hơn, còn những người khác bắt đầu chết đói, mặc dù các chủ đề có cùng mức độ ưu tiên. Ở đây họ không hoàn toàn chết đói, bởi vì... những triết gia tồi đôi khi đặt lại nĩa của họ. Hóa ra những người tốt ăn ít hơn những người xấu khoảng 5 lần. Vì vậy, một lỗi nhỏ trong mã sẽ dẫn đến giảm hiệu suất. Ở đây cũng cần lưu ý rằng có thể xảy ra một tình huống hiếm hoi là tất cả các triết gia đều lấy nĩa bên trái, không có cái nào bên phải, họ đặt chiếc bên trái xuống, chờ đợi, lại lấy chiếc bên trái, v.v. Tình trạng này cũng là sự đói khát, giống như sự tắc nghẽn lẫn nhau hơn. Tôi không thể lặp lại nó. Dưới đây là hình ảnh về một tình huống trong đó hai triết gia tồi đã lấy cả hai chiếc nĩa và hai triết gia tốt đang chết đói.

Các nhà triết học được nuôi dưỡng tốt hoặc lập trình cạnh tranh trong .NET

Ở đây bạn có thể thấy rằng các luồng đôi khi thức dậy và cố gắng lấy tài nguyên. Hai trong số bốn lõi không làm gì cả (biểu đồ màu xanh lá cây ở trên).

Cái chết của một triết gia

Chà, một vấn đề nữa có thể làm gián đoạn bữa tối huy hoàng của các triết gia là nếu một trong số họ đột ngột chết với những chiếc nĩa trên tay (và anh ta sẽ được chôn cất theo cách đó). Sau đó, những người hàng xóm sẽ không được ăn trưa. Bạn có thể tự mình đưa ra một mã ví dụ cho trường hợp này, ví dụ như nó bị vứt đi NullReferenceException sau khi triết gia cầm nĩa. Và nhân tiện, ngoại lệ sẽ không được xử lý và mã gọi điện sẽ không chỉ bắt được nó (vì điều này AppDomain.CurrentDomain.UnhandledException và vân vân.). Do đó, cần có các trình xử lý lỗi trong chính các luồng và có sự kết thúc nhẹ nhàng.

Người phục vụ

Được rồi, làm thế nào để chúng ta giải quyết vấn đề bế tắc, đói khát và chết chóc này? Chúng tôi sẽ chỉ cho phép một triết gia tham gia các nhánh và chúng tôi sẽ thêm các chủ đề loại trừ lẫn nhau cho địa điểm này. Làm thế nào để làm nó? Giả sử bên cạnh các triết gia có một người phục vụ cho phép một triết gia cầm nĩa. Chúng ta nên làm người phục vụ này như thế nào và các triết gia sẽ hỏi anh ta như thế nào là những câu hỏi thú vị.

Cách đơn giản nhất là các triết gia chỉ cần liên tục yêu cầu người phục vụ lấy nĩa. Những thứ kia. Bây giờ các triết gia sẽ không đợi một cái nĩa gần đó mà đợi hoặc hỏi người phục vụ. Lúc đầu, chúng tôi chỉ sử dụng Không gian người dùng cho việc này; trong đó chúng tôi không sử dụng các ngắt để gọi bất kỳ thủ tục nào từ kernel (xem thêm về chúng bên dưới).

Giải pháp không gian người dùng

Ở đây chúng ta sẽ làm điều tương tự như chúng ta đã làm trước đây với một cái nĩa và hai triết gia, chúng ta sẽ quay một vòng và chờ đợi. Nhưng bây giờ sẽ là tất cả các triết gia và dường như chỉ có một ngã ba, tức là. Chúng ta có thể nói rằng chỉ có triết gia lấy “cái nĩa vàng” này từ người phục vụ mới ăn. Để làm điều này, chúng tôi sử dụng 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 đây là một công cụ chặn, nói một cách đại khái là giống nhau while(true) { if (!lock) break; }, nhưng thậm chí còn có nhiều “ma thuật” hơn trong SpinWait (được sử dụng ở đó). Bây giờ anh ấy biết cách đếm số người đang chờ đợi, ru họ ngủ một chút và hơn thế nữa. v.v. Nói chung, nó làm mọi thứ có thể để tối ưu hóa. Nhưng chúng ta phải nhớ rằng đây vẫn là vòng lặp hoạt động tương tự, tiêu tốn tài nguyên bộ xử lý và giữ một luồng, điều này có thể dẫn đến tình trạng chết đói nếu một trong các triết gia được ưu tiên hơn những triết gia khác nhưng không có ngã ba vàng (Vấn đề đảo ngược ưu tiên ). Do đó, chúng tôi chỉ sử dụng nó cho những thay đổi rất ngắn trong bộ nhớ dùng chung mà không có bất kỳ lệnh gọi nào của bên thứ ba, khóa lồng nhau hoặc các bất ngờ khác.

Các nhà triết học được nuôi dưỡng tốt hoặc lập trình cạnh tranh trong .NET

Vẽ cho SpinLock. Các luồng liên tục “tranh giành” gold fork. Xảy ra lỗi - vùng được đánh dấu trong hình. Các lõi không được sử dụng hết: chỉ khoảng 2/3 bởi XNUMX luồng này.

Một giải pháp khác ở đây là chỉ sử dụng Interlocked.CompareExchange với cùng một chế độ chờ tích cực như được hiển thị trong đoạn mã trên (ở các triết gia đang đói), nhưng điều này, như đã nói, về mặt lý thuyết có thể dẫn đến việc chặn.

trên Interlocked điều đáng nói là không chỉ có CompareExchange, mà còn các phương pháp khác để đọc VÀ ghi nguyên tử. Và bằng cách lặp lại thay đổi, nếu một luồng khác quản lý để thực hiện các thay đổi của nó (đọc 1, đọc 2, viết 2, ghi 1 là không tốt), thì nó có thể được sử dụng cho các thay đổi phức tạp đối với một giá trị (Mẫu Interlocked Anything).

Giải pháp chế độ hạt nhân

Để tránh lãng phí tài nguyên trong một vòng lặp, hãy xem cách chặn một luồng. Nói cách khác, tiếp tục ví dụ của chúng ta, chúng ta hãy xem người phục vụ đưa triết gia vào giấc ngủ và chỉ đánh thức anh ta khi cần thiết như thế nào. Trước tiên, hãy xem cách thực hiện việc này thông qua chế độ kernel của hệ điều hành. Tất cả các cấu trúc ở đó thường chậm hơn so với cấu trúc trong không gian người dùng. Chậm hơn nhiều lần, ví dụ AutoResetEvent có thể chậm hơn 53 lần SpinLock [Richter]. Nhưng với sự trợ giúp của họ, bạn có thể đồng bộ hóa các quy trình trên toàn bộ hệ thống, dù được quản lý hay không.

Thiết kế cơ bản ở đây là một semaphore, được Dijkstra đề xuất cách đây hơn nửa thế kỷ. Nói một cách đơn giản, một semaphore là một số nguyên dương được điều khiển bởi hệ thống và hai thao tác trên nó - tăng và giảm. Nếu không thể giảm số XNUMX thì luồng gọi sẽ bị chặn. Khi số lượng được tăng lên bởi một số luồng/quy trình hoạt động khác, thì các luồng này sẽ được truyền và semaphore lại giảm đi theo số lượng được truyền. Bạn có thể tưởng tượng những đoàn tàu gặp tình trạng tắc nghẽn với một đèn tín hiệu. .NET cung cấp một số cấu trúc có chức năng tương tự: AutoResetEvent, ManualResetEvent, Mutex và bản thân tôi Semaphore. Chúng tôi sẽ sử dụng AutoResetEvent, đây là cấu trúc đơn giản nhất trong số này: chỉ có hai giá trị 0 và 1 (false, true). Phương pháp của cô WaitOne() chặn chuỗi cuộc gọi nếu giá trị là 0 và nếu 1 thì giảm giá trị xuống 0 và bỏ qua. Một phương pháp Set() tăng lên 1 và cho một người đi qua, người này lại giảm xuống 0. Hoạt động giống như một cửa quay trong tàu điện ngầm.

Hãy làm phức tạp giải pháp và sử dụng tính năng chặn cho từng triết gia chứ không phải cho tất cả cùng một lúc. Những thứ kia. Bây giờ nhiều triết gia có thể ăn cùng một lúc chứ không chỉ một. Nhưng chúng tôi lại chặn quyền truy cập vào bàn để lấy nĩa một cách chính xác, tránh các điều kiện tranh đua.

// Для блокирования отдельного философа.
// Инициализируется: 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();
}

Để hiểu điều gì đang xảy ra ở đây, hãy xem xét trường hợp nhà triết học không lấy được chiếc nĩa, khi đó hành động của ông ta sẽ như sau. Anh ta đang đợi để được vào bàn. Nhận được rồi, anh ta cố gắng cầm lấy chiếc nĩa. Không thành công. Nó cho phép truy cập vào bảng (loại trừ lẫn nhau). Và anh ta vượt qua “cửa quay” của mình (AutoResetEvent) (lúc đầu chúng mở). Nó lại rơi vào chu kỳ, bởi vì anh ấy không có nĩa. Anh ta cố gắng lấy chúng và dừng lại ở "cửa quay" của mình. Một số người hàng xóm may mắn hơn ở bên phải hoặc bên trái, sau khi ăn xong, sẽ mở cửa cho triết gia của chúng ta bằng cách “mở cửa quay”. Nhà triết học của chúng ta đi qua nó (và nó khép lại sau lưng ông) lần thứ hai. Lần thứ ba cố gắng cầm nĩa. Thành công. Và anh ấy đi qua cửa quay để ăn trưa.

Ví dụ: khi có lỗi ngẫu nhiên trong mã đó (chúng luôn tồn tại), một hàng xóm sẽ được chỉ định không chính xác hoặc cùng một đối tượng sẽ được tạo AutoResetEvent cho tất cả (Enumerable.Repeat), thì các triết gia sẽ đợi các nhà phát triển, bởi vì Tìm lỗi trong mã như vậy là một nhiệm vụ khá khó khăn. Một vấn đề khác với giải pháp này là nó không đảm bảo rằng một số triết gia sẽ không chết đói.

Giải pháp lai

Chúng tôi đã xem xét hai cách tiếp cận để đồng bộ hóa, khi chúng tôi ở chế độ người dùng và quay vòng lặp và khi chúng tôi chặn luồng thông qua hạt nhân. Phương pháp đầu tiên phù hợp với các khối ngắn, phương pháp thứ hai phù hợp với các khối dài. Thông thường, trước tiên bạn cần đợi một thời gian ngắn để một biến thay đổi trong vòng lặp, sau đó chặn luồng khi chờ đợi lâu. Cách tiếp cận này được thực hiện trong cái gọi là. thiết kế lai. Nó có cấu trúc tương tự như đối với chế độ kernel, nhưng giờ đây có vòng lặp chế độ người dùng: SemaphorSlim, ManualResetEventSlim v.v. Thiết kế phổ biến nhất ở đây là Monitor, bởi vì trong C# có một cái nổi tiếng lock cú pháp. Monitor đây là cùng một semaphore có giá trị tối đa là 1 (mutex), nhưng có hỗ trợ chờ trong vòng lặp, đệ quy, mẫu Biến điều kiện (xem thêm ở bên dưới), v.v. Hãy xem giải pháp với nó.

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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);
    }
}

Ở đây, chúng tôi lại chặn toàn bộ bàn truy cập vào nĩa, nhưng bây giờ chúng tôi bỏ chặn tất cả các chủ đề cùng một lúc, thay vì chặn hàng xóm khi ai đó ăn xong. Những thứ kia. Đầu tiên, có người ăn và chặn hàng xóm, khi ăn xong nhưng muốn ăn tiếp ngay thì anh ta đi vào khối và đánh thức hàng xóm, bởi vì thời gian chờ đợi của nó ít hơn.

Bằng cách này, chúng ta tránh được sự bế tắc và sự đói khát của một số triết gia. Chúng tôi sử dụng vòng lặp để chờ trong thời gian ngắn và chặn luồng trong thời gian dài. Việc bỏ chặn tất cả mọi người cùng một lúc sẽ chậm hơn so với việc chỉ bỏ chặn người hàng xóm, như trong giải pháp với AutoResetEvent, nhưng sự khác biệt không lớn, bởi vì chủ đề phải ở chế độ người dùng trước tiên.

У lock cú pháp có một số bất ngờ khó chịu. Khuyến khích sử dụng Monitor trực tiếp [Richter] [Eric Lippert]. Một trong số đó là lock luôn luôn đi ra Monitor, ngay cả khi có ngoại lệ và khi đó một luồng khác có thể thay đổi trạng thái của bộ nhớ dùng chung. Trong những trường hợp như vậy, tốt hơn hết là bạn nên đi vào bế tắc hoặc bằng cách nào đó chấm dứt chương trình một cách an toàn. Một điều ngạc nhiên nữa là Monitor sử dụng khối đồng hồ (SyncBlock), có mặt trong mọi đối tượng. Do đó, nếu chọn một đối tượng không phù hợp, bạn có thể dễ dàng gặp bế tắc (ví dụ: nếu bạn khóa một chuỗi nội bộ). Chúng tôi luôn sử dụng một đối tượng ẩn cho việc này.

Mẫu Biến điều kiện cho phép bạn triển khai chính xác hơn kỳ vọng của một số điều kiện phức tạp. Theo tôi, trong .NET nó không đầy đủ, bởi vì... Về lý thuyết, cần có một số hàng đợi trên một số biến (như trong Chủ đề Posix) chứ không phải trên một khóa. Khi đó có thể làm chúng cho tất cả các triết gia. Nhưng ngay cả ở dạng này, nó cũng cho phép bạn rút ngắn mã.

Nhiều triết gia hoặc async / await

Được rồi, bây giờ chúng ta có thể chặn chủ đề một cách hiệu quả. Nhưng nếu chúng ta có nhiều triết gia thì sao? 100? 10000? Ví dụ: chúng tôi đã nhận được 100000 yêu cầu tới máy chủ web. Việc tạo một chủ đề cho mỗi yêu cầu sẽ tốn kém vì rất nhiều luồng sẽ không được thực thi song song. Chỉ có nhiều lõi logic sẽ được thực thi (tôi có 4 lõi). Và những người khác sẽ đơn giản lấy đi tài nguyên. Một giải pháp cho vấn đề này là mẫu async/await. Ý tưởng của nó là một hàm không giữ một luồng nếu nó cần đợi một cái gì đó tiếp tục. Và khi có điều gì đó xảy ra, nó sẽ tiếp tục thực thi (nhưng không nhất thiết phải trong cùng một luồng!). Trong trường hợp của chúng tôi, chúng tôi sẽ chờ một fork.

SemaphoreSlim có cho việc này WaitAsync() phương pháp. Đây là một triển khai sử dụng mẫu này.

// Запуск такой же, как раньше. Где-нибудь в программе:
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();
}

Phương pháp với async / await được dịch sang một máy trạng thái hữu hạn xảo quyệt, máy này ngay lập tức trả về nội bộ của nó Task. Thông qua đó, bạn có thể đợi phương thức này hoàn thành, hủy nó và mọi thứ khác mà bạn có thể thực hiện với Task. Bên trong phương thức, một máy trạng thái sẽ điều khiển việc thực thi. Điểm mấu chốt là nếu không có độ trễ thì quá trình thực thi sẽ đồng bộ và nếu có thì luồng sẽ được giải phóng. Để hiểu rõ hơn về điều này, tốt hơn nên xem xét máy trạng thái này. Bạn có thể tạo chuỗi từ những thứ này async / await phương pháp.

Hãy kiểm tra nó. Công việc của 100 nhà triết học trên một cỗ máy có 4 lõi logic, 8 giây. Giải pháp trước đó với Monitor chỉ thực thi 4 luồng đầu tiên và hoàn toàn không thực thi phần còn lại. Mỗi luồng trong số 4 luồng này không hoạt động trong khoảng 2ms. Và giải pháp async/await đã thực hiện tất cả 100, với trung bình 6.8 giây mỗi lần chờ. Tất nhiên, trong các hệ thống thực, việc không hoạt động trong 6 giây là không thể chấp nhận được và tốt hơn hết là không nên xử lý quá nhiều yêu cầu theo cách này. Giải pháp với Monitor hóa ra không thể mở rộng được.

Kết luận

Như bạn có thể thấy từ những ví dụ nhỏ này, .NET hỗ trợ nhiều cấu trúc đồng bộ hóa. Tuy nhiên, không phải lúc nào cũng rõ ràng về cách sử dụng chúng. Tôi hy vọng bài viết này hữu ích. Chúng tôi hiện đang kết thúc việc này, nhưng vẫn còn rất nhiều nội dung thú vị, ví dụ: bộ sưu tập an toàn theo luồng, TPL Dataflow, lập trình phản ứng, mô hình giao dịch phần mềm, v.v.

nguồn

Nguồn: www.habr.com

Thêm một lời nhận xét