diff options
| -rw-r--r-- | CHECKPOINT.md | 3 | ||||
| -rw-r--r-- | docs/design/017-apps-on-one-host.md | 51 |
2 files changed, 43 insertions, 11 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md index ff1f68f..23f7542 100644 --- a/CHECKPOINT.md +++ b/CHECKPOINT.md @@ -18,6 +18,7 @@ | HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문과 그 안의 그룹 객체를 제자리에서 읽는다, 라우터 코드 없음). 앱의 `send`는 이벤트와 함께 저장되고, `--send 출력=URL`로 다른 앱이나 HTTP 끝점에 순서대로 묶어 전해지며, 받는 앱은 번호마다 한 번 적용한다(서버를 죽였다 살려도 정확히 한 번). `--seal 출력=공개키`면 받는 노드의 키로 봉인해(HPKE, 전달선마다 세션 하나, 보내는 노드의 키로 인증) 사이의 중계가 읽지도 바꾸지도 못하고, 받는 노드는 `--sender 이름=키`로 보낸 이를 가린다(봉인 전달 462k/s, 봉인 없이 468k/s). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md), [086](docs/research/086-sealed-sends.md), [088](docs/research/088-sealed-sessions.md) | | 성능(Pi 5) | 2026-09-27, 같은 기기에서 axum·drogon과 함께 쟀다. 문서 서버(한 스레드): 64 B GET 139k/s(CPU 1초당 140k; axum 127k·63k, drogon 147k·63k), 1 MiB GET 2,362/s(axum 1,304, drogon 754), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스: 내구성 있는 쓰기 64연결 21.3k/s(axum·drogon 내구 약 200/s), 합계 GET 126k/s·두 스레드 153k(axum 136k, drogon 151k), 20행 페이지 두 스레드 148k(axum 93k, drogon 138k), 쓰기와 섞인 읽기 119k/s(axum·drogon 내구 1.1–1.4k/s). 유휴 RSS 2.3 MiB(axum 2.8, drogon 10.9), 최대 3.2–5.8 MiB(axum 6.6–9.7, drogon 11.9–78.8) | [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [081](docs/research/081-tail-records.md), [087](docs/research/087-small-reads-and-idle-memory.md) | | 노드 사이 전달 | 연결 없는 링크가 기본이다(`--send 출력=pure://호스트:포트/입력`, 2026-09-29 채택). 두 노드의 키로 첫 패킷부터 봉인해 보내고 핸드셰이크가 없다. 어느 쪽을 kill -9 해도, 받는 노드의 저장소를 다른 주소로 옮겨도 다음 패킷으로 이어지며 잃거나 겹친 기록이 없다(기기 1만 개가 초당 2,000건을 보내는 중에 저장이 멈춘 100 ms 칸 0). 쉬는 링크는 받는 노드 메모리 132 B(quinn 연결의 1/289). 에폭마다 키를 바꿔 전방 비밀성을 얻는다. 전달당 CPU는 1 KiB 이상에서 quinn·ngtcp2보다 25 % 넘게 적고 61 B에서 비긴다. 손실·재정렬·병목 경로(M6)에서 재정렬 아래 quinn·ngtcp2보다 많이 나르고, 무작위 손실 아래 같으며, 깨끗한 병목에서 틀의 바이트로 3–5 % 적다. 명세가 있고, 명세만 보고 쓴 파이썬 받는 쪽이 노드와 맞붙는다. 받는 입력은 필드를 이름으로 받는다(순서가 달라도, 받는 쪽 필드가 더 있어도 되고, 보낸 필드를 버리지는 않는다). 그래서 두 노드가 함께 쓰는 보드를 한 노드씩 올려도 멈추지 않고, 기다리는 보내기도 새 필드로 옮겨진다([105](docs/research/105-growing-a-shared-board.md), [014](docs/design/014-fields-by-name.md)) | [설계 011](docs/design/011-pure-transport.md), [명세 012](docs/design/012-link-protocol.md), [093](docs/research/093-connectionless-link.md)–[102](docs/research/102-adoption-and-spec.md) | +| 여러 앱 | 한 호스트가 앱 여럿을 품는다(`pure-serve --apps`, 경로는 `/앱/…`). 앱마다 스레드를 두지 않고 정해진 실행 스레드가 차례로 돌리며, 앱마다 연결이 모이는 집 연결 스레드가 있다. 앱마다 실행량(쓴 그대로의 명령 수, 넘으면 422 `work`)·대기열(503 `busy`)·메모리 예산이 있고, 모듈이나 저장소를 열지 못한 앱은 그 앱만 멈춘다. 앱 하나에 120 KiB(프로세스 하나의 PSS의 9%), 앱 100개에도 스레드 셋. 한 앱이 몰릴 때 다른 앱의 p99는 앱마다 프로세스일 때의 0.5배, 둘 다 바쁠 때 처리량과 p99는 같다 | [017](docs/design/017-apps-on-one-host.md), [111](docs/research/111-apps-on-one-host.md) | | 메모리 한도 | 앱마다 상태의 예산이 있다(`--memory`). 목록의 항목, 텍스트, outbox, 들은 번호를 저장 형식의 바이트로 센다(어느 기기에서나 같다). 예산을 넘기며 늘리는 사건은 거절하고(503 `memory`) 줄이는 사건은 받는다. 받는 쪽이 늦으면 outbox가 차서 클라이언트까지 역압이 가고, 가득 찬 받는 쪽은 링크로 알려 보내는 쪽이 다시 보내지 않는다. 답(`--reply`)은 자기 보내기의 자리를 쓴다. 상태 4 MB를 준 노드는 부하 아래 상주 9.6–9.8 MiB에 멈춘다(예산 없이 30초에 45.9 MiB). 오래 붙든 스냅숏은 그때 상태 한 벌만 쥔다(전: 30초에 678 MiB) | [설계 013](docs/design/013-memory-budget.md), [104](docs/research/104-where-memory-goes.md) | | 표면과 메신저 | 사람을 위한 표면이 노드 안에서 돈다(`pure-serve --surface`): 마지막 저장의 스냅숏을 제자리에서 읽고, 친 키와 글자는 HTTP 요청과 같은 줄로 소유자에게 간다. Vega의 디스플레이·픽셀이 그 표면이다. 두 노드의 메신저(Vega `examples/messenger.aup`)는 서로에게 봉인·인증해 보내고, 한 줄이 상대 노드에 보이기까지 RAM 저장소에서 p50 0.79 ms·p99 1.1 ms, SD 카드에서 p50 3.5 ms(꼬리는 SD의 동기화) | [설계 010](docs/design/010-surfaces.md), [089](docs/research/089-messenger.md) | | 표 크기의 상태 | 목록이 영속 B+트리다(변경·키 탐색·저장본 위치 O(log n), 스냅숏 O(1), 1,600만 항목, 런타임 상태 1 GiB). pgbench의 TPC-B 같은 이체(세 표의 행과 이력, 한 트랜잭션)가 10만 계좌에서 RAM 24.5k/s(전: 3만 계좌 3.2k/s), SD 카드 내구 1,339/s(SQLite WAL FULL 84–153/s) | [090](docs/research/090-tables.md) | @@ -31,7 +32,7 @@ +9%, 상주 −10%, [107](docs/research/107-snapshot-nodes-reused.md)). 남은 가장 큰 몫은 항목 찾기다. 키로 나눈 소유자는 그다음에 본다. 대신 압축한 파일이 한 주기의 0을 미리 가져 커밋이 새 블록을 받지 않는다(저장소 커밋 +21%, 저널 커밋 −59%, [085](docs/research/085-cycle-sized-files.md)). -남은 것: macOS 검증, 압축을 없애는 제자리 재사용 저장소(085 다음), 응답을 기다리는 출력의 묶음 전달(083), 앱 +남은 것: macOS 검증, 한 호스트의 앱 사이·노드 사이의 보냄과 앱의 수명(017의 2·3단계), 압축을 없애는 제자리 재사용 저장소(085 다음), 응답을 기다리는 출력의 묶음 전달(083), 앱 라우트의 인증·TLS(문서 피어의 TLS는 있다), aup·Vega의 배포 묶음, 캐시보다 큰 목록을 찾는 걸음(109: 미리 가져오기로는 줄지 않았다. 한 사건의 조회를 겹치거나 구조를 바꿔야 한다). diff --git a/docs/design/017-apps-on-one-host.md b/docs/design/017-apps-on-one-host.md index c9ee199..7167e2e 100644 --- a/docs/design/017-apps-on-one-host.md +++ b/docs/design/017-apps-on-one-host.md @@ -1,6 +1,6 @@ # 017 — 한 호스트의 여러 앱: 차례, 예산, 실패 경계 -작성일: 2026-10-01. 상태: 설계. 1단계의 착수 기준을 적었다. +작성일: 2026-10-01. 상태: 1단계 구현, 검사로 고정하고 쟀다([111](../research/111-apps-on-one-host.md)). 2·3단계는 설계. [외부 리뷰 004](../outside-feedback/feedback-004.md)의 "구현 방향에 대한 평가" 셋째에서 시작한다. 작은 OS의 운영 모델( [007](007-execution-domains.md))을 Pure가 실제로 갖게 하는 단계다. @@ -47,26 +47,36 @@ counter apps/count.pmod stores/counter `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=`).** 이벤트 하나, 그리고 보기·질의 하나의 계산마다 센다. 단위는 명령이다. +**실행량 예산(`work=`).** 이벤트 하나, 그리고 보기·셀·걸러진 표의 필터 하나의 계산마다 센다. 단위는 명령이고, 쓴 그대로 +센다. -- 핸들러, 보기, 열, 필터, 키의 블록이 한 번 돌면 그 블록의 길이를 센다. -- 걷기(EACH)와 집계(FOLD), 걸러진 표의 필터는 도는 항목마다 블록 길이를 센다. 잎 단위로 돌아도(레인) 같다. 범위로 좁혀 - 일부만 돌면 도는 만큼이다. 모양 있는 집계(그룹·순위, 091)는 패스를 만들 때 항목 수 × 블록 길이를 한 번, 줄마다 키 블록을 - 센다. 실제로 도는 일을 결정적으로 센다. 명령마다 세지 않으므로 해석기의 안쪽 루프에 비용이 들지 않는다. +- 핸들러와 보기와 열의 블록이 돌기 시작하면 그 블록의 길이를 센다. +- 걷기(EACH)와 집계(FOLD), 걸러진 표의 필터는 시작할 때 블록 길이 × 그 목록(조각을 받으면 그 조각)의 항목 수를 한 번에 + 센다. 계획이 무엇을 돌든 같다. 잎 단위(레인)든, 범위로 좁히든, 모양 있는 집계(그룹·순위, 091)의 패스를 다시 쓰든 쓴 + 그대로다. 패스는 만들어 둔 것을 다음 계산이 다시 쓰므로, 실제로 도는 일을 세면 같은 요청이 그때그때 다른 양이 된다. +- 넘을 일은 시작하기 전에 거절된다. 명령마다 세지 않으므로 해석기의 안쪽 루프에 비용이 들지 않는다. 예산이 없으면 세지도 + 않는다. - 넘으면 그 사건은 새 실패 `work`로 실패하고, 실패한 사건처럼 아무것도 바뀌지 않는다. HTTP로는 422 `work`다. 상태가 같으면 다시 해도 같으므로 "잠시 뒤"(503)가 아니다. 앱이 일을 나눠야 한다(조각, 범위). - 텍스트를 잇고 고치는 일은 바이트에 비례하고 명령으로 세지 않는다. 그 크기는 메모리 예산이 묶는다. @@ -82,7 +92,8 @@ counter apps/count.pmod stores/counter - 시작: 모듈을 읽지 못하거나 검증에 실패하거나 저장소를 열지 못한 앱(저장된 상태를 읽지 못하는 것도)은 멈춘 앱이 된다. 나머지는 시작한다. -- 실행 중: 저장이 불확실해진 앱(지금의 `uncertain`)은 그 뒤의 사건을 거절한다. 그 앱만이다. +- 실행 중: 저장이 불확실해진 앱(지금의 `uncertain`, 앱마다의 깃발이다)은 그 뒤의 사건을 거절한다. 그 앱만이다. 다른 + 프로세스가 한 앱의 저장소를 쥐면 그 앱만 저장하지 못한다(503 `store_busy`, 적용한 것 없음). - 끝: 앱마다 센 것(사건, 차례, 거절한 것과 까닭, 저장)을 멈출 때 알린다. ## 단계 @@ -119,6 +130,26 @@ counter apps/count.pmod stores/counter - 앱 하나: 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`(예산이 없으면 +세지 않는다). + ## 하지 않는 것 - 이벤트를 중간에 멈추고 다른 앱에 넘기는 선점. 이벤트는 원자적이고 끝까지 돈다. 그 대신 실행량 예산이 한 이벤트의 길이를, |
