aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-10-01 09:18:23 +0100
committereunhhu <[email protected]>2026-10-01 09:18:23 +0100
commit83286a0722be240690c134d9c6c75b2da6e94e8a (patch)
treeae2ff195b01102da8da81751040da875af782139
parentd1db24f0e5c3bba583a1f2a9d8a0c9d3fc64a55c (diff)
downloadpure-83286a0722be240690c134d9c6c75b2da6e94e8a.tar.gz
pure-83286a0722be240690c134d9c6c75b2da6e94e8a.zip
Say what one sending loop showed (017 2b, research 113)
The loop alone slowed four links to half: woken by every record queued, it chased the owner a record at a time. Pushing once a turn made links 1.6 times as fast, with threads or the loop alike; the loop's own cost is within the noise. Twenty outputs now add two threads, not 21.
-rw-r--r--CHECKPOINT.md4
-rw-r--r--docs/design/017-apps-on-one-host.md28
-rw-r--r--docs/research/113-one-sending-loop.md107
3 files changed, 131 insertions, 8 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md
index 29b20fb..527e36f 100644
--- a/CHECKPOINT.md
+++ b/CHECKPOINT.md
@@ -18,7 +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는 같다. 앱마다 보냄을 배선하고(`send=`), 링크 포트 하나가 모든 앱의 입력을 `앱/입력`으로 받는다. 같은 호스트의 앱끼리는 소켓 없이 건네 링크의 두 배(초당 114만 건)를 CPU 40%로 나른다 | [017](docs/design/017-apps-on-one-host.md), [111](docs/research/111-apps-on-one-host.md), [112](docs/research/112-sending-between-apps.md) |
+| 여러 앱 | 한 호스트가 앱 여럿을 품는다(`pure-serve --apps`, 경로는 `/앱/…`). 앱마다 스레드를 두지 않고 정해진 실행 스레드가 차례로 돌리며, 앱마다 연결이 모이는 집 연결 스레드가 있다. 앱마다 실행량(쓴 그대로의 명령 수, 넘으면 422 `work`)·대기열(503 `busy`)·메모리 예산이 있고, 모듈이나 저장소를 열지 못한 앱은 그 앱만 멈춘다. 앱 하나에 120 KiB(프로세스 하나의 PSS의 9%), 앱 100개에도 스레드 셋. 한 앱이 몰릴 때 다른 앱의 p99는 앱마다 프로세스일 때의 0.5배, 둘 다 바쁠 때 처리량과 p99는 같다. 앱마다 보냄을 배선하고(`send=`), 링크 포트 하나가 모든 앱의 입력을 `앱/입력`으로 받는다. 같은 호스트의 앱끼리는 소켓 없이 건네 링크의 두 배(초당 114만 건)를 CPU 40%로 나른다. 링크와 전달선은 프로세스의 보내는 루프 하나가 보낸다(링크와 전달선 스무 개에 스레드 24개가 5개, 링크 +63%, 전달선 같음) | [017](docs/design/017-apps-on-one-host.md), [111](docs/research/111-apps-on-one-host.md), [112](docs/research/112-sending-between-apps.md), [113](docs/research/113-one-sending-loop.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) |
@@ -32,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 검증, 보냄마다의 스레드를 하나의 루프로(017의 2b), 달리는 호스트의 앱을 더하고 바꾸기(017의 3단계), 압축을 없애는 제자리 재사용 저장소(085 다음), 응답을 기다리는 출력의 묶음 전달(083), 앱
+남은 것: macOS 검증, 달리는 호스트의 앱을 더하고 바꾸기(017의 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 aaaef51..3ffdc0f 100644
--- a/docs/design/017-apps-on-one-host.md
+++ b/docs/design/017-apps-on-one-host.md
@@ -1,7 +1,7 @@
# 017 — 한 호스트의 여러 앱: 차례, 예산, 실패 경계
-작성일: 2026-10-01. 상태: 1·2단계 구현, 검사로 고정하고 쟀다([111](../research/111-apps-on-one-host.md),
-[112](../research/112-sending-between-apps.md)). 2b는 착수 기준을 적었다. 3단계는 설계.
+작성일: 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단계는 설계.
[외부 리뷰 004](../outside-feedback/feedback-004.md)의 "구현 방향에 대한 평가" 셋째에서 시작한다. 작은 OS의 운영 모델(
[007](007-execution-domains.md))을 Pure가 실제로 갖게 하는 단계다.
@@ -175,12 +175,17 @@ counter apps/count.pmod stores/counter
**2b: 보내는 루프 하나.** 보냄마다의 스레드(전달선 하나, 링크 하나에 하나씩)를 호스트의 보내는 스레드 하나로 모은다. 출력을 가진
앱은 보냄마다 스레드 하나를 더해 왔다. 앱마다 스레드를 두지 않는다는 원칙에 어긋나는 마지막 자리다.
-- 루프 하나(`pure-send`)가 epoll 하나에 링크의 UDP 소켓과 전달선의 TCP 소켓, 깨움 하나를 둔다. 기다리는 시간은 링크들의 다음 때와
- 전달선들의 쉼 가운데 가장 이른 것이다. 출구에 보냄이 쌓이면 루프를 깨운다(자고 있을 때 한 번).
+- 루프 하나(`pure-send`, `link/sending.c`)가 epoll 하나에 링크의 UDP 소켓과 전달선의 TCP 소켓, 깨움 하나를 둔다. 보내는 쪽은
+ 루프에 들어오며 자기 바퀴(round)와 기다릴 소켓을 준다. 루프는 원하는 것만 돈다. 소켓이 준비됐거나, 밀렸거나, 원한 때가 온
+ 것이다. 기다리는 시간은 그 때들 가운데 가장 이른 것이다.
+- 소유자는 출구에 보냄을 쌓고, 차례의 것을 다 쌓은 뒤 출구마다 한 번 민다(`pure_link_push`, `pure_wire_push`). 미는 것이 루프를
+ 깨운다(자고 있을 때 한 번). 쌓기마다 깨우면 루프가 기록을 하나씩 따라잡으며 작은 패킷을 낸다(아래).
- 링크(2b-1): 지금 스레드가 한 바퀴에 하던 일을 루프의 한 바퀴에서 링크마다 한다. 답 받기, 시각, 보내기다. 받는 쪽을 다시 찾는
이름 찾기(DNS)는 그 자리에서 기다린다. 프로브 셋이 답을 못 받았을 때뿐이라 드물지만, 그동안 다른 보냄도 멈춘다. 이것을 밝힌다.
-- 전달선(2b-2): 비블로킹 HTTP/1.1 클라이언트의 상태기다(연결, TLS, 요청 쓰기, 답 읽기, 쉼). TLS는 crypto의 함수 표에 비블로킹
- 단계를 더한다. 이름 찾기는 연결할 때 기다린다(링크와 같다).
+- 전달선(2b-2): 비블로킹 HTTP/1.1 클라이언트의 상태기다(연결, TLS, 요청 쓰기, 답 읽기, 쉼). 바퀴는 소켓이 받는 데까지 가고,
+ 소켓이나 마감(아무것도 움직이지 않은 10초)을 기다린다. 답은 오는 대로 읽는다. 머리를 다 받고, 크기가 적힌 몸은 세어 내려가고,
+ 조각난(chunked) 몸은 자랄 때마다 처음부터 다시 읽는다(드물고 작다). TLS는 crypto의 함수 표(판 6)에 핸드셰이크의 걸음을
+ 더했다. 이름 찾기는 연결할 때 기다린다(링크와 같다).
**2b의 착수 기준.**
@@ -190,6 +195,17 @@ counter apps/count.pmod stores/counter
- 통과 조건(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단계의 착수 기준
- **바꿀 결정:** 같은 호스트의 앱 사이를 링크(소켓, 봉인)가 아니라 안쪽 출구로 건넬지다. 아래를 채우지 못하면 같은 호스트의
diff --git a/docs/research/113-one-sending-loop.md b/docs/research/113-one-sending-loop.md
new file mode 100644
index 0000000..1dd4ded
--- /dev/null
+++ b/docs/research/113-one-sending-loop.md
@@ -0,0 +1,107 @@
+# 113 — 보내는 루프 하나: 링크와 전달선을 스레드 하나에
+
+2026-10-01. [017](../design/017-apps-on-one-host.md)의 2b를 잰다. 기준은 구현하기 전에 017에 적어 커밋했다(`3791ab8`).
+
+- 바꿀 결정: 보냄(링크 하나, 전달선 하나)마다의 스레드를 프로세스의 보내는 루프 하나로 모을지.
+- 최소 확인: 링크·전달·답·봉인·`server_apps_links`의 검사가 모두 초록. 링크 열과 전달선 열로 보내는 호스트의 스레드가 출력이
+ 없는 호스트보다 둘(보내는 루프, 링크를 받는 스레드)만 많다(전에는 스물하나).
+- 통과 조건(RAM의 Pi 5, 전과 후 번갈아): 링크 하나와 전달선 하나의 처리량 중앙값이 5% 넘게 낮지 않다. 링크 넷이 함께 보낼 때
+ 처리량의 합이 10% 넘게 낮지 않다.
+
+## 쉬운 말로
+
+- **통과했다.** 링크와 전달선 스무 개를 가진 호스트의 스레드가 24개에서 5개가 됐다(출력이 없으면 3개). 전달선은 전과 같다
+ (하나 +0.7%, 넷 −2.3%, 보냄 하나의 CPU는 같다). 링크는 빨라졌다(하나 +63%, 넷 +67%).
+- **루프만 바꾼 첫 구현은 느렸다.** 링크 넷이 초당 58만 건에서 27만 건이 됐다. 루프가 자는 동안 쌓인 기록마다 깨어났기 때문이다.
+ 소유자는 기록을 하나씩 쌓고, 루프는 그것을 따라잡고 잠들기를 되풀이했다. 깰 때마다 모든 링크를 돌아, 링크마다 패킷이 작아졌다
+ (패킷 하나에 기록 20건에서 12건). 패킷이 많으니 받았다는 답도, 깨움도, 소유자에게 알림도 많았다.
+- **고친 것은 루프가 아니라 경계였다.** 소유자가 차례의 보냄을 다 쌓은 뒤 출구마다 한 번 민다(push). 루프는 원하는 링크만 돈다
+ (답이 왔거나, 밀렸거나, 때가 됐다). 그러자 링크 넷이 80만 건이 됐다. 같은 밀기를 링크마다 스레드인 전 구현에 붙여도 80만
+ 건이다. 빨라진 것은 밀기 덕이고, 루프 자체의 값은 이 측정의 흔들림(1–3%) 안이다(링크 하나 −2.8%, 넷 +0.2%). 스레드
+ 셋이 줄었다.
+- 전달선은 블로킹 HTTP 클라이언트에서 비블로킹 상태기가 됐다(연결, TLS 핸드셰이크, 쓰기, 답 읽기). 그 과정에서 TLS의 함수
+ 표가 핸드셰이크를 한 걸음씩 하게 됐다.
+
+## 결과
+
+원시 결과와 명령은 [028-one-sending-loop](../../research/experiments/028-one-sending-loop/)에 있다(`runs.sh`의 단계 `build`,
+`links`, `wires`, `threads`; `sends.py`, `threads.py`, 앱 `src.aup`, `dst.aup`, `many.aup`; 링크마다 스레드에 밀기만 붙인
+`threads-push.patch`).
+
+**방법.** 한 호스트(`--apps`, 연결 스레드 하나, 실행 스레드 둘, RAM, 코어 0–2)에 앱 `src`가 항목 1,000개를 들고, `burst` 사건
+하나가 그것을 걸으며 출력 하나(`out`)에, 또는 넷(`o1`–`o4`)에 하나씩 보낸다. 링크는 호스트가 자기 `--link-port`로 봉인해
+보내고, 전달선은 자기 HTTP 포트로 보낸다. 받는 앱(`d1`–`d4`)마다 출력 하나다. 한 판은 `burst` 1,000번(보냄 100만 건, 넷이면
+400만 건)을 차례로 보내고, 모든 받는 앱이 다 저장할 때까지의 시간과 호스트의 CPU를 잰다. 받는 앱의 합이 모든 보냄이 한 번씩
+왔는지 본다. 다섯 라운드, 빌드의 순서를 라운드마다 돌렸다. 부하 평균은 첫 판 0.5–1.0, 그 뒤로는 측정 자신이 만든 2.0–2.8이다.
+
+**링크**(`2026-10-01-links-…`). 빌드 넷: 링크마다 스레드(`3791ab8`), 거기에 밀기(`threads-push.patch`), 루프만(`32f96b3`), 루프와
+밀기(`6d3c3c2`). 중앙값:
+
+| | 스레드 | 스레드와 밀기 | 루프 | 루프와 밀기 |
+|---|---:|---:|---:|---:|
+| 링크 하나(건/초, µs/건) | 596,795, 2.96 | 923,011, 1.91 | 525,093, 3.29 | 896,986, 1.92 |
+| 링크 넷 | 506,173, 3.58 | 798,600, 2.26 | 267,735, 6.84 | 800,265, 2.24 |
+| 링크 넷의 패킷 | 169,233 | 64,239 | 426,383 | 64,064 |
+| 링크 넷의 스레드 | 8 | 8 | 5 | 5 |
+| 전달선 하나(코드는 그대로) | 788,620, 2.25 | 772,398, 2.29 | 781,221, 2.25 | 794,427, 2.24 |
+
+루프만의 링크 넷은 다섯 판이 212k–387k로 흔들렸다. 깨움이 기록을 따라 생기니 두 스레드의 엇갈림에 따라 달라진다.
+
+**루프만일 때 무슨 일이 있었나**(한 번 잰 것으로, 파일로 남기지 않았다. 링크 넷, `burst` 200번, 보냄 80만 건). 루프에 셈을
+넣은 빌드로 쟀다.
+
+| | 루프만 | 루프와 밀기(모든 링크를 돌던 때) |
+|---|---:|---:|
+| 루프가 기다린 번 | 176,547 | 7,502 |
+| 그중 기다리지 않은 번(쌓인 것이 있어서) | 83,562 | 52 |
+| 깨움으로 깬 번 | 75,767 | 647 |
+| 링크의 바퀴 | 706,175 | 29,995 |
+| 소유자에게 알림 | 17,426 | 3,583 |
+| 패킷 | 91,866 | 15,270 |
+| 건/초 | 257,202 | 651,438 |
+
+스레드마다 나눠 보면(같은 조건) 링크마다 스레드일 때 링크 스레드들이 합해 user 0.17 s, sys 0.29 s, 자발적 전환 2.7만 번이었다.
+루프만일 때 루프 하나가 user 0.68 s, sys 1.15 s, 9만 번이었다. 소유자의 자발적 전환은 3.8만 번에서 16.3만 번이 됐다.
+
+**전달선**(`2026-10-01-wires-…`). 빌드 셋: 스레드(`3791ab8`), 링크만 루프와 밀기(`6d3c3c2`), 전달선도 루프(`eb3812a`). 중앙값:
+
+| | 스레드 | 링크만 루프 | 전달선도 루프 |
+|---|---:|---:|---:|
+| 전달선 하나(건/초, µs/건) | 773,787, 2.30 | 781,585, 2.27 | 778,944, 2.25 (+0.7%) |
+| 전달선 넷 | 629,470, 2.75 | 625,265, 2.78 | 615,065, 2.74 (−2.3%) |
+| 전달선 넷의 스레드 | 7 | 7 | 4 |
+| 링크 하나 | 581,019, 3.00 | 921,686, 1.90 | 948,267, 1.87 (+63%) |
+| 링크 넷 | 484,872, 3.72 | 805,687, 2.21 | 811,087, 2.21 (+67%) |
+
+전달선 넷의 −2.3%는 보냄 하나의 CPU가 같은 채로 생겼다. 넷의 요청을 스레드 넷이 따로 쓰고 읽던 것을 루프 하나가 차례로 하니,
+겹치던 몫이 줄었다. 기준(10%) 안이다.
+
+**스레드**(`2026-10-01-threads-…`, `threads.py`). 앱 `many`가 링크 열(호스트의 링크 포트로 `d1`–`d10`에)과 전달선 열(호스트의
+HTTP 포트로 `d11`–`d20`에, 봉인해서)에 하나씩 보내고, 모두 받은 뒤 센다.
+
+| | 출력 없음 | 링크 열, 전달선 열 |
+|---|---:|---:|
+| 스레드(`3791ab8`) | 3 | 24 |
+| 루프(`eb3812a`) | 3 | 5 |
+
+늘어난 둘은 보내는 루프(`pure-send`)와 링크를 받는 스레드(`pure-listen`)다.
+
+**검사.** `make check`가 모두 초록이다. 전달선의 검사(웹훅의 HTTPS와 믿지 못할 인증서, 답, 메모리, 창, 이름으로 받기,
+업그레이드, 봉인, 시계를 옮긴 재시도)는 블로킹 클라이언트를 상태기로 바꾼 뒤 첫 실행에서 모두 지났다.
+
+## 판정
+
+- **017의 2b는 들어간다.** 프로세스의 링크와 전달선은 보내는 루프 하나(`link/sending.c`)가 보낸다. 앱과 출력을 더해도 스레드는
+ 늘지 않는다.
+- 밀기(소유자가 차례의 보냄을 다 쌓고 한 번 깨우기)는 루프와 상관없이 링크를 1.6배로 만들었다. 깨움의 단위가 기록이 아니라
+ 차례여야 한다. 루프는 그것을 드러냈을 뿐이다.
+
+## 다음
+
+- **전달선 넷의 −2.3%.** 넷의 요청이 차례로 쓰이고 읽힌다. 루프가 하나라는 값이다. 바쁜 호스트에서 이것이 커지면 루프를 둘로
+ 나누는 것(출구를 루프에 나눠 배정)을 잰다.
+- **이름 찾기(DNS)가 루프를 멈춘다.** 링크가 받는 쪽을 다시 찾을 때와 전달선이 연결할 때 `getaddrinfo`가 그 자리에서 기다린다.
+ 드물지만, 그동안 모든 보냄이 멈춘다. 이름을 찾는 일을 루프 밖으로 빼는 것은 이 문제가 측정에 보일 때 한다.
+- **출구 셋의 모양.** 호스트의 출구(전달선, 링크, 안쪽 출구)가 같은 일(쌓기, 밀기, 쌓은 번호, 저장된 번호, 쥔 바이트)을 세
+ 갈래로 한다(`serve/serve.c`의 `feed_outlets`). 하나의 출구 꼴로 줄일 수 있다.
+- 017의 3단계(앱의 수명).