diff options
Diffstat (limited to 'docs/research')
| -rw-r--r-- | docs/research/077-apps-as-http-services.md | 93 |
1 files changed, 93 insertions, 0 deletions
diff --git a/docs/research/077-apps-as-http-services.md b/docs/research/077-apps-as-http-services.md new file mode 100644 index 0000000..27e69fd --- /dev/null +++ b/docs/research/077-apps-as-http-services.md @@ -0,0 +1,93 @@ +# 077 — 앱이 곧 HTTP 서비스: 보기와 입력이 라우트이고, 상태의 소유자는 하나다 + +2026-09-26. `pure-serve`가 aup 앱의 보기와 입력을 HTTP로 내놓는다. 상태를 가진 앱을 여러 연결 스레드로 돌린다. + +착수 기준: + +- 바꿀 결정: Pure 앱을 백엔드로 쓰려면 라우터, 핸들러, 직렬화, 저장 코드를 따로 써야 하는지, 앱 명세 하나가 + 그대로 HTTP 서비스가 되는지. 상태를 가진 앱은 스레드 하나에 머무는지, 여러 코어를 쓰는지. +- 남은 질문: 같은 서비스를 axum·drogon으로 손수 쓴 것과 같은 기기에서 비교해, 내구성 있는 쓰기와 읽기와 메모리에서 + 어디에 서는가? 상태의 소유자를 하나로 두고도 읽기가 코어를 따라 늘어나는가? +- 최소 확인: 무작위 요청을 파이썬 모델과 대조하고(한 스레드와 소유자 모드 모두), 확인 응답한 이벤트가 `kill -9` 뒤에도 + 남는지 보고, 3회전 벤치마크의 중앙값을 기준선과 나란히 둔다. +- 중단 기준: 모델과 한 번이라도 다르거나, 확인 응답한 이벤트를 하나라도 잃거나, 읽기가 axum보다 느리면 다시 본다. + +## 의심한 관례 + +| 관례 | Pure | +|---|---| +| API는 라우터에 핸들러를 등록해 만든다 | 보기가 GET, 입력이 POST다. 쓸 라우트가 없다 | +| 응답은 핸들러가 직렬화한다 | 수·text 보기는 그대로, 표는 JSON 행. 요청한 쪽의 칸만 계산한다(`?from=&count=`) | +| 쓰기의 내구성은 앱이 데이터베이스로 얻는다 | 모든 이벤트가 트랜잭션이고, 답은 저장 뒤에 나간다. 요청 여럿이 저장 한 번을 나눈다 | +| 상태를 여러 스레드가 공유하려면 잠금이 필요하다 | 상태의 소유자는 스레드 하나다. 연결 스레드는 요청을 넘기고 답을 받아 쓴다. 앱에는 잠금이 없다 | + +## 점수판 + +같은 계약을 세 가지로 구현했다. `POST /inputs/score?points=N`(본문은 이름)은 그 이름의 점수를 올리고 이름을 답한다. +`GET /views/total`은 합계, `GET /views/leaders?from=0&count=20`은 이름순 20행을 JSON으로 답한다. + +- aup([scores.aup](../../benchmarks/http/scores.aup)): 22줄. 키 있는 리스트 하나, 메시지 입력 하나, 보기 둘. +- axum·drogon: 약 100줄씩. 맵과 잠금, 추가 전용 로그(요청마다 `fdatasync`하고, 시작할 때 재생한다), JSON 직렬화, 쿼리 해석. + +Raspberry Pi 5, 서버는 CPU 0–2, 부하는 CPU 3, 저장소는 SD 카드의 ext4. 3회전 중앙값(4초씩, 회전마다 서버 순서를 돌리고 +새 저장소를 쓴다). `pure-serve-N`은 연결 스레드 N개와 소유자 스레드 하나다. + +| 작업 | pure-serve | pure-serve-2 | pure-serve-3 | axum 메모리 | axum 내구 | drogon 메모리 | drogon 내구 | +|---|---:|---:|---:|---:|---:|---:|---:| +| 점수 POST, 64연결 | 12,896 | 9,437 | **13,692** | 129,122 | 180 | 146,112 | 172 | +| 점수 POST, 8연결 | 1,452 | 1,952 | **2,132** | 135,152 | 188 | 138,398 | 160 | +| 합계 GET, 64연결 | 115,354 | **148,544** | 147,354 | 136,114 | 136,659 | 149,140 | 147,900 | +| 20행 GET, 64연결 | 87,312 | **144,844** | 140,672 | 94,370 | 95,186 | 144,471 | 144,336 | + +초당 2xx. 서버 CPU 1초당 답(2xx/cpu-s): 합계 GET은 pure-serve-2 75,944, axum 68,690, drogon 68,616. 20행 GET은 +64,591, 51,802, 53,562. 최대 RSS: pure-serve-2 3.4 MiB, axum 6.2–6.4 MiB, drogon 11.6 MiB. + +- **내구성 있는 쓰기는 약 76배다.** 손으로 쓴 서비스는 요청마다 동기화한다. 그것이 이런 서비스를 쓰는 보통의 방법이다. + Pure는 준비된 요청을 묶어 저장 한 번에 담는다. 앱이 그룹 커밋을 설계할 필요가 없다. 메모리 모드의 axum·drogon이 + 빠른 것은 답한 쓰기를 잃을 수 있어서다. Pure에는 그런 모드가 없다. +- **읽기는 drogon과 같고 axum보다 빠르다.** 64연결 합계 GET은 부하 생성기(코어 하나)의 한계 근처라 서버 CPU당 답이 서버를 + 가른다. 두 읽기 모두 Pure가 CPU를 가장 적게 쓴다(20행 GET에서 22–25% 적다). +- **메모리는 가장 적다.** 소유자 스레드를 더해도 최대 3.4–4.2 MiB다. + +## 찾은 것 + +- **한 스레드는 시간 대부분을 커널에서 쓴다.** 20행 GET 하나에 시스템 호출은 `writev` 한 번과 `recv` 한 번이고(묶음마다 + `epoll_wait`와 `fstat` 한 번), 사용자 공간은 CPU의 약 20%였다. 상태를 가진 앱이 스레드 하나에 머물면 이 비용을 한 코어가 + 다 진다. 그래서 연결 스레드가 받기·해석·쓰기를 나누고 소유자는 상태만 만지게 했다. 20행 GET이 87k에서 145k/s가 됐다. +- **정수를 printf로 만들고 있었다.** 소유자가 20행 요청 하나에 8.8 µs를 썼다. 칸 계산은 40칸에 2 µs(칸당 50 ns)였다. 나머지 + 대부분은 `snprintf("%.0f")`였다. 정수를 직접 자릿수로 만들자(21 ns, 무작위 정수 262만 개에서 printf와 같다) 5.5 µs가 + 됐다. 열 이름 조각을 요청마다 한 번 만들고 버퍼를 한 번에 잡자 4.4 µs가 됐다. 문자열 보간(`"{n}"`)도 같은 함수를 쓰므로 + 모든 앱이 함께 빨라졌다. +- **소유자는 너무 일찍 저장을 시작했다.** 소유자 모드의 내구성 POST가 한 스레드보다 30% 느렸다. 저장 한 번에 담긴 이벤트가 + 50개에서 30개로 줄어 있었다. 한 스레드는 답을 쓰는 동안 클라이언트가 다음 요청을 보내므로 다음 묶음에 그것이 + 들어간다. 소유자는 연결 스레드 하나의 요청이 도착하자마자 다음 묶음을 시작했다. 답한 스레드가 한 바퀴 돌 때까지 + 기다려 봤지만 효과가 없었다(30 → 32개). 클라이언트가 답을 받고 다음 요청을 보내는 시간은 소유자가 볼 수 없기 때문이다. + 저장한 묶음 뒤에는 새 요청이 100 µs 동안 끊길 때까지(최대 1 ms) 모으게 했다. 그러자 50개가 되었고 처리량은 한 스레드와 + 같거나 높아졌다. 요청이 기다리는 동기화 횟수가 줄어 p50은 5.4 ms에서 3.5 ms로 오히려 짧아졌다. 읽기만 한 묶음 뒤에는 + 모으지 않으므로 읽기는 그대로다. + +## 검증 + +aup `make check`: 키 있는 리스트 앱에 무작위 요청 800개(쿼리 필드와 본문을 가진 메시지, text, 키 누름, 수·text 보기, 표의 +쪽, 없는 이름과 잘못된 쿼리)를 보내 상태 코드·오류 이름·본문·JSON 행이 파이썬 모델과 같다. 한 스레드와 연결 스레드 셋 + +소유자에서 모두 확인한다. 여섯 클라이언트가 보내는 중에 `kill -9`로 죽여도, 확인 응답한 이벤트(각 3,500–4,200개)가 다시 +시작한 뒤 모두 남는다. ASan·UBSan 빌드에서 같은 검사와, 클라이언트 열둘이 쓰기·읽기·실패를 섞어 보내는 부하(요청 3,600개)를 +통과했다. 이 기기(VMA 47비트)에서는 ThreadSanitizer가 돌지 않는다. 대신 스레드를 건너는 것을 하나씩 확인했다. + +- 소유자가 읽는 요청의 키와 입력: 연결 스레드의 런타임 코어가 완료까지 작업 슬롯을 붙잡는다(연결이 먼저 닫혀도). +- 돌려주는 결과: 잠금 아래 넘긴다. +- 빌려준 저장소 매핑: 참조 수가 원자적이다. +- 보기 결과 버퍼: 스레드를 건너 해제된다(malloc). + +문서 서버 경로는 그대로다. 같은 날 한 회전 결과: 64 B GET 148.5k/s(pure-serve-3), 1 MiB GET 2,786/s, 내구성 PUT 2,088/s. +[075](075-http-host-against-axum.md)와 같다. + +## 다음 결정 + +- **저장과 적용을 겹치기.** 소유자는 저장(동기화)하는 동안 다음 요청을 적용하지 못한다. 저장을 다른 스레드에 넘기고, 다음 + 묶음을 적용하는 동안 앞 묶음의 동기화가 끝나면 답하게 하면 동기화 지연이 처리량에서 빠진다. 트리 저장소의 "쓰기와 + 동기화" 분리가 먼저 필요하다. +- **읽기를 소유자 밖에서.** 보기는 상태를 바꾸지 않는다. 저장된 버전을 연결 스레드가 직접 읽게 하면(버전 있는 상태) 소유자가 + 동기화하는 동안에도 읽기가 기다리지 않는다. +- **메시지의 여러 text 필드와 JSON 본문.** 지금 메시지는 u32 필드와 bytes 꼬리 하나다. HTTP API에서는 필드 여럿을 JSON으로 + 받는 편이 자연스럽다. |
