בכל רגע נתון פועלות במחשב עשרות ולעיתים מאות תוכנות ומשימות.
הדפדפן פתוח, מוזיקה מתנגנת, קובץ יורד מהרשת, מערכת ההפעלה בודקת עדכונים ועורך הקוד מריץ בדיקות ברקע.
כדי להבין כיצד כל זה מתרחש, צריך להכיר שני מושגים בסיסיים:
- Process — תהליך.
- Thread — תהליכון.
למרות שהם קשורים זה לזה, הם אינם אותו דבר.
תוכנית אינה תהליך
כל עוד תוכנה כמו Chrome, VS Code או Spotify אינה פועלת, היא בסך הכול קוד ונתונים המאוחסנים בדיסק.
Program = תוכנית המאוחסנת בדיסק
Process = מופע פעיל של התוכניתאפשר לחשוב על תוכנית כעל מתכון ועל process כהכנת המנה בפועל. אותו מתכון יכול לשמש כמה פעמים, ובאותו אופן אותה תוכנה יכולה ליצור כמה processes במקביל.
מה מערכת ההפעלה מחזיקה עבור process?
Process אינו רק קוד שרץ. מערכת ההפעלה מנהלת עבורו מידע ומשאבים כגון:
- מזהה process.
- מרחב זיכרון וירטואלי.
- קבצים פתוחים וחיבורי רשת.
- הרשאות ומשתני סביבה.
- מצב הריצה.
- threads השייכים אליו.
לדוגמה:
Process: chrome
PID: 8421
User: evyatar
State: Running
Memory: 350 MBהמידע הזה מאפשר למערכת ההפעלה לנהל כל process בנפרד.
מהו PID?
לכל process מוקצה מספר בשם PID, קיצור של Process ID:
Chrome PID 4210
VS Code PID 8351
Spotify PID 9012ה-PID מאפשר לבדוק את מצב התהליך, לעקוב אחר צריכת המשאבים שלו, לשלוח לו signal או לסיים אותו.
שני processes פעילים לא יקבלו אותו PID באותו רגע. לאחר שאחד מהם מסתיים, מערכת ההפעלה יכולה למחזר את המספר. לכן PID הוא מזהה זמני של מופע פעיל, לא מזהה קבוע של תוכנה.
למה אותה תוכנה מופיעה כמה פעמים?
Chrome, למשל, יכול להפעיל processes נפרדים עבור התהליך הראשי, לשוניות, הרחבות, גרפיקה, רשת ורכיבים מבודדים.
היתרון הוא בידוד. אם לשונית אחת קורסת, ייתכן ששאר הדפדפן ימשיך לפעול. תהליך נפרד יכול גם לעזור להגביל קוד לא מהימן ולמנוע ממנו גישה ישירה לזיכרון של רכיבים אחרים.
המחיר הוא צריכת זיכרון גבוהה יותר ותקשורת מורכבת יותר בין processes.
Parent ו-Child Processes
Process יכול ליצור process אחר. היוצר נקרא parent והנוצר נקרא child:
Terminal
└── Python
└── Worker Processלכל אחד יש PID משלו. ל-child יש בדרך כלל גם PPID, כלומר Parent Process ID:
Terminal PID: 1200
Python PID: 1350
Python PPID: 1200המשמעות היא ש-process מספר `1350` נוצר על ידי process מספר `1200`.
מהו Thread?
Process הוא המסגרת שמחזיקה את המשאבים, והעבודה מתבצעת באמצעות threads.
אפשר לחשוב על process כמשרד ועל threads כעובדים בתוכו. העובדים משתמשים באותו משרד ובאותם משאבים, אבל יכולים לבצע משימות שונות:
Thread 1 → מציג את ממשק המשתמש
Thread 2 → מוריד מידע מהרשת
Thread 3 → שומר קובץ
Thread 4 → מבצע חישוב ברקעProcess פעיל מכיל לפחות thread אחד.
מה threads חולקים?
Threads בתוך אותו process חולקים בדרך כלל:
- את מרחב הכתובות וה-heap.
- קבצים פתוחים וחיבורי רשת.
- הרשאות ומשאבי process אחרים.
לכל thread יש גם מצב פרטי משלו:
- stack של קריאות.
- registers.
- program counter.
- מצב ריצה.
- thread-local storage, כאשר משתמשים בו.
השיתוף מאפשר תקשורת מהירה, אבל הוא גם מאפשר ל-thread אחד לפגוע בנתונים ש-thread אחר משתמש בהם.
Race Condition
נניח ששני threads מנסים להגדיל את אותו משתנה:
counter = 10כל אחד קורא את הערך, מוסיף אחד וכותב את התוצאה. אם שניהם קוראים `10` לפני שאחד הספיק לכתוב, שניהם יחשבו `11` ושניהם יכתבו `11`.
התוצאה הסופית תהיה `11`, למרות ששתי פעולות התרחשו והציפייה הייתה ל-`12`.
זהו race condition: התוצאה תלויה בתזמון המדויק של threads ולכן הבאג עשוי להופיע רק לפעמים.
כיצד מתאמים גישה משותפת?
מנגנונים נפוצים כוללים:
- Lock או mutex.
- Semaphore.
- Atomic operations.
- Monitor.
- Message queue.
המטרה היא להבטיח שרק thread מתאים יוכל לשנות משאב משותף בזמן מסוים, או להימנע משיתוף ישיר באמצעות העברת הודעות.
גם לתיאום יש מחיר: המתנה, האטה, מורכבות וסיכון ל-deadlock. לכן יצירת threads היא רק תחילת התכנון; צריך להחליט גם מי מחזיק את המידע וכיצד הוא משתנה.
מהו Context Switch?
במחשב יכולים להיות מאות threads, אבל מספר ליבות המעבד מוגבל. מערכת ההפעלה מחלקת את זמן המעבד ביניהם.
כאשר היא מפסיקה להריץ thread אחד ומתחילה להריץ אחר, היא צריכה לשמור את המצב הנוכחי, לבחור thread אחר, לטעון את מצבו ולהמשיך ממנו. הפעולה נקראת context switch.
המעברים מהירים ולכן נדמה שהכול פועל יחד, אבל הם אינם חינמיים. הם צורכים זמן מעבד ועלולים לפגוע ביעילות ה-CPU cache. מעבר בין processes עשוי לדרוש גם החלפת הקשר של מרחב הכתובות.
לכן יותר threads אינם מבטיחים יותר ביצועים.
מצבי ריצה
Process או thread יכולים להימצא בכמה מצבים עיקריים:
- Running — רץ כרגע על המעבד.
- Ready או Runnable — מוכן לרוץ ומחכה לזמן מעבד.
- Waiting או Blocked — מחכה לאירוע או למשאב.
- Terminated — סיים את פעולתו.
Thread שממתין לרשת, לדיסק, ל-lock או לקלט משתמש אינו צריך לצרוך זמן מעבד בזמן ההמתנה. מערכת ההפעלה יכולה להריץ משימה אחרת במקומו.
Concurrency לעומת Parallelism
Concurrency פירושה שכמה משימות מתקדמות באותו פרק זמן. בליבה אחת זה יכול להיראות כך:
Thread A רץ מעט
Thread B רץ מעט
Thread C רץ מעט
Thread A ממשיךParallelism פירושו שכמה משימות רצות ממש באותו רגע:
Core 1 → Thread A
Core 2 → Thread B
Core 3 → Thread CConcurrency אינה מחייבת parallelism. מערכת יכולה לנהל עבודה מקבילית בזמן גם כאשר רק משימה אחת רצה בכל רגע.
מתי לבחור Process?
נעדיף process נפרד כאשר חשוב לנו בידוד:
- רכיב שקריסתו לא צריכה להפיל את כל המערכת.
- קוד לא מהימן.
- שירות שצריך הרשאות שונות.
- עבודה שצריכה מרחב זיכרון נפרד.
- משימה שניתן להריץ ולנהל באופן עצמאי.
התועלת היא בידוד זיכרון, אבטחה, הפרדת הרשאות וקריסה מקומית יותר. המחיר הוא יותר זיכרון, יצירה כבדה יותר ותקשורת שדורשת IPC.
מתי לבחור Thread?
נעדיף thread כאשר משימות שייכות לאותה אפליקציה וצריכות לשתף מידע במהירות, למשל ממשק משתמש ועבודה ברקע, הורדת נתונים, עיבוד תמונות או טיפול בבקשות.
Threads מציעים שיתוף מהיר ועלות יצירה נמוכה יחסית, אבל הם מביאים race conditions, deadlocks וקושי באיתור באגים. שגיאת native memory חמורה ב-thread אחד יכולה להפיל את ה-process כולו; לעומת זאת, exception רגיל בשפות מסוימות עשוי לסיים רק את ה-thread, בהתאם ל-runtime ולמדיניות האפליקציה.
כיצד processes מתקשרים?
לכל process יש בדרך כלל מרחב כתובות נפרד, ולכן הוא אינו יכול פשוט לקרוא משתנה של process אחר. התקשורת מתבצעת באמצעות IPC, קיצור של Inter-Process Communication:
- Pipes.
- Sockets.
- Shared memory.
- Message queues.
- Signals.
- Files.
- RPC.
כל מנגנון מציע איזון אחר בין מהירות, בידוד, אמינות, מורכבות ואבטחה.
צפייה בתהליכים וסיום process
ב-Linux וב-macOS אפשר להשתמש ב:
ps -ef
topבמערכות שבהן הוא מותקן אפשר להשתמש גם ב-`htop`. ב-Windows קיימים Task Manager והפקודה `tasklist`.
כדי לבקש מתהליך להסתיים בצורה מסודרת:
kill <PID>ברירת המחדל היא בדרך כלל `SIGTERM`. התהליך יכול ללכוד את ה-signal, לשמור מידע, לסגור קבצים וחיבורים ולבצע cleanup.
אם הוא אינו מגיב, אפשר להסלים ל:
kill -9 <PID>הפקודה שולחת `SIGKILL`. התהליך אינו יכול ללכוד או להתעלם ממנו ואינו מקבל הזדמנות לבצע cleanup. לכן משתמשים בו כמוצא אחרון.
Zombie ו-Orphan
כאשר child process מסתיים, מערכת ההפעלה שומרת רשומה קטנה עם סטטוס הסיום שלו. ה-parent אמור לאסוף אותה באמצעות פעולה כמו `wait`.
אם ה-child הסתיים וה-parent עדיין לא אסף את התוצאה, הוא נשאר זמנית כ-zombie. הוא אינו מריץ קוד ובדרך כלל אינו מחזיק זיכרון עבודה רגיל, אבל הוא כן תופס רשומה בטבלת התהליכים.
Orphan הוא המצב ההפוך: ה-parent הסתיים אבל ה-child עדיין רץ. במערכות Unix תהליך מערכת או subreaper מאמץ בדרך כלל את ה-orphan ואוסף את סטטוס הסיום שלו בהמשך. Orphan אינו בהכרח תקלה; לעיתים תהליך נועד להמשיך לפעול אחרי מי שהפעיל אותו.
Zombie = ה-child הסתיים, ה-parent עדיין לא אסף אותו
Orphan = ה-parent הסתיים, ה-child עדיין רץמהו Thread Pool?
יצירה והרס חוזרים של threads עלולים להיות יקרים. Thread pool מחזיק מספר workers ומעביר אליהם משימות מתור:
- יוצרים מספר threads.
- מכניסים משימות לתור.
- כל worker לוקח משימה פנויה.
- לאחר הסיום הוא חוזר לקחת משימה אחרת.
כך אפשר להגביל את כמות ה-threads, לחסוך עלויות יצירה ולשלוט בעומס.
Pool קטן מדי יוצר תור ארוך. Pool גדול מדי יוצר context switches ותחרות. גם pool בגודל נכון אינו מספיק אם התור בלתי מוגבל: במקרה של עומס, המשימות עלולות להצטבר בזיכרון. לכן לעיתים צריך תור מוגבל, backpressure או מדיניות דחייה.
CPU-Bound לעומת I/O-Bound
משימת CPU-bound מבלה את רוב זמנה בחישוב, כמו עיבוד וידאו, הצפנה או חישובים מתמטיים. כאן רמת המקביליות היעילה קשורה בדרך כלל למספר הליבות ולמאפייני העבודה.
משימת I/O-bound מבלה זמן רב בהמתנה לרשת, דיסק, מסד נתונים או קלט. כאן אפשר לעיתים לנהל יותר משימות במקביל משום שחלק גדול מהזמן הן אינן משתמשות במעבד.
אין מספר threads אחד שמתאים לכל תוכנה. צריך למדוד throughput, latency, שימוש במעבד, זמן המתנה, זיכרון ומגבלות השירותים שמולנו.
Deadlock בקצרה
Deadlock נוצר כאשר threads מחכים זה לזה ואיש מהם אינו יכול להתקדם:
Thread A מחזיק Lock 1 ומחכה ל-Lock 2
Thread B מחזיק Lock 2 ומחכה ל-Lock 1המערכת אינה בהכרח קורסת, אבל העבודה נעצרת.
אפשר לצמצם את הסיכון באמצעות סדר קבוע ללקיחת locks, הימנעות מנעילות מיותרות, צמצום זמן החזקת lock, timeout כאשר הוא מתאים ותכנון שמקטין שיתוף mutable state.
סיכום
תוכנית היא קוד המאוחסן בדיסק. Process הוא מופע פעיל שלה שמערכת ההפעלה מנהלת. Thread הוא יחידת ביצוע בתוך ה-process.
- לכל process יש PID ומשאבים משלו.
- Process יכול ליצור child processes.
- Threads באותו process חולקים זיכרון ומשאבים, אך מחזיקים stack ומצב CPU נפרדים.
- השיתוף מהיר אך עלול ליצור race conditions ו-deadlocks.
- Context switch מאפשר למערכת לחלק זמן מעבד בין משימות.
- Concurrency אינה בהכרח parallelism.
- Process מציע בידוד; thread מציע שיתוף מהיר.
- IPC מאפשר ל-processes נפרדים לתקשר.
- `SIGTERM` מאפשר סיום מסודר ו-`SIGKILL` מסיים בכוח.
- Zombie הסתיים אך טרם נאסף; orphan ממשיך אחרי שה-parent הסתיים.
- Thread pool מגביל workers, אך גם התור שלו זקוק למדיניות עומס.
הבחירה הנכונה אינה "כמה שיותר processes או threads". היא איזון בין ביצועים, בידוד, אבטחה, צריכת זיכרון ומורכבות.