Elixir 程式碼效能最佳化指北
Elixir 作為基於 Erlang/OTP 的年輕語言,擁有良好的併發模型設計,在 Web 場景下對於實現能承載高併發的服務毫無問題。有好事者對比過包括 Phoenix 在內不同的 Web Framework 的效能,可見如果採用 Phoenix/Plug 來實現 Web Server 在效能上不會有太大的問題(程式碼實現良好的情況下)。所以,本文不會討論真正的工業生產環境下整個系統的效能狀態,因為系統性能受到很多因素的影響,具體程式語言的執行時的執行效率往往不是真正的問題所在,與其考慮程式語言本身的執行時效率,不如探討系統在具體架構和實現上如何能最佳化來承載更高的負載來得實際。在生產環境中,我們可以藉助 APM 服務來監控系統狀態和效能指標。
由於 Elixir 本身是基於 Erlang 的更高層次的抽象,所以直覺上我們會覺得 Elixir 在執行效能上應該比 Erlang 本身要差一些。實際情況是不是這樣呢?Elixir 相比於 Erlang 而言,為我們提供了一些可以快速呼叫的高階函式庫,典型的有
Enum
、
Stream
,提高了日常實現需求的開發效率,可以讓程式碼實現得更清晰且更易維護。而更高的抽象又幾乎必然意味著底層的實現邏輯需要更通用健壯,從而也會更復雜。更高的抽象程度似乎天然與更高的執行效率有著內在的矛盾。本文的焦點在 Elixir 程式碼的執行效能,即對於實現同樣的功能,用哪樣的 Elixir 實現方式會讓程式碼在執行時跑得更快。
接下來會拿我在剛接觸 Elixir 時實際開發過程中真實寫出來的程式碼來舉例如何進行最佳化,相信不少 Elixir 新手會跟當時的我一樣或多或少犯類似的錯誤。原始碼可以在這裡找到:ex_fieldmask - GitHub,透過專案的提交歷史也可以看到我的修改過程,整個程式碼才一百多行,改動也都很簡短,非常適合舉例。這個庫實現的需求是用簡單的字串解析的方式來實現類似 Google+/YouTube API 中的 Partial Responses 的語法和功能。我會以這個程式碼倉庫中的例子來舉例能明顯改善 Elixir 執行效能的實現改進,其他不會明顯改善效能但是也會讓程式碼更優的改動也會稍微提一下。我們從提交歷史中從前往後挑選一些值得一提的 commits 來一一解說。
判斷值時,用 case 替換 cond:commit 913be42。
改動前:
cond
do
keys
===
[]
->
data
keys
===
[
“*”
]
->
……
true
->
……
end
改動後:
case
keys
do
[]
->
data
[
“*”
]
->
……
_
->
……
end
解讀
:能用 case(或函式子句)的就不要用 cond。cond 適用於多個獨立的表示式求值判斷真假的情況,它需要從上至下對每個表示式求值直到遇到第一個值為「真」的分支。在這裡,我們全部是關於 keys 的簡單比較,顯然用 case 直接模式匹配會是更優的實現。cond 裡的 true 的 fallback 分支在 case 裡可以用 _left 來對應變更用於匹配任意項。
函式用一個完整的 Pipeline 串聯來提升可讀性:commit 3e610d8,其他類似的改動還有 commit fa03938。
改動前:
def
reveal
(
tree
,
data
)
when
is_map
(
tree
)
do
keys
=
Map
。
keys
(
tree
)
case
keys
do
[]
->
data
[
“*”
]
->
……
_
->
……
end
end
改動後:
def
reveal
(
tree
,
data
)
when
is_map
(
tree
)
do
tree
|>
Map
。
keys
()
|>
(
fn
[]
->
data
[
“*”
]
->
……
_
->
……
end
)
。
()
end
解讀
:最後的匿名函式中的引數匹配跟
case
一樣也是模式匹配,同樣優於
cond
,原始碼中把
case
改成匿名函式的形式是為了讓整個
reveal
函式是一個完整的用
|>
串聯起來的 Pieline 而又不失簡潔,但效能上並不會明顯更優。這樣的改動更多是把命令式的程式碼風格改成函式式的程式碼風格,Pipeline 的每個部分都是獨立無狀態的,可讀性和可維護性都會有所提升。
List/Tuple 直接用模式匹配取值:commit 801ff47。
改動前:
chars
=
elem
(
item
,
0
)
delimiter
=
elem
(
item
,
1
)
改動後:
{
chars
,
delimiter
}
=
item
解讀
:同樣也是用模式匹配替代使用函式來取值,不僅讓程式碼可以一行解決,而且還會提升效能。如果 List/Tuple 很長,而我們只需要提取前面一部分的片段,則又可以使用
_tail
來匹配我們不關心的尾部區域。
匿名函式用
&
改寫:commit c19d49f。
改動前:
Enum
。
filter
(
fn
str
->
str
!==
nil
and
str
!==
“”
end
)
改動後:
Enum
。
filter
(
&
(
&1
!==
nil
and
&1
!==
“”
))
解讀
:無他,就是程式碼更簡潔了,而且我們不再需要想如何給函式引數命名。眾所周知,命名在程式設計裡是一件很難的事情……(當然,只有在這種函式很簡單的情況下值得這樣做)
在函式引數中直接匹配複雜資料結構內部的值:commit 9af5145 和 commit 456d3d4,其他相同原因的改動還有 commit 21b1fee。
改動前:
Enum
。
reduce
({%{},
[],
[],
nil
},
fn
token
,
acc
->
{
tree
,
path
,
stack
,
last_token
}
=
acc
case
token
do
“,”
->
if
List
。
first
(
stack
)
===
“/”
do
{
tree
,
tl
(
path
),
tl
(
stack
),
token
}
else
acc
end
“/”
->
{
tree
,
[
last_token
|
path
],
[
token
|
stack
],
token
}
“(”
->
{
tree
,
[
last_token
|
path
],
[
token
|
stack
],
token
}
“)”
->
{
tree
,
tl
(
path
),
[
token
|
stack
],
token
}
_
->
{
put_in
(
tree
,
Enum
。
reverse
([
token
|
path
]),
%{}),
path
,
stack
,
token
}
end
end
)
改動後:
Enum
。
reduce
({%{},
[],
[],
nil
},
fn
“,”
=
token
,
{
tree
,
path
,
stack
,
last_token
}
->
if
List
。
first
(
stack
)
===
“/”
do
{
tree
,
tl
(
path
),
tl
(
stack
),
token
}
else
{
tree
,
path
,
stack
,
last_token
}
end
“/”
=
token
,
{
tree
,
path
,
stack
,
last_token
}
->
{
tree
,
[
last_token
|
path
],
[
token
|
stack
],
token
}
“(”
=
token
,
{
tree
,
path
,
stack
,
last_token
}
->
{
tree
,
[
last_token
|
path
],
[
token
|
stack
],
token
}
“)”
=
token
,
{
tree
,
path
,
stack
,
_
}
->
{
tree
,
tl
(
path
),
[
token
|
stack
],
token
}
token
,
{
tree
,
path
,
stack
,
_
}
->
{
put_in
(
tree
,
Enum
。
reverse
([
token
|
path
]),
%{}),
path
,
stack
,
token
}
end
)
解讀
:改動前的寫法更多的還是在用其他無模式匹配特性的程式語言的思維在寫程式碼,在 Elixir 裡,我們可以直接在函式引數中使用模式匹配,不僅簡化了步驟讓程式碼變得更簡潔,而且也簡化了變數個數、少了命名需求。在分支的匹配過程中我們還可以給匹配到的字元串同樣用模式匹配
“/” = token
的方式來命名。為什麼已經確定的匹配還要用一個新的變數來匹配呢?原因是在分支內部需要多次重複引用
“/”
,我們直接用
token
來統一引用即可,小小改動卻充分體現了 Don’t repeat yourself 的原則。
用 List Comprehensions 替換高階函式的使用:commit 8de1abf,其他類似的改動還有 commit 48afae9。
改動前:
fn
[]
->
data
[
“*”
]
->
data
|>
Map
。
keys
()
|>
Enum
。
map
(
&
[
&1
,
reveal
(
tree
[
“*”
],
data
[
&1
])])
|>
Map
。
new
(
fn
pair
->
List
。
to_tuple
(
pair
)
end
)
keys
->
case
data
do
data
when
is_list
(
data
)
->
Enum
。
map
(
data
,
&
reveal
(
tree
,
&1
))
data
when
is_map
(
data
)
->
keys
|>
Enum
。
map
(
&
[
&1
,
reveal
(
tree
[
&1
],
data
[
&1
])])
|>
Map
。
new
(
fn
pair
->
List
。
to_tuple
(
pair
)
end
)
end
end
改動後:
fn
[]
->
data
[
“*”
]
->
data
|>
Map
。
keys
()
|>
(
fn
keys
->
for
key
<-
keys
,
into
:
%{}
do
{
key
,
reveal
(
tree
[
“*”
],
data
[
key
])}
end
end
)
。
()
keys
->
case
data
do
data
when
is_list
(
data
)
->
for
item
<-
data
do
reveal
(
tree
,
item
)
end
data
when
is_map
(
data
)
->
keys
|>
(
fn
keys
->
for
key
<-
keys
,
into
:
%{}
do
{
key
,
reveal
(
tree
[
key
],
data
[
key
])}
end
end
)
。
()
end
end
解讀
:這裡效能上是不是真的有最佳化可能不那麼明顯。在 Elixir 1。9。1 中我使用 Benchee 做 benchmark 發現 List Comprehensions 確實要比使用 Elixir 提供的高階函式要快。沒有看 Elixir 的具體的實現,但大致揣測 List Comprehensions 在底層是用簡單的遞迴函式實現的,而高階函式應該做了更多複雜的事情,從邏輯上可以想到的是
Enum/Stream
庫需要先處理傳入資料結構的
Enumerable
協議的相關要求再用不同資料型別對應的不同邏輯來處理,自然會複雜一些。
總結上來,簡單的明顯可以改善程式碼效能的寫法其實只有兩類:儘可能用模式匹配、用 List Comprehensions 替換高階函式,其他的只是從其他角度考慮的程式碼層面的最佳化。最終我們線上沒有使用這個庫,因為 benchmark 發現直接定義 Partial Responses 的語法,然後用 Erlang 的
leex
做詞法分析,再用
yecc
做語法分析生成 AST,最後遍歷 AST 就可以得到做了 mask 的結果,即我們要的 Partial Response。程式碼同樣開源在 GitHub:fieldmask - GitHub,也是一個絕妙的學習 Erlang
leex
和
yecc
的例子。
本文作者:
Maples7
本文連結:
http://
maples7。com/2019/12/05/
elixir-code-performance-optimization/
版權宣告:
本部落格所有文章除特別宣告外,均採用 BY-NC-SA 許可協議。轉載請註明出處!
—— END ——
如果你想第一時間檢視我最新的文章,歡迎 RSS 訂閱我的個人部落格:Blog of Maples7。知乎專欄將延期數天到數月不等不完全同步部落格中的文章。
微信公眾號:Chapters_Of_Maples7,只更新自己隨手寫的想到的隻言片語或圖片。
本文內容可能已經不是最新,檢視原文:Elixir 程式碼效能最佳化指北