From a51232af741cb952d7deb72f238c583919ababc0 Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sat, 26 Sep 2026 22:56:47 +0100 Subject: Show views from the last save's snapshot on connection threads (080) The owner publishes a snapshot of the saved states after every save, before it hands back the answers; connection threads take the newest one each round and show views from it with a reader app of their own. A read made after a write's answer sees the write, nothing unsaved or undone is ever shown, and reads no longer wait while the owner applies events or syncs. Apps with states always get an owner thread, even with one connection thread. A round's inputs go to the owner first; its views are shown and then answered together (answering each one as it was shown made one thread's 20-row page 8% slower). On the Pi 5, with 8 connections posting durable scores and 56 reading the total, reads went from 10-16k/s at p99 13-29 ms to 107-149k/s at p99 0.6-0.7 ms, and posts stayed in range; axum and drogon, which sync under their lock, read 1.1-1.3k/s at p99 111-156 ms. A snapshot of a changed 1,024-item list costs 1.2 us (65,536 items: 87 us) against a 3-4 ms save. --- docs/research/080-snapshot-reads.md | 135 ++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/research/080-snapshot-reads.md (limited to 'docs/research') diff --git a/docs/research/080-snapshot-reads.md b/docs/research/080-snapshot-reads.md new file mode 100644 index 0000000..1300632 --- /dev/null +++ b/docs/research/080-snapshot-reads.md @@ -0,0 +1,135 @@ +# 080 — 읽기는 저장된 스냅숏에서: 소유자가 동기화해도 기다리지 않는다 + +2026-09-26. [로드맵](../roadmap.md) 단기 3 "읽기 독립화". 설계와 구현 기록. + +착수 기준: + +- 바꿀 결정: 앱 보기를 읽는 요청이 소유자의 묶음(쓰기와 동기화)을 거칠지, 연결 스레드가 마지막으로 저장된 상태를 + 직접 읽을지. +- 남은 질문: 쓰기가 이어지는 동안 읽기는 동기화 한 번(SD에서 수 ms)만큼 기다렸다. 읽기 먼저(소유자의 라운드에서 + 읽기를 쓰기 앞에 두기)로 p99는 55 → 13 ms가 됐지만, 소유자가 동기화에 묶인 동안 도착한 읽기는 여전히 기다린다. + 저장된 상태의 읽기 전용 사본을 게시하면 읽기가 동기화와 무관해지는가? 그 사본을 만드는 비용은? +- 최소 확인: 쓰기 8연결과 읽기 56연결을 섞은 부하에서 읽기의 p50·p99와 두 처리량을 전후로 잰다. 읽기가 저장되지 않은 + 상태를 보지 않는다는 것, 저장 실패로 되돌린 상태를 보지 않는다는 것을 검사한다. +- 중단 기준: 읽기가 한 번이라도 저장되지 않은 상태를 보이면 되돌린다. + +## 의심한 관례 + +| 관례 | 이 설계 | +|---|---| +| 읽기와 쓰기가 한 상태를 나누면 잠금을 나눈다. 쓰기가 동기화하는 동안 읽기는 기다린다 | 저장이 끝날 때마다 저장된 상태의 불변 스냅숏을 게시한다. 읽기는 스냅숏을 잡는 순간 말고는 소유자와 아무것도 나누지 않는다 | +| 스냅숏은 상태의 복사라서 상태가 클수록 비싸다 | text는 저장소 매핑의 그 버전을 가리키고, 리스트는 항목을 공유한다. 바뀐 항목만 다음에 바뀔 때 복사된다 | +| 여러 버전을 읽는 것(MVCC)은 데이터베이스의 기능이다 | 앱이 선언한 상태 전부(수, text, 리스트)가 버전을 가진다. 앱 코드도 모듈 형식도 그대로다 | + +## 설계 + +- **스냅숏은 읽기 전용 앱 상태다.** 저장이 성공하면 `pure_app_snapshot`이 수·커서·인덱스 값의 사본, text 상태마다 + 얼린 draft, 리스트마다 공유 사본으로 스냅숏을 만든다. 바뀌지 않은 리스트는 앞 스냅숏의 사본을 그대로 쓴다(리스트의 + 번호와 변경 번호가 같을 때). 저장을 기다리는 변경이 있거나, text가 저장소에 다 있지 않으면(상태를 맡는 저장 호스트가 + 없을 때) 만들지 않는다. +- **text는 복사하지 않는다.** 저장 직후 text draft의 버전은 모두 저장소 매핑에 있다. `pure_draft_freeze`는 그 버전을 + 가리키는 읽기 전용 draft를 만들고 매핑을 참조한다. 트리 읽기는 불변 트리를 읽기만 하므로 여러 스레드가 동시에 읽을 + 수 있고, 닫기는 매핑 참조를 놓는 것(원자적)이라 어느 스레드에서나 된다. 보기의 대여([079](079-lent-views.md))도 + 그대로 된다. +- **리스트는 항목을 공유한다.** 공유 사본은 항목 포인터 배열만 복사한다. 항목마다 만들어질 때의 공유 횟수를 적어 + 두고, 마지막 공유 전에 만들어진 항목은 다시 바꾸지 않는다. 앱이 그런 항목의 필드를 바꾸면 항목을 복사해 바꾸고, + 되돌리기 기록은 옛 항목을 가진다. 커밋된 뒤 리스트가 놓은 공유 항목(지운 것, 복사로 바뀐 것, 교체나 멈춤으로 + 버린 리스트의 것)은 해제하지 않고 모아 둔다. +- **스냅숏은 오래된 것부터 사라진다.** 스냅숏은 다음 스냅숏을 붙잡는 사슬이다. 어떤 스냅숏이 최신이던 동안 놓인 + 항목은 그 스냅숏과 그보다 오래된 스냅숏만 볼 수 있으므로, 그 스냅숏과 함께 해제된다. 참조 수는 원자적이고, 마지막 + 참조를 놓는 스레드(대개 연결 스레드)가 해제한다. +- **읽는 쪽은 자기 앱을 가진다.** 연결 스레드마다 스냅숏에 묶인 `PureApp`(계산 공간과 걸러진 행의 캐시)을 두고 + (`pure_app_read`), 기존 보기 함수(`pure_app_view`, `pure_app_cell` …)를 그대로 쓴다. +- **게시 순서.** 저장 → 스냅숏 게시 → 답 돌려주기. 연결 스레드는 라운드마다, 소유자가 돌려준 답을 처리한 뒤에 최신 + 스냅숏을 잡는다. 그래서 쓰기의 답을 받은 뒤의 읽기는 그 쓰기를 본다(한 연결에서도, 답을 전해 들은 다른 연결에서도). + 저장이 실패하면 앱은 마지막 저장의 상태로 돌아가고, 그것은 이미 게시된 스냅숏과 같다. 스냅숏을 만들지 못하면 게시를 + 거두고 보기도 소유자가 답한다. +- **상태를 가진 앱은 언제나 소유자를 둔다.** 연결 스레드가 하나여도 소유자 스레드가 따로 있어 동기화가 읽기를 막지 + 않는다. 연결 스레드는 라운드의 입력과 문서 요청을 먼저 소유자에게 넘기고(소유자는 그동안 적용하고 저장한다), 보기를 + 모두 평가한 뒤 답을 모아 보낸다. 문서 읽기(`GET /objects`)는 지금처럼 소유자가 답한다(저장소가 이미 버전으로 읽는다). + +## 장치가 말한 것 + +Raspberry Pi 5, 서버는 CPU 0–2, 부하는 CPU 3, 저장소는 SD 카드의 ext4. 점수판 앱([077](077-apps-as-http-services.md)), +`run.py --suite app`. `pure-serve-N`은 연결 스레드 N개와 소유자 스레드 하나다. + +**쓰기와 읽기를 함께** (`mixed`: 내구성 점수 POST 8연결과 합계 GET 56연결을 부하 생성기 둘로, 5초, 3회전 중앙값): + +| 서버 | POST/s | GET/s | GET p50 | GET p99 | +|---|---:|---:|---:|---:| +| pure-serve | 1,739 | **106,874** | 0.48 ms | 0.74 ms | +| pure-serve-2 | 2,017 | **149,219** | 0.34 ms | 0.61 ms | +| pure-serve-3 | 2,145 | **147,666** | 0.35 ms | 0.61 ms | +| axum 내구 | 177 | 1,266 | 35 ms | 111 ms | +| drogon 내구 | 181 | 1,058 | 41 ms | 156 ms | +| axum 메모리 | 51,619 | 82,847 | 0.40 ms | 6.1 ms | +| drogon 메모리 | 69,431 | 73,602 | 0.34 ms | 4.6 ms | + +손으로 쓴 두 서비스는 점수판 잠금을 쥔 채 `fdatasync`하므로 읽기도 동기화를 기다린다. 흔한 작성법이다. 메모리 +모드는 동기화하지 않지만 쓰기 5–7만 건과 CPU를 나눈다. + +같은 부하에서 이전 빌드(소유자가 읽기도 답하던 10dbf6e)와 번갈아 잰 3회전: + +| 연결 스레드 | 읽기/s 전 → 후 | 읽기 p99 전 → 후 | 쓰기/s 전 → 후 | +|---|---|---|---| +| 1 | 10,422–13,002 → **108,891–110,001** | 16–29 ms → **0.67 ms** | 1,467–1,821 → 1,766–1,978 | +| 2 | 14,747–16,040 → **144,886–147,167** | 13–16 ms → **0.61–0.67 ms** | 2,092–2,281 → 1,862–2,097 | +| 3 | 14,531–15,863 → **146,568–148,601** | 15–24 ms → **0.61 ms** | 2,060–2,257 → 1,986–2,277 | + +쓰기는 SD의 동기화 편차 안에서 같다. + +**나머지 앱 작업** (3회전 중앙값, 괄호는 077): + +| 작업 | pure-serve | pure-serve-2 | pure-serve-3 | +|---|---:|---:|---:| +| 점수 POST, 64연결 | 16,052 (12,896) | 10,415 (9,437) | 15,571 (13,692) | +| 점수 POST, 8연결 | 2,034 (1,452) | 2,072 (1,952) | 2,118 (2,132) | +| 합계 GET, 64연결 | 111,824 (115,354) | 154,259 (148,544) | 152,294 (147,354) | +| 20행 GET, 64연결 | 85,113 (87,312) | 149,414 (144,844) | 148,663 (140,672) | + +- 여러 연결 스레드의 읽기는 소유자를 거치지 않아 서버 CPU 1초당 답이 늘었다: 합계 GET 75,944 → 98,158, 20행 GET + 64,591 → 79,370(pure-serve-2). +- 연결 스레드 하나의 읽기는 이전 빌드와 번갈아 재면 같다(20행 88.1–88.6k 대 88.2–89.4k/s, 합계 112.3–112.5k 대 + 113.4–113.8k/s). 077의 수치와의 차이는 날마다의 편차다. +- 최대 RSS: 3.0 MiB(pure-serve), 3.6 MiB(-2), 4.1 MiB(-3). + +**스냅숏의 비용.** 저장 뒤 한 항목만 바뀐 점수판에서 스냅숏 만들기(200번 평균): 항목 64개 0.4 µs, 1,024개 1.2 µs, +16,384개 21.5 µs, 65,536개(한도) 87 µs. 같은 저장은 3.0–4.1 ms다. 옛 스냅숏을 놓는 데 1.4 µs 이하. 바뀐 리스트의 항목 +포인터 배열을 복사하는 것이 대부분이다. `pure-serve`의 코드는 4.3 KB(190.8 → 195.1 KB) 늘었다. 대부분 리스트의 +복사 후 쓰기와 스냅숏 함수다. + +## 찾은 것 + +- **평가와 전송을 번갈아 하면 느리다.** 처음에는 보기를 평가할 때마다 바로 답했다. 연결 스레드 하나에서 20행 GET이 이전 + 빌드보다 8% 느렸다(88.9k → 81.9k/s, 요청당 약 1 µs). 이전 빌드의 묶음 실행은 라운드의 요청을 모두 평가한 뒤 답을 + 모아 보냈다. 같게 하자 차이가 없어졌다. 평가(인터프리터, 리스트)와 `writev`(커널의 TCP 경로)를 번갈아 오가면 두 쪽의 + 코드와 데이터가 서로를 캐시에서 밀어내는 것으로 보인다. +- **쓰기를 먼저 넘긴다.** 한 라운드에 읽기 50개와 쓰기 1개가 있으면, 읽기를 다 답한 뒤 쓰기를 넘길 때 쓰기가 그만큼 + 늦게 저장을 시작한다. 입력을 먼저 소유자에게 넘기고 보기를 평가하게 했다. +- **스레드 하나는 이제 읽기로 가득 찬다.** 전에는 동기화가 읽기를 초당 1만여 건으로 묶었고, 그래서 한 스레드가 쉬는 + 시간이 많았다. 이제 읽기 56연결이 한 스레드를 채워도 쓰기는 같은 범위를 지켰다(중앙값 1,789 → 1,945). + +## 검증 + +- `test-module` 스냅숏: 키 있는 리스트 앱 3,000단계와 text 앱 300단계에서 저장마다 만든 스냅숏이 앱과 같다(상태와 + 모든 보기). 무작위로 붙잡아 둔 옛 스냅숏 8개는 항목이 바뀌고(복사 후 쓰기) 지워지고, 모듈이 키 없는 리스트로 + 바뀌었다 돌아오고, 앱이 멈추고 저장소가 닫힌 뒤에도 만들 때 그대로다. 놓인 공유 항목 450개가 스냅숏과 함께 해제된다. + 저장을 기다리는 변경이 있으면 스냅숏이 없고, 실패한 저장 뒤의 스냅숏은 실패 전과 같다. 상태를 메모리에만 두는 앱은 + 스냅숏을 만들지 않는다. 두 스레드가 최신 스냅숏을 읽는 동안(2만–4만 번) 앱이 계속 적용하고 게시한다. ASan·UBSan· + LeakSanitizer 빌드에서 같다. +- aup `make check`: 무작위 요청 800개의 파이썬 모델 대조가 연결 스레드 하나와 셋에서 같다. 여섯 클라이언트가 쓰는 동안 + `kill -9`하는 검사에 다른 연결의 읽기 둘을 더했다. 읽기마다 요청 전에 확인 응답된 점수를 모두 보고, 합계가 뒤로 + 가지 않으며, 다시 띄운 서버의 합계는 읽기가 본 어떤 합계 이상이다(저장되지 않은 것을 보지 않았다). 빌려준 보기의 + 회수(079)도 그대로다. `pure-serve`의 ASan·UBSan 빌드로 같은 서버 검사를 통과하고, 섞인 부하 뒤 정상 종료에서 누수가 + 없다. +- 게시를 답 뒤로 미룬 빌드를 일부러 만들면 모델 대조가 곧바로 실패한다(쓰기 직후의 GET이 옛 상태를 본다). +- 이 기기에서는 ThreadSanitizer가 돌지 않는다(VMA 47비트). 스레드를 건너는 것: 스냅숏·리스트 사본·매핑의 참조 수는 + 원자적이고(놓기는 acquire-release), 스냅숏이 게시된 뒤 소유자가 그 스냅숏에 쓰는 것(놓인 항목, 다음 스냅숏)은 읽는 + 쪽이 보지 않는 필드이며, 해제하는 스레드는 소유자가 참조를 놓은 뒤에 그것을 읽는다. + +## 다음 결정 + +- **저장과 적용의 겹치기**(로드맵 단기 2). 읽기가 소유자를 기다리지 않으니, 이제 소유자는 앞 묶음의 동기화가 다른 + 스레드에서 도는 동안 다음 묶음을 적용할 수 있다. 그때 게시할 스냅숏은 소유자의 현재 상태가 아니라 동기화한 묶음이 + 끝난 상태여야 한다. 저장을 시작할 때 만들고, 동기화가 끝날 때 게시하면 된다. 트리 저장소의 쓰기와 동기화 분리가 + 먼저 필요하다. -- cgit v1.2.3