Xử Lý Các Vấn Đề Không An Toàn Khi Đồng Bộ Hóa Luồng Trong Java

Vấn Đề Cốt Lõi Của Đa Luồng

Khi phát triển ứng dụng sử dụng công nghệ đa luồng, thách thức lớn nhất nằm ở việc quản lý các tác vụ truy cập hoặc thay đổi cùng một đối tượng dữ liệu đồng thời (hay còn gọi là xung đột tài nguyên). Để giải quyết tình trạng này, lập trình viên cần áp dụng cơ chế đồng bộ hóa.

Nguyên lý của đồng bộ hóa luồng hoạt động tương tự như việc xếp hàng: khi nhiều luồng muốn thao tác lên một đối tượng chung, chúng phải lần lượt tham gia vào một hàng đợi chờ. Chỉ khi luồng trước đó hoàn thành nhiệm vụ và trả lại quyền kiểm soát, các luồng tiếp theo mới được phép thực hiện. Cơ chế này dựa trên nền tảng của mô hình khóa (Lock). Một luồng sở hữu khóa độc quyền sẽ chiếm dụng tài nguyên, buộc các luồng khác phải ngưng hoạt động cho đến khi khóa được nhả ra.

Tuy nhiên, việc áp dụng khóa cũng đi kèm với những hệ lụy:

  • Luồng giữ khóa có thể khiến các luồng khác bị treo vô thời hạn nếu không xử lý đúng cách.
  • Hành động khóa và mở khóa thường xuyên gây ra sự chuyển đổi ngữ cảnh (context switching), làm giảm hiệu suất tổng thể của hệ thống.
  • Nếu một luồng ưu tiên thấp nắm giữ khóa nhưng đang bị chặn, luồng ưu tiên cao phải chờ đợi, dẫn đến hiện tượng đảo ngược ưu tiên.

Ba Kịch Bản Điển Hình Về Tính Không An Toàn

Gốc rễ của vấn đề thường nằm ở việc mỗi luồng duy trì một vùng nhớ riêng biệt (work memory). Nếu việc đồng bộ hóa bộ nhớ chủ và bộ nhớ làm việc không được kiểm soát chặt chẽ, dữ liệu cuối cùng sẽ không khớp.

1. Hệ Thống Bán Vé Xe Khách

Tình huống giả định: Một nhà xe có giới hạn số lượng vé. Nhiều cửa hàng đại lý cùng bán vé cho cùng một chuyến đi.

public class VanDeBanVe {
    public static void main(String[] args) {
        // Khởi tạo đối tượng chia sẻ chung
        QuanLyVe khachHang = new QuanLyVe();
        
        // Tạo ba luồng đại diện cho ba điểm bán
        new Thread(khachHang, "Point_A").start();
        new Thread(khachHang, "Point_B").start();
        new Thread(khachHang, "Point_C").start();
    }
}

class QuanLyVe implements Runnable {
    
    // Tổng số vé còn lại ban đầu
    private int veConLai = 5;
    private boolean conDuocChoIn = true;

    @Override
    public void run() {
        while (conDuocChoIn) {
            try {
                thucHienBanVe();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void thucHienBanVe() throws InterruptedException {
        // Kiểm tra điều kiện dừng
        if (veConLai <= 0) {
            conDuocChoIn = false;
            return;
        }
        
        // Giả lập độ trễ khi xử lý giao dịch
        Thread.sleep(150); 
        
        System.out.println(Thread.currentThread().getName() 
                + " vừa xuất vé thứ: " + (veConLai--) + " của chuyến");
    }
}

Kết quả thực thi thường thấy (Không ổn định):

Point_A vừa xuất vé thứ: 5 của chuyến
Point_C vừa xuất vé thứ: 5 của chuyến
Point_B vừa xuất vé thứ: 4 của chuyến
Point_A vừa xuất vé thứ: 3 của chuyến
Point_C vừa xuất vé thứ: 2 của chuyến
Point_B vừa xuất vé thứ: 1 của chuyến
Point_A vừa xuất vé thứ: 0 của chuyến
Point_C vừa xuất vé thứ: -1 của chuyến

Hiện tượng âm số (-1) chứng tỏ hai luồng đã đọc giá trị cũ và ghi đè lẫn nhau trước khi kiểm tra lại điều kiện dừng.

2. Rút Tiền Tại Cây ATM Chung

Tình huống giả định: Hai người dùng (nam và nữ) cùng rút tiền từ một tài khoản liên kết, nhưng không biết thông tin về giao dịch của người kia ngay lập tức.

public class ThietBiRutTien {
    public static void main(String[] args) {
        TaiKhoanTaiNganHan taiKhoan = new TaiKhoanTaiNganHan(100, "TK_GiaDinh");
        
        // Người nam rút 80
        XuLyRutTien nguoiNam = new XuLyRutTien(taiKhoan, 80, "ChuyenGiao_Nam");
        // Người nữ rút 50
        XuLyRutTien nguoiNu = new XuLyRutTien(taiKhoan, 50, "ChuyenGiao_Nu");
        
        nguoiNam.start();
        nguoiNu.start();
    }
}

class TaiKhoanTaiNganHan {
    int tienTuNhieu;
    String maTaiKhoan;

    public TaiKhoanTaiNganHan(int tienTuNhieu, String maTaiKhoan) {
        this.tienTuNhieu = tienTuNhieu;
        this.maTaiKhoan = maTaiKhoan;
    }
}

class XuLyRutTien extends Thread{

    TaiKhoanTaiNganHan taiKhoanChung;
    int luongRutCanThieu;
    int soTienDaLay = 0;

    public XuLyRutTien(TaiKhoanTaiNganHan taiKhoanChung, int luongRutCanThieu, String ten) {
        super(ten);
        this.taiKhoanChung = taiKhoanChung;
        this.luongRutCanThieu = luongRutCanThieu;
    }

    @Override
    public void run() {
        // Kiểm tra số dư (có thể sai lệch do chưa được cập nhật kịp thời)
        if (taiKhoanChung.tienTuNhieu - luongRutCanThieu < 0){
            System.out.println(getName()+ ": Số dư không đủ để thực hiện giao dịch");
            return;
        }
        
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        // Cập nhật số dư
        taiKhoanChung.tienTuNhieu = taiKhoanChung.tienTuNhieu - luongRutCanThieu;
        soTienDaLay += luongRutCanThieu;
        
        System.out.println(taiKhoanChung.maTaiKhoan + "-Số dư thực tế:" + taiKhoanChung.tienTuNhieu);
        System.out.println(getName() +"-Tổng nhận được:"+ soTienDaLay);
    }
}

Kết quả thực thi thường thấy:

TK_GiaDinh-Số dư thực tế:-30
TK_GiaDinh-Số dư thực tế:-30
ChuyenGiao_Nam-Tổng nhận được:80
ChuyenGiao_Nu-Tổng nhận được:50

Số dư ra âm (-30) và cả hai đều cho rằng mình rút thành công, mặc dù tổng số tiền vượt quá số dư ban đầu.

3. Thao Tác Ghi Vào Danh Sách Chung

Tình huống giả định: Hàng nghìn luồng cố gắng thêm dữ liệu vào cùng một mảng động (ArrayList) mà không có cơ chế bảo vệ bên ngoài.

public class XungDotData {
    public static void main(String[] args) {
        // Danh sách lưu trữ chuỗi ký tự
        java.util.List<String> dsBienNhan = new java.util.ArrayList<String>();
        
        // Khởi chạy 1000 luồng đồng thời
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
               dsBienNhan.add("luong_" + Thread.currentThread().getName());
               // Nguy cơ: Hai luồng có thể cùng thao tác chỉ mục nội bộ tại cùng vị trí
               // Dẫn đến việc dữ liệu bị ghi đè hoặc mất mát
            }).start();
        }
        
        // Đợi tất cả luồng hoàn thành (trong thực tế cần CountDownLatch)
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // Kết quả đếm thường nhỏ hơn 1000
        System.out.println(dsBienNhan.size());
    }
}

Kết quả thực thi thường thấy:

989

Mặc dù khởi tạo 1000 luồng để thêm phần tử, nhưng kích thước danh sách thực tế nhỏ hơn, do cơ chế mở rộng dung lượng và cập nhật con trỏ trong ArrayList không đảm bảo tính nguyên tử trong môi trường đa luồng.

Thẻ: Java thread Synchronization RaceCondition multithreading

Đăng vào ngày 29 tháng 5 lúc 06:30