0

Cạm Bẫy GetOrAdd: Cache Stampede trong ConcurrentDictionary và Cách Khắc Phục

nếu anh em lười đọc phần dưới: ANH EM HÃY CẨN THẬN, ĐỪNG NHƯ MÌNH khi dùng ConcurrentDictionary.GetOrAdd mà factory method của mình tốn nhiều tài nguyên (CPU/IO). Sẽ có trường hợp factory method đó bị gọi nhiều lần không cần thiết khi nhiều thread cùng đụng vào key chưa có trong dictionary. Cách fix đơn giản: wrap giá trị được cache trong Lazy<T> để đảm bảo factory method chỉ được chạy đúng một lần duy nhất.

Vấn đề cần xử lí là gì

Gần đây mình gặp phải một bug khi làm việc với cache trong .NET (may quá(?) anh Lead chỉ ra). Khi dùng ConcurrentDictionary làm cache, mình ngu dốt nghĩ rằng GetOrAdd sẽ đảm bảo factory method chỉ được gọi một lần duy nhất, như cách mình code dưới đây.

// cache là một ConcurrentDictionary
cache.GetOrAdd("key_to_look", _ => {
    // Factory method này chạy rất nặng (IO hoặc CPU intensive)
    return result;
});

Nhưng mà hóa ra không phải! ConcurrentDictionary chỉ đảm bảo thread-safety cho dictionary, nhưng không đảm bảo factory method chỉ được gọi một lần. Khi nhiều thread cùng truy cập một key chưa tồn tại, tất cả đều có thể gọi factory method dù chỉ một giá trị được lưu vào cache. Cái vấn đề này còn được gọi là cache stampede.

Tìm hiểu về Cache Stampede

Cache Stampede là hiện tượng xảy ra khi nhiều client cùng lúc cố gắng truy cập một resource chưa được cache. Điều này dẫn đến:

  • Tất cả đều không thấy giá trị trong cache
  • Tất cả đều cố gắng tính toán/tạo giá trị cache cùng lúc
  • Tài nguyên hệ thống bị tiêu tốn một cách lãng phí
  • Chỉ có một giá trị cuối cùng được lưu vào cache

Lưu ý rằng vấn đề trong bài này không hẳn là cache stampede truyền thống khi nhiều client bên ngoài spam một API backend, mà là hiện tượng tương tự xảy ra ngay trong nội bộ ứng dụng với ConcurrentDictionary.

Demo vấn đề

Code demo dưới đây sẽ giúp anh em hiểu rõ vấn đề:

var threadSafeCache = new ConcurrentDictionary<string, string>();
var oneSingleKey = "key";
var taskList = new List<Task>();
for (var i = 0; i < 1000; i++)
{
    // Tạo nhiều task cùng truy cập một key
    var task = Task.Run(() => {
        var cachedResult = threadSafeCache.GetOrAdd(oneSingleKey, _ => {
            Console.WriteLine("CPU Expensive operation");
            // Mô phỏng operation tốn nhiều CPU
            for (int j = 0; j < 2000000000; j++)
            {
                var haha = 2;
                var y = haha;
            }
            return "value";
        });
        Console.WriteLine($"Result is {cachedResult}");
    });
    taskList.Add(task);
}
Task.WaitAll(taskList.ToArray());

Nếu bạn chạy đoạn code trên, sẽ thấy "CPU Expensive operation" được in ra nhiều lần, chứng tỏ factory method bị gọi nhiều lần thay vì một lần như mong đợi.

Cách giải quyết

Giải pháp đơn giản nhưng hiệu quả là sử dụng Lazy<T>. Class này có 2 nhiệm vụ:

  • Khởi tạo giá trị một cách trì hoãn (chỉ khi cần giá trị, ví dụ như bạn có var x = new Lazy<int>(()=>5); thì khi nào bạn muốn lấy giá trị của x thì chỉ cần gọi x.Value lúc đấy hàm lambda trong lazy sẽ được chạy nếu nó chưa chạy bao giờ)
  • Đảm bảo thread-safety cho việc khởi tạo, chỉ thực hiện đúng một lần duy nhất (nói thế này thì cũng không chuẩn lắm tại Lazy có nhiều mode, nhưng mà để bài viết khác vậy)
var threadSafeCache = new ConcurrentDictionary<string, Lazy<string>>();
var oneSingleKey = "key";
var taskList = new List<Task>();
for (var i = 0; i < 1000; i++)
{
    // Tạo nhiều task cùng truy cập một key
    var task = Task.Run(() => {
        var cachedResult = threadSafeCache.GetOrAdd(oneSingleKey, _ => new Lazy<string>(() =>
        {
            Console.WriteLine("Expensive operation");
            // Mô phỏng operation tốn nhiều CPU
            for (int j = 0; j < 2000000000; j++)
            {
                var haha = 2;
                var y = haha;
            }
            return "value";
        })).Value;
        Console.WriteLine($"Result is {cachedResult}");
    });
    taskList.Add(task);
}
Task.WaitAll(taskList.ToArray());

Với cách này:

  • Việc tạo Lazy<T> rất nhẹ, không tốn tài nguyên
  • Factory method chỉ được thực thi khi gọi .Value
  • Nhờ tính chất thread-safe của Lazy<T>, factory method chỉ chạy đúng một lần duy nhất

Lý giải tại sao cách này hoạt động

Khi nhiều thread cùng gọi GetOrAdd, các thread có thể đồng thời tạo nhiều instance Lazy<T>, nhưng chỉ một instance được lưu vào cache. Khi các thread gọi .Value trên instance đã cache, cơ chế thread-safe của Lazy<T> đảm bảo factory method chỉ chạy một lần duy nhất.

Mặc định khi tạo Lazy<T> không có tham số gì thêm, nó sẽ dùng mode ExecutionAndPublication - mode này đảm bảo hàm khởi tạo chỉ được chạy một lần duy nhất dù có bao nhiêu thread cùng truy cập vào .Value. Chính xác là cái chúng ta cần cho trường hợp này!

Nguồn tham khảo

Cảm ơn anh em đã đọc bài viết. Nếu có thắc mắc hay góp ý, hãy để lại comment phía dưới nhé! Tiện tay follow mình trên LinkedIn nha.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí