CodeGym /Các khóa học /JAVA 25 SELF /Tài nguyên dùng chung và đồng bộ: vấn đề truy cập

Tài nguyên dùng chung và đồng bộ: vấn đề truy cập

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

1. Tài nguyên dùng chung là gì

Bạn đã từng gặp các tài nguyên dùng chung. Ngôi nhà nơi một gia đình sống — là tài nguyên dùng chung của gia đình đó. Tủ lạnh văn phòng — là tài nguyên dùng chung cho tất cả đồng nghiệp. Ý tưởng là vậy.

Trong lập trình, tài nguyên dùng chung là biến, đối tượng hoặc cấu trúc dữ liệu mà nhiều luồng có thể truy cập đồng thời. Có thể là:

  • Biến đếm số lượng đơn hàng đã xử lý.
  • Danh sách yêu cầu, được một số luồng thêm vào và luồng khác xử lý.
  • Tệp đang mở, nơi nhiều luồng cùng ghi.
  • Kết nối cơ sở dữ liệu mà các phần khác nhau của chương trình cùng sử dụng.

Trong Java, bất kỳ đối tượng hoặc biến nào có thể được nhiều luồng truy cập đều trở thành “tài nguyên dùng chung” tiềm tàng.

Ví dụ về tài nguyên dùng chung: bộ đếm toàn cục

public class Counter {
    public int value = 0;
}

Nếu nhiều luồng cùng tăng bộ đếm này, chúng sẽ truy cập cùng một biến value — đó chính là tài nguyên dùng chung.

2. Vấn đề của truy cập đồng thời

Trong chương trình đơn luồng, mọi thứ khá đơn giản: một luồng — một tác nhân thực thi — nó đi theo mã như tàu chạy trên đường ray. Nhưng một khi có nhiều luồng tham gia, bắt đầu “điệu múa với gươm” thực thụ: các luồng có thể can thiệp vào nhau ở những chỗ không ngờ tới.

Race condition (điều kiện tranh chấp)

Race condition là tình huống khi kết quả chương trình phụ thuộc vào cách các hành động của luồng “đan xen” nhau. Tức là, nếu bạn chạy cùng một chương trình nhiều lần, kết quả có thể khác nhau — đó không hẳn là bug; đó là “đặc tính” của lập trình đa luồng.

Ví dụ kinh điển: hai luồng tăng bộ đếm

Hãy mô phỏng một tình huống đơn giản: chúng ta có một bộ đếm dùng chung, và hai luồng tăng giá trị của nó một nghìn lần.

public class Counter {
    public int value = 0;
}

public class CounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable incrementTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.value++; // VỊ TRÍ NGUY HIỂM!
            }
        };

        Thread t1 = new Thread(incrementTask);
        Thread t2 = new Thread(incrementTask);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Giá trị mong đợi: 2000");
        System.out.println("Giá trị thực tế: " + counter.value);
    }
}

Sẽ thấy gì trên màn hình?

Đôi khi — 2000, đôi khi — 1985, có khi — 1937... Vì sao? Bởi vì phép toán counter.value++ không nguyên tử! Nó gồm ba bước:

  1. Đọc giá trị hiện tại của counter.value.
  2. Tăng nó lên 1.
  3. Ghi lại.

Nếu hai luồng đồng thời đọc cùng một giá trị, cả hai đều tăng nó, rồi cả hai cùng ghi kết quả — cuối cùng một lần tăng sẽ “bị mất”. Đây chính là lost update — mất cập nhật.

Trạng thái không nhất quán của đối tượng

Nếu bạn có một đối tượng phức tạp gồm nhiều trường, và các luồng đồng thời thay đổi các trường khác nhau, đối tượng có thể rơi vào trạng thái “lạ” hoặc không nhất quán. Ví dụ, số dư tài khoản đã giảm nhưng lịch sử giao dịch chưa được cập nhật — khách hàng hoang mang, kế toán sốc.

3. Tại sao cần đồng bộ

Đồng bộ là cách nói với chương trình: “Dừng lại, đoạn mã này chỉ được phép thực thi bởi một luồng tại một thời điểm! Những luồng khác — hãy chờ!” Giống như tấm biển “Đang dọn vệ sinh! Không vào!” trên cửa nhà vệ sinh: khi một người đang ở bên trong, những người khác sẽ chờ bên ngoài (và thầm trách người ở trong quá lâu).

Bảo đảm tính toàn vẹn dữ liệu

Nếu chúng ta muốn bộ đếm luôn tăng đúng, cần cấm việc thay đổi giá trị của nó đồng thời bởi nhiều luồng.

Ví dụ: đồng bộ việc tăng bộ đếm

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

Giờ đây, nếu hai luồng gọi increment(), chỉ một luồng có thể thực thi phương thức này tại một thời điểm. Luồng thứ hai sẽ chờ tới khi luồng thứ nhất hoàn thành.

Sơ đồ: điều gì xảy ra khi đồng bộ

+-------------------+
|  Luồng 1          |    --\
+-------------------+      \
      |                      \
      V                       \
+-------------------+          > [ synchronized increment() ]
|  Luồng 2          |    --/  /
+-------------------+      / /
      |                    / /
      V                   / /
+-------------------+    / /
|  Luồng 3          | --/ /
+-------------------+    /
      |                 /
      V                /
+-------------------+ /
|  Luồng N          |/
+-------------------+

Tất cả các luồng xếp hàng để thực thi đoạn mã được bảo vệ. Chỉ một luồng có thể ở trong “vùng tới hạn” (khối synchronized) tại một thời điểm.

4. Giới thiệu nhanh các cách đồng bộ

Đồng bộ trong Java không chỉ có một cách, mà là cả một “kho vũ khí” công cụ cho phép bảo vệ tài nguyên dùng chung khỏi truy cập đồng thời.

Từ khóa synchronized

Đây là công cụ đồng bộ cơ bản trong Java. Có thể dùng theo hai cách:

Phương thức được đồng bộ

public synchronized void increment() {
    value++;
}

Khối đồng bộ

public void increment() {
    synchronized (this) {
        value++;
    }
}

Ở đây this là đối tượng dùng để khóa. Khi một luồng đang thực thi khối này, các luồng khác muốn vào khối tương tự với cùng đối tượng sẽ phải chờ.

Các lớp chuyên dụng từ java.util.concurrent

  • Lock, ReentrantLock — linh hoạt hơn so với synchronized.
  • ReadWriteLock — tách khóa đọc và khóa ghi.
  • Semaphore — giới hạn số luồng đồng thời thực thi mã.
  • CountDownLatch, CyclicBarrier và các lớp khác — để điều phối hoạt động giữa các luồng.

Lưu ý: Hôm nay chúng ta chỉ làm quen với những điều cơ bản — các lớp này sẽ bàn sau.

5. Ví dụ thực tế: ứng dụng với bộ đếm đa luồng

Giả sử chúng ta ghi thống kê lượt truy cập của người dùng vào một dịch vụ nào đó. Mỗi luồng — là một người dùng, người đó sẽ tăng bộ đếm chung.

Không đồng bộ

public class Counter {
    public int value = 0;
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.value++;
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("Giá trị mong đợi: 30000");
        System.out.println("Giá trị thực tế: " + counter.value);
    }
}

Kết quả: Hầu như luôn nhỏ hơn 30000. Đôi khi — nhỏ hơn rất nhiều! Vì sao? Vì các luồng “đè” lên nhau.

Đồng bộ: sửa lỗi

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("Giá trị mong đợi: 30000");
        System.out.println("Giá trị thực tế: " + counter.value);
    }
}

Kết quả: Luôn luôn 30000. Tuyệt, đồng bộ hoạt động!

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

Trực quan hóa: race condition trông thế nào

Hãy vẽ một bảng nhỏ để cho thấy hai luồng có thể “làm mất” một lần tăng như thế nào:

Bước Luồng 1 Luồng 2 Giá trị value
1 Đọc value=0 0
2 Đọc value=0 0
3 Tăng lên 1 0
4 Tăng lên 1 0
5 Ghi 1 1
6 Ghi 1 1

Khi nào cần đồng bộ

Không phải lúc nào cũng cần đồng bộ. Khi một biến “sống” trong thế giới riêng và chỉ một luồng làm việc với nó — bạn có thể yên tâm. Nhưng chỉ cần chia sẻ nó với các luồng khác, thì không thể thiếu đồng bộ. Dù có vẻ như mọi thứ sẽ ổn — đừng tin. Lỗi race condition rất xảo quyệt: nó có thể ẩn mình lâu, rồi bùng phát vào lúc không ngờ nhất.

Mở rộng: còn những công cụ đồng bộ nào nữa

Hôm nay chúng ta mới làm quen với công cụ cơ bản — synchronized. Ở các bài sau, chúng ta sẽ xem:

  • Cách monitor của đối tượng hoạt động và các loại khóa.
  • Phương thức đồng bộ tĩnh (static + synchronized).
  • Từ khóa volatile hoạt động ra sao và dùng để làm gì.
  • Các lớp hiện đại để đồng bộ (Lock, Semaphore, v.v.).

7. Các lỗi thường gặp khi làm việc với tài nguyên dùng chung

Lỗi số 1: Bỏ qua tính đa luồng.
Một trong những lỗi phổ biến nhất là không nghĩ tới việc biến có thể được truy cập từ nhiều luồng. Dù hiện tại chương trình là đơn luồng, sau này ai đó có thể thêm luồng — và bug sẽ “xuất hiện từ hư không”.

Lỗi số 2: Đồng bộ thiếu hoặc thừa.
Nếu không đồng bộ truy cập vào tài nguyên dùng chung — bạn sẽ gặp race condition và dữ liệu không nhất quán. Nếu đồng bộ tất cả mọi thứ, chương trình sẽ “nghẹt thở” vì khóa và trở nên chậm chạp. Hãy luôn cố gắng chỉ đồng bộ những gì thực sự cần.

Lỗi số 3: Đồng bộ trên đối tượng sai.
Nếu đồng bộ trên các đối tượng khác nhau (ví dụ, biến cục bộ hoặc literal chuỗi), điều đó sẽ không bảo vệ tài nguyên dùng chung. Tất cả các luồng phải đồng bộ trên cùng một đối tượng.

Lỗi số 4: Kỳ vọng tính nguyên tử từ các phép không nguyên tử.
Phép toán i++ không nguyên tử! Ngay cả khi biến được khai báo là volatile, điều đó không làm phép tăng trở nên nguyên tử. Với những phép như vậy, cần có đồng bộ.

Lỗi số 5: “May mắn là máy tôi chạy được”.
Race condition có thể không xuất hiện trên máy của bạn, nhưng chắc chắn sẽ xuất hiện trên máy chủ hoặc của người dùng. Đừng bao giờ trông chờ vào “may rủi” trong các chương trình đa luồng!

1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Gỡ lỗi dây chuyền sản xuất 🏭
Gỡ lỗi dây chuyền sản xuất 🏭
1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Điểm trong trò chơi đội 🎮
Điểm trong trò chơi đội 🎮
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION