Xử lý đồng thời an toàn trong Ada — Hướng dẫn thực hành task và protected object

· Cập nhật ngày: · · Ada, Concurrency, Tasking, Protected Objects, Rendezvous, Real Time, Parallel Programming, Ngôn ngữ lập trình, Xử lý đồng thời, Độ tin cậy cao

1. Mở đầu — Xử lý đồng thời gắn sẵn trong ngôn ngữ

Xử lý đồng thời là chủ đề không tránh được trong phát triển phần mềm hiện đại. Thế nhưng, ở nhiều ngôn ngữ, xử lý đồng thời là thứ “gắn thêm sau”: phụ thuộc thư viện hoặc khả năng của OS, và muốn dùng đúng thì cần kiến thức sâu cùng thiết kế thận trọng.

Ada có câu trả lời riêng cho vấn đề này. Bản thân đặc tả ngôn ngữ đã gắn sẵn xử lý đồng thời.

Mô hình xử lý đồng thời của Ada:
- Task ── đơn vị đồng thời chạy độc lập
- Rendezvous ── giao tiếp đồng bộ giữa các task
- Protected object ── mutual exclusion do ngôn ngữ quản lý
- Ưu tiên real-time ── tính năng real-time Annex D

Task và rendezvous đã có từ Ada 83 (1983); protected object và tính năng real-time Annex D được thêm ở Ada 95, rồi tiếp tục tiến hóa qua Ada 2005 và 2012. Không phải primitive đồng bộ mức thấp như mutex hay semaphore; đặc trưng lớn nhất của xử lý đồng thời Ada là “ý đồ thiết kế có thể viết thẳng thành mã”.

Bài viết này giải thích xử lý đồng thời của Ada từng bước, qua tám ví dụ mã thực tiễn. Mỗi ví dụ là snippet độc lập, biên dịch và chạy được, bạn có thể thử trên máy mình.

Các mảnh mã xuất hiện trong bài cũng được công bố trên GitHub dưới dạng bộ mã tham chiếu, sắp theo từng chương thành file.

ada-task-concurrency - komurasoft-blog-samples (GitHub)

Chạy trên máy mình — Build và thực thi

Đã viết “biên dịch và chạy được” thì cũng nên chỉ sẵn bước làm.

Chuẩn bị GNAT

GNAT là trình biên dịch Ada của GCC. Trên Linux dùng apt install gnat-13, trên Windows dùng gói MSYS2 mingw-w64-x86_64-gcc-ada, hoặc cài từ Alire (trình quản lý gói cho Ada / SPARK).

Build rồi chạy

Mỗi snippet chứa nhiều compilation unit trong một file (task spec, task body, main procedure), nên tách bằng gnatchop rồi mới gnatmake. gnatchop là công cụ tách file theo quy ước đặt tên của GNAT: “tên unit = tên file”.

mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo

Tên main procedure sau khi tách chính là tên file thực thi.

Tương ứng chương và file

Số chương và số file lệch nhau 1 (chương 3 là 01_). Bảng tương ứng như sau.

Chương File File thực thi Nội dung
Chương 3 01_hello_task.ada hello_task_demo Dạng cơ bản của task
Chương 4 02_rendezvous_intro.ada rendezvous_demo Chuyển dữ liệu hai chiều bằng rendezvous
Chương 5 03_selective_accept.ada selective_accept_demo Selective accept và server task
Chương 6 04_producer_consumer.ada producer_consumer_demo Producer-consumer
Chương 7 05_protected_counter.ada protected_counter_demo Mutual exclusion bằng protected object
Chương 8 06_bounded_buffer.ada bounded_buffer_demo Protected entry có barrier (bounded buffer)
Chương 9 07_timed_entry.ada timed_entry_demo Lời gọi select có timeout
Chương 10 08_task_priorities.ada task_priorities_demo Ưu tiên task và real-time scheduling

Các mảnh mã trong bài chỉ cắt phần cần cho giải thích. Để nguyên thì không chạy được; khi muốn tự tay chạy, hãy dùng các file trên.

Bản đồ tri thức của bài viết này

Task của Ada giao tiếp đồng bộ với bên ngoài bằng rendezvous qua entry và accept; selective accept (câu select) chờ nhiều entry kèm guard condition, đồng thời dùng or terminate để tránh server task cứ chờ mãi — nguyên nhân điển hình của deadlock. Protected object là mutual exclusion do ngôn ngữ quản lý: giữ caller chờ đến khi barrier condition của entry thành true, bảo đảm exclusive access tới shared data và ngăn data race; mặt khác, làm xử lý bị cấm như delay trong protected operation thì thuộc bounded error, tùy implementation có thể dẫn tới deadlock. Priority Ceiling Protocol gán ceiling priority cho protected object để ngăn priority inversion, và tính real-time được nâng đỡ trên nền tảng lý thuyết Rate Monotonic Scheduling. Ravenscar profile hạn chế mô hình task để phân tích deadlock tĩnh được.

Bản đồ tri thức task và protected object của AdaSơ đồ cho thấy task Ada giao tiếp đồng bộ bằng rendezvous, selective accept chờ nhiều entry đồng thời tránh deadlock, protected object thực hiện mutual exclusion bằng barrier và ngăn data race, cùng Priority Ceiling Protocol và Ravenscar profile nâng đỡ tính real-timeyêu cầuyêu cầusử dụngsử dụngngăn chặnsử dụngngăn chặncó thể gâycó thể gâycó thể gâyngăn chặnyêu cầusử dụngsử dụngkhuyến nghị chogiảm thiểuyêu cầucó thể gâycấu hình bằnggiảm thiểuTask của Ada (xử lý đồng thời)Đối tượng được bảo vệ (protected object)Ada (ngôn ngữ lập trình)RendezvousSelective accept (câu lệnh select)DeadlockBarrier (điều kiện when của protected entry)Data raceThực thi sai (erroneous execution)Bounded error (lỗi giới hạn)Giao thức trần độ ưu tiên (Priority Ceiling Protocol)Đảo ngược độ ưu tiên (priority inversion)Độ ưu tiên task (pragma Priority)Rate Monotonic Scheduling (RMS)Hồ sơ Ravenscar

Trong sơ đồ, đường liền nét biểu thị quan hệ luôn đúng và đường nét đứt biểu thị quan hệ có điều kiện (điều kiện nằm trong phần giải thích từng quan hệ trên trang chi tiết). Danh sách đầy đủ các quan hệ (tổng 20, kèm bằng chứng và mức chắc chắn) cùng định nghĩa các khái niệm chính được tập hợp tại trang chi tiết bản đồ tri thức (bằng tiếng Nhật). Dữ liệu: JSON-LD / Turtle

2. Nhìn lại “nguy hiểm” của xử lý đồng thời

Trước khi vào Ada, hãy nhắc ngắn gọn vì sao xử lý đồng thời “an toàn” lại quan trọng.

Các bug điển hình trong xử lý đồng thời gồm:

  • Data race: nhiều thread truy cập cùng một vị trí bộ nhớ cùng lúc, và ít nhất một bên ghi. Kết quả không xác định.
  • Deadlock: nhiều task chờ nhau hoàn tất mãi, không bên nào tiến được.
  • Priority inversion: task ưu tiên cao chờ resource mà task ưu tiên thấp đang giữ, rồi task ưu tiên trung bình preempt task ưu tiên thấp (ngắt task đang chạy và chuyển sang task khác).
  • Starvation: một task không bao giờ giành được resource cần dùng.

Mô hình xử lý đồng thời của Ada cung cấp hàng rào ở cấp ngôn ngữ trước các vấn đề đó.

Data race            → protected object bảo đảm exclusive access
Deadlock             → mô hình rendezvous cung cấp đồng bộ có cấu trúc
Priority inversion   → Priority Ceiling Protocol dùng được như tính năng ngôn ngữ
Starvation           → entry barrier và chính sách queue cho phép kiểm soát

3. Cơ bản của task — Đơn vị thực thi độc lập

Đơn vị cơ bản của xử lý đồng thời trong Ada là task. Task giống thread, nhưng không nhất thiết tương ứng 1-1 với OS thread; Ada runtime quản lý scheduling.

task Greeter is
   entry Start;
end Greeter;

task body Greeter is
begin
   accept Start;
   Put_Line ("Hello from a task!");
end Greeter;

Mã này (01_hello_task.ada) có vài điểm quan trọng.

Task bắt đầu chạy ngay khi được khai báo. Task Greeter được khởi động tại begin của procedure chứa nó, rồi đứng ở accept Start; chờ yêu cầu rendezvous từ caller.

Entry là interface mà task mở ra bên ngoài. Khi caller gọi Greeter.Start;, nó đồng bộ với accept Start; của task. Đó gọi là rendezvous.

Việc kết thúc task được chờ tự động. Khi main procedure kết thúc, nếu còn task đang chạy thì chúng được chờ hoàn tất một cách ngầm. Điều này đối lập với crash vì quên gọi std::thread::join trong C++.

Ví dụ chạy

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.

Ở đây có một lưu ý. accept Start; không có khối do ... end. Nghĩa là ngay khi rendezvous thành, cả hai bên được thả, rồi tiếp tục chạy song song. Vì thế thứ tự hai dòng sau không được bảo đảm. Tùy môi trường, Main: task has completed. có thể in trước. Nếu muốn cố định cả thứ tự, hãy đặt xử lý cần giữ thứ tự vào trong do ... end của accept Start do ... end Start;. Chỉ dòng đầu luôn đứng trước: Greeter đứng im ở accept cho đến khi Greeter.Start; được gọi.

4. Rendezvous — Giao tiếp đồng bộ có chuyển dữ liệu

Rendezvous không chỉ là đồng bộ; nó còn chuyển dữ liệu hai chiều.

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;

Phía caller dùng như sau (02_rendezvous_intro.ada).

Worker.Compute (3, 4, Answer);
Put_Line ("Main: result = " & Integer'Image (Answer));

Điểm thiết kế quan trọng ở đây là parameter mode được viết tường minh.

  • Mode in: truyền giá trị từ caller sang task
  • Mode out: trả kết quả từ task về caller
  • Mode in out: hai chiều

Khối do ... end trong body của acceptcritical section. Trong lúc đó caller bị block, và task không nhận entry khác. Khi xử lý xong, cả hai resume.

Nhìn theo thời gian, cảnh chờ nhau như sau.

Chuỗi thời gian rendezvous AdaCaller và Worker task đồng bộ tại Compute, chạy body của accept, rồi cùng resume.Worker taskCallerWorker taskCallerBlock cho đến khi tới acceptRendezvous thành / body của accept được chạyTại end Compute cả hai cùng resumeGọi Worker.ComputeTới accept Compute ... doGhi kết quả vào out parameter ResultXử lý tiếpXử lý tiếp

Bên nào tới trước thì chờ. Caller tới trước thì đứng đến khi accept được tới; task tới trước thì đứng đến khi ai đó gọi. Dù ai trước, bên trong do ... end luôn chạy khi đủ cả hai.

Tóm tắt đặc trưng của rendezvous:

Đặc trưng Mô tả
Đồng bộ Caller và task chờ cho đến khi cả hai tới rendezvous point
Chuyển dữ liệu Parameter in / out / in out truyền giá trị hai chiều
Mutual exclusion Trong lúc body của accept chạy, các entry khác của task bị block
Có cấu trúc Entry nào được nhận lúc nào được viết tường minh trong task body

Ví dụ chạy

gnatchop ../src/snippets/02_rendezvous_intro.ada
gnatmake rendezvous_demo
./rendezvous_demo
Main: calling Worker.Compute...
Main: result = 25

3 * 3 + 4 * 4 = 25 về qua out parameter. Khoảng trắng sau = là vì 'Image của kiểu nguyên đặt một ký tự trắng trước giá trị không âm. Khác chương 3, ở đây thứ tự hai dòng được bảo đảm: lời gọi Worker.Compute không trở về trước end Compute;.

5. Selective accept — Chờ nhiều loại dịch vụ

Server task thực tế phải chờ nhiều loại yêu cầu. Câu select của Ada làm việc đó ở cấp ngôn ngữ.

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;

Câu select trong mã này (03_selective_accept.ada) có nhiều nhánh or; một trong các entry đang có lời gọi sẽ được chọn (cách chọn là implementation-defined). Nếu chưa entry nào được gọi, task chờ đến khi có một lời gọi.

or terminate; là nhánh đặc biệt: nó cho task kết thúc an toàn khi “main procedure đã xong và không còn ai có thể gọi entry của task này”. Đây là cơ chế riêng của Ada để giải bài toán “server task cứ chờ mãi” — nguyên nhân điển hình của deadlock.

Sức mạnh của selective accept còn ở chỗ viết được guard condition.

Tiếp theo là task giữ ring buffer bên trong. Nếu chỉ cắt phần select thì không rõ Count hay Head từ đâu tới, nên đoạn dưới đi từ phần khai báo. Cùng ý tưởng viết lại bằng protected object sẽ gặp ở chương 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;   -- vị trí lấy tiếp theo
   Tail  : Integer := 0;   -- vị trí ghi tiếp theo
   Count : Integer := 0;   -- số phần tử hiện có
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;

Nhánh có guard condition sai sẽ bị loại khỏi tập chọn tại thời điểm đó. Nhờ vậy có thể viết một cách khai báo kiểu “buffer rỗng thì Get phải chờ, đầy thì Put phải chờ”. Việc tăng HeadTail bằng mod Max chính là thân ring buffer; guard condition còn bảo đảm chỉ số nằm trong phạm vi hợp lệ.

6. Producer-consumer — Đồng bộ bằng rendezvous

Hãy xem producer-consumer, một mẫu điển hình dùng rendezvous.

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;

Trong mẫu này (04_producer_consumer.ada), mỗi lần producer gọi Deliver là đồng bộ với consumer. Producer quá nhanh thì bị chờ đến khi consumer accept; consumer quá nhanh thì bị chờ lời gọi tiếp theo của producer. Backpressure tự nhiên xuất hiện (khi bên nhận không kịp xử lý, tốc độ bên gửi bị kìm lại). Với rendezvous không xen queue ở giữa, cơ chế này vẫn hoạt động mà không lo buffer tràn.

7. Protected object — Mutual exclusion không cần lock

Nếu task là “chủ thể hành động chủ động”, thì protected object là cơ chế cho “dữ liệu chia sẻ thụ động”.

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;

Các quy tắc quan trọng của protected object:

  • Function chỉ đọc. Nhiều task gọi function đồng thời được.
  • Procedure đọc-ghi. Trong lúc procedure chạy, cả procedure khác lẫn function đều bị block.
  • Entry có barrier. Caller chờ trong queue đến khi barrier condition thành true.

Trong mã này (05_protected_counter.ada), ba worker task mỗi cái gọi Increment 1.000 lần. Protected object bảo đảm mutual exclusion, nên giá trị counter cuối cùng luôn đúng 3.000. Không cần tự viết lock/unlock mutex.

task type Worker (Id : Integer; Rounds : Integer);

task body Worker is
begin
   for I in 1 .. Rounds loop
      Counter.Increment;  -- protected object bảo đảm exclusive access
   end loop;
end Worker;

W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);

Ví dụ chạy

Ở đây có một vấn đề. Nếu main procedure đọc ngay Counter.Value, nó sẽ đọc giá trị giữa chừng khi worker còn đang chạy. Vì thế bản đầy đủ (05_protected_counter.ada) thêm procedure đếm hoàn tất và entry chờ mọi người xong.

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;

Đặt barrier entry All_Done when Done_Count = Num_Workers; mỗi worker, sau khi thoát vòng lặp, gọi Counter.Mark_Done;. Main procedure chờ mọi người xong bằng Counter.All_Done; rồi mới đọc giá trị. Không cần flag chờ riêng, cũng không cần sleep.

gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000

Chạy bao nhiêu lần cũng ra 3000. Ba task gọi Increment tổng 3.000 lần, và protected object thực thi từng lần một, một cách exclusive.

Không có protected object thì chuyện gì xảy ra

Để hiểu giá trị của protected object, hãy xem mã nguy hiểm khi không bảo vệ.

-- ⚠ Nguy hiểm: thao tác trực tiếp biến chia sẻ
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;

Ở cấp CPU, Shared_Counter := Shared_Counter + 1 là ba bước: “đọc → cộng → ghi lại”. Nhiều task chạy cùng lúc thì kết quả cộng của task này có thể chưa kịp vào lần đọc của task kia, và increment bị mất. Hơn nữa, đây thuộc erroneous execution theo Ada RM 9.10. “Erroneous execution” là thuật ngữ chuẩn mạnh hơn “giá trị lệch”: nó nghĩa là chuẩn không còn bảo đảm gì về hành vi của chương trình đó. Đọc-ghi đồng thời một biến chia sẻ chưa được đồng bộ không chỉ làm count cuối sai; hành vi cả chương trình có thể thành tùy ý. Dù hai task mỗi cái chạy 10.000 lần, cũng không có bảo đảm nào rằng giá trị cuối là 20.000.

Nếu muốn tự mắt thấy “không có bảo đảm” đó, hãy làm bản Bad_Worker ở trên, chạy lặp nhiều lần, và ghi lại giá trị cuối mỗi lần. Bài này không đưa số đo thực tế. Kết quả data race đổi theo CPU, tùy chọn tối ưu, và timing lúc chạy; lấy một con số ra từ một môi trường cụ thể rồi bảo “nó ra vậy” sẽ vô tình tạo thước đo sai kiểu “lệch khoảng chừng này”. Điều cần xác nhận không phải “ra một giá trị cụ thể nhỏ hơn 20.000”, mà là mỗi lần chạy kết quả khác nhau, và dù một lần ra đúng giá trị thì cũng chẳng có ý nghĩa gì.

Protected object là cơ chế “chặn bằng cú pháp”. Chỉ cần gọi Counter.Increment;, compiler và runtime bảo đảm mutual exclusion.

8. Protected entry và barrier — Bounded buffer

Thêm entry vào protected object thì đồng bộ có điều kiện trở nên khả thi. Hãy xem bounded buffer cổ điển.

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_Sizebarrier. Barrier được đánh giá mỗi lần gọi entry: true thì chạy, false thì task gọi chờ trong queue. Mỗi khi state của buffer đổi (task khác chạy Put hoặc Get), barrier của các task đang chờ được đánh giá lại.

Thời điểm đánh giá lại khó bám nếu chỉ đọc chữ. Nếu Get tới trước trên buffer rỗng, chuỗi thời gian như sau.

Tái đánh giá barrier của protected objectGet trên buffer rỗng chờ trong queue; sau Put, barrier được đánh giá lại và Get chạy.Producer taskProtected object BufConsumer taskProducer taskProtected object BufConsumer taskChờ trong entry queue của GetLúc protected operation kết thúc, đánh giá lại barrier của entry đang chờGọi GetĐánh giá barrier Count có lớn hơn 0 không → falseGọi PutĐánh giá barrier Count có nhỏ hơn Buffer_Size không → trueChạy body của Put / Count thành 1Barrier của Get đã thành trueChạy body của Get và thả Consumer

Điểm then chốt: việc đánh giá lại barrier được làm gộp, vào lúc protected operation sắp kết thúc. Trong khoảng từ lúc body Put xong đến lúc lock của Buf được thả, barrier của các entry đang chờ được đánh giá, và cái đã thành true được chạy luôn. Không có kiểu lỗi của condition variable trên C: “ai đó quên gọi signal thì ngủ mãi”.

Mẫu này (06_bounded_buffer.ada) là một trong những chỗ protected object của Ada sáng rõ nhất. Hãy so với cách viết bằng mutex + condition variable của pthread trên C.

// C + pthread (để so với Ada)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) {           // tương đương when của Ada
    pthread_cond_wait(&not_full, &mutex); // chờ barrier
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(&not_empty);         // báo các task đang chờ
pthread_mutex_unlock(&mutex);

Ở Ada, tất cả gom thành một dòng when Count < Buffer_Size. Điều kiện vòng while, gửi signal, nhầm timing lúc unlock — mọi cơ hội bug đó biến mất.

9. Lời gọi có timeout — Không chờ mãi

Hệ thống real-time không cho phép “chờ mãi”. Ada hỗ trợ timeout bằng cú pháp 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;

Trong mã này (07_timed_entry.ada), Slow_Worker đang chạy delay 2.0 nên chưa tới accept; lời gọi entry đã vào queue bị timeout sau 500ms. (Timeout chỉ tác dụng lên thời gian chờ trong queue trước khi rendezvous được nhận; nó không ngắt body của rendezvous.) delay until chỉ định thời điểm tuyệt đối, và đó là kỹ thuật cơ bản của lập trình real-time để tránh drift tích lũy.

Ada còn hỗ trợ conditional entry call.

select
   Server.Process (Item);
else
   Put_Line ("Server is busy, will retry later");
end select;

Nhánh else cho phép, nếu rendezvous không thể xảy ra ngay, chuyển ngay sang xử lý thay thế. Không cần tự viết polling.

Đừng quên thiết kế phần “sau timeout”

Timeout tiện, nhưng bản chất thiết kế là “không chờ được thì làm gì tiếp”. Giá trị có thực sự được bỏ không, có nên retry không, có nên báo lỗi lên trên không — để mơ hồ chỗ này, trên production sẽ thành mất dữ liệu hoặc dịch vụ dừng. Khi viết timeout, hãy thiết kế luôn trách nhiệm sau timeout tại cùng chỗ.

Periodic task và delay until

delay until không chỉ dùng cho timeout; nó còn dùng cho chạy theo chu kỳ. delay 0.1 đơn giản biến chu kỳ thành “thời gian xử lý + 0,1 giây”; còn delay until chốt điểm chạy kế theo thời điểm tuyệt đối, nên chu kỳ ổn định, không phụ thuộc thời gian xử lý.

loop
   Next := Next + Period;
   Do_Work;
   delay until Next;
end loop;

Mẫu này hiệu quả ở mọi chỗ cần xử lý đúng chu kỳ: giám sát sensor, vòng điều khiển, v.v.

10. Ưu tiên task và real-time scheduling

Tính năng real-time của Ada được định nghĩa ở Annex D (Real-Time Systems). Nếu implementation Ada hỗ trợ Annex D, có thể chỉ định ưu tiên task và chính sách scheduling.

Kiểm tra môi trường mình có dùng được không

Annex D là một Specialized Needs Annex (phụ lục cho lĩnh vực cụ thể); mức hỗ trợ phụ thuộc implementation và môi trường chạy. Để biết máy mình dùng được không, tách thành ba bước.

1. Xem khoảng ưu tiên

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;

Khoảng và giá trị mặc định của System.Priority phụ thuộc implementation, nên bài này không đưa số cụ thể. Nếu khoảng in ra đủ rộng, đó là môi trường mà chỉ định kiểu pragma Priority (System.Default_Priority + 5) có ý nghĩa.

2. Xem chỉ định chính sách có biên dịch được không

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

Build với các pragma này mà qua, thì ít nhất về cú pháp chúng đã được nhận.

3. Xem ưu tiên có thực sự chạy đúng không

Đây là chỗ dễ sập bẫy nhất. Biên dịch được và OS scheduler thực sự chạy theo ưu tiên là hai chuyện khác nhau. Trên OS đa dụng như Linux hay Windows, muốn ưu tiên real-time thực sự được áp dụng trên scheduler, đôi khi cần cấu hình quyền phía OS. README của bộ sample trong bài cũng ghi chú: ở môi trường Annex D chưa được hỗ trợ đầy đủ, 08_task_priorities.ada chạy như task thường.

Nói cách khác, dù ưu tiên không có hiệu lực, chương trình vẫn chạy. Với mục đích đòi hard real-time, ngoài thiết kế ưu tiên trên giấy, bước đo và xác nhận thứ tự thực tế trên môi trường đích là bắt buộc.

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;

Cấu hình nâng hơn, còn chỉ định được chính sách scheduling và Priority Ceiling Protocol.

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

Priority Ceiling Protocol là protocol để ngăn priority inversion. Mỗi protected object được gán ceiling priority tường minh bằng pragma Priority (hoặc aspect Priority). Nếu active priority của task gọi vượt ceiling đó thì Program_Error được raise. Trong lúc object bị lock, thực thi chạy ở ceiling priority, nên task ưu tiên trung bình không preempt được.

protected Shared_Data is
   pragma Priority (15);  -- ceiling priority
   procedure Update (Val : Integer);
   function Read return Integer;
private
   Data : Integer := 0;
end Shared_Data;

Các tính năng này dựa trên nền tảng lý thuyết Rate Monotonic Scheduling (RMS), và đã có thành tích trên hệ thống hard real-time như điều khiển bay máy bay hay thiết bị y tế. RMS là schema ưu tiên cố định theo kiểu “task chu kỳ ngắn hơn thì được ưu tiên cao hơn”; điểm được coi trọng cho hard real-time là có thể phân tích trước khi chạy liệu tập periodic task có giữ được deadline hay không.

11. Hướng dẫn thiết kế cho thực tế

Đến đây đã xem cú pháp cơ bản của task và protected object. Cuối cùng, hãy sắp xếp các hướng dẫn thiết kế cần nhớ khi dùng xử lý đồng thời Ada trong dự án thực.

Những việc không được làm trong protected object

Quy tắc vàng: trong protected object chỉ cập nhật state cho ngắn gọn; việc nặng thì chạy bên ngoài. Thao tác trên protected object vốn đã exclusive bên trong, nên block lâu trong đó sẽ chặn mọi task khác đang dùng cùng object.

Cụ thể, tránh:

  • delay hoặc I/O tốn thời gian
  • Lời gọi phức tạp sang protected object khác
  • Lời gọi nặng tới thư viện ngoài

Lưu ý: delay và một số I/O trong protected operation không chỉ là vấn đề hiệu năng; theo chuẩn Ada đó là bounded error. Bounded error nghĩa là “phạm vi kết quả có thể xảy ra do chuẩn định, nhưng kết quả nào thì không chốt”. Không vô hạn như erroneous execution, nhưng cũng không có bảo đảm chạy đúng. Thực tế, tùy implementation có thể raise Program_Error hoặc rơi vào deadlock, nên không phải “hạn chế” mà phải loại hẳn.

Thiết kế tốt theo mẫu: lấy nhanh giá trị cần từ protected object → tính toán nặng hoặc I/O ở ngoài → ghi nhanh chỉ kết quả trở lại protected object.

Giữ barrier condition cho đơn giản

Barrier entry ... when <condition> mạnh, nhưng quá phức tạp thì khó đọc, và khó lần ra vì sao task không được thả.

Lý tưởng là mức nhìn vào là hiểu ngay ý nghĩa của state, như when Count < Buffer_Size hay when Used > 0. Khi thực sự cần nhiều điều kiện, hãy cân nhắc biểu diễn state bằng enumeration type, để barrier đọc được theo tên state kiểu when State = Running.

Exception trong task và việc dừng

Chính sách khi exception xảy ra trong task phải được quyết định tường minh. Tối thiểu: bắt exception ở tầng ngoài cùng của task body và ghi lại chuyện gì đã xảy ra.

Quan trọng hơn nữa là thiết kế sau exception. Task đó dừng thì hệ thống còn chạy tiếp được không, có được restart không, báo các task khác thế nào, đưa shared state về trạng thái an toàn ra sao — cần trả lời được các câu đó. Ada có exception như tính năng ngôn ngữ, nhưng an toàn sau exception là trách nhiệm thiết kế ứng dụng.

Mini checklist thiết kế

Góc nhìn Việc cần kiểm tra
Shared state Đã nhốt trong protected object chưa. Bên ngoài có đụng trực tiếp không
Protected operation Có ngắn không. Có block bên trong không
Entry Barrier có đơn giản không. Có khả năng chờ mãi không. Có chính sách timeout không
Vòng đời task Điều kiện kết thúc đã rõ chưa. Có chính sách khi exception không
Xử lý chu kỳ Đã cân nhắc delay until thay vì delay chưa

Trong xử lý đồng thời, câu “chắc là ổn” là nguy hiểm nhất. Viết tường minh shared state, điều kiện chờ, điều kiện kết thúc, và chính sách exception ngay trên mã — đó là bước đầu của xử lý đồng thời an toàn.

12. Kết luận — Ngôn ngữ biến xử lý đồng thời thành “ngữ pháp”

Điểm khiến mô hình xử lý đồng thời của Ada khác hẳn ngôn ngữ khác là xử lý đồng thời an toàn không phải “best practice gắn thêm sau”, mà được gắn như “ngữ pháp”.

Việc muốn làm Ngữ pháp Ada
Đơn vị thực thi độc lập task / task body
Giao tiếp đồng bộ entry / accept
Chờ nhiều loại yêu cầu select / or / else
Mutual exclusion protected / function / procedure
Đồng bộ có điều kiện entry ... when <barrier>
Timeout or delay until <time>
Điều khiển ưu tiên pragma Priority

Các cú pháp này là đối tượng kiểm tra của compiler. Ví dụ, trong function của protected object mà cố sửa private component của chính object đó thì lỗi biên dịch. Khi protected operation xong, barrier của các entry đang chờ được đánh giá lại tự động — không cần gửi signal bằng tay.

"Giống hệ thống kiểu bảo đảm an toàn bộ nhớ,
  cú pháp xử lý đồng thời của Ada bảo đảm an toàn đồng bộ."

Tám ví dụ mã trong bài là nhập môn thực tiễn về task, rendezvous, protected object, và tính năng real-time. Hãy chạy chúng trên máy, rồi thử thêm các chủ đề nâng cao sau.

  • Ravenscar profile: profile hạn chế tasking cho hệ thống real-time độ tin cậy cao. Mô hình task bị hạn chế giúp phân tích deadlock tĩnh được.
  • Parallel block của Ada 2022: xử lý data-parallel bằng cú pháp parallel ... do.
  • Tích hợp SPARK: formal verification hành vi chương trình đồng thời (GNATprove hỗ trợ dưới Ravenscar profile).

Dù vậy, “dùng Ada” không có nghĩa là an toàn

Một lưu ý quan trọng lúc kết. Cú pháp xử lý đồng thời của Ada mạnh, nhưng dùng Ada không tự biến chương trình thành an toàn. Đụng shared data trực tiếp mà không nhốt vào protected object, block lâu trong protected object, để nhiều protected object gọi nhau phức tạp — những sai thiết kế đó vẫn xảy ra được trên Ada.

Tính năng ngôn ngữ được thiết kế để “muốn viết nguy hiểm thì phải cố ý”, nhưng chúng không làm hộ thiết kế đúng. Giá trị thực của Ada là kéo cuộc thảo luận về an toàn sát vào mã: các câu “state này đã được bảo vệ chưa”, “task này kết thúc lúc nào”, “entry này chờ điều kiện nào” có thể để lại ngay trên cú pháp.

Tư tưởng Ada nói thiết kế bằng kiểu cũng nhất quán ở xử lý đồng thời. Xử lý đồng thời an toàn không bắt đầu từ cầm lock cho cẩn thận, mà từ việc không để shared state nguy hiểm tồn tại trần.

Trước quan niệm “xử lý đồng thời thì khó”, Ada trả lời: “chọn đúng cú pháp thì compiler bảo đảm an toàn”. Tư tưởng thiết kế đó gần với Rust hay Pony hiện đại — nhưng Ada đã giữ nó trong đặc tả ngôn ngữ hơn 40 năm.

Các bài viết gần đây có cùng thẻ để tìm hiểu sâu hơn những chủ đề lân cận.

Các trang này đặt chủ đề trong bối cảnh rộng hơn của dịch vụ và quyết định.

Câu hỏi thường gặp

Các câu hỏi thường gặp khi tư vấn về chủ đề của bài viết.

Task trong Ada là gì?
Task là đơn vị cơ bản của xử lý đồng thời trong Ada. Nó giống thread, nhưng không nhất thiết tương ứng 1-1 với OS thread; Ada runtime quản lý scheduling. Task bắt đầu chạy ngay khi được khai báo, và khi main procedure kết thúc thì các task còn đang chạy được chờ hoàn tất một cách ngầm. Với bên ngoài, task giao tiếp đồng bộ qua rendezvous bằng entry. Task và rendezvous đã nằm trong đặc tả ngôn ngữ từ Ada 83 (1983).
Protected object của Ada khác mutex ở điểm nào?
Protected object là cơ chế mutual exclusion do ngôn ngữ quản lý; không cần tự viết lock/unlock. Function chỉ đọc và nhiều task gọi đồng thời được; procedure dùng để đọc-ghi và khi đang chạy thì các lời gọi khác bị block; entry giữ caller trong queue cho đến khi barrier condition thành true. Điều khiển bounded buffer mà trên C phải ghép mutex và condition variable của pthread, ở Ada gom lại thành một dòng barrier kiểu "when Count < Buffer_Size".
Rendezvous trong Ada hoạt động thế nào?
Rendezvous là cơ chế giao tiếp đồng bộ giữa các task: lời gọi entry phía caller và câu accept phía task chờ nhau cho đến khi cả hai tới rendezvous point cùng lúc. Parameter mode in/out/in out cho phép chuyển dữ liệu hai chiều. Khối do...end trong body của accept là critical section: lúc chạy thì caller bị block và task không nhận entry khác. Kết hợp với câu select, có thể viết một cách khai báo việc chờ nhiều entry, timeout, và guard condition.
Trong protected object, những việc nào không được làm?
Những xử lý block lâu: delay, I/O tốn thời gian, lời gọi nặng tới thư viện ngoài. delay và một số I/O trong protected operation thuộc bounded error theo chuẩn Ada; tùy implementation có thể raise Program_Error hoặc dẫn tới deadlock, nên phải loại hẳn, không chỉ "hạn chế". Quy tắc vàng: chỉ cập nhật state cho ngắn gọn, còn tính toán nặng và I/O thì chạy ngoài protected object rồi ghi kết quả trở lại trong thời gian ngắn.

Hồ sơ tác giả

Trang giới thiệu tác giả bài viết.

Go Komura

Đại diện của KomuraSoft LLC

Chuyên về phát triển phần mềm Windows, tư vấn kỹ thuật và điều tra lỗi, đặc biệt trong các dự án có hệ thống hiện hữu và lỗi khó tái hiện.

Liên kết công khai

Quay lại blog