Concurrency בטוח ב-Ada — מדריך מעשי ל-task ול-protected object

· עודכן בתאריך: · · Ada, Concurrency, Tasking, Protected Objects, Rendezvous, Real-Time, Parallel Programming, Programming Language, מקביליות, אמינות גבוהה

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 סטטי.

knowledge map: task ו-protected object ב-Adaתרשים שמראה ש-task ב-Ada מתקשר באופן סינכרוני ב-rendezvous, ש-selective accept ממתין לכמה entry תוך הימנעות מ-deadlock, ש-protected object מבטיח mutual exclusion דרך barrier ומונע data race, וש-Priority Ceiling Protocol ופרופיל Ravenscar תומכים ב-real-time.דורשדורשמשתמש במשתמש במונעמשתמש במונעעלול לגרום לעלול לגרום לעלול לגרום למונעדורשמשתמש במשתמש במומלץ למפחיתדורשעלול לגרום למוגדר במפחיתAda taskprotected objectAdarendezvousselective accept (select)deadlockbarrier (תנאי when של protected entry)data raceerroneous executionbounded errorPriority Ceiling Protocolpriority inversionpriority של task (pragma Priority)Rate Monotonic Scheduling (RMS)Ravenscar profile

ב-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 אחר. כשהעיבוד מסתיים שני הצדדים ממשיכים.

בציר הזמן, ההמתנה נראית כך.

זרימת rendezvous בין הקורא ל-Worker taskהקורא קורא ל-Worker.Compute ונחסם עד accept; גוף ה-accept רץ כ-critical section; ב-end Compute שני הצדדים ממשיכים יחדWorker taskהקוראWorker taskהקוראנחסם עד שמגיעים ל-acceptrendezvous מתקיים / גוף ה-accept רץב-end Compute שני הצדדים ממשיכים יחדקורא ל-Worker.Computeמגיע ל-accept Compute ... doכותב את התוצאה ל-out parameter Resultהמשך העיבודהמשך העיבוד

מי שמגיע קודם ממתין. אם הקורא קודם, הוא עוצר עד שמגיעים ל-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 ריק, בציר הזמן זה נראה כך.

חישוב מחדש של barrier ב-protected object של bounded bufferGet מגיע ל-buffer ריק וממתין בתור ה-entry; אחרי Put ה-barrier מחושב מחדש בסוף הפעולה המוגנת ו-Get רץProducer taskprotected object BufConsumer taskProducer taskprotected object BufConsumer taskממתין בתור ה-entry של Getבסוף הפעולה המוגנת מעריכים מחדש barrier של entry שמחכיםקורא ל-Getמעריך barrier האם Count גדול מ-0 → falseקורא ל-Putמעריך barrier האם Count קטן מ-Buffer_Size → trueמריץ את גוף Put / Count נהיה 1ה-barrier של Get נהיה trueמריץ את גוף 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(&not_full, &mutex); // המתנה ב-barrier
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(&not_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 מחזיקה אותה במפרט השפה כבר ארבעים שנה.

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

מה זה 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 בחוץ, ולהחזיר פנימה רק את התוצאה במהירות.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג