Java synchronized la gi

Chào mừng các bạn đã đến với bài học Java số 45, bài học về Đồng bộ hoá (phần tiếp theo). Đây là bài học trong chuỗi bài về lập trình ngôn ngữ Java của Yellow Code Books.

Như vậy là sau khi kết thúc bài học mở màn về Đồng bộ hóa hôm trước, mình có nói rằng sẽ có hai cách thức để đồng bộ các Thread với nhau. Các cách đồng bộ này đều mang đến một mục tiêu chung là giới hạn các Thread truy cập vào cùng một tài nguyên dùng chung. Và bài học hôm nay mình sẽ trình bày cụ thể cách thức đầu tiên trong hai cách trên đây, cách thức này có cái tên Loại trừ lẫn nhau (Mutual Exclusive). Sau các bài học về đồng bộ này, bạn sẽ biết cách làm thế nào để tránh sự xung đột về tài nguyên hệ thống khi làm việc với Multithread, và cả biết xem khi nào thì nên dùng cách thức đồng bộ nào nữa đấy.

Mời các bạn cùng đến với bài học.

Đồng Bộ Mutual Exclusive Là Gì?

Chắc chắn Đồng bộ Mutual Exclusive là một phương pháp đồng bộ Thread, giúp cho các Thread được đồng bộ sao cho không đồng thời can thiệp vào tài nguyên dùng chung rồi. Vậy thì tại sao lại gọi là Loại trừ lẫn nhau (Mutual Exclusive)? Loại trừ ở đây có nghĩa là Ngăn chặn, nghĩa là hệ thống sẽ chặn lại các Thread cùng gọi đến tài nguyên dùng chung, và chỉ cho phép một Thread được dùng đến tài nguyên này mà thôi. Các Thread bị ngăn chặn đó sẽ phải đợi đến khi chúng bị hết ngăn chặn, mới có thể dùng đến tài nguyên đó. Tóm lại, Loại trừ ở đây hiểu đúng là Ngăn chặn, chứ không là Hủy bỏ Thread đi đâu nhé.

Vậy thì hệ thống sẽ thực hiện việc loại trừ, hay ngăn chặn ấy bằng cách nào? Để dễ hình dung nhất, chúng ta hãy lấy một ví dụ thực tế sau đây (đây là ví dụ dễ hiểu nhất về Mutual Exclusive mà mình thấy nhiều tài liệu dùng đến). Ví dụ trong một hội nghị nọ có nhiều diễn giả cùng ngồi với nhau, họ đều cùng nhau nói về một chủ đề là làm sao để học Java tốt nhất có thể (chủ đề này thì do mình chế). Vấn đề xảy ra giống như với Thread mà chúng ta đang nói đến là, chỉ có một chủ đề thôi, mà mỗi diễn giả đều có một ý kiến, và ai cũng tranh giành để được nêu ý kiến của mình lên. Kết quả là người nghe sẽ nhận được các thông tin hỗn tạp, chẳng ai hiểu được nội dung mà hội nghị mang đến là gì.

Java synchronized la gi
Java synchronized la gi
Ví dụ về các diễn giả để nói lên bài toán đồng bộ

Nếu xem mỗi diễn giả là một Thread, và chủ đề mà họ đang nói đến chính là tài nguyên dùng chung. Thì chính các diễn giả là những nhân tố làm cho cái chủ đề nó trở nên banh chành như vậy. Để giải quyết vấn đề này, bạn nghĩ ra một cơ chế, cơ chế này có cái tên Mutual Exclusive. Ý tưởng của cơ chế chính là làm sao cho các diễn giả phải tự giành lấy quyền được nói của họ, để loại trừ các quyền được nói của diễn giả khác, buộc các diễn giả khác phải lắng nghe cho tới khi diễn giả đang nói ấy xong câu chuyện. Ồ vậy phải làm sao, bạn không thể xen vào chỉ định ai sẽ nói và ai sẽ phải nghe rồi, vì làm vậy sẽ mất công quá. Không, bạn không làm vậy. Bạn cung cấp cho họ một cái microphone. Bum! Vấn đề đã được giải quyết. Với một microphone để ở trên bàn, chính diễn giả nào nhận được microphone về phía mình, diễn giả đó có quyền nói, những người khác lắng nghe cho đến khi diễn giả đang nói ấy nhường lại microphone. Các diễn giả đang lắng nghe đó có quyền đăng ký được nói vào một danh sách, để khi diễn giả kia kết thúc bài nói, người tiếp theo trong danh sách sẽ được quyền sử dụng microphone.

Java synchronized la gi
Java synchronized la gi
Ví dụ về các diễn giả sau khi được đồng bộ việc phát biểu

Ví dụ trên đây đã nói lên rõ phương pháp để thực hiện đồng bộ Mutual Exclusive rồi đấy. Chúng ta chỉ cần xem xét xem khi áp dụng ví dụ trên vào kiến thức về Đồng bộ Thread, thì hệ thống sẽ làm như thế nào, mời bạn cùng đến với mục tiếp theo.

Đồng Bộ Mutual Exclusive Như Thế Nào?

Cái cơ chế mà diễn giả chỉ được phép nói khi có microphone, khi áp dụng vào đồng bộ Thread, người ta gọi nó với một cái tên nữa là Monitor & Lock, hay nhiều tài liệu gọi ngắn là Monitor Lock. Không phải hiểu Monitor là màn hình và Lock là cái ổ khóa đâu nhé. Cơ chế này được hiểu rằng, microphone, hay các tài liệu dùng chung khác, sẽ được một đối tượng được gọi là Monitor, bảo hộ. Với mỗi một diễn giả (hay Thread) muốn sử dụng microphone (hay tài nguyên dùng chung), phải đăng ký qua Monitor để có được một Lock. Mỗi Monitor sẽ chỉ có một Lock. Thread nào lấy được Lock trên Monitor đó, Thread đó được phép sử dụng tài nguyên dùng chung, cho đến khi nào Thread đó kết thúc việc sử dụng tài nguyên và trả lại Lock cho Monitor, Lock này sẽ được chuyển qua cho Thread kế tiếp trong danh sách đợi ở Monitor, để Thread kế tiếp đó có cơ hội sử dụng và Lock tài nguyên đó. Cứ như vậy Lock được truyền nhau cho hết Thread còn đợi trong Monitor.

Cơ chế là như vậy, cũng không quá khó khăn để hiểu đúng không nào. Vậy áp dụng Monitor Lock vào cho code của chúng ta như thế nào, mời các bạn cùng đến với mục kế tiếp.

Từ Khóa synchronized

Chúng ta đang làm quen với một cách thức đồng bộ có tên là Mutual Exclusive. Chúng ta biết rằng cơ chế để hệ thống thực hiện đồng bộ được gọi là Monitor Lock. Và để gọi được hệ thống sử dụng cơ chế này để đồng bộ, thì chúng ta lại phải làm quen với cách sử dụng đến một từ khóa mới trong Java, từ khóa này có tên là synchronized.

Điều này có nghĩa là, khi chúng ta muốn đối tượng nào đó được bảo hộ bởi Monitor, thì hãy đặt vào trong nó từ khóa synchronized. Việc sử dụng từ khóa synchronized bên trong một đối tượng nào đó thì mình sẽ nói ở các mục cụ thể bên dưới. Việc của bạn hiện tại nên hiểu rằng, khi đối tượng nào đó có từ khóa synchronized bên trong, nó sẽ được hệ thống quản lý trong một Monitor. Mỗi đối tượng sẽ có một Monitor quản lý riêng biệt. Và vì vậy, như bạn biết, các Thread muốn sử dụng đến các phương thức synchronized bên trong đối tượng đó, nó phải có Lock. Và khi một Monitor của đối tượng mà nó quản lý trao Lock về Thread nào đó, nó phải đợi Thread đó trao trả Lock lại thì Thread khác mới có thể sử dụng được các phương thức synchronized này. Và như vậy bài toán Đồng bộ hóa của bạn được giải quyết.

Về cơ bản thì cách sử dụng synchronized cũng không khó. Đầu tiên bạn có thể hiểu rằng synchronized có thể được khai báo ở cấp độ phương thức trong lớp, hoặc ở cấp độ  bên trong phương thức.

Chúng ta sẽ tiến hành khảo sát từng loại synchronized ở các mục cụ thể sau.

Dùng synchronized Cho Phương Thức

Khi bạn khai báo một phương thức, nếu muốn đồng bộ hóa trên phương thức này, hãy thêm vào từ khóa synchronized như code minh họa sau.

public synchronized void withdraw() {
    // Nội dung phương thức
    // ...
}

Để dễ hiểu hơn, chúng ta cùng đến với bài thực hành.

Bài Thực Hành Số 1

Ở bài thực hành này chúng ta cùng lấy lại  ở bài trước.

Mình tóm tắt một chút ở ví dụ này. Ở ví dụ hôm trước bạn đã xây dựng một lớp BankAccount. Lớp này chứa hai phương thức checkAccountBalance() và withdraw(), chúng lần lượt là các phương thức kiểm tra số dư và rút tiền từ ngân hàng nếu số dư đó vẫn còn đủ để rút. Sau đó chúng ta khai báo hai Thread là husbandThread và wifeThread rồi cùng tiến hành rút tiền, thì kết quả nhận được ở bài hôm trước như sau.

Java synchronized la gi
Java synchronized la gi
Kết quả của ví dụ rút tiền ở bài trước

Các dòng trên console thể hiện rằng hai Thread muốn rút tiền, và kết quả sau khi rút ở cả 2 Thread nhìn thấy đều là số âm, chứng tỏ việc kiểm tra số dư đã có sai sót, do cả hai lần kiểm tra số dư ở cả hai Thread đều thấy khả dụng, và đều thực hiện việc rút với tổng số tiền vượt quá số dư cho phép.

Như vậy chúng ta cần phải đồng bộ lại các Thread này, cụ thể là can thiệp vào cái tài nguyên dùng chung BankAccount. Làm cho đối tượng của BankAccount được bảo hộ bằng Monitor. Và khi đó các Thread muốn sử dụng đến các phương thức của đối tượng này, chúng phải yêu cầu Lock. Vậy theo như bài học, chúng ta chỉ cần thêm từ khóa synchronized vào BankAccount như sau (mình có thay đổi code chỗ in ra console cho nó rõ nghĩa hơn so với bài hôm trước).

public class BankAccount {
     
    long amount = 20000000; // Số tiền có trong tài khoản 
     
    public synchronized boolean checkAccountBalance(long withDrawAmount) {
        // Giả lập thời gian đọc cơ sở dữ liệu và kiểm tra tiền
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
         
        if (withDrawAmount <= amount) {
            // Cho phép rút tiền
            return true;
        }
         
        // Không cho phép rút tiền
        return false;
    }
     
    public synchronized void withdraw(String threadName, long withdrawAmount) {
        // In thông tin người rút
        System.out.println(threadName + " check: " + withdrawAmount);
         
        if (checkAccountBalance(withdrawAmount)) {
            // Giả lập thời gian rút tiền và 
            // cập nhật số tiền còn lại vào cơ sở dữ liệu
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
             
            amount -= withdrawAmount;
            System.out.println(threadName + " withdraw successful: " + withdrawAmount);
        } else {
            System.out.println(threadName + " withdraw error!");
        }
         
        // In ra số dư tài khoản
        System.out.println(threadName + " see balance: " + amount);
    }
}

Khi thực thi lên, bạn hãy so sánh kết quả.

Java synchronized la gi
Java synchronized la gi
Kết quả sau khi đồng bộ với synchronized cho phương thức

Bạn xem, kết quả của việc đồng bộ này là, husbandThread yêu cầu rút tiền trước, nó sẽ được cấp phát Lock trước, cho đến khi husbandThread kết thúc việc rút tiền, thì wifeThread mới bắt đầu được thực hiện và không hề bị tranh chấp gì cả.

Dùng synchronized Cho Khối Lệnh Bên Trong Phương Thức

Đến đây thì bạn đã hiểu phần nào cách thức hoạt động của từ khóa synchronized rồi đúng không nào. Mình nói rõ hơn một tí là, khi bạn đặt từ khóa synchronized vào một hoặc nhiều phương thức bên trong lớp. Thì đối tượng của lớp đó sẽ được Monitor quản lý, một khi có một Thread đăng ký sử dụng đến một trong các phương thức có từ khóa synchronized, Monitor đó cấp Lock cho Thread đó cho đến khi nó hoàn thành xong các phương thức đó.

Vậy có những lúc bạn không cần phải xin Lock cho toàn bộ phương thức. Nếu bạn chỉ cần một phần trong phương thức đó được bảo hộ bởi Monitor thôi. Thì hãy áp dụng cách thức synchronized cho khối lệnh của mục này.

Bạn có thể tham khảo cú pháp của việc synchronized đến khối lệnh bên trong phương thức như sau.

synchronized (đối_tượng) {
     // Nội dung của khối lệnh
}

Cú pháp trên không quá khó, bạn chỉ cần quan tâm đến tham số đối_tượng truyền vào cho khối synchronized thôi. Tham số này báo cho hệ thống biết đối tượng nào cần được Monitor của nó quản lý sự đồng bộ mà thôi. Để dễ hiểu hơn mời bạn đến với bài thực hành.

Bài Thực Hành Số 2

Chúng ta sẽ lấy lại code của lớp BankAccount ở bài thực hành số 1 trên kia. Nhưng khi này chúng ta chỉ cần hệ thống đồng bộ một khối lệnh được tô sáng sau. Bạn thấy ngoài việc bao khối synchronized này vào các dòng code quen thuộc, và bỏ các synchronizedkhỏi các phương thức như ở bài thực hành số 1 ra, thì mọi thứ không thay đổi nhé.

public class BankAccount {
     
    long amount = 20000000; // Số tiền có trong tài khoản 
     
    public boolean checkAccountBalance(long withDrawAmount) {
        // Giả lập thời gian đọc cơ sở dữ liệu và kiểm tra tiền
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
         
        if (withDrawAmount <= amount) {
            // Cho phép rút tiền
            return true;
        }
         
        // Không cho phép rút tiền
        return false;
    }
     
    public void withdraw(String threadName, long withdrawAmount) {
        // In thông tin người rút
        System.out.println(threadName + " check: " + withdrawAmount);
         
        synchronized (this) {
            if (checkAccountBalance(withdrawAmount)) {
                // Giả lập thời gian rút tiền và 
                // cập nhật số tiền còn lại vào cơ sở dữ liệu
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                 
                amount -= withdrawAmount;
                System.out.println(threadName + " withdraw successful: " + withdrawAmount);
            } else {
                System.out.println(threadName + " withdraw error!");
            }
        }
         
        // In ra số dư tài khoản
        System.out.println(threadName + " see balance: " + amount);
    }
}

Với code trên thì mình chỉ đồng bộ một khối lệnh nhỏ. Các dòng in ra màn hình đều nằm ngoài sự đồng bộ này, và khi thực thi ứng dụng, kết quả có phần hơi khác tí xíu. Tuy nhiên ứng dụng vẫn chạy đúng.

Như đã nói ở cú pháp trên kia, việc truyền this vào khối synchronized là báo cho Monitorthực hiện bảo hộ trên đối tượng này, nhưng chỉ bảo hộ trong khối lệnh mà thôi.

Java synchronized la gi
Java synchronized la gi
Kết quả sau khi đồng bộ với synchronized cho khối lệnh

Nếu như với bài thực hành số 1 thì do sự đồng bộ là trên cả 2 phương thức kiểm tra và rút tiền, nên khi người chồng vào trước, hệ thống sẽ kiểm tra thấy người chồng hoàn thành hết mọi thao tác thì mới phục vụ cho người vợ. Như vậy, với hệ thống của bài thực hành số 1 thì khi người vợ vào sử dụng hệ thống, sẽ phải đợi lâu mới thấy hệ thống phản hồi, do còn phải đợi người chồng xong việc. Còn với bài thực hành này, bạn thấy rằng hệ thống sẽ đáp ứng ngay cho cả hai (in số dư khả dụng ra màn hình) vì chưa vào đến các dòng code đồng bộ. Đến khi người chồng chính thức vào phương thức kiểm tra tiền, hệ thống mới thực hiện Mutual Exclusive đến người vợ. Và như những gì bạn đã thấy trên console trên kia.