Ada의 안전한 동시성 처리 ── 태스크와 보호 객체 실전 가이드
· 小村 豪 · Ada, 동시성, 태스킹, 보호 객체, 랑데부, 실시간, 병렬 프로그래밍, 프로그래밍 언어, 고신뢰성
1. 들어가며 ── 언어에 내장된 동시성 처리
동시성 처리는 현대 소프트웨어 개발에서 피할 수 없는 주제입니다. 하지만 많은 언어에서 동시성 처리는 「나중에 덧붙인」 라이브러리나 OS 기능에 의존하고 있어서, 제대로 사용하려면 깊은 지식과 신중한 설계가 필요합니다.
Ada는 이 문제에 대해 독자적인 답을 가지고 있습니다. 언어 규격 그 자체에 동시성 처리가 내장되어 있는 것입니다.
Ada의 동시성 처리 모델:
- 태스크(task) ── 독립적으로 실행되는 동시성 단위
- 랑데부(rendezvous) ── 태스크 간 동기 통신
- 보호 객체(protected object) ── 언어가 관리하는 배타 제어
- 실시간 우선순위 ── Annex D 실시간 기능
태스크와 랑데부는 1983년의 Ada 83부터 존재했고, 보호 객체와 Annex D 실시간 기능은 Ada 95에서 추가되어 Ada 2005, 2012로 계속 진화해 왔습니다. 뮤텍스나 세마포어 같은 저수준 동기화 프리미티브가 아니라, 「설계 의도를 코드로 직접 표현할 수 있다」는 점이 Ada 동시성 처리의 가장 큰 특징입니다.
이 글에서는 Ada의 동시성 처리를 8개의 실전 코드 예제로 단계적으로 설명합니다. 각 코드 예제는 독립된 스니펫으로 실제로 컴파일·실행할 수 있으므로, 직접 손으로 시험해 볼 수 있습니다.
또한 이 글에 등장하는 코드 조각은, 장별로 파일로 정리한 참고용 코드 모음집으로 GitHub에 공개하고 있습니다.
ada-task-concurrency - komurasoft-blog-samples (GitHub)
2. 동시성 처리의 「위험」을 되짚어보기
Ada 이야기로 들어가기 전에, 왜 「안전한」 동시성 처리가 중요한지 간단히 확인해 두겠습니다.
동시성 처리에서 대표적인 버그로는 다음과 같은 것들이 있습니다.
- 데이터 경쟁 (data race): 여러 스레드가 같은 메모리 위치에 동시에 접근하고, 그중 적어도 하나가 쓰기인 경우. 결과는 정의되지 않습니다.
- 데드락 (deadlock): 여러 태스크가 서로의 완료를 계속 기다려서, 영원히 진행되지 않는 상태.
- 우선순위 역전 (priority inversion): 높은 우선순위 태스크가 낮은 우선순위 태스크가 붙잡은 리소스를 기다리는 동안, 중간 우선순위 태스크가 낮은 우선순위 태스크를 선점해 버리는 문제.
- 기아 상태 (starvation): 어떤 태스크가 영원히 리소스를 얻지 못하는 상태.
Ada의 동시성 처리 모델은 이러한 문제에 대해 언어 수준의 방어책을 제공합니다.
데이터 경쟁 → 보호 객체가 배타적 접근을 보장
데드락 → 랑데부 모델이 구조적인 동기화를 제공
우선순위 역전 → Priority Ceiling Protocol을 언어에 내장된 형태로 사용 가능
기아 상태 → 엔트리 배리어와 큐잉 정책으로 제어
3. 태스크의 기본 ── 독립된 실행 단위
Ada에서 동시성 처리의 기본 단위는 태스크(task)입니다. 태스크는 스레드와 비슷하지만, OS 스레드와 반드시 1대1로 대응하지는 않으며 Ada 런타임이 스케줄링을 관리합니다.
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)에는 몇 가지 중요한 포인트가 있습니다.
태스크는 선언되면 자동으로 실행을 시작합니다. Greeter 태스크는 이를 포함하는 프로시저의 begin 시점에서 기동되어, accept Start;에서 호출자로부터의 랑데부 요청을 기다립니다.
엔트리(entry)는 태스크가 외부에 공개하는 인터페이스입니다. 호출자가 Greeter.Start;를 호출하면, 태스크의 accept Start;와 동기화됩니다. 이를 랑데부라고 부릅니다.
태스크의 종료는 자동으로 대기됩니다. 메인 프로시저가 종료될 때, 아직 실행 중인 태스크가 있다면 그 완료를 암묵적으로 기다립니다. 이는 C++에서 std::thread::join 호출을 잊어서 발생하는 크래시와 대조적입니다.
4. 랑데부 ── 데이터를 주고받는 동기 통신
랑데부는 단순한 동기화뿐 아니라, 양방향 데이터 전달도 할 수 있습니다.
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모드: 호출 측에서 태스크로 값을 전달out모드: 태스크에서 호출 측으로 결과를 반환in out모드: 양방향
accept 본문 안의 do ... end 블록이 크리티컬 섹션이 됩니다. 이 동안 호출 측은 블록되고, 태스크는 다른 엔트리를 받아들이지 않습니다. 처리가 끝나면 양쪽 모두 다시 진행됩니다.
랑데부의 특징을 정리하면:
| 특징 | 설명 |
|---|---|
| 동기화 | 호출 측과 태스크 측이 동시에 랑데부 지점에 도달할 때까지 기다림 |
| 데이터 전달 | in / out / in out 파라미터로 양방향으로 값을 전달할 수 있음 |
| 배타 제어 | accept 본문 실행 중에는 태스크의 다른 엔트리가 블록됨 |
| 구조화 | 어떤 엔트리가 언제 받아들여지는지 태스크 본문에 명시됨 |
5. 선택적 accept ── 여러 서비스를 대기하기
실제 서버 태스크에서는 여러 종류의 요청을 대기할 필요가 있습니다. 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 분기가 있으며, 호출이 들어온 엔트리 중 하나가 선택됩니다(선택 방식은 처리기 정의입니다). 어떤 엔트리도 호출되지 않았다면, 어느 하나가 호출될 때까지 대기합니다.
or terminate;는 특별한 분기로, 「메인 프로시저가 종료되어, 이 태스크에 대해 아무도 엔트리를 호출할 가능성이 없어졌을」 때 태스크를 안전하게 종료시킵니다. 데드락의 원인이 되는 「계속 기다리는 서버 태스크」 문제를 해결하는 Ada 고유의 방식입니다.
선택적 accept의 강력한 점은, 가드 조건도 작성할 수 있다는 것입니다.
select
when Count > 0 =>
accept Get_Item (Item : out Integer) do
Item := Data (Head);
Count := Count - 1;
end Get_Item;
or
when Count < Max =>
accept Put_Item (Item : Integer) do
Data (Tail) := Item;
Count := Count + 1;
end Put_Item;
end select;
가드 조건이 거짓인 분기는 그 시점에서 선택 대상에서 제외됩니다. 이를 통해 「버퍼가 비어 있으면 Get을 기다리게 하고, 가득 차 있으면 Put을 기다리게 한다」와 같은 제어를 선언적으로 작성할 수 있습니다.
6. 생산자·소비자 ── 랑데부로 동기화
랑데부를 사용한 대표적인 패턴으로, 생산자·소비자를 살펴보겠습니다.
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)에서는 생산자가 Deliver를 호출할 때마다 소비자와 동기화됩니다. 생산자가 너무 빠르면 소비자가 accept할 때까지 대기하게 되고, 소비자가 너무 빠르면 생산자의 다음 호출까지 대기하게 됩니다. 자연스러운 백프레셔(backpressure)가 걸리는 것입니다.
7. 보호 객체 ── 잠금이 필요 없는 배타 제어
태스크가 「능동적인 동작 주체」인 것과 달리, 보호 객체(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;
보호 객체의 중요한 규칙은 다음과 같습니다.
- 함수(function)는 읽기 전용입니다. 여러 태스크가 동시에 함수를 호출할 수 있습니다.
- 프로시저(procedure)는 읽기·쓰기입니다. 프로시저 실행 중에는 다른 프로시저도 함수도 블록됩니다.
- 엔트리(entry)는 배리어가 있습니다. 배리어 조건이 참이 될 때까지 호출 측은 큐에서 기다립니다.
이 코드(05_protected_counter.ada)에서는 3개의 워커 태스크가 각각 1,000번씩 Increment를 호출합니다. 보호 객체가 배타 제어를 보장하므로, 최종 카운터 값은 항상 3,000이 됩니다. 뮤텍스의 잠금·해제를 직접 작성할 필요가 없습니다.
task type Worker (Id : Integer; Rounds : Integer);
task body Worker is
begin
for I in 1 .. Rounds loop
Counter.Increment; -- 보호 객체가 배타를 보장
end loop;
end Worker;
W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);
보호 객체가 없으면 무슨 일이 일어날까
보호 객체의 가치를 이해하기 위해, 보호하지 않는 경우의 위험한 코드를 살펴보겠습니다.
-- ⚠ 위험: 공유 변수를 직접 조작하고 있음
Shared_Counter : Integer := 0;
task body Bad_Worker is
begin
for I in 1 .. 10_000 loop
Shared_Counter := Shared_Counter + 1; -- 데이터 경쟁!
end loop;
end Bad_Worker;
Shared_Counter := Shared_Counter + 1은 CPU 수준에서 보면 「읽기 → 더하기 → 다시 쓰기」의 3단계입니다. 여러 태스크가 이를 동시에 실행하면, 어떤 태스크의 덧셈 결과가 다른 태스크의 읽기를 따라잡지 못해 증가분이 사라집니다. 게다가 이것은 Ada RM 9.10에서 말하는 잘못된 실행(erroneous execution)에 해당합니다. 동기화되지 않은 공유 변수에 대한 동시 읽기·쓰기는 최종 카운트 값의 부정확함에 그치지 않고, 프로그램 전체의 동작이 임의의 결과가 될 수 있습니다. 두 태스크가 각각 10,000번씩 실행해도, 최종 값이 20,000이 될 보장은 전혀 없습니다.
보호 객체는 이 문제를 「문법으로 막는」 방식입니다. Counter.Increment;라고 호출하기만 하면, 컴파일러와 런타임이 배타 제어를 보장해 줍니다.
8. 보호 엔트리와 배리어 ── 경계 버퍼
보호 객체에 엔트리를 추가하면, 조건부 동기화가 가능해집니다. 고전적인 경계 버퍼(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)입니다. 배리어는 엔트리가 호출될 때마다 평가되며, 참이면 실행되고 거짓이면 호출 측 태스크는 큐에서 대기합니다. 버퍼의 상태가 바뀔 때마다(다른 태스크가 Put이나 Get을 실행할 때마다), 대기 중인 태스크의 배리어가 다시 평가됩니다.
이 패턴(06_bounded_buffer.ada)은 Ada의 보호 객체가 가장 빛을 발하는 장면 중 하나입니다. C 언어에서 pthread의 mutex + condition variable을 사용해 작성하는 경우와 비교해 보십시오.
// C 언어 + pthread의 경우(Ada와의 비교용)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) { // Ada의 when에 해당
pthread_cond_wait(¬_full, &mutex); // 배리어 대기
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(¬_empty); // 대기 중인 태스크에 통지
pthread_mutex_unlock(&mutex);
Ada에서는 이 모든 것이 when Count < Buffer_Size 한 줄로 응축되어 있습니다. while 루프 조건, 시그널 전송, 잠금 해제 타이밍의 실수——이런 모든 버그의 여지가 사라집니다.
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에 도달하지 않았기 때문에, 큐에 들어간 엔트리 호출이 500ms에 타임아웃됩니다.(타임아웃이 적용되는 것은 랑데부가 받아들여지기 전의 큐 대기 시간이며, 랑데부 본체의 실행을 중단시키는 것은 아닙니다.) delay until은 절대 시각으로 지정하는 방식으로, 누적 드리프트를 막는 실시간 프로그래밍의 기본 기법입니다.
게다가 Ada는 조건부 호출(conditional entry call)도 지원합니다.
select
Server.Process (Item);
else
Put_Line ("Server is busy, will retry later");
end select;
else 절을 사용하면, 즉시 랑데부할 수 없을 경우 바로 대체 처리로 넘어갑니다. 폴링을 직접 작성할 필요가 없습니다.
타임아웃 이후의 설계를 잊지 않기
타임아웃은 편리하지만, 「기다리지 못한 뒤에 무엇을 할 것인가」가 설계의 본질입니다. 값을 정말로 버려도 되는지, 재시도해야 하는지, 오류로 상위에 통지해야 하는지——이런 것들을 모호하게 두면 실서비스 환경에서 데이터 손실이나 서비스 중단으로 이어집니다. 타임아웃을 작성할 때는, 타임아웃 이후의 책임도 같은 곳에서 함께 설계하십시오.
주기 태스크와 delay until
delay until은 타임아웃뿐 아니라, 주기 실행에도 사용할 수 있습니다. 단순한 delay 0.1은 「처리 시간 + 0.1초」가 주기가 되어 버리는 반면, delay until은 절대 시각으로 다음 기동 시점을 정하기 때문에 처리 시간에 좌우되지 않는 안정적인 주기를 유지할 수 있습니다.
loop
Next := Next + Period;
Do_Work;
delay until Next;
end loop;
이 패턴은 센서 모니터링이나 제어 루프처럼, 정주기 처리가 요구되는 모든 상황에서 유효합니다.
10. 태스크 우선순위와 실시간 스케줄링
Ada의 실시간 기능은 Annex D(Real-Time Systems)에서 정의됩니다. Ada 처리기가 Annex D를 지원하는 경우, 태스크 우선순위와 스케줄링 정책을 지정할 수 있습니다.
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;
더 고급 설정으로, 스케줄링 정책과 우선순위 상한 프로토콜도 지정할 수 있습니다.
pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);
Priority Ceiling Protocol(우선순위 상한 프로토콜)은 우선순위 역전을 방지하기 위한 프로토콜입니다. 각 보호 객체에 pragma Priority(또는 Priority aspect)로 실링(ceiling) 우선순위를 명시적으로 설정합니다. 호출자 태스크의 활성 우선순위가 그 실링을 넘으면 Program_Error가 발생합니다. 객체를 잠그고 있는 동안에는 실링 우선순위로 실행되어, 중간 우선순위 태스크에 의한 선점을 방지합니다.
protected Shared_Data is
pragma Priority (15); -- 실링 우선순위
procedure Update (Val : Integer);
function Read return Integer;
private
Data : Integer := 0;
end Shared_Data;
이러한 기능은 Rate Monotonic Scheduling(RMS)의 이론적 배경에 기반하고 있으며, 항공기의 비행 제어나 의료기기 같은 하드 리얼타임 시스템에서 실적이 있습니다.
11. 실전을 위한 설계 지침
지금까지 태스크와 보호 객체의 기본 문법을 살펴봤습니다. 마지막으로, 실무에서 Ada의 동시성 처리를 사용할 때 유의해야 할 설계 지침을 정리합니다.
보호 객체 안에서 해서는 안 되는 것
보호 객체 안에서는 상태 갱신만 짧게 수행하고, 무거운 처리는 밖에서 실행하는 것이 원칙입니다. 보호 객체의 연산은 내부적으로 배타 제어되기 때문에, 그 안에서 오랫동안 블록되면 같은 보호 객체를 사용하는 다른 모든 태스크를 멈추게 됩니다.
구체적으로 피해야 할 처리는:
delay나 시간이 걸리는 I/O- 다른 보호 객체에 대한 복잡한 호출
- 외부 라이브러리의 무거운 호출
그리고 보호 연산 안에서의 delay나 특정 I/O는 단순한 성능 문제가 아니라, Ada 규격상의 한정 오류(bounded error)입니다. 처리기에 따라 Program_Error가 발생하거나 데드락에 빠질 가능성이 있으므로, 「가급적 피한다」가 아니라 완전히 배제해야 합니다.
좋은 설계는 「보호 객체에서 필요한 값을 짧은 시간에 꺼낸다 → 밖에서 무거운 계산이나 I/O를 수행한다 → 결과만 보호 객체에 짧게 다시 써넣는다」라는 패턴입니다.
배리어 조건은 단순하게 유지하기
entry ... when <condition>의 배리어는 강력하지만, 너무 복잡해지면 읽기 어려워지고 왜 태스크가 해제되지 않는지 조사하기 어려워집니다.
when Count < Buffer_Size나 when Used > 0처럼, 상태의 의미를 한눈에 알 수 있는 수준이 이상적입니다. 여러 조건이 필요한 경우에는 열거형으로 상태를 표현하고, 배리어를 when State = Running처럼 상태 이름으로 읽을 수 있는 형태에 가깝게 만드는 것을 검토해 보십시오.
태스크의 예외와 정지
태스크 안에서 예외가 발생했을 때의 방침은 명시적으로 정해 두어야 합니다. 최소한 태스크 본문의 최상위에서 예외를 포착하고, 무슨 일이 일어났는지 기록해야 합니다.
더 중요한 것은 예외 이후의 설계입니다. 그 태스크가 멈추면 시스템은 계속될 수 있는지, 재시작해도 되는지, 다른 태스크에는 어떻게 통지할지, 공유 상태를 어떻게 안전한 상태로 되돌릴지——이런 질문에 답할 수 있도록 해 두어야 합니다. Ada는 예외 메커니즘을 언어로 갖고 있지만, 예외 이후의 안전성은 애플리케이션 설계의 책임입니다.
미니 설계 체크리스트
| 관점 | 확인할 것 |
|---|---|
| 공유 상태 | 보호 객체 안에 갇혀 있는가. 외부에서 직접 건드리지 않는가 |
| 보호 연산 | 짧은가. 안에서 블록되지 않는가 |
| 엔트리 | 배리어는 단순한가. 계속 기다릴 가능성은 없는가. 타임아웃 방침이 있는가 |
| 태스크 수명 | 종료 조건이 명확한가. 예외 시 방침이 있는가 |
| 주기 처리 | 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 |
이러한 문법들은 컴파일러의 검증 대상입니다. 예를 들어 보호 객체의 함수 안에서 보호 객체 자신의 비공개 요소를 다시 쓰려고 하면 컴파일 오류가 됩니다. 보호 연산이 끝나면, 대기 중인 엔트리의 배리어가 자동으로 다시 평가됩니다——수동으로 시그널을 보낼 필요가 없습니다.
「타입 시스템이 메모리 안전성을 보장하듯,
Ada의 동시성 처리 문법은 동기화의 안전성을 보장한다」
이 글에서 다룬 8개의 코드 예제는 태스크, 랑데부, 보호 객체, 실시간 기능에 대한 실전 입문입니다. 이것들을 직접 손으로 돌려보면서, 다음과 같은 발전된 주제에도 도전해 보십시오.
- Ravenscar 프로파일: 고신뢰 실시간 시스템을 위한 태스크 제한 프로파일. 제한된 태스크 모델을 통해 정적인 데드락 분석이 가능해집니다.
- Ada 2022의 병렬 블록:
parallel ... do구문을 이용한 데이터 병렬 처리. - SPARK와의 통합: 동시성 프로그램의 동작을 형식 검증합니다(Ravenscar 프로파일 하에서 GNATprove가 지원).
그래도 「Ada를 쓰면 안전하다」는 아니다
마지막으로 중요한 주의사항입니다. Ada의 동시성 처리 문법은 강력하지만, Ada를 쓴다고 해서 자동으로 안전해지는 것은 아닙니다. 공유 데이터를 보호 객체에 넣지 않고 직접 건드리기, 보호 객체 안에서 오랫동안 블록하기, 여러 보호 객체를 복잡하게 서로 호출하기——이런 설계 실수는 Ada에서도 일어날 수 있습니다.
언어 기능은 「위험한 방식으로 작성하려면 명시적인 노력이 필요하다」는 식으로 설계되어 있지만, 올바른 설계 그 자체를 대신해 주는 것은 아닙니다. Ada의 진짜 가치는 안전성에 대한 논의를 코드에 가까운 곳으로 가져올 수 있다는 것——「이 상태는 보호되고 있는가」「이 태스크는 언제 끝나는가」「이 엔트리는 어떤 조건에서 기다리는가」와 같은 질문을, 문법으로서 코드 위에 남길 수 있다는 점입니다.
타입으로 설계를 말하는 Ada의 사상은 동시성 처리에서도 일관됩니다. 안전한 동시성 처리는 잠금을 신중하게 다루는 것이 아니라, 위험한 공유 상태를 무방비 상태로 존재하게 하지 않는 것에서부터 시작됩니다.
「동시성 처리는 어렵다」는 통념에 대해, Ada는 「문법을 올바르게 고르면 안전성은 컴파일러가 보장해 준다」고 답합니다. 그 설계 사상은 현대의 Rust나 Pony에도 이어지는 것이지만, Ada는 그것을 40년 전부터 언어 규격으로 계속 지녀 온 것입니다.
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Ada를 활용한 실시간 시스템 프로그래밍 ── 우선순위·주기·실행 시간 제어의 실전
Ada의 Annex D(실시간 시스템)가 제공하는 실시간 기능──태스크 우선순위, Ceiling_Locking 프로토콜, delay until에 의한 주기 실행, Ravenscar 프로파일, 타이밍 이벤트, 보호 큐, 태스크별 실행 시간 계측, ...
Ada 제네릭 프로그래밍 ── 타입으로 계약을 작성하고 재사용을 제로 코스트로 실현하기
Ada의 제네릭 프로그래밍을 총칭 서브프로그램, 총칭 패키지, 가인수 서브프로그램, 타입 카테고리, 정식 패키지 파라미터, 실무 설계 지침까지 체계적으로 설명합니다. Mermaid 다이어그램과 실제 예제를 곁들여 타입 안전한 재사용과 제로 코스트...
SPARK를 이용한 형식 검증 입문 —— Ada의 계약에서 수학적 증명으로
Ada의 서브셋 언어인 SPARK를 이용한 형식 검증의 실전 입문 글입니다. 계약(Pre/Post)에서 증명으로 단계를 밟아가는 방법, GNATprove 사용법, 루프 불변조건, 데이터 흐름 계약, 증명 레벨, 그리고 실제 프로젝트 적용 방법까지...
Ada 언어의 매력 —— 타입으로 설계를 말하고, 수십 년간 동작하는 소프트웨어를 지탱하는 언어
Ada 언어의 매력을 소개합니다. 강한 타입, 범위 제약, 패키지를 통한 사양과 구현의 분리, 계약에 의한 설계, 언어에 내장된 태스크, SPARK를 통한 정형 검증, GNAT와 Alire를 이용한 개발 환경까지, 고신뢰 소프트웨어를 지탱하는 설...
PerfView와 dotnet-trace로 “느림”의 원인을 찾는다 ── .NET 성능 조사 실무 입문
업무 앱이 “느리다”, “CPU를 다 써버린다”, “가끔 멈춘다”고 할 때, 어떤 도구로 무엇을 봐야 할까요. PerfView와 dotnet-trace의 역할 분담, CPU 샘플링을 읽는 방법(inclusive/exclusive), ThreadT...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Ada의 태스크란 무엇인가요?
- 태스크는 Ada에서 동시성 처리의 기본 단위로, 스레드와 비슷하지만 OS 스레드와 반드시 1대1로 대응하지는 않으며 Ada 런타임이 스케줄링을 관리합니다. 태스크는 선언되면 자동으로 실행을 시작하고, 메인 프로시저가 종료될 때는 실행 중인 태스크의 완료를 암묵적으로 기다립니다. 외부와는 엔트리(entry)를 통한 랑데부로 동기 통신을 합니다. 태스크와 랑데부는 1983년의 Ada 83부터 언어 규격에 포함되어 있습니다.
- Ada의 보호 객체는 뮤텍스와 무엇이 다른가요?
- 보호 객체는 언어가 관리하는 배타 제어 방식으로, 잠금·해제를 직접 작성할 필요가 없습니다. 함수(function)는 읽기 전용이라 여러 태스크가 동시에 호출할 수 있고, 프로시저(procedure)는 읽기·쓰기용이라 실행 중에는 다른 호출이 블록되며, 엔트리(entry)는 배리어 조건이 참이 될 때까지 호출 측을 큐에서 기다리게 합니다. C 언어에서 pthread의 mutex와 조건 변수를 조합해서 작성하는 경계 버퍼 제어가, Ada에서는 `when Count < Buffer_Size`와 같은 배리어 한 줄로 응축됩니다.
- Ada의 랑데부는 어떤 방식으로 동작하나요?
- 랑데부는 태스크 간 동기 통신 방식으로, 호출 측의 엔트리 호출과 태스크 측의 accept 문이 동시에 랑데부 지점에 도달할 때까지 서로 기다립니다. in/out/in out 파라미터 모드로 양방향으로 데이터를 주고받을 수 있습니다. accept 본문의 do~end 블록이 크리티컬 섹션이 되어, 실행 중에는 호출 측이 블록되고 태스크는 다른 엔트리를 받아들이지 않습니다. select 문과 결합하면 여러 엔트리 대기, 타임아웃, 가드 조건도 선언적으로 작성할 수 있습니다.
- 보호 객체 안에서 해서는 안 되는 것은 무엇인가요?
- delay나 시간이 걸리는 I/O, 외부 라이브러리의 무거운 호출처럼 오랫동안 블록되는 처리입니다. 보호 연산 안에서의 delay나 특정 I/O는 Ada 규격상의 한정 오류(bounded error)에 해당하며, 처리기에 따라 Program_Error 발생이나 데드락으로 이어질 수 있으므로 완전히 배제해야 합니다. 상태 갱신만 짧게 수행하고, 무거운 계산이나 I/O는 보호 객체 밖에서 실행한 뒤 결과만 짧게 다시 써넣는 것이 원칙입니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크