隱藏在 Node.js 浮點反序列化錯誤背後的故事

在 Node.js 中,當我們把一個浮點數序列化,再反序列化:

var f = 2.3;nvar buffer = new Buffer(1024);nbuffer.writeFloatBE(f);nvar g = buffer.readFloatBE();nconsole.log(f == g);n

我們會發現,再也取不出之前的值了:

然而,如果再序列化回去,發現結果還是相同的:

這是為什麼?

排查出現問題的環節

問題是不是出現在序列化的環節?為此,我們編寫了一個 C 語言程序對同一個浮點數進行序列化:

注意到 51、51、19、64 等價於 0x33、0x33、0x13、0x40,運行後除了存在小端序與大端序的差異外,我們發現序列化結果完全一致:

可見,序列化的環節 C、Node.js 兩者完全相同,沒有任何問題。

接下來,讓我們試驗一下用 C 語言反序列化這個浮點數能得到怎樣的結果:

C 語言的將二進位浮點數轉為十進位可以得到正確的結果,我們判定問題很可能出在 Node.js 的 二進位浮點數轉十進位的演算法上。

C 語言如何將二進位浮點數轉為十進位

我們找出 glibc 的源碼,發現邏輯位於 printf_fp.c 的 ___printf_fp 函數內。該函數共一千多行,可見二進位浮點數轉為十進位不是個簡單的任務:

簡單閱讀源代碼發現,它取出了系統當前的 locale(考慮 LC_ALL 環境變數),按照 locale 的不同先找到正確的小數點表示(例如德語里的小數點是逗號,英文里是句號)。隨後取出浮點數的各部分,調用各種 mpn 高精度庫函數進行各種高精度運算,最後算出浮點數的十進位的表示。

這麼有名的標準庫,一個函數寫一千行,作者肯定是實現了一個著名演算法,否則這麼複雜的邏輯如何維護得起?果然,我們在 CHANGELOG 的 1992 年的一條記錄中發現了所用的演算法 —— Dragon4。

JS 語言如何將二進位浮點數轉為十進位

那麼 JavaScript 又是什麼演算法呢?我們在 ECMA 的 9.8.1 章節中找到了解釋。原來,JS 多年來遵循的了一套複雜的對數字進行展示的演算法。標準該章節的末尾還提示 JS 的實現者參考 David M. Gay 在 1990 年發表的論文《Correctly Rounded Binary-Decimal and Decimal-Binary Conversions》。

ECMAScript Language Specification - ECMA-262 Edition 5.1

源碼分析

首先,閱讀 v8 的源碼發現,它並沒有使用 David M. Gay 的樸素演算法。2010 年 Florian Loitsch 發表了論文《Printing floating-point numbers quickly and accurately with integers》,取得了該問題近 20 年來最大的進展,提出了新演算法 —— Grisu3,這使得 v8 不用高精度運算庫便可以求得二進位浮點數的十進位表示,幾乎完勝 dragon4 演算法。唯一的缺點是有 0.5% 的浮點數會轉換失敗,這時 v8 可以聰明地 fallback 到了高精度運算來搞定他們。

其次,輸出 2.3 並不是數學上正確的結果。運用一些數學知識進行分析就會發現,有些二進位的浮點數是永遠不會轉換成漂亮的有限位的十進位小數的。在十進位數系中,只要一個有理數的分母含有10的素因子之外的因子,那麼他就是一個無限小數。例如 1/15 等於 0.066666 循環,因為 15 含因子 3。這一結論可以使用費馬小定理,取 a=10,p=2 或 5 :

同理,在二進位數系中,只要一個有理數的分母含有2的素因子(只有2)之外的因子,那麼他就是一個無限小數。所以,本例中的有理數 2.3 = 23/10 的分母的素因子有 2 和 5,含有 2 之外的因子,因此在二進位下的表示是無限的。

然而計算機是有限的,因此在浮點被序列化成4個位元組的一瞬間,精度就損失了。當反序列化回來的時候,那個輸出 2.3 的程序反而在數學上不正確,輸出 2.299999952316284 的程序在數學上才是更正確的。

現實很殘酷。

2.3 是怎麼來的

原來,C 語言的 printf 默認進行了一定有效數字位數的取整,如果我們不斷擴大有效數字的位數:

就可以發現,當小數後的有效數字位數設定為1~7時,剛好取整到我們想要的十進位結果,其他情況下並無卵:

Node.js 根本沒有 float

但是我們回過頭來想一想,即便現實世界中的浮點數是殘酷的,但 Node.js 也有它做的不對的地方。為什麼有同等二進位表示的兩個數字,可以被 toString() 成兩種結果?而且關於認為它們相等這件事,Node.js 也是拒絕的呢?

原因就是 Node.js 根本沒有頭髮!哦不對,是根本沒有 float!v8 引擎中 js 的 Number 對象的內部實現只有兩種,一是smi(也就是小整數),二是double。Grisu3 演算法的實現也只被封裝在 DoubleToCString 等函數中,從來沒有 FloatToCString 這種處理 float 的函數。

更為坑爹的是,在 readFloatBE 得到 float 值而傳入 v8 的一瞬,float 被悄無聲息地 cast 成 double 了。亦即,進則 promote 成 double,出則 rounding 成 float。

因此,他們的二進位其實是不一致的,因為我們先前只看了前四個位元組,沒看後四個位元組:

應對之策

首先,如果實在想要像 C 那樣得到 2.3,那麼需要人工指定取整的位數,用 Number.prototype.toPrecision 函數四捨五入後重新實例化 Number 對象:

其次,應盡量避免使用 float,如今 double 已經得到伺服器的廣泛支持,沒有必要為了節省內存或帶寬棄 64 位不用而使用 32 位。尤其是在沒有 float 的 Node.js 的世界裡,使用 float 作為數據存儲或數據交換格式必然會引入各種與直覺不符的問題。

最後,在精度要求高的場合,應犧牲性能,放棄機器原生的浮點數,轉而使用 BigDecimal 等無限精度的運算庫。


推薦閱讀:

Node.js 新計劃:使用 V8 snapshot 將啟動速度提升 8 倍
阿里雲前端周刊 - 第 25 期
V8 性能再升級,支持更多 ES2015+ 語法優化
chrome瀏覽器頁面渲染工作原理淺析
超大文件如何計算md5?

TAG:IEEE754 | V8 | 浮点数 |