25 액터 — 상태를 가진 채 메시지로 사는 것
먼저 알아야 할 것
state·panic 은 권한 없이 적는 효과다돌아보기
20장의 범프 할당기는 무엇으로 만들고 무엇으로 불렀는가? 할당기의 커서는 어디에 살았는가?
답. spawn actor allocs.bump_bytes 로 만들고 send a reserve 3 으로 불렀다. 커서는 액터의 상태에 살았고, 바깥에서는 메시지로만 그 상태에 닿았다. 이 장은 그 액터를 제대로 다룬다.
이 장의 필요성과 맥락
이 장이 끝나면
actor … do state do … end . … end . 로 액터를 선언하고, spawn actor 로 만들고, send 로 메시지를 보내는 법을 익힌다. 액터 안의 op 도 fn·proc 규칙을 따른다는 것, 메시지에 값을 싣고 소유를 넘기는 법을 알게 된다. 우편함에 넣고 나중에 비우는 spawn send·drain, 터진 액터를 다시 세우는 failure restart, 그리고 액터를 쓸 수 있는 자리를 정하는 build profile 도 보게 된다.이 장에서 답할 질문
- 액터를 부르는 것은 결국 함수 호출과 무엇이 다른가?
25.1 선언하고, 만들고, 말을 건다#
examples/ch25/counter.low
module counter_demo .
rem run: use_counter
actor counter do
state do
value u64 .
end .
proc inc output u64 . effects state .
do
set value. add value. 1 . .
return value. .
end .
fn get output u64 .
do
return value. .
end .
end .
proc use_counter output u64 . effects state .
do
var c counter spawn actor counter . .
let a u64 send c. inc . .
let b u64 send c. inc . .
return send c. get . .
end .
실행 결과
$ lowentc --run use_counter counter.low
use_counter() = 2
actor counter do … end .가 액터를 선언한다.state do value u64 . end .가 액터의 상태다. 상태는 액터 안에만 있고 바깥에서 직접 건드릴 수 없다.- 상태를 고치는
inc는proc이고effects state다. 읽기만 하는get은fn이다. 액터 안의 op 도 5장의 갈래 규칙을 그대로 따른다. var c counter spawn actor counter . .가 액터 하나를 만든다. 상태는 0 으로 시작한다.send c inc는c에게inc메시지를 보내고 처리가 끝날 때까지 기다려 결과를 받는다.
액터는 메시지를 한 번에 하나씩 처리한다. 그래서 그 안의 상태는 동시에 건드려지지 않는다. 자물쇠를 손으로 걸 필요가 없는 이유다.
창구가 하나뿐인 은행 창구에 빗대면 쉽다. 손님(메시지)은 줄을 서고, 창구 직원(액터)은 한 번에 한 손님만 받는다. 금고(상태)는 창구 뒤에 있어 손님이 직접 열 수 없다.
보내는 쪽들 우편함(줄) 액터 counter
┌──────────────────────┐
send c inc ───────▶ ┌─────┬─────┬─────┐ │ 지금 처리 중: inc │
send c inc ───────▶ │ inc │ inc │ get │ ────────▶ │ │
send c get ───────▶ └─────┴─────┴─────┘ 하나씩 │ state: value = 1 │ ← 바깥에서 못 건드린다
└──────────────────────┘줄에서 하나씩만 꺼내므로 value 를 두 메시지가 동시에 고치는 일이 생기지 않는다.
상태를 고치면서 fn 이라고 적으면 거절된다.
examples/ch25/pure_bad.low
module pure_bad .
rem expect: E-EFFECT-PURITY
actor counter do
state do
value u64 .
end .
fn inc output u64 .
do
set value. add value. 1 . .
return value. .
end .
end .
실행 결과
$ lowentc --check pure_bad.low
pure_bad.low:9:0 E-EFFECT-PURITY: a `fn` message handler WRITES the actor's STATE — and that write is visible to the NEXT message. A fn is an enforced purity contract (SPEC-003 §27): callers may memoise, reorder or elide it. Declare it a `proc` (and say `effects state`). ★ Nobody used to look here: handlers were declared with `on`, which said NOTHING about pure-vs-procedural, and the IR read that as PURE. RFC-0046 removed the bare-`op` default exactly so the 1 bit is always stated — `on` had put it back. RFC-0057
pure_bad.low:10:3 E-EFFECT-CALC: this fn is declared pure but performs `state` — make it a `proc` with `effects …`, or remove the effect
진단의 말대로 그 쓰기는 다음 메시지에 보인다. 호출자가 결과를 기억해 두거나 차례를 바꾸면 틀린 답이 나온다.
25.2 메시지에 값을 싣는다#
메시지 이름 뒤에 값을 나란히 적는다. 받는 쪽에서 액터 자신은 첫 매개변수처럼 다뤄지고, 실은 값이 그 뒤를 잇는다.
examples/ch25/args.low
module args .
rem run: tally 5 7
actor account do
state do
balance u64 .
end .
proc deposit input amount u64 . output u64 . effects state .
requires le amount. 1000000 . .
do
set balance. wrap_add balance. amount. . .
return balance. .
end .
end .
proc tally input a u64 . input b u64 . output u64 . effects state .
requires le a. 1000000 . .
requires le b. 1000000 . .
do
var acct account spawn actor account . .
let x u64 send acct. deposit a. . .
return send acct. deposit b. . .
end .
실행 결과
$ lowentc --run tally args.low 5 7
tally(5, 7) = 12
send acct deposit a 는 deposit 의 amount 에 a 를 싣는다. 메시지 op 에도 계약을 적을 수 있다.
소유를 가진 값을 보내면 소유가 넘어간다.
examples/ch25/handoff.low
module handoff .
rem expect: E-OWN-MOVED
type job u64 .
actor worker do
state do
done u64 .
end .
proc take input j owned job . output u64 . effects state .
do
drop j. .
set done. add done. 1 . .
return done. .
end .
end .
proc give_twice input n u64 . output u64 . effects state .
do
var w worker spawn actor worker . .
var j owned job n. .
let a u64 send w. take j. . .
return send w. take j. . .
end .
실행 결과
$ lowentc --check handoff.low
handoff.low:23: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
j 를 첫 메시지로 넘긴 순간 give_twice 는 j 를 더 쓸 수 없다. 두 흐름이 같은 값을 함께 들고 있으면 경합이 생긴다. 소유가 메시지로만 옮겨 다니면 어느 순간에도 그 값을 만질 수 있는 쪽은 하나뿐이고, 경합은 막는 것이 아니라 있을 자리가 없다.
문. 액터를 부르는 것은 결국 함수 호출과 무엇이 다른가?
답. send 는 문법상 호출과 비슷하지만 두 가지가 다르다. 상태가 액터 안에 갇혀 있어 부르는 쪽이 필드를 읽거나 쓸 방법이 없고, 처리가 한 번에 하나씩이라는 것이 보장된다. 액터에게서 빌린 것을 들고 그 액터에게 다시 말을 거는 것도 거절된다 (E-BORROW-EXCL). 그 액터가 상태를 고치는 동안 우리가 그 상태를 보고 있게 되기 때문이다.
25.3 우편함 — 넣기와 비우기#
send 는 처리가 끝날 때까지 기다린다. 결과가 필요 없는 전달은 spawn send 로 우편함에 넣기만 한다.
examples/ch25/mailbox.low
module mailbox .
rem run: drive
rem run: nodrain
actor counter do
state do
v u64 .
end .
proc inc output u64 . effects state .
do
set v. add v. 1 . .
return v. .
end .
fn get output u64 .
do
return v. .
end .
end .
proc drive output u64 . effects state .
do
var c counter spawn actor counter . .
spawn send c. inc . .
spawn send c. inc . .
spawn send c. inc . .
drain c. .
return send c. get . .
end .
proc nodrain output u64 . effects state .
do
var c counter spawn actor counter . .
spawn send c. inc . .
spawn send c. inc . .
return send c. get . .
end .
실행 결과
$ lowentc --run drive mailbox.low
drive() = 3
$ lowentc --run nodrain mailbox.low
nodrain() = 0
drive 는 inc 를 세 번 넣고 drain c. . 로 우편함을 넣은 차례대로 비운 뒤 3 을 읽는다. nodrain 은 비우지 않았으므로 0 이다. 우편함에 든 메시지는 저절로 처리되지 않는다.
처리기가 언제 배달할지 스스로 정하지 않는 까닭은 결정성이다. 같은 프로그램은 같은 답을 내야 하고, 배달 시점이 곧 답이다. 비우는 자리를 사람이 고르므로 VM 과 네이티브가 같은 차례로 배달하고, 두 백엔드 대조가 그것을 지킨다. 모든 액터의 우편함을 한꺼번에 비우려면 schedule . 을 쓴다. 이때도 한 액터에 온 메시지는 넣은 차례대로 하나씩 처리되고, 처리 코드가 yield 해도 다음 메시지는 앞의 것이 끝난 뒤에 간다. 서로 다른 액터 사이의 차례는 정해져 있지 않으므로 그 차례에 기대는 프로그램은 schedule explore_interleavings 시험이 잡는다. 액터가 우편함의 메시지를 아직 처리하는 중일 때 그 액터를 drain 하면 프로그램이 멈춘다(E-VM-DRAIN-BUSY) — 비우는 일은 그 메시지가 끝난 뒤에 한다. mailbox bounded 2 . 처럼 우편함 크기를 정하면 넘치는 넣기가 멈추고, try spawn send 는 멈추는 대신 오류를 값으로 준다.
흔한 오해. 액터는 각자 스레드 하나씩이다
send·spawn send·drain 은 한 흐름 안에서 결정적으로 배달된다. 액터가 주는 것은 병렬성이 아니라 상태의 격리다. 여러 흐름에 일을 나누는 것은 태스크와 채널 (26장), 데이터를 나누어 동시에 계산하는 것은 병렬 되풀이(27장)의 몫이다.25.4 터지게 두고 다시 세운다#
액터의 op 이 panic 하면 그 액터를 다시 세울 수 있다. 상태가 처음으로 돌아가고 그 메시지가 다시 처리된다.
examples/ch25/restart.low
module restart .
rem run: recover
rem trap: escalate
actor recov do
state do
bad u64 .
end .
failure restart max 3 .
proc tick output u64 . effects state panic .
do
if ne bad. 0 . do
panic "corrupted state" .
end .
set bad. 1 .
return 42 .
end .
end .
actor doomed do
state do
v u64 .
end .
failure restart max 2 .
proc boom output u64 . effects state panic .
do
panic "always fails" .
return 0 .
end .
end .
proc recover output u64 . effects state panic .
do
var c recov spawn actor recov . .
let x u64 send c. tick . .
return send c. tick . .
end .
proc escalate output u64 . effects state panic .
do
var c doomed spawn actor doomed . .
return send c. boom . .
end .
실행 결과
$ lowentc --run recover restart.low
recover() = 42
$ lowentc --run escalate restart.low
== ir diagnostics (1) ==
0:0 E-VM-PANIC: the program called `panic` — this is an unrecoverable stop, and it is NOT a contract violation (the code chose to stop, it did not break a promise)
recov는 첫tick에서bad를 1 로 만들고, 둘째tick에서panic한다.failure restart max 3 .이 있으므로 상태를 처음(bad = 0)으로 되돌리고 메시지를 다시 처리해 42 를 낸다.doomed는 언제나 터진다. 두 번 다시 세운 뒤에도 터지면 그 실패는 위로 넘어가 프로그램이 멈춘다. 조용히 멈추어 있는 액터를 남기지 않는다.
정책은 셋이고 모두 failure restart 뒤에 적는다 — max <수>(그 수만큼), never(처음 터질 때 바로 위로), always(셈하지 않고 계속). 다시 세우는 것은 panic 에만 해당한다. 계약 위반은 프로그램이 스스로 적은 약속을 어긴 것이라 다시 해도 같으므로 다시 세우지 않는다. 되살아나는 것은 자기 상태뿐이고 다른 액터는 건드리지 않는다. spawn send 로 보내 drain·schedule 이 배달한 메시지가 터져도 같다. 그 액터 앞으로 우편함에 남은 메시지는 버리지 않는다 — 다시 세운 액터가 넣은 차례대로 처리한다(spawn send 가 이미 받아 주었다고 돌아왔기 때문이다).
restart max 3 within 1 minute 처럼 기간을 붙일 수 있다. 단위는 second·minute·hour 셋이고 여럿일 때는 seconds 처럼 적는다. 다만 이 판의 처리기는 기간을 적어 두기만 한다. 횟수(max)는 지키지만 “1 분 안에” 는 아직 세지 않고, 그 사실을 W-NOT-YET 경고로 알린다. 시간에 기대는 복구를 설계한다면 이 경고를 읽고 횟수만으로도 안전한지 확인한다.
실패 정책은 한 액터에 하나다. 우편함 정책(mailbox)도 그렇다.
examples/ch25/mistake_twopolicies.low
module mistake_twopolicies .
rem expect: E-ACTOR-FAILURE
actor worker do
state do
n u64 .
end .
rem ✘ 실패 정책이 둘이다
failure restart max 3 .
failure restart never .
proc tick output u64 . effects state .
do
set n. add n. 1 . .
return n. .
end .
end .
실행 결과
$ lowentc --check mistake_twopolicies.low
10:0 E-ACTOR-FAILURE: an actor declares `failure` twice — one restart policy per actor
정책이 둘이면 어느 쪽이 서는지를 글에서 읽을 수 없고, 답이 처리기의 속사정이 된다. 그래서 E-ACTOR-FAILURE 로 거절한다 (우편함 정책이 둘이면 E-ACTOR-MAILBOX).
상태가 어쩌다 이상해진 실패를 하나하나 손으로 되돌리는 코드는 길고, 길면 그 코드 자체가 틀린다. 상태를 처음으로 돌리는 것은 짧고 언제나 맞는 복구다.
25.5 액터를 쓸 수 있는 자리#
프로그램은 자기가 어느 자리에서 도는지 build profile <이름> . 으로 적을 수 있다. 적으면 그 자리가 감당하지 못하는 동시성을 번역할 때 거절한다.
| 프로파일 | 등급 | 무엇까지 |
|---|---|---|
freestanding | 0 | 운영체제 없음. 동시성을 쓰지 않는다 |
embedded | 1 | 정해진 일감을 나누어 도는 데까지(흐름이 정적으로 정해진다) |
native | 2 | 흐름을 만들고 채널로 주고받는다 |
server | 3 | 액터까지 |
표 25.1 — 프로파일과 여는 등급
examples/ch25/profile.low
module profile .
rem expect: E-PROFILE-LEVEL
build profile embedded .
actor counter do
state do
value u64 .
end .
proc inc output u64 . effects state .
do
set value. add value. 1 . .
return value. .
end .
end .
실행 결과
$ lowentc --check profile.low
4:0 E-PROFILE-LEVEL: this module uses a CONCURRENCY FEATURE its build profile does not provide (RFC-0009 §105/§139): freestanding has no concurrency at all, embedded has a static task graph, native adds jobs/green threads, server adds actors. The level is not declared — it is COMPUTED from the constructs you used — and the profile caps it. Either raise the profile or stop using the feature
actor 를 선언하는 것만으로 3 등급이다. 프로파일을 적지 않으면 막지 않는다. 적은 사람만 그 약속을 진다.
25.6 권한을 든 액터와 상태에 둘 수 있는 것#
액터의 상태에는 권한 칸을 둘 수 있다. 권한은 번역할 때만 있는 표시라서 그 칸은 실행 중 크기가 0 이다. 대신 그 칸이 무엇으로 채워지는지가 규칙으로 정해진다.
examples/ch25/capfield.low
module capfield .
rem run: main
rem 기록 하나마다 고정 창에서 16 바이트를 깎아 두는 장부
actor logbook do
state do
rem 권한 칸 — 실행 중 크기가 0 이고, 띄우는 자리의 권한으로 채워진다
root cap allocator .
entries u64 .
end .
proc write input code u8 . output bool . effects alloc state . do
let g option mut slice u8 alloc_bytes root. capacity 16 . .
guard is_some g. . else return false . .
let v mut slice u8 some_value g. . .
set idx v. 0 . code. .
set entries. add entries. 1 . .
return true .
end .
fn count output u64 . do
return entries. .
end .
end .
proc main input al cap allocator . output u8 . effects alloc state .
do
var book logbook spawn actor logbook . .
let a bool send book. write 7 . .
let b bool send book. write 9 . .
return narrow u8 send book. count . . .
end .
실행 결과
$ lowentc --run main capfield.low
main() = 2
root cap allocator .가 권한 칸이다.write는 그 칸으로alloc_bytes root capacity 16을 부른다. 권한을 메시지마다 인자로 건네지 않아도 된다.main이cap allocator를 받았기 때문에spawn actor logbook이 허락된다. 칸은 띄우는 자리의 권한으로 채워진다.- 기록 둘을 남기고
count가 2 를 답한다.
권한을 받지 않은 op 이 같은 액터를 띄우면 거절된다.
examples/ch25/mistake_capfield.low
module mistake_capfield .
rem expect: E-CAP-FORGE
actor logbook do
state do
root cap allocator .
entries u64 .
end .
proc write input code u8 . output bool . effects alloc state . do
let g option mut slice u8 alloc_bytes root. capacity 16 . .
guard is_some g. . else return false . .
set entries. add entries. 1 . .
return true .
end .
end .
rem ✘ cap allocator 를 받지 않은 op 이 권한 칸 액터를 띄운다
proc sneaky output u64 . effects alloc state .
do
var book logbook spawn actor logbook . .
let a bool send book. write 7 . .
return 0 .
end .
실행 결과
$ lowentc --check mistake_capfield.low
mistake_capfield.low:20: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
이것이 허락되면 spawn 한 줄이 없던 권한을 지어낸다. 권한 칸은 “이 액터를 만든 자리가 이미 그 권한을 쥐었다” 는 사실을 옮겨 적는 것일 뿐이다. 표준 할당기 allocs.fixed_bytes·heap_bytes 도 이 규칙을 따르는 평범한 액터다(20장).
상태 칸에 둘 수 있는 것과 없는 것은 이렇다.
| 타입 | 받아들이나 | 까닭 |
|---|---|---|
수 · bool · option · 구조체 | 받는다 | 값이라서 액터 안에 갇힌다 |
slice · mut slice | 받는다 | 할당기가 받침 바이트를 이렇게 쥔다(allocs.bump_bytes) |
cap allocator · cap heap | 받는다 — 띄우는 op 에 같은 권한이 있어야 한다 | E-CAP-FORGE 가 지어내기를 막는다 |
array <수> <타입> | 거절 — E-TYPE-ARRAY | 고정 길이 배열은 op 입력에서만 받는다. 길이를 둘 자리가 없다 |
ref · mut_ref | 이 판은 받는다 — 그러나 쓰면 멈춘다 | 무엇을 빌렸는지 적을 자리가 없다. 아래 “흔한 실수” 를 본다 |
표 25.2 — 액터 상태 칸의 타입
25.7 액터로 설계하기 — 계좌 둘 사이의 이체#
지금까지의 조각을 한 설계에 모은다. 계좌마다 액터 하나를 두고, 이체는 두 액터에게 차례로 말을 거는 op 이 맡는다.
examples/ch25/transfer.low
module transfer .
rem run: move 30
rem run: move 80
enum bank_error do
insufficient .
end .
actor account do
state do
balance u64 .
end .
rem 잔액을 채운다 --- 상태를 고치므로 proc
proc deposit input amount u64 . output u64 . effects state .
requires le amount. 1000000 . .
do
set balance. wrap_add balance. amount. . .
return balance. .
end .
rem 모자라면 상태를 건드리지 않고 오류를 값으로 돌려준다
proc withdraw input amount u64 . output result u64 bank_error . effects state .
errors insufficient .
do
guard le amount. balance. . else return error insufficient . . .
set balance. sub balance. amount. . .
return ok balance. . .
end .
fn peek_balance output u64 .
do
return balance. .
end .
end .
rem 두 액터 사이의 이체 --- 빼기가 성공했을 때만 넣는다
proc move input amount u64 . output u64 . effects state .
requires le amount. 1000 . .
do
var src account spawn actor account . .
var dst account spawn actor account . .
let seed u64 send src. deposit 50 . .
let r result u64 bank_error send src. withdraw amount. . .
guard not is_error r. . . else return add mul send src. peek_balance . 1000 . send dst. peek_balance . . . .
let t u64 send dst. deposit amount. . .
return add mul send src. peek_balance . 1000 . send dst. peek_balance . . .
end .
실행 결과
$ lowentc --run move transfer.low 30
move(30) = 20030
$ lowentc --run move transfer.low 80
move(80) = 50000
- 잔액은
account의 상태에만 있다. 바깥은deposit·withdraw·peek_balance세 메시지로만 계좌에 닿는다. withdraw는 잔액이 모자라면 상태를 건드리지 않고error insufficient .를 돌려준다. 멈추지 않는다 — 잔액 부족은 계좌를 쓰는 쪽이 다룰 수 있는 실패다(17장).move는 빼기가 성공했을 때만 넣는다.move 30은 보내는 쪽 20, 받는 쪽 30 이라 20030 을 낸다.move 80은 빼기가 실패해 두 계좌가 50 과 0 그대로다(50000).- 메시지는 한 번에 하나씩 처리되므로
withdraw가 잔액을 확인하고 줄이는 사이에 다른 메시지가 끼어들 수 없다. “확인하고 쓰기” 사이의 경합이 이 모양에는 없다.
withdraw 의 errors insufficient . 에 조건을 붙이지 않은 것은 일부러다. 아래 “흔한 실수” 의 넷째 항목이 그 까닭이다.
25.8 흔한 실수#
반례. 크기를 정한 우편함에 비우지 않고 계속 넣는다
examples/ch25/mistake_bounded.low
module mistake_bounded .
rem trap: burst
actor counter do
state do
value u64 .
end .
mailbox bounded 2 .
proc inc output u64 . effects state .
do
set value. add value. 1 . .
return value. .
end .
fn get output u64 .
do
return value. .
end .
end .
proc burst output u64 . effects state .
do
var c counter spawn actor counter . .
spawn send c. inc . .
spawn send c. inc . .
rem ✘ 우편함은 두 통까지인데 비우기 전에 셋째를 넣는다
spawn send c. inc . .
drain c. .
return send c. get . .
end .
실행 결과
$ lowentc --run burst mistake_bounded.low
== ir diagnostics (1) ==
0:0 E-VM-MAILBOX-FULL: the actor's bounded mailbox is full — `spawn send` would exceed `mailbox bounded N` pending messages (drain it first, or raise N)
mailbox bounded 2 . 는 “대기 중인 메시지는 둘까지” 라는 약속이다. 셋째 spawn send 는 넘치므로 E-VM-MAILBOX-FULL 로 멈춘다. 멈추는 대신 다루려면 try spawn send 로 넣는다. 그러면 넘침이 result 로 돌아오고, 비운 뒤 다시 넣을 수 있다.
examples/ch25/bounded_fixed.low
module bounded_fixed .
rem run: burst
actor counter do
state do
value u64 .
end .
mailbox bounded 2 .
proc inc output u64 . effects state .
do
set value. add value. 1 . .
return value. .
end .
fn get output u64 .
do
return value. .
end .
end .
proc burst output u64 . effects state .
do
var c counter spawn actor counter . .
spawn send c. inc . .
spawn send c. inc . .
rem 넣지 못하면 멈추지 않고 오류를 값으로 받는다 --- 비운 뒤 다시 넣는다
let r result u64 mailbox_full try spawn send c. inc . . . .
if is_error r. . do
drain c. .
spawn send c. inc . .
end .
drain c. .
return send c. get . .
end .
실행 결과
$ lowentc --run burst bounded_fixed.low
burst() = 3
반례. 액터가 다루지 않는 메시지를 보낸다
examples/ch25/mistake_unknownmsg.low
module mistake_unknownmsg .
rem expect: E-IR-UNDEF
actor counter do
state do
value u64 .
end .
proc inc output u64 . effects state .
do
set value. add value. 1 . .
return value. .
end .
end .
proc use_counter output u64 . effects state .
do
var c counter spawn actor counter . .
rem ✘ `counter` 는 `dec` 메시지를 다루지 않는다
return send c. dec . .
end .
실행 결과
$ lowentc --check mistake_unknownmsg.low
mistake_unknownmsg.low:19:0 E-IR-UNDEF: send names a message this actor does not handle (the handler is looked up IN THE RECEIVER'S TYPE — it used to be found by BARE NAME, so two actors with a handler of the same name silently shared one)
메시지 이름은 받는 액터의 타입 안에서 찾는다. counter 에는 dec 가 없으므로 E-IR-UNDEF 다. 진단이 덧붙인 사연대로, 한때는 이름만으로 찾아서 같은 이름의 처리기를 가진 두 액터가 조용히 하나를 나눠 썼다.
반례. 바깥에서 액터의 상태 칸을 읽는다
examples/ch25/mistake_peekstate.low
module mistake_peekstate .
rem expect: E-ACTOR-FIELD
actor counter do
state do
value u64 .
end .
proc inc output u64 . effects state .
do
set value. add value. 1 . .
return value. .
end .
end .
proc peek output u64 . effects state .
do
var c counter spawn actor counter . .
let a u64 send c. inc . .
rem ✘ 액터의 상태 칸을 바깥에서 읽는다 --- 막혀야 할 자리가 이 판에서는 통과한다
return field c. value . .
end .
실행 결과
$ lowentc --check mistake_peekstate.low
mistake_peekstate.low:20:0 E-ACTOR-FIELD: this reads a STATE FIELD of an actor from outside it. An actor's state lives inside the actor and nowhere else (§10.2): the only door is a message (`send a <op> …`). If the state were readable from outside, the isolation that makes actors safe without locks — one message at a time — would not hold, and the reader would see a value between two messages. Add an op to the actor that returns what you need
이 장의 첫 약속은 “상태는 액터 안에만 있고 바깥에서 직접 건드릴 수 없다” 였다. field c. value . 는 그 문을 돌아가려는 시도이고, E-ACTOR-FIELD 로 거절된다. 상태가 바깥에서 읽히면 메시지를 한 번에 하나씩 처리한다는 전제가 소용없어진다 — 읽는 쪽이 메시지 사이의 값을 보기 때문이다. 상태가 필요하면 get 같은 읽기 메시지를 두고 send c get 으로 묻는다.
반례. 메시지 op 의 오류 조건에 상태 칸을 쓴다
examples/ch25/mistake_errorsstate.low
module mistake_errorsstate .
rem expect: E-ERRORS-STATE
enum bank_error do
insufficient .
end .
actor account do
state do
balance u64 .
end .
proc deposit input amount u64 . output u64 . effects state .
requires le amount. 1000000 . .
do
set balance. wrap_add balance. amount. . .
return balance. .
end .
rem ✘ 오류 조건이 상태 칸 `balance` 를 읽는다 --- 나갈 때는 이미 줄어든 잔액으로 다시 읽힌다
proc withdraw input amount u64 . output result u64 bank_error . effects state .
errors insufficient gt amount. balance. . .
do
guard le amount. balance. . else return error insufficient . . .
set balance. sub balance. amount. . .
return ok balance. . .
end .
end .
proc take30 output u64 . effects state .
do
var a account spawn actor account . .
let seed u64 send a. deposit 50 . .
let r result u64 bank_error send a. withdraw 30 . .
guard not is_error r. . . else return 1 . .
return ok_value r. . .
end .
var fee u64 5 .
rem ✘ 액터 밖에서도 같다 --- 모듈 `var` 와 `mut` 자리의 인자도 몸통이 바꿀 수 있으므로 오류 조건에 서지 못한다
proc charge input amount u64 . input log mut slice u8 . output result u64 bank_error . effects state .
errors insufficient lt amount. fee. . .
do
set fee. 0 .
return ok amount. . .
end .
실행 결과
$ lowentc --check mistake_errorsstate.low
mistake_errorsstate.low:42:0 E-ERRORS-STATE: the condition of this `errors` clause names something whose value can DIFFER between entry and exit — an actor state field, a module `var`, or a `mut` parameter. An `errors` condition is read on the values the op was ENTERED with (canon §6.4.2): the clause says what the CALLER got wrong, and what the caller handed over is all it can be blamed for. A name the body may change cannot carry that meaning — read on exit it accuses the op of owing an error it never owed. Write the condition over the inputs that do not change (and module constants), and guard on the changing thing inside the body
21:0 E-ERRORS-STATE: the condition of this `errors` clause names something whose value can DIFFER between entry and exit — an actor state field, a module `var`, or a `mut` parameter. An `errors` condition is read on the values the op was ENTERED with (canon §6.4.2): the clause says what the CALLER got wrong, and what the caller handed over is all it can be blamed for. A name the body may change cannot carry that meaning — read on exit it accuses the op of owing an error it never owed. Write the condition over the inputs that do not change (and module constants), and guard on the changing thing inside the body
mistake_errorsstate.low:43:0 W-ERRORS-UNRAISED: this `errors` clause names a failure the body never returns. The clause is read as a promise about what this op can do, and the callers write their handling from it — a branch that can never be taken is dead code the reader cannot tell from live code. Return it (`return error <variant>`), forward one (`try`), or remove the clause
errors insufficient gt amount balance . 는 “잔액보다 많이 빼려 하면 이 오류” 라는 뜻으로 적었다. 그런데 errors 의 조건은 op 에 들어올 때의 값으로 읽는다(정본 6.4.2). errors 는 requires 와 같은 쪽 — 부르는 쪽이 무엇을 잘못했는가를 말하는 절이고, 부르는 쪽이 한 일은 건넨 것뿐이기 때문이다. 그래서 몸통이 바꿀 수 있는 이름(상태 칸 · 모듈 var · mut 자리의 인자)은 조건에 설 수 없고, 번역이 E-ERRORS-STATE 로 거절한다. 나갈 때 값으로 읽으면 성공한 실행이 스스로를 고발한다 — 잔액이 50 에서 20 으로 줄었으므로 나갈 때 gt 30 20 . 이 참이 되어 “조건이 참인데 오류를 내지 않았다” 가 된다. 오류 조건은 입력으로 적고, 상태를 보는 판정은 본문의 guard 가 맡는다 — 위의 transfer.low 처럼 조건 없이 errors insufficient . 만 적는다.
반례. 상태 칸에 빌림을 둔다
examples/ch25/mistake_reffield.low
module mistake_reffield .
rem expect: E-ACTOR-STATE-REF
actor holder do
state do
rem ✘ 빌림을 상태에 둔다 — 무엇을 빌렸는지 적을 자리가 없다
r ref u64 .
end .
fn peek output u64 . do
return deref r. . .
end .
end .
proc peek_it output u64 . effects state .
do
var h holder spawn actor holder . .
return send h. peek . .
end .
실행 결과
$ lowentc --check mistake_reffield.low
7:0 E-ACTOR-STATE-REF: an actor STATE field may not be a borrow (`ref` / `mut_ref`). A borrow may not outlive what it borrows (§8.4.1), and a state field lives as long as the actor — there is nowhere here to say what it borrows. Keep a VALUE in state (copy it in), or keep a slice the actor was handed and owns for its lifetime
mistake_reffield.low:17: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`)
빌림(ref)은 빌려준 쪽보다 오래 살 수 없다(12장). 액터의 상태는 액터가 사는 동안 남으므로, 그 칸이 무엇을 빌렸는지 적을 자리가 없다. 그래서 선언에서 E-ACTOR-STATE-REF 로 거절한다. 2026-09-16 까지는 선언을 받아들이고 칸을 비운 채 액터를 띄웠고, deref r. . 에 이르러서야 VM 은 E-VM-TYPE 으로, 네이티브는 panic 으로 멈췄다. 상태에는 빌림 대신 값을 두고, 값이 크면 액터가 사는 동안 유효한 슬라이스를 받아 둔다.
흔한 오해. 다시 세운 액터는 터지기 직전 상태에서 이어 간다
examples/ch25/restart_resets.low
module restart_resets .
rem run: three_incs
actor counter do
state do
value u64 .
end .
failure restart max 3 .
proc inc output u64 . effects state panic .
do
rem 값이 2 일 때 한 번 터진다
if eq value. 2 . do panic "at two" . end .
set value. add value. 1 . .
return value. .
end .
end .
proc three_incs output u64 . effects state panic .
do
var c counter spawn actor counter . .
let a u64 send c. inc . .
let b u64 send c. inc . .
rem 셋째 메시지에서 터지고, 상태가 0 으로 돌아간 뒤 이 메시지가 다시 처리된다
return send c. inc . .
end .
실행 결과
$ lowentc --run three_incs restart_resets.low
three_incs() = 1
inc 두 번으로 값이 2 가 된 뒤 셋째 메시지에서 터진다. 다시 세우면 상태는 처음(0)으로 돌아가고 그 메시지가 다시 처리되므로 답은 3 이 아니라 1 이다. 터지기 직전의 상태는 바로 그 상태 때문에 터졌을 수 있으므로 믿지 않는다. 잃으면 안 되는 값은 액터 바깥 — 다른 액터나 파일 — 에 두고, 다시 선 액터가 그것을 읽게 한다.
흔한 오해. 같은 액터 타입의 값들은 상태를 나눠 쓴다
examples/ch25/separate_state.low
module separate_state .
rem run: two_counters
actor counter do
state do
value u64 .
end .
proc inc output u64 . effects state .
do
set value. add value. 1 . .
return value. .
end .
end .
proc two_counters output u64 . effects state .
do
var left counter spawn actor counter . .
var right counter spawn actor counter . .
let a u64 send left. inc . .
let b u64 send left. inc . .
rem `right` 는 자기 상태를 따로 가진다 --- 1 이 나온다
return send right. inc . .
end .
실행 결과
$ lowentc --run two_counters separate_state.low
two_counters() = 1
spawn actor counter 를 두 번 하면 상태가 둘 생긴다. left 를 두 번 올려도 right 는 1 부터 센다. 액터 타입은 설계도이고 상태는 spawn 할 때마다 새로 생긴다. 여럿이 같은 값을 봐야 한다면 그 값을 가진 액터 하나를 두고 모두가 그 액터에게 말을 건다.
25.9 이 장의 문법을 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
actor counter do state do value u64 . end . … end . | 상태를 가둔 실행 단위를 선언 | 상태를 동시에 만질 길이 처음부터 없다 |
proc inc … effects state . · fn get … | 상태를 고치는 메시지 · 읽기만 하는 메시지 | fn·proc 규칙이 그대로 — 고치면서 fn 이면 E-EFFECT-PURITY |
var c counter spawn actor counter . . | 액터 하나를 만든다(상태는 0 에서 시작) | spawn 마다 상태가 따로 |
send c inc · send acct deposit a | 보내고 처리가 끝날 때까지 기다린다 · 값을 싣는다 | 액터가 먼저 — 메시지는 액터를 첫 매개변수로 받는 op |
spawn send c. inc . . · drain c . · schedule . | 우편함에 넣기 · 그 액터의 우편함 비우기 · 모두 비우기 | 배달 시점을 사람이 고른다 — 결정성 |
mailbox bounded 2 . · try spawn send | 우편함 크기 · 넘침을 값으로 받기 | 넘치면 멈추거나 result |
failure restart max 3 . · failure restart never . · failure restart always . | panic 한 액터를 처음 상태로 다시 세운다 | 다 쓰면 실패를 위로 넘긴다 |
build profile server . | 액터(3 등급)를 쓸 수 있는 자리 | 적은 사람만 그 약속을 진다 |
state do root cap allocator . … end . | 권한 칸 — 실행 중 크기 0 | 띄우는 op 에 같은 권한이 없으면 E-CAP-FORGE |
표 25.3 — 액터의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
spawn actor 로 만들고 send 로 기다려 부르며, 메시지 op 은 fn·proc 규칙을 따른다. 메시지에는 값을 싣고, 소유 값을 보내면 소유가 넘어간다. spawn send 는 우편함에 넣기만 하고 drain·schedule 이 비운다. failure restart 는 panic 한 액터의 상태만 처음으로 돌려 다시 세우고, 다 쓰면 실패를 위로 넘긴다. build profile 은 액터를 쓸 수 있는 자리를 정한다.