¿Qué es WebAssembly y de dónde proviene?

Desde que la Web se convirtió en una plataforma no solo para documentos, sino también para apps, algunas de las apps más avanzadas han llevado los navegadores web a sus límites. El enfoque de "acercarse al metal" interactuando con lenguajes de nivel inferior para mejorar el rendimiento se encuentra en muchos lenguajes de nivel superior. Por ejemplo, Java tiene la interfaz nativa de Java. En el caso de JavaScript, este lenguaje de nivel inferior es WebAssembly. En este artículo, descubrirás qué es el lenguaje ensamblador y por qué puede ser útil en la Web. Luego, aprenderás cómo se creó WebAssembly a través de la solución provisional de asm.js.

Lenguaje ensamblador

¿Alguna vez programaste en lenguaje ensamblador? En la programación informática, el lenguaje ensamblador, a menudo denominado simplemente ensamblador y abreviado comúnmente como ASM o asm, es cualquier lenguaje de programación de bajo nivel con una correspondencia muy sólida entre las instrucciones del lenguaje y las instrucciones del código de máquina de la arquitectura.

Por ejemplo, si observamos las arquitecturas Intel® 64 y IA-32 (PDF), la instrucción MUL (para la multiplicación) realiza una multiplicación sin signo del primer operando (operando de destino) y el segundo operando (operando de origen), y almacena el resultado en el operando de destino. De forma muy simplificada, el operando de destino es un operando implícito ubicado en el registro AX, y el operando de origen se encuentra en un registro de uso general, como CX. El resultado se vuelve a almacenar en el registro AX. Considera el siguiente ejemplo de código x86:

mov ax, 5  ; Set the value of register AX to 5.
mov cx, 10 ; Set the value of register CX to 10.
mul cx     ; Multiply the value of register AX (5)
           ; and the value of register CX (10), and
           ; store the result in register AX.

A modo de comparación, si tuvieras la tarea de multiplicar 5 y 10, probablemente escribirías un código similar al siguiente en JavaScript:

const factor1 = 5;
const factor2 = 10;
const result = factor1 * factor2;

La ventaja de usar el lenguaje ensamblador es que este código de bajo nivel y optimizado para la máquina es mucho más eficiente que el código de alto nivel y optimizado para los humanos. En el caso anterior, no importa, pero puedes imaginar que, para operaciones más complejas, la diferencia puede ser significativa.

Como su nombre lo indica, el código x86 depende de la arquitectura x86. ¿Qué sucedería si hubiera una forma de escribir código de ensamblador que no dependiera de una arquitectura específica, pero que heredara los beneficios de rendimiento del ensamblador?

asm.js

El primer paso para escribir código de ensamblador sin dependencias de arquitectura fue asm.js, un subconjunto estricto de JavaScript que se podía usar como un lenguaje de destino eficiente y de bajo nivel para los compiladores. Este sublenguaje describía de manera eficaz una máquina virtual en zona de pruebas para lenguajes no seguros para la memoria, como C o C++. Una combinación de validación estática y dinámica permitió que los motores de JavaScript emplearan una estrategia de compilación de optimización anticipada (AOT) para el código asm.js válido. Un compilador de código fuente a código fuente, como el primer Emscripten (basado en LLVM), traducía el código escrito en lenguajes con escritura estática y administración manual de la memoria (como C).

Se mejoró el rendimiento limitando las funciones de lenguaje a aquellas que son compatibles con la compilación AOT. Firefox 22 fue el primer navegador en admitir asm.js, lanzado con el nombre OdinMonkey. Chrome agregó compatibilidad con asm.js en la versión 61. Si bien asm.js sigue funcionando en los navegadores, WebAssembly lo reemplazó. En este punto, el motivo para usar asm.js sería como alternativa para los navegadores que no admiten WebAssembly.

WebAssembly

WebAssembly es un lenguaje de bajo nivel similar al ensamblador con un formato binario compacto que se ejecuta con un rendimiento casi nativo y proporciona lenguajes como C/C++, Rust y muchos más con un objetivo de compilación para que se ejecuten en la Web. La compatibilidad con lenguajes administrados por memoria, como Java y Dart, está en desarrollo y debería estar disponible pronto, o ya se lanzó, como en el caso de Kotlin/Wasm. WebAssembly está diseñado para ejecutarse junto con JavaScript, lo que permite que ambos funcionen en conjunto.

Además del navegador, los programas de WebAssembly también se ejecutan en otros tiempos de ejecución gracias a WASI, la interfaz del sistema de WebAssembly, una interfaz del sistema modular para WebAssembly. WASI se creó para ser portátil en todos los sistemas operativos, con el objetivo de ser seguro y poder ejecutarse en un entorno de zona de pruebas.

El código de WebAssembly (código binario, es decir, código de bytes) está diseñado para ejecutarse en una máquina virtual (VM) portátil basada en pila. El código de bytes está diseñado para ser más rápido de analizar y ejecutar que JavaScript, y para tener una representación de código compacta.

La ejecución conceptual de las instrucciones se realiza a través de un contador de programa tradicional que avanza por las instrucciones. En la práctica, la mayoría de los motores de Wasm compilan el bytecode de Wasm en código de máquina y, luego, lo ejecutan. Las instrucciones se dividen en dos categorías:

  • Las instrucciones de control que forman construcciones de control y extraen sus valores de argumentos de la pila pueden cambiar el contador de programa y enviar valores de resultados a la pila.
  • Instrucciones simples que extraen los valores de los argumentos de la pila, aplican un operador a los valores y, luego, insertan los valores del resultado en la pila, seguidas de un avance implícito del contador de programa.

Volviendo al ejemplo anterior, el siguiente código de WebAssembly sería equivalente al código x86 del comienzo del artículo:

i32.const 5  ; Push the integer value 5 onto the stack.
i32.const 10 ; Push the integer value 10 onto the stack.
i32.mul      ; Pop the two most recent items on the stack,
             ; multiply them, and push the result onto the stack.

Si bien asm.js se implementa completamente en software, es decir, su código se puede ejecutar en cualquier motor de JavaScript (incluso si no está optimizado), WebAssembly requirió una nueva funcionalidad en la que todos los proveedores de navegadores estuvieron de acuerdo. Anunciado en 2015 y lanzado por primera vez en marzo de 2017, WebAssembly se convirtió en una recomendación del W3C el 5 de diciembre de 2019. El W3C mantiene el estándar con las contribuciones de todos los principales proveedores de navegadores y otras partes interesadas. Desde 2017, la compatibilidad con navegadores es universal.

WebAssembly tiene dos representaciones: textual y binaria. Lo que ves arriba es la representación textual.

Representación textual

La representación textual se basa en S-expressions y, por lo general, usa la extensión de archivo .wat (para el formato de texto de WebAssembly). Si quisieras, podrías escribirlo a mano. Si tomamos el ejemplo de multiplicación anterior y lo hacemos más útil al no codificar de forma rígida los factores, probablemente puedas comprender el siguiente código:

(module
  (func $mul (param $factor1 i32) (param $factor2 i32) (result i32)
    local.get $factor1
    local.get $factor2
    i32.mul)
  (export "mul" (func $mul))
)

Representación binaria

El formato binario que usa la extensión de archivo .wasm no está diseñado para el consumo humano, y mucho menos para la creación humana. Con una herramienta como wat2wasm, puedes convertir el código anterior en la siguiente representación binaria. (Por lo general, los comentarios no forman parte de la representación binaria, sino que la herramienta wat2wasm los agrega para facilitar la comprensión).

0000000: 0061 736d                             ; WASM_BINARY_MAGIC
0000004: 0100 0000                             ; WASM_BINARY_VERSION
; section "Type" (1)
0000008: 01                                    ; section code
0000009: 00                                    ; section size (guess)
000000a: 01                                    ; num types
; func type 0
000000b: 60                                    ; func
000000c: 02                                    ; num params
000000d: 7f                                    ; i32
000000e: 7f                                    ; i32
000000f: 01                                    ; num results
0000010: 7f                                    ; i32
0000009: 07                                    ; FIXUP section size
; section "Function" (3)
0000011: 03                                    ; section code
0000012: 00                                    ; section size (guess)
0000013: 01                                    ; num functions
0000014: 00                                    ; function 0 signature index
0000012: 02                                    ; FIXUP section size
; section "Export" (7)
0000015: 07                                    ; section code
0000016: 00                                    ; section size (guess)
0000017: 01                                    ; num exports
0000018: 03                                    ; string length
0000019: 6d75 6c                          mul  ; export name
000001c: 00                                    ; export kind
000001d: 00                                    ; export func index
0000016: 07                                    ; FIXUP section size
; section "Code" (10)
000001e: 0a                                    ; section code
000001f: 00                                    ; section size (guess)
0000020: 01                                    ; num functions
; function body 0
0000021: 00                                    ; func body size (guess)
0000022: 00                                    ; local decl count
0000023: 20                                    ; local.get
0000024: 00                                    ; local index
0000025: 20                                    ; local.get
0000026: 01                                    ; local index
0000027: 6c                                    ; i32.mul
0000028: 0b                                    ; end
0000021: 07                                    ; FIXUP func body size
000001f: 09                                    ; FIXUP section size
; section "name"
0000029: 00                                    ; section code
000002a: 00                                    ; section size (guess)
000002b: 04                                    ; string length
000002c: 6e61 6d65                       name  ; custom section name
0000030: 01                                    ; name subsection type
0000031: 00                                    ; subsection size (guess)
0000032: 01                                    ; num names
0000033: 00                                    ; elem index
0000034: 03                                    ; string length
0000035: 6d75 6c                          mul  ; elem name 0
0000031: 06                                    ; FIXUP subsection size
0000038: 02                                    ; local name type
0000039: 00                                    ; subsection size (guess)
000003a: 01                                    ; num functions
000003b: 00                                    ; function index
000003c: 02                                    ; num locals
000003d: 00                                    ; local index
000003e: 07                                    ; string length
000003f: 6661 6374 6f72 31            factor1  ; local name 0
0000046: 01                                    ; local index
0000047: 07                                    ; string length
0000048: 6661 6374 6f72 32            factor2  ; local name 1
0000039: 15                                    ; FIXUP subsection size
000002a: 24                                    ; FIXUP section size

Compilación en WebAssembly

Como puedes ver, ni .wat ni .wasm son particularmente fáciles de usar. Aquí es donde entra en juego un compilador como Emscripten. Te permite compilar desde lenguajes de nivel superior, como C y C++. Hay otros compiladores para otros lenguajes, como Rust y muchos más. Considera el siguiente código en C:

#include <stdio.h>

int main() {
  printf("Hello World\n");
  return 0;
}

Por lo general, compilarías este programa en C con el compilador gcc.

$ gcc hello.c -o hello

Con Emscripten instalado, lo compilas en WebAssembly con el comando emcc y casi los mismos argumentos:

$ emcc hello.c -o hello.html

Esto creará un archivo hello.wasm y el archivo de wrapper HTML hello.html. Cuando entregues el archivo hello.html desde un servidor web, verás "Hello World" impreso en la consola de Herramientas para desarrolladores.

También hay una forma de compilar a WebAssembly sin el wrapper HTML:

$ emcc hello.c -o hello.js

Al igual que antes, se creará un archivo hello.wasm, pero esta vez un archivo hello.js en lugar del wrapper HTML. Para probar, ejecuta el archivo JavaScript resultante hello.js con, por ejemplo, Node.js:

$ node hello.js
Hello World

Más información

Esta breve introducción a WebAssembly es solo la punta del iceberg. Obtén más información sobre WebAssembly en la documentación de WebAssembly en MDN y consulta la documentación de Emscripten. A decir verdad, trabajar con WebAssembly puede parecerse un poco al meme de cómo dibujar un búho, especialmente porque los desarrolladores web que conocen HTML, CSS y JavaScript no necesariamente dominan los lenguajes que se compilan, como C. Por suerte, existen canales como la etiqueta webassembly de StackOverflow, en los que los expertos suelen estar dispuestos a ayudar si se lo pides amablemente.

Agradecimientos

Jakob Kummerow, Derek Schuff y Rachel Andrew revisaron este artículo.