WebAssembly

作者: 极乐君 | 来源:发表于2017-06-19 12:02 被阅读68次

    Webassembly(WASM)和CSS的Grid布局一样都是一个新东西,Chrome从57开始支持。在讲wasm之前我们先看代码是怎么编译的成机器码,因为计算机只认识机器码。

    1. 机器码

    计算机只能运行机器码,机器码是一串二进制的数字,如下面的可执行文件a.out:

    上面显示成16进制,是为了节省空间。

    例如我用C写一个函数,如下:

    int main(){
        int a = 5;
        int b = 6;
        int c = a + b;
        return 0;
    }
    

    然后把它编译成一个可执行文件,就变成了上面的a.out。a.out是一条条的指令组成的,如下图所示,研究一下为了做一个加法是怎么进行的:

    第一个字节表示它是哪条指令,每条指令的长度可能不一样。上面总共有四条指令,第一条指令的意思是把0x5即5这个数放到内存内置为[rbp - 0x8]的位置,第二条指令的意思是把6放到内存地址为[rbp - 0xc]的位置,为什么内存的位置是这样呢,因为我们定义了两个局部变量a和b,局部变量是放在栈里面的,而new出来的是放在内存堆里面的。上面main函数的内存栈空间如下所示:

    rbp是一个base pointer,即当前栈的基地址,这里应该为main函数入口地地址,然后又定义了两个局部变量,它们依次入栈,栈由下往上增长,向内存的低位增长,在我的这个Linux操作系统上是这样的。最后return返回的时候这个栈就会一直pop到入口地址位置,回到调它的那个函数的地址,这样你就知道函数栈调用是怎么回事了。

    一个栈最大的空间为多少呢?可以执行ulimit -s或者ulimit -a命令,它会打印出当前操作系统的内存栈最大值:

    ulimit -a
    stack size (kbytes, -s) 8192

    这里为8Mb,相对于一些OS默认的64Kb,已经是一个比较大的值了。一旦超出这个值,就会发生栈溢出stack overflow.

    理解了第一条指令和第二条指令的意思后就不难理解第三条和第四条了。第三条是把内存地址为[rbp - 8]放到ecx寄存器里面,第四条做一个加法,把[rbp - 12]加到ecx寄存器。就样就完成了c = a + b的加法。

    更多汇编和机器码的运算读者有兴趣可以自行去查资料继续扩展,这里我提了一下,帮助读者理解这种比较较陌生的机器码是怎么回事,也是为了下面讲解WASM.

    2. 编译和解释

    我们知道编程语言分为两种,一种是编译型的如C/C++,另一种是解释型如Java/Python/JS等。

    在编译型语言里面,代码需经过以下步骤转成机器码:

    先把代码文本进行词法分析、语法分析、语义分析,转成汇编语言,其实解释型语言也是需要经过这些步骤。通过词法分析识别单词,例如知道了var是一个关键词,people这个单词是自定义的变量名字;语法分析把单词组成了短句,例如知道了定义了一个变量,写了一个赋值表达式,还有一个for循环;而语义分析是看逻辑合不合法,例如如果赋值给了this常量将会报错。

    再把汇编再翻译成机器码,汇编和机器码是两个比较接近的语言,只是汇编不需要去记住哪个数字代表哪个指令。

    编译型语言需要在运行之前生成机器码,所以它的执行速度比较快,比解释型的要快若干倍,缺点是由于它生成的机器码是依赖于那个平台的,所以可执行的二进制文件无法在另一个平台运行,需要再重新编译。

    相反,解释型为了达到一次书写,处处运行(write once, run evrywhere)的目的,它不能先编译好,只能在运行的时候,根据不同的平台再一行行解释成机器码,导致运行速度要明显低于编译型语言。

    如果你看Chrome源码的话,你会发现V8的解释器是一个很复杂的工程,有200多个文件:

    最后终于可以来讲WebAssembly了。

    3. WebAssembly介绍

    WASM的意义在于它不需要JS解释器,可直接转成汇编代码(assembly code),所以运行速度明显提升,速度比较如下:

    通过一些实验的数据,JS大概比C++慢了7倍,ASM.js官网认为它们的代码运行效率是用clang编译的代码的1/2,所以就得到了上面比较粗糙的对比。
    Mozilla公司最开始开发asm.js,后来受到Chrome等浏览器公司的支持,慢慢发展成WASM,W3C还有一个专门的社区,叫WebAssembly Community Group。
    WASM是JS的一个子集,它必须是强类型的,并且只支持整数、浮点数、函数调用、数组、算术计算,如下使用asm规范写的代码做两数的加法:

    function () {
        "use asm";
        function add(x, y) {
            x = x | 0;
            y = y | 0;
            return x | 0 + y | 0;
        }
        return {add: add};
    }
    

    正如asm.js官网提到的:

    An extremely restricted subset of JavaScript that provides only strictly-typed integers, floats, arithmetic, function calls, and heap accesses

    WASM的兼容性,如caniuse所示:

    最新的主流浏览器基本上已经支持。

    4. WASM Demo

    (1)准备

    Mac电脑需要安装以下工具:

    cmake make Clang/XCode
    Windows需要安装:

    cmake make VS2015 以上

    然后再装一个

    WebAssembly binaryen (asm2Wasm)

    (2)开始

    写一个add.asm.js,按照asm规范,如下图所示:

    然后再运行刚刚装的工具asm2Wasm,就可以得到生成的wasm格式的文本,如下图所示

    可以看到WASM比较接近汇编格式,可以比较方便地转成汇编。

    如果不是在控制台输出,而是输出到一个文件,那么它是二进制的。运行以下命令:

    ../bin/asm2wasm add.asm.js -o add.wasm

    打开生成的add.wasm,可以看到它是一个二进制的:

    有了这个文件之后怎么在浏览器上面使用呢,如下代码所示,使用Promise,与WebAssembly相关的对象本身就是Promise对象:

    fetch("add.wasm").then(response =>
        response.arrayBuffer())
    .then(buffer => 
        WebAssembly.compile(buffer))
    .then(module => {
        var imports = {env: {}};
        Object.assign(imports.env, {
            memoryBase: 0,
            tableBase: 0,
            memory: new WebAssembly.Memory({ initial: 256, maximum: 256 }), 
            table: new WebAssembly.Table({ initial: 0, maximum: 0, element: 'anyfunc' })
       })
       var instance =  new WebAssembly.Instance(module, imports)
       var add = instance.exports.add;
       console.log(add, add(5, 6));
    })
    

    先去加载add.wasm文件,接着把它编译成机器码,再new一个实例,然后就可以用exports的add函数了,如下控制台的输出:

    可以看到add函数已经变成机器码了。

    现在来写一个比较有用的函数,斐波那契函数,先写一个asm.js格式的,如下所示:

    function fibonacci(fn, fn1, fn2, i, num) {
        num = num | 0;
        fn2 = fn2 | 0;
        fn = fn | 0;
        fn1 = fn1 | 0;
        i = i | 0;
        if(num < 0)  return 0;
        else if(num == 1) return 1;
        else if(num == 2) return 1;
        while(i <= num){
            fn = fn1;
            fn1 = fn2;
            fn2 = fn + fn1;
            i = i + 1;
        }   
        return fn2 | 0;
    }
    

    这里笔者最到一个问题,就是定义的局部变量无法使用,它的值始终是0,所以先用传参的方式。

    然后再把刚刚那个加载编译的函数封装成一个函数,如下所示:

    loadWebAssembly("fibonacci.wasm").then(instance => {
        var fibonacci = instance.exports.fibonacci;
        var i = 4, fn = 1, fn1 = 1, fn2 = 2;
        console.log(i, fn, fn1, fn2, "f(5) = " + fibonacci(5));
    });
    

    最后观察控制台的输出:

    可以看到在f(47)的时候发生了溢出,在《JS与多线程》这一篇提到JS溢出了会自动转成浮点数,但是WASM就不会了,所以可以看到WASM/ASM其实和JS没有直接的关系,只是说你可以用JS写WASM,虽然官网的说法是ASM是JS的一个子集,但其实两者没有血肉关系,用JS写ASM你会发现非常地笨拙和不灵活,编译成WASM会有各种报错,提示信息非常简陋,总之很难写。但是不用沮丧,因为下面我们会提到还可以用C写。

    然后我们可以做一个兼容,如果支持WASM就去加载wasm格式的,否则加载JS格式,如下所示:

    5. JS和WASM的速度比较

    (1)运行速度的比较

    如下代码所示,计算1到46的斐波那契值,然后重复一百万次,分别比较wasm和JS的时间:

    //wasm运行时间
    loadWebAssembly("fib.wasm").then(instance => {
        var fibonacci = instance.exports._fibonacci;
        var num = 46;
        var count = 1000000;
        console.time("wasm fibonacci");
        for(var k = 0; k < count; k++){
            for(var j = 0; j < num; j++){
                var i = 4, fn = 1, fn1 = 1, fn2 = 2;
                fibonacci(fn, fn1, fn2, i, j);
            }
        }
        console.timeEnd("wasm fibonacci");
    });
    
    //js运行时间
    loadWebAssembly("fibonacci.js", {}, "js").then(instance => {
        var fibonacci = instance.exports.fibonacci;
        var num = 46;
        var count = 1000000;
        console.time("js fibonacci");
        for(var k = 0; k < count; k++){
            for(var j = 0; j < num; j++){
                var i = 4, fn = 1, fn1 = 1, fn2 = 2;
                fibonacci(fn, fn1, fn2, i, j);
            }
        }
        console.timeEnd("js fibonacci");
    });
    

    运行四次,比较如下:

    可以看到,在这个例子里面WASM要比JS快了一倍。

    然后再比较解析的时间

    (2)解析时间比较

    如下代码所示:

    console.time("wasm big content parse");
    loadWebAssembly("big.wasm").then(instance => {
        var fibonacci = instance.exports._fibonacci;
        console.timeEnd("wasm big content parse");
        console.time("js big content parse");
        loadJs();
    });
    
    function loadJs(){
       loadWebAssembly("big.js", {}, "js").then(instance => {
           var fibonacci = instance.exports.fibonacci;
           console.timeEnd("js big content parse");
       });
    }
    

    分别比较解析100、2000、20000行代码的时间,统计结果如下:

    WASM的编译时间要高于JS,因为JS定义的函数只有被执行的时候才去解析,而WASM需要一口气把它们都解析了。

    上面表格的时间是一个什么概念呢,可以比较一下常用库的解析时间,如下图所示:

    (3)文件大小比较

    20000行代码,wasm格式只有3.4k,而压缩后的js还有165K,如下图所示:

    所以wasm文件小,它的加载时间就会少,可以一定程度上弥补解析上的时间缺陷,另外可以做一些懒惰解析的策略。

    6. WASM的优缺点

    WASM适合于那种对计算性能特别高的,如图形计算方面的,缺点是它的类型检验比较严格,写JS编译经常会报错,不方便debug。

    WASM官网提供的一个WebGL + WebAssembly坦克游戏如下所示:

    它的数据和函数都是用的wasm格式:

    7. C/Rust写前端

    WASM还支持用C/Rust写,需要安装一个emsdk。然后用C函数写一个fibonacci.c文件如下所示:

    /* 不考虑溢出 */
    int fibonacci(int num){
        if(num <= 0) return 0;
        if(num == 1 || num == 2) return 1;
        int fn = 1,
            fn1 = 1,
            fn2 = fn + fn1;
        for(int i = 4; i <= num; i++){
            fn = fn1;
            fn1 = fn2;
            fn2 = fn1 + fn;
        }
        return fn2;
    }
    

    运行以下命令编译成一个wasm文件:

    emcc fibonacci.c -Os -s WASM=1 -s SIDE_MODULE=1 -o fibonacci.wasm

    这个wasm和上面的是一样的格式,然后再用同样的方式在浏览器加载使用。

    用C写比用JS写更加地流畅,定义一个变量不用在后面写一个“| 0”,编译起来也非常顺畅,一次就过了,如果出错了,提示非常友好。这就可以把一些C库直接挪过来前端用。

    8. WASM对写JS的提示

    WASM为什么非得强类型的呢?因为它要转成汇编,汇编里面就得是强类型,这个对于JS解释器也是一样的,如果一个变量一下子是数字,一下子又变成字符串,那么解释器就得额外的工作,例如把原本的变量销毁再创建一个新的变量,同时代码可读性也会变差。所以提倡:
    定义变量的时候告诉解释器变量的类型
    不要随意改变变量的类型
    函数返回值类型是要确定的

    这个我在《Effective前端8:JS书写优化》已经提到.
    到此,介绍完毕,通过本文应该对程序的编译有一个直观的了解,特别是代码是怎么变成机器码的,还有WebAssembly和JS的关系又是怎么样的,Webassembly是如何提高运行速度,为什么要提倡强类型风格代码书写。对这些问题应该可以有一个理解。
    另外一方面,web前端技术的发展真的是非常地活跃,在学这些新技术的同时,别忘了打好基本功。

    原文:极乐科技知乎专栏

    相关文章

      网友评论

      本文标题:WebAssembly

      本文链接:https://www.haomeiwen.com/subject/ulegqxtx.html