CodeGym /Các khóa học /C# SELF /Tối ưu hóa với ReaderWrite...

Tối ưu hóa với ReaderWriterLockSlim

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

1. Tại sao lock thông thường có thể không đủ?

Hãy nghĩ xem: chúng ta có một tài nguyên chung — ví dụ, danh sách khách hàng, mà chúng ta lưu trữ ở đâu đó trong ứng dụng đa luồng. Phần lớn thời gian chúng ta chỉ đọc danh sách này — ví dụ, hiển thị dữ liệu lên giao diện người dùng, tạo báo cáo, lọc và sắp xếp thông tin. Chỉ thỉnh thoảng mới có ai đó thêm khách hàng mới hoặc xóa khách cũ.

Nếu ta dùng lock quen thuộc, thì bất kỳ luồng nào muốn đọc dữ liệu cũng phải chờ cho luồng khác hoàn thành đọc hoặc, tệ hơn, ghi. Còn nếu khoảng 99% thao tác là đọc? Thì chúng ta vô tình làm chậm tất cả các reader, mặc dù họ có thể chạy song song.

Lúc này một loại khóa đặc biệt xuất hiện — Reader-Writer Lock (khóa cho reader và writer). Ý tưởng chính: nhiều luồng có thể đọc đồng thời, nhưng nếu ai đó muốn thay đổi, họ chiếm quyền truy cập độc quyền, chặn các reader khác.

2. Lý thuyết: ReaderWriterLockSlim là gì

Tóm tắt về các lớp

  • ReaderWriterLock — cũ hơn, chậm hơn, có thể dẫn đến deadlock. Chỉ dùng nếu bạn cần hỗ trợ .NET Framework 2.0.
  • ReaderWriterLockSlim — lựa chọn hiện đại nhanh hơn (từ khóa Slim: "mảnh", "nhẹ"). Lớp này được tối ưu cho các thao tác đọc/ghi cạnh tranh thường xuyên và được khuyên dùng trong ứng dụng có nhiều luồng, nơi đọc dữ liệu thường xuyên hơn ghi.

Nguyên lý hoạt động

ReaderWriterLockSlim thực hiện ba chế độ khóa:

  • Read Lock (khóa để đọc): nhiều luồng có thể đọc đồng thời.
  • Write Lock (khóa để ghi): chỉ một luồng có thể thay đổi dữ liệu, và không có luồng nào khác được đọc.
  • Upgradeable Read Lock (khóa đọc có thể nâng cấp): chỉ một luồng có thể giữ loại khóa này cùng lúc; nó đọc, nhưng khi cần có thể "nâng cấp" lên write lock.

Sơ đồ khóa

Luồng-Độc giả Luồng-Người ghi Luồng với Upgradeable Read Lock
Độc giả ✅/⛔
Người ghi
Upgradeable Read ✅*

✅ — được phép

⛔ — bị khóa

— chỉ nếu upgradeable read lock chưa được "nâng cấp" lên write

Khi nào nên (và không nên) dùng ReaderWriterLockSlim

Khuyên dùng:

  • Nhiều luồng đồng thời, chủ yếu đọc dữ liệu chung (bảng tra cứu, cache, cấu hình).
  • Việc ghi xảy ra hiếm (ví dụ: mỗi giây/phút/giờ).
  • Quan trọng tối ưu hoá hiệu năng đọc tối đa mà không làm ảnh hưởng các luồng khác.

Không nên:

  • Ghi/đọc diễn ra với tỷ lệ tương đương (tốt hơn dùng lock thông thường).
  • Dữ liệu thay đổi thường xuyên (lợi ích không rõ rệt).

3. Sử dụng ReaderWriterLockSlim trong thực tế

Hãy mở rộng ứng dụng học của chúng ta! Trước đây chúng ta đã xây dựng một danh bạ khách hàng đơn giản và thử truy cập đa luồng vào collection. Giờ làm phức tạp hơn: giả sử chúng ta có một danh sách khách hàng mà nhiều luồng truy cập (ví dụ, một service thông báo cho tất cả khách hàng về chương trình khuyến mãi, trong khi luồng khác thêm khách hàng mới).

Đầu tiên mô tả collection khách hàng của chúng ta:


// Lớp Client — nhân vật quen thuộc từ các bài giảng trước
public class Client
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Bây giờ tạo một wrapper để truy cập an toàn theo luồng qua ReaderWriterLockSlim:

using System;
using System.Collections.Generic;
using System.Threading;

public class ClientDirectory
{
    private readonly List<Client> _clients = new List<Client>();
    // Slim-lock cho đọc/ghi
    private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();

    // Thêm khách hàng (write lock)
    public void AddClient(Client client)
    {
        _lock.EnterWriteLock(); // Chỉ một luồng có thể thêm khách hàng
        try
        {
            _clients.Add(client);
            Console.WriteLine($"[Luồng {Thread.CurrentThread.ManagedThreadId}] Đã thêm khách hàng {client.Name}");
        }
        finally
        {
            _lock.ExitWriteLock();
        }
    }

    // Lấy bản sao danh sách khách hàng (read lock)
    public List<Client> GetClients()
    {
        _lock.EnterReadLock(); // Nhiều luồng có thể đọc đồng thời
        try
        {
            // Trả về bản sao, để ai đó không vô tình thay đổi bản gốc
            return new List<Client>(_clients);
        }
        finally
        {
            _lock.ExitReadLock();
        }
    }
}

4. Đọc đa luồng và ghi định kỳ

Giả sử có 5 luồng thường xuyên đọc tất cả khách hàng ("tạo báo cáo" hoặc chỉ tò mò xem ai có trong danh sách), và một luồng riêng thỉnh thoảng thêm khách hàng mới.

using System.Threading;

class Program
{
    static void Main()
    {
        var directory = new ClientDirectory();

        // Luồng để thêm khách hàng
        var writerThread = new Thread(() =>
        {
            for (int i = 1; i <= 5; i++)
            {
                directory.AddClient(new Client { Id = i, Name = $"Khách hàng {i}" });
                Thread.Sleep(700); // Mô phỏng thao tác tốn thời gian
            }
        });

        // Một vài reader
        for (int j = 0; j < 5; j++)
        {
            int readerId = j + 1;
            new Thread(() =>
            {
                for (int k = 0; k < 10; k++)
                {
                    var clients = directory.GetClients();
                    Console.WriteLine($"[Người đọc {readerId}][Luồng {Thread.CurrentThread.ManagedThreadId}] Tổng số khách hàng: {clients.Count}");
                    Thread.Sleep(200); // Đọc thường xuyên hơn ghi
                }
            }).Start();
        }

        writerThread.Start();
        writerThread.Join();

        // Cho các reader kết thúc vòng:
        Thread.Sleep(3000);
    }
}

Hầu như luôn có vài luồng có thể đọc danh sách khách hàng đồng thời (không phải chờ nhau), nhưng khi một luồng thêm khách hàng — các luồng khác phải đợi cho đến khi thao tác ghi hoàn tất. Điều này cho phép phục vụ nhiều truy vấn đọc mà không gây khóa thừa.

5. Khóa có thể nâng cấp: UpgradeableReadLock

Thỉnh thoảng xảy ra trường hợp: một luồng đọc dữ liệu, và chỉ khi thỏa điều kiện thì mới muốn thay đổi. Có vẻ hợp lý là lấy read lock, kiểm tra điều kiện, rồi thoát read lock và lấy write lock. Nhưng trong khoảng "giữa" đó, luồng khác có thể đã thay đổi dữ liệu! Lúc này khóa có thể nâng cấp cứu giúp.

Dùng nó rất đơn giản:

public bool AddClientIfNotExists(string name)
{
    _lock.EnterUpgradeableReadLock(); // Chỉ một luồng có thể giữ loại khóa này
    try
    {
        bool exists = _clients.Exists(c => c.Name == name);
        if (!exists)
        {
            _lock.EnterWriteLock(); // Nâng cấp lên write lock
            try
            {
                _clients.Add(new Client { Name = name });
                return true;
            }
            finally
            {
                _lock.ExitWriteLock();
            }
        }
        return false;
    }
    finally
    {
        _lock.ExitUpgradeableReadLock();
    }
}

Ban đầu luồng đọc, và nếu cần thay đổi — nâng cấp mức khóa mà không để danh sách bị truy cập bởi các luồng khác.

6. Những điểm hữu ích

So sánh: ReaderWriterLockSlim vs lock

lock
ReaderWriterLockSlim
Đọc đồng thời Không
Ghi đồng thời Không Không
Hỗ trợ nâng cấp đọc Không Có (EnterUpgradeableReadLock)
Tốc độ Rất nhanh Nhanh hơn khi đọc thường xuyên
Bộ nhớ Tối thiểu Hơi nhiều hơn
Độ đơn giản Dễ Phức tạp hơn một chút, có những lưu ý

Trông nó như thế nào trong dự án thực tế

Trong các ứng dụng lớn, nơi có các bảng lớn hoặc cache dữ liệu được đọc bởi hàng trăm luồng, nhưng hiếm khi thay đổi, việc dùng ReaderWriterLockSlim là cách tránh "nghẽn" khi đọc. Ví dụ:

  • Cache cấu hình mà nhiều microservice truy xuất.
  • Kho lưu trữ bảng tra cứu cho các tính toán tài chính.
  • Cache nội bộ cho định tuyến message.

Nhấn mạnh — trong hầu hết trường hợp, đặc biệt nếu bạn mới bắt đầu với đa luồng, lock thông thường đơn giản và an toàn hơn. ReaderWriterLockSlim là giải pháp cho những bài toán thực sự có nhiều đọc đồng thời.

Minh họa: sơ đồ truy cập

flowchart TD
    A[Luồng-Độc giả 1] --Read--> D[ReaderWriterLockSlim]
    B[Luồng-Độc giả 2] --Read--> D
    C[Luồng-Người ghi] --Write--> D
    D --Read được phép--> E[Đọc từ collection chung]
    D --Write chặn đọc/ghi--> F[Ghi vào collection chung]

Khi toàn bộ là đọc — truy cập mở cho tất cả; khi có yêu cầu ghi — mọi người chờ cho đến khi ghi xong.

7. ReaderWriterLockSlim: các chi tiết và lỗi điển hình

Lock Recursion (Đệ quy khóa): Mặc định ReaderWriterLockSlim không cho phép cùng một luồng chiếm lại cùng một loại khóa nhiều lần. Tức là, nếu một luồng đã giữ read lock và cố gắng lấy lại lần thứ hai — sẽ ném ngoại lệ. Tương tự với write lock. Tuy nhiên, UpgradeableReadLock có thể được nâng cấp lên write lock từ bên trong.

Đừng nhầm thứ tự: Giữ EnterUpgradeableReadLock() → bên trong gọi EnterWriteLock(). Nhưng không được làm ngược lại!

Ngoại lệ: Nếu bạn không thả lock (ví dụ không gọi ExitWriteLock()), các luồng khác sẽ chờ mãi. Vì vậy luôn dùng cấu trúc try { ... } finally { ... }.

Đừng giữ lâu: Không chạy các thao tác IO dài trong khi đang giữ khóa. Giữ khóa càng lâu, càng làm tăng độ trễ cho người khác. Cố gắng thoát khỏi khối lock càng nhanh càng tốt.

Đánh giá độ phức tạp: Đừng dùng ReaderWriterLockSlim để bảo vệ các thao tác "nhỏ", nơi lock thông thường đơn giản và nhanh hơn.

1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Đồng bộ hóa luồng
Vấn đề tài nguyên chung
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION