Ruby 동시성과 병렬성: 실용적인 튜토리얼
게시 됨: 2022-03-11Ruby 개발자들 사이에서 너무도 흔한 혼동 지점을 정리하는 것으로 시작하겠습니다. 즉: 동시성과 병렬성은 같은 것이 아닙니다 (즉, 동시성 != 병렬성).
특히 Ruby 동시성 은 두 작업이 겹치는 기간에 시작, 실행 및 완료될 수 있는 경우입니다. 그러나 반드시 둘 다 같은 순간에 실행된다는 의미는 아닙니다(예: 단일 코어 시스템의 다중 스레드). 대조적으로 병렬 처리 는 두 개의 작업이 문자 그대로 동시에 실행되는 경우입니다 (예: 멀티코어 프로세서의 다중 스레드).
여기서 핵심은 동시 스레드 및/또는 프로세스가 반드시 병렬로 실행되는 것은 아니라는 것입니다.
이 튜토리얼은 Ruby의 동시성 및 병렬 처리에 사용할 수 있는 다양한 기술과 접근 방식에 대해 이론적인 것보다 실제적인 방법을 제공합니다.
더 많은 실제 Ruby 예제를 보려면 Ruby Interpreters 및 Runtimes에 대한 기사를 참조하십시오.
우리의 테스트 사례
간단한 테스트 사례를 위해 다음과 같이 Mailer
클래스를 만들고 피보나치 함수( sleep()
메서드 대신)를 추가하여 각 요청을 CPU 집약적으로 만듭니다.
class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end
그런 다음 이 Mailer
클래스를 다음과 같이 호출하여 메일을 보낼 수 있습니다.
Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end
(참고: 이 테스트 케이스의 소스 코드는 여기 github에서 사용할 수 있습니다.)
비교 목적을 위한 기준선을 설정하기 위해 메일러를 100번 호출하는 간단한 벤치마크부터 시작하겠습니다.
puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }
MRI Ruby 2.0.0p353이 포함된 쿼드 코어 프로세서에서 다음과 같은 결과가 나타났습니다.
15.250000 0.020000 15.270000 ( 15.304447)
다중 프로세스 대 다중 스레딩
여러 프로세스를 사용할지 아니면 Ruby 애플리케이션을 멀티스레딩할지 결정할 때 "모든 경우에 적용되는" 정답은 없습니다. 아래 표에는 고려해야 할 몇 가지 주요 요소가 요약되어 있습니다.
프로세스 | 스레드 |
---|---|
더 많은 메모리를 사용합니다. | 더 적은 메모리를 사용합니다 |
자식이 나가기 전에 부모가 죽으면 자식이 좀비 프로세스가 될 수 있습니다. | 프로세스가 죽으면 모든 스레드가 죽습니다(좀비 가능성 없음). |
OS가 모든 것을 저장하고 다시 로드해야 하기 때문에 포크된 프로세스가 컨텍스트를 전환하는 데 더 비쌉니다. | 스레드는 주소 공간과 메모리를 공유하므로 오버헤드가 상당히 적습니다. |
분기된 프로세스에는 새로운 가상 메모리 공간이 제공됩니다(프로세스 격리). | 스레드는 동일한 메모리를 공유하므로 동시 메모리 문제를 제어하고 처리해야 합니다. |
프로세스 간 통신 필요 | 대기열 및 공유 메모리를 통해 "통신" 가능 |
생성 및 파괴 속도가 느림 | 더 빠른 생성 및 파괴 |
더 쉬운 코딩 및 디버그 | 코딩 및 디버그가 훨씬 더 복잡할 수 있음 |
여러 프로세스를 사용하는 Ruby 솔루션의 예:
- Resque: 백그라운드 작업을 생성하고 여러 대기열에 배치하고 나중에 처리하기 위한 Redis 지원 Ruby 라이브러리입니다.
- Unicorn: 짧은 대기 시간, 고대역폭 연결에서 빠른 클라이언트에게만 서비스를 제공하고 Unix/Unix 유사 커널의 기능을 활용하도록 설계된 랙 애플리케이션용 HTTP 서버입니다.
멀티스레딩을 사용하는 Ruby 솔루션의 예:
- Sidekiq: Ruby를 위한 모든 기능을 갖춘 백그라운드 처리 프레임워크. 최신 Rails 애플리케이션과 간단하게 통합하고 다른 기존 솔루션보다 훨씬 높은 성능을 목표로 합니다.
- Puma: 동시성을 위해 구축된 Ruby 웹 서버입니다.
- Thin: 매우 빠르고 간단한 Ruby 웹 서버입니다.
다중 프로세스
Ruby 멀티스레딩 옵션을 살펴보기 전에 여러 프로세스를 생성하는 더 쉬운 경로를 살펴보겠습니다.
Ruby에서 fork()
시스템 호출은 현재 프로세스의 "복사본"을 만드는 데 사용됩니다. 이 새 프로세스는 운영 체제 수준에서 예약되므로 다른 독립 프로세스와 마찬가지로 원래 프로세스와 동시에 실행할 수 있습니다. ( 참고: fork()
는 POSIX 시스템 호출이므로 Windows 플랫폼에서 Ruby를 실행하는 경우 사용할 수 없습니다.)
자, 테스트 케이스를 실행해 보겠습니다. 하지만 이번에는 fork()
를 사용하여 여러 프로세스를 사용합니다.
puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }
( Process.waitall
은 모든 자식 프로세스가 종료될 때까지 기다렸다가 프로세스 상태 배열을 반환합니다.)
이 코드는 이제 다음 결과를 생성합니다(MRI Ruby 2.0.0p353가 있는 쿼드 코어 프로세서에서).
0.000000 0.030000 27.000000 ( 3.788106)
너무 초라하지! 몇 줄의 코드를 수정하여(즉, fork()
사용) 메일러를 ~5배 더 빠르게 만들었습니다.
그렇다고 너무 흥분하지 마세요. 분기를 사용하는 것은 Ruby 동시성을 위한 쉬운 솔루션이기 때문에 사용하고 싶을 수 있지만 소비할 메모리의 양이라는 주요 단점이 있습니다. 특히 사용 중인 Ruby 인터프리터에서 CoW(Copy-on-Write)를 사용하지 않는 경우 포크는 다소 비용이 많이 듭니다. 예를 들어 앱에서 20MB의 메모리를 사용하는 경우 앱을 100번 포크하면 최대 2GB의 메모리가 소모될 수 있습니다!
또한 멀티스레딩에도 자체 복잡성이 있지만 공유 파일 디스크립터 및 세마포어(부모와 자식 분기 프로세스 간)와 같이 fork()
를 사용할 때 고려해야 할 여러 복잡성이 있으며 파이프를 통해 통신해야 합니다. , 등등.
루비 멀티스레딩
자, 이제 Ruby 멀티스레딩 기술을 대신 사용하여 동일한 프로그램을 더 빠르게 만들어 보겠습니다.
단일 프로세스 내의 다중 스레드는 주소 공간과 메모리를 공유하기 때문에 해당 수의 프로세스보다 오버헤드가 상당히 적습니다.
이를 염두에 두고 테스트 케이스를 다시 살펴보겠습니다. 하지만 이번에는 Ruby의 Thread
클래스를 사용합니다.
threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
이 코드는 이제 다음 결과를 생성합니다(MRI Ruby 2.0.0p353가 있는 쿼드 코어 프로세서에서).
13.710000 0.040000 13.750000 ( 13.740204)
버머. 그다지 인상적이지는 않습니다! 무슨 일이야? 코드를 동기적으로 실행할 때 얻은 결과와 거의 동일한 결과가 생성되는 이유는 무엇입니까?
많은 Ruby 프로그래머의 존재의 골칫거리인 대답은 GIL(Global Interpreter Lock) 입니다. GIL 덕분에 CRuby(MRI 구현)는 실제로 스레딩을 지원하지 않습니다.
Global Interpreter Lock은 컴퓨터 언어 인터프리터에서 스레드 실행을 동기화하여 한 번에 하나의 스레드만 실행할 수 있도록 하는 메커니즘입니다. GIL을 사용하는 인터프리터는 멀티 코어 프로세서에서 실행되더라도 항상 정확히 하나의 스레드와 하나의 스레드만 한 번에 실행 하도록 허용합니다. Ruby MRI 및 CPython은 GIL이 있는 인기 있는 인터프리터의 가장 일반적인 두 가지 예입니다.
그래서 우리의 문제로 돌아가서 GIL에 비추어 성능을 향상시키기 위해 Ruby에서 멀티스레딩을 어떻게 활용할 수 있습니까?
음, MRI(CRuby)에서 불행한 대답은 기본적으로 고정되어 있고 멀티스레딩이 사용자를 위해 할 수 있는 일은 거의 없다는 것입니다.

병렬 처리가 없는 Ruby 동시성은 IO가 많은 작업(예: 네트워크에서 자주 기다려야 하는 작업)에 여전히 매우 유용할 수 있습니다. 따라서 스레드 는 IO가 많은 작업의 경우 MRI에서 여전히 유용할 수 있습니다. 멀티 코어 서버가 보편화되기 전에 쓰레드가 발명되고 사용된 데에는 이유가 있습니다.
하지만 CRuby 이외의 버전을 사용할 수 있는 옵션이 있는 경우 JRuby 또는 Rubinius와 같은 대체 Ruby 구현을 사용할 수 있습니다. GIL이 없고 실제 병렬 Ruby 스레딩을 지원하기 때문입니다.
요점을 증명하기 위해 이전과 똑같은 스레드 버전의 코드를 실행할 때 얻은 결과입니다. 하지만 이번에는 (CRuby 대신) JRuby에서 실행합니다.
43.240000 0.140000 43.380000 ( 5.655000)
지금 우리는 '말하고 있습니다!
하지만…
스레드는 무료가 아닙니다
다중 스레드로 향상된 성능은 우리가 더 빠르고 더 빠르게 코드를 계속 실행하기 위해 기본적으로 무한히 더 많은 스레드를 계속 추가할 수 있다고 믿게 만들 수 있습니다. 그것이 사실이라면 참으로 좋겠지만 현실은 스레드가 무료가 아니므로 조만간 리소스가 고갈될 것입니다.
예를 들어 샘플 메일러를 100번이 아니라 10,000번 실행하고 싶다고 가정해 보겠습니다. 무슨 일이 일어나는지 보자:
threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
팔! 약 2,000개의 스레드를 생성한 후 OS X 10.8에서 오류가 발생했습니다.
can't create Thread: Resource temporarily unavailable (ThreadError)
예상대로 조만간 우리는 스래싱을 시작하거나 리소스가 완전히 고갈됩니다. 따라서 이 접근 방식의 확장성은 분명히 제한됩니다.
스레드 풀링
다행히 더 좋은 방법이 있습니다. 즉, 스레드 풀링.
스레드 풀은 필요에 따라 작업을 수행하는 데 사용할 수 있는 재사용 가능한 미리 인스턴스화된 스레드 그룹입니다. 스레드 풀은 긴 작업의 적은 수보다 수행할 짧은 작업의 수가 많을 때 특히 유용합니다. 이렇게 하면 스레드를 여러 번 생성하는 오버헤드가 발생하는 것을 방지할 수 있습니다.
스레드 풀의 주요 구성 매개변수는 일반적으로 풀의 스레드 수입니다. 이러한 스레드는 한 번에 모두(즉, 풀이 생성될 때) 또는 느리게(즉, 풀의 최대 스레드 수가 생성될 때까지 필요에 따라) 인스턴스화될 수 있습니다.
풀에 수행할 작업이 전달되면 현재 유휴 스레드 중 하나에 작업을 할당합니다. 유휴 상태인 스레드가 없으면(그리고 최대 스레드 수가 이미 생성된 경우) 스레드가 작업을 완료하고 유휴 상태가 될 때까지 기다린 다음 해당 스레드에 작업을 할당합니다.
따라서 예제로 돌아가서 Queue
(스레드 안전 데이터 유형이므로)를 사용하여 시작하고 스레드 풀의 간단한 구현을 사용합니다.
"./lib/mailer" 필요 "벤치마크" 필요 '스레드' 필요
POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)
위의 코드에서는 수행해야 하는 작업에 대한 jobs
대기열을 만드는 것으로 시작했습니다. Queue는 스레드로부터 안전하기 때문에(따라서 여러 스레드가 동시에 액세스해도 일관성을 유지하므로) 뮤텍스를 사용해야 하는 더 복잡한 구현이 필요하지 않기 때문에 이 용도로 Queue
를 사용했습니다.
그런 다음 메일러의 ID를 작업 대기열에 푸시하고 10개의 작업자 스레드 풀을 만들었습니다.
각 작업자 스레드 내에서 작업 대기열에서 항목을 팝합니다.
따라서 작업자 스레드의 수명 주기는 작업이 작업 큐에 들어갈 때까지 계속 대기하고 실행하는 것입니다.
따라서 좋은 소식은 이것이 문제 없이 작동하고 확장된다는 것입니다. 그러나 불행히도 이것은 간단한 튜토리얼의 경우에도 상당히 복잡합니다.
셀룰로이드
Ruby Gem 에코시스템 덕분에 멀티스레딩의 복잡성 대부분은 즉시 사용할 수 있는 사용하기 쉬운 여러 Ruby Gem으로 깔끔하게 캡슐화됩니다.
좋은 예는 내가 가장 좋아하는 루비 보석 중 하나인 셀룰로이드입니다. 셀룰로이드 프레임워크는 Ruby에서 액터 기반 동시 시스템을 구현하는 간단하고 깔끔한 방법입니다. Celluloid를 사용하면 사람들이 순차 개체에서 순차 프로그램을 구축하는 것처럼 쉽게 동시 개체에서 동시 프로그램을 구축할 수 있습니다.
이 게시물의 논의 맥락에서 저는 특히 Pools 기능에 초점을 맞추고 있지만 스스로에게 호의를 베풀고 더 자세히 확인하십시오. Celluloid를 사용하면 교착 상태와 같은 심각한 문제에 대해 걱정하지 않고 다중 스레드 Ruby 프로그램을 구축할 수 있으며 Future 및 Promises와 같은 보다 정교한 기능을 사용하는 것이 간단하다는 것을 알게 될 것입니다.
다음은 메일러 프로그램의 다중 스레드 버전이 Celluloid를 사용하는 방법입니다.
require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end
깨끗하고, 쉽고, 확장 가능하고, 강력합니다. 무엇을 더 요청할 수 있습니까?
백그라운드 작업
물론 운영 요구 사항 및 제약 조건에 따라 잠재적으로 실행 가능한 또 다른 대안은 백그라운드 작업을 사용하는 것입니다. 백그라운드 처리(즉, 대기열에 작업을 저장하고 현재 스레드를 차단하지 않고 나중에 처리)를 지원하기 위해 여러 Ruby Gem이 있습니다. 주목할만한 예로 Sidekiq, Resque, Delayed Job 및 Beanstalkd가 있습니다.
이 게시물에서는 Sidekiq 및 Redis(오픈 소스 키-값 캐시 및 저장소)를 사용합니다.
먼저 Redis를 설치하고 로컬에서 실행해 보겠습니다.
brew install redis redis-server /usr/local/etc/redis.conf
로컬 Redis 인스턴스가 실행 중인 상태에서 Sidekiq를 사용하는 샘플 메일러 프로그램( mail_worker.rb
) 버전을 살펴보겠습니다.
require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end
mail_worker.rb
파일을 사용하여 Sidekiq를 트리거할 수 있습니다.
sidekiq -r ./mail_worker.rb
그리고 IRB에서:
⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100
엄청나게 간단합니다. 또한 작업자 수만 변경하면 쉽게 확장할 수 있습니다.
또 다른 옵션은 내가 가장 좋아하는 비동기 RoR 처리 라이브러리 중 하나인 Sucker Punch를 사용하는 것입니다. Sucker Punch를 사용한 구현은 매우 유사합니다. Sidekiq::Worker
대신 SuckerPunch::Job
을 포함하고 MailWorker.new.async.perform()
대신 MailWorker.perform_async()
만 포함하면 됩니다.
결론
높은 동시성은 Ruby에서 달성할 수 있을 뿐만 아니라 생각보다 간단합니다.
실행 가능한 접근 방식 중 하나는 실행 중인 프로세스를 분기하여 처리 능력을 배가하는 것입니다. 또 다른 기술은 멀티스레딩을 활용하는 것입니다. 스레드는 프로세스보다 가볍고 오버헤드가 적지만 동시에 너무 많은 스레드를 시작하면 리소스가 부족할 수 있습니다. 어느 시점에서 스레드 풀을 사용해야 할 수도 있습니다. 다행스럽게도 Celluloid 및 해당 Actor 모델과 같은 사용 가능한 많은 보석을 활용하면 멀티스레딩의 복잡성이 훨씬 더 쉬워집니다.
시간이 많이 걸리는 프로세스를 처리하는 또 다른 방법은 백그라운드 처리를 사용하는 것입니다. 애플리케이션에서 백그라운드 작업을 구현할 수 있는 많은 라이브러리와 서비스가 있습니다. 일부 인기 있는 도구에는 데이터베이스 지원 작업 프레임워크 및 메시지 대기열이 있습니다.
포크, 스레딩 및 백그라운드 처리는 모두 실행 가능한 대안입니다. 사용할 애플리케이션에 대한 결정은 애플리케이션의 특성, 운영 환경 및 요구 사항에 따라 다릅니다. 이 튜토리얼이 사용 가능한 옵션에 대한 유용한 소개를 제공했기를 바랍니다.