Ada에서의 안전한 동시성 처리 ── task와 protected object 실전 가이드
· 업데이트: · Go Komura · Ada, Concurrency, Tasking, ProtectedObjects, Rendezvous, RealTime, ParallelProgramming, ProgrammingLanguage, 동시성 처리, 고신뢰성
수정 이력(4건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 기사 앞부분에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응하여 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
- 로컬에서 빌드해 실행하기까지의 절차와, 장과 샘플 파일의 대응표를 추가했습니다. 아울러 rendezvous와 barrier의 동작을 그림으로 나타내고, guard 조건의 조각을 동작하는 형태로 확장했으며, 우선순위가 실제로 적용되는지 확인하는 방법을 추가했습니다. 실측값은 싣지 않고, 독자가 직접 확인하는 절차를 제시합니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635326)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「Ada에서의 안전한 동시성 처리 ── task와 protected object 실전 가이드」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/ada-task-concurrency/
- DOI(등록된 아카이브)
- 10.5281/zenodo.21635326
- DOI(마지막 등록 버전)
- 10.5281/zenodo.21635327
1. 들어가며 ── 언어에 내장된 동시성 처리
동시성 처리는 현대 소프트웨어 개발에서 피할 수 없는 주제입니다. 그러나 많은 언어에서 동시성 처리는 「나중에 붙인」 라이브러리나 OS 기능에 의존하며, 올바르게 쓰려면 깊은 지식과 신중한 설계가 필요합니다.
Ada는 이 문제에 대한 독자적인 답을 가지고 있습니다. 언어 사양 자체에 동시성 처리가 포함되어 있습니다.
Ada의 동시성 처리 모델:
- task ── 독립적으로 실행되는 동시성 단위
- rendezvous ── task 간 동기 통신
- protected object ── 언어가 관리하는 상호 배제
- 실시간 우선순위 ── Annex D 실시간 기능
task와 rendezvous는 1983년 Ada 83부터 존재하고, protected object와 Annex D 실시간 기능은 Ada 95에서 추가되었으며, Ada 2005, 2012로 이어지며 발전해 왔습니다. mutex나 semaphore 같은 저수준 동기화 primitive가 아니라, 「설계 의도를 코드로 직접 표현할 수 있다」는 점이 Ada 동시성 처리의 가장 큰 특징입니다.
이 기사에서는 Ada의 동시성 처리를 8개의 실전 코드 예제로 단계적으로 설명합니다. 각 코드 예제는 독립된 snippet으로서 실제로 컴파일·실행할 수 있으며, 로컬에서 시험해 볼 수 있습니다.
이 기사에 등장하는 코드 조각은, 장별로 파일에 정리한 참조용 코드집으로 GitHub에 공개하고 있습니다.
ada-task-concurrency - komurasoft-blog-samples (GitHub)
로컬에서 실행하기 ── 빌드와 실행
「컴파일·실행 가능」이라고 쓴 이상, 그 절차도 먼저 제시합니다.
GNAT 준비하기
GNAT는 GCC의 Ada 컴파일러입니다. Linux에서는 apt install gnat-13, Windows에서는 MSYS2의 mingw-w64-x86_64-gcc-ada, 또는 Alire(Ada / SPARK 패키지 매니저)에서 설치할 수 있습니다.
빌드하고 실행하기
각 snippet은 한 파일에 여러 compilation unit(task 사양, task 본문, 메인 프로시저)을 포함하므로, gnatchop으로 분할한 뒤 gnatmake합니다. gnatchop은 「unit 이름 = 파일 이름」이라는 GNAT 명명 규칙에 맞춰 파일을 분할하는 도구입니다.
mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
분할 후 메인 프로시저 이름이 그대로 실행 파일 이름이 됩니다.
장과 파일의 대응
장 번호와 파일 번호는 하나씩 다릅니다(3장이 01_). 대응은 다음과 같습니다.
| 장 | 파일 | 실행 파일 | 내용 |
|---|---|---|---|
| 3장 | 01_hello_task.ada |
hello_task_demo |
task의 기본 형태 |
| 4장 | 02_rendezvous_intro.ada |
rendezvous_demo |
rendezvous를 통한 양방향 데이터 전송 |
| 5장 | 03_selective_accept.ada |
selective_accept_demo |
선택적 accept와 서버 task |
| 6장 | 04_producer_consumer.ada |
producer_consumer_demo |
producer·consumer |
| 7장 | 05_protected_counter.ada |
protected_counter_demo |
protected object를 통한 상호 배제 |
| 8장 | 06_bounded_buffer.ada |
bounded_buffer_demo |
barrier가 있는 protected entry(bounded buffer) |
| 9장 | 07_timed_entry.ada |
timed_entry_demo |
타임아웃이 있는 select 호출 |
| 10장 | 08_task_priorities.ada |
task_priorities_demo |
task 우선순위와 실시간 스케줄링 |
본문의 코드 조각은 설명을 위해 필요한 부분만 잘라낸 것입니다. 그대로는 동작하지 않으므로, 직접 실행할 때는 위의 파일을 사용하세요.
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 20건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 동시성 처리의 「위험」을 다시 짚기
Ada 이야기에 들어가기 전에, 왜 「안전한」 동시성 처리가 중요한지 간단히 확인합니다.
동시성 처리에서 대표적인 버그는 다음과 같습니다.
- 데이터 경합 (data race): 여러 thread가 같은 메모리 위치에 동시에 접근하고, 그중 적어도 한쪽이 쓰기인 경우. 결과는 미정의입니다.
- deadlock: 여러 task가 서로의 완료를 계속 기다려 영원히 진행하지 않는 상태.
- 우선순위 역전 (priority inversion): 높은 우선순위 task가 낮은 우선순위 task가 보유한 리소스를 기다리고, 중간 우선순위 task가 낮은 우선순위 task를 preempt(실행 중인 task를 중단하고 다른 task로 전환하는 것)하는 문제.
- starvation: 어떤 task가 영원히 리소스를 얻지 못하는 상태.
Ada의 동시성 처리 모델은 이러한 문제에 대해 언어 수준의 방어책을 제공합니다.
데이터 경합 → protected object가 배타적 접근을 보장
deadlock → rendezvous 모델이 구조적인 동기화를 제공
우선순위 역전 → Priority Ceiling Protocol을 언어 내장으로 이용 가능
starvation → entry barrier와 queuing policy로 제어
3. task의 기본 ── 독립된 실행 단위
Ada에서 동시성 처리의 기본 단위는 task입니다. task는 thread와 비슷하지만 OS thread와 1대 1로 대응한다고 단정할 수는 없으며, Ada runtime이 스케줄링을 관리합니다.
task Greeter is
entry Start;
end Greeter;
task body Greeter is
begin
accept Start;
Put_Line ("Hello from a task!");
end Greeter;
이 코드(01_hello_task.ada)에는 중요한 포인트가 몇 가지 있습니다.
task는 선언되면 자동으로 실행을 시작합니다. Greeter task는 그것을 포함하는 프로시저의 begin 시점에 시작되며, accept Start;에서 호출 측의 rendezvous 요청을 기다립니다.
entry는 task가 외부에 공개하는 인터페이스입니다. 호출 측이 Greeter.Start;를 호출하면 task의 accept Start;와 동기화합니다. 이것을 rendezvous라고 합니다.
task의 종료는 자동으로 기다립니다. 메인 프로시저가 종료할 때 아직 실행 중인 task가 있으면, 그들의 완료를 암묵적으로 기다립니다. 이는 C++의 std::thread::join을 빼먹어 발생하는 crash와 대조적입니다.
실행 예
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
Main: starting task...
Hello from a task!
Main: task has completed.
여기에 한 가지 주의가 있습니다. accept Start;에는 do ... end 블록이 없습니다. 즉 rendezvous가 성립한 순간에 양쪽이 해제되고, 그 이후는 동시에 진행합니다. 따라서 후반 두 줄의 순서는 보장되지 않습니다. 환경에 따라 Main: task has completed.가 먼저 나올 수 있습니다. 순서까지 고정하려면, 순서를 지켜야 하는 처리를 accept Start do ... end Start;의 do ... end 안에 둡니다. 첫 줄만은 반드시 맨 앞에 옵니다. Greeter는 Greeter.Start;가 호출될 때까지 accept에서 멈춰 있기 때문입니다.
4. rendezvous ── 데이터를 전달하는 동기 통신
rendezvous는 단순한 동기화만이 아니라, 양방향 데이터 전달도 할 수 있습니다.
task Worker is
entry Compute (X, Y : Integer; Result : out Integer);
end Worker;
task body Worker is
A, B : Integer;
Output : Integer;
begin
accept Compute (X, Y : Integer; Result : out Integer) do
A := X;
B := Y;
Output := A * A + B * B;
Result := Output;
end Compute;
end Worker;
호출 측은 다음과 같이 사용합니다(02_rendezvous_intro.ada).
Worker.Compute (3, 4, Answer);
Put_Line ("Main: result = " & Integer'Image (Answer));
여기서 중요한 설계상의 포인트는 파라미터 모드가 명시되어 있다는 점입니다.
in모드: 호출 측에서 task로 값을 전달out모드: task에서 호출 측으로 결과를 반환in out모드: 양방향
accept 본문 안의 do ... end 블록이 critical section이 됩니다. 그동안 호출 측은 차단되고, task는 다른 entry를 받지 않습니다. 처리가 끝나면 양쪽이 재개합니다.
시간 순으로 보면 대기 모습은 다음과 같습니다.
sequenceDiagram
participant Main as 호출 측
participant W as Worker task
Main->>W: Worker.Compute를 호출
Note over Main: accept에 도달할 때까지 차단
W->>W: accept Compute ... do에 도달
Note over Main,W: rendezvous 성립 / accept 본문이 실행됨
W-->>Main: out 파라미터 Result에 결과를 씀
Note over Main,W: end Compute에서 양쪽이 동시에 재개
Main->>Main: 이어지는 처리
W->>W: 이어지는 처리
먼저 도달한 쪽이 기다립니다. 호출 측이 먼저면 accept에 도달할 때까지, task 측이 먼저면 누군가 호출할 때까지 각각 멈춥니다. 어느 쪽이 먼저든, do ... end 안은 반드시 둘이 모인 상태에서 실행됩니다.
rendezvous의 특징을 정리하면 다음과 같습니다.
| 특징 | 설명 |
|---|---|
| 동기화 | 호출 측과 task 측이 동시에 rendezvous point에 도달할 때까지 기다림 |
| 데이터 전송 | in / out / in out 파라미터로 양방향 값을 전달할 수 있음 |
| 상호 배제 | accept 본문 실행 중에는 task의 다른 entry가 차단됨 |
| 구조화 | 어느 entry가 언제 받아들여지는지가 task body에 명시됨 |
실행 예
gnatchop ../src/snippets/02_rendezvous_intro.ada
gnatmake rendezvous_demo
./rendezvous_demo
Main: calling Worker.Compute...
Main: result = 25
3 * 3 + 4 * 4 = 25가 out 파라미터를 통해 돌아옵니다. = 오른쪽에 공백이 하나 들어가는 것은, 정수형의 'Image가 음이 아닌 값 앞에 공백을 한 문자 두는 사양 때문입니다. 3장과 달리 이쪽은 두 줄의 순서가 보장됩니다. Worker.Compute 호출은 end Compute;까지 반환되지 않기 때문입니다.
5. 선택적 accept ── 여러 서비스를 대기하기
실제 서버 task에서는 여러 종류의 요청을 대기해야 합니다. Ada의 select 문은 이를 언어 수준에서 실현합니다.
task Server is
entry Deposit (Amount : Integer);
entry Withdraw (Amount : Integer; Success : out Boolean);
entry Balance (Value : out Integer);
end Server;
task body Server is
Current : Integer := 0;
begin
loop
select
accept Deposit (Amount : Integer) do
Current := Current + Amount;
end Deposit;
or
accept Withdraw (Amount : Integer; Success : out Boolean) do
if Current >= Amount then
Current := Current - Amount;
Success := True;
else
Success := False;
end if;
end Withdraw;
or
accept Balance (Value : out Integer) do
Value := Current;
end Balance;
or
terminate;
end select;
end loop;
end Server;
이 코드(03_selective_accept.ada)의 select 문에는 여러 or 분기가 있으며, 호출이 있는 entry 중 하나가 선택됩니다(선택은 구현 정의입니다). 어느 entry도 호출되지 않았으면, 하나가 호출될 때까지 대기합니다.
or terminate;는 특별한 분기입니다. 「메인 프로시저가 종료했고, 이 task에 대해 아무도 entry 호출을 할 가능성이 없어진」 때에 task를 안전하게 종료시킵니다. deadlock의 원인이 되는 「계속 기다리는 서버 task」 문제를 해결하는 Ada 고유의 메커니즘입니다.
선택적 accept의 강력한 점은 guard 조건도 작성할 수 있다는 것입니다.
다음은 ring buffer를 내부에 가진 task입니다. select 부분만 잘라내면 Count나 Head가 어디서 왔는지 알 수 없으므로, 선언부부터 이어서 제시합니다. 같은 생각을 protected object로 다시 쓴 형태는 8장에서 다룹니다.
task Buffer_Task is
entry Put_Item (Item : Integer);
entry Get_Item (Item : out Integer);
end Buffer_Task;
task body Buffer_Task is
Max : constant := 8;
Data : array (0 .. Max - 1) of Integer;
Head : Integer := 0; -- 다음에 꺼낼 위치
Tail : Integer := 0; -- 다음에 쓸 위치
Count : Integer := 0; -- 현재 요소 수
begin
loop
select
when Count > 0 =>
accept Get_Item (Item : out Integer) do
Item := Data (Head);
Head := (Head + 1) mod Max;
Count := Count - 1;
end Get_Item;
or
when Count < Max =>
accept Put_Item (Item : Integer) do
Data (Tail) := Item;
Tail := (Tail + 1) mod Max;
Count := Count + 1;
end Put_Item;
or
terminate;
end select;
end loop;
end Buffer_Task;
guard 조건이 거짓인 분기는 그 자리에서는 선택 대상에서 빠집니다. 이로써 「버퍼가 비어 있으면 Get은 기다리게 하고, 가득 차면 Put은 기다리게 한다」와 같은 제어를 선언적으로 작성할 수 있습니다. Head와 Tail을 mod Max로 진행하는 것이 ring buffer의 본체이며, guard 조건은 그 첨자가 유효한 범위에 들어가도록 보장하는 역할도 합니다.
6. producer·consumer ── rendezvous로 동기화
rendezvous를 사용한 전형적인 패턴으로 producer·consumer를 보겠습니다.
task Consumer is
entry Deliver (Item : Integer);
end Consumer;
task Producer;
task body Consumer is
Sum : Integer := 0;
begin
for I in 1 .. 5 loop
accept Deliver (Item : Integer) do
Sum := Sum + Item;
end Deliver;
end loop;
end Consumer;
task body Producer is
begin
for I in 1 .. 5 loop
Consumer.Deliver (I);
end loop;
end Producer;
이 패턴(04_producer_consumer.ada)에서는 producer가 Deliver를 호출할 때마다 consumer와 동기화합니다. producer가 너무 빠르면 consumer가 accept할 때까지 기다리게 되고, consumer가 너무 빠르면 producer의 다음 호출까지 기다리게 됩니다. 자연스러운 backpressure(수신 측이 처리를 따라가지 못할 때, 송신 측 속도가 자동으로 억제되는 것)가 걸리는 셈입니다. 큐를 끼우지 않는 rendezvous에서는 이것이 버퍼 오버플로 걱정 없이 성립합니다.
7. protected object ── lock이 필요 없는 상호 배제
task가 「능동적인 동작 주체」인 데 비해, protected object는 「수동적인 공유 데이터」를 위한 메커니즘입니다.
protected Counter is
procedure Increment;
function Value return Integer;
private
Count : Integer := 0;
end Counter;
protected body Counter is
procedure Increment is
begin
Count := Count + 1;
end Increment;
function Value return Integer is
begin
return Count;
end Value;
end Counter;
protected object의 중요한 규칙은 다음과 같습니다.
- function은 읽기 전용. 여러 task가 동시에 function을 호출할 수 있습니다.
- procedure는 읽기·쓰기. procedure 실행 중에는 다른 procedure도 function도 차단됩니다.
- entry는 barrier가 있음. barrier 조건이 참이 될 때까지 호출 측은 큐에서 기다립니다.
이 코드(05_protected_counter.ada)에서는 3개의 worker task가 각각 1,000회씩 Increment를 호출합니다. protected object가 상호 배제를 보장하므로, 최종 카운터 값은 항상 3,000이 됩니다. mutex의 lock·unlock을 직접 쓸 필요는 없습니다.
task type Worker (Id : Integer; Rounds : Integer);
task body Worker is
begin
for I in 1 .. Rounds loop
Counter.Increment; -- protected object가 배타를 보장
end loop;
end Worker;
W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);
실행 예
여기에 한 가지 문제가 있습니다. 메인 프로시저가 그대로 Counter.Value를 읽으면, 아직 worker가 돌아가는 중의 값을 읽게 됩니다. 그래서 완전판(05_protected_counter.ada)에서는 완료를 세는 procedure와, 모두 완료되기를 기다리는 entry를 더했습니다.
protected Counter is
procedure Increment;
procedure Mark_Done;
entry All_Done;
function Value return Integer;
private
Count : Integer := 0;
Done_Count : Integer := 0;
end Counter;
entry All_Done when Done_Count = Num_Workers라는 barrier를 두고, 각 worker는 루프를 빠져나온 지점에서 Counter.Mark_Done;을 호출합니다. 메인 프로시저는 Counter.All_Done;으로 모두 완료되기를 기다린 뒤 값을 읽습니다. 대기용 별도의 플래그 변수도 sleep도 필요 없습니다.
gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000
몇 번 실행해도 3000입니다. 3개의 task가 합계 3,000회 Increment를 호출하고, protected object가 그 한 번 한 번을 배타적으로 실행하기 때문입니다.
protected object가 없으면 무엇이 일어나는가
protected object의 가치를 이해하기 위해, 보호하지 않는 경우의 위험한 코드를 보겠습니다.
-- ⚠ 위험: 공유 변수를 직접 조작하고 있음
Shared_Counter : Integer := 0;
task body Bad_Worker is
begin
for I in 1 .. 10_000 loop
Shared_Counter := Shared_Counter + 1; -- data race!
end loop;
end Bad_Worker;
Shared_Counter := Shared_Counter + 1은 CPU 수준에서는 「읽기→덧셈→다시 쓰기」의 3단계입니다. 여러 task가 동시에 이를 실행하면, 어떤 task의 덧셈 결과가 다른 task의 읽기에 따라잡지 못해 increment가 사라집니다. 나아가 이는 Ada RM 9.10의 erroneous execution에 해당합니다. 「erroneous execution」은 「값이 어긋난다」보다 강한 의미의 규격 용어이며, 그 프로그램의 동작에 대해 규격이 아무것도 보장하지 않게 된다는 뜻입니다. 비동기화되지 않은 공유 변수에 대한 동시 읽기·쓰기는 최종 카운트 값의 부정확함에 그치지 않고, 프로그램 전체의 동작이 임의의 결과가 될 수 있습니다. 2개의 task가 각각 10,000회 실행해도, 최종값이 20,000이 된다는 보장은 전혀 없습니다.
이 「보장이 없다」를 직접 확인하고 싶다면, 위의 Bad_Worker 판을 만들어 여러 번 반복 실행하고 매번 최종값을 기록해 보세요. 이 기사에서는 실측값을 싣지 않습니다. data race의 결과는 CPU, 최적화 옵션, 실행 시 타이밍에 따라 달라지므로, 특정 환경에서 나온 숫자 하나를 「이렇게 된다」고 보이면, 오히려 「이 정도 어긋나는 것」이라는 잘못된 기준을 주게 되기 때문입니다. 확인할 것은 「20,000보다 작은 특정 값이 나온다」가 아니라, 실행할 때마다 결과가 바뀌고, 게다가 한 번이라도 올바른 값이 나온 것에 아무 의미가 없다는 점입니다.
protected object는 이 문제를 「구문으로 막는」 메커니즘입니다. Counter.Increment;를 호출하기만 하면, 컴파일러와 runtime이 상호 배제를 보장합니다.
8. protected entry와 barrier ── bounded buffer
protected object에 entry를 추가하면 조건부 동기화가 가능해집니다. 고전적인 bounded buffer로 보겠습니다.
type Buffer_Array is array (0 .. Buffer_Size - 1) of Integer;
protected Buf is
entry Put (Item : Integer);
entry Get (Item : out Integer);
private
Data : Buffer_Array;
Head : Integer := 0;
Tail : Integer := 0;
Count : Integer := 0;
end Buf;
protected body Buf is
entry Put (Item : Integer) when Count < Buffer_Size is
begin
Data (Tail) := Item;
Tail := (Tail + 1) mod Buffer_Size;
Count := Count + 1;
end Put;
entry Get (Item : out Integer) when Count > 0 is
begin
Item := Data (Head);
Head := (Head + 1) mod Buffer_Size;
Count := Count - 1;
end Get;
end Buf;
when Count < Buffer_Size가 barrier입니다. barrier는 entry 호출마다 평가되어, 참이면 실행하고 거짓이면 호출 측 task는 큐에서 대기합니다. 버퍼 상태가 바뀔 때(다른 task가 Put 또는 Get을 실행할 때)마다, 대기 중인 task의 barrier가 재평가됩니다.
재평가가 언제 일어나는지는 글만으로는 따라가기 어렵습니다. 빈 버퍼에 Get이 먼저 온 경우를 시간 순으로 나열하면 다음과 같습니다.
sequenceDiagram
participant C as Consumer task
participant B as protected object Buf
participant P as Producer task
C->>B: Get을 호출
B->>B: barrier Count가 0보다 큰지를 평가 → 거짓
Note over C: Get의 entry 큐에서 대기
P->>B: Put을 호출
B->>B: barrier Count가 Buffer_Size 미만인지를 평가 → 참
B->>B: Put의 body를 실행 / Count는 1이 됨
Note over B: protected operation이 끝나는 시점에 대기 중 entry의 barrier를 재평가
B->>B: Get의 barrier가 참이 됨
B-->>C: Get의 body를 실행하여 Consumer를 해제
핵심은 barrier 재평가가 protected operation이 끝나는 시점에 한꺼번에 이루어진다는 점입니다. Put의 body가 끝난 뒤 Buf의 lock이 해제되기까지의 사이에, 대기 중인 entry의 barrier가 평가되고, 참이 된 것이 그대로 실행됩니다. C의 조건 변수처럼 「누군가 signal을 호출하는 것을 잊으면 영원히 깨어나지 않는다」는 실패 방식이 없습니다.
이 패턴(06_bounded_buffer.ada)은 Ada의 protected object가 가장 빛나는 장면 중 하나입니다. C에서 pthread mutex + condition variable로 작성하는 경우와 비교해 보세요.
// C언어 + pthread의 경우(Ada와의 비교용)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) { // Ada의 when 에 해당
pthread_cond_wait(¬_full, &mutex); // barrier 대기
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(¬_empty); // 대기 중 task에 통지
pthread_mutex_unlock(&mutex);
Ada에서는 이 모두가 when Count < Buffer_Size 한 줄로 모입니다. while 루프의 조건, 시그널 송신, lock 해제 타이밍 실수——이런 버그의 기회가 모두 사라집니다.
9. 타임아웃이 있는 호출 ── 영원히 기다리지 않기
실시간 시스템에서는 「영원히 기다린다」는 허용되지 않습니다. Ada는 select ... or delay 구문으로 타임아웃을 지원합니다.
select
Slow_Worker.Do_Work (Result);
Put_Line ("Main: work completed");
or
delay until Ada.Real_Time.Clock + Milliseconds (500);
Put_Line ("Main: timeout after 500ms!");
end select;
이 코드(07_timed_entry.ada)에서는 Slow_Worker가 delay 2.0 실행 중이라 아직 accept에 도달하지 않았으므로, 큐에 들어간 entry 호출이 500ms에서 타임아웃합니다.(타임아웃이 걸리는 것은 rendezvous가 받아들여지기 전의 큐 대기 시간이며, rendezvous 본체의 실행을 중단하는 것은 아닙니다.)delay until은 절대 시각으로의 지정이며, 누적 drift를 막는 실시간 프로그래밍의 기본 기법입니다.
나아가 Ada는 조건부 호출(conditional entry call) 도 지원합니다.
select
Server.Process (Item);
else
Put_Line ("Server is busy, will retry later");
end select;
else 절로, 바로 rendezvous할 수 없으면 즉시 대체 처리로 진행합니다. polling을 직접 쓸 필요는 없습니다.
타임아웃 이후의 설계를 잊지 않기
타임아웃은 편리하지만, 「기다리지 못한 뒤에 무엇을 할지」가 설계의 본질입니다. 값을 정말로 버려도 되는지, 재시도해야 하는지, 오류로 상위에 알려야 하는지——이를 모호하게 두면 운영 환경에서 데이터 누락이나 서비스 중단으로 바뀝니다. 타임아웃을 쓸 때는 타임아웃 이후의 책임도 같은 자리에서 설계하세요.
주기 task와 delay until
delay until은 타임아웃뿐 아니라 주기 실행에도 쓸 수 있습니다. 단순한 delay 0.1은 「처리 시간 + 0.1초」가 주기가 되어 버리는 반면, delay until은 절대 시각으로 다음 시작 시점을 정하므로 처리 시간에 좌우되지 않는 안정된 주기를 유지할 수 있습니다.
loop
Next := Next + Period;
Do_Work;
delay until Next;
end loop;
이 패턴은 센서 감시나 제어 루프처럼, 일정 주기 처리가 필요한 모든 장면에서 유효합니다.
10. task 우선순위와 실시간 스케줄링
Ada의 실시간 기능은 Annex D(Real-Time Systems)에서 정의됩니다. Ada 구현이 Annex D를 지원하는 경우, task 우선순위와 스케줄링 정책을 지정할 수 있습니다.
자신의 환경에서 쓸 수 있는지 확인하기
Annex D는 Specialized Needs Annex(특정 분야용 부속서)중 하나이며, 대응 상황은 구현과 실행 환경에 의존합니다. 로컬에서 쓸 수 있는지는 다음 3단계로 나눠 확인합니다.
1. 우선순위 범위를 본다
with Ada.Text_IO; use Ada.Text_IO;
with System;
procedure Check_Priority is
begin
Put_Line ("Priority range :"
& Integer'Image (System.Priority'First)
& " .."
& Integer'Image (System.Priority'Last));
Put_Line ("Default_Priority :"
& Integer'Image (System.Default_Priority));
end Check_Priority;
System.Priority의 범위와 기본값은 구현 의존이므로, 구체적인 수치는 여기 보이지 않습니다. 표시된 범위가 충분히 넓으면, pragma Priority (System.Default_Priority + 5)와 같은 지정이 의미를 갖는 환경입니다.
2. 정책 지정이 컴파일을 통과하는지 본다
pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);
이를 붙여 빌드가 통과하면, 적어도 구문으로는 받아들여진 것입니다.
3. 실제로 우선순위대로 동작하는지 본다
여기가 가장 큰 함정입니다. 컴파일이 통과하는 것과, OS 스케줄러가 우선순위대로 움직여 주는 것은 별개입니다. Linux나 Windows 같은 범용 OS 위에서는, 실시간 우선순위를 실제로 스케줄러에 반영하기 위해 OS 측 권한 설정이 필요할 수 있습니다. 이 기사의 샘플 모음 README에도, Annex D가 완전히 지원되지 않는 환경에서는 08_task_priorities.ada가 보통의 task로 동작한다고 주석되어 있습니다.
즉, 우선순위가 적용되지 않아도 프로그램은 동작한다는 뜻입니다. hard real-time을 요구하는 용도에서는, 이론상의 우선순위 설계뿐 아니라 대상 환경에서 실제로 순서를 측정해 확인하는 과정이 필수입니다.
task High_Task is
pragma Priority (System.Default_Priority + 5);
end High_Task;
task Low_Task is
pragma Priority (System.Default_Priority);
end Low_Task;
더 고급 설정으로, 스케줄링 정책과 Priority Ceiling Protocol도 지정할 수 있습니다.
pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);
Priority Ceiling Protocol(우선순위 상한 프로토콜)은 우선순위 역전을 막기 위한 프로토콜입니다. 각 protected object에 pragma Priority(또는 Priority aspect)로 ceiling 우선순위를 명시적으로 설정합니다. 호출 측 task의 active 우선순위가 그 ceiling을 넘으면 Program_Error가 발생합니다. 객체를 lock하는 동안은 ceiling 우선순위로 실행되어, 중간 우선순위 task에 의한 preemption을 막습니다.
protected Shared_Data is
pragma Priority (15); -- ceiling 우선순위
procedure Update (Val : Integer);
function Read return Integer;
private
Data : Integer := 0;
end Shared_Data;
이러한 기능은 Rate Monotonic Scheduling (RMS) 의 이론적 배경에 기반하며, 항공기 비행 제어나 의료 기기와 같은 hard real-time 시스템에서 실적이 있습니다. RMS는 「주기가 짧은 task일수록 높은 우선순위를 할당한다」는 고정 우선순위 방식이며, 주기 task의 집합이 deadline을 지킬 수 있는지를 실행 전에 해석할 수 있다는 점이 hard real-time 용도에서 중시됩니다.
11. 실전을 위한 설계 지침
여기까지 task와 protected object의 기본 구문을 보았습니다. 마지막으로, 실무에서 Ada의 동시성 처리를 쓸 때 의식해야 할 설계 지침을 정리합니다.
protected object에서 해서는 안 되는 일
protected object 안에서는 상태 갱신만 짧게 하고, 무거운 처리는 밖에서 실행하는 것이 철칙입니다. protected object의 조작은 내부적으로 상호 배제되므로, 그 안에서 오래 차단하면 같은 protected object를 쓰는 다른 task를 모두 멈추게 됩니다.
구체적으로 피해야 할 처리:
delay나 시간이 걸리는 I/O- 다른 protected object에 대한 복잡한 호출
- 외부 라이브러리의 무거운 호출
참고로, protected operation 안의 delay나 특정 I/O는 단순한 성능 문제가 아니라 Ada 규격상의 bounded error입니다. bounded error란 「일어날 수 있는 결과의 범위는 규격이 정하지만, 그 범위 중 무엇이 될지는 정해져 있지 않다」는 종류의 오류입니다. erroneous execution만큼 무제한은 아니지만, 올바르게 동작한다는 보장도 없습니다. 실제로 구현에 따라 Program_Error가 발생하거나 deadlock에 빠질 수 있으므로, 「자제한다」가 아니라 완전히 배제해야 합니다.
좋은 설계는 「protected object에서 필요한 값을 짧은 시간에 꺼낸다 → 밖에서 무거운 계산이나 I/O를 한다 → 결과만 protected object에 짧은 시간에 다시 쓴다」는 패턴입니다.
barrier 조건은 단순하게 유지하기
entry ... when <condition>의 barrier는 강력하지만, 너무 복잡해지면 읽기 어려워지고, 왜 task가 해제되지 않는지를 조사하기가 어려워집니다.
when Count < Buffer_Size나 when Used > 0처럼, 상태의 의미가 한눈에 보이는 수준이 이상적입니다. 여러 조건이 필요하면 열거형으로 상태를 나타내고, barrier를 when State = Running처럼 상태 이름으로 읽을 수 있는 형태에 가깝게 하는 것을 검토하세요.
task의 예외와 정지
task 안에서 예외가 발생했을 때의 방침은 명시적으로 정해 둘 필요가 있습니다. 최소한, task 본문의 최상위에서 예외를 포착하고 무엇이 일어났는지를 기록할 것.
더 중요한 것은 예외 이후의 설계입니다. 그 task가 멈추면 시스템은 계속할 수 있는지, 재기동해도 되는지, 다른 task에 어떻게 알릴지, 공유 상태를 어떻게 안전한 상태로 되돌릴지——이런 질문에 답할 수 있도록 해 둘 필요가 있습니다. Ada는 예외 메커니즘을 언어로서 가지고 있지만, 예외 이후의 안전성은 애플리케이션 설계의 책임입니다.
미니 설계 체크리스트
| 관점 | 확인할 것 |
|---|---|
| 공유 상태 | protected object에 갇혀 있는가. 외부에서 직접 건드리지 않는가 |
| protected operation | 짧은가. 안에서 차단하지 않는가 |
| entry | barrier는 단순한가. 계속 기다릴 가능성은 없는가. 타임아웃 방침은 있는가 |
| task 수명 | 종료 조건은 명확한가. 예외 시 방침은 있는가 |
| 주기 처리 | delay가 아니라 delay until을 검토했는가 |
동시성 처리에서는 「아마 괜찮을 것」이 가장 위험합니다. 공유 상태, 대기 조건, 종료 조건, 예외 방침을 코드 위에 명시하는 것이 안전한 동시성 처리의 첫걸음입니다.
12. 정리 ── 동시성 처리를 「문법」으로 만든 언어
Ada의 동시성 처리 모델이 다른 언어와 분명히 구별되는 점은, 안전한 동시성 처리가 「나중에 붙인 모범 사례」가 아니라 「문법」으로 포함되어 있다는 점입니다.
| 하고 싶은 일 | Ada의 문법 |
|---|---|
| 독립된 실행 단위 | task / task body |
| 동기 통신 | entry / accept |
| 여러 요청의 대기 | select / or / else |
| 상호 배제 | protected / function / procedure |
| 조건부 동기화 | entry ... when <barrier> |
| 타임아웃 | or delay until <time> |
| 우선순위 제어 | pragma Priority |
이러한 구문은 컴파일러 검증의 대상입니다. 예를 들어, protected object의 function 안에서 그 protected object 자신의 private 성분을 바꾸려고 하면 컴파일 오류가 됩니다. protected operation이 끝나면 대기 중인 entry의 barrier가 자동으로 재평가됩니다——수동으로 시그널을 보낼 필요는 없습니다.
「type system이 메모리 안전성을 보장하듯이,
Ada의 동시성 처리 구문은 동기화의 안전성을 보장한다」
이 기사에서 다룬 8개의 코드 예제는 task, rendezvous, protected object, 실시간 기능의 실전 입문입니다. 이를 로컬에서 돌리면서, 다음의 발전 토픽에도 도전해 보세요.
- Ravenscar 프로파일: 고신뢰 실시간 시스템용 task 제한 프로파일. 제한된 task 모델로 정적 deadlock 해석이 가능해집니다.
- Ada 2022의 병렬 블록:
parallel ... do구문에 의한 데이터 병렬 처리. - SPARK와의 통합: 동시성 프로그램의 동작을 형식 검증합니다(Ravenscar 프로파일 아래에서 GNATprove가 지원).
그래도 「Ada를 쓰면 안전하다」는 아닙니다
마지막으로 중요한 주의입니다. Ada의 동시성 처리 구문은 강력하지만, Ada를 쓰면 자동으로 안전해지는 것은 아닙니다. 공유 데이터를 protected object에 넣지 않고 직접 건드린다, protected object 안에서 오래 차단한다, 여러 protected object를 복잡하게 서로 호출한다——이런 설계 실수는 Ada에서도 일어날 수 있습니다.
언어 기능은 「위험한 작성법을 하려면 명시적인 노력이 필요하도록」 설계되어 있지만, 올바른 설계 그 자체를 대신해 주지는 않습니다. Ada의 진가는 안전성 논의를 코드에 가까운 자리로 가져올 수 있다는 점입니다. 「이 상태는 보호되어 있는가」「이 task는 언제 끝나는가」「이 entry는 어떤 조건에서 기다리는가」와 같은 질문을 구문으로서 코드 위에 남길 수 있다는 것입니다.
type으로 설계를 말하는 Ada의 사상은 동시성 처리에서도 일관됩니다. 안전한 동시성 처리는 lock을 신중히 다루는 것이 아니라, 위험한 공유 상태를 노출된 채로 두지 않는 것에서 시작합니다.
「동시성 처리는 어렵다」는 통념에 대해, Ada는 「구문을 올바르게 고르면, 안전성은 컴파일러가 보장해 준다」고 답합니다. 그 설계 사상은 현대의 Rust나 Pony에도 통하는 것이지만, Ada는 그것을 40년 전부터 언어 사양으로 가지고 있는 것입니다.
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Ada로 하는 실시간 시스템 프로그래밍 ── 우선순위·주기·실행 시간 제어 실전
Ada의 Annex D(실시간 시스템)를 8가지 실전 코드 예로 배웁니다. 태스크 우선순위, Ceiling_Locking, delay until을 이용한 주기 실행, Ravenscar 프로파일, 태스크별 실행 시간 계측까지 단계적으로 정리합니다.
Ada의 generic programming ── 타입으로 계약을 쓰고, 재사용을 제로 비용으로 구현한다
Ada의 generic programming을 generic subprogram, generic package, formal subprogram, 타입 카테고리, 실무 설계 지침까지 체계적으로 설명합니다. 타입 안전한 재사용과 제로 비용 추상화의...
SPARK로 하는 형식 검증 입문 ── Ada의 계약에서 수학적 증명으로
Ada의 서브셋 언어 SPARK로 형식 검증을 다루는 실무 입문 기사입니다. 계약(Pre/Post)에서 증명으로 단계를 올리는 방법, GNATprove 사용법, 루프 불변조건, 데이터 플로 계약, 증명 레벨, 그리고 실제 프로젝트에 적용하는 방법...
Ada 언어의 매력 ── 타입으로 설계를 말하고, 수십 년 동안 동작하는 소프트웨어를 지탱하는 언어
Ada 언어의 매력을 소개합니다. 강한 타입 시스템, 범위 제약, 패키지로 명세와 구현을 분리하는 방식, Design by Contract, 언어에 내장된 task, SPARK에 의한 형식 검증, GNAT과 Alire 개발 환경까지, 고신뢰 소프...
공유 메모리의 함정과 실무 베스트 프랙티스
공유 메모리를 실무에서 쓸 때의 함정과, 동기, 가시성, 수명, ABI, 권한까지 포함해 사고율을 낮추는 설계를 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Ada의 task란 무엇입니까?
- task는 Ada에서 동시성 처리의 기본 단위입니다. thread와 비슷하지만 OS thread와 1대 1로 대응한다고 단정할 수는 없으며, Ada runtime이 스케줄링을 관리합니다. task는 선언되면 자동으로 실행을 시작하고, 메인 프로시저가 종료할 때는 실행 중인 task의 완료를 암묵적으로 기다립니다. 외부와는 entry를 통한 rendezvous로 동기 통신을 합니다. task와 rendezvous는 1983년 Ada 83부터 언어 사양에 포함되어 있습니다.
- Ada의 protected object는 mutex와 무엇이 다릅니까?
- protected object는 언어가 관리하는 상호 배제 메커니즘이며, lock·unlock을 직접 쓸 필요가 없습니다. function은 읽기 전용이라 여러 task가 동시에 호출할 수 있고, procedure는 읽기·쓰기용이라 실행 중에는 다른 호출이 차단되며, entry는 barrier 조건이 참이 될 때까지 호출 측을 큐에서 기다리게 합니다. C에서 pthread mutex와 조건 변수를 조합해 작성하는 bounded buffer 제어가, Ada에서는 「when Count < Buffer_Size」와 같은 barrier 한 줄로 모입니다.
- Ada의 rendezvous는 어떤 메커니즘입니까?
- rendezvous는 task 간 동기 통신 메커니즘입니다. 호출 측의 entry 호출과 task 측의 accept 문이 동시에 rendezvous point에 도달할 때까지 서로 기다립니다. in/out/in out 파라미터 모드로 양방향 데이터를 주고받을 수 있습니다. accept 본문의 do ... end 블록이 critical section이 되며, 실행 중에는 호출 측이 차단되고 task는 다른 entry를 받지 않습니다. select 문과 결합하면 여러 entry의 대기, 타임아웃, guard 조건도 선언적으로 작성할 수 있습니다.
- protected object 안에서 해서는 안 되는 일은 무엇입니까?
- delay나 시간이 걸리는 I/O, 외부 라이브러리의 무거운 호출처럼 오래 차단되는 처리입니다. protected operation 안의 delay나 특정 I/O는 Ada 규격상 bounded error에 해당하며, 구현에 따라 Program_Error 발생이나 deadlock으로 이어질 수 있으므로 완전히 배제해야 합니다. 상태 갱신만 짧게 수행하고, 무거운 계산이나 I/O는 protected object 밖에서 실행한 뒤 결과만 짧은 시간에 다시 쓰는 것이 철칙입니다.