This page intends for crawlers/spiders/bots of search engines. If you didn't modify your HTTP UserAgent, please report the bug to spider-detector.

JavaScript 中 Closure 導致的記憶體洩漏 Bug

下面這一段 code, 可以用來洩漏任意大小的記憶體:

#!/usr/bin/env -S node --expose-gc

function get_closures () {
    let common = {}
    let big = new Uint8Array(1*1024*1024)
    for (let i = 0; i < big.length; i++) {
        big[i] = Math.floor(Math.random()*100)
    }
    let good = () => { common = {} }
    let bad = () => { common = {}; big[big.length-1] = 1; }
    return { good, bad }
}

let goods = []

function leak () {
    let object = get_closures()
    goods.push(object.good)
    delete object.bad
}

function main (argv) {
    let size = Number.parseInt(argv[0])
    if (Number.isNaN(size)) {
        console.log('bad size')
        process.exit(1)
    }
    console.log(size)
    for (let i = 0; i < size; i++) {
	    leak()
    }
    gc()
    console.log('gc() called')
    setInterval(() => void(0), 1000)
}

main(process.argv.slice(2, process.argv.length))

洩漏 400M:

洩漏 200M:

當然,正常取用記憶體也可以產生一樣的結果,但在本例中,這種結果表明記憶體確實洩漏了。為什麽?因為在每次乎叫 leak() 的過程中,由於 bad() 函式被 delete 掉了,big 這個 Uint8Array 已經無法被存取。而結果顯示,即便手動呼叫 gc(), GC 也不會釋放 big 所佔用的記憶體。

為什麽 GC 不回收 big 所佔用的記憶體?道理很簡單,每次呼叫 get_closures(), 就會產生 good()bad() 這兩個函式,它們都持有一個 reference 指向它們的 context, 也就是 get_closures() 剛剛使用的 scope. 即便 bad() 被扔掉了,good() 所持有的 reference 還在,雖然它其實並沒有使用它 context 中的 big 這個變數。

毫無疑問,這就是一個 bug. 其實這個 bug 在很多解釋型語言中都存在, 但未能引起足夠的重視——因為修復它會影響語言的功能。然而即便如此,也無法否定它「是一個 bug」的事實。存取不到的物件沒有被 GC, 就是 memory leak, 就是 bug.

假如用 Go 語言再去寫一段類似的 code, 就會發現 Go 沒有類似的 bug. 這是因為 Go 是編譯到 native code 的靜態型別語言,scope 只是一個抽象的概念而不是在 runtime 被 GC track 的一個物件。在 closure 中,儲存的是對每個外部變數的 reference, 而不是對某個整體的 reference. 於是類似的 bug 自然不會發生。

與之相對,JavaScript 是編譯到 bytecode 的動態型別語言,但這並不味著它不能使用與 Go 類似的機制來阻止上文所述 bug 的產生。然而最初的語言設計者並沒有考慮到這個問題。請看下面的這段 JS, 它是合法的、可以執行的:

function Outer () {
    function Inner () {
        return x
    }
    let x = 0
    return Inner()
}

console.log(Outer())

然而,如果把這段 JS 改寫成 Go, 是無法編譯的:

package main

import "fmt"

func Outer () int {
    var Inner = func() int {
        return x
    }
    var x = 0
    return Inner()
}

func main () {
    fmt.Println(Outer())
}
$ go run /tmp/1.go
/tmp/1.go:7:16: undefined: x

為何後面這段 Go 編譯不過?顯然,Inner() 函式使用了變數 x, 在定義該函式時,Go runtime 必須把變數 x 捕獲到 closure 內,而此時 x 尚未定義,於是編譯器不得不拒絕編譯。

而 JavaScript 卻可以這樣寫,是因為 JavaScript 的 closure 捕獲了整個 scope. 於是,如果為了解決上文中的 bug, 就把 JavaScript closure 的行為模式改成和 Go 一樣,就會破壞 backward compatibility, 讓很多舊 code 都不能用了,Break The Web. 另外,函式之間的互相呼叫也會成為一個大問題。

因此,這個 bug 其實可以說是一個「歷史包袱」,基本上不可能得到優雅的解決。