Lowent 매뉴얼←↑→

25 액터 — 상태를 가진 채 메시지로 사는 것

먼저 알아야 할 것

15장 효과 · state·panic 은 권한 없이 적는 효과다
19장 소유 · 소유를 넘기면 보낸 쪽은 더 쓸 수 없다
20장 할당기와 고정 메모리 · 할당기는 트레이트를 갖춘 액터다

돌아보기

20장의 범프 할당기는 무엇으로 만들고 무엇으로 불렀는가? 할당기의 커서는 어디에 살았는가?

답. spawn actor allocs.bump_bytes 로 만들고 send a reserve 3 으로 불렀다. 커서는 액터의 상태에 살았고, 바깥에서는 메시지로만 그 상태에 닿았다. 이 장은 그 액터를 제대로 다룬다.

이 장의 필요성과 맥락

제7부는 여러 일이 함께 진행되는 자리를 다룬다. 동시성 결함의 대부분은 여러 흐름이 같은 상태를 함께 만지는 데서 나온다. 자물쇠로 막을 수 있지만 자물쇠를 잊거나 차례를 틀리면 조용히 틀린다. 액터는 반대쪽에서 접근한다. 상태를 액터 안에만 두고, 메시지를 한 번에 하나씩 처리하게 한다. 그러면 그 상태를 동시에 만질 방법이 처음부터 없다. 제7부가 액터로 시작하는 것은, 표준 라이브러리의 할당기처럼 이 모양이 이미 이 언어의 곳곳에 쓰이기 때문이다.

이 장이 끝나면

actor … do state do … end … end 로 액터를 선언하고, spawn actor 로 만들고, send 로 메시지를 보내는 법을 익힌다. 액터 안의 op 도 fn·proc 규칙을 따른다는 것, 메시지에 값을 싣고 소유를 넘기는 법을 알게 된다. 우편함에 넣고 나중에 비우는 spawn send·drain, 터진 액터를 다시 세우는 failure restart, 그리고 액터를 쓸 수 있는 자리를 정하는 build profile 도 보게 된다.

이 장에서 답할 질문

  1. 액터를 부르는 것은 결국 함수 호출과 무엇이 다른가?

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 be spawn actor counter .
  let a u64 be send c inc .
  let b u64 be send c inc .
  return send c get .
end

실행 결과

$ lowentc --run use_counter counter.low
use_counter() = 2

액터는 메시지를 한 번에 하나씩 처리한다. 그래서 그 안의 상태는 동시에 건드려지지 않는다. 자물쇠를 손으로 걸 필요가 없는 이유다.

창구가 하나뿐인 은행 창구에 빗대면 쉽다. 손님(메시지)은 줄을 서고, 창구 직원(액터)은 한 번에 한 손님만 받는다. 금고(상태)는 창구 뒤에 있어 손님이 직접 열 수 없다.

  보내는 쪽들                    우편함(줄)               액터 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 be spawn actor account .
  let x u64 be 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 be spawn actor worker .
  var j owned job be n .
  let a u64 be 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 be 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 be 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 . 을 쓴다. 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 step 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 be spawn actor recov .
  let x u64 be send c step .
  return send c step .
end

proc escalate output u64 . effects state panic .
do
  var c doomed be 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)

정책은 셋이다 — restart max <수>(그 수만큼), never(처음 터질 때 바로 위로), always(셈하지 않고 계속). 다시 세우는 것은 panic 에만 해당한다. 계약 위반은 프로그램이 스스로 적은 약속을 어긴 것이라 다시 해도 같으므로 다시 세우지 않는다. 되살아나는 것은 자기 상태뿐이고 다른 액터는 건드리지 않는다.

상태가 어쩌다 이상해진 실패를 하나하나 손으로 되돌리는 코드는 길고, 길면 그 코드 자체가 틀린다. 상태를 처음으로 돌리는 것은 짧고 언제나 맞는 복구다.

25.5 액터를 쓸 수 있는 자리#

프로그램은 자기가 어느 자리에서 도는지 build profile <이름> . 으로 적을 수 있다. 적으면 그 자리가 감당하지 못하는 동시성을 번역할 때 거절한다.

프로파일등급무엇까지
freestanding0운영체제 없음. 동시성을 쓰지 않는다
embedded1정해진 일감을 나누어 도는 데까지(흐름이 정적으로 정해진다)
native2흐름을 만들고 채널로 주고받는다
server3액터까지

표 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 . . be alloc_bytes root capacity 16 .
    guard is_some g . else return false .
    let v mut slice u8 . . be some_value g .
    set (index 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 be spawn actor logbook .
  let a bool be send book write 7 .
  let b bool be send book write 9 .
  return narrow u8 (send book count) .
end

실행 결과

$ lowentc --run main capfield.low
main() = 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 . . be 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 be spawn actor logbook .
  let a bool be 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 be spawn actor account .
  var dst account be spawn actor account .
  let seed u64 be send src deposit 50 .
  let r result u64 bank_error be 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 be 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

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 be 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 be spawn actor counter .
  spawn send c inc .
  spawn send c inc .
  rem 넣지 못하면 멈추지 않고 오류를 값으로 받는다 --- 비운 뒤 다시 넣는다
  let r result u64 mailbox_full be 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 be 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 be spawn actor counter .
  let a u64 be 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 be spawn actor account .
  let seed u64 be send a deposit 50 .
  let r result u64 bank_error be send a withdraw 30 .
  guard not (is_error r) . else return 1 .
  return ok_value r .
end

var fee u64 be 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:42:0 E-NAME-BUILTIN: a PARAMETER takes the name of a builtin op — inside this op the name now means two things, and which one it means decides how the sentence is bracketed (RFC-0046 P1). Rename the parameter

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 be 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 be spawn actor counter .
  let a u64 be send c inc .
  let b u64 be 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 be spawn actor counter .
  var right counter be spawn actor counter .
  let a u64 be send left inc .
  let b u64 be 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 be 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 . · never · alwayspanic 한 액터를 처음 상태로 다시 세운다다 쓰면 실패를 위로 넘긴다
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 은 액터를 쓸 수 있는 자리를 정한다.