To light a candle is to cast a shadow…

— Ursula K. Le Guin, A Wizard of Earthsea

核心概念#

每當我們寫程式時,都在管理資源:記憶體、交易、執行緒、網路連線、檔案、計時器——各種可用性有限的東西。大多數時候,資源的使用遵循可預測的模式:你分配資源、使用它、然後釋放它。

然而,許多開發者沒有一致的計畫來處理資源的分配和釋放。所以書中提出一個簡單的建議:

Tip 40 - Finish What You Start(善始善終)

這個建議在大多數情況下很容易應用。它簡單地意味著:分配資源的函式或物件應該負責釋放它。

問題範例#

書中以一段 Ruby 程式碼為例,展示 read_customer 開啟檔案並將檔案參照存在實例變數中,而 write_customer 使用那個存儲的參照來關閉檔案。這兩個常式透過共享的 customer_file 實例變數而緊密耦合。

當需求改變後(例如加上「餘額為負就不更新」的條件),write_customer 在某些情況下不會被呼叫,檔案就不會被關閉。這會導致生產環境中「太多開啟的檔案」的錯誤。

糟糕的修復#

update_customer 中加入特殊情況處理(在 else 分支中關閉檔案),這只會讓三個常式都透過共享變數耦合在一起,事情只會越來越混亂。

正確的做法#

將程式碼重構,讓 update_customer 負責整個檔案的生命週期——打開檔案、使用它、然後關閉它:

def update_customer(transaction_amount)
  file = File.open(@name + ".rec", "r+")
  read_customer(file)
  @balance = @balance.add(transaction_amount, 2)
  file.close
end

現在所有檔案的責任都在 update_customer 常式中。開啟和關閉在同一個地方,每個 open 都明顯有對應的 close。

縮小範圍#

在許多現代語言中,你可以將資源的生命週期範圍限定在某個封閉的區塊中:

def update_customer(transaction_amount)
  File.open(@name + ".rec", "r+") do |file|
    read_customer(file)
    @balance = @balance.add(transaction_amount, 2)
    write_customer(file)
  end
end

在區塊結束時,file 變數離開作用域,外部檔案被關閉。不需要記得關閉檔案——它保證會為你發生。

Tip 41 - Act Locally(在地行動)

隨時間平衡: 在這個主題中我們主要討論你執行中的行程使用的暫時性資源。但你可能也要考慮你留下的其他殘跡。例如:日誌檔如何處理?是否有機制來輪換和清理它們?如果你在資料庫中新增日誌記錄,是否有類似的過期機制?對於任何會佔用有限資源的東西,都要考慮如何平衡它。

巢狀分配(Nest Allocations)#

當一個常式需要多個資源時,基本的分配模式可以擴展。有兩個額外的建議:

  • 以相反的順序釋放資源:以分配時的相反順序來釋放資源,這樣如果某個資源包含對另一個資源的參照,就不會產生孤兒資源
  • 在不同地方分配相同的資源集時,始終以相同的順序分配:這將減少死鎖的可能性(如果行程 A 佔用了 resource1 並嘗試取得 resource2,而行程 B 佔用了 resource2 並嘗試取得 resource1,兩個行程就會永遠等待)

物件與例外#

分配和釋放之間的平衡讓人想起物件導向類別的建構子和解構子。類別代表一個資源,建構子給你該資源類型的物件,解構子從你的作用域中移除它。

在物件導向語言中,你可能會發現將資源封裝在類別中很有用。每次需要特定的資源類型時,實例化該類別的一個物件。當物件離開作用域或被垃圾回收器回收時,解構子就會釋放被包裝的資源。

平衡與例外#

支援例外的語言可能讓資源釋放變得棘手。如果例外被拋出,你如何保證在例外之前分配的所有東西都被清理了?你通常有兩個選擇:

  1. 使用變數作用域(例如 C++ 或 Rust 中的堆疊變數)
  2. 使用 finally 子句(在 try...catch 區塊中)

例外的反模式#

一個常見的錯誤:

# 錯誤:如果 allocate_resource() 失敗並拋出例外怎麼辦?
begin
  thing = allocate_resource()
  process(thing)
finally
  deallocate(thing)   # thing 可能從未被分配!
end

正確的模式是先分配資源,然後進入 try/finally:

thing = allocate_resource()
begin
  process(thing)
finally
  deallocate(thing)
end

無法平衡資源時#

有時基本的資源分配模式不適用。常見的情況是使用動態資料結構的程式。一個常式分配一塊記憶體並將它連結到某個更大的結構中。

解決方法是建立一個語意不變量來決定誰負責聚合資料結構中的資料。當你釋放頂層結構時,你有三個主要選項:

  • 頂層結構也負責釋放它包含的所有子結構(遞迴釋放)
  • 頂層結構被簡單釋放,任何被指向的結構(沒有在其他地方被參照的)成為孤兒
  • 如果頂層結構包含任何子結構,就拒絕釋放自己

檢查平衡#

因為務實的程式設計師不信任任何人(包括自己),所以建立實際檢查資源是否被適當釋放的程式碼是個好主意。對於大多數應用程式,這意味著為每種類型的資源產生包裝器,追蹤所有的分配和釋放。在程式碼的特定檢查點,使用這些包裝器來確認資源使用量沒有增加。

相關章節#

  • Topic 24,死程式不說謊
  • Topic 30,轉換式程式設計
  • Topic 33,打破時間耦合