35 저장소와 핸들 — pool·shard·budget·wire
먼저 알아야 할 것
newtype 은 표현이 같은 새 타입이다owned 값은 넘기면 옮겨진다돌아보기
10장에서 나무를 번호로 이을 때 생기는 불편으로 무엇을 들었고, 그것을 무엇이 잡는다고 했는가?
답. 노드를 지우고 그 번호를 다시 쓰면 옛 번호를 들고 있던 쪽이 엉뚱한 노드를 본다는 것이었다. 표준 라이브러리의 pool 이 번호에 세대 수를 함께 담는 세대 핸들로 이것을 실행 중에 잡는다고 했다. 이 장이 그 pool 과, 저장소를 다루는 다른 모듈들을 본다.
이 장의 필요성과 맥락
이 장이 끝나면
pool 이 고정 크기 블록을 세대 핸들로 빌려주고, 돌려준 뒤의 옛 핸들을 알아보는 방식을 익힌다. newtype 브랜드로 서로 다른 풀을 섞는 실수를 번역에서 막는 법을 알게 된다. shard 가 소유 토큰으로 저장소를 겹치지 않는 조각으로 나누는 모습, budget 이 핸들의 비트 예산을 번역 시점에 확인하는 모습, wire 가 마스크 하나로 비트 칸을 다루는 모습도 보게 된다.이 장에서 답할 질문
- 세대 비교는 실행 중 검사다. 번역에서 막을 수는 없는가?
35.1 세대 핸들#
examples/ch35/blocks.low
module blocks .
rem run: main
use pool .
newtype objects u8 .
proc main input al cap allocator . output u8 . effects alloc .
do
let gm option mut slice u8 be alloc_bytes al capacity 64 .
let gg option mut slice u8 be alloc_bytes al capacity 32 .
guard is_some gm . else return 255 .
guard is_some gg . else return 255 .
var gens mut slice u64 be view_array u64 (some_value gg) .
let po option (pool.block_pool objects) be pool.init objects (some_value gm) gens 16 .
guard is_some po . else return 254 .
var p pool.block_pool objects be some_value po .
let ho option (pool.handle objects) be pool.take objects p .
guard is_some ho . else return 253 .
let h pool.handle objects be some_value ho .
let before bool be pool.alive objects p h .
let first bool be pool.release objects p h .
let after bool be pool.alive objects p h .
let again bool be pool.release objects p h .
var code u8 be 0 .
if before . do set code (add code 1) . end
if first . do set code (add code 2) . end
if after . do set code (add code 4) . end
if again . do set code (add code 8) . end
return code .
end
실행 결과
$ lowentc --run main blocks.low
main() = 3
pool.init objects mem gens 16은 호출자가 건넨 바이트mem을 16 바이트 블록으로 나누고, 블록마다 세대 수를gens에 둔다. 풀은mem과gens를 봉해 들어서 이후의 op 은 그것을 따로 받지 않는다.pool.take는 블록 하나를 빌려주고 핸들을 준다. 핸들에는 블록 번호와 그때의 세대 수가 들어 있다.pool.release는 블록을 돌려받으며 그 블록의 세대 수를 하나 올린다.- 돌려준 뒤의 옛 핸들은 세대가 달라
alive가 거짓이고, 두 번째release도 거절된다.
결과 3 은 “돌려주기 전에 살아 있었고(1), 첫 반납이 성공했다(2)” 이고, 돌려준 뒤 살아 있지도(4) 두 번째 반납이 성공하지도(8) 않았다는 뜻이다. 지운 뒤 사용과 두 번 해제가 실행 중에 값으로 드러난다.
문. 세대 비교는 실행 중 검사다. 번역에서 막을 수는 없는가?
답. 핸들은 복사되고 저장되고 파일에 쓰일 수 있는 값이라서, 어느 핸들이 이미 낡았는지를 번역이 모두 알 수는 없다. 그래서 이 자리는 동적 검사다(19장의 강도 표에서 “세대 핸들의 늘어진 참조 — 동적”). 대신 번역이 막을 수 있는 실수 — 풀을 섞는 것 — 은 아래처럼 번역에서 막는다. 무엇을 번역이 막고 무엇을 실행이 막는지가 모듈 문서의 첫머리에 적혀 있다.
35.2 브랜드 — 풀을 섞지 못하게#
풀과 핸들은 번역 시점 타입 매개변수로 브랜드를 든다. newtype objects u8 . 선언 하나가 곧 저장소 하나다.
examples/ch35/mixed.low
module mixed .
rem expect: E-TYPE-INSTANCE
use pool .
newtype meshes u8 .
newtype sounds u8 .
proc wrong input m mut pool.block_pool meshes . input h pool.handle sounds . output bool . effects none .
do
return pool.release meshes m h .
end
실행 결과
$ lowentc --check mixed.low
mixed.low:11:0 E-TYPE-INSTANCE: two instances of the same generic template are DIFFERENT concrete types — a container opened for one element type cannot be bound to a variable declared for another. Monomorphization makes each instance a distinct struct (visible as `name#arg` in `--ir`), and their layouts differ; binding across them reads the same storage at the wrong element width and yields a wrong value with no error at all. Declare the variable with the SAME instance, or convert explicitly (RFC-0021 · RFC-0084)
sounds 풀에서 빌린 핸들을 meshes 풀에 돌려주려 하면, 두 인스턴스가 서로 다른 구체 타입이라 거절된다. 브랜드는 값이 아니라 타입이라서 핸들의 칸은 늘지 않는다. 비용이 0 이다. 같은 브랜드로 저장소를 두 번 여는 것도 거절된다(E-BRAND-REUSED) — 그러면 한 이름이 둘을 가리키게 되어 브랜드가 막으려던 혼동이 돌아온다.
35.3 겹치지 않는 조각 — shard#
shard 는 저장소 한 덩어리를 서로 겹치지 않는 조각으로 나누고, 조각마다 토큰을 준다. 토큰은 “이 범위는 내 것” 이라는 권리이고 owned 값이다.
examples/ch35/split.low
module split_tokens .
rem expect: E-OWN-MOVED
use shard .
newtype grid u8 .
fn halves_then_root output u64 .
do
var r owned shard.token grid be shard.open grid 8 .
var h shard.halves grid be shard.split_at grid r 4 .
return shard.width grid r .
end
실행 결과
$ lowentc --check split.low
split.low:12:0 E-OWN-MOVED: this `owned` value was already MOVED (consumed) — using it again is use-after-move, which SPEC-004 §4.8 has always called a compile error and which nothing enforced. To keep using it, either CONSUME AND PUT IT BACK (`set <name> <new value>` re-initialises the place — that is how a handle threads through a loop), or borrow it LOCALLY with `ref h`. ☞ borrowing across an OP BOUNDARY is not lowered yet (E-IR-UNSUP says so at the call site), so `f (ref h)` is not a way out today
shard.split_at grid r 4 는 전체 토큰 r 을 소비해 둘로 나눈다. 그 뒤 r 로 폭을 물으면 E-OWN-MOVED 다. 이 실수를 막는 것은 shard 모듈이 아니라 언어다. 토큰이 owned 라서 넘기는 순간 손을 떠난다. 접근 단위라는 개념이 새 규칙이 아니라 이미 있던 규칙의 결과다. 나눈 조각을 여러 흐름에 태우든 순차로 돌든, 겹치지 않는다는 사실 자체가 값이다.
같은 모양이 pagecache 에도 있다. 페이지를 고정(acquire)하면 캐시 토큰이 소비되어, 고정이 살아 있는 동안 축출(evict)에 건넬 토큰이 손에 없다. 고정된 페이지를 쫓아내는 결함이 번역에서 막힌다.
35.4 비트 예산 — budget#
세대 핸들을 한 워드에 넣으려면 “칸 번호·샤드 번호·세대 수에 몇 비트씩 주나” 를 정해야 한다. budget 은 그 수를 소스에 적고 도구가 지키게 한다.
examples/ch35/budget_bad.low
module budget_bad .
rem expect: E-CONTRACT-IMPOSSIBLE
use budget .
fn too_wide output u64 .
do
let ho option u64 be budget.pack 40 16 16 7 1 0 .
guard is_some ho . else return 0 .
return some_value ho .
end
실행 결과
$ lowentc --check budget_bad.low
budget_bad.low:8:0 E-CONTRACT-IMPOSSIBLE: this call breaks the callee's `requires`, and BOTH SIDES ARE CONSTANTS — so it can be decided here, now. It used to compile green and trap at run time (E-VM-CONTRACT): a bit budget that does not fit (`slot + shard + generation > word`) shipped as a runnable program. A contract that can be decided at compile time IS decided at compile time (RFC-0104 §8-3)
budget.pack 40 16 16 … 은 합이 72 비트라서 64 비트 워드에 들어가지 않는다. 인자가 모두 상수이므로 계약 위반이 번역에서 판정된다 (14장). 진단이 적은 사연대로, 맞지 않는 비트 예산을 지닌 프로그램이 한때 실행할 수 있는 모습으로 배포되었다. 이 모듈은 그 일 때문에 생긴 검사를 쓰는 첫 자리다. 세대 수가 다 차면 그 칸은 은퇴한다(retired). 몰래 0 으로 감아 옛 핸들이 다시 살아나는 일은 없다.
35.5 비트 칸 — wire#
examples/ch35/fields.low
module fields .
rem run: mode_of 1234
rem run: with_mode 1234 5
use wire .
fn mode_of input w u64 . output u64 .
do
return wire.pick 3840 w .
end
fn with_mode input w u64 . input m u64 .
output u64 .
requires le m 15 .
do
return wire.merge 3840 w m .
end
실행 결과
$ lowentc --run mode_of fields.low 1234
mode_of(1234) = 4
$ lowentc --run with_mode fields.low 1234 5
with_mode(1234, 5) = 1490
한 정수 안에 여러 값을 나란히 넣을 때, wire 는 칸을 마스크 하나로 다룬다. 마스크 0xF00(3840)는 “8 번 비트에서 시작하는 4 비트” 라는 자리와 폭을 둘 다 말한다. pick 은 그 칸의 값을 꺼내고, merge 는 그 칸만 갈아 끼운다. 1234 는 0x4D2 이므로 칸의 값은 4 이고, 5 로 갈아 끼우면 0x5D2(1490)다. 손으로 적을 때 늘 갈리던 “자리” 와 “폭” 두 수가 한 마스크에서 나오므로 갈릴 수 없다. 이름 붙은 켬/끔 설정을 한 워드에 담는 일은 flags 가 맡는다.
흔한 오해. 이런 모듈은 결국 unsafe 코드로 짜여 있을 것이다
pool·shard·budget·wire 는 모두 이 책에서 배운 Lowent 로 쓰였고 unsafe 가 없다. 세대 비교는 슬라이스 색인과 비교이고, 브랜드는 newtype 과 타입 매개변수이고, 토큰은 owned 이고, 예산은 계약이다. 새 낱말도 새 내장 연산도 늘지 않았다. 라이브러리가 특권 없이 쓰였기 때문에, 그 라이브러리를 쓰는 프로그램의 보장이 끊기지 않는다.35.6 그 밖의 저장소 모듈#
| 모듈 | 한 줄 |
|---|---|
allocs | 범프 할당기와 할당기 트레이트, 기본 할당기(20장) |
segarena | 같은 크기의 조각으로 자라며 이미 있는 것을 옮기지 않는다. 비용이 op 이름에 적혀 있다 |
pagecache | 오래 사는 이름(페이지 번호)과 짧게 닿는 권리(고정)를 가른다 |
segview | 흩어진 조각 뷰의 커서·총길이·펴기 |
lifemode · lifeatom | 값의 끝을 정하는 네 방법 — 단일 소유·스레드 국한 참조 계수·원자 참조 계수·외부 완결 |
표 35.1 — 그 밖의 저장소 모듈
35.7 흔한 실수#
반례. 돌려준 블록의 바이트를 묻지 않고 꺼낸다
examples/ch35/mistake_staleuse.low
module mistake_staleuse .
rem trap: main
use pool .
newtype objects u8 .
proc main input al cap allocator . output u8 . effects alloc .
do
let gm option mut slice u8 be alloc_bytes al capacity 64 .
let gg option mut slice u8 be alloc_bytes al capacity 32 .
guard is_some gm . else return 255 .
guard is_some gg . else return 255 .
var gens mut slice u64 be view_array u64 (some_value gg) .
let po option (pool.block_pool objects) be pool.init objects (some_value gm) gens 16 .
guard is_some po . else return 254 .
var p pool.block_pool objects be some_value po .
let ho option (pool.handle objects) be pool.take objects p .
guard is_some ho . else return 253 .
let h pool.handle objects be some_value ho .
let done bool be pool.release objects p h .
rem ✘ 돌려준 블록의 바이트를 묻지 않고 꺼낸다 --- 세대가 달라 `none` 이다
var b mut slice u8 be some_value (pool.bytes objects p h) .
set (index b 0) 7 .
return index b 0 .
end
실행 결과
$ lowentc --run main mistake_staleuse.low
== ir diagnostics (1) ==
0:0 E-VM-NONE: some_value of none (panic)
pool.bytes 는 블록 바이트에 닿는 유일한 문이고, 세대가 맞지 않으면 none 을 준다. C 라면 해제한 블록에 7 을 쓰는 순간 다른 객체가 조용히 망가지지만, 여기서는 그 자리가 none 이라는 값으로 드러난다. 그 값을 some_value 로 묻지 않고 꺼내면 이 예제처럼 멈춘다. 멈추는 편이 망가지는 것보다 낫지만, 핸들이 오래 살 수 있는 코드는 is_some 으로 묻고 낡은 핸들을 따로 다룬다.
반례. 같은 브랜드로 저장소를 둘 연다
examples/ch35/mistake_brandreuse.low
module mistake_brandreuse .
rem expect: E-BRAND-REUSED
use pool .
newtype objects u8 .
proc two_pools input al cap allocator . output u8 . effects alloc .
do
let g1 option mut slice u8 be alloc_bytes al capacity 64 .
let g2 option mut slice u8 be alloc_bytes al capacity 64 .
let e1 option mut slice u8 be alloc_bytes al capacity 32 .
let e2 option mut slice u8 be alloc_bytes al capacity 32 .
guard is_some g1 . else return 255 .
guard is_some g2 . else return 255 .
guard is_some e1 . else return 255 .
guard is_some e2 . else return 255 .
var gens1 mut slice u64 be view_array u64 (some_value e1) .
var gens2 mut slice u64 be view_array u64 (some_value e2) .
let p1 option (pool.block_pool objects) be pool.init objects (some_value g1) gens1 16 .
rem ✘ 같은 브랜드 `objects` 로 저장소를 또 연다 --- 두 풀의 핸들이 섞여도 타입이 막지 못한다
let p2 option (pool.block_pool objects) be pool.init objects (some_value g2) gens2 16 .
return 0 .
end
실행 결과
$ lowentc --check mistake_brandreuse.low
22:0 E-BRAND-REUSED: this brand already opened a storage somewhere else — a brand names ONE storage instance, and a second creation site makes it name two. Then two different storages share a type, and handles from one type-check against the other: the very confusion the brand exists to refuse. Declare a second `newtype` and use it here (a brand carries no data, so an extra one costs nothing at run time) (RFC-0104 §5.11 · §8-2)
브랜드 objects 는 저장소 하나의 이름이다. 둘째 pool.init objects … 가 통과하면 두 풀이 같은 타입을 나누게 되어, 첫 풀의 핸들을 둘째 풀에 돌려주는 실수를 타입이 막지 못한다. 그래서 E-BRAND-REUSED 다. 저장소마다 newtype 을 하나씩 선언한다. 선언 한 줄이 비용 없는 구별이다.
흔한 오해. wire 의 칸에 넣은 값은 넘치면 멈춘다
examples/ch35/wire_truncates.low
module wire_truncates .
rem run: put_mode 5
rem run: put_mode 20
rem run: fits_mode 20
use wire .
rem 칸은 네 비트(0 … 15)다 --- 20 은 잘려 4 가 들어간다
fn put_mode input m u64 . output u64 .
do
return wire.put 3840 m .
end
rem 잘리는 것이 싫으면 먼저 묻는다
fn fits_mode input m u64 . output bool .
do
return wire.fits 3840 m .
end
실행 결과
$ lowentc --run put_mode wire_truncates.low 5
put_mode(5) = 1280
$ lowentc --run put_mode wire_truncates.low 20
put_mode(20) = 1024
$ lowentc --run fits_mode wire_truncates.low 20
fits_mode(20) = 0
wire.put 은 칸보다 큰 값을 자른다. 네 비트 칸에 20(0b10100)을 넣으면 아래 네 비트 4 만 들어가 1024(0x400)가 된다. 모듈 문서가 그렇게 약속하고, 잘리는 것이 싫으면 wire.fits 로 먼저 물으라고 적는다(20 은 0, 곧 거짓). 선으로 나가는 비트 칸은 잘리는 것이 표준 동작인 경우가 많아 이렇게 정했다. 멈추기를 원하는 자리는 requires wire.fits … 를 계약으로 적는다.
35.8 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
newtype objects u8 . | 저장소 하나의 브랜드 | 비용 없는 구별 — 섞으면 E-TYPE-INSTANCE, 두 번 열면 E-BRAND-REUSED |
pool.init objects mem gens 16 | 호출자 바이트를 블록으로 나눈 풀(봉해 든다) | 뒤의 op 은 mem·gens 를 따로 받지 않는다 |
pool.take · pool.release · pool.alive | 빌리기(세대 핸들) · 돌려주기(세대 올림) · 묻기 | 지운 뒤 사용·두 번 해제가 값으로 드러난다 |
pool.bytes objects p h → option mut slice u8 | 블록 바이트에 닿는 유일한 문 | 세대가 다르면 none |
var r owned shard.token grid be shard.open grid 8 . | 겹치지 않는 조각의 소유 토큰 | 나누면 옛 토큰은 E-OWN-MOVED |
budget.pack … | 비트 예산을 계약으로 확인 | 맞지 않으면 번역에서 E-CONTRACT-IMPOSSIBLE |
wire.pick mask w · wire.merge mask w v · wire.fits | 마스크 하나로 비트 칸 읽기 · 갈아 끼우기 · 들어가는지 | 자리와 폭이 한 수에서 나온다 — put 은 자른다 |
표 35.2 — 저장소와 핸들의 모양 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
pool 은 세대 핸들로 블록을 빌려주고, 돌려준 뒤의 옛 핸들과 두 번 해제를 실행 중에 값으로 알린다. newtype 브랜드는 풀을 섞는 실수를 번역에서 막고 비용이 없다. shard·pagecache 는 owned 토큰으로 겹침과 고정 중 축출을 번역에서 막는다. budget 은 핸들의 비트 예산을 계약으로 번역 시점에 확인하고, wire 는 마스크 하나로 비트 칸의 자리와 폭을 함께 다룬다. 모두 새 낱말 없이 언어의 규칙만으로 쓰였다.