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: · Go Komura · 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.
flowchart LR
accTitle: Bản đồ tri thức task và protected object của Ada
accDescr: Sơ đồ 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-time
ada_task["Task của Ada (xử lý đồng thời)"]
protected_object["Đối tượng được bảo vệ (protected object)"]
ada["Ada (ngôn ngữ lập trình)"]
rendezvous["Rendezvous"]
selective_accept["Selective accept (câu lệnh select)"]
deadlock["Deadlock"]
barrier["Barrier (điều kiện when của protected entry)"]
data_race["Data race"]
erroneous_execution["Thực thi sai (erroneous execution)"]
bounded_error["Bounded error (lỗi giới hạn)"]
priority_ceiling_protocol["Giao thức trần độ ưu tiên (Priority Ceiling Protocol)"]
priority_inversion["Đảo ngược độ ưu tiên (priority inversion)"]
task_priority["Độ ưu tiên task (pragma Priority)"]
rate_monotonic_scheduling["Rate Monotonic Scheduling (RMS)"]
ravenscar_profile["Hồ sơ Ravenscar"]
ada_task -->|"yêu cầu"| ada
protected_object -->|"yêu cầu"| ada
ada_task -->|"sử dụng"| rendezvous
selective_accept -->|"sử dụng"| rendezvous
selective_accept -.->|"ngăn chặn"| deadlock
protected_object -.->|"sử dụng"| barrier
protected_object -->|"ngăn chặn"| data_race
data_race -->|"có thể gây"| erroneous_execution
bounded_error -.->|"có thể gây"| deadlock
protected_object -.->|"có thể gây"| bounded_error
priority_ceiling_protocol -->|"ngăn chặn"| priority_inversion
priority_ceiling_protocol -->|"yêu cầu"| protected_object
priority_ceiling_protocol -->|"sử dụng"| task_priority
rate_monotonic_scheduling -->|"sử dụng"| task_priority
rate_monotonic_scheduling -.->|"khuyến nghị cho"| ada_task
ravenscar_profile -.->|"giảm thiểu"| deadlock
ravenscar_profile -->|"yêu cầu"| ada_task
ada_task -.->|"có thể gây"| priority_inversion
ada_task -.->|"cấu hình bằng"| task_priority
rendezvous -.->|"giảm thiểu"| deadlock
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 accept là critical 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.
sequenceDiagram
accTitle: Chuỗi thời gian rendezvous Ada
accDescr: Caller và Worker task đồng bộ tại Compute, chạy body của accept, rồi cùng resume.
participant Main as Caller
participant W as Worker task
Main->>W: Gọi Worker.Compute
Note over Main: Block cho đến khi tới accept
W->>W: Tới accept Compute ... do
Note over Main,W: Rendezvous thành / body của accept được chạy
W-->>Main: Ghi kết quả vào out parameter Result
Note over Main,W: Tại end Compute cả hai cùng resume
Main->>Main: Xử lý tiếp
W->>W: Xử 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 Head và Tail 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_Size là barrier. 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.
sequenceDiagram
accTitle: Tái đánh giá barrier của protected object
accDescr: Get trên buffer rỗng chờ trong queue; sau Put, barrier được đánh giá lại và Get chạy.
participant C as Consumer task
participant B as Protected object Buf
participant P as Producer task
C->>B: Gọi Get
B->>B: Đánh giá barrier Count có lớn hơn 0 không → false
Note over C: Chờ trong entry queue của Get
P->>B: Gọi Put
B->>B: Đánh giá barrier Count có nhỏ hơn Buffer_Size không → true
B->>B: Chạy body của Put / Count thành 1
Note over B: Lúc protected operation kết thúc, đánh giá lại barrier của entry đang chờ
B->>B: Barrier của Get đã thành true
B-->>C: Chạ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(¬_full, &mutex); // chờ barrier
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(¬_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:
delayhoặ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.
Bài viết liên quan
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.
Lập trình generic trong Ada — Viết hợp đồng bằng kiểu, tái sử dụng với chi phí zero
Bài viết hệ thống hóa lập trình generic trong Ada, từ generic subprogram, generic package, formal subprogram, type category đến hướng dẫn...
Lập trình hệ thống real-time bằng Ada — thực hành ưu tiên, chu kỳ và kiểm soát thời gian thực thi
Học Annex D (Real-Time Systems) của Ada qua 8 ví dụ mã thực hành. Bài viết sắp xếp từng bước từ ưu tiên task, Ceiling_Locking, thực thi t...
Bên trong ảo hóa Windows (Phần 3) — Máy ảo khởi động trong vài giây: Vì sao WSL2, Windows Sandbox và container lại nhẹ
Vì sao WSL2 và Windows Sandbox khởi động trong vài giây và cảm giác nhẹ đến vậy? Bài viết này giải thích các cơ chế, từ ảnh cơ sở động và...
Bên trong ảo hóa Windows (Phần 2) — Bộ nhớ ngay cả kernel cũng không thấy: VBS, HVCI và Credential Guard
Khi cài sạch trên phần cứng tương thích, VBS được bật mặc định và dùng hypervisor cùng SLAT để tạo cô lập mạnh hơn kernel. Bài viết này g...
Bên trong ảo hóa Windows (Phần 1) — Windows của bạn thực sự chạy ở đâu? Hypervisor và phân vùng
Khi bạn bật Hyper-V, chính Windows máy chủ chạy trên hypervisor như phân vùng gốc. Bài viết này giải thích nền tảng ảo hóa qua vai trò củ...
Chủ đề liên quan
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.
Chủ đề kỹ thuật Windows
Cổng vào phát triển Windows, điều tra lỗi và khai thác tài sản hiện có.
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.