Trong bài viết này chúng ta sẽ cùng tìm hiểu việc sử dụng ngôn ngữ lập trình Java trong đồng bộ hóa, tôi sẽ giúp bạn nắm bắt các khái niệm và kinh nghiệm thực tế về đồng bộ hóa trong việc lập trình đa luồng. Ở phần đầu tiên này, hãy xem nhiều luồng đang cập nhật cùng một dữ liệu có thể gây ra các sự cố như thế nào?

Trong một ứng dụng đa luồng, một số luồng có thể truy cập đồng thời vào cùng một dữ liệu, điều này có thể khiến dữ liệu ở trạng thái không nhất quán (bị hỏng hoặc không chính xác). Hãy cùng tìm hiểu xem truy cập đa luồng có thể là một nguyên nhân gây ra vấn đề như thế nào bằng cách xem qua một ví dụ minh họa việc xử lý các giao dịch trong ngân hàng.

Giả sử rằng chúng ta có một lớp đại diện cho một tài khoản trong ngân hàng như sau:

/**
 * @author anhdt
 * Account.java
 * This class represents an account in the bank.
 */
public class Account {

    private int balance = 0;

    public Account(int balance) {
        this.balance = balance;
    }

    public void withdraw(int amount) {
        this.balance -= amount;
    }

    public void deposit(int amount) {
        this.balance += amount;
    }

    public int getBalance() {
        return this.balance;
    }
}

Số dư của tài khoản có thể thay đổi thường xuyên do các giao dịch gửi và rút tiền.

Đoạn mã sau đại diện cho một ngân hàng quản lý một số tài khoản:

/**
 * Bank.java
 * @author anhdt
 * This class represents a bank that manages accounts and provides money transfer function.
 */
public class Bank {
    public static final int MAX_ACCOUNT = 10;
    public static final int MAX_AMOUNT = 10;
    public static final int INITIAL_BALANCE = 100;

    private Account[] accounts = new Account[MAX_ACCOUNT];

    public Bank() {
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = new Account(INITIAL_BALANCE);
        }
    }

    public void transfer(int from, int to, int amount) {
        if (amount <= accounts[from].getBalance()) {
            accounts[from].withdraw(amount);
            accounts[to].deposit(amount);

            String message = "%s transfered %d from %s to %s. Total balance: %d\n";
            String threadName = Thread.currentThread().getName();
            System.out.printf(message, threadName, amount, from, to, getTotalBalance());
        }
    }

    public int getTotalBalance() {
        int total = 0;

        for (int i = 0; i < accounts.length; i++) {
            total += accounts[i].getBalance();
        }

        return total;
    }
}

Như bạn thấy, ngân hàng này bao gồm 10 tài khoản, mỗi tài khoản được khởi tạo với số dư là 100. Vậy tổng số dư của 10 tài khoản này là 10 x 100 = 1000.

Phương thức transfer() rút một số tiền cụ thể từ một tài khoản và gửi số tiền đó tới tài khoản đích. Việc chuyển sẽ được xử lý khi và chỉ khi tài khoản nguồn có đủ số dư. Và sau khi chuyển khoản xong, một thông báo nhật ký sẽ được in ra để cho chúng ta biết chi tiết giao dịch.

Phương thức getTotalBalance() phương thức trả về tổng số tiền của tất cả các tài khoản, phải luôn là 1000. Chúng ta kiểm tra số này sau mỗi giao dịch để đảm bảo rằng chương trình chạy chính xác.

Vì ngân hàng cho phép nhiều giao dịch xảy ra cùng một lúc, nên lớp sau đại diện cho một giao dịch:

/**
 * @author anhdt
 * This class represents a transaction task that can be executed by a thread.
 */
public class Transaction implements Runnable {
    private Bank bank;
    private int fromAccount;

    public Transaction(Bank bank, int fromAccount) {
        this.bank = bank;


        this.fromAccount = fromAccount;
    }

    public void run() {

        while (true) {
            int toAccount = (int) (Math.random() * Bank.MAX_ACCOUNT);

            if (toAccount == fromAccount) continue;

            int amount = (int) (Math.random() * Bank.MAX_AMOUNT);

            if (amount == 0) continue;

            bank.transfer(fromAccount, toAccount, amount);

            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Như bạn có thể thấy, lớp Transaction này sử dụng Runnable interface để đoạn code trong phương thức run() của nó có thể được thực thi bởi một luồng riêng biệt.

Tài khoản nguồn được truyền từ phương thức khởi tạo và tài khoản đích được chọn ngẫu nhiên và cả hai tài khoản không được giống nhau. Ngoài ra, số tiền được chuyển được chọn ngẫu nhiên nhưng luôn nhỏ hơn 10. Sau khi giao dịch được thực hiện, luồng hiện tại chuyển sang trạng thái "ngủ" trong một thời gian rất ngắn (50 mili giây), và sau đó nó tiếp tục thực hiện các bước tương tự lặp lại cho đến khi luồng bị chấm dứt.

Và đây là chương trình thử nghiệm:

/**
 * @author anhdt
 * This is a test program that creates threads to process many transactions concurrently.
 */
public class TransactionTest {
    public static void main(String[] args) {
        Bank bank = new Bank();

        for (int i = 0; i < Bank.MAX_ACCOUNT; i++) {
            Thread t = new Thread(new Transaction(bank, i));
            t.start();
        }
    }
}

Như bạn có thể thấy, một "thể hiện" của lớp Bank được tạo và chia sẻ giữa các luồng thực hiện các giao dịch. Đối với mỗi tài khoản, một luồng mới được tạo để chuyển tiền từ tài khoản đó sang các tài khoản được chọn ngẫu nhiên khác. Điều đó có nghĩa là có tổng cộng 10 luồng chia sẻ một "phiên bản" của lớp Bank. Các luồng này sẽ chạy mãi mãi cho đến khi kết thúc chương trình bằng cách nhấn (Ctrl + C).

Hãy nhớ quy tắc này: Cho dù có bao nhiêu giao dịch được xử lý, tổng số dư của tất cả các tài khoản phải không thay đổi. Nói cách khác, chương trình phải báo cáo con số này một cách nhất quán là 1000.

Bây giờ, hãy biên dịch và chạy chương trình TransactionTest và quan sát kết quả đầu ra. Ban đầu, bạn sẽ thấy một số đầu ra như thế này:

out_put

Tổng số dư được báo cáo là 1000 một cách nhất quán. Nhưng!!! Để chương trình tiếp tục chạy lâu hơn, bạn sẽ nhanh chóng thấy sự cố xảy ra: Oái oăm! Bằng cách nào đó, tổng số dư đang được thay đổi. Nó không còn ở mức 1000 nữa. Nó ngày càng nhỏ dần theo thời gian. Tại sao điều này xảy ra? Phải có điều gì đó sai trong chương trình. Hãy phân tích mã để tìm hiểu lý do.

Nhìn vào lớp Transaction, bạn sẽ thấy nhiều luồng thực thi phương thức transfer() của "thực thể dùng chung" của lớp Bank:

bank.transfer(fromAccount, toAccount, amount);

Phương thức này được thực hiện như sau:

public void transfer(int from, int to, int amount) {
        if (amount <= accounts[from].getBalance()) {
            accounts[from].withdraw(amount);
            accounts[to].deposit(amount);

            String message = "%s transfered %d from %s to %s. Total balance: %d\n";
            String threadName = Thread.currentThread().getName();
            System.out.printf(message, threadName, amount, from, to, getTotalBalance());
        }
    }

Giả sử rằng tài khoản số #1 có số dư là 5 sau một số giao dịch. Luồng #1 đang thực hiện câu lệnh if để xác minh rằng tài khoản có đủ tiền để chuyển và số tiền là 3. Vì số dư của tài khoản là 5, luồng số #1 đi vào phần thân của khối if.

Nhưng ngay trước khi luồng số #1 thực hiện câu lệnh rút tiền:

accounts[from].withdraw(amount);

Một luồng khác (ví dụ luồng số #2) đã thực hiện một giao dịch rút một số tiền là 4 từ tài khoản #1. Lúc này luồng số #1 thực hiện thao tác rút tiền và lúc này số dư là 5 - 4 = 1, không còn được xem là số 5 bởi luồng số #1 nữa. Do đó số dư của tài khoản số 1 bây giờ là 1 - 3 = -2. Số dư là số âm, vì vậy đó là lý do tại sao khi chương trình tính toán lại tổng số dư, nó sẽ bị giảm xuống!

Nếu bạn tiếp tục chạy chương trình ngày càng lâu hơn, bạn sẽ thấy tổng số dư có thể ngày càng nhỏ hơn:

out_put_02

Điều đó có nghĩa là dữ liệu được chia sẻ có thể bị hỏng khi nó được cập nhật bởi nhiều luồng đồng thời.

Một vấn đề tương tự có thể xảy ra với hoạt động gửi tiền. Giả sử rằng luồng số #3 sắp cộng một số tiền là 8 vào tài khoản #3. Trước khi thêm, luồng số #3 thấy số dư của tài khoản này là 10. Nhưng ngay trước khi luồng số #3 cập nhật số dư, một luồng khác (ví dụ luồng số 4) thực hiện rút số tiền là 5 trên tài khoản này, vì vậy số dư của nó là 10 - 5 = 5.

Trong thời gian bình thường, luồng số #3 vẫn thấy số dư là 10 nên nó cộng 8 vào 10, kết quả là số dư của tài khoản số 3 là 18. Nhưng số tiền 5 đã được thêm vào tài khoản khác, nghĩa là tổng số được tăng thêm 5. Đó là lý do tại sao bạn cũng có thể thấy rằng tổng số dư được tăng lên theo thời gian khi chương trình tiếp tục chạy, như thể hiện trong ảnh chụp màn hình bên dưới:

out_put_03

Hãy chạy chương trình thử nghiệm vài lần và tự quan sát kết quả đầu ra. Kết quả đầu ra không dự đoán được trước: đôi khi bạn thấy tổng số dư tăng lên, đôi khi nó giảm xuống, và đôi khi nó tăng lên và đi xuống, bất cứ điều gì!

Cũng cố thử thay đổi "sleep time" trong lớp Transaction. Thời gian dài hơn, tổng số dư thay đổi chậm hơn. Và thời gian ngắn hơn, tổng số dư sẽ thay đổi nhanh hơn.

Vậy chúng ta phải làm gì để khắc phục sự cố này?

Chúng ta cần một cơ chế có thể đảm bảo rằng mã trong phương thức transfer() chỉ được thực thi bởi một luồng tại một thời điểm (thực thi tuần tự từng luồng một - xong hết một luồng thì luồng tiếp mới được thực hiện). Nói cách khác, chúng ta cần đồng bộ hóa quyền truy cập vào dữ liệu được chia sẻ.

Bây giờ bạn đã hiểu loại sự cố nào có thể xảy ra với mã không đồng bộ. Tôi sẽ chỉ cho bạn giải pháp đầu tiên trong phần tiếp theo.

Tham khảo tại đây.