Rails는 특정 상황을 위해 내장된 편리한 도구가 많이 포함된 대규모 프레임워크입니다. 이 시리즈에서는 Rails의 대규모 코드베이스에 숨겨져 있는 잘 알려지지 않은 도구를 살펴보겠습니다.
이 시리즈의 기사에서는 ActiveRecord의 update_counters
를 살펴보겠습니다. 방법. 이 과정에서 우리는 다중 스레드 프로그램에서 "경합 조건"의 일반적인 함정과 이 방법이 이를 방지할 수 있는 방법을 살펴볼 것입니다.
스레드
프로그래밍할 때 프로세스, 스레드, 그리고 최근에는(Ruby에서) 파이버 및 리액터를 포함하여 코드를 병렬로 실행할 수 있는 여러 가지 방법이 있습니다. 이 기사에서는 스레드에 대해서만 걱정할 것입니다. 스레드는 Rails 개발자가 접하게 될 가장 일반적인 형식이기 때문입니다. 예를 들어 Puma는 다중 스레드 서버이고 Sidekiq는 다중 스레드 백그라운드 작업 프로세서입니다.
여기서는 스레드와 스레드 안전성에 대해 자세히 다루지 않을 것입니다. 알아야 할 주요 사항은 두 개의 스레드가 동일한 데이터에서 작동할 때 데이터가 쉽게 동기화되지 않을 수 있다는 것입니다. 이것이 "경합 조건"으로 알려져 있습니다.
경주 조건
경쟁 조건은 두 개(또는 그 이상)의 스레드가 동일한 데이터에서 동시에 작동할 때 발생합니다. 즉, 스레드가 오래된 데이터를 사용하게 될 수 있습니다. 두 스레드가 서로 경주하는 것과 같기 때문에 "경합 조건"이라고 하며, 어떤 스레드가 "경주에서 이겼는지"에 따라 데이터의 최종 상태가 다를 수 있습니다. 아마도 최악의 경우 경쟁 조건은 일반적으로 스레드가 특정 순서와 코드의 특정 지점에서 "교대로" 발생하는 경우에만 발생하기 때문에 재현하기가 매우 어렵습니다.
예시
경쟁 조건을 표시하는 데 사용되는 일반적인 시나리오는 은행 잔고를 업데이트하는 것입니다. 어떤 일이 일어나는지 볼 수 있도록 기본 Rails 애플리케이션 내에서 간단한 테스트 클래스를 만들 것입니다.
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
매우 간단합니다. Account
를 조회하는 메소드가 하나만 있습니다. (BigDecimal balance
가 있는 스톡 표준 Rails 모델 기인하다). 테스트를 더 쉽게 재실행할 수 있도록 잔액을 0으로 재설정했습니다.
내부 루프는 상황이 좀 더 흥미로워지는 곳입니다. 우리는 계정의 현재 잔고를 파악하고 여기에 100을 더한 다음(예:$100 예금) 100을 즉시 빼는(예:$100 인출) 4개의 스레드를 만들고 있습니다. reload
도 사용 중입니다. 두 번 모두 추가 최신 잔액이 있는지 확인하십시오.
나머지 줄은 정리 중입니다. Thread.join
진행하기 전에 모든 스레드가 종료될 때까지 기다린 다음 메서드가 끝날 때 최종 잔액을 반환한다는 의미입니다.
단일 스레드로 이것을 실행했다면(루프를 1.times do
로 변경하여 ), 우리는 행복하게 백만 번 실행할 수 있고 최종 계정 잔액이 항상 0이 되도록 할 수 있습니다. 그러나 두 개(또는 그 이상) 스레드로 변경하면 상황이 덜 확실해집니다.
콘솔에서 한 번 테스트를 실행하면 아마도 정답이 나올 것입니다.
UnsafeTransaction.run
=> 0.0
그러나 우리가 그것을 계속해서 실행한다면 어떨까요? 10번 실행했다고 가정해 보겠습니다.
(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]
여기의 구문이 익숙하지 않은 경우 (1..10).map {}
블록의 코드를 10번 실행하고 각 실행의 결과를 배열에 넣습니다. .map(&:to_f)
마지막에는 BigDecimal 값이 일반적으로 0.1e3
과 같은 지수 표기법으로 인쇄되므로 사람이 더 읽기 쉽게 숫자를 만들 수 있습니다. .
기억하세요. 우리 코드는 현재 잔액을 가져와서 100을 더한 다음 즉시 100을 빼므로 최종 결과는 이어야 합니다. 항상 0.0
이어야 합니다. . 이러한 100.0
및 300.0
따라서 항목은 경쟁 조건이 있다는 증거입니다.
주석이 있는 예
여기에서 문제 코드를 확대하고 무슨 일이 일어나는지 봅시다. balance
에 대한 변경 사항을 구분하겠습니다. 더 명확하게.
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
주석에서 볼 수 있듯이 이 코드의 거의 모든 시점에서 스레드가 스와핑될 수 있습니다. 스레드 1이 잔액을 읽으면 컴퓨터가 스레드 2를 실행하기 시작하므로 update!
를 호출할 때 데이터가 최신이 아닐 가능성이 큽니다. . 즉, 스레드 1, 스레드 2 및 데이터베이스 모두에 데이터가 있지만 서로 동기화되지 않습니다.
여기 예제는 분석하기 쉽도록 의도적으로 사소한 것입니다. 그러나 실제 세계에서는 특히 일반적으로 안정적으로 재현할 수 없기 때문에 경쟁 조건을 진단하기가 더 어려울 수 있습니다.
솔루션
경합 상태를 방지하기 위한 몇 가지 옵션이 있지만 거의 대부분이 단일 아이디어를 중심으로 이루어집니다. 즉, 주어진 시간에 단 하나의 엔티티만 데이터를 변경하도록 하는 것입니다.
옵션 1:뮤텍스
가장 간단한 옵션은 일반적으로 뮤텍스로 알려진 "상호 배제 잠금"입니다. 뮤텍스는 키가 하나만 있는 잠금으로 생각할 수 있습니다. 한 스레드가 키를 보유하고 있으면 뮤텍스에 있는 모든 항목을 실행할 수 있습니다. 다른 모든 스레드는 키를 보유할 수 있을 때까지 기다려야 합니다.
예제 코드에 뮤텍스를 적용하면 다음과 같이 할 수 있습니다.
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
여기서 account
를 읽고 쓸 때마다 , 먼저 mutex.lock
을 호출합니다. , 그리고 완료되면 mutex.unlock
을 호출합니다. 다른 스레드가 회전할 수 있도록 합니다. mutex.lock
을 호출하면 됩니다. 블록 시작 부분 및 mutex.unlock
끝에; 그러나 이것은 스레드가 더 이상 동시에 실행되지 않음을 의미하며, 이는 처음에 스레드를 사용하는 이유를 다소 부정합니다. 성능을 위해 mutex
내부에 코드를 유지하는 것이 가장 좋습니다. 스레드가 가능한 한 많은 코드를 병렬로 실행할 수 있으므로 가능한 한 작게 합니다.
.lock
을 사용했습니다. 및 .unlock
명확성을 위해 Ruby의 Mutex
클래스는 멋진 synchronize
를 제공합니다. 블록을 가져와 처리하는 메서드이므로 다음을 수행할 수 있습니다.
mutex.synchronize do
balance = ...
...
end
Ruby의 Mutex는 우리가 필요로 하는 작업을 수행하지만 아마도 상상할 수 있듯이 Rails 애플리케이션에서 특정 데이터베이스 행을 잠글 필요가 있는 것은 매우 일반적이며 ActiveRecord는 이 시나리오를 다루었습니다.
옵션 2:ActiveRecord 잠금
ActiveRecord는 몇 가지 다른 잠금 메커니즘을 제공하며 여기에서 모두 자세히 다루지는 않겠습니다. 우리의 목적을 위해 lock!
을 사용할 수 있습니다. 업데이트하려는 행을 잠그려면:
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
Mutex가 특정 스레드에 대한 코드 섹션을 "잠그는" 반면, lock!
특정 데이터베이스 행을 잠급니다. 이는 동일한 코드가 여러 계정에서 병렬로 실행될 수 있음을 의미합니다(예:많은 백그라운드 작업에서). 동일한 레코드에 액세스해야 하는 스레드만 기다려야 합니다. ActiveRecord는 편리한 #with_lock
도 제공합니다. 트랜잭션을 수행하고 한 번에 잠글 수 있는 메서드이므로 위의 업데이트를 다음과 같이 좀 더 간결하게 작성할 수 있습니다.
account = account.reload
account.with_lock do
account.update!(account.balance + 100)
end
...
해결책 3:원자적 방법
'원자적' 메서드(또는 함수)는 실행 도중에 중지할 수 없습니다. 예를 들어, 일반적인 +=
Ruby에서의 작업은 아닙니다. 단일 작업처럼 보이지만 원자적:
value += 10
# equivalent to:
value = value + 10
# Or even more verbose:
temp_value = value + 10
value = temp_value
스레드가 value + 10
을 계산하는 사이에 갑자기 "잠자기" 상태가 되면 is이고 결과를 다시 value
에 씁니다. , 경쟁 조건의 가능성을 엽니다. 그러나 Ruby가 이 작업 동안 스레드를 잠자기 상태로 두지 않았다고 가정해 보겠습니다. 이 작업 동안 스레드가 절대 절전 모드로 전환되지 않는다고 확실히 말할 수 있다면(예:컴퓨터는 실행을 다른 스레드로 전환하지 않을 것입니다) "원자적" 작업으로 간주될 수 있습니다.
일부 언어에는 정확히 이러한 종류의 스레드 안전성을 위한 기본 값의 원자 버전이 있습니다(예:AtomicInteger 및 AtomicFloat). 그렇다고 Rails 개발자로서 사용할 수 있는 "원자적" 작업이 몇 가지 없다는 의미는 아닙니다. 한 번 예는 ActiveRecord의 update_counters
입니다. 방법.
이것은 카운터 캐시를 최신 상태로 유지하기 위한 것이지만 애플리케이션에서 사용하는 것을 막지는 못합니다. 카운터 캐시에 대한 자세한 내용은 캐싱에 대한 이전 기사를 참조하십시오.
이 방법을 사용하는 것은 매우 간단합니다.
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
뮤텍스도 없고 잠금도 없고 Ruby 두 줄만 있으면 됩니다. update_counters
레코드 ID를 첫 번째 인수로 취한 다음 변경할 열을 알려줍니다(balance:
) 및 얼마나 변경할지 (100
또는 -100
). 이것이 작동하는 이유는 읽기-업데이트-쓰기 주기가 단일 SQL 호출로 데이터베이스에서 발생하기 때문입니다. 이것은 Ruby 스레드가 작업을 중단할 수 없음을 의미합니다. 데이터베이스가 실제 계산을 하고 있기 때문에 잠자기 상태가 되어도 문제가 되지 않습니다.
생성되는 실제 SQL은 다음과 같이 나옵니다(적어도 내 컴퓨터의 postgres의 경우).
Account Update All (1.7ms) UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2 [["balance", "100.0"], ["id", 1]]
이 방법은 또한 계산이 데이터베이스에서 완전히 이루어지기 때문에 훨씬 더 나은 성능을 제공합니다. reload
할 필요가 없습니다. 최신 값을 가져오는 레코드입니다. 하지만 이 속도에는 대가가 따릅니다. 원시 SQL에서 이 작업을 수행하기 때문에 Rails 모델을 우회합니다. 즉, 유효성 검사 또는 콜백이 실행되지 않습니다(즉, 무엇보다도 updated_at
타임스탬프).
결론
경쟁 조건은 Heisenbug 포스터 자식이 될 수 있습니다. 그것들은 들여오기 쉽고, 종종 번식이 불가능하고, 예측하기 어렵습니다. Ruby와 Rails는 최소한 이러한 문제를 발견하면 해결할 수 있는 몇 가지 유용한 도구를 제공합니다.
일반 Ruby 코드의 경우 Mutex
이것은 좋은 옵션이며 아마도 대부분의 개발자가 "스레드 안전성"이라는 용어를 들었을 때 가장 먼저 생각하는 것입니다.
Rails를 사용하면 데이터가 ActiveRecord에서 올 가능성이 높습니다. 이러한 경우 lock!
(또는 with_lock
)는 사용하기 쉽고 데이터베이스의 관련 행만 잠그므로 뮤텍스보다 더 많은 처리량을 허용합니다.
솔직히 말하겠습니다. update_counters
에 도달할지 확신이 서지 않습니다. 현실 세계에서 많이. 다른 개발자가 작동 방식에 익숙하지 않을 수 있을 만큼 흔하지 않으며 코드의 의도가 특히 명확하지 않습니다. 스레드 안전 문제에 직면하면 ActiveRecord의 잠금(lock!
또는 with_lock
)는 더 일반적이고 코더의 의도를 더 명확하게 전달합니다.
그러나 백업하는 간단한 '추가 또는 빼기' 작업이 많고 실제 속도가 필요한 경우 update_counters
뒷주머니에 유용한 도구가 될 수 있습니다.