diff options
| author | eunhhu <[email protected]> | 2026-09-26 22:56:47 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-26 22:56:47 +0100 |
| commit | a51232af741cb952d7deb72f238c583919ababc0 (patch) | |
| tree | 43b250cb0d76460fb447461e878cce0a65aefc41 | |
| parent | ea631f980a029141cd0b4835f35b27cd487ff3ea (diff) | |
| download | pure-a51232af741cb952d7deb72f238c583919ababc0.tar.gz pure-a51232af741cb952d7deb72f238c583919ababc0.zip | |
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.
| -rw-r--r-- | CHECKPOINT.md | 6 | ||||
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | benchmarks/http/README.md | 2 | ||||
| -rw-r--r-- | docs/design/008-module-format.md | 27 | ||||
| -rw-r--r-- | docs/research/080-snapshot-reads.md | 135 | ||||
| -rw-r--r-- | prototypes/connections/serve.c | 112 |
6 files changed, 253 insertions, 36 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md index 948b1a6..641b2ab 100644 --- a/CHECKPOINT.md +++ b/CHECKPOINT.md @@ -15,10 +15,10 @@ | 앱 표현 | 상태(수, text와 커서·마크, 레코드의 리스트와 키 있는 리스트, 인덱스), 입력(키, text, 필드 있는 메시지, 타이머), 원자적 이벤트 핸들러, 순수 보기(조건식, 표와 걸러진 표, count·sum·max·min·first), `fn`·`use` 라이브러리, text 비교, 시간(`now`, `every`, `floor`, `%`). 로드할 때 타입·스택·합류·커서와 인덱스의 소속을 모두 검증하고 이벤트의 실행량에 상한이 있다 | 008, aup `docs/design/002-dsl-direction.md`, aup `make check`(파이썬 참조 모델 대조, 진단 132건) | | 영속 | 선언한 상태가 곧 영속 상태다. 트리 저장소에 커밋당 동기화 한 번, 이벤트 묶음마다 저장 한 번. `kill -9` 뒤 확인 응답한 이벤트가 모두 남는다. 레코드·키가 바뀐 모듈로 바꿔도 데이터가 이름으로 옮겨진다 | [076](docs/research/076-one-sync-commit.md), 008 상태 영속·교체 | | 인터페이스 | 한 명세가 TUI(셀 단위 갱신), CLI, 문장(NUI), 웹 GUI가 된다. 저장하면 실행 중인 앱이 상태를 이어 새 모듈로 바뀐다(`aup watch`, `vega … --watch`) | Vega `docs/design/001-first-component.md`, Vega `make check`(가상 터미널, 헤드리스 Chromium 포함) | -| HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문, 라우터 코드 없음). 답은 저장 뒤에 나가고, 상태를 가진 앱은 연결 스레드 여럿과 소유자 하나로 돈다 | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md) | -| 성능(Pi 5) | 문서 서버: 작은 GET 151.6k/s, 1 MiB GET 2,794/s(axum 1,372, drogon 708), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스(aup 22줄 대 손으로 쓴 서비스): 내구성 있는 쓰기 약 76배, 20행 JSON GET 145k/s(axum 95k, drogon 144k), 최대 RSS 3.4 MiB(axum 6.4, drogon 11.6) | [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md) | +| HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문, 라우터 코드 없음). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md) | +| 성능(Pi 5) | 문서 서버: 작은 GET 151.6k/s, 1 MiB GET 2,794/s(axum 1,372, drogon 708), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스(aup 22줄 대 손으로 쓴 서비스): 내구성 있는 쓰기 약 76배, 20행 JSON GET 145k/s(axum 95k, drogon 144k), 쓰기와 섞인 읽기 149k/s·p99 0.6 ms(axum·drogon 내구 1.1–1.3k/s·p99 111–156 ms), 최대 RSS 3.0–4.1 MiB(axum 6.4, drogon 11.6) | [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) | -남은 것: macOS 검증, `bool`·`i64`, 소유자 밖에서의 읽기와 저장·적용의 겹치기(077 다음 결정), 앱 라우트의 인증·TLS(문서 +남은 것: macOS 검증, `bool`·`i64`, 저장과 적용의 겹치기(로드맵 단기 2, 080 다음 결정), 앱 라우트의 인증·TLS(문서 피어의 TLS는 있다), aup·Vega의 배포 묶음. ## 공개할 작업물과 완료 증거 @@ -4,9 +4,10 @@ ([format v5](docs/design/008-module-format.md)), verified completely before they execute: declared states persist in Pure's tree store, every event is a transaction, views are pure functions of state. The same spec becomes a terminal UI, a CLI, spoken sentences and a web page through [Vega](/srv/git/vega.git), and an HTTP service through -`pure-serve` (views are GETs, inputs are POSTs with JSON bodies; answers are durable). Measurements against axum and -drogon on a Raspberry Pi 5 are in [075](docs/research/075-http-host-against-axum.md) and -[077](docs/research/077-apps-as-http-services.md); the status table is [CHECKPOINT.md](CHECKPOINT.md). The text below +`pure-serve` (views are GETs, inputs are POSTs with JSON bodies; answers are durable; views are read from a snapshot of +the last save, so reads never wait for a sync). Measurements against axum and drogon on a Raspberry Pi 5 are in +[075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md) and +[080](docs/research/080-snapshot-reads.md); the status table is [CHECKPOINT.md](CHECKPOINT.md). The text below records the earlier document runtime this grew from. A standalone native micro runtime being designed as a small operating diff --git a/benchmarks/http/README.md b/benchmarks/http/README.md index 3abd431..3672c08 100644 --- a/benchmarks/http/README.md +++ b/benchmarks/http/README.md @@ -33,5 +33,5 @@ python3 run.py --module scores.pmod --suite app --rounds 3 --seconds 4 \ ``` 앱 벤치마크의 마지막 작업(`mixed`)은 내구성 점수 POST 8연결과 합계 GET 56연결을 부하 생성기 둘로 함께 보내, 쓰기가 -동기화하는 동안 읽기가 기다리는지 본다. 두 행(posts, reads)으로 +동기화하는 동안 읽기가 기다리는지 본다([080](../../docs/research/080-snapshot-reads.md)). 두 행(posts, reads)으로 나오고, CPU 1초당 답은 두 행을 합친 것이다. diff --git a/docs/design/008-module-format.md b/docs/design/008-module-format.md index be658dd..b77ec9c 100644 --- a/docs/design/008-module-format.md +++ b/docs/design/008-module-format.md @@ -291,16 +291,22 @@ text·리스트가 남을 때만 위치를 유지한다. ingress, 요청에서 빌린 PUT 응답). 효과는 비동기 인터페이스(`pure_runtime_take`/`complete`)로 소유 스레드에서 처리한다. - **이벤트 한 바퀴가 묶음 하나다.** 준비된 요청을 모두 지연 저장 모드의 앱에 차례로 적용하고, 저장 한 번 **뒤에** 응답한다. 저장이 실패하면 묶음의 읽기까지 모두 실패한다. 같은 문서 키가 다시 오거나 문서 수가 한 저장의 한도를 - 넘으려 하면 묶음을 먼저 끝낸다. 보기와 앱 상태의 이벤트는 어느 묶음에나 들어간다. 그래서 묶음 안의 모든 요청은 - 자기 묶음의 저장으로 저장되거나 그것과 함께 실패하고, 보기는 저장될 상태만 내보낸다. 한 연결의 요청은 앞 요청에 - 답한 뒤에 읽으므로 쓴 것을 곧 읽는다. + 넘으려 하면 묶음을 먼저 끝낸다. 앱 상태의 이벤트는 어느 묶음에나 들어간다. 그래서 묶음 안의 모든 요청은 + 자기 묶음의 저장으로 저장되거나 그것과 함께 실패한다. 보기는 저장된 상태만 내보낸다(아래 스냅숏). 한 연결의 + 요청은 앞 요청에 답한 뒤에 읽으므로 쓴 것을 곧 읽는다. - **저장소 핸들.** 문서와 상태를 저장소 핸들(`pure_store_data`)로 열어 파일 매핑 하나를 공유하고, 묶음마다 한 번만 저장소가 바뀌었는지 본다. - **연결 스레드와 소유자.** `--threads N`은 스레드마다 같은 포트의 리스너(`SO_REUSEPORT`), epoll, 런타임 코어를 둔다. 키별 문서만 가진 모듈은 스레드마다 앱과 저장소 핸들을 두고 저장소 말고는 아무것도 나누지 않는다. 상태를 가진 - 모듈은 상태의 소유자가 하나여야 하므로, 스레드 하나가 더 앱을 소유한다. 연결 스레드는 요청을 받고 해석하고 답을 - 쓰는 일(시스템 호출이 대부분이다)을 나눠 맡고, 준비된 요청을 소유자에게 넘긴다. 소유자는 모든 스레드의 요청을 한 - 묶음으로 적용하고 저장 한 번 뒤에 각 스레드에 돌려주며(eventfd로 깨운다), 스레드는 돌려받은 것을 완료하고 답한다. + 모듈은 상태의 소유자가 하나여야 하므로, 연결 스레드가 하나여도 따로 소유자 스레드가 앱을 돌린다. 연결 스레드는 + 요청을 받고 해석하고 답을 쓰는 일(시스템 호출이 대부분이다)을 나눠 맡고, 입력과 문서 요청을 소유자에게 넘긴다. + 소유자는 모든 스레드의 요청을 한 묶음으로 적용하고 저장 한 번 뒤에 각 스레드에 돌려주며(eventfd로 깨운다), + 스레드는 돌려받은 것을 완료하고 답한다. +- **보기는 스냅숏에서.** 소유자는 저장이 성공할 때마다, 답을 돌려주기 전에 저장된 상태의 스냅숏을 게시한다. + 연결 스레드는 라운드마다(돌려받은 답을 처리한 뒤) 최신 스냅숏을 잡아 자기 읽기용 앱으로 그 라운드의 보기를 모두 + 평가하고 답을 모아 보낸다. 입력은 그보다 먼저 소유자에게 넘겨, 소유자가 그동안 적용하고 저장한다. 그래서 쓰기의 + 답을 받은 뒤의 읽기는 그 쓰기를 보고, 저장되지 않았거나 실패로 되돌린 상태는 아무도 보지 않으며, 소유자가 + 동기화하는 동안에도 읽기는 기다리지 않는다. 스냅숏을 만들지 못하면 보기도 소유자가 답한다. 작업 슬롯의 라우트와 입력은 완료될 때까지 그 스레드의 런타임 코어가 붙잡고, 빌려준 저장소 매핑의 참조 수는 원자적이다. 저장한 묶음 뒤에는 새 요청이 100 µs 동안 끊길 때까지(최대 1 ms) 모은다. 답을 받은 클라이언트의 다음 요청이 한 저장에 들어가게 하는 것으로, 한 스레드에서는 답하는 시간이 그 구실을 한다(묶음당 이벤트 30 → 50, p50은 @@ -316,9 +322,12 @@ text·리스트가 남을 때만 위치를 유지한다. 상태 코드·ETag·오류·본문이 같고, 동시 편집이 커밋을 나누어 쓰며, `kill -9` 뒤에도 확인 응답한 편집이 모두 남는다. 앱 라우트는 키 있는 리스트 앱에 무작위 POST(쿼리 필드와 본문을 가진 메시지, text, 키)와 GET(수, text, 표의 쪽)을 800번 보내 상태 코드·오류·본문·JSON 행이 파이썬 모델과 같고, 멈췄다 다시 띄워도, 여섯 클라이언트가 보내는 중에 -`kill -9`로 죽여도 확인 응답한 이벤트(약 4,200개)가 모두 남는다. 한 스레드와 소유자 모드 모두 확인한다. 같은 서비스를 -axum·drogon으로 손수 쓴 것과의 비교는 [연구 077](../research/077-apps-as-http-services.md): 내구성 있는 쓰기 약 76배, -읽기는 drogon과 같고 axum보다 빠르며(20행 JSON 145k/s 대 95k/s), CPU와 메모리가 가장 적다. +`kill -9`로 죽여도 확인 응답한 이벤트(약 4,200개)가 모두 남는다. 그동안 다른 연결의 읽기는 먼저 확인 응답된 쓰기를 +모두 보고, 합계가 뒤로 가지 않으며, 다시 띄운 서버의 합계는 읽기가 본 어떤 합계 이상이다. 연결 스레드 하나와 셋에서 +모두 확인한다. 같은 서비스를 axum·drogon으로 손수 쓴 것과의 비교는 [연구 077](../research/077-apps-as-http-services.md): +내구성 있는 쓰기 약 76배, 읽기는 drogon과 같고 axum보다 빠르며(20행 JSON 145k/s 대 95k/s), CPU와 메모리가 가장 적다. +쓰기와 읽기를 함께 보내면 읽기가 149k/s(p99 0.6 ms)로, 잠금을 쥔 채 동기화하는 axum·drogon(1.1–1.3k/s, p99 +111–156 ms)과 동기화하지 않는 메모리 판(74–83k/s)보다 많다([연구 080](../research/080-snapshot-reads.md)). 아직 모듈 앱을 런타임 코어의 효과 단위로 세분하지는 않았다(`runtime.c`의 `definition`은 앱 전체다). 호스트가 직접 호출한다. 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). 읽기가 소유자를 기다리지 않으니, 이제 소유자는 앞 묶음의 동기화가 다른 + 스레드에서 도는 동안 다음 묶음을 적용할 수 있다. 그때 게시할 스냅숏은 소유자의 현재 상태가 아니라 동기화한 묶음이 + 끝난 상태여야 한다. 저장을 시작할 때 만들고, 동기화가 끝날 때 게시하면 된다. 트리 저장소의 쓰기와 동기화 분리가 + 먼저 필요하다. diff --git a/prototypes/connections/serve.c b/prototypes/connections/serve.c index 82c93cb..d5c7aa4 100644 --- a/prototypes/connections/serve.c +++ b/prototypes/connections/serve.c @@ -116,9 +116,11 @@ typedef struct { const char *type; /* a view's content type */ } Applied; -/* The app and its store. A connection thread runs it itself, or, with - * several connection threads and an app with states, one owner thread runs - * it for all of them. */ +/* The app and its store. A connection thread runs it itself (an app of + * documents only), or one owner thread runs it for all connection threads. + * The owner publishes a snapshot of the states after each save, and the + * connection threads show views from it themselves: reads never wait for + * the owner, which may be applying events or waiting for a sync. */ typedef struct { const PureModule *module; PureStore *store; @@ -131,6 +133,12 @@ typedef struct { bool reads_first; /* the owner's: a round's reads before its writes */ double next[PURE_MODULE_INPUTS]; /* when each timer ticks next (wall clock, ms) */ uint64_t ticks; + /* The owner's: the last save's snapshot (NULL: the owner shows views), + * taken under the lock, and the app's changes it shows. */ + bool publishes; + pthread_mutex_t published_lock; + PureSnapshot *published; + uint64_t published_changes, publications; } Executor; /* A request made ready: its effect, what it names, and the thread that answers it. */ @@ -156,6 +164,10 @@ struct Host { bool documents; /* the module serves /objects: it has show and edit */ Executor local; /* the app, when this thread runs it */ Owner *owner; /* or the owner that runs it */ + PureApp reader; /* shows the owner's snapshot here */ + Applied *views; /* a round's views shown here, answered together */ + size_t views_capacity; + uint64_t shown; /* views shown from snapshots */ Route *routes; /* by work slot */ PureRuntime runtime; PureWork *work; @@ -1055,6 +1067,21 @@ static void apply_effect(Executor *ex, const Job *job, Applied *out) { .release = release_copy}; } +/* After a save, before its answers go: from now on views show what it + * saved, so a read made after a write's answer sees the write. A failed save + * returns the app to the last save's states, which the snapshot still shows. */ +static void publish(Executor *ex) { + if (!ex->publishes || (ex->published && ex->published_changes == ex->app.changes)) return; + PureSnapshot *made = pure_app_snapshot(&ex->app), *old; + pthread_mutex_lock(&ex->published_lock); + old = ex->published; + ex->published = made; + pthread_mutex_unlock(&ex->published_lock); + pure_snapshot_release(old); + ex->published_changes = ex->app.changes; + ++ex->publications; +} + /* One save for a burst: its requests are durable with it or fail with it. */ static void save_burst(Executor *ex, Applied *applied, size_t count) { if (!count) return; @@ -1062,6 +1089,7 @@ static void save_burst(Executor *ex, Applied *applied, size_t count) { ex->wrote = ex->wrote || (applied[i].status == PURE_OK && applied[i].input == PURE_INPUT_WRITE); PureResult saved = pure_app_save(&ex->app); ++ex->bursts; + if (saved == PURE_OK) publish(ex); for (size_t i = 0; i < count; ++i) if (applied[i].status == PURE_OK) applied[i].status = saved; } @@ -1130,7 +1158,7 @@ static long run_timers(Executor *ex) { } if (soonest < 0 || ex->next[i] - now < soonest) soonest = ex->next[i] - now; } - if (ticked) pure_app_save(&ex->app); /* a failed save undoes the ticks */ + if (ticked && pure_app_save(&ex->app) == PURE_OK) publish(ex); /* a failed save undoes the ticks */ return soonest < 0 ? -1 : (long)ceil(soonest); } @@ -1194,8 +1222,23 @@ static void answer_handed(Host *host) { host->pending -= count; } +/* The owner's newest snapshot, held, shown by this thread's reader; NULL + * when there is none. Taken for each round, after the round's answers from + * the owner: those were handed back after their saves' snapshots. */ +static PureSnapshot *read_published(Host *host) { + Executor *ex = &host->owner->executor; + pthread_mutex_lock(&ex->published_lock); + PureSnapshot *snapshot = ex->published; + if (snapshot) pure_snapshot_hold(snapshot); + pthread_mutex_unlock(&ex->published_lock); + if (snapshot) pure_app_read(&host->reader, snapshot); + return snapshot; +} + /* Every request made ready since the last round: run here, or handed to the - * owner. Answering may make more ready; they form the next round. */ + * owner. Views are shown here from the snapshot when there is one, after the + * rest went to the owner, which meanwhile applies and saves them. Answering + * may make more ready; they form the next round. */ static void serve_bursts(Host *host) { for (;;) { size_t count = 0; @@ -1207,15 +1250,35 @@ static void serve_bursts(Host *host) { execute(&host->local, host->jobs, count, answer_burst, NULL); continue; } - Owner *owner = host->owner; - pthread_mutex_lock(&owner->lock); - if (!grow(&owner->jobs, &owner->capacity, owner->count + count, sizeof(*owner->jobs))) abort(); - memcpy(owner->jobs + owner->count, host->jobs, count * sizeof(*host->jobs)); - owner->count += count; - pthread_cond_signal(&owner->waiting); - pthread_mutex_unlock(&owner->lock); - host->pending += count; - return; + PureSnapshot *snapshot = NULL; + size_t views = 0; + for (size_t i = 0; i < count; ++i) views += host->jobs[i].route.kind == ROUTE_VIEW; + if (views && !(snapshot = read_published(host))) views = 0; /* the owner shows them */ + size_t handed = count - views; + if (handed) { + Owner *owner = host->owner; + pthread_mutex_lock(&owner->lock); + if (!grow(&owner->jobs, &owner->capacity, owner->count + handed, sizeof(*owner->jobs))) abort(); + for (size_t i = 0; i < count; ++i) + if (!snapshot || host->jobs[i].route.kind != ROUTE_VIEW) owner->jobs[owner->count++] = host->jobs[i]; + pthread_cond_signal(&owner->waiting); + pthread_mutex_unlock(&owner->lock); + host->pending += handed; + } + if (!views) return; + /* Shown first and answered after, as a burst is. */ + if (!grow(&host->views, &host->views_capacity, views, sizeof(*host->views))) abort(); + size_t shown = 0; + for (size_t i = 0; i < count; ++i) { + const Job *job = &host->jobs[i]; + if (job->route.kind != ROUTE_VIEW) continue; + Applied *a = &host->views[shown++]; + *a = (Applied){.host = host, .ticket = job->effect.ticket, .input = job->effect.input, .route = ROUTE_VIEW}; + a->status = show_view(&host->reader, &job->route, job->effect.available, a); + } + pure_snapshot_release(snapshot); /* what answers lend holds its own mapping */ + for (size_t i = 0; i < shown; ++i) answer_applied(host, &host->views[i]); + host->shown += shown; } } @@ -1382,6 +1445,7 @@ static bool start_executor(Executor *ex, const PureModule *module, uint32_t show } static void stop_executor(Executor *ex) { + pure_snapshot_release(ex->published); pure_app_stop(&ex->app); pure_store_close(ex->store); free(ex->applied); @@ -1487,6 +1551,7 @@ static void *run_shard(void *context) { static void stop_shard(Host *host) { if (!host->owner) stop_executor(&host->local); + pure_app_stop(&host->reader); if (host->listener >= 0) close(host->listener); if (host->epoll >= 0) close(host->epoll); if (host->wake >= 0) close(host->wake); @@ -1504,6 +1569,7 @@ static void stop_shard(Host *host) { free(host->routes); free(host->done); free(host->answering); + free(host->views); } int main(int argc, char **argv) { @@ -1537,12 +1603,16 @@ int main(int argc, char **argv) { Host *hosts = calloc(threads, sizeof(*hosts)); pthread_t *thread = calloc(threads, sizeof(*thread)), owner_thread; if (!hosts || !thread) return 1; - /* States have one owner: with several connection threads, it runs on its own. */ - bool owned = threads > 1 && module.states; + /* States have one owner on a thread of its own, whose snapshots the + * connection threads read. */ + bool owned = module.states; if (owned) { if (!start_executor(&owner.executor, &module, show, edit, store) || pthread_mutex_init(&owner.lock, NULL) || - pthread_cond_init(&owner.waiting, NULL)) return 1; + pthread_cond_init(&owner.waiting, NULL) || pthread_mutex_init(&owner.executor.published_lock, NULL)) + return 1; owner.executor.reads_first = true; + owner.executor.publishes = true; + publish(&owner.executor); owner.hosts = hosts; owner.host_count = threads; } @@ -1569,8 +1639,9 @@ int main(int argc, char **argv) { } size_t active = 0, retained = 0; uint64_t accepted = 0, requests = 0, bursts = owner.executor.bursts, events = owner.executor.burst_events, expired = 0, - ticks = owner.executor.ticks; + ticks = owner.executor.ticks, shown = 0; for (unsigned i = 0; i < threads; ++i) { + shown += hosts[i].shown; active += hosts[i].active; retained += hosts[i].runtime.retained; accepted += hosts[i].accepted; @@ -1589,9 +1660,10 @@ int main(int argc, char **argv) { pure_store_group_counts(&groups, &grouped); fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"accepted\":%" PRIu64 ",\"requests\":%" PRIu64 ",\"bursts\":%" PRIu64 ",\"burst_events\":%" PRIu64 ",\"ticks\":%" PRIu64 ",\"expired\":%" PRIu64 - ",\"threads\":%u,\"owner\":%s,\"commit_groups\":%lu,\"grouped_commits\":%lu}\n", + ",\"threads\":%u,\"owner\":%s,\"snapshots\":%" PRIu64 ",\"views_from_snapshots\":%" PRIu64 + ",\"commit_groups\":%lu,\"grouped_commits\":%lu}\n", active, retained, accepted, requests, bursts, events, ticks, expired, threads, owned ? "true" : "false", - groups, grouped); + owner.executor.publications, shown, groups, grouped); free(hosts); free(thread); return 0; |
