From c37d47e30897762145835e66ae752cce924af01d Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 30 Sep 2014 01:32:42 +0930 Subject: Soften the lock requirements when eager_load is disabled We don't need to fully disable concurrent requests: just ensure that loads are performed in isolation. --- actionpack/lib/action_dispatch.rb | 1 + .../action_dispatch/middleware/load_interlock.rb | 12 +++ .../lib/active_support/concurrency/share_lock.rb | 118 +++++++++++++++++++++ activesupport/lib/active_support/dependencies.rb | 81 ++++++++------ .../lib/active_support/dependencies/interlock.rb | 35 ++++++ .../rails/application/default_middleware_stack.rb | 38 +++++-- railties/test/application/middleware_test.rb | 25 ++++- 7 files changed, 263 insertions(+), 47 deletions(-) create mode 100644 actionpack/lib/action_dispatch/middleware/load_interlock.rb create mode 100644 activesupport/lib/active_support/concurrency/share_lock.rb create mode 100644 activesupport/lib/active_support/dependencies/interlock.rb diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index dcd3ee0644..f6336c8c7a 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -52,6 +52,7 @@ module ActionDispatch autoload :DebugExceptions autoload :ExceptionWrapper autoload :Flash + autoload :LoadInterlock autoload :ParamsParser autoload :PublicExceptions autoload :Reloader diff --git a/actionpack/lib/action_dispatch/middleware/load_interlock.rb b/actionpack/lib/action_dispatch/middleware/load_interlock.rb new file mode 100644 index 0000000000..cbe8d750fe --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/load_interlock.rb @@ -0,0 +1,12 @@ +require 'active_support/dependencies' +require 'rack/lock' + +module ActionDispatch + class LoadInterlock < ::Rack::Lock + FLAG = 'activesupport.dependency_race'.freeze + + def initialize(app, mutex = ::ActiveSupport::Dependencies.interlock) + super + end + end +end diff --git a/activesupport/lib/active_support/concurrency/share_lock.rb b/activesupport/lib/active_support/concurrency/share_lock.rb new file mode 100644 index 0000000000..e03f2cfc7a --- /dev/null +++ b/activesupport/lib/active_support/concurrency/share_lock.rb @@ -0,0 +1,118 @@ +require 'thread' +require 'monitor' + +module ActiveSupport + module Concurrency + class ShareLock + include MonitorMixin + + # We track Thread objects, instead of just using counters, because + # we need exclusive locks to be reentrant, and we need to be able + # to upgrade share locks to exclusive. + + + # If +loose_upgrades+ is false (the default), then a thread that + # is waiting on an Exclusive lock will continue to hold any Share + # lock that it has already established. This is safer, but can + # lead to deadlock. + # + # If +loose_upgrades+ is true, a thread waiting on an Exclusive + # lock will temporarily relinquish its Share lock. Being less + # strict, this behavior prevents some classes of deadlocks. For + # many resources, loose upgrades are sufficient: if a thread is + # awaiting a lock, it is not running any other code. + attr_reader :loose_upgrades + + def initialize(loose_upgrades = false) + @loose_upgrades = loose_upgrades + + super() + + @cv = new_cond + + @sharing = Hash.new(0) + @exclusive_thread = nil + @exclusive_depth = 0 + end + + def start_exclusive(no_wait=false) + synchronize do + unless @exclusive_thread == Thread.current + return false if no_wait && busy? + + loose_shares = nil + if @loose_upgrades + loose_shares = @sharing.delete(Thread.current) + end + + @cv.wait_while { busy? } if busy? + + @exclusive_thread = Thread.current + @sharing[Thread.current] = loose_shares if loose_shares + end + @exclusive_depth += 1 + + true + end + end + + def stop_exclusive + synchronize do + raise "invalid unlock" if @exclusive_thread != Thread.current + + @exclusive_depth -= 1 + if @exclusive_depth == 0 + @exclusive_thread = nil + @cv.broadcast + end + end + end + + def start_sharing + synchronize do + if @exclusive_thread && @exclusive_thread != Thread.current + @cv.wait_while { @exclusive_thread } + end + @sharing[Thread.current] += 1 + end + end + + def stop_sharing + synchronize do + if @sharing[Thread.current] > 1 + @sharing[Thread.current] -= 1 + else + @sharing.delete Thread.current + @cv.broadcast + end + end + end + + def exclusive(no_wait=false) + if start_exclusive(no_wait) + begin + yield + ensure + stop_exclusive + end + end + end + + def sharing + start_sharing + begin + yield + ensure + stop_sharing + end + end + + private + + def busy? + (@exclusive_thread && @exclusive_thread != Thread.current) || + @sharing.size > (@sharing[Thread.current] > 0 ? 1 : 0) + end + end + end +end diff --git a/activesupport/lib/active_support/dependencies.rb b/activesupport/lib/active_support/dependencies.rb index 664cc15a29..2a0e06495f 100644 --- a/activesupport/lib/active_support/dependencies.rb +++ b/activesupport/lib/active_support/dependencies.rb @@ -12,12 +12,16 @@ require 'active_support/core_ext/kernel/reporting' require 'active_support/core_ext/load_error' require 'active_support/core_ext/name_error' require 'active_support/core_ext/string/starts_ends_with' +require "active_support/dependencies/interlock" require 'active_support/inflector' module ActiveSupport #:nodoc: module Dependencies #:nodoc: extend self + mattr_accessor :interlock + self.interlock = Interlock.new + # Should we turn on Ruby warnings on the first load of dependent files? mattr_accessor :warnings_on_first_load self.warnings_on_first_load = false @@ -234,10 +238,12 @@ module ActiveSupport #:nodoc: end def load_dependency(file) - if Dependencies.load? && ActiveSupport::Dependencies.constant_watch_stack.watching? - Dependencies.new_constants_in(Object) { yield } - else - yield + Dependencies.interlock.loading do + if Dependencies.load? && ActiveSupport::Dependencies.constant_watch_stack.watching? + Dependencies.new_constants_in(Object) { yield } + else + yield + end end rescue Exception => exception # errors from loading file exception.blame_file! file if exception.respond_to? :blame_file! @@ -325,9 +331,11 @@ module ActiveSupport #:nodoc: def clear log_call - loaded.clear - loading.clear - remove_unloadable_constants! + Dependencies.interlock.loading do + loaded.clear + loading.clear + remove_unloadable_constants! + end end def require_or_load(file_name, const_path = nil) @@ -336,39 +344,44 @@ module ActiveSupport #:nodoc: expanded = File.expand_path(file_name) return if loaded.include?(expanded) - # Record that we've seen this file *before* loading it to avoid an - # infinite loop with mutual dependencies. - loaded << expanded - loading << expanded - - begin - if load? - log "loading #{file_name}" + Dependencies.interlock.loading do + # Maybe it got loaded while we were waiting for our lock: + return if loaded.include?(expanded) - # Enable warnings if this file has not been loaded before and - # warnings_on_first_load is set. - load_args = ["#{file_name}.rb"] - load_args << const_path unless const_path.nil? + # Record that we've seen this file *before* loading it to avoid an + # infinite loop with mutual dependencies. + loaded << expanded + loading << expanded - if !warnings_on_first_load or history.include?(expanded) - result = load_file(*load_args) + begin + if load? + log "loading #{file_name}" + + # Enable warnings if this file has not been loaded before and + # warnings_on_first_load is set. + load_args = ["#{file_name}.rb"] + load_args << const_path unless const_path.nil? + + if !warnings_on_first_load or history.include?(expanded) + result = load_file(*load_args) + else + enable_warnings { result = load_file(*load_args) } + end else - enable_warnings { result = load_file(*load_args) } + log "requiring #{file_name}" + result = require file_name end - else - log "requiring #{file_name}" - result = require file_name + rescue Exception + loaded.delete expanded + raise + ensure + loading.pop end - rescue Exception - loaded.delete expanded - raise - ensure - loading.pop - end - # Record history *after* loading so first load gets warnings. - history << expanded - result + # Record history *after* loading so first load gets warnings. + history << expanded + result + end end # Is the provided constant path defined? diff --git a/activesupport/lib/active_support/dependencies/interlock.rb b/activesupport/lib/active_support/dependencies/interlock.rb new file mode 100644 index 0000000000..c3601bac13 --- /dev/null +++ b/activesupport/lib/active_support/dependencies/interlock.rb @@ -0,0 +1,35 @@ +require 'active_support/concurrency/share_lock' + +module ActiveSupport + module Dependencies #:nodoc: + class Interlock + def initialize + @lock = ActiveSupport::Concurrency::ShareLock.new(true) + end + + def loading + @lock.exclusive do + yield + end + end + + def start_running + @lock.start_sharing + end + + def done_running + @lock.stop_sharing + end + + def running + @lock.sharing do + yield + end + end + + # Match the Mutex API, so we can be used by Rack::Lock + alias :lock :start_running + alias :unlock :done_running + end + end +end diff --git a/railties/lib/rails/application/default_middleware_stack.rb b/railties/lib/rails/application/default_middleware_stack.rb index 6f9ccec137..14ea073039 100644 --- a/railties/lib/rails/application/default_middleware_stack.rb +++ b/railties/lib/rails/application/default_middleware_stack.rb @@ -26,7 +26,35 @@ module Rails middleware.use ::Rack::Cache, rack_cache end - middleware.use ::Rack::Lock unless allow_concurrency? + if config.allow_concurrency == false + # User has explicitly opted out of concurrent request + # handling: presumably their code is not threadsafe + + middleware.use ::Rack::Lock + + elsif config.allow_concurrency + # Do nothing, even if we know this is dangerous + + else + # Default concurrency setting + + if config.cache_classes && config.eager_load + # No lock required + + elsif config.cache_classes + # The load interlock is required, but not a full request + # lock + + middleware.use ::ActionDispatch::LoadInterlock + + else + # If we're reloading on each request, they all need to be + # run in isolation + + middleware.use ::Rack::Lock + end + end + middleware.use ::Rack::Runtime middleware.use ::Rack::MethodOverride unless config.api_only middleware.use ::ActionDispatch::RequestId @@ -65,14 +93,6 @@ module Rails config.reload_classes_only_on_change != true || app.reloaders.map(&:updated?).any? end - def allow_concurrency? - if config.allow_concurrency.nil? - config.cache_classes && config.eager_load - else - config.allow_concurrency - end - end - def load_rack_cache rack_cache = config.action_dispatch.rack_cache return unless rack_cache diff --git a/railties/test/application/middleware_test.rb b/railties/test/application/middleware_test.rb index ce92ebbf66..0ea2c57c55 100644 --- a/railties/test/application/middleware_test.rb +++ b/railties/test/application/middleware_test.rb @@ -121,23 +121,40 @@ module ApplicationTests assert !middleware.include?("ActiveRecord::Migration::CheckPending") end - test "includes lock if cache_classes is set but eager_load is not" do + test "includes interlock if cache_classes is set but eager_load is not" do add_to_config "config.cache_classes = true" boot! - assert middleware.include?("Rack::Lock") + assert_not_includes middleware, "Rack::Lock" + assert_includes middleware, "ActionDispatch::LoadInterlock" + end + + test "includes lock if cache_classes is off" do + add_to_config "config.cache_classes = false" + boot! + assert_includes middleware, "Rack::Lock" + assert_not_includes middleware, "ActionDispatch::LoadInterlock" end test "does not include lock if cache_classes is set and so is eager_load" do add_to_config "config.cache_classes = true" add_to_config "config.eager_load = true" boot! - assert !middleware.include?("Rack::Lock") + assert_not_includes middleware, "Rack::Lock" + assert_not_includes middleware, "ActionDispatch::LoadInterlock" end test "does not include lock if allow_concurrency is set" do add_to_config "config.allow_concurrency = true" boot! - assert !middleware.include?("Rack::Lock") + assert_not_includes middleware, "Rack::Lock" + assert_not_includes middleware, "ActionDispatch::LoadInterlock" + end + + test "includes lock if allow_concurrency is disabled" do + add_to_config "config.allow_concurrency = false" + boot! + assert_includes middleware, "Rack::Lock" + assert_not_includes middleware, "ActionDispatch::LoadInterlock" end test "removes static asset server if serve_static_files is disabled" do -- cgit v1.2.3 From 383fed5f232630c198847ec573821ede4cf267a9 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 30 Sep 2014 01:46:07 +0930 Subject: Rely on the load interlock for non-caching reloads, too --- .../lib/active_support/dependencies/interlock.rb | 10 ++++++++++ .../rails/application/default_middleware_stack.rb | 22 +++++++--------------- railties/lib/rails/application/finisher.rb | 6 ++++-- railties/test/application/middleware_test.rb | 14 +++++++------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/activesupport/lib/active_support/dependencies/interlock.rb b/activesupport/lib/active_support/dependencies/interlock.rb index c3601bac13..035802d680 100644 --- a/activesupport/lib/active_support/dependencies/interlock.rb +++ b/activesupport/lib/active_support/dependencies/interlock.rb @@ -13,6 +13,16 @@ module ActiveSupport end end + # Attempt to obtain a "loading" (exclusive) lock. If possible, + # execute the supplied block while holding the lock. If there is + # concurrent activity, return immediately (without executing the + # block) instead of waiting. + def attempt_loading + @lock.exclusive(true) do + yield + end + end + def start_running @lock.start_sharing end diff --git a/railties/lib/rails/application/default_middleware_stack.rb b/railties/lib/rails/application/default_middleware_stack.rb index 14ea073039..88eade5c5a 100644 --- a/railties/lib/rails/application/default_middleware_stack.rb +++ b/railties/lib/rails/application/default_middleware_stack.rb @@ -32,26 +32,18 @@ module Rails middleware.use ::Rack::Lock - elsif config.allow_concurrency - # Do nothing, even if we know this is dangerous + elsif config.allow_concurrency == :unsafe + # Do nothing, even if we know this is dangerous. This is the + # historical behaviour for true. else - # Default concurrency setting + # Default concurrency setting: enabled, but safe - if config.cache_classes && config.eager_load - # No lock required - - elsif config.cache_classes - # The load interlock is required, but not a full request - # lock + unless config.cache_classes && config.eager_load + # Without cache_classes + eager_load, the load interlock + # is required for proper operation middleware.use ::ActionDispatch::LoadInterlock - - else - # If we're reloading on each request, they all need to be - # run in isolation - - middleware.use ::Rack::Lock end end diff --git a/railties/lib/rails/application/finisher.rb b/railties/lib/rails/application/finisher.rb index 0599e988d9..f8f92792a7 100644 --- a/railties/lib/rails/application/finisher.rb +++ b/railties/lib/rails/application/finisher.rb @@ -86,8 +86,10 @@ module Rails # added in the hook are taken into account. initializer :set_clear_dependencies_hook, group: :all do callback = lambda do - ActiveSupport::DescendantsTracker.clear - ActiveSupport::Dependencies.clear + ActiveSupport::Dependencies.interlock.attempt_loading do + ActiveSupport::DescendantsTracker.clear + ActiveSupport::Dependencies.clear + end end if config.reload_classes_only_on_change diff --git a/railties/test/application/middleware_test.rb b/railties/test/application/middleware_test.rb index 0ea2c57c55..d298e8d632 100644 --- a/railties/test/application/middleware_test.rb +++ b/railties/test/application/middleware_test.rb @@ -26,7 +26,7 @@ module ApplicationTests assert_equal [ "Rack::Sendfile", "ActionDispatch::Static", - "Rack::Lock", + "ActionDispatch::LoadInterlock", "ActiveSupport::Cache::Strategy::LocalCache", "Rack::Runtime", "Rack::MethodOverride", @@ -58,7 +58,7 @@ module ApplicationTests assert_equal [ "Rack::Sendfile", "ActionDispatch::Static", - "Rack::Lock", + "ActionDispatch::LoadInterlock", "ActiveSupport::Cache::Strategy::LocalCache", "Rack::Runtime", "ActionDispatch::RequestId", @@ -128,11 +128,11 @@ module ApplicationTests assert_includes middleware, "ActionDispatch::LoadInterlock" end - test "includes lock if cache_classes is off" do + test "includes interlock if cache_classes is off" do add_to_config "config.cache_classes = false" boot! - assert_includes middleware, "Rack::Lock" - assert_not_includes middleware, "ActionDispatch::LoadInterlock" + assert_not_includes middleware, "Rack::Lock" + assert_includes middleware, "ActionDispatch::LoadInterlock" end test "does not include lock if cache_classes is set and so is eager_load" do @@ -143,8 +143,8 @@ module ApplicationTests assert_not_includes middleware, "ActionDispatch::LoadInterlock" end - test "does not include lock if allow_concurrency is set" do - add_to_config "config.allow_concurrency = true" + test "does not include lock if allow_concurrency is set to :unsafe" do + add_to_config "config.allow_concurrency = :unsafe" boot! assert_not_includes middleware, "Rack::Lock" assert_not_includes middleware, "ActionDispatch::LoadInterlock" -- cgit v1.2.3 From 48a735aff7032afaf5534613b10c635acead042a Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 7 Apr 2015 05:51:43 +0930 Subject: Fix the Interlock middleware We can't actually lean on Rack::Lock's implementation, so we'll just copy it instead. It's simple enough that that's not too troubling. --- .../lib/action_dispatch/middleware/load_interlock.rb | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/actionpack/lib/action_dispatch/middleware/load_interlock.rb b/actionpack/lib/action_dispatch/middleware/load_interlock.rb index cbe8d750fe..07f498319c 100644 --- a/actionpack/lib/action_dispatch/middleware/load_interlock.rb +++ b/actionpack/lib/action_dispatch/middleware/load_interlock.rb @@ -1,12 +1,21 @@ require 'active_support/dependencies' -require 'rack/lock' +require 'rack/body_proxy' module ActionDispatch - class LoadInterlock < ::Rack::Lock - FLAG = 'activesupport.dependency_race'.freeze + class LoadInterlock + def initialize(app) + @app = app + end - def initialize(app, mutex = ::ActiveSupport::Dependencies.interlock) - super + def call(env) + interlock = ActiveSupport::Dependencies.interlock + interlock.start_running + response = @app.call(env) + body = Rack::BodyProxy.new(response[2]) { interlock.done_running } + response[2] = body + response + ensure + interlock.done_running unless body end end end -- cgit v1.2.3 From 0b93c48bbe74857ead9a9ef56b35f87965edbb49 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Thu, 9 Jul 2015 04:33:14 +0930 Subject: Document ShareLock and the Interlock --- .../lib/active_support/concurrency/share_lock.rb | 20 +++++++++++++++++++ activesupport/lib/active_support/dependencies.rb | 23 +++++++++++++++++++--- .../lib/active_support/dependencies/interlock.rb | 8 ++------ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/activesupport/lib/active_support/concurrency/share_lock.rb b/activesupport/lib/active_support/concurrency/share_lock.rb index e03f2cfc7a..f1c6230084 100644 --- a/activesupport/lib/active_support/concurrency/share_lock.rb +++ b/activesupport/lib/active_support/concurrency/share_lock.rb @@ -3,6 +3,15 @@ require 'monitor' module ActiveSupport module Concurrency + # A share/exclusive lock, otherwise known as a read/write lock. + # + # https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock + #-- + # Note that a pending Exclusive lock attempt does not block incoming + # Share requests (i.e., we are "read-preferring"). That seems + # consistent with the behavior of +loose_upgrades+, but may be the + # wrong choice otherwise: it nominally reduces the possibility of + # deadlock by risking starvation instead. class ShareLock include MonitorMixin @@ -35,6 +44,9 @@ module ActiveSupport @exclusive_depth = 0 end + # Returns false if +no_wait+ is specified and the lock is not + # immediately available. Otherwise, returns true after the lock + # has been acquired. def start_exclusive(no_wait=false) synchronize do unless @exclusive_thread == Thread.current @@ -56,6 +68,8 @@ module ActiveSupport end end + # Relinquish the exclusive lock. Must only be called by the thread + # that called start_exclusive (and currently holds the lock). def stop_exclusive synchronize do raise "invalid unlock" if @exclusive_thread != Thread.current @@ -88,6 +102,10 @@ module ActiveSupport end end + # Execute the supplied block while holding the Exclusive lock. If + # +no_wait+ is set and the lock is not immediately available, + # returns +nil+ without yielding. Otherwise, returns the result of + # the block. def exclusive(no_wait=false) if start_exclusive(no_wait) begin @@ -98,6 +116,7 @@ module ActiveSupport end end + # Execute the supplied block while holding the Share lock. def sharing start_sharing begin @@ -109,6 +128,7 @@ module ActiveSupport private + # Must be called within synchronize def busy? (@exclusive_thread && @exclusive_thread != Thread.current) || @sharing.size > (@sharing[Thread.current] > 0 ? 1 : 0) diff --git a/activesupport/lib/active_support/dependencies.rb b/activesupport/lib/active_support/dependencies.rb index 2a0e06495f..770c845435 100644 --- a/activesupport/lib/active_support/dependencies.rb +++ b/activesupport/lib/active_support/dependencies.rb @@ -22,6 +22,23 @@ module ActiveSupport #:nodoc: mattr_accessor :interlock self.interlock = Interlock.new + # :doc: + + # Execute the supplied block without interference from any + # concurrent loads + def self.run_interlock + Dependencies.interlock.running { yield } + end + + # Execute the supplied block while holding an exclusive lock, + # preventing any other thread from being inside a #run_interlock + # block at the same time + def self.load_interlock + Dependencies.interlock.loading { yield } + end + + # :nodoc: + # Should we turn on Ruby warnings on the first load of dependent files? mattr_accessor :warnings_on_first_load self.warnings_on_first_load = false @@ -238,7 +255,7 @@ module ActiveSupport #:nodoc: end def load_dependency(file) - Dependencies.interlock.loading do + Dependencies.load_interlock do if Dependencies.load? && ActiveSupport::Dependencies.constant_watch_stack.watching? Dependencies.new_constants_in(Object) { yield } else @@ -331,7 +348,7 @@ module ActiveSupport #:nodoc: def clear log_call - Dependencies.interlock.loading do + Dependencies.load_interlock do loaded.clear loading.clear remove_unloadable_constants! @@ -344,7 +361,7 @@ module ActiveSupport #:nodoc: expanded = File.expand_path(file_name) return if loaded.include?(expanded) - Dependencies.interlock.loading do + Dependencies.load_interlock do # Maybe it got loaded while we were waiting for our lock: return if loaded.include?(expanded) diff --git a/activesupport/lib/active_support/dependencies/interlock.rb b/activesupport/lib/active_support/dependencies/interlock.rb index 035802d680..148212c951 100644 --- a/activesupport/lib/active_support/dependencies/interlock.rb +++ b/activesupport/lib/active_support/dependencies/interlock.rb @@ -1,9 +1,9 @@ require 'active_support/concurrency/share_lock' -module ActiveSupport +module ActiveSupport #:nodoc: module Dependencies #:nodoc: class Interlock - def initialize + def initialize # :nodoc: @lock = ActiveSupport::Concurrency::ShareLock.new(true) end @@ -36,10 +36,6 @@ module ActiveSupport yield end end - - # Match the Mutex API, so we can be used by Rack::Lock - alias :lock :start_running - alias :unlock :done_running end end end -- cgit v1.2.3