This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="15 | 如何实现一个 WebAssembly 在线多媒体处理应用(一)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/87/38b91fe3bd185ae8b8fc67496141c987.mp3"></audio>
你好,我是于航。
在之前两个章节的内容中,我们讲解了 Wasm 相关的核心原理,并介绍了 Wasm 在实际各个应用领域内的一些实践情况。从这一讲开始,我们将进入“实战篇”。作为第一个实战应用,我会手把手教你从零构建一个完整的 Wasm Web 应用。
具体是什么应用呢?你应该还记得,前面我们曾讲过一节课,题目是 “WebAssembly 在物联网、多媒体与云技术方面有哪些创新实践?” 。在那节课中,我们曾谈到过 Wasm 在 Web 多媒体资源处理领域所具有的极大优势。因此,接下来我们将一起尝试构建的应用,便是这样一个基于 Wasm 的在线 DIP 应用。
我把这个构建 Wasm Web 应用的完整过程,分成了上中下三讲。希望在你学完这三讲之后,能够游刃有余地了解一个 Wasm Web 应用从 0 到 1 的完整构建过程。我会在课程中尽量覆盖到足够多的实现细节,这样你可以通过细节去结合各方面的知识,不会在学习的过程中出现“断层”。
那接下来我们就直接进入主题,先来了解下这个 DIP 应用的概况。
## DIP 应用概览
DIP 的全称为 “Digital Image Processing”即“数字图像处理”。在我们将要构建的这个 Web 应用中,我们将会为在线播放的流媒体资源,去添加一个特定的实时“图像处理滤镜”,以改变视频本身的播放显示效果。
由于添加实时滤镜需要在视频播放时,同步地对当前某一帧画面上的所有像素点,进行一定的像素值的数学处理变换,因此整个应用从某个角度来说,是一个“计算密集型”应用。
首先,我们来看下这个应用在实际运行时的样子,你可以先看看下面给出的这张图。
<img src="https://static001.geekbang.org/resource/image/9b/24/9b5c89f8a3da6e12638e745db9b63624.gif" alt="">
根据这张图,我们可以将整个应用的运行界面划分为三个部分。其中最为明显就是上方的视频显示区域。在这个矩形的区域中,我们将循环播放一段视频,并根据用户是否选择添加滤镜,来实时动态地改变这里的视频显示效果。
紧接着视频下方的区域用来显示当前视频的实时播放帧率。通过显示播放帧率,我们将能够在应用运行时明显地看出,当在分别采用 JavaScript 以及 Wasm 为视频资源“添加”滤镜时,两者在计算性能上的差异。
再往下的一部分,便是整个应用的控制区域。在这个区域中,我们可以控制是否选择为视频添加滤镜效果。以及是使用 JavaScript 还是 Wasm 来处理滤镜的添加过程。当我们选择好某一项设置后,可以直接点击下方的“确定”按钮,使当前设置立即生效。
介绍完这个应用的 UI 布局之后,我们来总体看一看整个应用的结构图,如下所示。在后面的内容中,我们将会根据这个结构图,一步一步地来构建这个 Web 应用。
<img src="https://static001.geekbang.org/resource/image/4e/17/4e5096725af5c0e8563c94b40d896517.png" alt="">
应用被划分为几个部分。首先,为了能够实时地处理视频数据,我们需要将 HTML `&lt;video&gt;`标签所承载视频的每一帧,都绘制在一个 Canvas 对象上,并通过 Web API — “requestAnimationFrame” 来让这些帧“动起来”。
然后这些数据将会根据用户所选择的设置,分别传递给 Wasm 模块或 JavaScript 进行相应的滤镜处理。这里 JavaScript 还同时兼具控制整个应用 UI 交互的作用,比如处理“点击事件”,处理用户所选择的设置等等。
## 滤镜的基本原理
在正式开始编写应用之前,我们还需要介绍几个在应用开发过程中会涉及到的概念和工具。首先,当我们说到“为视频添加滤镜”时,这一操作里的“滤镜”,主要是指什么?只有当你真正了解这个概念之后,你才能够知道相应的 JavaScript 代码,或者是 Wasm 代码需要做些什么。
为了了解什么是滤镜,我们需要先学习 DIP 领域中的一个基本概念 —— “卷积”。从一个直观的角度来看,对图像进行卷积的过程,其实就是通过一个具有固定尺寸的矩阵(也可以理解为二维数组),来对该图像中的每一个像素点的值进行重新计算的过程。
这个过程通常也被称为“滤波”。而我们上面介绍的固定尺寸的矩阵,一般被称为“卷积核”。每一种类型的卷积核都会对图像产生不同的滤镜效果。卷积的计算过程也十分简单,主要分为以下几个步骤。
- 首先将卷积核矩阵翻转 180 度。
- 然后将图像上除最外一圈(考虑到“边缘效应”,我们选择直接忽略边缘像素)的其他各像素点的灰度值,与对应的卷积核矩阵上的数值相乘,然后对所有相乘后得到的值求和,并将结果作为卷积核中间像素点对应图像上像素的灰度值。(这里提到的“灰度值”也可以由每个像素点的各 RGB 分量值来进行代替)。
- 重复上述步骤,直至图像中所有其他像素点均完成此计算过程。
为了加深你对上面计算过程的理解,这里我们来举个例子。首先,我们给出一个 3 x 3 大小的卷积核矩阵:
<img src="https://static001.geekbang.org/resource/image/61/6a/612fb2ab1a5d301c3df54d27e9fb856a.jpg" alt="">
按照步骤,第一步我们需要对该矩阵进行 180 度的旋转,但由于该矩阵是中心对称的,因此经过旋转后的矩阵与原矩阵相比,没有任何变化。接下来,我们给出如下一个 4 x 4 像素大小的图像,并使用上述卷积核来对此图像进行滤波操作。该图像中各像素点的 RGB 分量值如下所示:
<img src="https://static001.geekbang.org/resource/image/3e/8d/3eb92fcc4117264579e9127bdbf58d8d.jpg" alt="">
按照规则,对于 3 x 3 大小的卷积核矩阵,我们可以直接忽略图像最外层像素的卷积处理。相应地,我们需要从第二行第二列的像素点开始进行卷积计算。
首先,将之前翻转后的卷积核矩阵中心,与第二行第二列位置的这个像素点对齐,然后你会发现,卷积核矩阵中的各个单元,正好与图像左上角 3 x 3 范围内的像素一一对应。这就是我们可以忽略对图像最外一层像素进行卷积处理的原因。
因为在对这些像素点进行卷积计算时,卷积核矩阵覆盖的像素范围会超过图像的边界。你可以参考文中下面这张图来帮助理解我们现在所进行的步骤。
<img src="https://static001.geekbang.org/resource/image/0a/91/0aae0c53b99f9caceedba409557caf91.png" alt="">
接着,我们开始计算。计算过程很简单。首先,我们把卷积核矩阵对应的 9 个单元格内,各像素点的 RGB 分量值与对应单元内的数值相乘,然后将这九个值进行求和。得到的结果值就是在卷积核矩阵中心单元格内,所对应像素的 RGB 分量值的卷积结果值。对于其他分量的卷积计算过程可以以此类推。
可能这么说,你还是有些不好理解。我以图像中第二行第二列的像素点为例,给你计算一下这个像素点 R 分量的卷积结果 `R(c)`
`R(c) = 2 x 0 + -1 x 0 + 2 x 0 + -1 x 0 + 2 x 10 + -1 x 255 + 2 x 0 + -1 x 0 + 2 x 100 = -35`
可以看到,这个分量值在经过卷积计算后的结果值为 -35。但别急相信你已经发现了不对的地方。一个 RGB 分量的有效取值范围为 [0, 255],而负数则明显是不正确的。
因此,在实际的卷积计算过程中,我们还需增加另外一个规则,也就是:对于小于 0 的计算结果,用 0 代替,大于 255 的计算结果,则用 255 进行代替。按照这个规则,该像素值经过卷积计算后的实际结果值应该为 0。
而在本次实践中,我们将会使用下面这个同样 3 x 3 大小的卷积核:
<img src="https://static001.geekbang.org/resource/image/07/a4/07f769cee3a36b9fbd67f72e6707f2a4.jpg" alt="">
## Emscripten 的基本用法
接下来,我们将讲解一下,本次实践所需要使用到的编译工具 — Emscripten**(版本 1.39.19**。简单来说Emscripten 是一个“源到源”语言编译器工具集,这个工具集可以将 C/C++ 代码编译成对应 JavaScript 代码。
既然是工具集,它便不是由单一的二进制可执行文件组成的,除了最为重要的编译器组件 emcc 以外Emscripten 还同时为我们提供了包含有特定功能宏定义的 C/C++ 头文件、一些 Python 脚本以及其他的附属命令行工具等。其中emcc 的基本组成结构如下图所示:
<img src="https://static001.geekbang.org/resource/image/0b/24/0bfdd89a6d80d3dcbb88a40bb2f89c24.png" alt="">
可以看到emcc 能够将输入的 C/C++ 代码,编译成对应的 JavaScript 代码以及用于组成 Web 应用的 HTML 文件。
起初Emscripten 主要用于将 C/C++ 代码编译成对应的 ASM.js 代码,而随着后来 Wasm 的逐渐发展和流行Emscripten 也开始支持将这些输入代码编译成 Wasm 二进制代码。这部分代码的转换功能,主要依赖于 LLVM 为支持 Wasm 而特意添加的编译器后端。
因此整个转换的大致流程可以简单归结为C/C++ 源代码 -&gt; LLVM IR -&gt; Wasm。
emcc 直接使用了 Clang 编译器的前端,把编译输入的 C/C++ 源代码转换到 LLVM-IR 中间代码。这些中间形式的代码有利于编译器进行特殊的优化,以便生成更加优质的目标代码。
需要注意的一点是,在上图中你可以看到一个名为 “Fastcomp” 的组件,这个组件是 Emscripten 在旧版本中用于生成 ASM.js 代码的编译器后端,当然它也兼有生成 Wasm 代码的功能。
但是在最近的版本中Emscripten 已经完全使用 LLVM 的后端,来代替 Fastcomp 生成 Wasm 二进制代码。从某种程度上来看,使用 LLVM 的 Wasm 编译器后端,将逐渐成为在主流静态编译器中,将 Wasm 作为编译目标的首选实现方式。
关于 Emscripten 的具体安装过程你可以参考官方文档。安装完成后,我们便可以来小试身手。接下来,我们将尝试使用 Emscripten 编译如下这段 C/C++ 代码(文件名为 main.cc
```
#include &lt;iostream&gt;
#include &lt;emscripten.h&gt;
extern &quot;C&quot; {
EMSCRIPTEN_KEEPALIVE
int add(int x, int y) {
return x + y;
}
}
int main(int argc, char **argv) {
std::cout &lt;&lt; add(10, 20) &lt;&lt; std::endl;
return 0;
}
```
在这段代码中,我们声明了一个函数 “add”该函数接收两个整型参数并返回这两个参数的算数和。整个函数的定义被放置在 extern “C” {} 结构中,以防止函数名被 C++ Name Mangling 改变。这样做的目的主要在于,我们可以确保当在宿主环境(比如浏览器)中调用该函数时,可以用基本与 C/C++ 源代码中保持一致的函数名,来直接调用这个函数。
这里需要注意的一个点是,我们使用了名为 “EMSCRIPTEN_KEEPALIVE” 的宏标记了该函数。这个宏定义在头文件 “emscripten.h” 中,通过使用它,我们能够确保被“标记”的函数不会在编译器的编译过程中,被 DCEDead Code Elimination过程处理掉。紧接着我们定义了主函数 main并在其中调用了该函数。最后通过 std::cout 将该函数的调用结果输出到 stdout。
在代码编写完成后,我们可以使用下面的命令行来编译这段代码:
```
emcc main.cc -s WASM=1 -O3 -o main.html
```
这里我们通过 “-s” 参数,为 emcc 指定了编译时选项 “WASM=1”。该选项可以让 emcc 将输入的 C/C++ 源码编译为对应的 Wasm 格式目标代码。同时,我们还指定了产出文件的格式为 “.html”这样 Emscripten 便会为我们生成一个可以直接在浏览中使用的 Web 应用。
在这个应用中,包含了所有我们需要使用到的 Wasm 模块文件、JavaScript 代码以及 HTML 代码。为了能够在本地运行这个简单的 Web 应用,我们还需要准备一个简单的 Web 服务器,这里我们直接使用 Node.js 创建了一个简易的版本。代码如下所示:
```
const http = require('http');
const url = require('url');
const fs = require('fs');
const path =require('path');
const PORT = 8888; // 服务器监听的端口号;
const mime = {
&quot;html&quot;: &quot;text/html;charset=UTF-8&quot;,
&quot;wasm&quot;: &quot;application/wasm&quot; // 当遇到对 &quot;.wasm&quot; 格式文件的请求时,返回特定的 MIME 头;
};
http.createServer((req, res) =&gt; {
let realPath = path.join(__dirname, `.${url.parse(req.url).pathname}`);
// 检查所访问文件是否存在,且是否可读;
fs.access(realPath, fs.constants.R_OK, err =&gt; {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end();
} else {
fs.readFile(realPath, &quot;binary&quot;, (err, file) =&gt; {
if (err) {
// 文件读取失败时返回 500
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end();
} else {
// 根据请求的文件返回相应的文件内容;
let ext = path.extname(realPath);
ext = ext ? ext.slice(1) : 'unknown';
let contentType = mime[ext] || &quot;text/plain&quot;;
res.writeHead(200, { 'Content-Type': contentType });
res.write(file, &quot;binary&quot;);
res.end();
}
});
}
});
}).listen(PORT);
console.log(&quot;Server is runing at port: &quot; + PORT + &quot;.&quot;);
```
关于代码的实现细节,主要部分我都以注释的形式进行了标注。其中最为重要的一个地方就是对 “.wasm” 格式文件请求的处理。可以看到,这里当服务器收到一个对 “.wasm” 格式文件的 HTTP 请求时,会返回特殊的 MIME 类型 “application/wasm”。
通过这种方式,我们可以明确告诉浏览器,这个文件是一个 Wasm 格式的文件,进而浏览器便可以允许应用使用针对 Wasm 文件的“流式编译”方式(也就是我们在之前文章中介绍的 WebAssembly.instantiateStreaming 这个 Web API来加载和解析该文件这种方式在加载的处理大体积 Wasm 文件时会有着非常明显的效率优势。
接着,启动这个本地服务器后,我们便可以在浏览器中通过 8888 端口来访问刚刚由 Emscripten 编译生成的 main.html 文件。如果你顺利地到达了这里,那么恭喜,你将可以看到如下界面:
<img src="https://static001.geekbang.org/resource/image/1b/8a/1b2067df16ae17d002dec5d2c0586f8a.png" alt="">
仔细观察,你会发现我们之前在 C/C++ 代码的 main 函数中,通过 std::cout 打印的,函数 add 的调用结果被显示在了下方的黑色区域中。
我们都知道,在 C/C++ 代码中,可以通过标准库提供的一系列 API 来直接访问主机上的文件,甚至也可以通过调用本地主机的系统调用来使用多线程等功能。那么,这部分代码是如何被编译到 Wasm 里,并允许在 Web 浏览器中使用的呢?这一切,都要归功于 Emscripten 为我们提供的一个虚拟主机运行时环境。
如下面这张图所示,通常一个完整的 Wasm Web 应用都会由三部分组成Wasm 模块代码、JavaScript 胶水代码以及一些对 Web API 的调用。
<img src="https://static001.geekbang.org/resource/image/4f/15/4f7ba2b46c6f03658a478byydd416515.png" alt="">
为了能够支持在 Web 浏览器中“使用”诸如 std::fopen 等 C/C++ 语言中用于访问本机文件资源的标准库函数Emscripten 会使用诸如 LocalStorage 之类的浏览器特性,来模拟完整的 POSIX 文件操作和相关的数据结构。当然,只不过这一切都是使用 JavaScript 来模拟实现的。
同样,在我们这个例子中,对于发送到 stdout 的数据Emscripten 会通过 JavaScript 直接映射并输出到页面上的指定的 textarea 区域中。类似的,对于多线程甚至 TCP 网络访问POSIX SocketEmscripten 也会相应地通过浏览器上的 Web Worker 以及 Web Socket 等方式来进行模拟。
在上面的例子中,我们尝试了 Emscripten 最基本、最简单的,用于构建 Wasm Web 应用的一种方式。但该方法的弊端在于由 Emscripten 自动生成的“胶水代码”中,包含有通过 JavaScript 模拟出的 POSIX 运行时环境的完整代码因此在某些情况下应用整体的体积可能还是稍显过大。在极端网络环境的情况下Web 应用可能会显得力不从心。
是否有方法可以让 Emscripten 仅生成 C/C++ 代码对应的 Wasm 模块,而对于 JS Glue 和 Web API 这两部分的代码则由我们自行编写呢?在接下来的两节课里,我将为你解答这个疑问。
## 总结
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
今天我们主要讲解了与实战项目有关的一些概念,比如“什么是滤镜?”,“为图片添加滤镜的具体步骤?”,以及 “什么是 Emscripten“Emscripten 的基础用法?”等等。提前对这些概念有所了解可以加深我们对整个实战项目的组成结构与实现细节的把握。
其中,我希望你能够明确了解 Emscripten 在构建 Wasm Web 应用时,其所充当的一个编译器的角色,它可以将源 C/C++ 代码编译到 JavaScript 代码(甚至包括相应的 HTML 文件)。
另外,你需要重点了解的是,当 Emscripten 作为工具链使用时,它“以 emcc 、多种具有特定功能宏定义的 C/C++ 头文件为主,其他脚本和命令行工具为辅”的整体组成结构。
作为首个成功帮助 Wasm 在 Web 浏览器中进行生产实践的工具链Emscripten 还有着众多的特性和功能等待着你去探索。
## **课后练习**
最后,我们来做一个小练习吧。
还记得在今天的 Emscripten 实例中,我们使用到了名为 “EMSCRIPTEN_KEEPALIVE” 的宏,来确保被标记的函数不会被编译器优化掉。那么,你知道它具体是怎样实现的吗?
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,319 @@
<audio id="audio" title="16 | 如何实现一个 WebAssembly 在线多媒体处理应用(二)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/da/3727d6d3ecdc3015e78084fbddec75da.mp3"></audio>
你好,我是于航。
在上一节课中我们介绍了本次实践项目在代码层面的大体组成结构着重给你讲解了需要了解的一些基础性知识比如“滤镜的基本原理及实现方法”以及“Emscripten 的基本用法”等等。而在这节课中,我们将继续构建这个基于 Wasm 实现的多媒体 Web 应用。
## HTML
首先,我们来构建这个 Web 应用所对应的 HTML 部分。这部分代码如下所示:
```
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;title&gt;DIP-DEMO&lt;/title&gt;
&lt;style&gt;
* { font-family: &quot;Arial,sans-serif&quot;; }
.fps-num { font-size: 50px; }
.video { display: none; }
.operation { margin: 20px; }
button {
width: 150px;
height: 30px;
margin-top: 10px;
border: solid 1px #999;
font-size: 13px;
font-weight: bold;
}
.radio-text { font-size: 13px; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;canvas class=&quot;canvas&quot;&gt;&lt;/canvas&gt;
&lt;div class=&quot;operation&quot;&gt;
&lt;h2&gt;帧率:&lt;span class=&quot;fps-num&quot;&gt;NaN&lt;/span&gt; FPS&lt;/h2&gt;
&lt;input name=&quot;options&quot; value=&quot;0&quot; type=&quot;radio&quot; checked=&quot;checked&quot;/&gt;
&lt;span class=&quot;radio-text&quot;&gt;不开启渲染.&lt;/span&gt; &lt;br/&gt;
&lt;input name=&quot;options&quot; value=&quot;1&quot; type=&quot;radio&quot;/&gt;
&lt;span class=&quot;radio-text&quot;&gt;使用 &lt;b&gt;[JavaScript]&lt;/b&gt; 渲染.&lt;/span&gt;
&lt;br/&gt;
&lt;input name=&quot;options&quot; value=&quot;2&quot; type=&quot;radio&quot;/&gt;
&lt;span class=&quot;radio-text&quot;&gt;使用 &lt;b&gt;[WebAssembly]&lt;/b&gt; 渲染.&lt;/span&gt;
&lt;br/&gt;
&lt;button&gt;确认&lt;/button&gt;
&lt;/div&gt;
&lt;video class=&quot;video&quot; type=&quot;video/mp4&quot;
muted=&quot;muted&quot;
loop=&quot;true&quot;
autoplay=&quot;true&quot;
src=&quot;media/video.mp4&quot;&gt;
&lt;/body&gt;
&lt;script src='./dip.js'&gt;&lt;/script&gt;
&lt;/html&gt;
```
为了便于演示HTML 代码部分我们尽量从简,并且直接将 CSS 样式内联到 HTML 头部。
其中最为重要的两个部分为 `“&lt;canvas&gt;`” 标签和 “`&lt;video&gt;`” 标签。`&lt;canvas&gt;` 将用于展示对应 `&lt;video&gt;` 标签所加载外部视频资源的画面数据;而这些帧数据在被渲染到`&lt;canvas&gt;`之前,将会根据用户的设置,有选择性地被 JavaScript 代码或者 Wasm 模块进行处理。
还有一点需要注意的是,可以看到我们为`&lt;video&gt;` 标签添加了名为 “muted”、“loop” 以及 “autoplay” 的三个属性。这三个属性分别把这个视频资源设置为“静音播放”、“循环播放”以及“自动播放”。
实际上,根据 Chrome 官方给出的 “Autoplay Policy” 政策,我们并不能够直接依赖其中的 “autoplay” 属性,来让视频在用户打开网页时立即自动播放。稍后你会看到,在应用实际加载时,我们仍会通过调用 `&lt;video&gt;` 标签所对应的 play() 方法,来确保视频资源可以在网页加载完毕后,直接自动播放。
最后,在 HTML 代码的末尾处,我使用 `&lt;script&gt;` 标签加载了同目录下名为 “dip.js” 的 JavaScript 文件。在这个文件中,我们将完成该 Web 应用的所有控制逻辑包括视频流的控制与显示逻辑、用户与网页的交互逻辑、JavaScript 版滤镜的实现、Wasm 版滤镜实现对应的模块加载、初始化与调用逻辑,以及实时帧率的计算逻辑等。
## JavaScript
趁热打铁,我们接着来编写整个 Web 应用组成中,最为重要的 JavaScript 代码部分。
### 视频流的控制与显示逻辑
第一步,我们要实现的是将 `&lt;video&gt;` 标签所加载的视频资源,实时渲染到 `&lt;canvas&gt;` 标签所代表的画布对象上。这一步的具体实现方式,你可以参考下面这张示意图。
<img src="https://static001.geekbang.org/resource/image/1c/98/1c22fcd901f33a622b1fdc117a7db798.png" alt="">
其中的核心逻辑是,我们需要通过名为 “CanvasRenderingContext2D.drawImage()” 的 Web API ,来将 `&lt;video&gt;` 标签所承载视频的当前帧内容,绘制到 `&lt;canvas&gt;` 上。这里我们使用到的 drawImage() 方法,支持设置多种类型的图像源,`&lt;video&gt;` 标签所对应的 “HTMLVideoElement” 便是其中的一种。
CanvasRenderingContext2D 接口是 Web API 中, Canvas API 的一部分。通过这个接口,我们能够获得一个,可以在对应 Canvas 上进行 2D 绘图的“渲染上下文”。稍后在代码中你会看到,我们将通过 `&lt;canvas&gt;` 对象上名为 “getContext” 的方法,来获得这个上下文对象。
我们之前曾提到drawImage() 方法只能够绘制 `&lt;video&gt;` 标签对应视频流的“当前帧”内容,因此随着视频的播放,“当前帧”内容也会随之发生改变。
为了能够让绘制到 `&lt;canvas&gt;` 上的画面可以随着视频的播放来实时更新,这里我们将使用名为 “window.requestAnimationFrame” 的 Web API 来实时更新绘制在 `&lt;canvas&gt;` 上的画面内容(如果你对这个 API 不太熟悉,可以点击[这里](https://time.geekbang.org/column/article/288704)回到“基础课”进行复习)。
下面我们给出这部分功能对应的代码实现:
```
// 获取相关的 HTML 元素;
let video = document.querySelector('.video');
let canvas = document.querySelector('.canvas');
// 使用 getContext 方法获取 &lt;canvas&gt; 标签对应的一个 CanvasRenderingContext2D 接口;
let context = canvas.getContext('2d');
// 自动播放 &lt;video&gt; 载入的视频;
let promise = video.play();
if (promise !== undefined) {
promise.catch(error =&gt; {
console.error(&quot;The video can not autoplay!&quot;)
});
}
// 定义绘制函数;
function draw() {
// 调用 drawImage 函数绘制图像到 &lt;canvas&gt;
context.drawImage(video, 0, 0);
// 获得 &lt;canvas&gt; 上当前帧对应画面的像素数组;
pixels = context.getImageData(0, 0, video.videoWidth, video.videoHeight);
// ...
// 更新下一帧画面;
requestAnimationFrame(draw);
}
// &lt;video&gt; 视频资源加载完毕后执行;
video.addEventListener(&quot;loadeddata&quot;, () =&gt; {
// 根据 &lt;video&gt; 载入视频大小调整对应的 &lt;canvas&gt; 尺寸;
canvas.setAttribute('height', video.videoHeight);
canvas.setAttribute('width', video.videoWidth);
// 绘制函数入口;
draw(context);
});
```
关于代码中每一行的具体功能,你可以参考附加到相应代码行前的注释加以理解。首先,我们需要获得相应的 HTML 元素,这里主要是 `&lt;canvas&gt;``&lt;video&gt;` 这两个标签对应的元素对象,然后我们获取了 `&lt;canvas&gt;` 标签对应的 2D 绘图上下文。
紧接着,我们处理了 `&lt;video&gt;` 标签所加载视频自动播放的问题,这里我们直接调用了 `&lt;video&gt;` 元素的 play 方法。该方法会返回一个 Promise针对 reject 的情况,我们做出了相应的处理。
然后,我们在 `&lt;video&gt;` 元素的加载回调完成事件 “loadeddata” 中,根据所加载视频的尺寸相应地调整了 `&lt;canvas&gt;` 元素的大小,以确保它可以完整地显示出视频的画面内容。同时在这里,我们调用了自定义的 draw 方法,来把视频的首帧内容更新到 `&lt;canvas&gt;` 画布上。
在 draw 方法中,我们调用了 drawImage 方法来更新 `&lt;canvas&gt;` 画布的显示内容。该方法在这里接受三个参数,第一个为图像源,也就是 `&lt;video&gt;` 元素对应的 HTMLVideoElement 对象;第二个为待绘制图像的起点在 `&lt;canvas&gt;` 上 X 轴的偏移;第三个参数与第二个类似,相应地为在 Y 轴上的偏移。这里对于最后两个参数,我们均设置为 0。
然后,我们使用了名为 “CanvasRenderingContext2D.getImageData()” 的方法(下文简称 “getImageData”来获得 `&lt;canvas&gt;` 上当前帧对应画面的像素数组。
getImageData 方法接受四个参数。前两个参数指定想要获取像素的帧画面,在当前帧画面 x 轴和 y 轴上的偏移范围。最后两个参数指定这个范围的长和宽。
四个参数共同指定了画面上的一个矩形位置,在对应该矩形的范围内,所有像素序列将会被返回。我们会在后面来使用和处理这些返回的像素数据。
最后,我们通过 requestAnimationFrame 方法,以 60Hz 的频率来更新 `&lt;canvas&gt;` 上的画面。
在上述这部分代码实现后,我们的 Web 应用便可在用户打开网页时,直接将 `&lt;video&gt;` 加载播放的视频,实时地绘制在 `&lt;canvas&gt;` 对应的画布中。
### 用户与网页的交互逻辑
接下来,我们继续实现 JavaScript 代码中,与“处理用户交互逻辑”这部分功能有关的代码。
这部分代码比较简单,主要流程就是监听用户做出的更改,然后将这些更改后的值保存起来。这里为了实现简单,我们直接以“全局变量”的方式来保存这些设置项的值。这部分代码如下所示:
```
// 全局状态;
const STATUS = ['STOP', 'JS', 'WASM'];
// 当前状态;
let globalStatus = 'STOP';
// 监听用户点击事件;
document.querySelector(&quot;button&quot;).addEventListener('click', () =&gt; {
globalStatus = STATUS[
Number(
document.querySelector(&quot;input[name='options']:checked&quot;).value
)
];
});
```
这里我们需要维护应用的三种不同状态不使用滤镜STOP、使用 JavaScript 实现滤镜JS、使用 Wasm 实现滤镜WASM。全局变量 globalStatus 维护了当前应用的状态,在后续的代码中,我们也将使用这个变量的值,来调用不同的滤镜实现,或者选择关闭滤镜。
### 实时帧率的计算逻辑
作为开始真正构建 JavaScript 版滤镜函数前的最后一步,我们先来实现帧率的实时计算逻辑,然后观察在不开启任何滤镜效果时的 `&lt;canvas&gt;` 渲染帧率情况。
帧率的一个粗糙计算公式如下图所示。对于帧率,我们可以将其简单理解为在 1s 时间内屏幕上画面能够刷新的次数。比如若 1s 时间内画面能够更新 60 次,那我们就可以说它的帧率为 60 赫兹Hz
<img src="https://static001.geekbang.org/resource/image/c1/f7/c159210666f44bb8df1cdfdb8fccc4f7.png" alt="">
因此,一个简单的帧率计算逻辑便可以这样来实现:首先,把每一次从对画面像素开始进行处理,直到真正绘制到 `&lt;canvas&gt;`这整个流程所耗费的时间,以毫秒为单位进行计算;然后用 1000 除以这个数值,即可得到一个估计的,在 1s 时间所内能够渲染的画面次数,也就是帧率。
这部分逻辑的 JavaScript 实现代码如下所示:
```
function calcFPS (vector) {
// 提取容器中的前 20 个元素来计算平均值;
const AVERAGE_RECORDS_COUNT = 20;
if (vector.length &gt; AVERAGE_RECORDS_COUNT) {
vector.shift(-1); // 维护容器大小;
} else {
return 'NaN';
}
// 计算平均每帧在绘制过程中所消耗的时间;
let averageTime = (vector.reduce((pre, item) =&gt; {
return pre + item;
}, 0) / Math.abs(AVERAGE_RECORDS_COUNT));
// 估算出 1s 内能够绘制的帧数;
return (1000 / averageTime).toFixed(2);
}
```
这里,为了能够让帧率的估算更加准确,我们为 JavaScript 和 Wasm 这两个版本的滤镜实现,分别单独准备了用来保存每帧计算时延的全局数组。这些数组会保存着在最近 20 帧里,每一帧计算渲染时所花费的时间。
然后,在上面代码中的函数 calcFPS 内,我们会通过对这 20 个帧时延记录取平均值,来求得一个更加稳定、相对准确的平均帧时延。最后,使用 1000 来除以这个平均帧时延,你就能够得到一个估算出的,在 1s 时间内能够绘制的帧数,也就是帧率。
上面代码中的语句 vector.shift(-1) 其主要作用是,当保存最近帧时延的全局数组内元素个数超过 20 个时,会移除其中最老的一个元素。这样,我们可以保证整个数组的大小维持在 20 及以内,不会随着应用的运行而产生 OOMOut-of-memory的问题。
我们将前面讲解的这些代码稍微整合一下,并添加上对应需要使用到的一些全局变量。然后尝试在浏览器中运行这个 Web 应用。在不开启任何滤镜的情况下,你可得到如下的画面实时渲染帧率(这里我们使用 Chrome 进行测试,不同的浏览器和版本结果会有所差异)。
<img src="https://static001.geekbang.org/resource/image/a7/b7/a70ce11d428523cb0126923765ca73b7.gif" alt="">
### JavaScript 滤镜方法的实现
接下来,我们将编写整个 Web 应用的核心组成之一 —— JavaScript 滤镜函数。关于这个函数的具体实现步骤,你可以参考在上一节课中介绍的“滤镜基本原理”。
首先,根据规则,我们需要准备一个 3x3 大小的二维数组,来容纳“卷积核”矩阵。然后将该矩阵进行 180 度的翻转。最后得到的结果矩阵,将会在后续直接参与到各个像素点的滤镜计算过程。这部分功能对应的 JavaScript 代码实现如下所示:
```
// 矩阵翻转函数;
function flipKernel(kernel) {
const h = kernel.length;
const half = Math.floor(h / 2);
// 按中心对称的方式将矩阵中的数字上下、左右进行互换;
for (let i = 0; i &lt; half; ++i) {
for (let j = 0; j &lt; h; ++j) {
let _t = kernel[i][j];
kernel[i][j] = kernel[h - i - 1][h - j - 1];
kernel[h - i - 1][h - j - 1] = _t;
}
}
// 处理矩阵行数为奇数的情况;
if (h &amp; 1) {
// 将中间行左右两侧对称位置的数进行互换;
for (let j = 0; j &lt; half; ++j) {
let _t = kernel[half][j];
kernel[half][j] = kernel[half][h - j - 1];
kernel[half][h - j - 1] = _t;
}
}
return kernel;
}
// 得到经过翻转 180 度后的卷积核矩阵;
const kernel = flipKernel([
[-1, -1, 1],
[-1, 14, -1],
[1, -1, -1]
]);
```
关于“如何将矩阵数组进行 180 度翻转”的实现细节,你可以参考代码中给出的注释来加以理解。
在一切准备就绪后,我们来编写核心的 JavaScript 滤镜处理函数 jsConvFilter。该处理函数一共接受四个参数。第一个参数是通过 getImageData 方法,从 `&lt;canvas&gt;` 对象上获取的当前帧画面的像素数组数据。
getImageData 在执行完毕后会返回一个 ImageData 类型的对象,在该对象中有一个名为 data 的属性。data 属性实际上是一个 Uint8ClampedArray 类型的 “Typed Array”其中便存放着所有像素点按顺序排放的 RGBA 分量值。你可以借助下面这张图来帮助理解上面我们描述的,各个方法与返回值之间的对应关系。
<img src="https://static001.geekbang.org/resource/image/24/b2/246c9fbfc1668c146d2e409yyf768eb2.png" alt="">
jsConvFilter 处理函数的第二和第三个参数为视频帧画面的宽和高;最后一个参数为所应用滤镜对应的“卷积核”矩阵数组。至此,我们可以构造如下的 JavaScript 版本“滤镜函数”:
```
function jsConvFilter(data, width, height, kernel) {
const divisor = 4; // 分量调节参数;
const h = kernel.length, w = h; // 保存卷积核数组的宽和高;
const half = Math.floor(h / 2);
// 根据卷积核的大小来忽略对边缘像素的处理;
for (let y = half; y &lt; height - half; ++y) {
for (let x = half; x &lt; width - half; ++x) {
// 每个像素点在像素分量数组中的起始位置;
const px = (y * width + x) * 4;
let r = 0, g = 0, b = 0;
// 与卷积核矩阵数组进行运算;
for (let cy = 0; cy &lt; h; ++cy) {
for (let cx = 0; cx &lt; w; ++cx) {
// 获取卷积核矩阵所覆盖位置的每一个像素的起始偏移位置;
const cpx = ((y + (cy - half)) * width + (x + (cx - half))) * 4;
// 对卷积核中心像素点的 RGB 各分量进行卷积计算(累加)
r += data[cpx + 0] * kernel[cy][cx];
g += data[cpx + 1] * kernel[cy][cx];
b += data[cpx + 2] * kernel[cy][cx];
}
}
// 处理 RGB 三个分量的卷积结果;
data[px + 0] = ((r / divisor) &gt; 255) ? 255 : ((r / divisor) &lt; 0) ? 0 : r / divisor;
data[px + 1] = ((g / divisor) &gt; 255) ? 255 : ((g / divisor) &lt; 0) ? 0 : g / divisor;
data[px + 2] = ((b / divisor) &gt; 255) ? 255 : ((b / divisor) &lt; 0) ? 0 : b / divisor;
}
}
return data;
}
```
你可以借助代码中的注释来了解整个卷积过程的实现细节。其中有这样几个点需要注意:
在整个方法的实现过程中,我们使用了名为 divisor 的变量来控制滤镜对视频帧画面产生的效果强度。divisor 的值越大,滤镜的效果就越弱。
在遍历整个帧画面的像素序列时(最外层的两个循环体),我们将循环控制变量 y 和 x 的初始值,设置为 Math.floor(h / 2),这样可以直接忽略对帧画面边缘像素的处理,进而也不用考虑图像卷积产生的“边缘效应”。
所谓“边缘效应”,其实就是指当我们在处理帧画面的边缘像素时,由于卷积核其范围内的一部分“单元格”无法找到与之相对应的像素点,导致边缘像素实际上没有经过“完整”的滤镜计算过程,会产生与预期不符的滤镜处理效果。而这里为了简化流程,我们选择了直接忽略对边缘像素的处理过程。
最后,在得到经过卷积累加计算的 RGB 分量值后,我们需要判断对应值是否在 [0, 255] 这个有效区间内。若没有,我们就将这个值,直接置为对应的最大有效值或最小有效值。
现在,我们将前面的所有代码功能加以整合,然后试着在浏览器中再次运行这个 Web 应用。你会看到类似下图的结果。相较于不开启滤镜,使用滤镜后的画面渲染帧率明显下降了。
<img src="https://static001.geekbang.org/resource/image/68/7c/68a6f2f38461bc9114cb480053644b7c.gif" alt="">
## 总结
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
今天我们主要讲解了本次实践项目中与 JavaScript 代码相关的几个重要功能的实现思路,以及实现细节。
JavaScript 代码作为当前用来构建 Web 应用所必不可少的一个重要组成部分,它负责构建整个应用与用户进行交互的逻辑处理部分。不仅如此,我们还使用 JavaScript 代码实现了一个滤镜处理函数,并用该函数处理了 `&lt;canvas&gt;` 上的帧画面像素数据,然后再将这些数据重新绘制到 `&lt;canvas&gt;` 上。
在下一节课里,你将会看到我们实现的 Wasm 滤镜处理函数,与 JavaScript 版滤镜函数在图像处理效率上的差异。
## **课后练习**
最后,我们来做一个练习题吧。
你可以试着更改我们在 JavaScript 滤镜函数中所使用的卷积核矩阵(更改矩阵中元素的值,或者改变矩阵的大小),来看看不同的卷积核矩阵会产生怎样不同的滤镜效果。
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,199 @@
<audio id="audio" title="17 | 如何实现一个 WebAssembly 在线多媒体处理应用(三)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/8a/01eyyb12d64da6dcd80d3f5164befd8a.mp3"></audio>
你好,我是于航。
在上一节课中,我们已经完成了本次实践项目的其中一个核心部分,也就是由 JavaScript 实现的滤镜函数。并且还同时完成了整个 Web 应用与用户的 UI 交互控制部分、视频图像的渲染和绘制逻辑,以及帧率计算逻辑及显示逻辑。
在这节课里,我们将一起来完成整个应用的另外一个核心部分,同时也是整个实践的主角。让我们来看看,相较于 JavaScript 滤镜函数,由 Wasm 实现的同版本滤镜函数会带来怎样的性能提升呢?
## 编写 C/C++ 函数源码
首先,为了能够得到对应 Wasm 字节码格式的函数实现,我们需要首先准备由 C/C++ 等高级语言编写的源代码,然后再通过 Emscripten 将其编译到 Wasm 格式。这部分代码的主要逻辑,与上一篇中的 JavaScript 版本滤镜函数其实现逻辑基本相同。代码如下所示:
```
// dip.cc
// 引入必要的头文件;
#include &lt;emscripten.h&gt;
#include &lt;cmath&gt;
// 宏常量定义,表示卷积核矩阵的高和宽;
#define KH 3
#define KW 3
// 声明两个数组,分别用于存放卷积核数据与每一帧对应的像素点数据;
char kernel[KH][KW];
unsigned char data[921600];
// 将被导出的函数,放置在 extern &quot;C&quot; 中防止 Name Mangling
extern &quot;C&quot; {
// 获取卷积核数组的首地址;
EMSCRIPTEN_KEEPALIVE auto* cppGetkernelPtr() { return kernel; }
// 获取帧像素数组的首地址;
EMSCRIPTEN_KEEPALIVE auto* cppGetDataPtr() { return data; }
// 滤镜函数;
EMSCRIPTEN_KEEPALIVE void cppConvFilter(
int width,
int height,
int divisor) {
const int half = std::floor(KH / 2);
for (int y = half; y &lt; height - half; ++y) {
for (int x = half; x &lt; width - half; ++x) {
int px = (y * width + x) * 4;
int r = 0, g = 0, b = 0;
for (int cy = 0; cy &lt; KH; ++cy) {
for (int cx = 0; cx &lt; KW; ++cx) {
const int cpx = ((y + (cy - half)) * width + (x + (cx - half))) * 4;
r += data[cpx + 0] * kernel[cy][cx];
g += data[cpx + 1] * kernel[cy][cx];
b += data[cpx + 2] * kernel[cy][cx];
}
}
data[px + 0] = ((r / divisor) &gt; 255) ? 255 : ((r / divisor) &lt; 0) ? 0 : r / divisor;
data[px + 1] = ((g / divisor) &gt; 255) ? 255 : ((g / divisor) &lt; 0) ? 0 : g / divisor;
data[px + 2] = ((b / divisor) &gt; 255) ? 255 : ((b / divisor) &lt; 0) ? 0 : b / divisor;
}
}
}
}
```
在这段代码中,我们将定义的所有函数均以 “cpp” 作为其前缀来命名,表明这个函数的实际定义来自于对应的 C/C++ 代码实现。其中“cppConvFilter” 函数为主要的滤镜计算函数。在该函数中我们保持着几乎与上一节课中JavaScript 版滤镜函数同样的实现逻辑。
在代码的开始,我们首先以 “#include” 的方式,包含了很多需要使用到的 C/C++ 头文件。其中 “emscripten.h” 头文件便由 Emscripten 工具链提供,其中包含着众多与 Wasm 编译相关的宏和函数定义。
另外的 “cmath” 头文件,是原始 C 标准库中的 “math.h” 头文件在 C++ 中的对应。两者在所提供函数的功能上基本没有区别。我们将使用该头文件中提供的 “std::floor” 函数,去参与滤镜的计算过程。
接下来,我们使用 “#define” 定义了两个宏常量 “KH” 与 “KW”分别表示卷积核的“高”与“宽”。并同时使用这两个常量定义了用来存放实际卷积核矩阵数据的二维数组 “kernel”。类似的我们还定义了用来存放每一帧对应像素数据的一维数组 “data”。
这里要注意的是,由于在 C/C++ 中,无法声明全局的动态大小数组,因此我们需要提前计算出,由 Web API “CanvasRenderingContext2D.getImageData” 所返回的,存放有每一帧对应像素数据的那个 Uint8ClampedArray 数组,在 C/C++ 中对应到 unsigned char 类型数组时的大小。
由于这两个数组所存储的单个元素其类型完全相同,因此我们直接使用这个得到的 Uint8ClampedArray 数组的大小,来作为对应 C/C++ 中 “data” 数组的大小。经过实践,我们得到的数组大小为 “921600”。
`extern "C" {}` 结构中,我们声明了所有需要导出到外部宿主环境(这里为浏览器的 JavaScript 环境)中使用的函数。其中除了 cppConvFilter 函数以外,还有另外的 cppGetkernelPtr 和 cppGetDataPtr 函数。这两个函数主要用来获取先前声明的数组 kernel 与 data 的首地址。通过这种方式,我们便可以在外部的 JavaScript 环境中,向定义在 C/C++ 中的这两个数组结构填充实际的运行时数据了。
## 使用 Emscripten 进行编译
当 C/C++ 源代码准备完毕后,我们便可以使用 Emscripten 来进行编译。与我们在实践项目的第一节课中介绍的 Emscripten 编译方式不同,这次我们不需要它帮助我们生成 JavaScript 胶水文件以及 HTML 文件,我们需要的仅是一个根据 C/C++ 代码生成的 Wasm 二进制模块文件,对于其他部分,我们将基于之前已经构建好的 JavaScript 和 HTML 代码来进行开发。
相较于 Emscripten 之前同时生成 JavaScript 胶水文件和 HTML 文件的方式,这种仅生成 Wasm 模块文件的方式,我们通常将其称为 “Standalone 模式”。对应的编译命令如下所示:
```
emcc dip.cc -s WASM=1 -O3 --no-entry -o dip.wasm
```
相比于之前的编译命令,这里我们做了两个更改。首先,我们将 “-o” 参数所指定的输出文件格式由原来 “.html” 变更为 “.wasm”。这样我们可以告诉 Emscripten 我们希望以 “Standalone” 的方式来编译输入的 C/C++ 源码。“no-entry” 参数告诉编译器,我们的 Wasm 模块没有声明 “main” 函数,因此不需要与 CRTC Runtime Library相关的功能进行交互。
在上述命令行执行完毕后,我们将会得到一个名为 “dip.wasm” 的 Wasm 二进制模块文件。
## 整合上下文资源
至此,我们便可以将这个通过 Emscripten 编译得到的名为 “dip.wasm” 的 Wasm 模块文件,整合到现阶段项目的 JavaScript 代码中。这里我们将使用 “WebAssembly.instantiate” 的方式来加载这个模块文件。对应的代码如下所示:
```
let bytes = await (await fetch('./dip.wasm')).arrayBuffer();
let { instance, module } = await WebAssembly.instantiate(bytes);
let {
cppConvFilter,
cppGetkernelPtr,
cppGetDataPtr,
memory } = instance.exports;
```
可以看到,通过 `fetch` 方法返回的 Respose 对象上的 arrayBuffer 函数,会将请求返回的内容解析为对应的 ArrayBuffer 形式。而这个 ArrayBuffer ,随后便会作为 WebAssembly.instantiate 方法的实际调用参数。
函数返回的 Promise 对象在被 resolve 之后,我们可以得到对应的 WebAssembly.Instance 实例对象和 WebAssembly.Module 模块对象(这里分别对应到名为 instance 和 module 的属性上)。然后在名为 instance 的变量中,我们便可以获得从 Wasm 模块导出的所有方法。
眼尖的你一定发现了,上面的代码除了从 instance.exports 对象中导出了定义在 Wasm 模块内的函数以外,还有另一个名为 memory 的对象。这个 memory 对象便代表着模块实例所使用到的线性内存段。线性内存段在 JavaScript 中的表示形式,也就是我们上文中提到的,是一个 ArrayBuffer 对象。
当然,这里 memory 实际上是一个名为 WebAssembly.Memory 的包装类对象,而该对象上的 “buffer” 属性中,便实际存放着对应模块线性内存的 ArrayBuffer 对象。
下面,我们便可以通过调用相应的方法来完成 Wasm 滤镜函数与 Web 应用的整合。
首先,我们需要将在 JavaScript 代码中获得到的卷积核矩阵数据,以及每一帧所对应的画面像素数据,填充到我们之前在 C/C++ 代码中定义的相应数组中。为了完成这一步,我们需要首先调用从模块实例中导出的 “cppGetDataPtr” 和 “cppGetkernelPtr” 两个方法,来分别获得这两个数组的首地址,也就是在模块实例线性内存段中的具体偏移位置。
然后,我们将使用 “Uint8Array” 与 “Int8Array” 这两个 TypedArray 类型来作为模块线性内存的操作视图,并向其中写入数据。
待数据填充完毕后,我们便可以调用从模块中导出的 “cppConvFilter” 方法来为原始的像素数据添加滤镜。
待方法调用完毕后,我们将通过 TypedArray 的 subarray 方法来返回一个,包含有已处理完毕像素数据的新的 TypedArray这些数据随后将会通过名为 CanvasRenderingContext2D.putImageData() 的 API 被重新绘制在 `&lt;canvas&gt;` 对象上,以实现画面的更新。
这部分功能对应的代码如下所示:
```
// 获取 C/C++ 中存有卷积核矩阵和帧像素数据的数组,在 Wasm 线性内存段中的偏移位置;
const dataOffset = cppGetDataPtr();
const kernOffset = cppGetkernelPtr();
// 扁平化卷积核的二维数组到一位数组,以方便数据的填充;
const flatKernel = kernel.reduce((acc, cur) =&gt; acc.concat(cur), []);
// 为 Wasm 模块的线性内存段设置两个用于进行数据操作的视图,分别对应卷积核矩阵和帧像素数据;
let Uint8View = new Uint8Array(memory.buffer);
let Int8View = new Int8Array(memory.buffer);
// 填充卷积核矩阵数据;
Int8View.set(flatKernel, kernOffset);
// 封装的 Wasm 滤镜处理函数;
function filterWASM (pixelData, width, height) {
const arLen = pixelData.length;
// 填充当前帧画面的像素数据;
Uint8View.set(pixelData, dataOffset);
// 调用滤镜处理函数;
cppConvFilter(width, height, 4);
// 返回经过处理的数据;
return Uint8View.subarray(dataOffset, dataOffset + arLen);
}
```
这里需要注意的是,我们之前在 JavaScript 中使用的卷积核矩阵数组,实际上是以二维数组的形式存在的。而为了能够方便地将这部分数据填充到 Wasm 线性内存中,这里我们将其扁平化成了一维数组,并存放到变量 flatKernel 中。
另外,我们仅将那些在视频播放过程中可能会发生变化的部分(这里主要是指每一帧需要填充到 Wasm 模块实例线性内存的像素数据),都单独整和到了名为 filterWasm 的函数中,这样在动画的播放过程中,可以减少不必要的数据传递过程。
## 性能对比
最后我们选择了如下两款市面上最为常见的浏览器,来分别测量我们构建的这个 DIP Web 应用在 JavaScript 滤镜和 Wasm 滤镜这两个选项下的视频播放帧率。
- Chrome Version 84.0.4147.89 (Official Build) (64-bit)
- Firefox Version 79.0
实际测试结果的截图如下所示。其中左侧为 JavaScript 版本滤镜函数,右侧为对应的 Wasm 版本滤镜函数。
首先是 Chrome
<img src="https://static001.geekbang.org/resource/image/a4/e9/a4eb93d61be5af9e716ed706654669e9.png" alt="">
然后是 Firefox
<img src="https://static001.geekbang.org/resource/image/78/c6/78ae36409a42d36cf3156c66ece069c6.png" alt="">
可以看到,同样逻辑的滤镜函数,在对应的 JavaScript 实现版本和 Wasm 实现版本下有着极大的性能差异。Wasm 版本函数的帧画面实时处理效率几乎是对应 JavaScript 版本函数的一倍之多。当然,上述的性能对比结果仅供参考,应用的实际表现是一个综合性的结果,与浏览器版本、代码实现细节、编译器版本甚至操作系统版本都有着密切的关系。
如果再通过 Chrome 的 Performance 工具来查看jsConvFilter 与 cppConvFilter 这两个分别对应的 JavaScript 滤镜实现与 Wasm 滤镜实现函数的运行耗时,你可以发现如下所示的结果:
<img src="https://static001.geekbang.org/resource/image/a1/a0/a1790yyc7172bf7a49a0b13a11bdcca0.png" alt="" title="Wasm 滤镜函数实现的耗时">
<img src="https://static001.geekbang.org/resource/image/29/cb/296bf7d7c3e2ec40bc1db0408f9bffcb.png" alt="" title="JavaScript 滤镜函数实现的耗时">
可以看到JavaScript 滤镜函数实现的运行耗时是 Wasm 版本的将近 3 倍。但由于 getImageData 函数在应用实际运行时也会占用一部分时间,因此使得在每一帧画面的刷新和滤镜渲染过程中,整个 Wasm 滤镜处理过程的耗时只能被优化到对应 JavaScript 版本的一半时间左右。同样的Wasm 实现下通过 Uint8View.set 向 Wasm 实例线性内存段中填充像素数据的过程也同样会占用一定的额外耗时,但这部分的比例相对很小。
## 总结
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
通过完整的三节课,我们讲解了如何从第一行代码开始着手编写一个完整的 Wasm Web 应用。在构建应用的过程中,我们知道了如何使用 Emscripten 来直接编译输入的 C/C++ 代码到一个完整的、可以直接运行的 Web 应用;或者是基于 “Standalone 模式”来仅仅输出源代码对应的 Wasm 二进制模块文件。
不仅如此,我们还知道了 Emscripten 在被作为工具链使用时,它还为我们提供了诸如 EMSCRIPTEN_KEEPALIVE 等宏函数以支持编译过程的正常进行。Emscripten 为我们提供了极其强大的宏函数支持以及对 Web API 的无缝整合。
甚至你可以直接将基于 OpenGL 编写的 C/C++ 应用编译成 Wasm Web 应用而无需做任何源代码上的修改。Emscripten 会通过相应的 JavaScript 胶水代码来处理好 OpenGL 与 WebGL 的调用映射关系,让你真正地做到“无痛迁移”。
在编译完成后,我们还学习了如何通过 Web API 和 JavaScript API 来加载并实例化一个 Wasm 模块对象。WebAssembly.instantiate 与 WebAssembly.instantiateStreaming 这两个主要用来实例化 Wasm 对象的 Web API 在模块实例化效率上的不同。基于“流式编译”的特性,后者往往通常会有着更高的模块实例化性能。
最后,你应该知道了如何通过 TypedArray 向 Wasm 模块实例的线性内存段中填充数据,以及如何从中读取数据。在本文这个实例中,我们分别使用了 set 与 subarray 这两个 TypedArray 对象上的方法来分别达到这两个目的。
通过本次实践,我们在最后的性能对比中,也清楚地看到了 Wasm 所带来的 Web 应用的性能提升。希望你也能够通过这次实践,亲身体会到 Wasm 在不久的将来,所能够带给 Web 应用的一系列变革。
## **课后练习**
最后,我们来做一个小练习吧。
你可以尝试在其他的,诸如 Edge 和 Safari 浏览器中运行这个 Wasm Web 应用, 并查看在这些浏览器中,当分别使用 JavaScript 版滤镜函数和 Wasm 滤镜函数时的画面实时处理帧率。
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,147 @@
<audio id="audio" title="18 | 如何进行 Wasm 应用的调试与分析?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ce/34/ce124b750f090c1ab790e8e1617f0f34.mp3"></audio>
你好,我是于航。
所有正在应用 Wasm 这门技术的开发者,都会被频繁问到这样一个问题 —— “如何能够以最正确的方式来对一个 Wasm 模块(应用)进行调试?”
实际上,针对 Wasm 模块的调试方案与相应的工具链,暂时还没有统一的“事实标准”。而又由于 Wasm 本身可以被应用在诸如 Web 与 out-of-web 等多种场景中,这便使得对 Wasm 模块或应用的调试过程,变得十分复杂。
在本节课里我将为你总结现阶段所能够使用的一些,针对于独立 Wasm 模块或者说 Wasm 应用的调试方案。这些方案本身并没有优劣之分,你可以根据自己的实际情况和应用场景来挑选合适的 Wasm 调试方式。
这些方案将会基于不同的工具实现来展开,而关于工具本身的安装过程,你可以参考我在这节课里给出的相关链接。
总的来说,我们可以将这些方案划分为 Web 与 out-of-web 两种场景。前者对应于运行在 Web 浏览器中的 Wasm 应用,这些应用仅使用到了 Wasm 核心标准中的特性;而后者则对应于运行在如 Wasmtime 等 Wasm 运行时中的 Wasm 应用,这部分应用还将会使用到除 Wasm 核心标准之外的 WASI 抽象操作系统接口标准。
## 编译时调试
作为开发 Wasm 应用的一个必不可少的流程,“编译”是一个无论如何也要跨过去的“槛”。但总是有着各种各样的原因,导致你的应用在编译阶段会出现问题。所以我们先来看看如何调试应用在编译期发生的错误。
### Emscripten
Emscripten 作为构建可运行于 Web 浏览器上的 Wasm 应用的首选编译工具之一,它为我们提供了众多的调试选项,可以在编译过程中输出详细的调试信息以供排错之用。
#### EMCC_DEBUG
以我们上节课从零构建的 Wasm DIP 应用为例,在实际使用 emccEmscripten 提供的编译器)编译该项目时,我们可以通过为编译命令添加 “EMCC_DEBUG” 环境变量的方式,来让 emcc 以“调试模式”的方式来编译我们的项目,修改后的编译命令如下所示。
注:此命令行形式仅适用于 Linux / MacOS 系统,对于 Windows 则会有所区别。你可以参考 [Emscripten 官方文档](https://emscripten.org/docs/index.html)来查看相关细节。
```
EMCC_DEBUG=1 emcc dip.cc
-s WASM=1
-O3
--no-entry
-o dip.wasm
```
这里命令行中设置的环境变量 “EMCC_DEBUG” 支持三个值0、1 与 2。其中 “0” 表示关闭调试模式也就是默认不加该环境变量时的情况“1” 表示输出编译时的调试性信息,同时生成包含有编译器各个阶段运行信息的中间文件。这些输出信息和文件可用于对整个 emcc 编译流程的各个步骤进行调试。以下为 emcc 的编译输出信息及所生成中间文件的截图。
<img src="https://static001.geekbang.org/resource/image/c6/c1/c69785b478c75e4e315b08d920e572c1.png" alt="" title="编译时输出的调试性信息">
在编译时输出的调试性信息中,包含有 emcc 在实际编译源代码时其各个编译阶段所实际调用的命令行信息(命令+参数)。比如在编译阶段调用的 clang++、链接阶段调用的 wasm-ld甚至在优化阶段还会调用的 node 等等。
通过这些输出的详细命令行参数,你就能够知道 emcc 在对源代码的实际编译过程中,使用了哪些编译器参数,以及哪些缺少或错误添加的参数会影响源代码的编译流程。通过这种方式,能够在一定程度上辅助你找到项目编译失败的“根源”。
<img src="https://static001.geekbang.org/resource/image/f4/db/f4960bc276376619d24dd67d0dfa95db.png" alt="" title="编译时输出的中间调试文件">
而当为 “EMCC_DEBUG” 设置的值为 “2” 时emcc 会生成更多的包含有中间调试性信息的文件,在这些文件中将包含有与 JavaScript 优化器相关的编译时信息。
#### -s [DEBUGGER_FLAG=VALUE]
除了我们上述介绍的 “EMCC_DEBUG” 之外emcc 还有很多针对特定场景的编译器调试选项可以使用。而这些选项都需要以 “emcc -s [DEBUGGER_FLAG=VALUE]” 的方式,来将其应用到编译命令行中。
比如 “ASSERTIONS” 选项。该选项可用于启用 emcc 对常见内存分配错误的运行时断言检查。其值可以被设置为 “0”“1” 或 “2”。其中“0” 表示禁用该选项,另外的 “1” 和 “2” 随着数字的逐渐增大,表示所启用相关测试集的增多。
类似的,还有其他如 “SAFE_HEAP” 等编译器调试选项可以使用。而关于这些可以在 emcc 中使用的调试器选项信息,你可以参考[这里](https://github.com/emscripten-core/emscripten/blob/master/src/settings.js)进行了解。
## 运行时调试
相较于“编译时调试”,“运行时调试”意味着我们已经成功地编译了 Wasm 应用,但是却在实际运行时发生了错误。那本节我们来看看,如何调试那些在运行时发生异常的 Wasm 应用。
### Emscripten
为了能够调试运行在 Web 浏览器中的 Wasm 应用,我们需要在通过 Emscripten 编译应用时,为编译命令指定特殊的“调试参数”,以保留这些与调试相关的信息。而这个参数就是 “-g”。
“-g” 参数控制了 emcc 的编译调试等级,每一个调试等级都会保留更多的相应调试性信息。整个等级体系被分为 0-4 五个级别。在其中 “-g4” 级别会保留最多的调试性信息。
不仅如此,在 “-g4” 这个级别下emcc 还会为我们生成可用于在 Web 浏览器中进行“源码级”调试的特殊 DWARF 信息。通过这些特殊格式的信息,我们便可以直接在 Web 浏览器中对 Wasm 模块编译之前的源代码进行诸如“设置断点”、“单步跟踪”等调试手段。如下所示,假设此时我们使用该参数重新编译上一节课中的 DIP Web 应用。
```
emcc dip.cc
-g4
-s WASM=1
-O3
--no-entry
-o dip.wasm
```
可以看到,这里在命令行中,我们使用了参数 “-g4”。编译完成后我们便可以使用浏览器来加载这个 Web 应用。在此同时并打开“开发者面板”,来尝试直接通过“操作” C/C++ 源代码的方式,来为应用所使用的 Wasm 模块设置断点。具体你可以参考下面这张图(这里我们使用的是 Chrome 浏览器)。
<img src="https://static001.geekbang.org/resource/image/2d/45/2dd21169d170a870582c405990473745.png" alt="">
通过这种方式,我们可以方便地在 Wasm Web 应用的实际运行过程中,来调试那些发生在 Wasm 模块内部C/C++)的“源码级”错误。
但目前这项调试功能还不是十分完善。我们仅能够在 Web 浏览器中为 C/C++ 等源代码设置断点、进行单步跟踪调试,或者是查看当前的调用栈信息。而比如“查看源代码中的变量值和类型信息”、“跟踪观察变量或表达式的值变化”等更加实用的功能,暂时还无法使用。
对于使用 Rust 语言编写的 Wasm 模块来说,我们可以通过类似地为 rustc 添加 “-g” 参数的方式,来让编译器将 DWARF 调试信息加入到生成的 Wasm 模块中。而对于直接使用 cargo 编译的 Wasm 项目来说,调试信息将会自动被默认加入到生成的模块中。
### Wasmtime
对于 out-of-web 领域中的 Wasm 应用,根据第 [07](https://time.geekbang.org/column/article/287138) 讲中的介绍,我们知道借助于 WASI这些应用可以在保证良好可移植性的情况下进一步与各类操作系统资源打交道。而为了能够在 Web 浏览器之外的环境中执行 Wasm 模块中的字节码,则需要诸如 Wasmtime、Lucet 等各类 Wasm 运行时的支持。
对比于在 Native 环境中直接编译而成的可执行文件来说,这些基于 WASI 构建的 Wasm 模块可以具有与这些原生可执行程序同等的能力,前提是只要 WASI 标准中支持相应的操作系统调用抽象接口即可。
能力虽然可以相同,但两者的运行时环境却完全不同。对于原生可执行程序来说,它们的实际执行过程会交由操作系统来统一负责。而对于 Wasm 模块来说,无论是运行在 Web 平台之上,还是应用于 out-of-web 领域中的 Wasm 字节码,它们都需要通过 Wasm 运行时(引擎)来提供字节码的实际执行能力。这也就造成了两者在调试过程和方法上的区别。
为了能够尽量使两者的调试方式保持一致Wasmtime一个 Wasm 运行时)便为我们提供了这样的一种能力,让我们可以使用诸如 LLDB 与 GDB 等专用于原生可执行程序的调试工具,来直接调试 Wasm 的二进制模块文件。不过需要注意的是,为了能够确保这个特性具有最大的可用性,我们需要使用最新版的 LLDB、GDB 以及 Wasmtime。
在此基础之上,我们便可以仅通过如下这行命令,来在 LLDB 中调试我们的 Wasm 字节码(假设这里我们要调试的 Wasm 模块文件名为 “app.wasm”
```
lldb -- wasmtime -g app.wasm
```
当然,现实的情况是,如果想要使用这种针对 Wasm 字节码的 out-of-web 调试方式,你需要重新编译整个 LLDB 或 GDB 调试工具链,并确保本机的 Wasmtime 已经被更新到最近的版本。其中,前者要花费不少的精力,而后者还没有发布正式的版本。因此这种调试方式所能够支持的调试功能仍有着一定的限制。更多的信息你可以参考这里的[链接](https://hacks.mozilla.org/2019/09/debugging-webassembly-outside-of-the-browser/)。
<img src="https://static001.geekbang.org/resource/image/b8/6c/b8f8bea5f284d8de518151e26a19f66c.png" alt="">
## 其他调试工具
对于其他的 Wasm 相关调试工具,这里主要推荐你使用 “[WABT](https://github.com/WebAssembly/wabt)” 。关于这个工具集,我在文章 [06](https://time.geekbang.org/column/article/286276) 中曾提到过。WABT 内置了众多可以直接对 Wasm 字节码或者 WAT 可读文本代码进行转换和分析的工具。比如用于将 WAT 格式转换为 Wasm 字节码的工具 “wat2wasm”、用于将 WAT 转换为 Flat-WAT 的工具 “wat-desugar” 等等。
除此之外,还有一些可以针对 Wasm 字节码进行“反编译”的工具,比如 “wasm-decompile” 工具可以将 Wasm 字节码反向转换为“类 C 语法格式” 的可读代码。其可读性相较于 WAT 来说可以说是又更近了一步。
<img src="https://static001.geekbang.org/resource/image/21/22/21c21fb351247ebf71edd12d347bdc22.png" alt="">
## 总结
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
在今天这节课中,我们主要讲解了有关 Wasm 应用调试的一些现阶段可用的方案。Wasm 应用的构建和使用主要被分为“编译”与“运行”两个阶段,而相应的调试方案便也需要针对这两个阶段分别进行处理。
对于“编译”阶段,我们主要介绍了在通过 Emscripten 构建 Wasm 应用时可以在编译命令行中使用的一些调试参数。其中“EMCC_DEBUG” 参数可以让 emcc 在编译过程中输出更多的信息以用于支持应用的编译时调试。相应地,通过 “-s” 参数,我们也可以为 emcc 指定一些针对某些特定场景的调试参数,这些参数可以让 emcc 在编译过程中检查某些特定问题。
对于“运行”阶段,我们首先介绍了如何通过为 Emscripten 的编译命令添加 “-g” 参数,来让我们可以直接在 Web 浏览器中针对 Wasm 模块的编译前源代码进行调试。但就目前而言,我们能够在 Web 浏览器中获得的调试性信息,还不足以让我们可以直接高效地在浏览器中解决相应的运行时问题。
最后,我们还介绍了如何在 out-of-web 环境中调试 Wasm 字节码。这里我们依赖于 Wasmtime 所提供的支持,使得我们可以直接在诸如 LLDB、GDB 等传统调试器中调试 Wasm 字节码对应的编译前源代码。但一个重要的前提是,你需要事先安装这些调试器与 Wasmtime 的最新版本,这在某种程度上来说,可能对你也是一种负担。
总的来说,就现阶段的 Wasm 应用调试而言,无论是在 Web 平台上,还是 out-of-web 环境中,都没有一个成熟、稳定、可靠的“一站式”调试解决方案。但好在 Wasm CG 旗下的 “[WebAssembly Debugging Subgroup](https://github.com/WebAssembly/debugging)” 正在努力解决这个问题。相信在不久的将来,针对 Wasm 应用的调试不会再成为一个令开发者“望而生畏”的难题。
## 更新2020-12-11
在今年的 Chrome 2020 开发者峰会上Chrome 团队推出了一款新的 Chrome 扩展,可以帮助我们增强浏览器上的 Wasm 应用调试体验。相较于之前在浏览器中直接调试 Wasm 模块对应 C/C++ 源代码的体验,在这款扩展的帮助下,我们还可以做到诸如:查看原始 C/C++ 源代码中变量的值、对 C/C++ 源代码进行单步跟踪,甚至直接跟踪观察某个变量值的变化等等。下图所示为在借助这款插件后,对同一个项目的调试体验差异。
<img src="https://static001.geekbang.org/resource/image/33/df/33b556af454434a45c761cb5eed4fedf.png" alt="">
关于这款插件的具体使用方式和更多信息,你可以点击“[这里](https://developers.google.com/web/updates/2020/12/webassembly)”进行查看。
## **课后思考**
最后,我们来做一个思考题吧。
现阶段针对 Wasm 的调试过程虽然没有成熟的“一站式”解决方案,但各种小的调试技巧和方法却层出不穷。那么你在日常的开发工作中是怎样调试这些 Wasm 应用的呢?
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,159 @@
<audio id="audio" title="19 | 如何应用 WASI 及其相关生态?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/34/8143f3835a35c3dd8eaebc5e99c9d234.mp3"></audio>
你好,我是于航。
作为“实践篇”的最后一课,今天我们来一起看看“如何从零构建一个 WASI 应用?”。在实践篇的前三节课里,我花了大量的篇幅来介绍如何基于 Emscripten ,去构建一个可以运行在 Web 浏览器中的 Wasm 应用。而基于 WASI 构建的 Wasm 应用将会被运行在浏览器之外的 Native 环境中,因此其构建和使用方式与基于 Emscripten 的 Wasm 应用有所不同。
但也正如我们在第 [07](https://time.geekbang.org/column/article/287138) 讲中介绍的那样WASI 本身作为一种抽象的操作系统调用接口,对上层的应用开发者来说,没有较为直接的影响。
甚至对于同样的一段可以被编译为本地可执行应用程序的代码来说,我们只需要适当调整编译器的相关设置,就可以在不做任何代码更改的情况下,编译出所对应的 WASI 版本代码(也就是 Wasm 字节码)。然后再配合相应的 Wasm 虚拟机,我们就能够以“另一种方式”来执行这些代码了。
总的来说你可以看到相较于传统的可执行文件WASI 应用程序的整个“生命周期”基本上只有“编译”与“运行”两个阶段会有所不同。在接下来的内容中,我们将以一段 C/C++ 代码入手,来从编码、编译,再到运行,一步步带你完成这个 WASI 应用。
## 编码
首先,我们先来编写应用对应的 C/C++ 代码,这部分内容如下所示。
```
// wasi-app.c
#include &lt;stdio.h&gt;
#define BUF_SIZE 1024
int main(int argc, char **argv) {
size_t counter = 0;
char buf[BUF_SIZE];
int c;
while ((c = getchar()) != '\n') {
buf[counter++] = c;
}
if (counter &gt; 0) {
printf(&quot;The input content is: %s\n&quot;, buf);
// write content to local file.
FILE* fd;
if ((fd = fopen(&quot;wasi-static.txt&quot;, &quot;w&quot;))) {
fwrite(buf, sizeof(char), counter, fd);
} else {
perror(&quot;Open static file failed!&quot;);
}
}
return 0;
}
```
这段代码所对应的功能是这样的:程序在实际运行时,会首先要求用户输入一些随机的文本字符,而当用户输入“回车键(\n”后之前输入的所有内容都将会被回显到命令行中。
除此之外,这些输入的文本字符也会被同时保存到当前目录下名为 “wasi-static.txt” 的文本文件中。而无论在程序运行时该文本文件是否存在,应用都将会重新创建或清空该文件,并写入新的内容。
这里为了完成上面的功能,在代码中我们使用了诸如 “fopen” 与 “fwrite” 等用于操作系统文件资源的 C 标准库函数。这些函数在被编译至 Native 可执行文件后,会通过间接调用“操作系统调用”的方式,来实现与文件等系统资源的实际交互过程。
## Native 可执行程序
接下来,我们尝试将上述这段代码编译为本地可执行文件,并尝试运行这个程序以观察应用的实际运行效果。对应的编译和运行命令如下所示:
```
clang wasi-app.c -o wasi-app &amp;&amp; ./wasi-app
```
在上述命令执行完毕后,我们可以按照下图所示的方式来与这个应用程序进行交互。
<img src="https://static001.geekbang.org/resource/image/d3/84/d374c03c13063d7d0d15baa3c246aa84.gif" alt="">
可以看到,应用的实际运行效果与我们之前所描述的保持一致。
接下来,我们将尝试把上面这段 C/C++ 代码编译成对应的 Wasm 字节码,并使用 Wasmtime 来执行它。而为了完成这个工作,我们首先需要了解整个编译链路的基本情况。
## 交叉编译
我们曾在第 13 讲中介绍过LLVM 工具链已经具备可以将 LLVM-IR 编译为 Wasm 字节码的编译器后端能力。因此,基于 LLVM 构建的编译器 Clang便也可以同样享受这个能力。
那么按照这样的思路,我们是否可以直接使用 Clang 来编译 C/C++ 代码到 Wasm 字节码呢?事实上确实是可以的。而这里我们需要做的就是借助 Clang 来进行针对 WASI 的“交叉编译”。
那为什么说是“交叉编译Cross-Compilation”呢你可以按照这样的方式来简单理解其实我们说的无论是 Wasm32 还是 Wasm64它们都是指一种“指令集架构”也就是 “(V)ISA”。而 ISA 本身只是规定了与指令相关的一些信息,比如:有哪些指令?指令的用法和作用?以及这些指令对应的 OpCode 编码是什么?等等。
但回到 “WASI”。它是一种基于 Wasm 指令集的“平台类型”。所谓“平台”,你可以用诸如 Linux、Windows 等各种不同的操作系统类型来进行类比。WASI 指定了一种自己独有的操作系统接口使用方式,那就如同 Linux 与 Windows 都有其各自不同的操作系统调用号一样。这将会影响着我们的 C/C++ 代码应该如何与对应平台的不同操作系统调用进行编译整合。
当然,这种类比方式并不严谨,但对于帮助我们理解上面的问题是完全足够的。
## 基于 Clang 的编译管道
既然我们想要借助 Clang 来进行针对 WASI 的交叉编译,那么在开始真正动手编译之前,我们需要准备哪些其他必要的组件呢?通常在 Clang 中,一个大致的交叉编译流程如下图所示。
<img src="https://static001.geekbang.org/resource/image/ef/af/efeddaaf9a32a4d8234ce73byy2425af.png" alt="">
可以看到,其实同正常的编译流程十分类似,输入到编译器的 C/C++ 源代码会通过适用于对应目标平台的头文件,来引用诸如 “C 标准库” 中的函数。
而在编译链路中,应用本身对应的对象文件将会与标准库对应的动态或静态库文件再进行链接,以提取所引用函数的实际定义部分。最后,再根据所指定的平台类型,将编译输出的内容转换为对应的平台代码格式。
在上面的流程中,输入到编译链路的源代码,以及针对 WASI 平台适用的标准库头文件、静态库以及动态库则将需要由我们自行提供。在 Clang 中,我们将通过 “sysroot” 参数来指定这些标准库相关文件的所在位置;参数 “target” 则负责指定交叉编译的目标平台。
接下来,我们将通过 WASI SDK 的帮助来简化上述流程。
## WASI SDK
顾名思义“WASI SDK” 是一套能够帮助我们简化 WASI 交叉编译的“开发工具集”。但与其说它是开发工具集,不如说它为我们整合了用于支持 WASI 交叉编译的一切文件和工具资源,其中包括:基于 “wasi-libc” 编译构建的适用于 WASI 平台的 C 标准库、可用于支持 WASI 交叉编译的最新版 Clang 编译器,以及其他的相关必要配置信息等等。
它的安装过程十分简单,只需要将其下载到本地,然后解压缩即可,详情你可以参考[这里](https://github.com/WebAssembly/wasi-sdk)。假设此时我们已经将 WASI SDK 下载到当前目录并得到了解压缩后的文件夹wasi-sdk-11.0)。
下面我们将正式开始进入编译流程。首先我们来看看对应的交叉编译命令是怎样的。
```
./wasi-sdk-11.0/bin/clang \
--target=wasm32-wasi \
--sysroot=./wasi-sdk-11.0/share/wasi-sysroot \
wasi-app.c -o wasi-app.wasm
```
你可以参考上面这行命令。同我们之前所介绍的一样,这里我们直接使用了由 WASI SDK 提供的 Clang 编译器来进行这次交叉编译。然后我们使用了 “sysroot” 参数来指定适用于 WASI 的标准库相关文件其所在目录。这里可以看到,我们通过参数 “target” 所指定的平台类型 “wasm32-wasi” 便是 LLVM 所支持的、针对于 WASI 的平台编译类型。
编译完成后,我们便可以得到一个 Wasm 文件 “wasi-app.wasm”。最后我们将使用 Wasmtime 来运行这个 Wasm 模块。如果一切顺利,我们可以看到同 Native 可执行程序一样的运行结果。(关于 Wasmtime 的安装过程可以参考[这里](https://github.com/bytecodealliance/wasmtime)
## Wasmtime
按照正常的思路,我们可能会通过下面的方式来尝试运行这个 Wasm 文件。
```
wasmtime wasi-app.wasm
```
而当命令实际执行时,你会发现 Wasmtime 却给出了我们这样的一条错误提示“Capabilities insufficient”这便是 “Capability-based Security” 在 WASI 身上的体现。
Wasmtime 在实际执行 “wasi-app.wasm” 文件中的字节码时,发现这个 WASI 应用使用到了文件操作相关的操作系统接口,而对于一个普通的 WASI 应用来说,这些接口在正常情况下是无法被直接使用的。换句话说,默认情况下的 WASI 应用是不具备“文件操作”相关的 Capability 的。这些 Capability 需要我们在实际运行应用时主动“授予”给应用,方式如下所示。
```
wasmtime wasi-app.wasm --dir=.
```
这里我们在通过 Wasmtime 运行 WASI 应用时,为其指定了一个额外的 “dir=.” 参数。通过该参数Wasmtime 可以将其所指定的文件路径(.)“映射”到 WASI 应用中,以供其使用。
这样,我们便可以使用 Wasmtime 来成功运行这个 WASI 应用了,如下图所示。
<img src="https://static001.geekbang.org/resource/image/65/4e/659cda4297463244fc136860b735eb4e.gif" alt="">
当然,对于其他的支持 WASI 的 Wasm 运行时来说,它们也会以类似的方式来实现 Capability-based Security 这一 WASI 最为重要的安全模型。而这一模型也是 WASI+Wasm 能够在一定程度上“取代” Docker 进行应用沙盒化的基础。
## 总结
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
在本节课中,我们主要讲解了如何从零构建一个 WASI 应用。
我们要知道的是,构建 WASI 应用的过程其实是一个“交叉编译”的过程。我们需要在各自的宿主机器Linux、MacOS、Windows 等等)上构建“以 Wasm 字节码为 ISA 架构WASI 作为平台类型”的这样一种应用。而应用的实际运行将交由支持 WASI 的 Wasm 运行时来负责。
目前,我们可以直接借助 “WASI SDK” 来简化整个交叉编译的实施难度。WASI SDK 为我们整合了在编译 WASI 应用时需要使用的所有工具与组件。其中包含有可以支持 “wasm32-wasi” 这一平台类型的 Clang 编译器、WASI 适用的相关标准库头文件与库文件等等。
在执行 WASI 应用时,我们也需要注意 WASI 本身所基于的 “Capability-based Security” 这一安全模型。这意味着,在实际执行 WASI 应用时,我们需要主动“告知”运行时引擎当前 WASI 应用所需要使用到的 Capability。否则即使当前用户拥有对某一个系统资源的访问权限但也无法通过 Wasm 运行时来隐式地让所执行的 WASI 应用访问这些资源。
而这便是 Capability-based Security 模型与 Protection Ring 模型两者之间的区别。
## **课后思考**
最后,我们来做一个思考题吧。
你可以尝试使用另外名为 “Lucet” 的 Wasm 运行时来运行上述这个 WASI 应用,来看看 Lucet 会以怎样的“交互方式”来支持 Capability-based Security 这一安全模型呢?
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,73 @@
<audio id="audio" title="20 | 总结与答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/85/0b400523ab1391488472cbyy05dbe385.mp3"></audio>
你好,我是于航。
在这节课里,我将和你总结一下自开课这段时间以来,各位同学在各个渠道提出的有关 Wasm 的一些问题。限于篇幅,这里我优先选择了 8 个相对比较有代表性的问题,来给你进行解答。对于其中一些已经回复过的问题,我也会给你做进一步的解析。如果你有其他的问题,也欢迎在评论区和我进一步交流。
### 问题1Wasm 就像 Node.js 源于 Web 但又不止于 Web 吗?
两者并不一样。对于 Node.js 本身来说,它只是一个可以在 Web 浏览器之外运行 JavaScript 代码的 Runtime 运行时,同时它还提供了一些特殊的 API 可以让我们使用 JavaScript 代码来与操作系统相关的资源打交道,比如文件资源、网络资源等等。因此,我们说 Node.js 是一种实现。
而反观 Wasm正如我们在第 [03](https://time.geekbang.org/column/article/283436) 讲中介绍的那样,它是一种新的 V-ISA 标准,而非实现。如果实在想要去类比的话,你可以将 Wasm 类比为 JavaScript 的所在位置(编程语言),但实际上 Wasm 更加底层,类比到 Java 字节码可能会更加恰当。
因此总结来看Node.js 为在 Web 浏览器之外执行 JavaScript 代码提供了可能,而 Wasmtime 等虚拟机为在 Web 浏览器之外执行 Wasm 字节码提供了可能。但 Wasm 本身一般被作为高级语言的编译目标,其标准更加贴近底层(汇编),与 JavaScript高级语言并不在一个层面上。
### 问题2Wasm 能够与系统底层进行通信吗?
Wasm 是标准而非实现。同上一个问题类似Wasm 本身只是一个新的 V-ISA 标准,而非实现。因此,能否与底层系统进行通信完全取决于用来执行它的 Runtime 运行时环境。
比如在 Web 浏览器中,我们便无法通过 Wasm 来访问操作系统的底层资源。而在通过诸如 Wasmtime、Lucet 等运行时环境,来在 Web 浏览器之外执行 Wasm 字节码时,便可以在 WASI 标准的基础之上来访问操作系统的相关资源了,这正如我们在第 [19](https://time.geekbang.org/column/article/283436) 讲中介绍的实例那样。
而至于访问的到底是不是“系统底层资源”,就要看你如何定义“底层”这个词了。但无论如何,只要 WASI 抽象操作系统接口标准能够覆盖所有操作系统实际提供的接口,那么,我们实际上就拥有了完全的操作系统控制能力。我想这个时候是不是底层资源,就已经不那么 重要了。
### 问题3TypeScript 可以设置参数类型,但是最后 TypeScript 代码也会被编译成 JavaScript所以 TypeScript 是不是只是规范程序员写代码,对于应用的性能其实没有什么帮助?
从流程上来看,由于 TypeScript 代码最终会被编译为 JavaScript 代码因此事实上对应用整体性能的影响可以说是微乎其微TypeScript 编译器在编译过程可能会进行一些优化)。因此,大部分使用 TypeScript 的场景,在我看来主要还是为了利用其“静态类型检查”的特性,来保障应用在多人团队协作时,其各个组成部分的接口使用能够准确无误,以防止意外的 BUG 产生。
但从另外一个角度来看,既然 TypeScript 中有着变量“类型”的概念,那是不是也可以将它的代码转换为 Wasm 字节码呢?实际上,一个名为 AssemblyScript 的项目便正在尝试这样的事情。通过这个项目,你可以使用 TypeScript 的语法来编写代码,然后再将这些代码转换为 Wasm 字节码。当然,受限于 TypeScript 本身的语言特性,为了能够支持 Wasm 中如“内存操作”等相关的指令AssemblyScript 还进行了一些其他的扩展,详情你可以点击[这里](https://github.com/AssemblyScript/assemblyscript)进行了解。
<img src="https://static001.geekbang.org/resource/image/b1/af/b1ce36dfce069ec256bec23ba673aaaf.png" alt="">
### 问题4如果 ES6 等后续 JavaScript 版本解决了浏览器兼容性问题,不再需要“编译”回老版本代码,从而获得一定的性能保障。这是否会成为 Wasm 发展的重大阻碍呢?
有很多企业都在尝试直接在浏览器中使用 ES6 代码,以提升应用的性能。比如 Twitter 曾在今年八月初宣布其 Web App 将在所有现代浏览器中直接使用 ES6+ 的代码。而这一举动使得其 Web 应用的代码库体积,从原先的 16.6KB 大小下降到了 2.7KB,整整减小 83%。但除开 Twitter 外的其他企业大多都还比较保守,仍然处在观望阶段。
但无论如何,直接使用 ES6+ 代码所带来的应用性能提升是显著的。比如更小的网络资源开销,更少的需要执行的代码等等。但如果我们换一个角度来看,对浏览器引擎来说,只要执行的是 JavaScript 代码,那就一定少不了生成 AST、Profiling、生成 IR 代码、优化以及去优化等过程。而这些过程才是相较于 Wasm 来说,真正花时间的地方。
因此,如果我们不考虑“直接使用 ES6+ 代码”这一方案的可实施性,光从现代 JavaScript 语言和 Wasm 两者之间来看JavaScript 作为一种高级动态语言,其执行性能还是无法跟 Wasm 这类 V-ISA 直接相比的,这个比较过程就如同拿 JavaScript 来与 X86 汇编进行比较。
当然,你也需要注意的是,性能只是 Wasm 众多发展目标中的一个,并且相对好的性能也是由于其 V-ISA 的本质决定的。除此之外Wasm 希望能够通过提供一种新的、通用的虚拟字节码格式,来统一各个语言之间的差异,并且借助于 Capability-based Security 安全模型来为现代应用提供更好的安全保护策略。可以说 Wasm 是起源于 Web但志不仅仅在 Web。
### 问题5感觉 Flat-WAT比 WAT 看着好懂Wasm 为啥不直接使用 Flat-WAT
这个主要是由于 Wasm 核心团队初期在设计 Wasm 可读文本格式时对“S-表达式”这种代码表达方式的选择。而为什么会选择“S-表达式”则是出于对以下这样几个因素的考虑:
1. 尽量不自行创建新的格式,而是直接利用现有的、常用的、成熟的格式规范;
1. 这种格式可以表达 Wasm 模块的内部结构,并且可以与字节码一一对应;
1. 这种格式可以“转换”为方便人们阅读的形式。
因此出于对这样几个因素的考虑核心团队便选择了“S-表达式”来作为 Wasm 可读文本 WAT 的一种表达方式。而对于编译器和相关工具来说这种“S-表达式”可以被现有的很多代码实现直接解析和使用,不需要重新造轮子,进而减轻了 Wasm 早期发展时的难度和负担。
而同时“S-表达式”也可以被转换为相应的 “Linear Representation” 的形式,也就是 “Flat-WAT” 这种格式。所以这里的因果关系是先有“S-表达式”形式的 WAT才有其对应的 Flat-WAT。
### 问题6什么时候用 Clang(LLVM) 编译 Wasm而什么时候又该用 Emscripten 编译 Wasm
这个区分其实很简单,需要在 Web 浏览器中运行的 Wasm 应用,一定要使用 Emscripten 来进行编译;而需要在 out-of-web 环境中使用的 Wasm(WASI) 应用,可以使用 Clang 来编译。
当然Clang 与 Emscripten 两者在可编译和生成 Wasm 字节码这个能力上,有着一定的重叠。毕竟 Emscripten 就是借助了 LLVM 的后端来生成 Wasm 格式的。但不同的是Emscripten 会在编译过程中,为所编译代码在 Web 平台上的功能适配性进行一定的调整。比如 OpenGL 到 WebGL 的适配、文件操作到虚拟文件操作的适配等等。
而使用 Clang 编译 Wasm 应用,不会进行上述这些针对 Web 平台的适配。因此仅在编译 WASI 应用时选择使用 Clang 来进行交叉编译。大多数时候,你的最佳选择仍然是 Emscripten。
### 问题7对于使用 Webpack 的 Web 前端项目,如何优雅地引入第三方的 Wasm 组件?
就目前来看,大多数的第三方 Wasm 库都是以 JavaScript 函数来作为库的实际使用入口的,而并没有直接暴露出一个或多个 Wasm 模块文件给到用户。因为一个 Wasm 模块在实例化时,可能还需要使用到很多不同的外部“导入性”信息(通过 Import Section。而这些信息则属于这个库本身组成的一部分这部分内容不应该全部由用户来提供。
因此,在实际使用时可以直接通过 “import” 的方式来导入对应的库即可。唯一要注意的是,对于 Webpack 可能需要设置相应的 Wasm Loader具体可以参考实际项目的使用说明。
### 问题8我想知道我伟大的大不列颠太阳永不落 PHP 同志是否可以被编译成 Wasm
答案当然是可以的。不过由于 PHP 是一种动态类型的语言,因此我们只能把 PHP 的运行时编译成 Wasm然后将其运行在其他的宿主环境中。这里可以参考一个名为 “pib” 的项目, 链接在[这里](https://github.com/oraoto/pib)。
除此之外,目前 Wasm 已经支持多达几十种编程语言,它们都会以不同的方式(本身被编译为 Wasm或其运行时被编译为 Wasm来与 Wasm 产生交集。我们先不说这些项目都是否有着其实际的应用价值,但无论如何,这都从侧面说明了人们对 Wasm 的未来期望。
好了,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。