다른 프로그램을 돌린다
네 도구의 첫 번째는 task runner —— 그것이 세우기를 돕는 바로 그 언어로 쓰인 작은 make였다. 선택된 것은 task runner가 runtime에 증명적으로 없던 유일한 힘 없이는 존재할 수 없기 때문이었다:다른 프로세스를 시작하기. 그 힘을 더하는 것은 한 오후였다. 놀람은 그 뒤, 계획이 가리키지 않은 곳에 왔다 —— 빠진 에러 채널, 빠진 파일 검사, 그리고 OS 자신의 C 라이브러리에 파묻힌 글로벌 락으로 판명된 성능 버그.
첫 번째 도구는 task runner였다:이름 붙은 태스크의 작은 파일을 읽고, 각각이 임의의 의존을 동반한
커맨드이며, 그것을 올바른 순서로 돌린다. 이름은 mk —— 축소판 make. 선택된 것은 세계가 또 하나
make를 요해서가 아니라, task runner가 다른 프로그램을 돌리는 힘 없이 존재할 수 없고, 그리고 그것이
바로 grep이 「없음」이라 보고한 힘이었기에. 기분 좋은 순환도 있었다:언어는 그 순간, 컴파일러를
자기 자신에 거는 shell script로 세워지고 있었다. 언어로 쓰인 task runner는, 언어를 세우고 있는
바로 그런 종류의 커맨드를 돌리게 된다. 도구는 곧장 자신의 토대에 향해져 있었다.
예측된 gap과, 그것이 얼마나 작았는가
계획은 아픔이 subprocess 제어라 말했고, 계획은 옳았다 —— 딱 한 오후 동안은. 첫 run "echo hi"를
움직이는 데 요한 것은 builtin 하나, run을, 가장 단순하고 정직한 타입으로 더하는 것이었다:커맨드
문자열이 들고, exit code가 난다. interpreter에서 그것은 표준 라이브러리의 커맨드 실행자로의 호출이
되었고;C backend에서 exit status를 꺼낸 system이 되었다. 그것으로 전부였다. 도구 전체가 그것을
존재로 강제하려 세워진 그 능력은 수십 줄로 판명되었고, 일단 그것이 있으면 마일스톤은 빠르게
떨어졌다:출력을 포착, 태스크 파일을 parse, 의존을 토폴로지 순으로 해결, 첫 실패에서 short-circuit.
그것들의 각각이, 이제는 편안한 언어에서의 평범한 작업이었다 —— record, 「이 태스크를 돌린다」와
「그 의존을 돌린다」의 상호 재귀, exit code와 done 플래그를 나르기 위해 threading된 tuple. 아픔은
전무. 그 부재 자체가 하나의 발견이다:코어는 견고하고, 날카로운 가장자리는 모두 OS와의 경계에
외재해 있었다.
계획이 이름 짓지 않은 gap
흥미로운 실패는 계획이 예측하지 않은 것으로, 그것들은 interpreter에서 돌리는 게 아니라 도구를
native 바이너리로 컴파일했을 때에만 나타났다. mk가 실패한 커맨드를 표준 에러로 알릴 필요가 처음
생겼을 때, C backend는 단지 stderr에 인쇄할 술을 갖지 않았다 —— interpreter는 가지고 있었기에,
프로그램은 타입 검사를 통과하고 interpreted로 돌았고, native build만이 그 구멍을 드러냈다. 한 함수의
수정이었지만, 아무리 interpreter로 테스트해도 surface하지 않는 실의 gap이었다. 같은 이야기가,
incremental build가 출력 파일이 이미 있는지를 물을 필요가 생겼을 때 반복되었다:backend는 파일의
갱신 시각을 읽을 수 있어도, 「이것은 있는가?」라는 평명한 물음에 답할 수 없었다. 두 개의 작은 구멍,
함께 같은 솔기 위 —— interpreter에 있고, 컴파일된 backend에 없는 거동 —— 그리고 함께 같은 방식으로,
올바른 형태의 실 프로그램이 그것을 밟고 넘어감으로써 찾아졌다. 이것은 backend를 일치에 유지하는
논거를 부정형으로 진술한 것이다:그것들이 어긋나는 모든 곳은, 올바른 형태의 프로그램을 기다리는
잠재 버그다.
이빨은 OS 안에 있었다
마지막 마일스톤이 가장 세게 물었고, 그것은 아무도 지도에 표시하지 않은 곳에서 물었다. 독립한
태스크를 병렬로 돌리는 것은 거저나 다름없을 터였다 —— 언어에는 이미 thread와 parallel-map이 있고,
태스크는 구성상 독립이었다. 하지만 각각이 3분의 1초 자는 세 태스크를 병렬로 돌리자, 3분의 1이
아니라 온전히 1초가 걸렸다:그것들은 함께가 아니라 차례차례 돌고 있었다. thread는 실이고, 병렬성은
실이 아니었다. 작은 C probe로 갈라내자 판정이 나왔다:이 OS에서는 C 라이브러리의 system 호출이
글로벌 락을 잡으므로, 그것을 부르는 몇 개의 thread도 단일한 문 뒤에 줄을 선다. 병렬성을 목 졸라
죽이고 있던 것은 언어가 아니라, 플랫폼 자신의 runtime에 숨은 직렬화였다. 수정은 그 문을 지나는
것을 완전히 그만두는 것 —— 더 저수준의 spawn 호출로 프로세스를 시작하고, 개별로 기다리는 것 ——
이었고, 그 뒤로는 3분의 1초의 태스크 셋이 3분의 1초에 끝났다, 늘 그래야 했던 대로. 도구가 드러내려
세워진 버그는 subprocess 제어였고;그것이 실제로 가르친 것은, 「커맨드를 돌린다」와 「다른 모두를
몰래 직렬화하지 않고 커맨드를 돌린다」는 두 개의 다른 능력이고, 값하는 것은 후자뿐이다, 라는
것이었다.
끝까지 이 runner는 작은 make가 해야 할 것을 모두 했다 —— parse, 의존, 토폴로지 순, incremental한 skip, 병렬 그룹, exit code의 전파 —— 그리고 그것을 native 바이너리로서 행했다. 언어의 다섯 릴리스를 구동했고, 그 릴리스의 하나하나가, 이제 이 한 본만이 아니라 모든 프로그램에 속하는 능력이었다. 예측된 gap은 실이지만 얕았고;값진 발견은 그 바로 뒤에 서 있던 것으로, 그것이 프런티어에서 세우는 것의 반복되는 교훈이다:보이는 gap에 겨냥을 두고, 배우는 것은 그 그림자에 숨어 있던 무언가다. 다음 도구는 완전히 다른 곳에 겨냥을 두었다 —— 다른 프로그램이 아니라, 단말 그 자체에.