같은 소스를, 호스트 없이 컴파일하다
Mere가 돌릴 수 있는 모든 web과 database 워크로드는, 소켓도 HTTP 서버도 crypto도 Node 호스트의 공급에 기댔다. native backend는 커맨드라인 도구를 낳을 수 있어도, 포트에서 listen하거나 database와 이야기하는 것은 낳을 수 없었다. 그 gap을 닫는 것은 native runtime을 무에서 발명하는 게 아니라, Wasm backend가 이미 쓰는 메모리 모델을 빌리는 것이었다 —— 그래서 같은 라이브러리가 전혀 변경 없이 native 컴파일되고, 같은 소스가, 무변경으로, 이제 Node 위에서도, Postgres와 손으로 쓴 C의 속도로 이야기하는 단일한 자기 완결 바이너리로도 돈다.
이 시점에 Mere는 web 위에서 큰 도달을 가지고 있었다: HTTP 서버, 실의 wire protocol을 말하는 손으로 쓴 Postgres 클라이언트, Redis 클라이언트, ORM, 그 모두가 Mere 소스에서 쓸 수 있었다. 하지만 그 마지막 한 조각까지 같은 방식으로 돌았다 —— Node 호스트 아래의 WebAssembly로서, 호스트가 소켓을, listen하는 서버를, 암호 primitive를 공급하며. native backend, 실의 실행 가능 파일을 낳는 쪽, 은 커맨드라인 도구를 컴파일할 수 있고 그 이상은 못 했다. 소켓을 여는 수단도, 접속을 accept하는 수단도, 패스워드를 hash하는 수단도 없었다. 언어의 가장 유능한 절반이, 서버를 전혀 돌릴 수 없었다.
이것은 치워 둘 papercut이 아니었다; native path가 결한 통째로 하나의 능력이고, 그것이 눈치채이지 않고 온 이유는 CLI의 이야기에서와 같다. Node 호스트가 늘 이 호출들에 답했기에, 언어는 그 자신으로 답하도록 만들어진 적이 없었다. 물음은, 아무것도 없는 데서 두 번째 runtime을 세우지 않고 그 gap을 어떻게 닫는가, 였다.
메모리 모델을 발명하지 않고 빌린다
database와 이야기하는 라이브러리는 Wasm backend의 메모리 모델에 대해 쓰여 있었다. wire protocol은 바이트다 —— 버퍼를 할당하고, 빅엔디언 길이 prefix를 쓰고, 문자열을 복사해 넣고, 바이트를 읽어 돌려받는다 —— 그리고 Wasm 쪽에서는 그 어휘가, 32bit 오프셋으로 주소되는 linear memory의 단일한 평평한 영역으로의 연산으로 lower되었다. 라이브러리는 그런 연산으로 가득했다. 그것들을 무언가 다른 idiom으로 native로 재구현하는 것은, 라이브러리도 다시 쓰는 것을 의미했을 것이다.
그래서 native runtime은 자기 자신의 모델을 발명하지 않았다; 이미 존재하는 것을 빌렸다. C backend는 평평한 바이트 아레나를 길렀다 —— 32bit 오프셋으로 주소되는 큰 static 버퍼, 꼭 Wasm의 linear memory의 형태 —— 그리고 그것에 대해 같은 메모리 어휘를 구현했다: 할당, 바이트와 빅엔디언 정수를 읽고 쓰기, 문자열을 복사해 넣기, 문자열을 읽어내기. 프로그램이 그 연산들 중 하나를 쓰면, backend는 미정의 호스트 함수로의 참조를 emit하기를 멈추고, 대신 실의 native 구현을 emit했다. 라이브러리는 전혀 변하지 않았다. 그것들은 메모리 모델에 대해 쓰여 있었고, C backend는 같은 모델을 다른 곳에서 공급했을 뿐이다. 이것은 연재가 되돌아오길 반복하는 규율이다: 새 문제가 나타났을 때, 첫 수는 이미 세워진 기구에 기대는 것. 몇 년의 결정 전에 세워진 Wasm backend의 메모리 모델이, native한 database를 가능케 하는 것으로 판명되었다.
같은 이름 아래의 소켓
아레나 위에 소켓이 실렸다: POSIX의 tcp_connect, tcp_write, tcp_read, tcp_close, native로
구현되고 —— 그리고 결정적으로, Node 호스트가 쓰고 있던 것과 같은 이름 아래에서 export되었다. 그것이
parity의 온 트릭이다. 라이브러리가 tcp_write를 import한다; Wasm path에서는 그 이름이 Node 호스트의
한 조각에 해결되고, native path에서는 C 구현에 해결되고, 그 위의 라이브러리는 어느 쪽인지 알지도
신경 쓰지도 않는다. 하나의 Mere 소스가 이제 두 방식으로 컴파일된다: Node 호스트용 WebAssembly거나,
native 바이너리거나, 양쪽에서 같은 거동으로. Postgres 클라이언트가 실제로 C 컴파일러를 통해 돌려진
첫 번째 때, 그것은 backend의 문자열 escape의 잠복 버그를 씻어냈다 —— escape되는 대신 날것으로 쓰이던
캐리지 리턴으로, wire protocol이 나르는 바로 그런 종류의 바이너리에 가까운 payload를 망가뜨리고
있었다. 그 자리에서 수정되었고, 그리고 각주에 값한다, 왜냐하면 같은 작은 누락이, 훨씬 뒤에, 훨씬
비싸게, 아무도 예기치 않은 곳에서 재부상하기 때문이다.
하나의 바이너리
이 모두의 노림수는 단일한 성과물이었다. blog 애플리케이션 —— signup, login, logout, 세션, 게시물의
소유권을 가진 실의 web 앱 —— 이, 수백 킬로바이트의 native 실행 가능 파일로 곧장 컴파일되고, Node도
Wasm도 어디에도 없다. 그것은 소켓에서 listen하고, native한 accept 루프를 통해 HTTP 요청을 파싱하고,
실의 TCP 접속 위에서 Postgres와 이야기하고, native한 SHA-256으로 패스워드를 hash하고, 오퍼레이팅
시스템의 난수로 세션 식별자를 주조하고, 모든 endpoint에서 curl에 답한다. 아크 전체가 노리고 있던
기함의 한 문장 —— Mere로 web과 database의 애플리케이션을 쓰고, 하나의 바이너리로 전한다 —— 이 이제
소망이 아니라 문자 그대로의, 실증된 사실이 되었다.
터널 없는 crypto
한 조각은 TLS로의 의존을 강요할 듯 보였고, 그리고 강요하지 않았다. 패스워드 인증으로 설정된 Postgres로의 접속은 SCRAM 핸드셰이크의 완료를 의미하고, 그것은 실의 암호를 요한다: SHA-256, HMAC, PBKDF2, base64. 그것들은 native로 구현되고 레퍼런스 벡터와 대조되었다. 기분 좋은 놀라움은, SCRAM이 challenge-response 프로토콜이라는 것이다 —— 패스워드를 결코 보내지 않고 그 지식을 증명한다 —— 그래서 안전한데도 transport 암호를 요하지 않는다. native 바이너리가 패스워드 보호된 database에 맨 TCP 접속 위에서, TLS 스택 전혀 없이 인증할 수 있었다. 그리고 아래의 runtime이 Postgres 특유가 아니라 generic이었기에, 같은 아레나와 소켓, 더해 두 작은 hex 헬퍼가, Redis 클라이언트를 native로 구동하는 데도 충분했다. runtime은 database의 feature가 아니었다; 클라이언트 일가 전체가 서는 토대였다.
숫자
native path를 더하는 것은, 그것이 실제로 빠른가라는 명백한 물음을 부르고, 그리고 이것은 언어가 실의
답을 가진 첫 번째 때였다. 재귀적인 fib(35)를 native 컴파일하면 대략 30밀리초에 돌았다. 같은 계산을
손으로 C에 쓰고 최적화와 함께 컴파일하면 대략 30밀리초에 돌았다. Mere→C→C 컴파일러는, 평범한 재귀
코드로, 손으로 쓰인 C와 동급의 native 코드를 낳았다 —— 그리고 다른 벤치마크들에 걸쳐 native path는
interpreter보다 40에서 80배 빠르게 돌았고, 말단 호출은 실의 루프로 바뀌고, 할당이 무거운 코드는
병리를 보이지 않았다. 생성 코드에 적신호는 없었다. 숫자가 말하는 이야기는 태그라인이 될 만큼 단순하다:
Mere를 native 컴파일하면, C의 속도가 얻어진다.
이 아크가 실제로 더한 것은 feature가 아니라, 이미 쓰인 모든 것으로의 두 번째 삶이었다. 라이브러리는 변하지 않고; 소스는 변하지 않고; 호스트가 그저 임의가 되었다. 관대한 runtime 안에서 살아온 언어가, 오퍼레이팅 시스템 위에 직접 서기를 배웠고, 그리고 그렇게 한 것은 새 토대를 발명함이 아니라, 낡은 토대를 새 곳으로 옮김으로써였다.