Computer >> Máy Tính >  >> Lập trình >> Ruby

Sử dụng ActiveRecords #update_counters để Ngăn chặn Điều kiện Cuộc đua

Rails là một framework lớn với rất nhiều công cụ tiện dụng được tích hợp sẵn cho các tình huống cụ thể. Trong loạt bài này, chúng ta sẽ xem xét một số công cụ ít được biết đến hơn được ẩn trong cơ sở mã lớn của Rails.

Trong bài viết này của loạt bài, chúng ta sẽ xem xét update_counters của ActiveRecord phương pháp. Trong quá trình này, chúng ta sẽ xem xét cái bẫy phổ biến về "điều kiện chủng tộc" trong các chương trình đa luồng và cách phương pháp này có thể ngăn chặn chúng.

Chủ đề

Khi lập trình, chúng ta có một số cách để chạy mã song song, bao gồm các quy trình, luồng và gần đây (trong Ruby), các sợi và lò phản ứng. Trong bài viết này, chúng ta sẽ chỉ lo lắng về các luồng, vì nó là dạng phổ biến nhất mà các nhà phát triển Rails sẽ gặp phải. Ví dụ:Puma là một máy chủ đa luồng và Sidekiq là một bộ xử lý công việc nền đa luồng.

Chúng tôi sẽ không đi sâu vào chủ đề và sự an toàn của chủ đề ở đây. Điều chính cần biết là khi hai luồng đang hoạt động trên cùng một dữ liệu, dữ liệu có thể dễ dàng bị mất đồng bộ. Đây là điều được gọi là "điều kiện chủng tộc".

Điều kiện cuộc đua

Điều kiện chạy đua xảy ra khi hai (hoặc nhiều) luồng đang hoạt động trên cùng một dữ liệu tại cùng một thời điểm, có nghĩa là một luồng có thể kết thúc bằng cách sử dụng dữ liệu cũ. Nó được gọi là "điều kiện cuộc đua" vì nó giống như hai luồng đang chạy đua với 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ó lẽ điều tồi tệ nhất là điều kiện chủng tộc rất khó tái tạo vì chúng thường chỉ xảy ra nếu các chuỗi "thay phiên nhau" theo một thứ tự cụ thể và tại một điểm cụ thể trong mã.

Ví dụ

Một kịch bản phổ biến được sử dụng để hiển thị tình trạng cuộc đua là cập nhật số dư ngân hàng. Chúng tôi sẽ tạo một lớp thử nghiệm đơn giản trong một ứng dụng Rails cơ bản để chúng tôi có thể xem điều gì sẽ 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 tôi khá đơn giản; chúng tôi chỉ có một phương pháp tra cứu Account (một mô hình Rails tiêu chuẩn chứng khoán với số dư balance thuộc tính). Chúng tôi đặt lại số dư về 0 để việc chạy lại kiểm tra đơn giản hơn.

Vòng lặp bên trong là nơi mọi thứ trở nên thú vị hơn một chút. Chúng tôi đang tạo bốn chuỗi sẽ lấy số dư hiện tại của tài khoản, thêm 100 vào đó (ví dụ:khoản tiền gửi 100 đô la), và sau đó ngay lập tức trừ đi 100 (ví dụ:khoản rút 100 đô la). Chúng tôi thậm chí đang sử dụng reload cả hai lần đều là phụ đảm bảo rằng chúng tôi có số dư cập nhật.

Các dòng còn lại chỉ là một số thu dọn. Thread.join có nghĩa là chúng tôi sẽ đợi tất cả các chuỗi kết thúc trước khi tiếp tục và sau đó chúng tôi trả lại số dư cuối cùng ở cuối phương thức.

Nếu chúng tôi chạy điều này với một chuỗi duy nhất (bằng cách thay đổi vòng lặp thành 1.times do ), chúng tôi có thể vui vẻ chạy nó hàng triệu lần và chắc chắn rằng số dư tài khoản cuối cùng sẽ luôn bằng 0. Tuy nhiên, hãy thay đổi nó thành hai (hoặc nhiều) chuỗi và mọi thứ ít chắc chắn hơn.

Chạy thử nghiệm của chúng tôi một lần trong bảng điều khiển có thể sẽ cho chúng tôi câu trả lời chính xác:

UnsafeTransaction.run
=> 0.0

Tuy nhiên, điều gì sẽ xảy ra nếu chúng ta chạy đi chạy lại nó. Giả sử chúng tôi đã chạy nó 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]

Trong trường hợp cú pháp ở đây không quen thuộc, hãy (1..10).map {} sẽ chạy mã trong khối 10 lần, với kết quả từ mỗi lần chạy được đưa vào một mảng. .map(&:to_f) cuối cùng chỉ là để con người dễ đọc hơn, vì các giá trị BigDecimal thường được in theo ký hiệu hàm mũ như 0.1e3 .

Hãy nhớ rằng, mã của chúng tôi lấy số dư hiện tại, cộng 100 và sau đó trừ ngay 100, vì vậy kết quả cuối cùng nên luôn là 0.0 . 100.0 này và 300.0 do đó, các mục nhập là bằng chứng rằng chúng tôi có điều kiện chủng tộc.

Ví dụ được chú thích

Hãy phóng to mã sự cố ở đây và xem điều gì đang xảy ra. Chúng tôi sẽ tách các thay đổi đối với balance để rõ ràng hơn nữa.

threads << Thread.new do
  # Thread could be switching here
  balance = account.reload.balance
  # or here...
  balance += 100
  # or here...
  account.update!(balance: balance)
  # or here...

  balance = account.reload.balance
  # or here...
  balance -= 100
  # or here...
  account.update!(balance: balance)
  # or here...
end

Như chúng ta thấy trong các bình luận, các chủ đề có thể được hoán đổi ở hầu hết mọi thời điểm trong quá trình mã này. Nếu Luồng 1 đọc số dư, thì máy tính sẽ bắt đầu thực hiện Luồng 2, vì vậy rất có thể dữ liệu sẽ bị lỗi thời vào thời điểm nó gọi update! . Nói một cách khác, Luồng 1, Luồng 2 và cơ sở dữ liệu, tất cả đều có dữ liệu trong đó, nhưng chúng không đồng bộ với nhau.

Ví dụ ở đây cố tình tầm thường để dễ mổ xẻ. Tuy nhiên, trong thế giới thực, điều kiện chủng tộc có thể khó chẩn đoán hơn, đặc biệt vì chúng thường không thể được tái tạo một cách đáng tin cậy.

Giải pháp

Có một vài lựa chọn để ngăn chặn các điều kiện chủng tộc, nhưng gần như tất cả chúng đều xoay quanh một ý tưởng duy nhất:đảm bảo rằng chỉ có một thực thể thay đổi dữ liệu tại bất kỳ thời điểm nào.

Tùy chọn 1:Mutex

Tùy chọn đơn giản nhất là "khóa loại trừ lẫn nhau", thường được gọi là mutex. Bạn có thể coi mutex như một ổ khóa chỉ có một chìa khóa. Nếu một luồng đang giữ khóa, nó có thể chạy bất cứ thứ gì có trong mutex. Tất cả các chuỗi khác sẽ phải đợi cho đến khi họ có thể giữ khóa.

Áp dụng mutex cho mã mẫu của chúng tôi có thể được thực hiện như vậy:

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

Tại đây, mỗi khi chúng tôi đọc và ghi vào tài khoản account , trước tiên chúng tôi gọi mutex.lock , và sau khi hoàn tất, chúng tôi gọi mutex.unlock để cho phép các chủ đề khác có một lượt. Chúng tôi chỉ có thể gọi mutex.lock ở đầu khối và mutex.unlock cuối cùng; tuy nhiên, điều này có nghĩa là các luồng không còn chạy đồng thời, điều này phần nào phủ nhận lý do sử dụng các luồng ngay từ đầu. Để có hiệu suất, tốt nhất nên giữ mã bên trong mutex càng nhỏ càng tốt, vì nó cho phép các luồng thực thi nhiều mã song song nhất có thể.

Chúng tôi đã sử dụng .lock.unlock để rõ ràng ở đây, nhưng Mutex của Ruby lớp cung cấp một synchronize tốt đẹp phương thức nhận một khối và xử lý điều này cho chúng tôi, vì vậy chúng tôi có thể đã làm như sau:

mutex.synchronize do
  balance = ...
  ...
end

Ruby's Mutex thực hiện những gì chúng ta cần, nhưng như bạn có thể tưởng tượng, khá phổ biến trong các ứng dụng Rails khi cần khóa một hàng cơ sở dữ liệu cụ thể và ActiveRecord đã hỗ trợ chúng tôi giải quyết tình huống này.

Tùy chọn 2:ActiveRecord Locks

ActiveRecord cung cấp một số cơ chế khóa khác nhau và chúng tôi sẽ không đi sâu tìm hiểu tất cả chúng ở đây. Đối với mục đích của chúng tôi, chúng tôi chỉ có thể sử dụng lock! để khóa một hàng mà chúng tôi 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" phần mã cho một chuỗi cụ thể, lock! khóa hàng cơ sở dữ liệu cụ thể. Điều này có nghĩa là cùng một mã có thể thực thi song song trên nhiều tài khoản (ví dụ:trong một loạt các công việc nền). Chỉ các chuỗi cần truy cập vào cùng một bản ghi mới phải đợi. ActiveRecord cũng cung cấp một #with_lock tiện dụng cho phép bạn thực hiện giao dịch và khóa một lần, vì vậy các cập nhật ở trên có thể được viết ngắn gọn hơn một chút như sau:

account = account.reload
account.with_lock do
  account.update!(account.balance + 100)
end
...

Giải pháp 3:Phương pháp nguyên tử

Một phương thức (hoặc hàm) 'nguyên tử' không thể bị dừng giữa chừng trong quá trình thực thi. Ví dụ:+= chung hoạt động trong Ruby là không nguyên tử, mặc dù nó trông giống như một hoạt động duy nhất:

value += 10

# equivalent to:
value = value + 10

# Or even more verbose:
temp_value = value + 10
value = temp_value

Nếu chuỗi đột ngột "ngủ" khi đang tìm ra value + 10 là và ghi kết quả trở lại giá trị account , sau đó nó mở ra khả năng về một điều kiện chủng tộc. Tuy nhiên, hãy tưởng tượng rằng Ruby không cho phép các luồng ngủ trong hoạt động này. Nếu chúng ta có thể nói một cách chắc chắn rằng một luồng sẽ không bao giờ ngủ (ví dụ:máy tính sẽ không bao giờ chuyển việc thực thi sang một luồng khác) trong quá trình hoạt động này, thì nó có thể được coi là một hoạt động "nguyên tử".

Một số ngôn ngữ có phiên bản nguyên tử của các giá trị nguyên thủy cho chính xác loại an toàn luồng này (ví dụ:AtomicInteger và AtomicFloat). Tuy nhiên, điều này không có nghĩa là chúng tôi không có sẵn một vài thao tác "nguyên tử" với tư cách là nhà phát triển Rails. Một khi ví dụ là update_counters của ActiveRecord phương pháp.

Mặc dù điều này nhằm mục đích nhiều hơn để giữ cho bộ nhớ đệm của bộ đếm được cập nhật, nhưng không có gì ngăn cản chúng tôi sử dụng nó trong các ứng dụng của mình. Để biết thêm thông tin về bộ nhớ đệm của bộ đếm, bạn có thể xem bài viết trước đây của tôi về bộ nhớ đệm).

Sử dụng phương pháp này 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 có mutexes, không có ổ khóa, chỉ có hai dòng Ruby; update_counters lấy ID bản ghi làm đối số đầu tiên, sau đó chúng tôi cho nó biết cột nào cần thay đổi (balance: ) và thay đổi nó bằng bao nhiêu (100 hoặc -100 ). Lý do điều này hoạt động là vì chu kỳ đọc-cập nhật-ghi hiện xảy ra trong cơ sở dữ liệu trong một lệnh gọi SQL duy nhất. Điều này có nghĩa là chuỗi Ruby của chúng tôi không thể làm gián đoạn hoạt động; ngay cả khi nó ở chế độ ngủ, nó sẽ không thành vấn đề vì cơ sở dữ liệu đang thực hiện tính toán thực tế.

SQL thực tế đang được sản xuất xuất hiện như thế này (ít nhất là cho các postgres trên máy của 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 hoạt động tốt hơn nhiều, điều này không có gì đáng ngạc nhiên, vì việc tính toán diễn ra đầy đủ trong cơ sở dữ liệu; chúng tôi không bao giờ phải reload bản ghi để nhận giá trị mới nhất. Tuy nhiên, tốc độ này có một cái giá phải trả. Bởi vì chúng tôi đang làm điều này trong SQL thô, chúng tôi đang bỏ qua mô hình Rails, có nghĩa là bất kỳ xác thực hoặc lệnh gọi lại nào sẽ không được thực thi (có nghĩa là, trong số những thứ khác, không có thay đổi nào đối với updated_at dấu thời gian).

Kết luận

Điều kiện chủng tộc rất có thể là con của áp phích Heisenbug. Chúng rất dễ cho vào, thường không thể sinh sản và khó lường trước được. Ruby và Rails, ít nhất, cung cấp cho chúng tôi một số công cụ hữu ích để giải quyết những vấn đề này khi chúng tôi tìm thấy chúng.

Đối với mã Ruby chung, Mutex là một lựa chọn tốt và có lẽ là điều đầu tiên mà hầu hết các nhà phát triển nghĩ đến khi nghe đến thuật ngữ "an toàn luồng".

Với Rails, nhiều khả năng hơn là không, dữ liệu đến từ ActiveRecord. Trong những trường hợp này, lock! (hoặc with_lock ) dễ sử dụng và cho phép nhiều thông lượng hơn mutex, vì nó chỉ khóa các hàng có liên quan trong cơ sở dữ liệu.

Tôi thành thật ở đây, tôi không chắc mình sẽ tiếp cận với update_counters nhiều trong thế giới thực. Nó không phổ biến đến mức các nhà phát triển khác có thể không quen với cách nó hoạt động và nó không làm cho ý định của mã đặc biệt rõ ràng. Nếu đối mặt với các mối quan tâm về an toàn luồng, ActiveRecord sẽ khóa (hoặc là lock! hoặc with_lock ) đều phổ biến hơn và truyền đạt rõ ràng hơn ý định của người lập trình.

Tuy nhiên, nếu bạn đang sao lưu nhiều công việc 'cộng hoặc trừ' đơn giản và bạn cần tốc độ từ bàn đạp đến kim loại thô, hãy update_counters có thể là một công cụ hữu ích trong túi sau của bạn.