WebAssembly backend: 샌드박스로 컴파일하기

WebAssembly는 네 타깃 중 가장 제약이 강하다: 하나의 평평한 메모리 배열을 가진 스택 머신, malloc 없음, 함수 포인터 없음, 그리고 자기 혼자서는 바깥 세계에 닿을 방법이 없음. 그것이 결여한 것 저마다를, 컴파일러가 공급한다 —— 범프 할당자, 클로저를 위한 함수 테이블, I/O를 위한 host import. 그리고 그 마지막 제약은, 언어 자신의 능력 개념과 운을 맞추게 된다: 샌드박스에서는, 세계에 영향을 줄 권한은 건네받아야 한다.

merebackendswebassemblysandboxcapabilitieslanguage-design

세 backend를 마쳤고, 모두 같은 출력에 유지되었다. 넷째는, 모든 비교에 함께 있었지만, 그것 홀로 살펴본 적은 없고, 그리고 이 조에서 가장 기묘한 타깃이다: WebAssembly. C가 사람을 위한 언어이고 LLVM IR이 컴파일러를 위한 형식인 데 반해, Wasm은 샌드박스를 위한 명령어 집합이다 —— 신뢰할 수 없는 코드를 웹 페이지 안에서 안전하게 돌릴 수 있도록, 거의 모든 것을 일부러 벗겨낸 기계. 거기로 컴파일하는 것은, 이 Part를 관통하는 주제의 가장 명료한 예다: 타깃이 주지 않는 것은 무엇이든, 컴파일러가 만들어야 한다.

거의 모든 것을 결여한 기계

네 결여가 Wasm backend의 형태를 정하고, 그 저마다가, C와 LLVM 타깃이 조용히 제공하던 것이다.

식도 레지스터도 없다 —— Wasm은 스택 머신이다. 200을 push하고, 100을 push하고, 덧셈 명령이 둘을 pop하여 결과를 push한다. 코드는, 중첩된 식이나 SSA 레지스터가 아니라, 스택 조작의 열로 뱉어진다.

할당자가 없다. Wasm이 주는 것은 하나의 평평한 바이트 배열 —— 선형 메모리, 시작은 64KB의 한 페이지, 필요에 따라 성장한다 —— 그것뿐이다. 그래서 모든 구조화된 값은, 그 배열로의 offset에 살고, 글로벌 포인터를 범프하여 나눠진다. 튜플은 연속된 offset의 그 요소; 레코드는 선언 순서의 그 필드; variant는 태그와, 만약 가진다면, 그 직후의 페이로드다. region은 그저, 범프 포인터의 save와 restore다. Part II의 메모리 모델 통째가, 하나의 성장하는 바이트 배열 위에 다시 세워진다.

함수 포인터가 없다. Wasm은 함수의 주소를 취하게 하지 않는다. 대신, 함수는 함수 테이블에 살고, 그 안의 인덱스로 참조된다. 이것이 클로저의 정체를 바꾼다. C와 LLVM에서 클로저는 함수 포인터와 환경 포인터의 두 워드 쌍이었다; Wasm에서는 그것이, 선형 메모리로의 offset(환경을 위해)과 함수 테이블로의 인덱스(코드를 위해)의 쌍이 된다 —— 같은 아이디어지만, Wasm이 코드와 데이터를 별개의 세계에 두므로 “포인터”가 둘로 갈린다. 그것을 부르는 것은 테이블 너머의 call_indirect다. 순수 최상위 함수는, 테이블에 등록되는 작은 어댑터를 받아, 그것도 인덱스로 이름 붙여지고 값으로 넘겨질 수 있다.

그리고 I/O가 없다 —— 표시 없음, 파일 없음, 샌드박스 밖으로 닿는 것은 아무것도 없다. 그 마지막 결여가 가장 중요하고, 그 자신의 절을 얻는다.

Wasm이 쉽게 하는 한 가지

빼기만은 아니다. Wasm은, 다른 두 backend가 결코 갖지 않은 단순화를 되돌려준다: 모든 값이 단일한 4바이트 워드다. 스칼라는 그 워드를 직접; 구조화된 값은 그 워드를 메모리 offset으로 쓴다. 모든 것이 일률적으로 한 워드 폭이므로, backend는 타입마다의 레이아웃을 요하지 않는다 —— C와 LLVM이 해야 했던 단형화, 다형 타입의 각 구체 인스턴스에 별개의 구조체를 뱉는 것이, 그저 필요 없다. 하나의 Vec 구현이 어느 요소 타입의 벡터도 다룬다; 다형 variant는 특수화를 요하지 않는다. 가장 많이 빼앗는 타깃이, 이 한 점에서는, 가장 적게 요구한다. “더 제약이 강하다”가 단일한 축이 아니라는 유용한 각주다 —— 각 타깃은, 그 자신의 곳에서 어렵고, 다른 곳에서 쉽다.

샌드박스, 그리고 필요에 의한 능력

가장 중요한 결여는 I/O다. Wasm 모듈은, 그것 홀로는, 순수한 계산이다: 계산은 할 수 있지만, 줄을 표시할 수도, 파일을 읽을 수도, 네트워크 호출을 할 수도 없다. 그 어느 것의 명령도 갖지 않기 때문이다. 바깥 세계에 닿는 무엇이든, host import —— 둘레의 환경이 제공하는 함수 —— 여야 한다. Mere의 print는 import된 puts로의 호출로 컴파일되고, host —— 테스트에서는 Node.js, 다른 곳에서는 브라우저 —— 가 그 호출을 받아, 모듈의 선형 메모리에서 문자열을 읽어 내고, 실제 출력을 한다. 파일의 읽기 쓰기도, host가 제공하기로 고르면, 같은 방식으로 돈다.

이것은 멈춰 설 가치가 있다. 언어가 여러 Part 전에, 전혀 다른 이유로 스스로에게 정한 무언가와 운을 맞추기 때문이다. Part III는 능력 전달 위에 이펙트 시스템을 세웠다: 함수는, 값으로서 명시적으로 건네받은 권한을 통해서만 세계에 영향을 줄 수 있고, 손을 뻗을 주변 글로벌은 없다. WebAssembly는 바로 그 규율을, 기계의 레벨에서 강제한다 —— 모듈은 주변적 힘을 전혀 갖지 않고, 그것이 일으킬 수 있는 모든 이펙트는, host가 import 테이블을 통해 주어야 했던 무언가다. 언어는 설계로 권한을 명시로 만든다; 샌드박스는 필요로 그것을 명시로 만든다. 세계에 닿고 싶은 프로그램은, 그 수단을 주어져야 한다, 문이 Mere의 타입 시스템이든 Wasm의 import 목록이든. 전혀 별개의 두 갈래 추론 —— 하나는 검증 가능한 프로그램에 대해, 하나는 신뢰할 수 없는 코드를 안전하게 돌리는 것에 대해 —— 이 같은 형태에 도달하고, Mere를 Wasm으로 컴파일하는 것이, 그것들이 만나는 곳이다.

그것이 풀어놓는 것

Wasm backend가 자리 잡아, C와 LLVM을 통해 네이티브로 돈 것과 같은 프로그램이, 이제 브라우저 안에서도, 혹은 임의의 샌드박스화된 Wasm host에서도 돌고, 그리고 —— 다른 것과 같은 방식으로, wat2wasm과 host 러너와 바이트 단위 diff로 검증되어 —— 동일한 출력을 낸다. 그 이식성은 그 자신이 목적이 아니다; 그것은, 나중에 언어 자신의 도구가 웹 페이지에 살 수 있게 하는 것이고, 뒤따르는 Part가 그것을 직접 토대로 삼는다.

하나의 언어에 네 실행 경로가 이제 존재하고, feature마다 일치하며, 동일한 바이트에 유지된다: 기준을 위한 인터프리터, 도달 범위를 위한 C, 최적화를 위한 LLVM, 샌드박스를 위한 Wasm. 코드 생성은, 이 시점에서, 평범한 프로그램에 대해서는 해결된 문제다. 아직 할 수 없는 것은, 다른 언어로 쓰인 코드와 말하는 것 —— C 함수를 부르는 것, 혹은 JavaScript가 브라우저에서 도는 Mere의 클로저로 불러들이는 것 —— 이다. 다음 회: 외부 함수 인터페이스, 그리고, 네이티브 라이브러리로 불러내는 것과, host에서 불려 들어오는 것이라는, 전혀 다른 두 문제.

← Back to Mere: 언어를 만들다