在 Ruby 中常量(constant)其實是可以變更的
DEFAULT_MESSAGE = "Hello"
DEFAULT_MESSAGE << "123"
puts DEFAULT_MESSAGE.inspect # => "Hello123"
透過使用 freeze
我們可以創建出一開始我們所預期不能更改的常數
DEFAULT_MESSAGE = "Hello".freeze
DEFAULT_MESSAGE << "123" # => FrozenError (can't modify frozen String)
不管在那個程式語言上,有很多時後會造成記憶體洩漏(Memory leak)的部分,通常都是在分配(allocate)後,就算 GC 收走後還是發生記憶體碎片化所造成的效能問題。
因為你創建的 object 雖然會被 GC 收走,但真正提生效能的關鍵其實就是直接減少製造垃圾,把不必要重複 allocate 的 object 放在 memory 裡面。
以 ruby 的世界為萬物皆為 object 來說,假設執行
log("warning")
其實每一次生成字串 "warning"
都是一次 object 的 allocate
所以如果你的程式裡面到處都需要呼叫這個方法,也就表示到處在塞記憶體,在效能上會有更多的開銷。
在 Ruby 上面的解法是,使用 freeze
來把該 object 緩存起來作為備用
Benchmark 效能差異
我們可以跑一下 benchmark 來做實際的測試
這裡使用 benchmark/ips
來做效能測試,這個玩意比原本的 benchmark 還要準確的原因是,他不再只是執行多少次,而是在一定的時間內,嘗試逼出執行的最大次數,對於不同設備來說,會更具參考性。
gem install benchmark-ips
def call(text)
end
Benchmark.ips do |x|
x.report("normal") { call("hello") }
x.report("frozen") { call("hello".freeze) }
end
# Calculating -------------------------------------
# normal 8.489M (± 2.9%) i/s - 42.495M in 5.010428s
# frozen 11.014M (± 3.9%) i/s - 55.256M in 5.025667s
可以看到在 5 秒內,frozen 的對象執行次數比 normal 還來的多,這也表示被 frozen 的對象,在取用上速度比每次重新 allocate 一個還要快
用 GC 試試看到底差多少
可以打開 ruby console (irb) 來試試看,透過 GC 回收機制,到底 allocate 多少 object?
GC.start
before = GC.stat(:total_freed_objects)
RETAINED = []
100_000.times do
RETAINED << "a string".freeze
end
GC.start
after = GC.stat(:total_freed_objects)
puts "Objects Freed: #{after - before}"
# => "Objects Freed: 102014" (每台電腦跑出來的數字不一定一樣,但結果應該差不多)
可以從這段程式碼執行後看到 Objects Freed 大約有 10 萬筆,原因就是我們在 100_000.times
創建了一大堆的 "a string"
接著我們試試看將 "a string"
改成 "a string".freeze
有什麼差別呢?
GC.start
before = GC.stat(:total_freed_objects)
RETAINED = []
100_000.times do
+ RETAINED << "a string".freeze
end
GC.start
after = GC.stat(:total_freed_objects)
puts "Objects Freed: #{after - before}"
# => "Objects Freed: 2014"
可以看到 object freed 的部分大幅下降了,因為 freeze 後就不需要重複 allocate 一樣的 string 到 memory 內了
看看其他人是怎麼做的
如果你查看 Rails routes 的作法,因為路由用於網頁請求,需要更快速的回應,在這部分可以看到很多的 freeze
# excerpted from https://github.com/rails/rails/blob/f91439d848b305a9d8f83c10905e5012180ffa28/actionpack/lib/action_dispatch/journey/router/utils.rb#L15
def self.normalize_path(path)
path = "/#{path}"
path.squeeze!('/'.freeze)
path.sub!(%r{/+\Z}, ''.freeze)
path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase }
path = '/' if path == ''.freeze
path
end
再來看一下知名的非同步處理套件 sidekiq 也是在 routes 部分 freeze 很多字串
# excerpted from https://github.com/mperham/sidekiq/blob/44614b5da2ee0d89fc63a8ca3e4636f83cd424c6/lib/sidekiq/web/router.rb#L5-L14
module Sidekiq
module WebRouter
GET = 'GET'.freeze
DELETE = 'DELETE'.freeze
POST = 'POST'.freeze
PUT = 'PUT'.freeze
PATCH = 'PATCH'.freeze
HEAD = 'HEAD'.freeze
ROUTE_PARAMS = 'rack.route_params'.freeze
REQUEST_METHOD = 'REQUEST_METHOD'.freeze
PATH_INFO = 'PATH_INFO'.freeze
...
end
end
Ruby 的更新解決了這個問題嗎?
在 Ruby 2.2 後
針對 hash 使用的字串已經有做自動 freeze 的部分了
# 在 Ruby 版本 2.2 之後
user["name"]
# 等同於在 ruby 2.1 版本之前這樣寫(很醜吧)
user["name".freeze]
在 Ruby 2.3 後
可以使用一行程式碼來做到自動 String 的 freeze
# frozen_string_literal: true
這個 Pull Request 可以看到 sidekiq 的改動,將到處的 freeze
拔掉,改用註釋的方式
未來的 Ruby 3.0
目前就 Ruby 之父 Matz 的說法,會希望在 Ruby 3.0 中自動 freeze 所有字串,所以在這個過渡期下,就先用上述兩個解法吧
總結
主要兩招做優化
freeze
- one line comment magic =>
# frozen_string_literal: true
要用單行註釋搞定 freeze 的需要注意,該檔案的所有 string 部分都會被 freeze 住,如果更改的部分就會跳 Error, 所以也不見得所有情況適用,可以斟酌使用
建議可以先以 freeze
來做優化會比較有彈性,如果整個檔案全部都是 freeze 在嘗試改用單行註釋比較保險。