Double Lock Checking trong những ngôn ngữ lập trình song song

867
13-03-2018
Double Lock Checking trong những ngôn ngữ lập trình song song

Theo Bizfly Cloud chia sẻ nếu bạn đã từng lập trình với những ngôn ngữ có khả năng tiến hành song song sử dụng thread (hay là gần đây là fiber), chắc các bạn sẽ thấy việc kiểm soát mọi thứ là vô cùng khó khăn, đặc biệt là khi nhiều thread của bạn phải dùng chung một tài nguyên, bạn phải sắp xếp việc sử dụng của chúng sao cho hợp lý.

Mình có một ví dụ như sau, bạn có một class là User, class này sẽ access vào database để lấy ra rất nhiều thông tin. Tuy nhiên những thông tin này chỉ cần lấy ra 1 lần, và sẽ không thay đổi trong suốt quá trình sử dụng, ví dụ như thông tin về số lượng tiền lớn nhất User có thể mang được chẳng hạn. Nếu mỗi lần sử dụng classUser bạn lại phải "chui" vào database và lấy ra tất cả mọi thứ thì sẽ tốn rất nhiều cost. Vậy bạn nghĩ ra cách nào để giảm bớt cost đó, chắc bạn đã nghĩ ra rồi nên mình nói luôn, đó chính là bạn sẽ cache thông tin đó lại! Đúng là cache phải không.

object User {

lazy val cache = new ConcurrentHashMap[Information, Value]();

def accessUserBy(key: Information): Value = {

if (cache == null) {

initialize(cache); // very very costly!!!

} else {

return cache.get(information)

}

}

}

Rất đơn giản, khi nào Cache chưa được khởi tạo thì bạn sẽ khởi tạo cache, nếu không thì trả về thông tin nằm bên trong Cache.

Chú ý ở đoạn code trên dùng Object chứ không phải Class, tức là đây là một Singleton Class. 

Các bạn hãy suy nghĩ xem nhé, nếu Class User được sử dụng bời nhiều Thread khác nhau thì sao nhỉ?

Thread1

|

---> -----

Thread2 | | |

---> | User|

| |

Thread3 ---> -----

-------^

|

Thread4

Lúc đó sẽ có những trường hợp xảy ra như sau:

- Trường hợp dễ nghĩ đến nhất là khi mà Thread1 chui vào trong block if (cache != null), tiến hành khởi tạo cache. Việc khởi tạo này tốn rất lâu, cứ cho là 10 phút đi, thì khi đó nếu 1 Thread2 gọi đến hàm AccessUserBy, nó thấy rằng Cache vẫn khác Null nên lại chui vào khởi tạo Cache tiếp. Tức là Cache của bạn được khởi tạo lặp đi lặp lại.

Giả sử Cache tốn 200mb mà Ram của bạn chỉ có 300mb thì việc khởi tạo 2 lần sẽ dẫn đến chương trình của bạn ngỏm củ tỏi chắc rồi. Nếu có không ngỏm thì với java hay là scala việc Memory Overhead sẽ dẫn theo cả CPU Overhead vì tác động của GC nên không sớm thì muộn chương trình của bạn cũng ngỏm.

- Một trường hợp khác là Cache chưa được khởi tạo hoàn toàn xong nhưng bạn xử lý để khi vừa chui vào đoạn code initialize thì cache sẽ được gán khác null, khi đó thread2 sẽ bị sử dụng phải một Object mà không "hoàn chỉnh", sẽ dễ dẫn đến các bug khó tìm.

Vậy phải xử lý ra sao nếu bạn là một lập trình viên đã từng làm với multi threaded thì sẽ nghĩ rằng: rất dễ, thêm synchronized vào để lock là xong!

object User {

lazy val cache = new ConcurrentHashMap[Information, Value]();

def accessUserBy(key: Information): Value = synchornized {

if (cache == null) {

initialize(cache); // very very costly!!!

} else {

return cache.get(information)

}

}

}

Tuy nhiên bạn có biết rằng bản thân việc Synchronized cũng là một xử lý rất "tốn kém" hay không? Khi hàm AccessUserBy của bạn được gọi đi gọi lại rất nhiều trong code, việc đặt Synchrronized ở đây có thể "down" hiệu năng hệ thống của bạn xuống hàng chục, thậm trí hàng trăm lần vì mất đi sự tối ưu của Multithread. Tuy nhiên xem kĩ thêm đoạn code ở trên một chút, bạn sẽ thấy rằng liệu có cần Synchronize "toàn bộ" hàm accessUserBy hay không? 

Chúng ta chỉ cần đồng bộ việc khởi tạo cache thôi chứ đúng không. Sau đó bạn sửa thành:

object User {

lazy val cache = new ConcurrentHashMap[Information, Value]();

def accessUserBy(key: Information): Value = {

if (cache == null) {

synchronized {

initialize(cache); // very very costly!!!

}

} else {

return cache.get(information)

}

}

}

Tuy nhiên bạn cần nghĩ đến trường hợp này:

- Thread1 chui vào block bên trong if (cache == null). Sau khi chui vào nhưng chưa kịp khởi tạo cache rồi nó bị interrupt bởi Thread2.

- Thread2 thấy cache chưa được khởi tạo, và chưa bằng null, nói chui vào bên trong if (cache == null), và nó đợi lock từ Thread1.

- Thread1 khởi tạo cache và chui ra.

- Thread2 nhận được lock và khởi tạo cache lần thứ 2!

Như vậy chúng ta vẫn gặp vấn đề như cũ, vậy phải làm sao? Đến đây chúng ta sẽ có khái niệm Double-Check Locking:

object User {

lazy val cache = new ConcurrentHashMap[Information, Value]();

def accessUserBy(key: Information): Value = {

if (cache == null) {

synchronized {

if (cache == null) {

initialize(cache); // very very costly!!!

}

}

} else {

return cache.get(information)

}

}

}

Bạn có thể thấy đoạn code trên "an toàn tuyệt đối", bởi sau khi nhận được Lock thì bất kì Thread nào cũng phải check lại xem Cache đã được khởi tạo chưa, rồi mới tiến hành khởi tạo.

Việc check điều kiện 2 lần như trên gọi là Double-Check Locking.

>> Có thể bạn quan tâm: Lập trình hướng đối tượng nâng cao DI bằng "Minimal Cake Pattern"

Theo Bizfly Cloud chia sẻ

SHARE