CodeGym /Các khóa học /C# SELF /Semaphore: Semaphore và SemaphoreSlim

Semaphore: Semaphore và SemaphoreSlim

C# SELF
Mức độ, Bài học
Có sẵn

1. Giới thiệu

Mutex và lock — giống như barista phục vụ một khách một lần. Nhưng nếu mình có không chỉ một máy pha cà phê mà tới ba cái — và cùng lúc có thể làm ba tách?

Ví dụ, bạn có một quán cà phê với ba máy pha. Khách (luồng) tới, chiếm một máy trống, làm cà phê và đi. Nếu cả ba đã đầy, những người còn lại phải chờ tới khi có máy trống.

Câu hỏi: Làm sao để chỉ có tối đa ba khách dùng máy cùng lúc, những người khác đứng xếp hàng chờ?
Trả lời: dùng semaphore!

Semaphore là gì?

Semaphore — công cụ đồng bộ hóa cổ điển. Nếu lock/Mutex kiểm soát "một người vào — những người khác đứng ngoài", thì semaphore nói: "mình cho phép N cùng vào!".

Semaphore được Edsger Dijkstra đề xuất từ 1965. Tên gọi tới từ tín hiệu cờ ở biển: như cờ truyền thông tin ai được vào, semaphore trong code báo cho các luồng — được vào hay phải chờ.

Tình huống dùng

  • Giới hạn số luồng cùng lúc truy cập tài nguyên.
  • Giảm số kết nối đồng thời tới DB, số request song song, các tác vụ nặng.

2. Tổng quan các lớp: Semaphore và SemaphoreSlim

Semaphore

  • Lớp nặng, dùng kernel objects của OS.
  • Hỗ trợ đồng bộ giữa các luồng của các process khác nhau.
  • Có thể đặt tên và chia sẻ giữa các process.

SemaphoreSlim

  • Phiên bản nhẹ, chỉ hoạt động trong cùng một process.
  • Nhanh hơn và tiêu tốn ít tài nguyên hơn.
  • Hầu như luôn ưu tiên dùng nếu không cần đồng bộ giữa process.

So sánh: ba lô du lịch (SemaphoreSlim) so với vali to (Semaphore). Đi nhẹ thì mang ba lô.

Bảng so sánh

Lớp Liên-process Hiệu năng Khuyến nghị
Semaphore
Có Chậm hơn Khi cần đồng bộ giữa process
SemaphoreSlim
Không Nhanh hơn Trong 99% trường hợp, trong cùng một process

Các phương thức và thuộc tính chính của semaphore

Các tham số chính

  • InitialCount — số quyền ban đầu.
  • MaxCount — số quyền tối đa cùng lúc.

Các phương thức quan trọng

  • Wait() hoặc WaitAsync() — yêu cầu truy cập (chiếm một quyền).
  • Release() — giải phóng một quyền.

Cách hoạt động
Nếu gọi Wait() mà không còn quyền, luồng sẽ bị block và chờ tới khi ai đó gọi Release(). Khi có quyền được giải phóng, một trong các luồng đang chờ sẽ tiếp tục.

3. Ví dụ thực hành đầu tiên

Thêm vào ứng dụng console một "bãi đỗ" có 3 chỗ và thử khởi chạy 10 luồng.

using System;
using System.Threading;

class Program
{
    // Semaphore với 3 quyền (3 chỗ đỗ)
    static SemaphoreSlim parking = new SemaphoreSlim(3);

    static void Main()
    {
        for (int i = 1; i <= 10; i++)
        {
            int carNumber = i;
            new Thread(() =>
            {
                Console.WriteLine($"Xe #{carNumber} đang cố gắng đỗ...");
                parking.Wait(); // Đang chờ chỗ trống
                Console.WriteLine($"Xe #{carNumber} đã vào bãi đỗ!");
                Thread.Sleep(2000); // Đỗ trong 2 giây
                Console.WriteLine($"Xe #{carNumber} rời bãi đỗ.");
                parking.Release(); // Giải phóng chỗ
            }).Start();
        }
    }
}
  • Chỉ có tối đa ba xe "đỗ" cùng lúc.
  • Những xe khác sẽ chờ chỗ trống.
  • Output trộn lẫn — điều này bình thường trong đa luồng.

4. Semaphore như bộ giới hạn tải

Giới hạn số tác vụ nặng (ví dụ các download) cùng chạy tối đa 5.

static SemaphoreSlim semaphore = new SemaphoreSlim(5); // tối đa 5 download đồng thời

static void DownloadFile(int fileId)
{
    semaphore.Wait();
    try
    {
        Console.WriteLine($"--> Bắt đầu tải file {fileId}");
        Thread.Sleep(1000 + fileId * 100); // Tải (mô phỏng)
        Console.WriteLine($"<-- File {fileId} đã tải xong");
    }
    finally
    {
        semaphore.Release();
    }
}

static void Main()
{
    for (int i = 1; i <= 12; i++)
    {
        int localId = i;
        new Thread(() => DownloadFile(localId)).Start();
    }
}

Điểm quan trọng: đặt Wait() trước khối try, còn Release() thì trong finally. Vậy quyền sẽ luôn được giải phóng ngay cả khi có exception.

5. Wait(int millisecondsTimeout) và phương thức bất đồng bộ

Có thể chờ trong một khoảng thời gian giới hạn:

if (semaphore.Wait(500))
{
    // Đã chiếm được quyền trong nửa giây!
}
else
{
    // Không chờ được 500 ms — hủy
}

Trong ứng dụng hiện đại (ví dụ ASP.NET) nên dùng phương thức bất đồng bộ: await semaphore.WaitAsync(). Điều này không block luồng thực thi trong khi chờ quyền.
Lưu ý: trong code async hãy dùng SemaphoreSlim và WaitAsync, nếu không dễ gặp deadlock bất ngờ.

6. Ví dụ sai và đúng

Lỗi thường gặp — quên gọi Release(): quyền "rò rỉ", và mọi thứ dừng lại.

Tệ

static void SomeWork()
{
    semaphore.Wait();
    // ... xử lý, nhưng quên Release!
}

Đúng

static void SomeWork()
{
    semaphore.Wait();
    try
    {
        // xử lý
    }
    finally
    {
        semaphore.Release();
    }
}

Phiên bản bất đồng bộ

static async Task SomeAsyncWork()
{
    await semaphore.WaitAsync();
    try
    {
        // xử lý bất đồng bộ
    }
    finally
    {
        semaphore.Release();
    }
}

7. Cấu tạo bên trong của semaphore (giải thích đơn giản)

Semaphore là một bộ đếm. Wait() giảm nó đi 1. Nếu > 0 — luồng đi qua; nếu bằng 0 — luồng phải chờ. Release() tăng bộ đếm và đánh thức các luồng đang chờ.


+-------------------------------+
| Semaphore (bộ đếm = 3)       |
+-------------------------------+
|  [ ]  [ ]  [ ]                | <--- Quyền
+----+----+----+----------------+
     |    |    |
   Luồng Luồng Luồng

8. Các mẹo hữu ích

Khác biệt với các primitive khác

  • lock / Monitor / Mutex — cho phép chỉ một luồng (exclusive access).
  • Semaphore/SemaphoreSlim — cho phép tối đa N luồng cùng lúc.

Semaphore không gắn với "chủ sở hữu": quyền có thể được giải phóng bởi bất kỳ luồng nào. Đây là đặc tính chứ không phải bug.

Ứng dụng thực tế

  • Giới hạn kết nối đồng thời tới service hoặc DB.
  • Pools: không quá N luồng truy cập tài nguyên.
  • Giới hạn request web xử lý cùng lúc.
  • Giới hạn đọc/ghi để tránh quá tải.
  • Giới hạn số lần gọi API bên ngoài.

Ví dụ lỗi (Release nhiều hơn Wait)

var semaphore = new SemaphoreSlim(2);
semaphore.Release(); // Lỗi! Bộ đếm thành 3, vượt MaxCount — sẽ ném SemaphoreFullException.

Ở đây sẽ có SemaphoreFullException: bộ đếm vượt quá giới hạn.

Khác biệt Semaphore và SemaphoreSlim

  • SemaphoreSlim — trong process, nhanh hơn và đơn giản hơn (hầu như luôn dùng).
  • Semaphore — cần khi đồng bộ giữa process (kịch bản ít gặp).

Tại sao cần biết về semaphore?

Câu hỏi kinh điển phỏng vấn: "Làm sao giới hạn số luồng truy cập tài nguyên?" — đáp án đúng: semaphore.

  • lock — 1 luồng.
  • Semaphore/SemaphoreSlim — N luồng.

9. Lỗi phổ biến và lưu ý khi dùng semaphore

Lỗi #1: quên gọi Release(). Nếu luồng chiếm quyền (Wait() hoặc WaitAsync()) mà không thả, những luồng khác sẽ chờ vô hạn — app sẽ "đứng".

Lỗi #2: gọi Release() nhiều hơn số lần Wait(). Xuất hiện quyền "dư". Với Semaphore sẽ gây SemaphoreFullException và phá vỡ logic truy cập.

Lỗi #3: trộn lẫn cơ chế đồng bộ khác nhau. Ở chỗ này dùng lock, chỗ kia dùng semaphore trên cùng tài nguyên. Điều này tăng nguy cơ deadlock.

Lỗi #4: dùng Semaphore trong code async. Semaphore truyền thống không thân thiện với async/await. Với scenario async hãy dùng SemaphoreSlim và WaitAsync().

Lỗi #5: đặt sai initialCount và maxCount. Nếu chọn sai giá trị, giới hạn có thể bị vượt và nhiều luồng hơn mong muốn sẽ vào được tài nguyên.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Tạo một ứng dụng console mô phỏng hoạt động của bãi đậu xe với 2 chỗ đậu xe có sẵn.
Tạo một ứng dụng console mô phỏng hoạt động của bãi đậu xe với 2 chỗ đậu xe có sẵn.
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION