13 작업
먼저 알아야 할 것: 4장(블록), 6장(큐), 8장(결함).
이 장이 답하는 물음
- 한 상태 안에서 여러 일을 번갈아 하려면?
- 작업은 언제 바뀌고, 작업 사이에 자료는 어떻게 주고받는가?
- 작업 하나가 결함을 내면 어떻게 되는가?
13.1 번갈아 돌기
작업은 한 상태 안의 실행 흐름이다. 스레드가 아니다 — 작업은 스스로 yield 하는 자리에서만 바뀐다. 그래서 두 yield 사이에서는 다른 작업이 끼어들지 않고, 자물쇠가 필요 없다.
tasks 이름 수 가 작업 묶음을 선언하고, 블록 묶음 spawn 이 작업을 만들고, 묶음 run_all 이 모두 끝날 때까지 돌린다.
13-turns.pnx
use io
tasks workers 2
word count_to (name str, n int)
1 to i
loop
i n gt until
cat (name, " ", i) io.println
yield or_return rem give the other task a turn
i 1 add to i
end
end
word demo ()
do "ping" 3 count_to end workers spawn or_else (e) return end drop rem spawn gives the task; not needed here
do "pong" 2 count_to end workers spawn or_else (e) return end drop rem nothing runs yet: both wait for a turn
workers run_all rem now they run, until both are done
end
demo
출력
$ pnx 13-turns.pnx
ping 1
pong 1
ping 2
pong 2
ping 3
spawn 한 줄을 따라가면 작업이 무엇을 받고 무엇을 주는지 보인다.
| 토큰 | 그 뒤의 스택 | 일어난 일 |
|---|---|---|
do "ping" 3 count_to end | 블록 | 받는 것도 주는 것도 없는 블록이 값으로 올라간다. 안의 리터럴은 블록이 든다. |
workers spawn | 작업 | 묶음에 작업 하나를 만들고 그 작업을 준다. 자리가 없으면 실패한다. |
or_else (e) return end | 작업 | 성공: 건너뛴다. 실패였다면 낱말을 떠난다. |
drop | — | 작업 값은 resume 이나 cancel 에 쓰지만 여기서는 run_all 이 돌리므로 버린다. |
spawn 은 작업을 만들기만 하고 돌리지 않는다. run_all 이 묶음의 작업에 차례로 한 번씩 돌 기회를 주고, 작업은 yield 에서 차례를 내놓는다. 출력이 번갈아 나오는 까닭을 차례마다 적으면 이렇다.
| 차례 | 도는 작업 | 출력 | 그다음 |
|---|---|---|---|
| 1 | ping | ping 1 | yield: 차례를 내놓는다 |
| 2 | pong | pong 1 | yield |
| 3 | ping | ping 2 | yield |
| 4 | pong | pong 2 | yield |
| 5 | ping | ping 3 | yield |
| 6 | pong | (없음) | i 가 3 이 되어 until 로 떠난다. 작업이 끝난다 |
| 7 | ping | (없음) | i 가 4. 작업이 끝난다. 모두 끝났으므로 run_all 이 돌아온다 |
각 작업은 자기 지역 변수(name, n, i)와 자기 스택을 따로 갖는다. 그래서 yield 로 갔다가 돌아와도 하던 자리 그대로 이어서 돈다.
13.2 큐로 잇기
작업 사이의 자료는 선언된 저장소로 주고받는다. 큐가 가장 흔하다. 큐 wait_take 는 큐에서 꺼내고 v 큐 wait_put 은 넣는다. 큐가 비어(차) 있는 동안 작업은 양보하고, 다음 차례에 다시 본다.
13-pipeline.pnx
use io
rem Three stages linked by queues: numbers -> squares -> total.
tasks stages 3
queue numbers int 2
queue squares int 2
global total int
word produce ()
1 to i
loop
i 5 gt until
i numbers wait_put or_return rem waits while the queue is full
i 1 add to i
end
-1 numbers wait_put or_return
end
word square ()
loop
numbers wait_take or_return to n rem waits while the queue is empty
n 0 lt if -1 squares wait_put or_return return end
n n mul squares wait_put or_return
end
end
word add_up ()
loop
squares wait_take or_return to n
n 0 lt until
total n add set total
end
end
word demo ()
do produce end stages spawn or_else (e) return end drop
do square end stages spawn or_else (e) return end drop
do add_up end stages spawn or_else (e) return end drop
stages run_all
cat ("1 + 4 + 9 + 16 + 25 = ", total) io.println
end
demo
출력
$ pnx 13-pipeline.pnx
1 + 4 + 9 + 16 + 25 = 55
세 단계가 각각 작업이고, 큐의 용량은 둘뿐이다. 단계는 서로를 기다리며 번갈아 돈다. 끝을 알리는 데는 -1 을 보냈다.
모든 작업이 아무도 채우지 않을 큐를 기다리면 더 갈 수 없다. run_all 은 이것을 알아보고, 끝없이 도는 대신 결함 deadlock 으로 실행을 끝낸다. 작업이 아닌 주 실행에서 큐를 기다리는 것은 바로 그 결함이다.
13-deadlock.pnx
use io
tasks stages 2
queue numbers int 2
queue squares int 2
word square ()
numbers wait_take or_return to n
n n mul squares wait_put or_return
end
word show ()
squares wait_take or_return to n
cat ("square: ", n) io.println
end
word demo ()
do square end stages spawn or_else (e) return end drop
do show end stages spawn or_else (e) return end drop
stages run_all rem no one puts into numbers
end
demo
결함으로 멈춤
$ pnx 13-deadlock.pnx
13-deadlock.pnx:16: deadlock: waiting for a queue that nothing can change
지켜보는 것은 두 기다림 낱말뿐이다. 스스로 도는 대기(loop … yield … end)는 보지 않으므로, 그런 코드를 돌리는 호스트는 예산을 준다(14장).
흔한 오해: spawn 의 블록은 바깥의 지역 변수를 본다
spawn 에 주는 블록은 값이 되는 블록이라 바깥의 지역 변수를 보지 못한다. 작업은 그 낱말이 끝난 뒤에도 살 수 있기 때문이다. 작업에 값을 넘기려면 전역이나 큐를 쓰거나, 위의 예처럼 블록 안에서 낱말을 부르며 리터럴을 준다.13-no-locals.pnx
tasks workers 2
word start (n int) do n drop end workers spawn or_else (e) return end drop end
거절
$ pnx 13-no-locals.pnx
13-no-locals.pnx:2:23: this name is not declared before this place
13.3 결함과 취소
작업 하나의 결함은 그 실행 전체를 끝낸다 — 다른 작업도 함께 멈춘다. 상태는 남아서 다음 실행을 받는다.
13-fault.pnx
use io
tasks workers 2
global zero int
word demo ()
do "the healthy task starts" io.println yield or_return "and never finishes" io.println end workers spawn or_else (e) return end drop
do 1 zero div drop end workers spawn or_else (e) return end drop
workers run_all
end
demo
결함으로 멈춤
$ pnx 13-fault.pnx
the healthy task starts
13-fault.pnx:6: division by zero
작업들이 하는 일이 다른 작업까지 끌고 내려가서는 안 될 때(서버의 요청, 큐에서 받은 일)에는 묶음을 apart 로 선언한다. 그러면 결함은 그것이 난 작업만 끝내고, 다른 작업과 실행은 계속 간다.
13-apart.pnx
use io
tasks workers 2 apart
global zero int
word demo ()
do "the healthy task starts" io.println yield or_return "and finishes" io.println end workers spawn or_else (e) return end drop
do 1 zero div drop end workers spawn or_else (e) return end drop
workers run_all
"the run goes on" io.println
end
demo
출력
$ pnx 13-apart.pnx
the healthy task starts
and finishes
the run goes on
작업 cancel 은 그 작업의 다음 yield 나 큐 기다림을 cancelled 로 실패시킨다. 작업은 그 실패를 or_else 로 받아 정리하고 끝낸다. 취소는 작업을 억지로 멈추지 않는다.
문답
물음. 작업은 몇 개까지 만들 수 있는가?
답. tasks 이름 수 의 수 까지다(최대 4096). 끝난 작업의 자리는 다시 쓰인다. 작업마다 자기 호출 깊이와 구역이 있으니 수가 크면 덩이도 커야 한다.
문답
물음. 주 실행에서 yield 를 부르면?
답. 아무 일도 없이 성공한다. 주 실행은 작업이 아니라 차례를 넘길 데가 없다. 그래서 같은 낱말을 작업 안에서도 밖에서도 쓸 수 있다.
정리
- 작업은
yield하는 자리에서만 바뀐다(표준 14.2). spawn은 받는 것도 주는 것도 없는 블록으로 작업을 만들고,run_all이 돌린다(14.1).- 작업 사이의 자료는 전역과 큐로 나눈다.
wait_take·wait_put은 큐를 기다리고, 모든 작업이 큐를 기다리는 실행은 결함deadlock이다(14.3). spawn의 블록은 바깥의 지역 변수를 보지 못한다(14.4).- 작업의 결함은 실행 전체를 끝낸다(14.4). 묶음이
apart로 선언되었으면 그 작업만 끝낸다(14.4 (3a)).