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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
|
# 017 — 한 호스트의 여러 앱: 차례, 예산, 실패 경계
작성일: 2026-10-01. 상태: 1·2단계와 2b 구현, 검사로 고정하고 쟀다([111](../research/111-apps-on-one-host.md),
[112](../research/112-sending-between-apps.md), [113](../research/113-one-sending-loop.md)). 3단계(수명) 구현, 검사로 고정하고
쟀다([115](../research/115-apps-that-change.md)). 3b(경로의 인증)는 설계 전.
[외부 리뷰 004](../outside-feedback/feedback-004.md)의 "구현 방향에 대한 평가" 셋째에서 시작한다. 작은 OS의 운영 모델(
[007](007-execution-domains.md))을 Pure가 실제로 갖게 하는 단계다.
## 문제
- **앱 하나가 프로세스 하나다.** `pure-serve`는 모듈 하나와 저장소 하나를 품는다. 앱 열 개는 프로세스 열 개다. 앱마다
연결 스레드, 소유자 스레드, 체크포인트를 쓰는 스레드, 포트, 키를 따로 가진다. 유휴 프로세스 하나가 자기만의 메모리 864 KiB를
쓴다([087](../research/087-small-reads-and-idle-memory.md)). 무엇을 언제 돌리고 누구에게 얼마를 주는지를 Pure가 아니라
리눅스가 정한다. 007은 이것을 출발점으로 삼지 않는다고 정했다.
- **한 프로세스 안에서도 경계가 없다.**
- 시간: 이벤트 하나의 실행량은 정적으로 묶여 있지만 목록 크기에 비례한다(블록 길이 × 항목 수, 목록은 1,600만 항목까지).
한 이벤트가 소유자를 몇 초씩 잡을 수 있다. 보기와 질의도 연결 스레드를 그만큼 잡는다.
- 대기열: 소유자에게 넘긴 요청은 한없이 쌓인다(배열을 늘리고, 메모리가 다하면 멈춘다).
- 저장: 트리 저장소의 쓰기 차례가 프로세스에 하나다(`store/store-tree.c`의 `writers`). 서로 다른 저장소의 커밋도 동기화까지
서로 기다린다. 한 저장소의 동기화가 오래 걸리면(SD 카드에서는 몇 초도 걸린다) 그동안 다른 저장소는 아무것도 저장하지
못한다.
## 원칙
- **앱은 실행 영역이다**(007). 모듈, 상태와 그 저장소, 입력과 출력, 예산을 가진다. 상태의 소유자는 하나라서 한 앱은 한 번에
한 스레드에서만 돈다.
- **앱마다 스레드를 두지 않는다**(007). 정해진 수의 실행 스레드가 일이 있는 앱을 차례로 돈다. 앱을 더해도 스레드는 늘지 않는다.
- **한도는 결정적인 단위로 센다.** 메모리는 저장 형식의 바이트([013](013-memory-budget.md)), 실행량은 명령 수, 대기열은 요청
수다. 같은 앱과 같은 입력이면 어느 기기에서나 같은 사건이 같은 이유로 거절된다. 벽시계로 세면 빠른 기기에서 되던 사건이
느린 기기에서 실패한다.
- **실패는 그 앱에서 멈춘다.** 모듈을 읽지 못하거나 저장소를 열지 못한 앱은 그 앱만 시작하지 않는다. 저장이 불확실해진 앱은
그 앱만 멈춘다. 다른 앱의 이벤트와 저장은 계속된다.
- **보호의 범위를 부풀리지 않는다.** 같은 프로세스 안에서 앱을 가르는 것은 검증기(검증된 모듈은 자기 상태 밖을 읽거나 쓰지
못한다, 008)와 예산이다. 호스트 자신의 결함(C 코드의 버그, 할당기의 고갈)은 모든 앱의 운명이다. 이것을 리눅스 프로세스만큼의
격리라고 말하지 않는다.
## 모양
**호스트 구성.** `pure-serve --apps FILE`. 줄마다 앱 하나다(`#`부터 줄 끝은 주석).
```text
# 이름 모듈 저장소 예산
board apps/board.pmod stores/board memory=4194304 work=20000000 queue=1024
counter apps/count.pmod stores/counter
```
- 이름은 `[a-z0-9_-]`의 1–32자다. 경로의 첫 마디가 된다. 두 앱이 같은 저장소를 가리키면 시작하지 않는다.
- 예산(모두 없으면 한도 없음, 지금처럼): `memory=` 상태 바이트(013의 `--memory`), `work=` 이벤트나 보기 하나의 실행량,
`queue=` 차례를 기다리는 요청 수.
- 지금의 `--module FILE --store DIR`는 이름 없는 앱 하나다. 경로와 옵션이 그대로다(`/views/NAME`, `--memory`, `--send` …).
- `--workers N`: 실행 스레드의 수. 앱 하나면 1이다(지금의 소유자 스레드와 같다). 여러 앱이면 상태를 가진 앱 수와 CPU 수 가운데
작은 것이다. 여러 앱의 호스트에서는 모든 앱이 실행 스레드에서 돈다(상태 없이 문서만 가진 앱도). 앱 하나의 호스트에서
문서만 가진 앱은 지금처럼 연결 스레드가 돌린다.
**경로.** 여러 앱이면 `/앱/views/NAME`, `/앱/inputs/NAME`, `/앱/queries/NAME`, `/앱/objects/KEY`다. 모르는 앱은 404 `no_app`,
멈춘 앱은 503 `stopped`(지금 `PURE_CLOSED`의 이름)다. 시작할 때 알리는 줄에 앱마다 상태와 까닭이 있다.
**집 스레드.** 앱마다 연결 스레드 하나가 집이다(`--apps`의 순서를 스레드 수로 나눈 나머지). 연결은 첫 요청에서 그 앱의 집으로
한 번 옮겨진다(머리를 읽었고 티켓은 아직 없을 때: 집 스레드가 서술자와 읽은 바이트를 받아 요청을 다시 읽는다). 그래서 한 앱의
요청은 한 스레드에서 소유자를 만난다. 프로세스 하나에 앱 하나일 때와 같다. 연결 스레드의 한 라운드가 그 앱의 요청을 한 번에
넘기고, 한 차례의 답은 한 스레드로 돌아간다.
**차례.** 연결 스레드는 요청을 그 앱의 대기열에 넘기고, 그 앱이 차례 줄에 없으면 줄 끝에 세운다. 실행 스레드는 줄 앞의 앱을
가져와 한 차례를 돈다. 때가 된 타이머, 받은 답, 기다리는 요청을 한 번에 적용하고 한 번 저장하고 답을 돌려준다. 일이 남았고
다른 앱이 기다리지 않으면 같은 실행 스레드에서 다음 차례를 잇는다(코어를 옮기지 않고, 다른 스레드를 깨우지 않는다). 다른
앱이 기다리면 줄 끝으로 간다.
- 한 차례가 받는 요청: 줄에 다른 앱이 없으면 기다리는 것을 모두, 있으면 256개까지(연결 스레드의 한 라운드와 같다).
- 저장 뒤의 모으기(답을 받은 클라이언트의 다음 요청을 잠깐 기다려 한 저장에 묶는 것, 지금의 소유자가 한다)는 줄에 다른 앱이
없을 때만 한다. 기다리는 앱이 있으면 실행 스레드는 그 앱으로 간다.
- 타이머는 앱마다 다음 때를 가진다. 실행 스레드는 줄이 비면 가장 이른 때까지 잔다.
**실행량 예산(`work=`).** 이벤트 하나, 그리고 보기·셀·걸러진 표의 필터 하나의 계산마다 센다. 단위는 명령이고, 쓴 그대로
센다.
- 핸들러와 보기와 열의 블록이 돌기 시작하면 그 블록의 길이를 센다.
- 걷기(EACH)와 집계(FOLD), 걸러진 표의 필터는 시작할 때 블록 길이 × 그 목록(조각을 받으면 그 조각)의 항목 수를 한 번에
센다. 계획이 무엇을 돌든 같다. 잎 단위(레인)든, 범위로 좁히든, 모양 있는 집계(그룹·순위, 091)의 패스를 다시 쓰든 쓴
그대로다. 패스는 만들어 둔 것을 다음 계산이 다시 쓰므로, 실제로 도는 일을 세면 같은 요청이 그때그때 다른 양이 된다.
- 넘을 일은 시작하기 전에 거절된다. 명령마다 세지 않으므로 해석기의 안쪽 루프에 비용이 들지 않는다. 예산이 없으면 세지도
않는다.
- 넘으면 그 사건은 새 실패 `work`로 실패하고, 실패한 사건처럼 아무것도 바뀌지 않는다. HTTP로는 422 `work`다. 상태가 같으면
다시 해도 같으므로 "잠시 뒤"(503)가 아니다. 앱이 일을 나눠야 한다(조각, 범위).
- 텍스트를 잇고 고치는 일은 바이트에 비례하고 명령으로 세지 않는다. 그 크기는 메모리 예산이 묶는다.
**대기열 예산(`queue=`).** 앱의 대기열에 기다리는 요청이 한도면 새 요청은 503 `busy`다. 다른 앱의 요청은 받는다. 연결
스레드의 payload 예산(`--budget`)은 그대로 호스트 전체의 것이다.
**저장의 경계.** 트리 저장소의 쓰기 차례를 저장소마다 둔다. 같은 저장소의 스레드끼리만 차례를 기다린다. 묶음 커밋(그룹)도
저장소마다다. 기기가 동기화를 한 줄로 세우는 것([084](../research/084-parallel-syncs.md))은 그대로지만, 한 저장소의 멈춘
동기화가 다른 저장소의 커밋을 잡지 않는다.
**실패 경계.**
- 시작: 모듈을 읽지 못하거나 검증에 실패하거나 저장소를 열지 못한 앱(저장된 상태를 읽지 못하는 것도)은 멈춘 앱이 된다.
나머지는 시작한다.
- 실행 중: 저장이 불확실해진 앱(지금의 `uncertain`, 앱마다의 깃발이다)은 그 뒤의 사건을 거절한다. 그 앱만이다. 다른
프로세스가 한 앱의 저장소를 쥐면 그 앱만 저장하지 못한다(503 `store_busy`, 적용한 것 없음).
- 끝: 앱마다 센 것(사건, 차례, 거절한 것과 까닭, 저장)을 멈출 때 알린다.
## 단계
1. **한 호스트, 여러 앱.** 위의 모양 전부: 구성, 경로, 차례와 실행 스레드, 실행량·대기열 예산, 저장소마다의 쓰기 차례, 실패
경계. 여러 앱의 구성에서 보냄(`--send`), 봉인, 답, 링크 포트, 표면은 아직 받지 않는다(앱 하나에서는 지금처럼 된다).
2. **앱 사이와 노드 사이.** 앱마다의 보냄과 답. 링크 포트 하나가 모든 앱의 입력을 받는다(스트림의 input 이름 `앱/입력`, 012의
형식은 그대로). 같은 호스트의 앱끼리는 소켓과 봉인 없이 건넨다(007: 내부 연결은 준비된 관계를 쓴다). 보냄마다의 스레드를
하나의 보내는 루프로 모으는 것은 2b로 뗀다(아래).
3. **앱의 수명.** 달리는 호스트에 앱을 더하고, 멈추고, 모듈을 바꾼다. 불확실해진 앱을 저장소에서 다시 연다. 앱 경로의 인증과
함께다(CHECKPOINT의 남은 것).
## 1단계의 착수 기준
구현하기 전에 정해 커밋한다.
- **바꿀 결정:** 앱 여러 개를 한 프로세스에서 Pure의 차례로 돌릴지, 앱마다 프로세스를 띄워 리눅스에 맡길지다. 아래를 채우지
못하면 한 프로세스 모양을 다시 본다.
- **최소 확인:**
- 저장소: 한 저장소의 커밋이 동기화 중에 멈춰 있는 동안 다른 저장소가 커밋한다. 같은 저장소의 두 스레드는 여전히 차례다
(`store/test-tree-store.c`).
- 실행량(test-module): 걷기, 집계(항목 단위·잎 단위·모양 있는 패스), 걸러진 표, 보기가 위의 규칙대로 센다. 넘으면 `work`로
실패하고 아무것도 바뀌지 않는다. 한 사건은 계획(잎 단위인지 항목 단위인지)과 상관없이 같은 양을 센다.
- 호스트(`tests/check.py`의 `server_apps`): 앱 셋의 경로와 격리(같은 이름의 상태, 같은 이름의 보기), 재시작 뒤의 상태. 모듈을
읽지 못하는 앱과 저장소를 열지 못하는 앱은 그 앱만 멈춘다. 실행 중 저장이 불확실해진 앱도 그 앱만 멈춘다. 대기열을 넘긴
앱은 503 `busy`이고 다른 앱은 계속 답한다. 실행량을 넘긴 사건은 422 `work`이고 바뀐 것이 없다.
- 앱 하나의 호스트는 지금과 같다. 모든 검사가 초록이다.
- **통과 조건:** RAM 저장소의 Pi 5에서 잰다. 비교 상대는 앱마다 프로세스 하나다.
- 격리: 앱 A를 64연결의 쓰기로 포화시키는 동안 앱 B(한 연결, 쓰기)의 지연 p99가, 같은 둘을 두 프로세스로 띄웠을 때의 1.5배를
넘지 않는다(실행 스레드 둘).
- 비용: 작은 앱 하나를 더할 때 늘어나는 자기만의 메모리(더티)가 유휴 프로세스 하나(864 KiB)의 4분의 1 미만이다. 앱을
100개까지 더해도 스레드 수는 그대로다.
- 처리량: A와 B의 처리량 합이 두 프로세스일 때보다 10% 넘게 낮지 않다.
- 앱 하나: TPC-C(RAM, W=1, 64터미널)와 은행 질의를 전과 후 번갈아 여섯 쌍 재서 중앙값이 3% 넘게 느려지지 않고, 은행 질의의
명령 수(110의 `count.py`)가 1% 넘게 늘지 않는다.
### 1단계에서 알게 된 것
- **앱이 차례마다 실행 스레드를 옮겨 다니면 비싸다.** 처음 구현은 차례가 끝날 때마다 앱을 줄 끝에 세우고 다른 실행 스레드를
깨웠다. 바쁜 두 앱이 두 프로세스보다 28% 덜 썼다(짧게 잰 한 번). 다른 앱이 기다리지 않으면 같은 실행 스레드가 잇는다.
- **연결 스레드가 앱을 섞으면 프로세스보다 비싸다.** 한 앱의 연결이 연결 스레드들에 흩어지면 넘기고 돌려받는 일이
모두-대-모두가 되어 요청당 CPU가 3분의 1 더 들고, 둘 다 바쁠 때 p99가 커널의 한 틱(4 ms)이 됐다. 집 스레드가 그것을 없앴다.
설계에 없던 것이다.
- **실행량은 쓴 그대로 센다.** 처음 설계는 실제로 도는 일(패스, 범위)을 세려 했다. 패스는 만들어 둔 것을 다음 계산이 다시
쓰므로 같은 요청이 그때그때 다른 양이 된다. 계획과 상관없는 쓴 그대로를 세고, 넘을 일은 시작할 때 거절한다. 레인 대조
3,600건이 계획 있는 앱과 없는 앱의 셈이 같은지 본다.
- **세는 것도 비용이다.** 예산이 없어도 세고 칸마다 까닭을 물었더니 표의 줄마다 26 명령이 늘었다(앱 하나의 1% 기준을 넘었다).
예산이 없으면 세지 않고, 까닭은 실패했을 때만 쓴다.
- **저장의 경계는 저장소의 정체로 가른다.** 쓰기 차례를 경로 문자열로 가르면 한 디렉터리를 두 경로로 열 때 두 스레드가 함께
쓴다(파일 잠금은 프로세스의 것이다). 장치와 inode로 가른다.
측정과 판정은 [111](../research/111-apps-on-one-host.md)에 있다. 네 기준을 모두 채웠다(격리 0.50배, 앱당 120 KiB와 스레드 그대로,
처리량 −0.04%, 앱 하나 +1.2%와 명령 +0.94% 이하). 커밋: Pure `cac499d`(저장소마다의 차례), `38fde9d`(실행량 예산), `d8fcd53`(여러
앱과 실행 스레드), `d95740e`(바쁜 앱은 제 실행 스레드에), `eb9a3a5`(부하 도구), `41502b9`(집 스레드), `aef14c0`(예산이 없으면
세지 않는다).
## 2단계: 앱 사이와 노드 사이
**보냄.** `--apps`의 줄이 앱마다의 배선을 받는다. `send=출력=URL`(여럿), `seal=출력=키`, `reply=출력=입력`. 뜻은 앱 하나의
`--send`, `--seal`, `--reply`와 같다. 보낸 것의 source는 `앱.출력`이다(앱 하나에서는 `--name.출력`). 노드의 키(`--key`)와
받을 보내는 쪽(`--sender`, `--senders`, `--sealed-only`)은 호스트의 것이다.
**받기.** `--link-port` 하나가 모든 앱의 입력을 받는다. 스트림의 input 이름이 `앱/입력`이면 그 앱의 그 입력이다(보내는 쪽 URL은
`pure://호스트:포트/앱/입력`). 012의 형식은 그대로다(이름 1–64 B 안). 멈춘 앱이나 모르는 이름은 "그 이름의 입력이 없다"(REFUSED
1)다. HTTP 전달은 지금처럼 `/앱/inputs/입력`이다.
**호스트 안의 건넴.** `send=출력=앱/입력`(스킴 없이)은 같은 호스트의 앱에 건넨다. 소켓, 봉인, 패킷이 없다.
- 보내는 앱이 저장한 보냄을 출구 하나가 받는다(전달선·링크와 같은 창: 받는 쪽이 가진 것 너머로 65,536건, `--window` 바이트).
보내는 앱의 차례가 저장 뒤에 그 묶음을 받는 앱의 대기열에 일감 하나로 넣는다. 링크가 넘기는 기록과 같은 꼴이다(첫 번호,
건수, source, 저장된 그대로의 기록).
- 받는 앱은 링크의 기록처럼 적용한다. 들은 번호 다음부터, 순서대로, 한 번씩이다. 저장한 뒤 들은 번호를 출구에 알린다. 보내는
앱의 다음 차례가 outbox를 그만큼 다듬는다. 묶음은 출구마다 한 번에 하나만 가 있다.
- 받는 앱의 입력이 모양이 다르면 이름으로 받는다(014): 시작할 때 `pure_module_takes`로 보고, 못 받으면 보내는 앱이 시작하지
않는다. 받는 앱이 메모리 예산으로 받지 못하면(013) 묶음은 돌아오고, 100 ms 뒤에 다시 간다.
- 재시작: 보내는 앱의 outbox에 남은 것이 다시 가고, 받는 앱의 들은 번호가 이미 받은 것을 걸러낸다(링크와 같다).
**2b: 보내는 루프 하나.** 보냄마다의 스레드(전달선 하나, 링크 하나에 하나씩)를 호스트의 보내는 스레드 하나로 모은다. 출력을 가진
앱은 보냄마다 스레드 하나를 더해 왔다. 앱마다 스레드를 두지 않는다는 원칙에 어긋나는 마지막 자리다.
- 루프 하나(`pure-send`, `link/sending.c`)가 epoll 하나에 링크의 UDP 소켓과 전달선의 TCP 소켓, 깨움 하나를 둔다. 보내는 쪽은
루프에 들어오며 자기 바퀴(round)와 기다릴 소켓을 준다. 루프는 원하는 것만 돈다. 소켓이 준비됐거나, 밀렸거나, 원한 때가 온
것이다. 기다리는 시간은 그 때들 가운데 가장 이른 것이다.
- 소유자는 출구에 보냄을 쌓고, 차례의 것을 다 쌓은 뒤 출구마다 한 번 민다(`pure_link_push`, `pure_wire_push`). 미는 것이 루프를
깨운다(자고 있을 때 한 번). 쌓기마다 깨우면 루프가 기록을 하나씩 따라잡으며 작은 패킷을 낸다(아래).
- 링크(2b-1): 지금 스레드가 한 바퀴에 하던 일을 루프의 한 바퀴에서 링크마다 한다. 답 받기, 시각, 보내기다. 받는 쪽을 다시 찾는
이름 찾기(DNS)는 그 자리에서 기다린다. 프로브 셋이 답을 못 받았을 때뿐이라 드물지만, 그동안 다른 보냄도 멈춘다. 이것을 밝힌다.
- 전달선(2b-2): 비블로킹 HTTP/1.1 클라이언트의 상태기다(연결, TLS, 요청 쓰기, 답 읽기, 쉼). 바퀴는 소켓이 받는 데까지 가고,
소켓이나 마감(아무것도 움직이지 않은 10초)을 기다린다. 답은 오는 대로 읽는다. 머리를 다 받고, 크기가 적힌 몸은 세어 내려가고,
조각난(chunked) 몸은 자랄 때마다 처음부터 다시 읽는다(드물고 작다). TLS는 crypto의 함수 표(판 6)에 핸드셰이크의 걸음을
더했다. 이름 찾기는 연결할 때 기다린다(링크와 같다).
**2b의 착수 기준.**
- 바꿀 결정: 보냄을 스레드 하나의 루프로 돌릴지다. 아래를 채우지 못하면 보냄마다의 스레드를 둔다.
- 최소 확인: 링크·전달·답·봉인·`server_apps_links`의 검사가 모두 초록. 출력이 링크 열과 전달선 열인 호스트의 스레드가 출력이 없는
호스트보다 둘(보내는 루프, 링크를 받는 스레드)만 많다(지금은 스물하나).
- 통과 조건(RAM의 Pi 5, 전과 후 번갈아): 112의 링크 측정(두 앱, 루프백 링크 하나)과 같은 방식의 전달선 측정(HTTP로 받는 앱)에서
처리량의 중앙값이 5% 넘게 낮지 않다. 링크 넷이 함께 보낼 때 처리량의 합이 10% 넘게 낮지 않다.
### 2b에서 알게 된 것
- **깨움의 단위가 문제였다.** 루프만 바꾼 첫 구현(`32f96b3`)은 링크 넷을 초당 58만 건에서 27만 건으로 늦췄다. 쌓기마다 깨우니
루프가 소유자의 쌓기를 하나씩 따라잡고 잠들기를 되풀이했다. 루프가 기다린 번이 17.7만, 링크의 바퀴가 70만, 패킷이 9.2만이었다.
차례마다 한 번 밀고 원하는 링크만 돌게 하자(`6d3c3c2`) 링크 넷이 80만 건이 됐다. 같은 밀기를 링크마다 스레드인 전 구현에
붙여도 80만 건이다. 빨라진 것은 밀기 덕이고, 루프 자체의 값은 이 측정의 흔들림(1–3%) 안이다.
- **결과.** 링크와 전달선 스무 개를 가진 호스트의 스레드가 24개에서 5개다(출력이 없으면 3개). 전달선 하나 +0.7%, 넷 −2.3%(보냄
하나의 CPU는 같다), 링크 하나 +63%, 넷 +67%. 검사가 모두 초록이다. 커밋: Pure `32f96b3`(링크의 루프), `6d3c3c2`(밀기),
`b6bcd4c`(루프를 따로), `eb3812a`(전달선의 상태기), `56eb228`(측정).
- **남은 값.** 이름 찾기(DNS)가 루프를 멈추고, 전달선 넷의 요청이 차례로 쓰이고 읽힌다. 둘 다 측정에 보일 때 다룬다.
## 2단계의 착수 기준
- **바꿀 결정:** 같은 호스트의 앱 사이를 링크(소켓, 봉인)가 아니라 안쪽 출구로 건넬지다. 아래를 채우지 못하면 같은 호스트의
앱끼리도 `pure://`로 보낸다.
- **최소 확인**(`tests/check.py`의 `server_apps_links`):
- 한 호스트의 앱이 같은 호스트의 앱(안쪽 출구, 이름으로 받는 모양 하나 포함)과 다른 호스트의 앱(`pure://…/앱/입력`)에
보낸다. 모든 보냄이 한 번씩, 순서대로 도착한다. 보내는 호스트를 `kill -9`하고 다시 띄워도 잃거나 겹친 것이 없다.
- 받는 앱이 메모리 예산으로 가득 차면 보내는 앱의 outbox가 붙든다. 받는 앱이 비우면 이어서 간다. 잃는 것이 없다.
- 앱 하나의 호스트는 지금과 같다(링크, 전달, 답의 검사가 모두 초록).
- **통과 조건:** RAM의 Pi 5에서, 한 호스트의 두 앱 사이를 안쪽 출구와 루프백 `pure://` 링크로 잰다(보내는 앱에 64연결의 쓰기,
쓰기마다 보냄 하나).
- 받는 앱에 저장된 보냄의 초당 수가 링크보다 낮지 않다.
- 저장된 보냄 하나에 드는 호스트의 CPU가 링크의 절반 이하다.
### 2단계에서 알게 된 것
- **받는 쪽은 새로 쓸 것이 없었다.** 안쪽 출구는 링크가 넘기는 기록과 같은 꼴(첫 번호, 건수, source, 저장된 그대로의 기록, 이름으로
받을 때의 필드)로 넘긴다. 받는 앱은 링크의 기록을 적용하던 길로 들은 번호 다음부터 적용하고, 저장 뒤의 알림만 갈래가 하나
늘었다. 보내는 쪽도 전달선·링크와 같은 출구의 꼴(쌓은 번호, 저장된 번호, 쥔 바이트, 창)이다.
- **출력은 한 길로만 간다.** 한 출력을 두 출구에 걸면, 빠른 쪽이 저장된 만큼 outbox를 다듬어 느린 쪽이 아직 보내지 않은 것을
잃는다. 같은 출력을 두 번 배선하면 그 앱이 시작하지 않는다.
- **결과.** 안쪽 출구가 링크의 두 배(초당 114만 건)를 나르고, 보냄 하나에 CPU를 40%(1.17 µs) 쓴다. 다른 호스트의 앱, 같은
모양과 이름으로 받는 모양, 메모리가 찬 앱에 보내는 것이 `kill -9`를 지나도 한 번씩 순서대로 간다. 커밋: Pure `8f75b32`(앱마다의
배선, 링크 포트 하나), `b7b86df`(안쪽 출구).
## 3단계: 앱의 수명
**구성 파일이 호스트의 모습이다.** `--apps FILE`은 시작할 때만 읽지 않는다. 호스트가 SIGHUP을 받으면 파일을 다시 읽고, 달리는
앱들을 파일의 모습에 맞춘다.
- 새 줄의 앱은 시작한다. 1단계의 시작과 같다(모듈 검증, 저장소, 배선, 실패하면 그 앱만 멈추고 까닭을 남긴다).
- 없어진 줄의 앱은 멈춘다(아래의 순서). 그 뒤로 그 경로는 404 `no_app`이다.
- 바뀐 줄의 앱은 그 자리에서 바뀐다. 모듈 파일의 내용, 예산, 배선 가운데 하나라도 바뀐 줄이다. 받은 일을 다 적용하고 저장한
뒤, 메모리의 상태를 그대로 두고 모듈을 바꾼다(`pure_app_replace`: 상태와 outbox의 보냄을 이름으로 옮긴다). 저장소는 열린
채이고, 출구만 새 모듈로 다시 만든다. 새 모듈에 상태가 들어가지 않으면 그 앱은 멈추고 까닭을 남긴다.
- 저장소가 바뀐 줄의 앱, 저장이 불확실해진 앱은 멈추고 저장소에서 다시 연다(상태는 이름으로 이어받는다, `server_upgrade`).
- 그대로인 줄의 앱은 건드리지 않는다. 멈춰 있거나(시작하지 못했거나) 불확실해진 앱만 다시 시작한다. 저장소에서 다시 연다.
- 다 맞추면 시작할 때와 같은 꼴의 줄 하나를 stdout에 낸다(`{"reading":N,"apps":[…]}`). 앱마다 상태와 까닭, 그리고 이번에 한
일이다(kept, changed, restarted, started, removed).
- 파일을 읽지 못하거나 틀린 줄이 있으면 아무것도 바꾸지 않고 까닭을 낸다.
HTTP로 앱을 더하는 길을 두지 않는 까닭은 인증이다. 그 길은 경로의 인증(3b)을 먼저 요구한다. 신호는 프로세스의 주인만 보낼 수 있고,
파일은 호스트의 구성을 그대로 남긴다(다시 띄워도 같은 모습이다). 다시 읽기는 호스트의 스레드 하나(`pure-apps`)가 한다. 연결
스레드와 실행 스레드는 기다리지 않는다.
**멈추는 순서.** 앱 하나를 멈추는 것은 이렇다(그 자리에서 바꿀 때는 1–3까지 하고, 모듈을 바꾼 뒤 이어 간다).
1. 새 일을 받지 않는다. 그 앱의 경로는 503 `stopped`다. 링크와 안쪽 출구의 기록은 받지 않는다(보내는 쪽이 쥐고 다시 보낸다).
2. 소유자가 이미 받은 일을 다 적용하고, 저장하고, 답한다.
3. 그 앱의 보냄(전달선, 링크, 안쪽 출구)을 닫는다. 아직 가지 않은 것은 outbox에 남는다. 다른 앱에 가 있는 묶음은 돌아올 때까지
기다린다.
4. 저장소를 닫는다. 다른 프로세스가 열 수 있다.
**모듈이 바뀌는 사이의 일.** 요청의 경로는 연결 스레드가 해석하고 적용은 실행 스레드가 한다. 그 사이에 모듈이 바뀌면 입력의
번호와, JSON에서 이미 바꾼 페이로드의 배치가 새 모듈과 맞지 않는다.
- 요청은 해석한 모듈을 들고 다닌다. 소유자는 지금의 모듈과 다른 모듈로 해석된 요청을 적용하지 않고 503 `stopped`로 답한다.
클라이언트가 다시 보내면 새 모듈로 해석된다. 보기도 같다. 스냅숏의 모듈과 해석한 모듈이 다르면 503이다.
- 링크의 스트림은 받는 쪽 입력을 처음에 한 번 푼다(번호, 모양이 같은지, 이름으로 받는지). 앱이 다른 모듈로 다시 시작하면
링크를 받는 쪽에 그 앱의 스트림을 다시 풀게 한다. 그 사이에 넘어온 묶음은 받지 않는다(보내는 쪽이 다시 보낸다).
- 안쪽 출구는 받는 앱의 모듈이 바뀌면 그 입력을 이름으로 다시 찾는다(모양, 이름으로 받기). 찾지 못하면 쥐고 까닭을 남긴다.
- 모듈이 그대로면(예산이나 배선만 바뀌었거나 불확실해서 다시 연 것이면) 해석한 것이 그대로 맞다. 그대로 적용한다.
**바뀐 모듈의 메모리.** 스냅숏과 연결 스레드의 리더가 모듈을 가리킨다. 그래서 바뀐 모듈은 호스트가 멈출 때까지 놓지 않는다.
한 번 바꿀 때 모듈 하나(18.7 KB와 모듈의 바이트)다. 잰다.
**앱의 자리.** 앱의 자리는 호스트가 시작할 때 잡는다(`--app-slots N`, 기본 256, 처음의 앱 수보다 작지 않다). 자리는 이름과
소유자, 그리고 연결 스레드마다의 리더다. 이름은 자리를 떠나지 않는다. 지운 앱의 이름을 다시 쓰면 그 자리로 간다. 연결 스레드가
이름으로 자리를 찾는 동안 이름이 바뀌지 않기 때문이다. 쓰지 않은 자리는 손대지 않은 메모리라 상주하지 않는다. 자리가 다 차면
새 앱은 까닭 "no slot"으로 멈춘다.
**실행 스레드.** 달리는 앱이 실행 스레드보다 많아지면 CPU 수까지 하나씩 더한다(1단계의 규칙과 같다. 앱 수와 CPU 수 가운데
작은 것이다). 줄이지는 않는다. 일 없는 실행 스레드는 잔다.
**하지 않는 것.**
- 끊김 없는 교체(새 모듈을 옆에서 띄우고 넘기기). 바꾸는 동안 그 앱은 503을 답한다. 그 자리에서 바꾸면 1 ms 남짓이다(기록의
모양이 바뀌는 큰 목록은 목록을 다시 쓰는 동안).
- HTTP로 앱을 더하고 바꾸는 길. 경로의 인증(3b) 뒤에 따로 정한다.
**3b: 경로의 인증.** 앱의 경로를 누가 부를 수 있는지다(CHECKPOINT의 남은 것). 3단계의 수명과 따로 설계한다.
### 3단계에서 알게 된 것
- **멈추고 다시 열면 다시 읽기가 전부였다.** 처음 구현(`12878cf`)은 바뀐 앱을 멈추고 저장소에서 다시 열었다. 항목 100만 개의
앱이 150.6 ms를 답하지 못해 호스트를 다시 띄우는 것(149.7 ms)과 같았다. 그 가운데 142 ms가 저장소를 다시 읽는 일이었다.
저장소가 그대로인 앱을 그 자리에서 바꾸자(`7daf406`) 1.12 ms가 됐다(1,024개 1.06 ms). 기록의 모양이 바뀌는 모듈은 목록을 새
모양으로 다시 쓰느라 171.5 ms다(다시 띄우면 332 ms).
- **결과.** 한 앱을 거듭 바꾸는 동안 다른 앱의 p99는 0.97배, 앱 하나의 호스트는 TPC-C +0.7%와 명령 수 +0.02% 이하, 모듈을 바꿀
때마다 19 KB가 남는다. 파일을 읽는 스레드가 하나 늘었다(앱 100개에 스레드 넷). 커밋: Pure `14c75d0`(해석한 모듈과 앱의 자리),
`1d892e2`(링크 스트림의 입력을 다시 찾기), `12878cf`(SIGHUP과 멈추고 다시 열기), `7daf406`(그 자리에서 바꾸기), `f5e9db5`와
그 뒤(측정).
- **코드를 읽어 찾은 것.** 보내는 앱이 멈추는 동안 받는 앱의 차례가 놓인 출구를 읽던 자리, 시작하기 전의 앱의 락을 다른 앱의
출구가 쓰던 것. 그때의 검사는 둘이 있는 채로 지났다(보내는 앱을 바꾸지 않았다). 고친 뒤 검사가 그 길을 지나게 넓혔다.
## 3단계의 착수 기준
- **바꿀 결정:** 달리는 호스트의 앱을 SIGHUP과 구성 파일로 더하고, 멈추고, 바꿀지다. 아래를 채우지 못하면 앱을 바꿀 때 호스트를
다시 띄운다(지금과 같다).
- **최소 확인**(`tests/check.py`의 `server_apps_lifecycle`):
- 한 호스트에서 앱을 더하면 그 앱이 바로 답한다. 지우면 그 경로가 404 `no_app`이고, 그 저장소를 다른 프로세스가 연다. 모듈을
바꾸면 상태를 이름으로 이어받는다. 그동안 다른 앱의 요청은 하나도 실패하지 않는다.
- 모듈을 바꾸는 동안에도 그 앱에 쓰기를 계속 보낸다. 200을 받은 쓰기는 모두 한 번씩 적용되고, 503 `stopped`를 받은 쓰기는
적용되지 않는다(합이 맞는다).
- 바뀌는 앱이 같은 호스트의 앱(안쪽 출구)과 다른 노드(링크)에서 받는 동안 입력의 번호가 바뀌는 모듈로 바꿔도, 보낸 것이 모두 한
번씩 순서대로 도착한다. 보내는 앱을 바꿔도 outbox의 것이 이어 간다.
- 저장소를 열지 못해 시작하지 못한 앱이, 고친 뒤의 SIGHUP에 시작한다. 틀린 줄이 있는 파일은 아무것도 바꾸지 않는다.
- 다시 읽은 뒤에 `kill -9`하고 다시 띄워도 상태와 앱들이 파일 그대로다.
- 1·2단계의 검사가 모두 초록이다.
- **통과 조건**(RAM의 Pi 5):
- 바꾸는 앱이 503을 답하는 시간(첫 503부터 첫 200까지)이 그 호스트를 다시 띄울 때 그 앱이 답하지 못하는 시간보다 짧다. 상태가
작은 앱(항목 1,000개)과 큰 앱(항목 100만 개)에서 각각 잰다.
- 한 앱을 거듭 바꾸는 동안 다른 앱(한 연결)의 p99가 바꾸지 않을 때의 1.5배를 넘지 않는다.
- 앱 하나의 호스트는 그대로다. TPC-C와 은행 질의를 전과 후 번갈아 재서 중앙값이 3% 넘게 느려지지 않고, 은행 질의의 명령 수가
1% 넘게 늘지 않는다.
- 바꿀 때마다 남는 메모리를 잰다(기준은 두지 않고 밝힌다).
## 하지 않는 것
- 이벤트를 중간에 멈추고 다른 앱에 넘기는 선점. 이벤트는 원자적이고 끝까지 돈다. 그 대신 실행량 예산이 한 이벤트의 길이를,
차례의 요청 수가 한 차례의 길이를 묶는다.
- 실행 스레드에 앱을 고정하거나 앱마다 무게를 주는 스케줄링. 차례 줄 하나로 시작하고, 측정이 요구할 때 더한다.
- 앱들의 저장을 한 저장소, 한 동기화로 묶는 것. 동기화가 비싼 기기에서 이득일 수 있지만, 한 앱의 망가진 저장소가 모두를
멈추게 된다. 실패 경계를 먼저 두고, 묶음은 따로 잰다.
|