開發與維運

Go的閉包看你犯錯,但Rust的lifetime卻默默幫你排坑

閉包(Closure)在某些編程語言中也被稱為 Lambda 表達式,是能夠讀取其他函數內部變量的函數。一般只有函數內部的子函數才能讀取局部變量,所以閉包這樣一個函數內部的函數,在本質上是將函數內部和函數外部連接起來的橋樑。

在實踐當中,假如我們需要統計一個函數被調用的次數,最簡單的方式就是定義一個全局變量,每當目標函數被調用時就將此變量加1,但是全局變量會帶來很多誤用等問題,安全性往往得不到保證;而為調用次數專門設計一個以計數的接口又太小題大做了。

但是通過閉包就比較容易實現計數功能,以Go語言為例具體代碼及註釋如下:

package main

import (
"fmt"
)

func SomeFunc() func() int { // 創建一個函數,返回一個閉包,閉包每次調用函數會對函數內部變量進行累加
var CallNum int = 0 //函數調用次數,系函數內部變量,外部無法訪問,僅當函數被調用時進行累加

return func() int { // 返回一個閉包

CallNum++ //對value進行累加
//實現函數具體邏輯

return CallNum // 返回內部變量value的值
}
}

func main() {

accumulator := SomeFunc() //使用accumulator變量接收一個閉包

// 累加計數並打印
fmt.Println("The first call CallNum is ", accumulator()) //運行結果為:The first call CallNum is 1
// 累加計數並打印
fmt.Println("The second call CallNum is ", accumulator()) //運行結果為:The second call CallNum is 2

}

運行結果為:

The first call CallNum is 1
The second call CallNum is 2

可以看到我們通過閉包即沒有暴露CallNum這個變量,又實現了為函數計數的目的。

Goroutine+閉包卻出了莫名其妙的BUG

在Go語言中,閉包所依託的匿名函數也是Goroutine所經常用到的方案之一,但是這兩者一結合卻容易出現極難排查的BUG,接下來我把出現問題的代碼簡化一下,請讀者們來看下面這段代碼:

import (
"fmt"
"time"
)

func main() {

tests1ice := []int{1, 2, 3, 4, 5}

for _, v := range tests1ice {
go func() {

fmt.Println(v)

}()
}

time.Sleep(time.Millisecond)
}

這段代碼的邏輯不難看懂,其目標是通過Goroutine將1,2,3,4,5亂序輸出到屏幕上,但最終執行結果卻如下:

5
5
5
3
5
成功: 進程退出代碼 0.

也就是隻有大多數情況下只有5被輸出出來了,1-4幾乎沒有什麼機會登場,這裡簡要複述一下問題的排查過程,由於沒有在Goroutine中對切片執行寫操作,所以首先排除了內存屏障的問題,最終還是通過反編譯查看彙編代碼,發現Goroutine打印的變量v,其實是地址引用,Goroutine執行的時候變量v所在地址所對應的值已經發生了變化,彙編代碼如下:

​for _, v := range tests1ice {
499224: 48 8d 05 f5 af 00 00 lea 0xaff5(%rip),%rax # 4a4220 <type.*+0xa220>
49922b: 48 89 04 24 mov %rax,(%rsp)
49922f: e8 8c 3a f7 ff callq 40ccc0 <runtime.newobject>
499234: 48 8b 44 24 08 mov 0x8(%rsp),%rax
499239: 48 89 44 24 48 mov %rax,0x48(%rsp)
49923e: 31 c9 xor %ecx,%ecx
499240: eb 3e jmp 499280 <main.main+0xc0>
499242: 48 89 4c 24 18 mov %rcx,0x18(%rsp)
499247: 48 8b 54 cc 20 mov 0x20(%rsp,%rcx,8),%rdx
49924c: 48 89 10 mov %rdx,(%rax)
go func() {
49924f: c7 04 24 08 00 00 00 movl $0x8,(%rsp)
499256: 48 8d 15 f3 b7 02 00 lea 0x2b7f3(%rip),%rdx # 4c4a50 <go.func.*+0x6c>
49925d: 48 89 54 24 08 mov %rdx,0x8(%rsp)
499262: 48 89 44 24 10 mov %rax,0x10(%rsp)
499267: e8 54 3a fa ff callq 43ccc0 <runtime.newproc>​

可Goroutine中fmt.Println所處理的v,其實是v的地址中所對應的值。這也是產生這個BUG的基本原因。

找到了問題的原因,解決起來也就簡單多了。

解決方案一:在參數方式向匿名函數傳遞值引用,具體代碼如下:

package main

import (
"fmt"
"time"
)

func main() {

tests1ice := []int{1, 2, 3, 4, 5}

for _, v := range tests1ice {
go func(v int) {

fmt.Println(v)

}(v)
}

time.Sleep(time.Millisecond)
}

解決方案二:在調用gorouinte前將變量進行值拷貝

package main

import (
"fmt"
"time"
)

func main() {

tests1ice := []int{1, 2, 3, 4, 5}

for _, v := range tests1ice {
w := v
go func() {

fmt.Println(w)

}()
}

time.Sleep(time.Millisecond)
}

總而言之只要傳值就沒事,而傳地址引用就會出現問題。

Rust為什麼行

利用週末時間我想看看上述問題代碼在Rust的實現中是如何處理的,卻有比較意外的收穫,我們來看上述代碼的Rust實現,

use std::thread;
use std::time::Duration;

fn main() {
let arr = [1, 2, 3, 5, 5];
for i in arr.iter() {
let handle = thread::spawn(move || {
println!("{}", i);
});
}
thread::sleep(Duration::from_millis(10));
}

但是上述這段代碼編譯都無法通過,原因是arr這個變量的生命週期錯配。具體編譯結果如下:

error[E0597]: `arr` does not live long enough
--> hello16.rs:6:14
|
6 | for i in arr.iter() {
| ^^^
| |
| borrowed value does not live long enough
| cast requires that `arr` is borrowed for `'static`
...
13 | }
| - `arr` dropped here while still borrowed

error: aborting due to previous error; 1 warning emitted

我們剛剛提到過匿名函數其實是通過地址引用的方式來訪問局部變量的,而地址引用也就對應Rust當中借用的概念,那麼我們就可以推出來for i in arr.iter()中的 arr.iter()實際是對arr的借用,這個借用後的結果i被let handle = thread::spawn(move 中的move關鍵字強制轉移走了,因此在handle線程離開作用域之後就被釋放了,而下次迭代時arr變量由於lifetime的問題不能被編譯器編譯通過。

為了更簡要的說明這個問題我們來看下面的代碼:

fn main() {
{
let x;
{
let y = 5;
x = &y;// x借用y的值

}
// y在這裡已經被釋放,因此借用y的x也不能通過lifetime檢查
println!("x: {}", x);

}
}

x借用y的值,如果在y的lifetime以外,再出現x的訪問就會出現問題。如果想避免這個問題就不能再使用借用的機制,可以編譯通過的代碼如下:

use std::thread;
use std::time::Duration;

fn main() {
let arr = [1, 2, 3, 5, 5];
for i in arr.iter() {//這段代碼中i是對arr的借用
let j=i+1;//j通過值拷貝的方式獲取了i的值
let handle = thread::spawn(move || {//move將j強制轉移給了handle
println!("{}", j);
});//這裡j超出lifetime就不會影響到i了
}
thread::sleep(Duration::from_millis(10));
}

新添加的let j=i+1;是通過值拷貝的方式將i和j剝離了,因此j在被釋放的時候就不會影響到arr的借用i了。

凡是編譯器能發現的錯誤,都不會形成問題。通過這個Go語言問題的排查,我對於Rust的變量生命週期檢查機制有了更進一步的認識,不得不承認雖然Rust學起來比較勸退,但是其安全語言的名號真是所言不虛,強制讓程序員做正確事,如果能知其然又知其所以然,那麼提升將是巨大的。

Leave a Reply

Your email address will not be published. Required fields are marked *