0

Go Zero to Hero - Bài 60: Tối ưu đọc/ghi với sync.RWMutex

1. Bản chất của sync.RWMutex (Reader/Writer Mutex)

sync.RWMutex là phiên bản nâng cấp thông minh hơn của sync.Mutex. Nó chia ổ khóa thành 2 chìa khóa độc lập dựa trên hành vi của lập trình viên: Đọc và Ghi.

Luật lệ của RWMutex cực kỳ rạch ròi và công bằng:

  • Đọc chia sẻ (Shared Lock - RLock): Khi một luồng chỉ muốn đọc dữ liệu, nó xin khóa đọc (mu.RLock()). Nhiều luồng khác hoàn toàn có thể cùng lúc xin khóa đọc và chạy song song với nhau mà không bị ai cản trở.
  • Ghi độc quyền (Exclusive Lock - Lock): Khi một luồng muốn thay đổi dữ liệu, nó xin khóa ghi (mu.Lock()). Lúc này, luồng ghi sẽ chặn đứng tất cả mọi người (cả các luồng đọc khác lẫn các luồng ghi khác) cho đến khi nó thực hiện xong và mở khóa.

Tóm gọn quy tắc chiến trường:

  • Nhiều Đọc cùng lúc? Được phép (Song song).
  • Có Đọc, có Ghi? Chặn đứng (Tuần tự).
  • Nhiều Ghi cùng lúc? Chặn đứng (Tuần tự).

2. Cặp bài trùng thao tác

Đi kèm với sync.RWMutex là 2 bộ phương thức tương ứng:

Hành vi Phương thức khóa Phương thức mở khóa Tính chất
Đọc (Read) mu.RLock() mu.RUnlock() Cho phép nhiều luồng cùng thực thi.
Ghi (Write) mu.Lock() mu.Unlock() Độc quyền hoàn toàn, chặn tất cả.

3. Thực chiến: Xây dựng hệ thống Cache Cấu hình trạm AFC

Hãy áp dụng sync.RWMutex vào bài toán thực tế: Quản lý cấu hình hệ thống máy bán vé (TVM). Cấu hình này được đọc liên tục hàng nghìn lần mỗi giây bởi các luồng xử lý vé, nhưng thỉnh thoảng sẽ được cập nhật ngầm bởi một Worker định kỳ.

package main

import (
    "fmt"
    "sync"
    "time"
)

// Struct quản lý cấu hình trạm AFC
type StationConfig struct {
    mu          sync.RWMutex
    configs     map[string]string
}

// 1. HÀM ĐỌC (Reader): Dùng RLock() để hàng ngàn luồng đọc chạy song song
func (s *StationConfig) GetConfig(key string) string {
    s.mu.RLock()
    defer s.mu.RUnlock() // Luật bất thành văn: Luôn dùng defer để mở khóa
    
    return s.configs[key]
}

// 2. HÀM GHI (Writer): Dùng Lock() độc quyền khi cần cập nhật dữ liệu
func (s *StationConfig) SetConfig(key, value string) {
    s.mu.Lock()
    defer s.mu.Unlock() // Khóa độc quyền, không ai được đọc hoặc ghi lúc này
    
    s.configs[key] = value
    fmt.Printf("🔄 [Admin] Đã cập nhật cấu hình: %s = %s\n", key, value)
}

func main() {
    // Khởi tạo cache ban đầu
    cache := &StationConfig{
        configs: map[string]string{
            "max_ticket_price": "15000",
            "station_status":   "ACTIVE",
        },
    }

    var wg sync.WaitGroup

    // --- MÔ PHỎNG 10 LUỒNG ĐỌC LIÊN TỤC (Reader Goroutines) ---
    for i := 1; i <= 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := 0; j < 3; j++ {
                price := cache.GetConfig("max_ticket_price")
                fmt.Printf("🔍 [Worker %d] Đọc giá vé: %s\n", id, price)
                time.Sleep(100 * time.Millisecond)
            }
        }(i)
    }

    // --- MÔ PHỎNG 1 LUỒNG GHI NGẦM (Writer Goroutine) ---
    wg.Add(1)
    go func() {
        defer wg.Done()
        time.Sleep(200 * time.Millisecond) // Đợi một chút rồi thay đổi cấu hình
        cache.SetConfig("max_ticket_price", "20000")
    }()

    wg.Wait()
    fmt.Println("✅ Hoàn tất kiểm thử mô phỏng Cache chịu tải cao!")
}

Khi chạy đoạn code trên, bạn sẽ thấy các [Worker] đọc dữ liệu diễn ra đồng thời cực kỳ mượt mà. Ngay khi luồng [Admin] gọi hàm SetConfig, nó sẽ tạm ngắt các luồng đọc, cập nhật giá trị mới, rồi lại nhường chỗ cho hàng loạt luồng đọc tiếp tục chạy.


4. Cái bẫy "Đói khát" (Writer Starvation) của RWMutex

Mặc dù sync.RWMutex tăng hiệu năng đọc lên gấp nhiều lần, nó lại ẩn chứa một hiểm họa trong các hệ thống cực kỳ nặng về đọc (Heavy Read-heavy): Writer Starvation (Sự chết đói của luồng ghi).

  • Kịch bản thảm họa: Nếu hệ thống của bạn có dòng lũ Reader liên tục đổ vào không ngừng nghỉ (luồng này vừa nhả RUnlock thì lập tức có 3 luồng khác nhảy vào RLock), luồng Writer sẽ không bao giờ có cơ hội nhận được khóa Lock() độc quyền. Nó sẽ bị kẹt vĩnh viễn ở trạng thái chờ đợi, dẫn đến việc cấu hình mới không bao giờ được cập nhật lên hệ thống!
  • Giải pháp thực chiến:
    • Nếu tỷ lệ Đọc/Ghi là 50/50, đừng bao giờ dùng RWMutex, hãy dùng sync.Mutex truyền thống cho đơn giản và công bằng.
    • Chỉ dùng sync.RWMutex khi tỷ lệ Đọc chiếm áp đảo (từ 90% trở lên, ví dụ như Cache, Lookup Table, System Config).
    • Go Runtime hiện đại đã tối ưu hóa RWMutex theo hướng ưu tiên Writer phần nào để tránh việc Writer bị đói hoàn toàn, nhưng bạn vẫn phải cực kỳ cẩn trọng khi thiết kế kiến trúc.

💡 Tổng kết

Tóm gọn cẩm nang tối ưu hóa với sync.RWMutex:

  • Phân quyền rõ ràng: Dùng RLock/RUnlock cho các hàm chỉ đọc dữ liệu (Get), và dùng Lock/Unlock cho các hàm thay đổi dữ liệu (Set, Update).
  • Sức mạnh song song: Cho phép hàng vạn luồng đọc chạy đồng thời, tiêu diệt hoàn toàn nút cổ chai của sync.Mutex.
  • Điều kiện sử dụng: Chỉ áp dụng khi hệ thống có tỷ lệ Đọc cực kỳ lớn so với Ghi. Nếu ghi liên tục, RWMutex phản tác dụng và gây tốn chi phí quản lý khóa của CPU.

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í