diff options
Diffstat (limited to 'docs/research/094-idle-links.md')
| -rw-r--r-- | docs/research/094-idle-links.md | 142 |
1 files changed, 142 insertions, 0 deletions
diff --git a/docs/research/094-idle-links.md b/docs/research/094-idle-links.md new file mode 100644 index 0000000..57fc736 --- /dev/null +++ b/docs/research/094-idle-links.md @@ -0,0 +1,142 @@ +# 094 — 쉬는 링크의 값: 링크 100만 개와 QUIC 연결 + +2026-09-28. [설계 011](../design/011-pure-transport.md)의 M1이다. [093](093-connectionless-link.md)이 연결 없이 이어지는지를 +봤다면, 여기서는 그 값이 얼마인지 잰다. + +착수 기준: + +- **바꿀 결정.** 011의 H1이다. 쉬는 링크의 비용은 저장소의 행이고, 프로세스 메모리는 활성 링크 수에만 비례한다. 쉬는 링크 + 하나가 QUIC 연결 하나의 1/100보다 더 들면 모델이 틀린 것이고, 다시 설계한다. +- **최소 확인.** 받는 노드 하나가 보내는 이를 1천, 1만, 10만, 100만으로 늘려 허용한다. 보내는 이마다 주문 하나를 보내고, + 저장됐다는 말을 받은 뒤 쉰다. 노드의 메모리를 잰다. quinn 서비스도 같은 교환을 한다. 클라이언트마다 연결해 주문 하나를 보내고 + 저장됐다는 답을 받은 뒤, 연결을 연 채 쉰다. 그 서비스의 메모리를 잰다. +- **중단 기준.** 위의 1/100이다. + +## 쉬운 말로 + +- 알아낸 것: + - 쉬는 링크 하나는 노드 메모리를 **132 B** 쓴다. 허용 목록이 73 B, 들은 번호가 약 60 B다. 전송 상태는 0이다. + - quinn은 쉬는 연결 하나에 **38,106 B**를 쓴다. 289배다. + - 링크 100만 개를 받는 노드는 139 MB로 돈다. quinn은 같은 메모리로 연결 약 3,800개를 연 채 둔다. + - 쉬던 링크의 다음 기록은 핸드셰이크 없이 **0.56 ms** 만에 저장까지 확인된다. 루프백이고, 노드의 X25519와 저장이 들어 있다. + - QUIC은 연결을 닫았다면 새 핸드셰이크를 해서 1.06 ms가 든다. 저장은 빠진 값이다. 연결을 열어 두었다면 38 KB를 쥐고 + 있어야 한다. +- 아직 모르는 것: 재시작(M2), 처음 만남의 왕복 수(M4), 전달당 CPU(M5). +- 사용 경험의 차이: 기기 100만 대가 붙는 서비스가 연결 관리 없이 작은 기계 한 대로 선다. 기기는 언제 깨어나든 첫 패킷으로 + 이어진다. + +## 한 것 + +### 코어: 들은 번호를 목록으로 + +재 보기 전에 받는 쪽의 코어가 먼저 걸렸다. 앱은 출처마다 마지막으로 들은 번호를 저장한다. 여기에 문제가 둘 있었다. + +- 저장할 때마다 번호 전체를 링크 값에 통째로 다시 썼다. 출처가 많으면 저장 한 번이 출처 수만큼의 바이트를 쓴다. +- 개수를 2바이트로 적었다. 출처가 65,535를 넘으면 틀린 개수가 저장된다. 링크 7만 개면 저장소가 깨진다. + +들은 번호는 이제 런타임이 가진 목록 {source, seq}다. 출처 순으로 두고, 목록 상태와 똑같이 저장한다. 이미지 `0heard`와 로그의 +변경 기록이다. 저장은 바뀐 번호의 기록만 로그에 더한다. 목록이 로그보다 작아지면 목록 체크포인트가 이미지를 새로 쓴다. 번호는 +그것을 전달하는 이벤트의 변경으로 바뀐다. 그래서 저장이 실패하면 이벤트와 함께 되돌려진다. 옛 저장소(PLK1)의 번호는 시작할 때 +목록으로 옮긴다([1d8eabc](../../core/module-save.c)). + +설계 011은 "링크의 행은 목록의 한 항목"이라고 적었다. 그 행은 링크가 아니라 앱이 이미 가진 들은 번호였다. 저장소에서 한 항목은 +약 20 B다(이름 길이 4 B, 이름 8 B, 번호 8 B). + +### 받는 쪽: 허용만 남기고 나머지는 필요할 때 + +- 허용된 보내는 이는 48 B다. 키, 키 번호, 출처 이름의 자리다. 키 번호의 색인으로 찾는다. 키 번호는 32비트라 100만 개면 + 겹치는 쌍이 백여 개 생긴다. 겹치면 각 키로 열어 본다. +- 나머지는 활성 상태다. 키가 노드와 나누는 비밀, run의 키와 주소, 흐름이다. 첫 패킷에서 X25519 한 번으로 만든다. 쉬는 + 기간(`--link-idle-ms`, 기본 30초) 동안 조용하고 기다리는 것이 없으면 비운다. +- 리스너는 바뀐 흐름과 run만 넘기고 답한다. 예전에는 매번 전부 훑었다. +- `--senders FILE`로 허용 목록을 한 줄에 하나씩 읽는다. 100만 줄에 0.67초다. + +### 16 KB 페이지와 조각 + +처음 재 보니 보내는 이 1만 개가 왔다 간 뒤에도 노드가 익명 메모리 42 MB를 쥐고 있었다. 할당기에게 물으니 쓰는 것은 0.6 +MB뿐이었다. 나머지는 빈 조각 5,800개였다. + +- 원인은 run마다 따로 잡던 OpenSSL AES-GCM 문맥 둘이다. 그 사이사이에 오래 사는 작은 할당이 끼어 있었다. +- 이 Pi의 커널은 16 KB 페이지다. 조각 하나가 페이지 하나를 온전히 비우지 못하면, `malloc_trim`도 페이지를 돌려주지 못한다. + +그래서 run은 키만 바이트로 가진다. 키는 한 방향에 16 B이고, 논스 기반이 붙는다. 리스너는 AES-GCM 문맥 하나에 패킷마다 그 +run의 키를 넣어 쓴다. 비용은 패킷당 약 130 ns다. 그 뒤로 남는 것은 활성이 가장 많던 때만큼의 빈 힙뿐이다. 1만 개가 +몰렸을 때 약 11 MB였다. 이것은 링크 수에 비례하지 않는다. 다음에 깨어나는 링크들이 그 자리를 다시 쓴다. + +### 명세만 보고 짠 두 번째 구현 + +부하 생성기([links.c](../../research/experiments/019-idle-links/links.c))는 `serve/link.h`의 패킷 형식만 보고 OpenSSL로 다시 +짰다. 보내는 이마다 키가 따로이고, 자기 소켓으로 봉인된 첫 패킷을 보낸다. 저장됐다는 말이 오면 끝난다. 노드는 이것을 100만 +번 받아 모두 정확히 한 번씩 적용했다. 명세가 구현 둘 사이에서 맞는다는 확인이기도 하다. + +## 결과 + +라즈베리 파이 5, 8 GB, 커널 페이지 16 KB이고 저장소는 tmpfs다. 메모리는 프로세스의 익명 메모리(`smaps_rollup`의 +Anonymous)다. MB는 2^20 B이고, 링크 하나의 값은 바이트로 적는다. 원시 결과는 [2026-09-28-links-linux-aarch64.jsonl](../../research/experiments/019-idle-links/2026-09-28-links-linux-aarch64.jsonl), +[2026-09-28-quinn-linux-aarch64.jsonl](../../research/experiments/019-idle-links/2026-09-28-quinn-linux-aarch64.jsonl)에 있고, +[run.py](../../research/experiments/019-idle-links/run.py)가 만든다. + +**Pure 노드**(`pure-serve`, 받는 앱 하나, 쉬는 기간 1초): + +| 보내는 이 | 허용 뒤 | 모두 보내고 쉰 뒤 | 그때 RSS | 시작(허용 읽기) | +|---:|---:|---:|---:|---:| +| 0 | 1.8 MB | | 7.6 MB | | +| 1,000 | 1.9 MB | 4.5 MB | 10.3 MB | 0.005초 | +| 10,000 | 2.7 MB | 13.8 MB | 19.7 MB | 0.010초 | +| 100,000 | 9.3 MB | 25.5 MB | 31.4 MB | 0.056초 | +| 1,000,000 | 72.1 MB | 138.7 MB | 144.5 MB | 0.67초 | + +- 기울기(10만→100만): 허용 **73 B**, 쉰 뒤 **132 B**. 차이 약 60 B가 들은 번호 목록이다. +- 쉰 뒤의 활성 상태는 0이다. 리스너가 만든 비밀과 run이 모두 비워졌다(`listener_swept`). +- 1천→1만의 기울기가 1 KB로 큰 까닭은 앞의 조각이다. 활성이 가장 많던 때의 빈 힙이 고정으로 약 11 MB 남는다. +- 새 보내는 이 하나를 받는 데 노드 CPU가 0.27 ms 들었다. 그 가운데 X25519가 0.17 ms다. 초당 약 3,700개를 받았다. +- 저장소: 100만 번 전달한 뒤 68 MB다. 대부분은 전달 100만 번의 로그다. 체크포인트와 로그 반납의 때에 따라 달라진다(첫 + 측정은 97 MB). 쉬는 링크 하나가 남기는 행은 들은 번호 목록의 한 항목이고, 약 20 B다. + +**quinn 0.11**(rustls, ring, AES-128-GCM, tokio 한 스레드, 쉬는 시간 한도 1시간, keep-alive 없음): + +| 연결 | 쉬는 연결로 | 그때 RSS | 연결당 | +|---:|---:|---:|---:| +| 0 | 0.4 MB | 3.8 MB | | +| 1,000 | 38.0 MB | 41.6 MB | 39,453 B | +| 5,000 | 183.1 MB | 186.7 MB | 38,309 B | +| 10,000 | 364.8 MB | 368.4 MB | 38,208 B | + +- 기울기(5천→1만)는 **38,106 B**다. 서비스가 적어 두는 클라이언트별 번호(해시 맵)가 들어 있다. +- 연결을 맺고 첫 주문을 저장하는 데 서비스 CPU가 0.75 ms 들었다. +- 이 Pi에서는 더 늘리지 않았다. 클라이언트 쪽 프로세스도 같은 만큼 들기 때문이다. + +**깨우는 첫 패킷.** 쉬던 1,000개가 하나씩 다음 주문을 보냈다. + +| | 저장됐다는 말까지(p50 / p99) | 받는 쪽 CPU | +|---|---:|---:| +| Pure, 쉬던 링크(활성 상태 없음, X25519 포함) | 0.56 / 0.69 ms | 0.28 ms | +| quinn, 연 채 둔 연결 | 0.07 ms(평균, 저장 없음) | 0.05 ms | +| quinn, 새 연결(핸드셰이크) | 1.06 / 1.63 ms(저장 없음) | 0.78 ms(주문 둘 포함) | + +- Pure의 값에는 노드의 X25519(이 Pi의 OpenSSL로 169 µs)와 앱의 저장이 들어 있다. quinn 서비스는 메모리의 해시 맵에만 적는다. +- 연결을 연 채 두는 QUIC이 가장 빠르다. 대신 쉬는 동안 38 KB를 쥔다. 닫으면 Pure보다 두 배 가까이 느려지고, 받는 쪽 CPU도 + 2.8배 든다. + +## 판정 + +M1은 통과다. 쉬는 링크 하나가 132 B이고, QUIC 연결 하나의 1/289이다. 기준인 1/100 이하에 든다. 전송이 쉬는 동안 쥐는 상태만 +보면 0이다. + +H1의 뒷부분("프로세스 메모리는 활성 링크 수에만 비례")은 절반만 맞다. + +- 링크마다 132 B가 남는다. 허용 목록(키와 이름)과 들은 번호다. +- 둘 다 전송의 상태가 아니라 앱과 설정의 데이터다. QUIC 서비스도 인증할 키 목록과 중복 제거 번호를 어딘가에 둬야 한다. +- 그래도 지금은 메모리에 있다. 저장소로 내리면 0에 가까워진다. 들은 번호는 이미 저장소의 목록이므로, 목록을 필요할 때만 + 올리면 된다. 이것은 런타임의 일이다. + +## 한계와 다음 + +- **재시작의 비대칭 연산.** 활성 상태를 비우면 비밀도 잊는다. 그래서 재시작한 노드는 링크마다 X25519를 한 번 한다(169 µs). + 011의 M2 중단 기준("이어지는 데 링크 수에 비례하는 비대칭 연산")에 걸린다. 다음 일은 비밀의 메모다. 키 파일 옆 파일에 + 보내는 이마다 64 B 칸을 두고 매핑한다. 쉬는 링크의 칸은 상주하지 않는다. +- **받는 쪽의 처리량.** 새 보내는 이를 받는 속도는 초당 약 3,700이다. 노드 CPU의 대부분이 X25519다. +- **보내는 쪽.** 링크마다 스레드와 소켓이 하나씩이다. 노드가 기기 100만 대에 보내는 방향은 아직 안 된다. 보내는 쪽도 한 + 스레드와 한 소켓에 여러 링크를 둬야 한다. +- **공격.** 알려진 키 번호로 쓰레기 패킷을 보내면, 받는 쪽이 X25519를 하게 만들 수 있다. 메모가 이것도 줄인다. 그 뒤에는 + 비율 제한을 둔다(011의 보안 단계). |
