레일스의 스레딩과 코드 실행
이 가이드를 읽고 나면 다음을 알게 될 것입니다:
- 레일스가 자동으로 동시에 실행할 수 있는 코드
- 수동 동시성을 레일스 내부와 통합하는 방법
- 모든 애플리케이션 코드를 감싸는 방법
- 애플리케이션 재로딩에 영향을 미치는 방법
자동 동시성
레일스는 다양한 작업을 동시에 수행할 수 있도록 자동으로 허용합니다.
Puma와 같은 스레드 기반 웹 서버를 사용할 때, 여러 HTTP 요청이 동시에 처리되며, 각 요청에 자체 컨트롤러 인스턴스가 제공됩니다.
내장 Async를 포함한 스레드 기반 Active Job 어댑터 또한 여러 작업을 동시에 실행합니다. Action Cable 채널도 이와 같은 방식으로 관리됩니다.
이러한 메커니즘은 모두 고유한 객체(컨트롤러, 작업, 채널) 인스턴스에 대한 작업을 관리하는 여러 스레드를 포함하며, 전역 프로세스 공간(클래스와 해당 구성, 전역 변수 등)을 공유합니다. 코드가 이러한 공유 항목을 수정하지 않는 한, 다른 스레드의 존재를 대부분 무시할 수 있습니다.
이 가이드의 나머지 부분에서는 레일스가 이를 “대부분 무시할 수 있게” 만드는 메커니즘과 특별한 요구사항이 있는 확장 및 애플리케이션이 이를 사용하는 방법을 설명합니다.
실행자
레일스 실행자는 프레임워크 코드와 애플리케이션 코드를 분리합니다: 프레임워크가 작성한 코드를 호출할 때마다 실행자로 감싸집니다.
실행자는 to_run
과 to_complete
두 가지 콜백으로 구성됩니다. Run 콜백은 애플리케이션 코드 실행 전에 호출되며, Complete 콜백은 실행 후에 호출됩니다.
기본 콜백
기본 레일스 애플리케이션에서 실행자 콜백은 다음과 같은 용도로 사용됩니다:
- 자동 로딩 및 재로딩에 안전한 위치를 추적
- Active Record 쿼리 캐시 활성화 및 비활성화
- 획득한 Active Record 연결을 풀로 반환
- 내부 캐시 수명 제한
Rails 5.0 이전에는 이러한 작업이 별도의 Rack 미들웨어 클래스(예: ActiveRecord::ConnectionAdapters::ConnectionManagement
)나 ActiveRecord::Base.connection_pool.with_connection
과 같은 메서드로 직접 처리되었습니다. 실행자는 이를 단일 더 추상적인 인터페이스로 대체합니다.
애플리케이션 코드 감싸기
라이브러리나 구성 요소를 작성하여 애플리케이션 코드를 호출하는 경우, 실행자로 감싸야 합니다:
Rails.application.executor.wrap do # 여기에 애플리케이션 코드 호출 end
팁: 장기 실행 프로세스에서 애플리케이션 코드를 반복적으로 호출하는 경우 Reloader를 사용하는 것이 좋습니다.
각 스레드는 애플리케이션 코드를 실행하기 전에 실행자로 감싸야 합니다. 따라서 Thread.new
또는 스레드 풀을 사용하는 Concurrent Ruby 기능과 같이 애플리케이션이 수동으로 작업을 다른 스레드에 위임하는 경우, 즉시 블록으로 감싸야 합니다:
Thread.new do Rails.application.executor.wrap do # 여기에 코드 작성 end end
참고: Concurrent Ruby는 ThreadPoolExecutor
를 사용하며, 때때로 executor
옵션으로 구성합니다. 이름이 같지만 관련이 없습니다.
실행자는 안전하게 재진입할 수 있습니다. 현재 스레드에서 이미 활성화된 경우 wrap
은 아무 작업도 하지 않습니다.
애플리케이션 코드를 블록으로 감싸기가 실용적이지 않은 경우(예: Rack API에서 이렇게 하기 어려움), run!
/ complete!
쌍을 사용할 수 있습니다:
Thread.new do execution_context = Rails.application.executor.run! # 여기에 코드 작성 ensure execution_context.complete! if execution_context end
동시성
실행자는 현재 스레드를 Load Interlock의 running
모드에 넣습니다. 다른 스레드가 상수를 자동 로드하거나 애플리케이션을 언로드/재로드하고 있는 경우 이 작업은 일시적으로 차단됩니다.
Reloader
실행자와 마찬가지로 Reloader도 애플리케이션 코드를 감싸줍니다. 현재 스레드에서 실행자가 이미 활성화되어 있지 않은 경우 Reloader가 대신 호출합니다. 따라서 Reloader가 수행하는 모든 작업, 즉 모든 콜백 호출은 실행자 내부에서 발생합니다.
Rails.application.reloader.wrap do # 여기에 애플리케이션 코드 호출 end
Reloader는 장기 실행 프레임워크 수준 프로세스가 반복적으로 애플리케이션 코드를 호출하는 경우에만 적합합니다. 예를 들어 웹 서버나 작업 큐에서 사용할 수 있습니다. 대부분의 경우 실행자가 더 적합한 선택일 것입니다.
콜백
Reloader는 감싼 블록에 들어가기 전에 실행 중인 애플리케이션이 재로딩이 필요한지 확인합니다. 예를 들어 모델의 소스 파일이 수정되었는지 등입니다. 재로딩이 필요하다고 판단되면 안전할 때까지 기다린 후 재로딩을 수행하고 계속 진행합니다. 애플리케이션이 변경 여부와 관계없이 항상 재로딩하도록 구성된 경우 재로딩은 블록 끝에 수행됩니다.
Reloader는 to_run
과 to_complete
콜백도 제공합니다. 이 콜백은 실행자의 해당 콜백과 동일한 시점에 호출되지만, 현재 실행이 애플리케이션 재로딩을 시작한 경우에만 호출됩니다. 재로딩이 필요하지 않은 경우 Reloader는 추가 콜백 없이 감싼 블록을 실행합니다.
클래스 언로드
재로딩 프로세스의 가장 중요한 부분은 클래스 언로드입니다. 여기서 모든 자동 로드된 클래스가 제거되어 다시 로드될 준비가 됩니다. 이는 Run 또는 Complete 콜백 직전에 즉시 발생하며, reload_classes_only_on_change
설정에 따라 달라집니다.
종종 클래스 언로드 직전 또는 직후에 추가 재로딩 작업을 수행해야 하므로 Reloader는 before_class_unload
와 after_class_unload
콜백도 제공합니다.
동시성
Reloader는 오직 장기 실행 “최상위” 프로세스에서 호출해야 합니다. 재로딩이 필요하다고 판단되면 모든 다른 스레드가 실행자 호출을 완료할 때까지 차단되기 때문입니다.
“자식” 스레드에서 Reloader를 호출하면 피할 수 없는 교착 상태가 발생할 수 있습니다. 재로딩은 자식 스레드가 실행되기 전에 발생해야 하지만, 부모 스레드가 실행 중인 동안에는 안전하게 수행할 수 없습니다. 자식 스레드는 대신 실행자를 사용해야 합니다.
프레임워크 동작
레일스 프레임워크 구성 요소도 자체 동시성 요구사항을 관리하기 위해 이러한 도구를 사용합니다.
ActionDispatch::Executor
와 ActionDispatch::Reloader
는 제공된 실행자 또는 Reloader로 요청을 감싸는 Rack 미들웨어입니다. 이들은 기본 애플리케이션 스택에 자동으로 포함됩니다. Reloader는 코드 변경이 발생한 경우 새로 로드된 애플리케이션 사본으로 모든 HTTP 요청을 처리합니다.
Active Job도 작업 실행을 Reloader로 감싸, 큐에서 꺼낸 각 작업을 실행할 때 최신 코드를 로드합니다.
Action Cable은 대신 실행자를 사용합니다. Cable 연결이 특정 클래스의 인스턴스에 연결되어 있기 때문에 모든 WebSocket 메시지에 대해 재로딩하는 것은 불가능합니다. 메시지 핸들러만 감싸지만, 장기 실행 Cable 연결은 새로운 수신 요청이나 작업으로 인한 재로딩을 막지 않습니다. 대신 Action Cable은 Reloader의 before_class_unload
콜백을 사용하여 모든 연결을 끊습니다. 클라이언트가 자동으로 다시 연결하면 새 버전의 코드와 통신하게 됩니다.
위의 것들은 프레임워크의 진입점이므로 해당 스레드를 보호하고 재로딩이 필요한지 여부를 결정할 책임이 있습니다. 다른 구성 요소는 추가 스레드를 생성할 때만 실행자를 사용하면 됩니다.
구성
Reloader는 config.enable_reloading
이 true
이고 config.reload_classes_only_on_change
도 true
인 경우에만 파일 변경을 확인합니다. 이는 development
환경의 기본값입니다.
config.enable_reloading
이 false
인 경우(production
환경의 기본값), Reloader는 실행자로의 단순 전달만 수행합니다.
실행자는 데이터베이스 연결 관리와 같은 중요한 작업을 항상 수행합니다. config.enable_reloading
이 false
이고 config.eager_load
가 true
인 경우(production
환경의 기본값), 재로딩이 발생하지 않으므로 Load Interlock이 필요하지 않습니다. development
환경의 기본 설정에서 실행자는 Load Interlock을 사용하여 상수가 안전한 위치에서만 로드되도록 합니다.
Load Interlock
Load Interlock은 다중 스레드 런타임 환경에서 자동 로딩과 재로딩을 가능하게 합니다.
한 스레드가 적절한 파일에서 클래스 정의를 평가하여 자동 로드를 수행하는 동안, 다른 스레드가 부분적으로 정의된 상수를 참조하지 않도록 하는 것이 중요합니다.
마찬가지로, 애플리케이션 코드가 실행 중이 아닐 때만 안전하게 언로드/재로드를 수행할 수 있습니다. 그렇지 않으면 재로딩 후 User
상수가 다른 클래스를 가리킬 수 있으며, User.new.class == User
또는 User == User
가 거짓이 될 수 있습니다.
이 두 가지 제약 사항은네, 계속해서 한국어 번역을 제공하겠습니다.
Load Interlock은 이러한 제약 사항을 해결합니다. 현재 애플리케이션 코드를 실행 중인 스레드, 클래스를 로드 중인 스레드, 자동 로드된 상수를 언로드 중인 스레드를 추적합니다.
한 번에 하나의 스레드만 로드 또는 언로드할 수 있으며, 이를 수행하려면 다른 스레드가 애플리케이션 코드를 실행 중이지 않을 때까지 기다려야 합니다. 로드를 수행하려는 스레드가 대기 중이더라도 다른 스레드가 로드를 수행할 수 있습니다(실제로 그렇게 하며, 모든 대기 중인 로드를 차례로 수행한 후 모두 다시 실행됩니다).
permit_concurrent_loads
실행자는 자동으로 블록 전체에 대해 running
잠금을 획득하며, 자동 로드는 load
잠금으로 업그레이드하고 나중에 running
잠금으로 다시 전환합니다.
그러나 실행자 블록 내에서 수행되는 다른 차단 작업(애플리케이션 코드 전체 포함)은 불필요하게 running
잠금을 유지할 수 있습니다. 다른 스레드에서 자동 로드해야 할 상수를 만나면 교착 상태가 발생할 수 있습니다.
예를 들어 User
가 아직 로드되지 않았다고 가정하면 다음과 같은 경우 교착 상태가 발생합니다:
Rails.application.executor.wrap do th = Thread.new do Rails.application.executor.wrap do User # 내부 스레드가 여기서 대기; 다른 스레드가 # 실행 중인 동안 User를 로드할 수 없음 end end th.join # 외부 스레드가 여기서 대기, 'running' 잠금 보유 end
이 교착 상태를 방지하려면 외부 스레드에서 permit_concurrent_loads
를 호출할 수 있습니다. 이 메서드를 호출하면 제공된 블록 내에서 자동 로드된 상수를 참조하지 않겠다고 보장합니다. 이 약속을 지키는 가장 안전한 방법은 차단 호출 가까이에 두는 것입니다:
Rails.application.executor.wrap do th = Thread.new do Rails.application.executor.wrap do User # 내부 스레드가 'load' 잠금을 획득하고 # User를 로드한 후 계속 실행할 수 있음 end end ActiveSupport::Dependencies.interlock.permit_concurrent_loads do th.join # 외부 스레드가 여기서 대기하지만 잠금 없음 end end
Concurrent Ruby를 사용하는 다른 예:
Rails.application.executor.wrap do futures = 3.times.collect do |i| Concurrent::Promises.future do Rails.application.executor.wrap do # 여기에 작업 수행 end end end values = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do futures.collect(&:value) end end
ActionDispatch::DebugLocks
애플리케이션이 교착 상태에 빠지고 Load Interlock이 관련되어 있다고 생각되면 config/application.rb
에 ActionDispatch::DebugLocks 미들웨어를 추가할 수 있습니다:
config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks
그런 다음 애플리케이션을 다시 시작하고 교착 상태 조건을 다시 트리거하면 /rails/locks
에서 interlock에 알려진 모든 스레드의 요약, 보유 중이거나 대기 중인 잠금 수준, 현재 백트레이스를 볼 수 있습니다.
일반적으로 교착 상태는 interlock과 다른 외부 잠금 또는 차단 I/O 호출이 충돌하여 발생합니다. 원인을 찾으면 permit_concurrent_loads
로 감싸면 됩니다.