Rails là một framework lớn với nhiều công cụ tiện ích tích hợp sẵn cho các tình huống cụ thể. Trong series này, chúng ta sẽ xem xét một số công cụ ít được biết đến ẩn giấu trong codebase lớn của Rails.
Trong bài viết này của series, chúng ta sẽ xem phương thức update_counters của ActiveRecord. Trong quá trình đó, chúng ta sẽ nhìn vào bẫy thường gặp "race conditions" (điều kiện chạy đua) trong chương trình đa luồng và cách phương thức này có thể ngăn chặn chúng.
Luồng (Threads)
Khi lập trình, chúng ta có một số cách chạy code song song, bao gồm tiến trình (processes), luồng (threads), và gần đây hơn (trong Ruby), fibers và reactors. Trong bài này, chúng ta chỉ quan tâm đến threads, vì đó là dạng phổ biến nhất mà lập trình viên Rails sẽ gặp. Ví dụ, Puma là một server đa luồng, và Sidekiq là bộ xử lý background job đa luồng.
Chúng ta sẽ không đi sâu vào threads và thread safety ở đây. Điều chính cần biết là khi hai luồng thao tác trên cùng dữ liệu, dữ liệu rất dễ bị out-of-sync (ra khỏi đồng bộ). Đây chính là "race condition".
Race Conditions (Điều Kiện Chạy Đua)
Race condition xảy ra khi hai (hoặc nhiều) luồng thao tác trên cùng dữ liệu cùng lúc, nghĩa là một luồng có thể dùng dữ liệu cũ (stale data). Nó gọi là "race condition" vì giống như hai luồng đang đua nhau, và trạng thái cuối cùng của dữ liệu có thể khác nhau tùy thuộc vào luồng nào "thắng cuộc đua". Có thể tệ nhất là race conditions rất khó tái hiện vì chúng thường chỉ xảy ra nếu các luồng "luân phiên" theo thứ tự cụ thể và tại điểm cụ thể trong code.
Một Ví Dụ
Một kịch bản phổ biến dùng để minh họa race condition là cập nhật số dư ngân hàng. Chúng ta sẽ tạo một class test đơn giản trong ứng dụng Rails cơ bản để thấy chuyện gì xảy ra:
class UnsafeTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
threads = []
4.times do
threads << Thread.new do
balance = account.reload.balance
account.update!(balance: balance + 100)
balance = account.reload.balance
account.update!(balance: balance - 100)
end
end
threads.map(&:join)
account.reload.balance
end
end
UnsafeTransaction của chúng ta khá đơn giản; chỉ có một phương thức tra cứu Account (model Rails tiêu chuẩn với thuộc tính balance kiểu BigDecimal). Chúng ta reset balance về 0 để chạy lại test dễ dàng hơn.
Vòng lặp bên trong là nơi mọi thứ trở nên thú vị. Chúng ta tạo 4 luồng sẽ lấy balance hiện tại của tài khoản, cộng 100 (ví dụ: nạp $100), rồi ngay lập tức trừ 100 (ví dụ: rút $100). Chúng ta thậm chí dùng reload cả hai lần để extra chắc chắn có balance cập nhật.
Các dòng còn lại chỉ dọn dẹp. Thread.join nghĩa là chúng ta sẽ chờ tất cả luồng kết thúc trước khi tiếp tục, và sau đó trả về balance cuối cùng ở cuối phương thức.
Nếu chạy với một luồng (bằng cách đổi loop thành 1.times do), chúng ta có thể chạy một triệu lần và chắc chắn balance cuối cùng luôn là 0. Đổi thành hai (hoặc nhiều) luồng, mọi thứ không chắc chắn nữa.
Chạy test một lần trong console có thể cho kết quả đúng:
UnsafeTransaction.run # => 0.0
Tuy nhiên, nếu chạy lại nhiều lần. Giả sử chạy mười lần:
(1..10).map { UnsafeTransaction.run }.map(&:to_f)
# => [0.0, 300.0, 300.0, 100.0, 100.0, 100.0, 300.0, 300.0, 100.0, 300.0]
Nếu cú pháp ở đây không quen thuộc, (1..10).map {} sẽ chạy code trong block 10 lần, kết quả mỗi lần đưa vào mảng. .map(&:to_f) cuối chỉ để số dễ đọc hơn, vì giá trị BigDecimal thường in dưới dạng exponential như 0.1e3.
Nhớ rằng code lấy balance hiện tại, cộng 100, rồi ngay lập tức trừ 100, nên kết quả cuối cùng luôn luôn phải là 0.0. Các entry 100.0 và 300.0 đây là bằng chứng chúng ta có race condition.
Ví Dụ Có Chú Thích
Hãy zoom vào code gây vấn đề và xem chuyện gì xảy ra. Chúng ta tách riêng các thay đổi balance để rõ hơn.
threads << Thread.new do
# Luồng có thể chuyển đổi ở đây
balance = account.reload.balance
# hoặc ở đây...
balance += 100
# hoặc ở đây...
account.update!(balance: balance)
# hoặc ở đây...
balance = account.reload.balance
# hoặc ở đây...
balance -= 100
# hoặc ở đây...
account.update!(balance: balance)
# hoặc ở đây...
end
Như thấy trong comment, luồng có thể chuyển đổi ở gần như mọi điểm trong code này. Nếu Luồng 1 đọc balance, rồi máy tính bắt đầu thực thi Luồng 2, rất có thể dữ liệu sẽ lỗi thời khi nó gọi update!. Nói cách khác, Luồng 1, Luồng 2, và database, đều có dữ liệu, nhưng chúng đang ra khỏi sync với nhau.
Ví dụ đây được thiết kế tầm thường để dễ phân tích. Trong thực tế, race conditions khó chẩn đoán hơn, đặc biệt vì thường không thể tái hiện tin cậy.
Giải Pháp
Có một vài lựa chọn ngăn chặn race conditions, nhưng gần như tất cả xoay quanh một ý tưởng: đảm bảo chỉ một thực thể thay đổi dữ liệu tại một thời điểm.
Lựa Chọn 1: Mutex
Lựa chọn đơn giản nhất là "mutual exclusion lock", thường gọi là mutex. Bạn có thể nghĩ mutex như cái khóa chỉ có một chìa khóa. Nếu một luồng nắm chìa khóa, nó có thể chạy bất cứ gì trong mutex. Tất cả luồng khác phải chờ đến khi nắm được chìa khóa.
Áp dụng mutex cho ví dụ code có thể làm như sau:
class MutexTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
mutex = Mutex.new
threads = []
4.times do
threads << Thread.new do
mutex.lock
balance = account.reload.balance
account.update!(balance: balance + 100)
mutex.unlock
mutex.lock
balance = account.reload.balance
account.update!(balance: balance - 100)
mutex.unlock
end
end
threads.map(&:join)
account.reload.balance
end
end
Ở đây, mỗi lần đọc và ghi account, chúng ta gọi mutex.lock trước, và khi xong, gọi mutex.unlock để cho luồng khác lượt. Chúng ta có thể gọi mutex.lock ở đầu block và mutex.unlock ở cuối; tuy nhiên, điều này có nghĩa luồng không còn chạy đồng thời, ngược lại mục đích dùng threads. Về hiệu suất, tốt nhất giữ code bên trong mutex càng nhỏ càng tốt, vì nó cho phép luồng thực thi càng nhiều code song song càng tốt.
Chúng ta dùng .lock và .unlock để rõ ràng, nhưng class Mutex của Ruby cung cấp phương thức synchronize hay nhận block và xử lý giúp chúng ta, nên có thể làm:
mutex.synchronize do
balance = ...
...
end
Mutex của Ruby làm những gì chúng ta cần, nhưng như bạn có thể tưởng tượng, khá phổ biến trong ứng dụng Rails cần khóa một dòng database cụ thể, và ActiveRecord đã lo cho kịch bản này.
Lựa Chọn 2: ActiveRecord Locks
ActiveRecord cung cấp vài cơ chế khóa khác nhau, chúng ta không đi sâu tất cả ở đây. Với mục đích của chúng ta, có thể dùng lock! để khóa dòng muốn cập nhật:
class LockedTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
threads = []
4.times do
threads << Thread.new do
Account.transaction do
account = account.reload
account.lock!
account.update!(balance: account.balance + 100)
end
Account.transaction do
account = account.reload
account.lock!
account.update!(balance: account.balance - 100)
end
end
end
threads.map(&:join)
account.reload.balance
end
end
Trong khi Mutex "khóa" đoạn code cho một luồng cụ thể, lock! khóa dòng database cụ thể. Điều này nghĩa là cùng code có thể chạy song song trên nhiều tài khoản (ví dụ: trong nhiều background jobs). Chỉ luồng cần truy cập cùng bản ghi mới phải chờ. ActiveRecord cũng cung cấp phương thức tiện lợi #with_lock cho phép transaction và lock một lần, nên các cập nhật trên có thể viết gọn hơn:
account = account.reload
account.with_lock do
account.update!(account.balance + 100)
end
...
Giải Pháp 3: Atomic Methods (Phương Thức Nguyên Tử)
Một phương thức 'atomic' (hoặc hàm) không thể bị dừng giữa chừng khi thực thi. Ví dụ, phép += phổ biến trong Ruby không phải atomic, dù trông như một phép toán đơn lẻ:
value += 10
# tương đương: value = value + 10
# Hoặc chi tiết hơn:
temp_value = value + 10
value = temp_value
Nếu luồng bất ngờ "ngủ" giữa việc tính value + 10 và ghi kết quả lại value, nó mở khả năng race condition. Tuy nhiên, hãy tưởng tượng Ruby không cho luồng ngủ trong phép toán này. Nếu chúng ta có thể nói, với chắc chắn, một luồng sẽ không bao giờ ngủ (ví dụ: máy tính sẽ không chuyển thực thi sang luồng khác) trong phép toán này, thì nó có thể coi là phép toán "atomic".
Một số ngôn ngữ có phiên bản atomic của kiểu nguyên thủy cho thread-safety (ví dụ: AtomicInteger, AtomicFloat). Điều này không có nghĩa là chúng ta không có vài phép "atomic" như Rails developer. Một ví dụ là phương thức update_counters của ActiveRecord.
Mặc dù phương thức này chủ yếu dùng để cập nhật counter caches, không gì cản trở dùng nó trong ứng dụng. Để biết thêm về counter caches, xem bài trước của tôi về caching.
Việc dùng phương thức cực kỳ đơn giản:
class CounterTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
threads = []
4.times do
threads << Thread.new do
Account.update_counters(account.id, balance: 100)
Account.update_counters(account.id, balance: -100)
end
end
threads.map(&:join)
account.reload.balance
end
end
Không mutex, không lock, chỉ hai dòng Ruby; update_counters nhận record ID làm đối số đầu, rồi chúng ta nói cột nào thay đổi (balance:) và thay đổi bao nhiêu (100 hoặc -100). Lý do nó hoạt động là chu trình read-update-write giờ xảy ra trong database trong một lời gọi SQL đơn lẻ. Điều này nghĩa là luồng Ruby không thể ngắt phép toán; ngay cả nếu nó ngủ, không quan trọng vì database đang làm phép tính thực tế.
SQL thực tế sinh ra trông như sau (ít nhất với postgres trên máy tôi):
Account Update All (1.7ms) UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2 [["balance", "100.0"], ["id", 1]]
Cách này cũng hiệu năng tốt hơn nhiều, không ngạc nhiên, vì phép tính xảy ra hoàn toàn trong database; chúng ta không bao giờ phải reload record để lấy giá trị mới nhất. Tốc độ này có cái giá, tuy nhiên. Vì chúng ta làm trong raw SQL, chúng ta bypass model Rails, nghĩa là validations hoặc callbacks sẽ không chạy (trong đó, không thay đổi timestamp updated_at).
Kết Luận
Race conditions có thể là poster child của Heisenbug (lỗi biến mất khi debug). Chúng dễ để lọt vào, thường không thể tái hiện, và khó dự đoán. Ruby và Rails, ít nhất, cho chúng ta vài công cụ hữu ích để dập tắt vấn đề này khi tìm thấy.
Đối với code Ruby chung, Mutex là lựa chọn tốt và có lẽ điều đầu tiên hầu hết developer nghĩ đến khi nghe "thread safety".
Với Rails, nhiều khả năng dữ liệu đến từ ActiveRecord. Trong trường hợp này, lock! (hoặc with_lock) đơn giản và cho throughput cao hơn mutex, vì chỉ khóa các dòng liên quan trong database.
Sẽ trung thực, tôi không chắc tôi sẽ dùng update_counters nhiều trong thực tế. Nó đủ hiếm để developer khác có thể không quen cách hoạt động, và nó không làm rõ ý định code. Nếu đối mặt thread-safety, lock của ActiveRecord (hoặc lock! hoặc with_lock) đều phổ biến hơn và truyền đạt ý định coder rõ ràng hơn.
Tuy nhiên, nếu bạn có nhiều job 'cộng hoặc trừ' đơn giản chờ xử lý, và cần tốc độ thô, update_counters có thể là công cụ hữu ích trong túi áo.