Lowent 매뉴얼←↑→

20 할당기와 고정 메모리

먼저 알아야 할 것

18장 영역 · 고정 창과 힙, 두 뿌리
16장 권한 · 권한은 종류로 인가되고 실행 비용이 없다
15장 효과 · via 는 타입 인자가 할당 효과를 정하게 한다

돌아보기

18장에서 운영체제가 없는 기계를 대상으로 지으면 무엇이 거절되었고, 무엇은 여전히 쓸 수 있었는가?

답. heap 효과·cap heap 입력·region … heap 블록이 모두 E-HEAP-NOHOST 로 거절되었다. 자라지 않는 고정 창에서 깎는 alloc 은 여전히 쓸 수 있었다. 이 장은 그 고정 창 위에서, 그리고 남이 빌려준 바이트 위에서 자리를 나눠 주는 할당기를 다룬다.

이 장의 필요성과 맥락

영역은 “블록이 끝날 때 한꺼번에” 라는 한 가지 정책이다. 실제 프로그램은 더 많은 정책을 원한다. 커다란 버퍼 하나를 받아 앞에서부터 잘라 쓰고 싶고, 정렬을 맞춰 자르고 싶고, 같은 코드를 힙에서도 고정 창에서도 돌리고 싶다. Lowent 에서 할당기는 언어의 특별한 장치가 아니라 트레이트를 갖춘 액터다. 그래서 사용자가 만든 할당기와 표준 할당기가 같은 자리에 들어간다. 메모리를 다루는 제5부의 마지막 장으로, 운영체제 없는 기계까지 이어지는 고정 메모리의 길을 정리한다.

이 장이 끝나면

할당기가 권한·정책·상태 셋으로 이루어진다는 것을 알게 된다. 빌린 바이트를 잘라 주는 범프 할당기를 쓰고, 메모리 부족이 값이라는 것을 확인한다. 할당기를 타입 매개변수로 받고 using 으로 건네는 법, 뿌리에서 곧장 깎는 기본 할당기와 권한 칸의 규칙을 익힌다. 운영체제 없는 기계의 고정 창 크기를 링커가 정하는 방식과, 비트를 그대로 둔 채 읽는 법만 바꾸는 bit_cast 도 보게 된다.

이 장에서 답할 질문

  1. 범프 할당기로 조각 하나를 돌려줄 수 있는가?

20.1 할당기를 이루는 셋#

층언제 있나무엇인가
권한번역할 때만뿌리에 닿아도 되는가 — cap allocator · cap heap
정책번역할 때(타입)어떤 규칙으로 깎는가 — byte_allocator 트레이트를 갖춘 타입
상태실행 중커서와 받침 바이트 — 그 타입의 값(액터)

표 20.1 — 할당기를 이루는 셋

표준 라이브러리 allocs 의 트레이트 byte_allocator 는 세 op 을 요구한다. reserve n 은 n 바이트를 잘라 option 으로 주고, grow 는 마지막 조각을 제자리에서 늘리며, used 는 지금까지 쓴 양을 답한다. 액터(25장)는 자기 상태를 들고 send 로만 말을 거는 객체다. 여기서는 spawn actor 로 만들고 send a reserve 3 으로 부른다는 것만 알면 된다.

20.2 빌린 바이트를 잘라 준다#

가장 단순한 할당기는 범프다. 커서 하나를 두고, 달라는 만큼 잘라 준 뒤 커서를 민다.

    buf (8 바이트)
    [ a  a  a │ b  b │ ·  ·  · ]
      └ reserve 3 이 준 조각
                └ reserve 2 가 준 조각
                       ▲ 커서 (used = 5) --- 다음 reserve 는 여기서부터 자른다

reserve 4 를 청하면 남은 것은 3 바이트뿐 → none (모자람도 값이다)

examples/ch20/borrowed.low

module borrowed .
rem run: carve [0,0,0,0,0,0,0,0]

use allocs .

proc carve input buf mut slice u8 . output u64 . effects state .
do
  var a allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let room u64 be send a init buf .
  let p option mut slice u8 be send a reserve 3 .
  guard is_some p . else return 91 .
  let pv mut slice u8 be some_value p .
  set (index pv 0) 65 .
  let q option mut slice u8 be send a reserve 99 .
  guard not (is_some q) . else return 92 .
  return send a used .
end

실행 결과

$ lowentc --run carve borrowed.low [0,0,0,0,0,0,0,0]
carve([65,0,0,0,0,0,0,0]) = 3
  arg0 (written) = [65,0,0,0,0,0,0,0]

이 op 의 머리에는 cap allocator 도 alloc 도 없다. 효과는 state 뿐이다. 바이트는 호출자가 빌려주었고, 할당기는 그것을 나눠 줄 뿐이기 때문이다. 권한이 능력을 가른다 — 할당 권한을 받지 않은 코드도 남이 준 바이트 위에서는 온전히 할당한다.

문. 범프 할당기로 조각 하나를 돌려줄 수 있는가?

답. 마지막 조각 하나만 돌려받는다(release, 트레이트 freeing_allocator). 그 밖의 조각은 false 를 답하고 아무것도 바꾸지 않는다. 모르는 조각을 받아 주는 척하면 두 번 돌려주기가 남의 자리를 지우기 때문이다. 받고 놓기를 되풀이하는 모양이면 세대 핸들을 쓰는 pool 이 맞다(35장).

20.3 할당기를 갈아 끼운다#

할당기를 쓰는 코드는 어느 구현인지 몰라도 된다. 할당기의 타입을 번역 시점 매개변수로 받고, 그 값은 using 절로 받는다.

examples/ch20/generic.low

module generic .
rem run: with_bump [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
rem run: with_aligned [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

use allocs .

proc two_from
  input comptime a type .
  using al a .
  output u64 .
  effects state via a .
  requires allocs.byte_allocator a .
do
  let p option mut slice u8 be send al reserve 3 .
  guard is_some p . else return 91 .
  let q option mut slice u8 be send al reserve 5 .
  guard is_some q . else return 92 .
  return send al used .
end

proc with_bump input buf mut slice u8 . output u64 . effects state .
do
  var b allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let c u64 be send b init buf .
  let n u64 using b be two_from .
  return n .
end

proc with_aligned input buf mut slice u8 . output u64 . effects state .
do
  var b allocs.bump_aligned be spawn actor allocs.bump_aligned .
  let c u64 be send b init buf .
  let n u64 using b be two_from .
  return n .
end

실행 결과

$ lowentc --run with_bump generic.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
with_bump([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) = 8
  arg0 (written) = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
$ lowentc --run with_aligned generic.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
with_aligned([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) = 13
  arg0 (written) = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

같은 two_from 에 bump_bytes 를 주면 3 + 5 = 8 을 쓰고, 8 의 배수로 시작점을 맞추는 bump_aligned 를 주면 둘째 조각이 8 에서 시작해 13 을 쓴다. 번역할 때 타입이 확정되므로 가상 함수 표도 간접 호출도 없다. 갈아 끼우기의 실행 비용이 0 이다.

출처를 적지 않으면 기본값이 정해진다. 바인딩의 using, 그 op 의 using 이름, 그리고 타입이 맞는 입력·바인딩이 하나뿐이면 그것을 쓴다. 둘 이상이면 짐작하지 않고 E-ALLOC-AMBIGUOUS 로 적으라고 한다. 기본값은 op 경계를 넘지 않는다. 부른 쪽의 할당기가 저절로 흘러드는 일이 없으니, 전역 할당기는 없다.

차례출처왜 이 자리인가
1바인딩에 적은 using <이름>적은 것이 언제나 이긴다
2이 op 의 using 절 이름 가운데 타입이 맞는 유일한 것op 이 스스로 받겠다고 한 할당기
3이 op 의 입력·바인딩 가운데 타입이 맞는 유일한 것눈에 보이는 값이 하나뿐이면 헷갈릴 일이 없다
없음E-ALLOC-NOSOURCE전역 할당기로 메우지 않는다
둘 이상E-ALLOC-AMBIGUOUS짐작하지 않고 후보 이름을 모두 말한다

표 20.2 — 할당기 출처를 정하는 차례 — 위에서 처음 맞는 것

필드는 세지 않는다. 구조체 안에 숨은 할당기가 조용히 쓰이면, 어느 버퍼가 줄어드는지 코드를 읽어서 알 수 없기 때문이다.

출처가 하나도 없으면 이렇게 거절된다.

examples/ch20/nosource.low

module nosource .
rem expect: E-ALLOC-NOSOURCE

use allocs .

proc one_from
  input comptime a type .
  using al a .
  output u64 .
  effects state via a .
  requires allocs.byte_allocator a .
do
  let p option mut slice u8 be send al reserve 3 .
  guard is_some p . else return 91 .
  return send al used .
end

proc caller output u64 . effects state .
do
  rem ✘ 이 op 안에는 깎아 올 할당기가 하나도 없다
  let n u64 be one_from .
  return n .
end

실행 결과

$ lowentc --check nosource.low
21:16 E-ALLOC-NOSOURCE: this call draws from an allocator, but NO value of a fitting type is visible in this op — no input, no binding, no `using` clause. There is no global allocator: take one as an input, create one here, or declare `using <name> <type> .` on this op (RFC-0112 D8(4))
nosource.low:19:1 W-EFFECT-OVER: this op DECLARES `state` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)

caller 에는 입력도, 띄운 액터도, using 절도 없다. 다른 언어라면 여기서 전역 힙이 조용히 쓰였을 것이다. Lowent 는 할당기를 입력으로 받거나, 이 자리에서 만들거나, using 절을 적으라고 한다. 호출이 거절되었으니 caller 의 state 도 실제로 쓰이지 않아 W-EFFECT-OVER 가 함께 붙는다. 첫 오류를 고치면 사라진다.

거꾸로, 할당기를 쓰지 않는 호출에 using 을 적어도 거절된다.

examples/ch20/usingunused.low

module usingunused .
rem expect: E-ALLOC-USING-UNUSED

use allocs .

fn twice input x u64 . output u64 . do
  return mul x 2 .
end

proc caller input buf mut slice u8 . output u64 . effects state .
do
  var b allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let c u64 be send b init buf .
  rem ✘ twice 는 할당기를 쓰지 않는데 using 으로 골라 준다
  let n u64 using b be twice 4 .
  return n .
end

실행 결과

$ lowentc --check usingunused.low
15:19 E-ALLOC-USING-UNUSED: this binding says which allocator to use, but the call it initialises does not draw from one (no `using` clause on that op). An object that already carries its allocator — like `vecgen.append` on a vector — does not take the caller's choice (RFC-0112 D8(5))

twice 는 수를 두 배로 할 뿐 할당기를 받지 않는다. 쓰이지 않을 선택을 적어 두면 읽는 사람은 twice 가 메모리를 쓴다고 오해한다. 그래서 적은 것은 반드시 쓰여야 한다.

20.4 뿌리에서 곧장 깎는 기본 할당기#

allocs 는 뿌리에서 곧장 깎는 할당기 둘도 같은 트레이트로 낸다.

액터상태reserve 의 효과
fixed_bytesroot cap allocator . · 쓴 양alloc state — 어디서나
heap_bytesroot cap heap . · 쓴 양heap state — 운영체제가 있는 기계만

표 20.3 — 기본 할당기

examples/ch20/fixed.low

module fixed .
rem run: main

use allocs .

proc main input al cap allocator . output u8 . effects alloc state .
do
  var fb allocs.fixed_bytes be spawn actor allocs.fixed_bytes .
  let a option mut slice u8 be send fb reserve 64 .
  guard is_some a . else return 1 .
  let b option mut slice u8 be send fb reserve 64 .
  guard is_some b . else return 2 .
  return narrow u8 (send fb used) .
end

실행 결과

$ lowentc --run main fixed.low
main() = 128

fixed_bytes 의 상태에는 권한 칸이 있다. 권한은 실행 중 값이 아니므로 그 칸은 크기가 없다. 대신 규칙이 하나 붙는다. 권한 칸을 가진 액터는 같은 종류의 권한을 쥔 op 에서만 띄울 수 있다.

examples/ch20/forge.low

module forge .
rem expect: E-CAP-FORGE

use allocs .

proc sneaky output u64 . effects heap state .
do
  var hb allocs.heap_bytes be spawn actor allocs.heap_bytes .
  return send hb used .
end

실행 결과

$ lowentc --check forge.low
forge.low:8:0 E-CAP-FORGE: this spawns an actor whose state HOLDS a capability (`cap allocator` / `cap heap`), but the op spawning it holds no capability of that kind. A capability field means nothing at run time — it is only real because the place that creates the actor already had the right. Without that, one `spawn` would forge authority out of thin air (RFC-0112 D6). Take the capability as an input of this op
forge.low:7:1 W-EFFECT-OVER: this op DECLARES `heap` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)

sneaky 는 cap heap 을 받지 않았는데 heap_bytes 를 띄우려 했다. 이것이 허락되면 권한 없는 곳에서 한 줄로 힙을 지어낼 수 있다. 권한은 건네받는 것이지 주워 쓰는 것이 아니다.

권한을 받으면 같은 일이 된다.

examples/ch20/heapbytes.low

module heapbytes .
rem run: main

use allocs .

proc main input h cap heap . output u8 . effects heap state .
do
  var hb allocs.heap_bytes be spawn actor allocs.heap_bytes .
  rem 고정 창(기본 64 KiB)보다 큰 100000 바이트도 힙은 준다
  let a option mut slice u8 be send hb reserve 100000 .
  guard is_some a . else return 1 .
  rem 쓴 양이 100000 이 아니면 2, 맞으면 0 을 돌려준다
  guard eq (send hb used) 100000 . else return 2 .
  return 0 .
end

실행 결과

$ lowentc --run main heapbytes.low
main() = 0

main 이 cap heap 을 받았고 효과 줄에 heap 을 적었다. 그래서 heap_bytes 를 띄울 수 있고, 고정 창보다 큰 100000 바이트도 받는다. 힙은 모자라면 청크를 더 잇는다. 운영체제가 없는 기계를 대상으로 지으면 이 파일은 E-HEAP-NOHOST 로 거절된다(18장).

20.5 조각을 늘리고 돌려준다 — grow 와 release#

범프 할당기는 앞으로만 민다. 그래도 마지막에 준 조각만은 되돌려도 안전하다. 그 뒤로 아무도 자리를 받지 않았기 때문이다. grow 는 그 조각을 제자리에서 늘리고, release(트레이트 freeing_allocator)는 그 조각을 돌려받는다.

examples/ch20/growrelease.low

module growrelease .
rem run: regrow [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

use allocs .

proc regrow input buf mut slice u8 . output u64 . effects state .
do
  var b allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let c u64 be send b init buf .
  let p option mut slice u8 be send b reserve 4 .
  guard is_some p . else return 91 .
  let pv mut slice u8 be some_value p .
  rem 방금 받은 조각을 4 에서 6 바이트로 제자리에서 늘린다
  let g option mut slice u8 be send b grow pv 6 .
  guard is_some g . else return 92 .
  let gv mut slice u8 be some_value g .
  let q option mut slice u8 be send b reserve 2 .
  guard is_some q . else return 93 .
  let qv mut slice u8 be some_value q .
  rem gv 는 마지막 조각이 아니다(뒤에 qv 가 있다) — 돌려받지 않는다
  let back1 bool be send b release gv .
  rem qv 는 마지막 조각이다 — 돌려받는다
  let back2 bool be send b release qv .
  let u u64 be send b used .
  var d1 u64 be 0 .
  if back1 . do
    set d1 1 .
  end
  var d2 u64 be 0 .
  if back2 . do
    set d2 1 .
  end
  rem 쓴 양 · 첫 답 · 둘째 답을 세 자리 수로 묶어 돌려준다
  return add (mul u 100) (add (mul d1 10) d2) .
end

실행 결과

$ lowentc --run regrow growrelease.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
regrow([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) = 601
  arg0 (written) = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

두 op 모두 최적화이지 약속이 아니다. 못 늘리면 부르는 쪽이 새로 받아 복사하면 되고, 답은 같아야 한다. 뿌리에서 곧장 깎는 fixed_bytes·heap_bytes 의 grow 는 언제나 none 이다. 뿌리는 마지막 조각이 누구 것인지 모른다.

20.6 표준 라이브러리에서 셋은 어디에 있나#

모듈 · 이름권한정책(타입)상태(값)
allocs.bump_bytes · bump_aligned없다 — 빌린 바이트byte_allocator · freeing_allocator 를 갖춘 액터받침 바이트 · 커서
allocs.fixed_bytes · heap_bytes권한 칸 cap allocator · cap heapbyte_allocator쓴 양
vecgen.vec t a없다할당기 타입 a 를 매개변수로 받는다원소 수 · 버퍼 — 할당기는 open 이 using 으로 받는다
growvec.gvec없다vecgen.vec u8 allocs.bump_bytes 로 고정한 이름vecgen 과 같다
pool.block_pool b없다 — 빌린 바이트할당기가 아니다 — 세대 핸들로 블록을 낱낱이 돌려받는 구조체블록 · 세대 배열

표 20.4 — 할당에 쓰는 모듈 — 권한 · 정책 · 상태 가운데 무엇을 맡나

권한은 뿌리에 닿는 두 액터에만 있다. 나머지는 모두 누군가 건네준 바이트 위에서 일한다. 그래서 권한을 받지 않은 라이브러리 코드도 그릇을 만들고 키울 수 있고, 어느 코드가 새 메모리를 프로그램에 들여오는지는 권한을 받은 op 만 보면 안다.

20.7 고정 창의 크기는 누가 정하나#

운영체제가 없는 기계에서 고정 창은 링커가 정한 두 기호 사이의 메모리다. 크기는 실행 파일에 박히지 않는다. 컴파일러가 링커 스크립트 조각을 내주고, 보드마다 그 조각의 크기만 바꾼다.

$ lowentc --emit-ldscript --fixed-bytes 4096 fixed.low
.lw_fixed (NOLOAD) : ALIGN(8)
{
    __lw_fixed_start = .;
    . = . + 4096;
    __lw_fixed_end = .;
}

호스트에서 다른 보드를 흉내 낼 때는 VM 에 창의 크기를 준다. fixed.low 는 64 바이트를 두 번 청하므로, lowentc --fixed-bytes 100 --run main fixed.low 로 창을 100 바이트로 줄이면 둘째 reserve 가 none 이 되어 main() = 2 가 나온다. 작은 기계에서 어디서 메모리가 모자라는지를 개발 기계에서 먼저 볼 수 있다.

흔한 오해. 임베디드에서는 동적 할당을 쓰지 않으니 할당기가 필요 없다

“동적 할당 금지” 의 속뜻은 자라는 힙과 실패를 숨기는 할당을 쓰지 말라는 것이다. 부팅 때 한 번 정해진 창에서 깎고, 부족을 값으로 돌려주고, 한꺼번에 되감는 할당은 그 규율과 충돌하지 않는다. Lowent 의 고정 창은 크기가 링커에 적히고, 부족은 none 이며, 힙 효과는 번역에서 막힌다. 규칙을 문법이 지키는 것이다.

20.8 비트는 그대로, 읽는 법만#

같은 바이트를 다른 타입으로 읽는 일은 두 갈래다. 바이트 슬라이스 위에 구조체 배치를 얹는 view 는 13장에서 보았다. 폭이 같은 두 스칼라 사이에서 비트열을 그대로 두고 읽는 법만 바꾸는 것은 bit_cast 다.

examples/ch20/bitcast.low

module bitcast .
rem run: reinterpret -1
rem run: float_bits 1.0

fn reinterpret input x i32 . output u32 .
do
  return bit_cast u32 x .
end

fn float_bits input f f64 . output u64 .
do
  return bit_cast u64 f .
end

실행 결과

$ lowentc --run reinterpret bitcast.low -1
reinterpret(-1) = 4294967295
$ lowentc --run float_bits bitcast.low 1.0
float_bits(1.0) = 4607182418800017408

i32 의 −1 은 모든 비트가 1 이므로 u32 로 읽으면 4294967295 다. f64 의 1.0 을 u64 로 읽으면 IEEE 754 표현이 그대로 나온다. cast 와 달리 값을 옮기지 않으므로 멈추는 일도 없다.

목표 타입은 모든 비트열이 쓸모 있는 값이어야 한다. bool 과 enum 은 그렇지 않다.

examples/ch20/bitcast_bool.low

module bitcast_bool .
rem expect: E-TYPE-BITCAST

fn truthy input a u8 . output bool .
do
  return bit_cast bool a .
end

실행 결과

$ lowentc --check bitcast_bool.low
bitcast_bool.low:6:0 E-TYPE-BITCAST: bit_cast's target must be a `plain` scalar type — every bit pattern valid, no padding (SPEC-004 §190). bool/enum have trap representations and are NOT plain, so they are outside safe punning

u8 의 2 를 bool 로 읽으면 참도 거짓도 아닌 값이 된다. 그런 값이 만들어지는 길을 막는다.

20.9 흔한 실수#

반례. 범프 할당기에 init 을 잊는다

examples/ch20/mistake_noinit.low

module mistake_noinit .
rem expect: E-ACTOR-UNINIT

use allocs .

proc carve input buf mut slice u8 . output u64 . effects state .
do
  var a allocs.bump_bytes be spawn actor allocs.bump_bytes .
  rem ✘ `send a init buf` 를 빠뜨렸다 --- 잘라 줄 바이트가 없는 할당기에 청한다
  let p option mut slice u8 be send a reserve 3 .
  guard is_some p . else return 91 .
  return send a used .
end

실행 결과

$ lowentc --check mistake_noinit.low
mistake_noinit.low:10:0 E-ACTOR-UNINIT: this is the FIRST message sent to an actor that was just spawned, and the handler READS a state field that holds a slice without setting it. A fresh actor's state is all zeroes, and zero is not a slice: the VM stops (`len needs a slice`) while native quietly answers `none` — the two back ends disagree, which means one of them is lying. Send the message that sets it up first (the one whose handler `set`s that field, typically `init`)

bump_bytes 는 스스로 메모리를 만들지 않는다. 잘라 줄 바이트를 send a init buf 로 걸기 전에는 나눠 줄 것이 없다. 갓 띄운 액터의 상태 칸은 모두 0 이고, 0 은 슬라이스가 아니다 — 그 자리를 읽으면 VM 과 네이티브가 서로 다르게 돈다. 그래서 번역이 거절한다 (E-ACTOR-UNINIT). 할당기를 띄우는 줄 바로 다음에 init 을 둔다.

반례. 같은 바이트를 두 할당기에 건다

examples/ch20/mistake_sharedbuf.low

module mistake_sharedbuf .
rem expect: E-EXCL

use allocs .

proc twice input buf mut slice u8 . output u8 . effects state .
do
  var a allocs.bump_bytes be spawn actor allocs.bump_bytes .
  var b allocs.bump_bytes be spawn actor allocs.bump_bytes .
  rem ✘ 같은 바이트를 두 할당기에 걸었다 --- 두 할당기가 같은 자리를 나눠 준다
  let c1 u64 be send a init buf .
  let c2 u64 be send b init buf .
  let p option mut slice u8 be send a reserve 3 .
  let q option mut slice u8 be send b reserve 3 .
  guard is_some p . else return 91 .
  guard is_some q . else return 92 .
  let pv mut slice u8 be some_value p .
  let qv mut slice u8 be some_value q .
  set (index pv 0) 65 .
  set (index qv 0) 66 .
  rem 65 를 썼는데 66 이 나온다
  return index pv 0 .
end

실행 결과

$ lowentc --check mistake_sharedbuf.low
mistake_sharedbuf.low:12:0 E-EXCL: the same WRITABLE place was handed to a SECOND actor. A write borrow is exclusive (§8.4): two actors holding the same bytes both hand them out, so two containers silently overlap and a write through one is read through the other (measured: 65 written, 66 read back). Give each actor its own bytes — `subslice` the buffer into pieces that do not overlap

두 할당기는 서로를 모른다. 둘 다 buf 의 앞에서부터 잘라 주므로 pv 와 qv 가 같은 자리가 되고, 65 를 쓴 뒤 66 을 쓰면 pv 를 읽어도 66 이다. 쓰기 빌림은 하나여야 한다는 규칙(12장)이 액터 경계에서도 서야 하므로 E-EXCL 로 거절한다 (2026-09-16 까지는 통과했다). 할당기마다 따로 된 바이트를 건다. 한 버퍼를 나눠야 하면 subslice 로 겹치지 않는 두 조각을 만든다.

반례. 맞는 할당기가 둘인데 using 을 적지 않는다

examples/ch20/mistake_ambiguous.low

module mistake_ambiguous .
rem expect: E-ALLOC-AMBIGUOUS

use allocs .

proc two_from
  input comptime a type .
  using al a .
  output u64 .
  effects state via a .
  requires allocs.byte_allocator a .
do
  let p option mut slice u8 be send al reserve 3 .
  guard is_some p . else return 91 .
  return send al used .
end

proc with_two input small mut slice u8 . input big mut slice u8 . output u64 . effects state .
do
  var s allocs.bump_bytes be spawn actor allocs.bump_bytes .
  var g allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let x u64 be send s init small .
  let y u64 be send g init big .
  rem ✘ 맞는 타입의 할당기가 둘인데 어느 것인지 적지 않았다
  let n u64 be two_from .
  return n .
end

실행 결과

$ lowentc --check mistake_ambiguous.low
25:16 E-ALLOC-AMBIGUOUS: this call draws from an allocator, and MORE THAN ONE value of a fitting type is in scope — the tool will not guess which one you meant. Say it on the binding: `let <name> <type> using <allocator> be …` (RFC-0112 D8(4))

s 와 g 가 모두 bump_bytes 라서 도구가 짐작할 수 없다. 짐작하면 작은 버퍼에서 깎아야 할 것을 큰 버퍼에서 깎거나 그 반대가 되고, 그런 결함은 메모리가 넉넉한 개발 기계에서는 드러나지 않는다. 그래서 E-ALLOC-AMBIGUOUS 로 멈추고 적으라고 한다.

examples/ch20/ambiguous_fixed.low

module ambiguous_fixed .
rem run: with_two [0,0,0,0] [0,0,0,0,0,0,0,0]

use allocs .

proc two_from
  input comptime a type .
  using al a .
  output u64 .
  effects state via a .
  requires allocs.byte_allocator a .
do
  let p option mut slice u8 be send al reserve 3 .
  guard is_some p . else return 91 .
  return send al used .
end

proc with_two input small mut slice u8 . input big mut slice u8 . output u64 . effects state .
do
  var s allocs.bump_bytes be spawn actor allocs.bump_bytes .
  var g allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let x u64 be send s init small .
  let y u64 be send g init big .
  rem 바인딩에 `using` 으로 출처를 적는다
  let n u64 using g be two_from .
  let m u64 using g be two_from .
  return m .
end

실행 결과

$ lowentc --run with_two ambiguous_fixed.low [0,0,0,0] [0,0,0,0,0,0,0,0]
with_two([0,0,0,0], [0,0,0,0,0,0,0,0]) = 6
  arg0 (written) = [0,0,0,0]
  arg1 (written) = [0,0,0,0,0,0,0,0]

두 번 부른 two_from 이 같은 g 에서 3 바이트씩 깎았으므로 used 가 6 이다. 출처가 호출마다 적혀 있으니 어느 버퍼가 줄어드는지 읽어서 안다.

반례. 제네릭 op 에서 via a 를 빠뜨린다

examples/ch20/mistake_novia.low

module mistake_novia .
rem expect: E-EFFECT

use allocs .

proc one_from
  input comptime a type .
  using al a .
  output u64 .
  rem ✘ `via a` 가 없다 --- 할당기가 내는 `alloc` 이 이 op 의 선언에 들어오지 않는다
  effects state .
  requires allocs.byte_allocator a .
do
  let p option mut slice u8 be send al reserve 3 .
  guard is_some p . else return 91 .
  return send al used .
end

proc with_fixed input al cap allocator . output u64 . effects alloc state .
do
  var fb allocs.fixed_bytes be spawn actor allocs.fixed_bytes .
  let n u64 using fb be one_from .
  return n .
end

실행 결과

$ lowentc --check mistake_novia.low
mistake_novia.low:13:1 E-EFFECT: this op performs `alloc`, which its `effects` clause does not declare — add it to `effects …`, or stop calling what needs it
mistake_novia.low:20:1 W-EFFECT-OVER: this op DECLARES `alloc` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)

one_from 은 effects state . 만 적었지만, fixed_bytes 로 단형화하면 reserve 가 alloc 을 낸다. via a 가 있어야 “타입 a 가 내는 할당 계열 효과도 내 선언이다” 가 되어 인스턴스마다 효과가 정확해진다. 없으면 E-EFFECT 가 나고, 효과가 부르는 쪽에 번지지 않아 with_fixed 에는 엉뚱하게 W-EFFECT-OVER 까지 붙는다. 첫 오류를 고치면 둘째도 사라진다.

흔한 오해. 할당기를 하나 더 띄우면 창도 하나 더 생긴다

examples/ch20/shared_window.low

module shared_window .
rem run: main

use allocs .

proc main input al cap allocator . output u8 . effects alloc state .
do
  rem 할당기 둘이지만 고정 창은 하나다
  var f1 allocs.fixed_bytes be spawn actor allocs.fixed_bytes .
  var f2 allocs.fixed_bytes be spawn actor allocs.fixed_bytes .
  let a option mut slice u8 be send f1 reserve 40000 .
  guard is_some a . else return 1 .
  rem `f2` 는 아직 아무것도 쓰지 않았지만 창에 남은 자리가 모자라다
  let b option mut slice u8 be send f2 reserve 40000 .
  guard is_some b . else return 2 .
  return 0 .
end

실행 결과

$ lowentc --run main shared_window.low
main() = 2

fixed_bytes 는 뿌리에서 깎는다. 뿌리는 하나이고 커서도 하나다(18장). f1 이 40000 바이트를 가져가면 f2 는 아직 아무것도 쓰지 않았어도 기본 창(65536 바이트)에 남은 자리가 모자라 none 을 받는다. 할당기 값의 used 는 그 할당기가 쓴 양이지 창 전체의 남은 양이 아니다. 따로 된 예산이 필요하면 창에서 한 번 크게 받아 bump_bytes 여럿에 겹치지 않게 나눠 건다.

20.10 이 장의 문법 한눈에#

모양뜻왜 이렇게
var a allocs.bump_bytes be spawn actor allocs.bump_bytes .할당기(상태)를 띄운다상태는 액터 값 — 전역 할당기가 없다
send a init buf잘라 줄 바이트를 건다할당기는 몰래 메모리를 만들지 않는다
send a reserve 3 · send a used조각을 청한다(option) · 쓴 양부족은 트랩이 아니라 값
input comptime a type .할당기의 타입(정책)을 번역 때 받는다갈아 끼우기의 실행 비용이 0
using al a .그 타입의 할당기 값을 받는다 — 입력이 아니다부르는 자리의 인자에 끼지 않는다
let n u64 using g be two_from .이 호출이 깎을 할당기를 적는다둘 이상이면 짐작하지 않는다
effects state via a . · requires allocs.byte_allocator a .타입의 효과를 물려받는다 · 트레이트 조건인스턴스마다 효과가 정확하다
allocs.fixed_bytes · allocs.heap_bytes뿌리에서 곧장 깎는 기본 할당기같은 종류의 권한을 쥔 op 만 띄운다 — E-CAP-FORGE
send b grow pv 6 · send b release qv마지막 조각을 늘린다 · 돌려받는다크기가 아니라 same_slice 로 정체를 확인한다
출처 없음 · 쓰이지 않는 usingE-ALLOC-NOSOURCE · E-ALLOC-USING-UNUSED전역 할당기도, 헛된 선택도 없다
bit_cast u32 x비트는 그대로 두고 읽는 법만 바꾼다bool·enum 으로는 읽지 않는다

표 20.5 — 할당기의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

할당기는 권한·정책(트레이트를 갖춘 타입)·상태(액터 값) 셋이다. 범프 할당기는 빌린 바이트를 잘라 주고 부족을 none 으로 알린다. 할당기는 input comptime a type . 과 using al a . 로 받아 갈아 끼우며 실행 비용이 없고, 전역 할당기는 없다. fixed_bytes·heap_bytes 는 권한 칸을 가진 기본 할당기이고, 같은 종류의 권한을 쥔 op 만 띄울 수 있다. 고정 창의 크기는 링커가 정한다. bit_cast 는 비트를 그대로 두고, bool·enum 으로는 읽지 않는다.