require, loadの高速化を行うbootsnap(1.2.0)のコードリーディングをしました。今回はrequireのパス検索の高速化部分を読んでいきます。
railsではboot/setupをrequireしますが、最終的にBootsnap.setupが呼ばれるのでここからコードを読んでいきます。
Bootsnap.setupの定義は以下の通りで、requireのパス検索を高速化するのはBootsnap::LoadPathCache.setupのところになります。
module Bootsnap
InvalidConfiguration = Class.new(StandardError)
def self.setup(
cache_dir:,
development_mode: true,
load_path_cache: true,
autoload_paths_cache: true,
disable_trace: false,
compile_cache_iseq: true,
compile_cache_yaml: true
)
if autoload_paths_cache && !load_path_cache
raise InvalidConfiguration, "feature 'autoload_paths_cache' depends on feature 'load_path_cache'"
end
setup_disable_trace if disable_trace
Bootsnap::LoadPathCache.setup(
cache_path: cache_dir + '/bootsnap-load-path-cache',
development_mode: development_mode,
active_support: autoload_paths_cache
) if load_path_cache
Bootsnap::CompileCache.setup(
cache_dir: cache_dir + '/bootsnap-compile-cache',
iseq: compile_cache_iseq,
yaml: compile_cache_yaml
)
end
def self.setup_disable_trace
RubyVM::InstructionSequence.compile_option = { trace_instruction: false }
end
end
Bootsnap::LoadPathCache.setupはload_pach_cache/core_ext/kernel_require.rbをrequireします。以下、通常のrequire, loadのパターンを追っていきます(今回はActiveSupportのautoloadは追いません)
module Bootsnap
module LoadPathCache
class << self
attr_reader :load_path_cache, :autoload_paths_cache, :loaded_features_index
def setup(cache_path:, development_mode:, active_support: true)
store = Store.new(cache_path)
@loaded_features_index = LoadedFeaturesIndex.new
@load_path_cache = Cache.new(store, $LOAD_PATH, development_mode: development_mode)
require_relative 'load_path_cache/core_ext/kernel_require'
if active_support
require 'active_support/dependencies'
@autoload_paths_cache = Cache.new(
store,
::ActiveSupport::Dependencies.autoload_paths,
development_mode: development_mode
)
require_relative 'load_path_cache/core_ext/active_support'
end
load_pach_cache/core_ext/kernel_require.rbでは以下のようにKernelに対してモンキーパッチしており、requireやloadメソッドが新しいメソッドに置き換えられます。LoadPathCache.load_path_cache.find
で指定した文字列に対して読み込むファイルの絶対パスを返してresolved変数にセットし、そのresolvedを引数に元のrequireを呼び出しています。絶対パスで指定した場合は$LOAD_PATHを辿らないため高速に読み込めるそうです。
module Kernel
private
alias_method :require_without_bootsnap, :require
# Note that require registers to $LOADED_FEATURES while load does not.
def require_with_bootsnap_lfi(path, resolved = nil)
Bootsnap::LoadPathCache.loaded_features_index.register(path, resolved) do
require_without_bootsnap(resolved || path)
end
end
def require(path)
return false if Bootsnap::LoadPathCache.loaded_features_index.key?(path)
if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
return require_with_bootsnap_lfi(path, resolved)
end
raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
rescue Bootsnap::LoadPathCache::ReturnFalse
return false
rescue Bootsnap::LoadPathCache::FallbackScan
require_with_bootsnap_lfi(path)
end
LoadPathCache.load_path_cacheはBootsnap::LoadPathCache::Cacheのインスタンスです。以下のようにコンストラクタで#reinitializeを呼び出します。
module Bootsnap
module LoadPathCache
class Cache
def initialize(store, path_obj, development_mode: false)
@development_mode = development_mode
@store = store
@mutex = defined?(::Mutex) ? ::Mutex.new : ::Thread::Mutex.new # TODO: Remove once Ruby 2.2 support is dropped.
@path_obj = path_obj
@has_relative_paths = nil
reinitialize
end
#reinitializeはpush_paths_lockedで@indexと@dirsをセットします。
def reinitialize(path_obj = @path_obj)
@mutex.synchronize do
@path_obj = path_obj
ChangeObserver.register(self, @path_obj)
@index = {}
@dirs = Hash.new(false)
@generated_at = now
push_paths_locked(*@path_obj)
end
end
def push_paths_locked(*paths)
@store.transaction do
paths.map(&:to_s).each do |path|
p = Path.new(path)
@has_relative_paths = true if p.relative?
next if p.non_directory?
entries, dirs = p.entries_and_dirs(@store)
# push -> low precedence -> set only if unset
dirs.each { |dir| @dirs[dir] ||= true }
entries.each { |rel| @index[rel] ||= p.expanded_path }
end
end
end
pathsは$LOAD_PATHになります。$LOAD_PATHからファイルとディレクトリの相対パスを取り出して@dirsと@indexをセットしています。@indexはファイル名をキーに$LOAD_PATHの絶対パスを値にしたハッシュになります。
Cache#findは以下のように定義されています。
def find(feature)
reinitialize if (@has_relative_paths && dir_changed?) || stale?
feature = feature.to_s
return feature if absolute_path?(feature)
return File.expand_path(feature) if feature.start_with?('./')
@mutex.synchronize do
x = search_index(feature)
return x if x
# Ruby has some built-in features that require lies about.
# For example, 'enumerator' is built in. If you require it, ruby
# returns false as if it were already loaded; however, there is no
# file to find on disk. We've pre-built a list of these, and we
# return false if any of them is loaded.
raise LoadPathCache::ReturnFalse if BUILTIN_FEATURES.key?(feature)
# ...
search_indexは@indexにファイル名のキーが存在すれば絶対パス化した文字列を返しています。
if DLEXT2
def search_index(f)
try_index(f + DOT_RB) || try_index(f + DLEXT) || try_index(f + DLEXT2) || try_index(f)
end
else
def search_index(f)
try_index(f + DOT_RB) || try_index(f + DLEXT) || try_index(f)
end
end
def try_index(f)
if p = @index[f]
p + '/' + f
end
end
また、各ファイルのエントリーとディレクトリはキャッシュしています。store.setでハッシュに格納しています。
def entries_and_dirs(store)
if stable?
# the cached_mtime field is unused for 'stable' paths, but is
# set to zero anyway, just in case we change the stability heuristics.
_, entries, dirs = store.get(expanded_path)
return [entries, dirs] if entries # cache hit
entries, dirs = scan!
store.set(expanded_path, [0, entries, dirs])
return [entries, dirs]
end
cached_mtime, entries, dirs = store.get(expanded_path)
current_mtime = latest_mtime(expanded_path, dirs || [])
return [[], []] if current_mtime == -1 # path does not exist
return [entries, dirs] if cached_mtime == current_mtime
entries, dirs = scan!
store.set(expanded_path, [current_mtime, entries, dirs])
[entries, dirs]
end
このハッシュ値はBootsnap::LoadPathCache::Store#dump_data(#transaction経由)によってMessagePack形式でファイルとして格納されます。これが tmp/cache/bootsnap-load-path-cacheのファイルになります。
module Bootsnap
module LoadPathCache
class Store
def dump_data
# Change contents atomically so other processes can't get invalid
# caches if they read at an inopportune time.
tmp = "#{@store_path}.#{Process.pid}.#{(rand * 100000).to_i}.tmp"
FileUtils.mkpath(File.dirname(tmp))
exclusive_write = File::Constants::CREAT | File::Constants::EXCL | File::Constants::WRONLY
# `encoding:` looks redundant wrt `binwrite`, but necessary on windows
# because binary is part of mode.
File.binwrite(tmp, MessagePack.dump(@data), mode: exclusive_write, encoding: Encoding::BINARY)
FileUtils.mv(tmp, @store_path)
rescue Errno::EEXIST
retry
end
Bootsnap::LoadPathCache::Path#entries_and_dirsメソッド内で#stable?によって分岐しているところですが、gemファイルなどの#stable? = trueなファイルは変更がないはずなのでentries, dirsを半永続的にキャッシュし、そうでなければmtimeが異なっていればキャッシュを使わずに再度ディレクトリを操作してentries, dirsを取得しています。