와이어의 반대편이 된다
언어가 돌려 온 네트워크 프로그램은 전부 client였다 —— 밖으로 dial하고, 말하고, 끊는다. 다섯 번째 능력 도구는 그 거울상: Redis wire protocol을 말하는 key-value 서버, 연결을 만드는 쪽이 아니라 기다리는 쪽. 빠져 있던 primitive는 여느 때처럼 한 오후로 끝났다. 서버가 정말로 배달한 것은, 언어의 병행성에 대한 첫 정직한 부하 시험 —— 그리고 가변 Map의 thread 간 공유가 깨끗이 컴파일되고 조용히 쓰기를 잃는다는 발견이었다.
이 Part는, 프로그램이 끝나기를 거부하면 무엇이 일어나는가에 대한 것이다. 지금까지 세운 모든 것 —— 컴파일러도, 게임도, inspector도, filter도 —— 는 돌고, 끝나고, 프로세스와 함께 메모리를 OS에 돌려준다. 서버에게 그런 사치는 없다. 기다리고, 응하고, 계속 기다린다. 그리고 언어가 조용히 낭비하고 조용히 망가뜨리고 있는 것은, 보이지 않기를 그만두고 청구서가 되기 시작한다. 앞으로의 몇 화에서 그 청구서는 몇 번 도착하고, 그 한 장 한 장이 언어가 그동안 눈감아져 온 무언가를 폭로한다. 첫 배달은 예상 밖의 방향에서 왔다: 메모리가 아니라, thread에 대한 타입 시스템의 약속에서.
서버까지 primitive 하나
도구는 Redis wire protocol을 말하는 작은 key-value 서버로, 시판 client가 그대로 말을 걸 수 있다. 실측한 gap은 이제 낯익은 형태였다: runtime에는 TCP client의 어휘가 완비 —— connect・read・ write・close, 전부 날것의 file descriptor 위, database client들을 위해 오래전에 자란 것 —— 에 더해, request를 읽고 response를 쓰고 끊는 HTTP 전용 서버 helper가 하나. 날것의 연결을 accept해서 열린 채로 유지할 수 있는 것은 아무것도 없다. 수정은 익숙한 패턴을 따랐다: 새 primitive 둘, listen과 accept를, connect와 같은 descriptor + buffer FFI에 대해 거울처럼, 각각 수십 줄. 지금까지의 모든 능력 회와 같이, 이름 불린 gap은 얕은 쪽이었다. 단일 연결 echo 서버는 같은 오후에 돌았고, 프로토콜 처리 —— 커맨드 프레임을 parse하고, 적용하고, reply를 encode하고, 끊지 않고 반복한다 —— 는 평범한 코드에 진작 익숙해진 언어에서의 평범한 코드였다.
실패했어야 할 컴파일
다음이 병행성이었다. 많은 client에 응한다는 것은 많은 thread가 하나의 store에 손을 뻗는 것이고, 자명한 초고는 자명한 일을 했다: 연결마다 thread를 spawn하고, 모두가 공유 가변 Map을 만지게 한다. 컴파일러는 그것을 받아들였다. 언어가 Send/Sync 류의 규율을 가진 것은, 바로 이런 종류의 프로그램이 안전하거나 거부되거나 둘 중 하나이기 위해서다 —— region에 묶인 값은 thread-local이어야 하고, 설계 문서도 그렇게 말하고 있었다. 하지만 부하 시험 —— 8 client가 각각 key를 쓰고 즉시 되읽는 —— 아래서, 읽기의 약 2 퍼센트가 비어서 돌아왔다. SET은 acknowledge되고, GET은 무를 반환한다. store의 runtime은, 경주하는 thread들에 의해 늘려지고 index되는 락 없는 배열이다. Map은 검사기의 축복을 받아 thread 경계를 넘었고, 그리고 정확히 데이터 레이스로서 행동했다. 왜는 하나의 낙관적 default로 귀결됐다: 분류기는 container를 타입 인자로 판정하고, container의 region marker는 미해결 타입 변수이며, 미해결은 「괜찮다고 간주」를 뜻했다. 이 버그의 수정 —— 그리고 추적이 폭로한 또 하나의, 더 큰 구멍 —— 은 이 Part의 뒤 회에 넘긴다. 정직한 순서가 중요하기 때문이다: 먼저 서버가 우회했고, 그다음 언어가 수리되었다.
공유가 아니라 통신으로
우회책은 락이 아니었다. 언어의 channel은 유일하게 정말로 thread-safe한 primitive이므로, 서버는
actor로 재구성되었다: owner thread가 혼자 Map을 들고, 그것을 만지는 유일한 thread가 된다. 각 연결
thread는 커맨드를 parse하고, 사유의 reply channel을 곁들여, 공유 request channel로 보낸다. store는
thread 경계를 일절 넘지 않고, 그래서 경주할 것도 잠글 것도 없다. 같은 부하 시험 아래서 actor 판은
수천의 쓰기를 하나도 잃지 않았다. 이것은 channel 기구에 놓인 첫 실부하이기도 했다 —— 지금까지
테스트와 장난감에만 나타나던 것이, 비행 중인 모든 커맨드에서 channel을 두 번 뛴다 —— 그리고 숫자는
버텨냈다. 이 패턴은 codebase의 어휘에 이름을 얻었다: share by communicating —— 가변 구조를
spawn 너머로 건네는 것이 아니라. 나중에 타입 시스템이 naive 판을 거부하기를 배웠을 때, 이 서버는
—— 무변경인 채 —— 축복받은 패턴의 회귀 테스트가 되었다.
청구서가 도착한다
actor 서버는 돌았고, 재활용 버퍼 풀 덕에 수백의 순차 연결을 살아남았고, 실물 Redis client에 옳게
응했다. 그러고 나서 부하 시험을 조금만 더 오래 돌리자, 약 8천 커맨드에서 프로세스가 죽었다 ——
서버 로직의 버그가 아니라, region OOM으로. 언어의 program-lifetime 메모리 region, 모든 reply
문자열과 parse된 커맨드가 조용히 착지하는 곳에는, 고정된 용량이 있었고 무언가를 돌려준다는 개념이
없었다. 단명한 도구는 이것을 결코 알아채지 못한다. 서버는 긴 주행 그 자체다. 이 실패 —— 그것이
성장과 회수에 대해 폭로한 것, 그리고 같은 이 서버가 수십만 커맨드 아래서 평평한 메모리를 유지하기에
이르는 두 단계의 수리 —— 가, 이 Part의 나머지가 말하는 이야기다.