돌아오는 메모리
장수 프로그램 편의 피날레: region 블록이 body의 할당을 정말로 회수하게 한다. 첫째 단은 copy-on-store로 container에 내용물을 소유시켰다. 둘째 단은 모든 값 할당을 thread-local한 current region으로 향하게 하고, 블록의 결과를 copy-out하고, channel의 짐을 per-message region 경유로 했다. 그 사이, 세 개의 서로 다른 복병이 꼬리 호출을 죽이러 왔다 —— stack struct의 escape하는 주소, inline된 asprintf, destructor 없는 thread-local 캐시. 끝나고 보니, 관용적인 줄 카운터는 wc보다 아래의 상수 메모리로 달리고, 이 Part를 연 서버는 40만 커맨드 아래서 평평함을 지킨다.
문제는 세 화 전에 이름 붙어 있었다: 커맨드마다의 값은 reply가 쓰인 순간 논리적으로는 죽는데, 살아남아야 하는 소수의 값과 같은 불사의 region에 섞여 쌓인다. 어떤 수리도 먼저 생존자에게 답해야 한다 —— 스코프 region이 body의 할당을 전부 회수한다면, 장수하는 map에 store된 문자열이나, 다른 thread로 보내진 문자열은 어떻게 되는가. 여기에 엉성하게 답하면, 회수는 use-after-free의 생성기가 된다. 그래서 수리는 의도적으로 두 단으로 출하되었다. 생존자가 먼저다.
첫째 단: container가 내용물을 소유한다
첫 릴리스는 관측 가능한 거동을 하나도 바꾸지 않았고, 그 사실을 자신의 changelog에 적었다. store는
copy하게 되었다: map_set은 key와 value를 map 자신의 region에 deep copy하고, vector로의 push는
요소를 copy한다. copy는 타입마다의 copy 함수를 지난다 —— derive 일가가 늘 그래 왔던 것과 같은
특수화로. 문자열은 바이트를 copy하고, tuple・record・variant 노드는 구조째 copy하고, 스칼라와
closure는 그대로 지나가고, 그리고 중첩된 container는 포인터로서 copy한다. 가변 container는
identity이고, 그것을 alias하는 데에야말로 의미가 있기 때문이다. 문자열은 불변이므로, 이 모든 것은
프로그램에서 관측할 수 없다. 릴리스 전체가 lifetime에 대한 약속이었다 —— store한 것은, store한
자보다 오래 산다 —— 그것을 시험할 기구에 앞서 맺어진 약속이다. 의도적으로 지루하고, 이후의 모든
것의 전제 조건이었다.
둘째 단: 스코프가 되찾는다
둘째 릴리스가 본원이었다. 값의 할당 —— 문자열, cons cell, variant 노드 —— 이 불사 region의 직서를
그만두고, thread-local한 current region을 향하게 되었다. region 블록은 body 동안 자신을 current로
한다. body가 할당하는 모든 것은 블록에 착지하고, exit에서 블록은 자신의 결과를 첫째 단의 기구로
바깥 region에 deep copy하고, 나머지를 해방한다. 설계 노트에 열거한 escape 경로는 하나씩 답을 얻었다:
결과는 copy-out. container로의 store는 첫째 단으로 이미 안전. channel의 send는 짐을 per-message
region에 deep copy하고, 수신 측이 자신의 current로 퍼내고 해방한다 —— 송신 측의 scratch는 메시지의
비행 중에 죽어도 된다. closure의 환경은 의도적으로 불사인 채 —— 작은 기지의 leak과 맞바꿔, 결코
dangle하지 않는다. 그리고 container는 블록의 결과가 될 수 없다 —— typer의 기존 region-escape 검사가
먼저 발화하고, codegen의 guard가 뒤에 대기한다. 실패 경로까지 계산에 들어갔다: 블록을 longjmp로
뛰어넘는 fail은, 해방된 메모리를 가리킨 채 두지 않고 current region을 복원한다.
꼬리 호출로 가는 길의 세 복병
설계는 즉시 기능했고, 구현은 세 번 죽을 뻔했다. 전부 같은 곳 —— 깊은 루프에서다. 꼬리 재귀 루프 속
per-iteration region 블록이 9만 반복에서 스택을 넘치게 했다 —— 블록의 stack 확보 struct의 주소가
thread-local로 escape했고, C 컴파일러가 자기 호출의 점프화를 말없이 거부하고 있었다. 블록 region을
heap 확보로 하고, thread마다 1단의 캐시로 핫 루프의 비용을 malloc이 아니라 포인터 교환으로 만들자,
escape는 사라졌다. 루프는 아직 죽었다. emit된 C의 이분 탐색이 둘째 복병을 찾았다: 정수 정형 helper가
루프에 inline되어, 로컬의 주소를 asprintf에 건네고 있었다 —— 같은 저해, 새 범인. 정형 일가는
이제 noinline이다. 셋째 복병은 서버에서 기다리고 있었다: 메모리가 연결마다 자란다. thread의
캐시 region에는 destructor가 없고, 연결마다 spawn하는 서버는 thread를 끊임없이 은퇴시키기 때문이다.
spawn의 trampoline이, thread가 끝날 때마다 캐시를 해방하게 되었다. 세 벌의 다른 의상, 하나의 교훈:
C를 경유해 컴파일하는 언어에서 꼬리 호출은 의도적으로 지켜 나가는 약속이다 —— 최적화기의 보수주의를
상대로, escape한 주소를 하나씩 밟아 없애면서.
영수증
두 숫자가 이 아크를 닫는다. 관용적인 string 방언의 줄 카운터 —— 줄마다 30 바이트의 영구 잔류를 쟀던
그 probe —— 는 각 줄을 region 블록에 싸서, 800만 줄을 1.5 메가바이트・상수・wc 자신보다 아래로
달렸다. 같은 카운터가 두 화 전에는 4분의 1 기가바이트를 요했다. 그리고 이 Part를 연 key-value 서버는
handler와 store owner에 per-command region을 채용하고, 새 연결로 8만 커맨드 × 5 라운드를 받았다:
상주 메모리는 10 메가바이트 언저리를 진동하며, 시작한 자리에서 끝났다. 8천에서의 abort에서, 무한한
성장으로, 그리고 평평함으로 —— 남은 micro-edge는 여느 때처럼 적어 남기고(덮어쓰기 과다한 store는
지금도 완만히 자란다. bump region은 치환된 옛 값을 해방할 수 없기 때문이다). 이 Part를 서버 하나
살리지 못한 채 시작한 언어는, 하나를 무기한 돌릴 수 있는 언어로서 Part를 마친다. 장수하는 프로그램이
요구하는 것은, 결국 언어라는 것의 대부분이었다: 건전한 타입, 정직한 앨러케이터, 그리고 thread를
넘어 지켜지는 약속.