1. מבוא — concurrency שמובנה בשפה
concurrency הוא נושא שאי אפשר לעקוף בפיתוח תוכנה מודרני. אבל ברוב השפות הוא “תוספת מאוחרת”: תלוי בספרייה או ביכולת של ה-OS, וכדי להשתמש בו נכון צריך ידע עמוק ועיצוב זהיר.
ל-Ada יש תשובה משלה לבעיה הזו. concurrency מובנה במפרט השפה עצמה.
מודל ה-concurrency של Ada:
- task — יחידת concurrency שרצה באופן עצמאי
- rendezvous — תקשורת סינכרונית בין task
- protected object — mutual exclusion שהשפה מנהלת
- priority בזמן אמת — יכולות real-time של Annex D
task ו-rendezvous קיימים מאז Ada 83 ב-1983; protected object ויכולות real-time של Annex D נוספו ב-Ada 95, והמשיכו להתפתח ב-Ada 2005 וב-2012. במקום low-level primitives כמו mutex ו-semaphore, היתרון המרכזי של concurrency ב-Ada הוא שאפשר לבטא את כוונת העיצוב ישירות בקוד.
המאמר מסביר concurrency ב-Ada בהדרגה דרך שמונה דוגמאות קוד מעשיות. כל דוגמה היא snippet עצמאי שאפשר לקמפל ולהריץ, ואפשר לנסות אותו אצלכם.
קטעי הקוד במאמר מפורסמים גם ב-GitHub כאוסף ייחוס שמסודר לפי פרקים.
ada-task-concurrency - komurasoft-blog-samples (GitHub)
להריץ אצלכם — build והרצה
אם כתבתי שהדוגמאות “ניתנות לקומפילציה ולהרצה”, כדאי להראות גם את הצעדים עצמם.
להתקין GNAT
GNAT הוא קומפיילר Ada של GCC. ב-Linux מתקינים עם apt install gnat-13, ב-Windows דרך חבילת MSYS2 בשם mingw-w64-x86_64-gcc-ada, או דרך Alire (מנהל החבילות של Ada / SPARK).
build והרצה
כל snippet כולל בקובץ אחד כמה יחידות קומפילציה (מפרט task, גוף task, ו-main procedure), ולכן מפצלים קודם ב-gnatchop ורק אז בונים ב-gnatmake. gnatchop מפצל קבצים לפי כלל השמות של GNAT: “שם היחידה = שם הקובץ”.
mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
שם ה-main procedure אחרי הפיצול הופך לשם קובץ ההרצה.
התאמה בין פרק לקובץ
מספר הפרק ומספר הקובץ מוזזים באחד (פרק 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 |
selective accept ו-server task |
| פרק 6 | 04_producer_consumer.ada |
producer_consumer_demo |
producer-consumer |
| פרק 7 | 05_protected_counter.ada |
protected_counter_demo |
mutual exclusion דרך protected object |
| פרק 8 | 06_bounded_buffer.ada |
bounded_buffer_demo |
protected entry עם barrier (bounded buffer) |
| פרק 9 | 07_timed_entry.ada |
timed_entry_demo |
קריאת select עם timeout |
| פרק 10 | 08_task_priorities.ada |
task_priorities_demo |
priority של task ו-scheduling בזמן אמת |
קטעי הקוד בגוף המאמר הם רק החלקים שדרושים להסבר. הם לא רצים כמו שהם — כשרוצים לנסות בפועל, משתמשים בקבצים למעלה.
knowledge map של המאמר
task ב-Ada מתקשר באופן סינכרוני עם החוץ ב-rendezvous דרך entry ו-accept. selective accept (משפט select) ממתין לכמה entry עם תנאי guard, ועם or terminate אפשר להימנע מ-server task שממשיך לחכות, שהוא מקור ל-deadlock. protected object הוא mutual exclusion שהשפה מנהלת: תנאי ה-barrier של ה-entry מחזיק את הקורא בהמתנה עד שהוא נהיה true, מבטיח גישה בלעדית לנתונים משותפים ומונע data race; מצד שני, delay ופעולות אסורות אחרות בתוך פעולה מוגנת הן bounded error, ובהתאם למימוש עלולות להוביל ל-deadlock. Priority Ceiling Protocol מגדיר ceiling priority ל-protected object ומונע priority inversion, והתמיכה ב-real-time נשענת על הרקע התיאורטי של Rate Monotonic Scheduling. פרופיל Ravenscar מצמצם את מודל ה-task ומאפשר ניתוח deadlock סטטי.
flowchart LR
accTitle: knowledge map: task ו-protected object ב-Ada
accDescr: תרשים שמראה ש-task ב-Ada מתקשר באופן סינכרוני ב-rendezvous, ש-selective accept ממתין לכמה entry תוך הימנעות מ-deadlock, ש-protected object מבטיח mutual exclusion דרך barrier ומונע data race, וש-Priority Ceiling Protocol ופרופיל Ravenscar תומכים ב-real-time.
ada_task["Ada task"]
protected_object["protected object"]
ada["Ada"]
rendezvous["rendezvous"]
selective_accept["selective accept (select)"]
deadlock["deadlock"]
barrier["barrier (תנאי when של protected entry)"]
data_race["data race"]
erroneous_execution["erroneous execution"]
bounded_error["bounded error"]
priority_ceiling_protocol["Priority Ceiling Protocol"]
priority_inversion["priority inversion"]
task_priority["priority של task (pragma Priority)"]
rate_monotonic_scheduling["Rate Monotonic Scheduling (RMS)"]
ravenscar_profile["Ravenscar profile"]
ada_task -->|"דורש"| ada
protected_object -->|"דורש"| ada
ada_task -->|"משתמש ב"| rendezvous
selective_accept -->|"משתמש ב"| rendezvous
selective_accept -.->|"מונע"| deadlock
protected_object -.->|"משתמש ב"| barrier
protected_object -->|"מונע"| data_race
data_race -->|"עלול לגרום ל"| erroneous_execution
bounded_error -.->|"עלול לגרום ל"| deadlock
protected_object -.->|"עלול לגרום ל"| bounded_error
priority_ceiling_protocol -->|"מונע"| priority_inversion
priority_ceiling_protocol -->|"דורש"| protected_object
priority_ceiling_protocol -->|"משתמש ב"| task_priority
rate_monotonic_scheduling -->|"משתמש ב"| task_priority
rate_monotonic_scheduling -.->|"מומלץ ל"| ada_task
ravenscar_profile -.->|"מפחית"| deadlock
ravenscar_profile -->|"דורש"| ada_task
ada_task -.->|"עלול לגרום ל"| priority_inversion
ada_task -.->|"מוגדר ב"| task_priority
rendezvous -.->|"מפחית"| deadlock
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 20, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
2. תזכורת: הסכנות של concurrency
לפני שנכנסים ל-Ada, כדאי לעצור רגע ולבדוק למה concurrency “בטוח” חשוב.
באגים טיפוסיים ב-concurrency כוללים את אלה.
- data race: כמה thread ניגשים באותו זמן לאותו מיקום בזיכרון, ולפחות אחד כותב. התוצאה לא מוגדרת.
- deadlock: כמה task ממתינים זה לסיום של זה, ואף אחד לא מתקדם.
- priority inversion: task בעל priority גבוה ממתין למשאב שמוחזק בידי task בעל priority נמוך, ו-task ב-priority בינוני עושה preemption ל-task הנמוך (מפסיק את מי שרץ ומעביר את הביצוע ל-task אחר).
- starvation: task לא מצליח אף פעם להשיג את המשאב שהוא צריך.
מודל ה-concurrency של Ada נותן מול הבעיות האלה הגנה ברמת השפה.
data race → protected object מבטיח גישה בלעדית
deadlock → מודל ה-rendezvous נותן סנכרון structured
priority inversion → Priority Ceiling Protocol זמין כחלק מהשפה
starvation → שולטים דרך barrier של entry ומדיניות תורים
3. יסודות ה-task — יחידת הרצה עצמאית
יחידת ה-concurrency הבסיסית ב-Ada היא task. task דומה ל-thread, אבל לא בהכרח ממופה אחד-לאחד ל-OS thread — ה-runtime של Ada מנהל את ה-scheduling.
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 הוא מתחיל לרוץ מעצמו. ה-task Greeter עולה כשה-procedure שמכילה אותו מגיעה ל-begin, וממתין ב-accept Start; לבקשת rendezvous מהקורא.
entry הוא הממשק שה-task חושף החוצה. כשהקורא כותב Greeter.Start;, הוא מסתנכרן עם accept Start; של ה-task. לזה קוראים rendezvous.
ממתינים אוטומטית לסיום של task. כשה-main procedure מסתיימת, אם יש עוד task שרץ, ממתינים לסיום שלו באופן implicit. זה הניגוד ל-crash שנגרם ב-C++ כששוכחים לקרוא ל-std::thread::join.
דוגמת הרצה
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. יופיע קודם. אם רוצים גם סדר קבוע, שמים את העיבוד שחשוב לשמור על הסדר שלו בתוך do ... end של accept Start do ... end Start;. רק השורה הראשונה מגיעה תמיד בהתחלה, כי Greeter ממתין ב-accept עד שקוראים ל-Greeter.Start;.
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: דו-כיווני
בלוק do ... end בגוף ה-accept הוא critical section. בזמן הזה הקורא חסום, וה-task לא מקבל entry אחר. כשהעיבוד מסתיים שני הצדדים ממשיכים.
בציר הזמן, ההמתנה נראית כך.
sequenceDiagram
accTitle: זרימת rendezvous בין הקורא ל-Worker task
accDescr: הקורא קורא ל-Worker.Compute ונחסם עד accept; גוף ה-accept רץ כ-critical section; ב-end Compute שני הצדדים ממשיכים יחד
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 parameter Result
Note over Main,W: ב-end Compute שני הצדדים ממשיכים יחד
Main->>Main: המשך העיבוד
W->>W: המשך העיבוד
מי שמגיע קודם ממתין. אם הקורא קודם, הוא עוצר עד שמגיעים ל-accept; אם ה-task קודם, הוא עוצר עד שמישהו קורא. בכל מקרה, התוכן של do ... end רץ רק כששני הצדדים כבר שם.
לסיכום, המאפיינים של rendezvous:
| מאפיין | הסבר |
|---|---|
| סנכרון | הקורא וה-task ממתינים עד ששניהם מגיעים יחד לנקודת ה-rendezvous |
| העברת נתונים | פרמטרי in / out / in out מעבירים ערכים לשני הכיוונים |
| mutual exclusion | בזמן שגוף ה-accept רץ, entry אחרים של אותו task חסומים |
| מבנה מפורש | בגוף ה-task כתוב במפורש איזה entry מתקבל ומתי |
דוגמת הרצה
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 parameter. הרווח האחד מימין ל-= מגיע מהמפרט של 'Image לטיפוס מספר שלם: לערך שאינו שלילי מוסיפים תו רווח אחד מלפנים. בניגוד לפרק 3, כאן סדר שתי השורות מובטח, כי קריאה ל-Worker.Compute לא חוזרת עד end Compute;.
5. selective accept — המתנה לכמה שירותים
server task אמיתי צריך להמתין לכמה סוגים של בקשות. משפט select ב-Ada מממש את זה ברמת השפה.
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;
ב-select של הקוד הזה (03_selective_accept.ada) יש כמה ענפי or, ונבחר אחד מבין ה-entry שיש להם קריאה ממתינה (הבחירה עצמה implementation-defined). אם אף entry לא נקרא, ממתינים עד שאחד מהם ייקרא.
or terminate; הוא ענף מיוחד: הוא מסיים את ה-task בבטחה כאשר “ה-main procedure הסתיימה, ואין יותר אפשרות שמישהו יקרא ל-entry של ה-task הזה”. זה מנגנון ייחודי ל-Ada שפותר את בעיית “ה-server task שממשיך לחכות לעד”, שהיא מקור ל-deadlock.
הכוח של selective accept הוא שאפשר לכתוב גם תנאי guard.
להלן task עם ring buffer פנימי. אם גוזרים רק את חלק ה-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 שלו false יוצא באותו רגע ממועמדי הבחירה. כך אפשר לכתוב באופן דקלרטיבי שליטה כמו “אם ה-buffer ריק, 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), בכל קריאת Deliver של ה-producer הוא מסתנכרן עם ה-consumer. אם ה-producer מהיר מדי, הוא ממתין עד שה-consumer יעשה accept; אם ה-consumer מהיר מדי, הוא ממתין לקריאה הבאה של ה-producer. זה backpressure טבעי: כשהמקבל לא מספיק לעבד, הקצב של השולח יורד מעצמו. ב-rendezvous בלי תור באמצע, זה מתקיים בלי חשש ל-overflow של buffer.
7. protected object — mutual exclusion בלי 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 נהיה true.
בקוד הזה (05_protected_counter.ada) שלושה worker task קוראים ל-Increment 1,000 פעמים כל אחד. ה-protected object מבטיח mutual exclusion, ולכן ערך המונה הסופי הוא תמיד 3,000. אין צורך לכתוב 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 מבטיח mutual exclusion
end loop;
end Worker;
W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);
דוגמת הרצה
כאן יש בעיה אחת. אם ה-main procedure קוראת מיד ל-Counter.Value, היא עלולה לקרוא ערך באמצע הסיבוב של ה-workers. לכן בגרסה המלאה (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;
שמים barrier מהצורה entry All_Done when Done_Count = Num_Workers, וכל worker קורא ל-Counter.Mark_Done; אחרי שהוא יוצא מהלולאה. ה-main procedure ממתין ב-Counter.All_Done; שכולם סיימו, ורק אז קוראת את הערך. לא צריך משתנה דגל נפרד להמתנה, וגם לא sleep.
gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000
בכל הרצה מתקבל 3000. שלושה task קוראים יחד ל-Increment 3,000 פעמים, וה-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 שלושה שלבים: קריאה, חיבור, וכתיבה חזרה. כשכמה task מריצים את זה במקביל, תוצאת החיבור של task אחד עלולה לא להספיק להיכתב לפני שהשני כבר קרא, ואינקרמנט הולך לאיבוד. מעבר לזה, זה erroneous execution לפי Ada RM 9.10. “erroneous execution” הוא מונח בתקן שחזק מ”הערך זז”: התקן כבר לא מבטיח דבר לגבי התנהגות התוכנית. קריאה וכתיבה במקביל למשתנה משותף בלי סנכרון לא נעצרות בערך מונה לא מדויק — התנהגות התוכנית כולה יכולה להיות שרירותית. גם אם שני task רצים 10,000 פעמים כל אחד, אין שום הבטחה שהערך הסופי יהיה 20,000.
אם רוצים לראות את ה”אין הבטחה” בעיניים, בונים גרסת Bad_Worker כזו, מריצים שוב ושוב, ורושמים את הערך הסופי בכל פעם. המאמר הזה לא מפרסם מדידות. תוצאה של data race משתנה לפי CPU, לפי אופטימיזציה, ולפי תזמון בזמן ריצה. מספר אחד מסביבה מסוימת, אם מציגים אותו כ”ככה זה יוצא”, נותן בטעות את הרושם ש”בערך ככה זה סוטה מהערך הנכון”. מה שצריך לבדוק הוא לא ש”יצא ערך מסוים קטן מ-20,000”, אלא שהתוצאה משתנה מהרצה להרצה, וגם הרצה אחת עם הערך הנכון לא אומרת כלום.
protected object מונע את הבעיה הזו ברמת התחביר. קריאה ל-Counter.Increment; מספיקה — הקומפיילר וה-runtime מבטיחים mutual exclusion.
8. protected entry ו-barrier — bounded buffer
כשמוסיפים entry ל-protected object, מתאפשר סנכרון מותנה. נראה את זה ב-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: אם true — מריצים; אם false — ה-task הקורא ממתין בתור. בכל פעם שמצב ה-buffer משתנה (task אחר מריץ Put או Get), מעריכים מחדש את ה-barrier של מי שמחכה.
מתי בדיוק קורה החישוב מחדש קשה לעקוב אחריו מטקסט בלבד. אם Get מגיע ל-buffer ריק, בציר הזמן זה נראה כך.
sequenceDiagram
accTitle: חישוב מחדש של barrier ב-protected object של bounded buffer
accDescr: Get מגיע ל-buffer ריק וממתין בתור ה-entry; אחרי Put ה-barrier מחושב מחדש בסוף הפעולה המוגנת ו-Get רץ
participant C as Consumer task
participant B as protected object Buf
participant P as Producer task
C->>B: קורא ל-Get
B->>B: מעריך barrier האם Count גדול מ-0 → false
Note over C: ממתין בתור ה-entry של Get
P->>B: קורא ל-Put
B->>B: מעריך barrier האם Count קטן מ-Buffer_Size → true
B->>B: מריץ את גוף Put / Count נהיה 1
Note over B: בסוף הפעולה המוגנת מעריכים מחדש barrier של entry שמחכים
B->>B: ה-barrier של Get נהיה true
B-->>C: מריץ את גוף Get ומשחרר את ה-Consumer
הנקודה היא שחישוב מחדש של barrier נעשה בבת אחת בסוף הפעולה המוגנת. בין סיום גוף Put לבין שחרור ה-lock של Buf, מעריכים את ה-barrier של entry שמחכים, ומי שנהיה true רץ מיד. אין כאן את כשל ה-condition variable ב-C: “מישהו שכח לקרוא ל-signal, ואז אף אחד לא מתעורר יותר”.
התבנית הזו (06_bounded_buffer.ada) היא אחד הרגעים שבהם protected object של Ada בולט במיוחד. השוו לכתיבה ב-C עם pthread mutex ועם condition variable.
// C + pthread (להשוואה מול Ada)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) { // מקביל ל-when של Ada
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, בשליחת ה-signal, ובתזמון שחרור ה-lock — כל הזדמנויות הבאג האלה נעלמות.
9. קריאה עם timeout — לא לחכות לנצח
במערכת real-time אסור “לחכות לנצח”. Ada תומכת ב-timeout עם התחביר 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 שנכנסה לתור עושה timeout אחרי 500ms. (ה-timeout חל על זמן ההמתנה בתור לפני שה-rendezvous מתקבל; הוא לא קוטע את גוף ה-rendezvous עצמו.) delay until הוא ציון בזמן מוחלט, וזו טכניקה בסיסית ב-real-time כדי למנוע drift מצטבר.
Ada תומכת גם ב-conditional entry call.
select
Server.Process (Item);
else
Put_Line ("Server is busy, will retry later");
end select;
סעיף else אומר: אם אי אפשר לעשות rendezvous מיד, עוברים מיד לעיבוד החלופי. אין צורך לכתוב polling ידני.
לא לשכוח את העיצוב אחרי timeout
timeout נוח, אבל מה שקובע בעיצוב הוא “מה עושים אחרי שלא הצלחנו לחכות”. האם מותר לזרוק את הערך, האם צריך לנסות שוב, האם להעביר שגיאה למעלה — אם זה נשאר מעורפל, בסביבת ייצור זה הופך לאובדן נתונים או להשבתת שירות. כשכותבים timeout, מעצבים באותו מקום גם את האחריות שאחריו.
task מחזורי ו-delay until
delay until לא משמש רק ל-timeout. אפשר להשתמש בו גם ל-הרצה מחזורית. delay 0.1 פשוט הופך את המחזור ל”זמן העיבוד + 0.1 שניות”, בעוד delay until קובע את נקודת ההפעלה הבאה בזמן מוחלט, ולכן שומר על מחזור יציב שלא תלוי בזמן העיבוד.
loop
Next := Next + Period;
Do_Work;
delay until Next;
end loop;
התבנית הזו שימושית בכל מקום שדורש עיבוד במחזור קבוע — ניטור חיישנים, לולאת בקרה, וכדומה.
10. priority של task ו-scheduling בזמן אמת
יכולות real-time של Ada מוגדרות ב-Annex D (Real-Time Systems). אם המימוש תומך ב-Annex D, אפשר לציין priority של task ומדיניות scheduling.
לבדוק אם זה עובד בסביבה שלכם
Annex D הוא אחד מ-Specialized Needs Annex (נספח לייעוד מסוים), והתמיכה תלויה במימוש ובסביבת הריצה. האם זה זמין אצלכם בודקים בשלושה שלבים.
1. לראות את טווח ה-priority
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);
אם מוסיפים את אלה וה-build עובר, לפחות התחביר התקבל.
3. לבדוק אם זה באמת רץ לפי priority
כאן המלכוד העיקרי. קומפילציה שעוברת, וה-scheduler של ה-OS שמריץ לפי priority, הם שני דברים נפרדים. על OS כללי כמו Linux או Windows לפעמים צריך גם הרשאות בצד ה-OS כדי ש-priority בזמן אמת באמת יגיע ל-scheduler. גם ב-README של אוסף הדוגמאות של המאמר מצוין שבסביבה בלי תמיכה מלאה ב-Annex D, 08_task_priorities.ada רץ כ-task רגיל.
כלומר, גם אם priority לא נכנס לפעולה, התוכנית עדיין רצה. לשימוש שדורש hard real-time לא מספיק תכנון priority על הנייר — חובה למדוד בפועל את הסדר על סביבת היעד.
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;
להגדרה מתקדמת יותר אפשר לציין גם מדיניות scheduling וגם Priority Ceiling Protocol.
pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);
Priority Ceiling Protocol מונע priority inversion. לכל protected object קובעים ceiling priority במפורש ב-pragma Priority (או ב-Priority aspect). אם ה-priority הפעיל של ה-task הקורא עובר את ה-ceiling, עולה Program_Error. כל עוד האובייקט נעול, הביצוע רץ ב-ceiling priority, וזה מונע preemption מ-task ב-priority בינוני.
protected Shared_Data is
pragma Priority (15); -- ceiling priority
procedure Update (Val : Integer);
function Read return Integer;
private
Data : Integer := 0;
end Shared_Data;
היכולות האלה נשענות על הרקע התיאורטי של Rate Monotonic Scheduling (RMS), ויש להן ניסיון במערכות hard real-time כמו בקרת טיסה במטוסים ומכשור רפואי. RMS הוא שיטת priority קבוע: “ל-task עם המחזור הקצר יותר נותנים priority גבוה יותר”. מה שחשוב בשימוש hard real-time הוא שאפשר לנתח לפני הריצה אם קבוצת task מחזוריים תעמוד ב-deadline.
11. עקרונות עיצוב לפרקטיקה
עד כאן התחביר הבסיסי של task ושל protected object. לסיום, עקרונות עיצוב שכדאי לשים עליהם לב כשמשתמשים ב-concurrency של Ada בפועל.
מה אסור לעשות בתוך protected object
כלל הברזל בתוך protected object: לעדכן מצב בקצרה בלבד, ולהריץ עבודה כבדה בחוץ. פעולות על protected object מוגנות פנימית ב-mutual exclusion, ולכן חסימה ארוכה בפנים עוצרת את כל ה-task האחרים שמשתמשים באותו אובייקט.
במפורש להימנע מ:
delayאו I/O שלוקח זמן- קריאות מורכבות ל-protected object אחר
- קריאות כבדות לספרייה חיצונית
שימו לב: delay ו-I/O מסוים בתוך פעולה מוגנת הם לא רק בעיית ביצועים. לפי תקן Ada זה bounded error. bounded error הוא סוג שגיאה שבו “טווח התוצאות האפשריות נקבע בתקן, אבל לא נקבע איזו מהן תקרה”. זה לא פתוח בלי גבול כמו erroneous execution, אבל גם אין הבטחה שהתוכנית תתנהג נכון. בפועל, בהתאם למימוש, עלול לעלות Program_Error או deadlock. לכן זה לא “להמעיט” — צריך לסלק לגמרי.
עיצוב טוב נראה כך: שולפים במהירות את הערכים הדרושים מה-protected object → מריצים בחוץ חישוב כבד או I/O → כותבים חזרה במהירות רק את התוצאה.
לשמור על תנאי barrier פשוטים
barrier של entry ... when <condition> חזק, אבל אם הוא מסתבך מדי הוא נהיה קשה לקריאה, וקשה לברר למה task לא משתחרר.
האידיאל הוא רמה שבה משמעות המצב ברורה במבט: when Count < Buffer_Size, when Used > 0. כשבאמת צריך כמה תנאים, כדאי לייצג מצב ב-enumeration ולהתקרב ל-barrier שנקרא לפי שם מצב, כמו when State = Running.
חריגות ועצירה של task
צריך לקבוע במפורש מה המדיניות כשעולה exception בתוך task. לכל הפחות, לתפוס exception ברמה העליונה של גוף ה-task, ולתעד מה קרה.
חשוב עוד יותר הוא העיצוב אחרי ה-exception. אם ה-task הזה נעצר, האם המערכת יכולה להמשיך? מותר להפעיל אותו מחדש? איך מודיעים ל-task אחרים? איך מחזירים מצב משותף למצב בטוח? Ada נותנת exception כמנגנון שפה, אבל הבטיחות אחרי exception היא אחריות של עיצוב האפליקציה.
מיני-checklist לעיצוב
| היבט | מה לבדוק |
|---|---|
| מצב משותף | האם הוא סגור בתוך protected object? האם אין נגיעה ישירה מבחוץ? |
| פעולה מוגנת | האם היא קצרה? האם היא לא חוסמת בפנים? |
| entry | האם ה-barrier פשוט? האם אין אפשרות לחכות לעד? האם יש מדיניות timeout? |
| מחזור חיים של task | האם תנאי הסיום ברור? האם יש מדיניות ל-exception? |
| עיבוד מחזורי | האם שקלתם delay until במקום delay? |
ב-concurrency, “כנראה בסדר” הוא המשפט המסוכן ביותר. לכתוב במפורש בקוד את המצב המשותף, תנאי ההמתנה, תנאי הסיום ומדיניות ה-exception — זה הצעד הראשון ל-concurrency בטוח.
12. סיכום — שפה שהפכה concurrency ל”דקדוק”
מה שמייחד את מודל ה-concurrency של Ada משפות אחרות הוא שconcurrency בטוח הוא לא “best practice שמוסיפים אחר כך” אלא חלק מהדקדוק.
| מה רוצים | התחביר של Ada |
|---|---|
| יחידת הרצה עצמאית | task / task body |
| תקשורת סינכרונית | entry / accept |
| המתנה לכמה בקשות | select / or / else |
| mutual exclusion | protected / function / procedure |
| סנכרון מותנה | entry ... when <barrier> |
| timeout | or delay until <time> |
| שליטה ב-priority | pragma Priority |
התחביר הזה נתון לבדיקה של הקומפיילר. למשל, ניסיון לשכתב רכיב private של ה-protected object מתוך function שלו נופל בקומפילציה. כשפעולה מוגנת מסתיימת, barrier של entry שמחכים מחושב מחדש אוטומטית — אין צורך ב-signal ידני.
"כשם שמערכת הטיפוסים מבטיחה בטיחות זיכרון,
תחביר ה-concurrency של Ada מבטיח בטיחות סנכרון"
שמונה דוגמאות הקוד במאמר הן מבוא מעשי ל-task, ל-rendezvous, ל-protected object וליכולות real-time. כדאי להריץ אותן אצלכם, ואז לגשת גם לנושאים המתקדמים האלה.
- Ravenscar profile: פרופיל הגבלות על task למערכות real-time בעלות אמינות גבוהה. מודל ה-task המצומצם מאפשר ניתוח deadlock סטטי.
- בלוקים מקביליים ב-Ada 2022: עיבוד data-parallel עם התחביר
parallel ... do. - שילוב עם SPARK: אימות פורמלי של התנהגות תוכנית concurrent (GNATprove תומך תחת Ravenscar profile).
ובכל זאת, “להשתמש ב-Ada” זה לא אוטומטית בטוח
הערה אחרונה שחשוב לא לפספס. תחביר ה-concurrency של Ada חזק, אבל Ada לא הופכת תוכנית לבטוחה מעצמה. לגעת בנתונים משותפים ישירות בלי לשים אותם ב-protected object, לחסום לזמן ארוך בתוך protected object, לקרוא באופן מורכב בין כמה protected object — טעויות עיצוב כאלה אפשריות גם ב-Ada.
יכולות השפה בנויות כך ש”כתיבה מסוכנת דורשת מאמץ מפורש”, אבל הן לא מחליפות עיצוב נכון. הערך האמיתי של Ada הוא שאפשר להביא את דיון הבטיחות קרוב לקוד: שאלות כמו “האם המצב הזה מוגן”, “מתי ה-task הזה מסתיים”, “באיזה תנאי ה-entry הזה ממתין” נשארות בקוד עצמו, כתחביר.
הגישה של Ada — לדבר עיצוב בטיפוסים — עקבית גם ב-concurrency. concurrency בטוח לא מתחיל בטיפול זהיר ב-lock, אלא בכך שלא משאירים מצב משותף מסוכן חשוף.
מול התפיסה ש”concurrency קשה”, Ada עונה: “אם בוחרים נכון את התחביר, הקומפיילר מבטיח את הבטיחות”. הגישה הזו קרובה גם ל-Rust ול-Pony של היום, אבל Ada מחזיקה אותה במפרט השפה כבר ארבעים שנה.
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
תכנות מערכות real-time ב-Ada — עדיפויות, מחזוריות ובקרת execution time
לומדים את Annex D של Ada (מערכות real-time) דרך שמונה דוגמאות קוד מעשיות. המאמר עובר בהדרגה על עדיפות של tasks, Ceiling_Locking, הרצה מחז...
Generic programming ב-Ada — חוזה בטיפוסים ו-reuse ב-zero cost
סקירה שיטתית של generic programming ב-Ada: generic subprograms, generic packages, formal subprograms, קטגוריות טיפוס, ועקרונות עיצוב מעשי...
מלכודות של shared memory ו-best practices מעשיים
המאמר עובר על המלכודות בשימוש ב-shared memory בשטח, ועל התכנון שמוריד את שיעור התקלות: סנכרון, visibility, lifetime, ABI והרשאות.
Windows Virtualization Internals (חלק 3) — VM שעולה תוך שניות: WSL2, Windows Sandbox ו-containers
למה WSL2 ו-Windows Sandbox עולים בשניות ומרגישים קלים? המאמר מסביר את המנגנונים, מ-dynamic base image ו-direct map דרך הקצאת זיכרון דינמי...
Windows Virtualization Internals (חלק 2) — זיכרון שאפילו ה-kernel לא רואה: VBS, HVCI ו-Credential Guard
בהתקנה נקייה על חומרה תואמת, VBS מופעל כברירת מחדל ומשתמש ב-hypervisor וב-SLAT כדי ליצור בידוד חזק מה-kernel. המאמר מסביר את המבנה של VTL...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מה זה task ב-Ada?
- task הוא יחידת ה-concurrency הבסיסית ב-Ada. הוא דומה ל-thread, אבל לא בהכרח ממופה אחד-לאחד ל-OS thread — ה-runtime של Ada מנהל את ה-scheduling. ברגע שמכריזים על task הוא מתחיל לרוץ מעצמו, וכשה-main procedure מסתיימת ממתינים באופן implicit לסיום של כל task שעדיין רץ. עם הקוד החיצוני הוא מתקשר באופן סינכרוני ב-rendezvous דרך entry. task ו-rendezvous הם חלק ממפרט השפה מאז Ada 83 ב-1983.
- במה protected object ב-Ada שונה מ-mutex?
- protected object הוא מנגנון mutual exclusion שהשפה מנהלת בעצמה; אין צורך לכתוב lock/unlock ידנית. function היא לקריאה בלבד, וכמה task יכולים לקרוא לה במקביל; procedure מיועדת לקריאה ולכתיבה, ובזמן שהיא רצה כל קריאה אחרת נחסמת; ל-entry יש תנאי barrier, והקורא נכנס לתור עד שהתנאי נהיה true. מימוש bounded buffer שב-C כותבים עם pthread mutex ו-condition variable מתכווץ ב-Ada לשורת barrier אחת כמו when Count < Buffer_Size.
- איך עובד rendezvous ב-Ada?
- rendezvous הוא מנגנון תקשורת סינכרונית בין task: קריאת entry מצד הקורא ו-accept מצד ה-task ממתינים זה לזה עד ששניהם מגיעים יחד לנקודת ה-rendezvous. עם מצבי פרמטר in/out/in out אפשר להעביר נתונים לשני הכיוונים. בלוק do...end בגוף ה-accept הוא critical section: בזמן שהוא רץ הקורא חסום, וה-task לא מקבל entry אחר. בשילוב עם select אפשר לכתוב באופן דקלרטיבי המתנה לכמה entry, timeout, וגם תנאי guard.
- מה אסור לעשות בתוך protected object?
- כל דבר שחוסם לזמן ארוך: delay, I/O איטי, או קריאות כבדות לספריות חיצוניות. delay ו-I/O מסוים בתוך פעולה מוגנת הם bounded error לפי תקן Ada, ובהתאם למימוש עלולים להוביל ל-Program_Error או ל-deadlock — לכן צריך לסלק אותם לגמרי, לא רק לצמצם. כלל הברזל: לעדכן מצב בקצרה בלבד בתוך ה-protected object, להריץ חישוב כבד או I/O בחוץ, ולהחזיר פנימה רק את התוצאה במהירות.