동시성
지금까지는 단일 프로세스(process) 알고리즘만 다뤘다. 하지만 정형 기법(formal methods)의 진짜 셀링 포인트는 동시성(concurrency)을 다루는 데 있다. 동시성은 아주 흔하면서도 추론하기가 몹시 어려워서, 우리 대신 추론해 줄 도구를 쓴다. TLA+ 같은 도구 말이다!
프로세스
프로세스는 동시성의 주된 행위자다. 프로세스는 서로 다른 OS 프로세스일 수도, 서로 다른 스레드(thread)일 수도, 서로 다른 머신에서 도는 서로 다른 프로그램일 수도, 심지어 서로 다른 사람일 수도 있다. 프로세스 하나로 시작하자.
---- MODULE reader_writer ----
EXTENDS Integers, Sequences, TLC
(* --algorithm reader_writer
variables
queue = <<>>;
total = 0;
process writer = 1
begin
AddToQueue:
queue := Append(queue, 1);
end process;
end algorithm; *)
====
specend process가 있다는 점만 빼면 프로세스가 없을 때와 똑같다. 프로세스에 값 1이 할당되어 있다는 점에 주목하자. 이게 나중에 중요해진다.
이제 큐에서 읽는 프로세스를 하나 더 추가하자.
AddToQueue:
queue := Append(queue, 1);
end process;
+
+process reader = 0
+begin
+ ReadFromQueue:
+ total := total + Head(queue);
+ queue := Tail(queue);
+end process;
end algorithm; *)
====
spec경고
모든 프로세스는 서로 비교 가능한 타입이어야 한다. 전부 정수, 전부 문자열, 전부 시퀀스(sequence) 하는 식이다. 유일한 예외로, 프로세스는 모델 값(model value)일 수도 있다.
서로 다른 프로세스가 같은 레이블(label) 이름을 쓸 수는 없다.
writer에는 액션(action)이 Write 하나뿐이고, reader에도 액션이 Read 하나뿐이다. 무엇이 먼저 일어나야 하는지 정하지 않았으므로 두 액션은 어떤 순서로든 일어날 수 있다. (1) 큐에 쓰고 나서 큐에서 읽거나, (2) 큐에서 읽고 나서 큐에 쓰거나 둘 중 하나다.
행동(behavior) (2)는 말이 안 된다. 큐에 아무것도 없는데 어떻게 큐에서 읽는단 말인가? 실제로 스펙을 돌려 보면 TLC가 이것을 에러로 보고한다.
Error: TLC threw an unexpected exception.This was probably caused by an error in the spec or model.See the User Output or TLC Console for clues to what happened.The exception was a tlc2.tool.EvalException: Attempted to apply Head to the empty sequence.
![digraph rw_error {
Init[label="<<>>"];
Bad[label="!?", color=tomato];
Good1[label="<<1>>"];
Good2[label="<<>>"];
Init -> Bad[label=Read];
Init -> Good1[label="Write"];
Good1 -> Good2[label="Read"];
}](/tla/_images/graphviz-4ccafb5c64365bee5eaa79ed91e07eed727d68d9.png)
쓰기 전에 읽는 것은 정의되지 않은 동작이므로 에러로 친다.
“예상치 못한 예외(unexpected exception)”라고는 하지만, 이는 우리 시스템의 실제 결함을 가리킨다. 빈 큐에서 읽으려 할 때 무엇이 가능해야 하는지를 우리가 명세하지 않은 것이다. 우리가 선택할 수 있는 방법은 여러 가지다:
디큐(dequeue) 로직을 그냥 건너뛰고 프로세스를 계속 진행할 수 있다.
큐가 비어 있지 않게 될 때까지 reader를 블록할 수 있다.
기본값으로 대체할 수 있다.
TLC가 이를 시스템의 결함으로 취급하게 하는 대신, 시스템의 일부로서 에러를 발생시킬 수 있다.
팁
불변식(invariant)을 아직 정하지 못했더라도 스펙을 정기적으로 모델 체킹(model checking)하라. 이런 애매한 경우는 일찍 잡아내는 게 좋다.
요점은 무엇이 올바른 선택인지를 우리가 시스템에 요구하는 바에 따라 정한다는 것이다. 지금은 로직을 if 블록으로 감싸서 reader가 빈 큐를 무시하게 하자.
process reader = 0
begin
ReadFromQueue:
- total := total + Head(queue);
- queue := Tail(queue);
+ if queue # <<>> then
+ total := total + Head(queue);
+ queue := Tail(queue);
+ end if;
end process;
end algorithm; *)
====
spec이제 통과한다.
![digraph rw_good {
Init[label="<<>>"];
Nah1[label="<<>>"];
Nah2[label="<<1>>"];
Good1[label="<<1>>"];
Good2[label="<<>>"];
Init -> {Nah1}[label=Read];
Init -> Good1[label="Write"];
Good1 -> Good2[label="Read"];
Nah1 -> Nah2[label="Write"];
}](/tla/_images/graphviz-6c2d3db38eadffe4da0c6c649514663488a5ed95.png)
이제 빈 큐를 읽으면 아무 일도 일어나지 않으므로(noop) 스펙이 통과한다.
pc
단일 프로세스 스펙에서는 pc(“프로그램 카운터”)라는 문자열 변수로 값을 추적했다. 여러 프로세스를 모델링하기 위해 변환기(translator)는 pc를 프로세스 값에서 문자열로 가는 함수(function)로 “끌어올린다”. 그래서 reader의 현재 레이블에 의존하는 불변식을 작성한다면 pc[0]으로 그 레이블을 가져올 수 있다.
지역 변수
writer가 한 번이 아니라 두 번 쓸 수 있도록 수정하자.
variables
queue = <<>>;
total = 0;
-
+ i = 0;
process writer = 1
begin
AddToQueue:
- queue := Append(queue, 1);
+ while i < 2 do
+ queue := Append(queue, 1);
+ i := i + 1;
+ end while;
end process;
process reader = 0
spec상태가 얼마나 늘었는지 보라. while 루프는 원자적(atomic)이지 않아서, 반복 한 번 한 번을 별개의 Write 액션으로 친다. 그래서 이제 가능한 순서는 Read-Write-Write, Write-Read-Write, Write-Write-Read 세 가지다.
i는 writer에서만 쓰이므로 굳이 reader에게 노출할 필요는 없다. 다음과 같이 변수를 프로세스의 지역 변수(local variable)로 만들 수 있다:
variables
queue = <<>>;
total = 0;
- i = 0;
process writer = 1
+variables
+ i = 0;
begin
AddToQueue:
while i < 2 do
spec전역 변수(global variable)와 마찬가지로 지역 변수도 시작값을 여러 개 가질 수 있다— i \in 1..3도 유효하다.
실무에서는 지역 변수를 그리 자주 쓰지 않는데, define 블록의 연산자(operator)가 지역 변수를 쓸 수 없기 때문이다. 즉 지역 변수에 대해서는 타입 검사를 하거나 헬퍼 연산자를 작성하는 등의 일을 하기가 쉽지 않다. 보통 지역 변수는 루프 반복 횟수나 모델 경계 설정처럼 보조 변수 또는 “장부 기록용” 변수로 쓴다.
일단은 while 루프를 빼고 이전 버전으로 돌아가자.
프로세스 집합
프로세스가 하나 있으면 이를 프로세스 집합(process set)으로 확장할 수 있다. process name = val이라고 쓰는 대신 process name \in val이라고 쓴다. 그러면 PlusCal이 집합의 각 값마다 별개의 프로세스를 하나씩 만든다.
---- MODULE reader_writer ----
EXTENDS Integers, Sequences, TLC
+
+Writers == {1, 2, 3}
(* --algorithm reader_writer
variables
queue = <<>>;
total = 0;
-
-process writer = 1
+process writer \in Writers
begin
AddToQueue:
queue := Append(queue, 1);
spec팁
바로 여기서 모델 값의 집합이 아주 유용해진다. reader도 여러 개 두고 싶다면 Readers와 Writers가 같은 타입이면서 서로 겹치지 않도록 해야 한다. 가장 쉬운 방법은 모델 값 집합 두 개를 쓰는 것이다.
동시성은 상태 공간 폭발(state space explosion)의 주요 원인 중 하나다. writer 셋과 reader 하나가 있으면, 이들의 액션 네 개를 순서 짓는 방법은 4! = 24가지다. 다섯 번째 writer를(또는 두 번째 reader를) 추가하면 순서는 120가지로 뛴다.
이제 큐에 값을 최대 세 개까지 넣는데, 읽는 값은 하나뿐이다. reader가 영원히 돌도록 하자.
total := total + Head(queue);
queue := Tail(queue);
end if;
+ goto ReadFromQueue;
end process;
end algorithm; *)
====
spec이는 레이블을 while TRUE 루프 안에 넣는 것과 같다.
self
프로세스 집합에는 특별한 키워드 self가 있는데, 이것은 프로세스의 “값”을 가져온다. 그러니 writer들의 경우 프로세스 값은 1과 2가 된다. writer들에게 1 대신 self를 넣으라고 하면, 최종 합계는 6이 되리라 기대할 수 있다.
process writer \in Writers
begin
AddToQueue:
- queue := Append(queue, 1);
+ queue := Append(queue, self);
end process;
process reader = 0
이것을 돌려 보면 상태 공간은 조금 늘고 고유 상태는 두 배 넘게 늘어난다(상태 69개 / 고유 상태 38개). 왜 그런지 보려면 writer 1과 2가 어떤 순서로든 실행된 뒤에 무슨 일이 생기는지 생각해 보자. 전에는 어느 writer가 먼저 실행되든 큐는 <<1, 1>>였을 것이다. 하지만 이제는 서로 다른 값을 넣으므로 가능한 큐가 <<1, 2>>와 <<2, 1>> 두 가지다.
팁
매크로(macro)는 내부에서 self 값을 쓸 수 있다. 그러니 다음과 같이 쓸 수 있다
macro write() begin
queue := Append(queue, self)
end macro;
단독으로 정의된 프로세스는 내부에 self가 없으므로 이 매크로를 쓸 수 없다. 이를 우회하려면 할당을 원소가 하나뿐인 집합에서 고르는 방식으로 바꾸면 된다:
- process P = val
+ process P \in {val}
await
실제 시스템에는 일정 크기를 넘으면 쓰기를 막는 제한된 큐가 흔하다. 가장 엄격한 제한은 “큐가 비어 있을 때만 쓸 수 있다”일 것이다. 이것을 추가하자:
process writer \in Writers
begin
AddToQueue:
+ await queue = <<>>;
queue := Append(queue, self);
end process;
specawait는 레이블이 언제 실행될 수 있는지에 대한 제약이다. 레이블 안의 모든 await 문이 참으로 평가될 때에만 레이블이 실행될 수 있다— 굳이 말하자면 그때에만 상태가 “커밋”된다.
노트
with x \in set도 집합이 비어 있으면 블록된다.
경고
await는 변수 갱신과 약간 이상하게 상호작용한다— 변수가 식에 직접 들어 있으면 갱신된 값을 기준으로 하지만, defined 연산자를 거쳐 쓰이면 그렇지 않다. 이는 PlusCal->TLA+ 변환 문법 때문이다. 일반적인 예방책으로, await 문에서는 갱신된 변수를 쓰지 마라.
reader에도 await를 추가하면 어떨까? 흥미로운 일이 벌어진다:
process reader = 0
begin
ReadFromQueue:
- if queue # <<>> then
+ await queue # <<>>;
total := total + Head(queue);
queue := Tail(queue);
- end if;
goto ReadFromQueue;
end process;
end algorithm; *)
spec이것을 돌리면 TLC가 “데드락(deadlock)”을 에러로 보고한다. 모든 writer가 실행을 마치고 나면, reader는 다시는 일어나지 않을 조건을 기다리며 ReadFromQueue에 영원히 갇힌다. 어떤 프로세스도 더 진행할 수 없으므로(writer는 끝났기 때문에, reader는 기다리는 상태에 갇혔기 때문에) 데드락이 된다. 보통 이것은 에러이지만, 여러분의 스펙에서는 에러가 아니라면 여기서 끌 수 있다:
노트
모든 프로세스가 끝나는 것(“Done” 상태에 도달하는 것)은 데드락을 일으키지 않는다. PlusCal 변환기가 “모든 게 끝났고 아무 일도 일어나지 않는” 액션을 추가로 끼워 넣기 때문이다. 변환 결과에서 Terminating이라는 이름으로 볼 수 있다.
프로시저
참고: 이 내용은 꽤 복잡하면서도 꽤 틈새적이니, 건너뛰었다가 나중에 다시 와도 좋다.
서로 다른 두 프로세스가 스펙 일부를 공유하게 하고 싶다면, 공통 부분을 매크로로 뽑아낼 수 있다. 하지만 레이블이 들어간, 더 복잡한 것을 뽑아내고 싶다면? 이럴 때는 매크로 대신 프로시저(procedure)를 쓴다. 프로시저는 매크로와 비슷하지만 레이블을 담을 수 있고, 작성하고 쓰기가 조금 더 복잡하다.
프로시저를 쓰려면 Sequences를 EXTENDS해야 한다. 변환기가 제어 흐름을 위해 호출 스택을 저장해야 하는데, 이를 튜플(tuple)에 저장하기 때문이다.
procedure Name(arg1, ...)
variables var1 = ...
begin
Label1:
\* stuff
Label2:
\* more stuff
return;
end procedure;
return에 도달하면 프로시저가 종료되고 제어가 호출한 프로세스로 돌아간다. 프로그래밍에서 말하는 의미로 무언가가 “반환”되지는 않는다. 프로시저가 return에 도달하지 않고 종료되면 TLC가 에러를 낸다. return은 그저 프로시저를 끝낼 뿐이다. 프로시저가 끝내 거기에 도달하지 못하면 TLC가 에러를 낸다.
프로시저를 호출하려면 call Name(val1, ...);이라고 써야 한다. 프로시저 호출 뒤에는 반드시 goto, 레이블, 또는 (다른 프로시저에서 호출했다면) 또 다른 return이 와야 한다.
process p = "process"
begin
A:
call my_procedure(a, b);
\* Must be followed by a label
B:
프로시저는 모든 매크로보다 뒤에, 모든 프로세스보다 앞에 정의해야 한다.
예제: 스레드
동시성 예제를 하나 더 살펴보자. 스레드 두 개가 카운터 하나를 증가시킨다. 처음에는 이를 원자적으로 하게 해서 기대한 값이 나온다는 것을 보인다. 그런 다음 갱신을 비원자적으로 바꿔서 경쟁 조건(race condition)이 존재한다는 것을 보인다.
---- MODULE threads ----
EXTENDS TLC, Sequences, Integers
NumThreads == 2
Threads == 1..NumThreads
(* --algorithm threads
variables
counter = 0;
define
AllDone ==
\A t \in Threads: pc[t] = "Done"
Correct ==
AllDone => counter = NumThreads
end define;
process thread \in Threads
begin
IncCounter:
counter := counter + 1;
end process;
end algorithm; *)
====
specCorrect는 앞서 작성한 중복(duplicates) 스펙의 불변식과 비슷하다. 모든 스레드가 실행을 마치고 나면 각 스레드가 counter를 한 번씩 증가시켰어야 하고, 이는 곧 counter = NumThreads라는 뜻이다. INVARIANT Correct로 스펙이 통과하는지 확인하라.
노트
예제를 돌리기 쉽도록 NumThreads를 하드코딩했다. 실제 스펙이라면 NumThreads는 상수(constant)일 것이다.
이제 counter를 원자적으로 갱신할 수 없다고 가정하자. 하드웨어가 지원하지 않을 수도 있고, counter가 네트워크상의 다른 머신에 있을 수도 있고, 증가 연산이 그저 더 복잡한 무언가를 대신하는 것일 수도 있다. 어느 쪽이든 이제는 두 스텝(step)에 걸쳐 갱신해야 한다. 먼저 값을 스레드 지역 변수에 할당하고, 이어서 다음 값을 계산해 counter에 할당한다. 이를 모델링하기 위해 레이블을 둘로 쪼개서 동시성이 끼어들 지점을 만든다.
스레드 지역 변수는 “내부 구현 세부 사항”이고, 여기에 전역적인 연산을 할 일도 없을 것 같으니 프로세스 변수를 쓰기에 좋은 자리다.
end define;
process thread \in Threads
+variables tmp = 0;
begin
+ GetCounter:
+ tmp := counter;
+
IncCounter:
- counter := counter + 1;
+ counter := tmp + 1;
end process;
end algorithm; *)
====
spec계속하기 전에, 모델링 실력을 키우는 좋은 연습을 하나 권하고 싶다. 내가 이 예제를 풀어 가는 방식을 보면 이게 실패하리라는 건 이미 알 것이다. 그런데 어떻게 실패할까? 모델 체커(model checker)를 돌리기 전에 어떤 에러가 왜 나올지 알아맞혀 보라. 몇 스텝이 걸릴지, 프로세스들이 어떤 순서로 실행될지도 추측해 보라.
이렇게 하면 TLA+ 실력이 늘기도 하지만, 효과는 그것만이 아니다. 스펙을 더 많이 쓸수록 모델 체커를 돌리지 않고도 에러가 보이기 시작한다. 동시성이 그토록 직관에 반하는 이유 중 하나는, 평소에는 우리가 저지른 실수에 대해 빠른 피드백을 받지 못한다는 데 있다. 코드에 경쟁 조건이 있어도 그게 여러분의 발목을 잡기까지 며칠, 몇 주가 걸릴 수 있고, 그 뒤에 그것을 완전히 이해하는 데는 더 오래 걸린다. 반면 스펙에서는 모델 체커가 즉시 보여 준다. 덕분에 경쟁 조건에 대한 직관이 평소보다 훨씬 빨리 길러진다.
…
자, 에러는 대략 이렇게 생겼을 것이다:
두 스레드 모두 counter가 0일 때 값을 읽는다. 즉 둘 다 tmp를 0으로 설정하고, 따라서 둘 다 counter := 0 + 1을 할당한다. 락(lock)을 추가하자.
---- MODULE threads ----
EXTENDS TLC, Sequences, Integers
+CONSTANT NULL
NumThreads == 2
Threads == 1..NumThreads
@@ -8,6 +9,7 @@
variables
counter = 0;
+ lock = NULL;
define
AllDone ==
@@ -20,11 +22,18 @@
process thread \in Threads
variables tmp = 0;
begin
+ GetLock:
+ await lock = NULL;
+ lock := self;
+
GetCounter:
tmp := counter;
IncCounter:
counter := tmp + 1;
+
+ ReleaseLock:
+ lock := NULL;
end process;
end algorithm; *)
====
spec(여기서 NULL <- -1로 해도 되고 NULL <- [model value]로 해도 된다. 나는 모델 값을 쓰기를 강력히 권한다. 그러면 실수로 숫자처럼 쓸 일이 아예 없기 때문이다!)
이제 스펙이 다시 통과한다.
불변식 더 찾기
내가 가르치는 사람들과 즐겨 하는 연습이 하나 더 있다. 이 시스템에는 다른 속성(property)이 무엇이 있을까?
항상 생각해야 할 것 하나는 타입 불변식(type invariant)이다. 타입 불변식은 느슨하고 단순해야 하며, 그저 각 값이 가질 수 있는 대략적인 집합이면 된다.
노트
이 불변식은 프라이빗 변수 tmp를 참조하므로 TLA+ 변환 결과보다 아래에 둬야 한다. \* END TRANSLATION 줄을 찾아 그 뒤에(그리고 ==== 앞에) 넣어라.
\* Needs to be below translation
TypeInvariant ==
/\ counter \in 0..NumThreads
/\ tmp \in [Threads -> 0..NumThreads]
/\ lock \in Threads \union {NULL}
좀 더 복잡한 속성은 counter가 어떤 스레드의 tmp 값보다도 결코 작지 않다는 것이다. 이것은 몇 가지 방법으로 쓸 수 있다:
CounterNotLtTmp1 ==
tmp \in [Threads -> 0..counter]
\* or
CounterNotLtTmp2 ==
\A t \in Threads:
tmp[t] <= counter
편한 쪽을 쓰면 된다.
이게 프로덕션 스펙이었다면, 스레드가 락을 해제하기 전에 실제로 락을 가지고 있는지 확인하는 간단한 에러 검사도 assert 형태로 추가했을 것이다.
counter := tmp + 1;
ReleaseLock:
+ assert lock = self;
lock := NULL;
end process;
end algorithm; *)
불변식의 한계
지금 내 눈에 보이는 흥미로운 불변식은 이게 전부다. 하지만 불변식이 아닌 속성도 몇 가지 보인다. 첫째, counter와 tmp는 증가만 할 수 있다는 점에 주목하라. 모델 체커가 이것도 검사해 주면 좋을 것이다. 둘째, 한 스레드가 다른 스레드의 락을 “훔치는” 일은 불가능해야 한다. 어떤 스레드가 락을 가지고 있다면, 다른 스레드가 락을 얻기 전에 그 스레드가 먼저 락을 해제해야 한다. 이 둘은 모두 불변식이 아니다. 이를 위반하는 것은 잘못된 상태가 아니라, 올바른 상태 사이의 잘못된 전이이기 때문이다.
이런 이유만으로도 불변식은 시스템의 속성을 온전히 모델링하기에 부족하다. 하지만 여기에는 더 깊은 문제가 있다. AllDone은 프로세스들이 끝나면 올바른 결과가 나온다는 것만 말한다. 스레드가 아무것도 하지 않는 루프를 영원히 돌 수 있도록 스펙을 바꾸면, 이 불변식은 자명하게 통과해 버린다. 우리가 정말로 말하고 싶은 것은, 무슨 일이 있어도 언젠가는 올바른 결과가 나온다는 것이다.
이것은 완전히 다른 부류의 요구 사항이다! 시스템이 잘못된 일을 한다는 것을 말하는 대신, 시스템이 항상 올바른 일을 한다는 것을 보이고 싶은 것이다. 다음 장에서는 이런 종류의 속성을 작성하고 검사하는 방법을 배운다.
요약
동시성이란 서로 다른 여러 독립적인 행위자가 스펙 안에서 상호작용하는 것이다.
PlusCal에서 동시성의 기본 단위는 프로세스다. 모든 프로세스는 같은 타입이어야 한다(또는 모델 값이어야 한다).
프로세스는 프로세스 집합에 속할 수 있다. TLC는 집합의 원소마다 프로세스를 하나씩 만든다. 프로세스 집합에 속한 프로세스의 값은
self로 가져올 수 있다.프로세스는 지역 변수를 가질 수 있다. 프로세스 집합에서는 프로세스마다 지역 변수의 시작값이 다를 수 있다. 지역 변수는 다른 프로세스나
define안에서 참조할 수 없다.await expr는expr가 거짓이면 레이블이 실행되지 못하게 막는다. 스펙에서 실행할 수 있는 레이블이 하나도 없으면 스펙은 데드락에 빠진다.