aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/094-idle-links.md
blob: 573db98a025d4671565ac71a37a56aa4ea3ff4f4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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 중단 기준("이어지는 데 링크 수에 비례하는 비대칭 연산")에 걸린다. [095](095-restart-and-first-contact.md)에서 비밀의
  메모로 풀었다. 키 파일 옆 파일에 보내는 이마다 64 B 칸을 두고 매핑한다. 쉬는 링크의 칸은 상주하지 않는다.
- **받는 쪽의 처리량.** 새 보내는 이를 받는 속도는 초당 약 3,700이다. 노드 CPU의 대부분이 X25519다.
- **보내는 쪽.** 링크마다 스레드와 소켓이 하나씩이다. 노드가 기기 100만 대에 보내는 방향은 아직 안 된다. 보내는 쪽도 한
  스레드와 한 소켓에 여러 링크를 둬야 한다.
- **공격.** 알려진 키 번호로 쓰레기 패킷을 보내면, 받는 쪽이 X25519를 하게 만들 수 있다. 메모가 이것도 줄인다. 그 뒤에는
  비율 제한을 둔다(011의 보안 단계).