המחבר בחר ב- Open Sourcing Mental Illness לקבל תרומה כחלק מתוכנית Write for Donations.
הקדמה
Node.js מריץ קוד JavaScript בתהליך יחיד, כלומר הקוד שלך יכול לבצע רק משימה אחת בכל פעם. בכל זאת, Node.js עצמה היא רב-תהליך ומספקת תהליכים נסתרים דרך הספרייה libuv, המטפלת בפעולות הקלט/פלט כגון קריאת קבצים מדיסק או בקשות רשת. דרך שימוש בתהליכים נסתרים, Node.js מספקת שיטות אסינכרוניות שמאפשרות לקוד שלך לבצע בקשות קלט/פלט מבלי לחסום את התהליך הראשי.
למרות ש- Node.js מציעה תהליכים נסתרים, אינך יכול להשתמש בהם כדי לפנות משימות הדורשות יכולת מעבד גבוהה במיוחד, כגון חישובים מורכבים, שינוי גודל תמונות או דחיסת וידאו. מאחר ש-JavaScript היא תהליך יחיד, כאשר משימת עיבוד מרובה המשתמשת במעבד רץ, היא חוסמת את התהליך הראשי ואין קוד אחר שמתבצע עד שהמשימה מסתיימת. בלעדי שימוש בתהליכים אחרים, הדרך היחידה להאיץ את המשימות הקשורות למעבד היא להגדיל את מהירות המעבד.
אך, בשנים האחרונות, מעבדי המחשב לא היו מתקדמים מהר יותר. במקום זאת, מחשבים מגיעים עם ליבות נוספות, וכעת נפוץ יותר שמחשבים יהיו בהם 8 ליבות או יותר. למרות הטרנד הזה, הקוד שלך לא יעשה שימוש בליבות הנוספות במחשב שלך כדי להאיץ משימות של המעבד או למנוע שבירת הליבה הראשית כיוון ש-JavaScript הוא תהליך יחיד.
כדי לתקן את זה, Node.js הכניס את מודול ה־worker-threads
, שמאפשר לך ליצור תהליכים ולבצע משימות רבות ב-JavaScript במקביל. לאחר שתהליך מסיים משימה, הוא שולח הודעה לליבה הראשית שמכילה את תוצאת הפעולה כך שניתן יהיה להשתמש בה עם חלקים אחרים של הקוד. היתרון בשימוש בתהליכי עובד הוא שמשימות מוגבלות במעבד אינן חוסמות את ליבה הראשית וניתן לחלק ולהפיץ משימה למספר עובדים כדי לאופטימז אותה.
במדריך זה, תיצור אפליקציית Node.js עם משימת עומס במעבד שחוסמת את ליבה הראשית. לאחר מכן, תשתמש במודול worker-threads
כדי להעביר את משימת העומס במעבד לתהליך אחר כדי למנוע חסימת הליבה הראשית. לבסוף, תחלק את משימת המעבד ותפעיל ארבעה תהליכים שיעבדו עליה במקביל כדי להאיץ את המשימה.
דרישות קדם
כדי להשלים מדריך זה, תצטרך:
-
מערכת רב־ליבית עם ארבעה יחידות עיבוד או יותר. ניתן עדיין למעשה את המדריך משלב 1 עד 6 על מערכת דו-ליבית. אך, השלב 7 דורש ארבעה ליבות כדי לראות שיפורים בביצועים.
-
סביבת פיתוח Node.js. אם אתה ב-Ubuntu 22.04, התקן את הגרסה האחרונה של Node.js על פי השלב 3 ב-איך להתקין את Node.js על Ubuntu 22.04. אם אתה במערכת הפעלה אחרת, ראה את איך להתקין את Node.js וליצור סביבת פיתוח מקומית.
-
הבנה טובה של לולאת הארועים, קולבקים והבטחות ב-JavaScript, שאתה יכול למצוא במדריך שלנו, הבנת לולאת הארועים, קולבקים, הבטחות ו-Async/Await ב-JavaScript.
-
ידע בסיסי על איך להשתמש בספריית Express לפיתוח אינטרנטי. בדוק את מדריך שלנו, איך להתחיל עם Node.js ו-Express.
הגדרת הפרויקט והתקנת תלות
בשלב זה, תיצור את ספריית הפרויקט, תאתחל את npm, ותתקין את כל התלויות הדרושות.
להתחלה, יש ליצור ולהיעבור אל תיקיית הפרויקט:
- mkdir multi-threading_demo
- cd multi-threading_demo
הפקודה mkdir
יוצרת תיקייה, והפקודה cd
משנה את תיקיית העבודה לאחת החדשות שנוצרה.
לאחר מכן, יש לאתחל את תיקיית הפרויקט עם npm באמצעות הפקודה npm init
:
- npm init -y
האפשרות -y
מקבלת את כל האפשרויות הברירת מחדל.
כאשר הפקודה רצה, הפלט יראה דומה לזה:
Wrote to /home/sammy/multi-threading_demo/package.json:
{
"name": "multi-threading_demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
לבסוף, יש להתקין את express
, מסגרת פיתוח אינטרנטית של Node.js:
- npm install express
תשתמש ב-Express כדי ליצור אפליקציית שרת שיש בה נקודות קצה חסינות ולא חסינות.
Node.js מגיע עם מודול ה-worker-threads
כבר תוך ברירת מחדל, כך שאין צורך להתקין אותו.
כעת התקנת את החבילות הנדרשות. לבאר את התהליכים והתהליכים הקבועים והלא קבועים ואיך הם מבוצעים במחשב.
הבנת תהליכים וחוטים
לפני שתתחיל לכתוב משימות המכולות במעבד ולהעביר אותן לחוטים נפרדים, עליך להבין מהם תהליכים וחוטים, וההבחנה ביניהם. המרכז המרכזי הוא לראות איך התהליכים והחוטים מתבצעים על מערכת מחשב יחידה או מרובה ליבות.
תהליך
A process is a running program in the operating system. It has its own memory and cannot see nor access the memory of other running programs. It also has an instruction pointer, which indicates the instruction currently being executed in a program. Only one task can be executed at a time.
כדי להבין זאת, תיצור תוכנית Node.js עם לולאה אינסופית כך שהיא לא תיצא בעת הרצתה.
באמצעות nano
, או העורך הטקסט המועדף עליך, תיצור ותפתח את קובץ process.js
:
- nano process.js
בקובץ process.js
שלך, הזן את הקוד הבא:
const process_name = process.argv.slice(2)[0];
count = 0;
while (true) {
count++;
if (count == 2000 || count == 4000) {
console.log(`${process_name}: ${count}`);
}
}
בשורה הראשונה, מאפיין process.argv
מחזיר מערך המכיל את ארגומנטי שורת הפקודה של התוכנית. לאחר מכן, אתה מחבר את שיטת slice()
של JavaScript עם ארגומנט של 2
כדי ליצור העתק של המערך ממקום 2
והלאה. בכך אתה דולף על שני הארגומנטים הראשונים, שהם נתיב Node.js ושם הקובץ. לבסוף, אתה משתמש בתחביר הואך סוגר לקרוא את הארגומנט הראשון מהמערך שנחתך ולאחסן אותו במשתנה process_name
.
לאחר מכן, אתה מגדיר לולאת while
ומעביר לה תנאי true
כדי להריץ את הלולאה לנצח. בתוך הלולאה, משתנה ה-count
מתווסף ב-1
בכל איטרציה. לאחר מכן ישנה הצהרת if
שבודקת אם ה-count
שווה ל-2000
או ל-4000
. אם התנאי מתקיים, מתודת console.log()
מדפיסה הודעה בטרמינל.
שמור וסגור את הקובץ באמצעות CTRL+X
, ואז לחץ על Y
כדי לשמור את השינויים.
הרץ את התוכנית באמצעות פקודת ה-node
:
- node process.js A &
A
is a command-line argument that is passed to the program and stored in the process_name
variable. The &
at end the allows the Node program to run in the background, which lets you enter more commands in the shell.
כאשר אתה מריץ את התוכנית, תראה פלט דומה לזה:
Output[1] 7754
A: 2000
A: 4000
המספר 7754
הוא מזהה תהליך שהמערכת הפעילה הקצתה לו. A: 2000
ו- A: 4000
הם הפלט של התוכנית.
כאשר אתה מריץ תוכנית באמצעות פקודת ה-node
, אתה יוצר תהליך. המערכת הפעילה מקצה זיכרון עבור התוכנית, מתייחסת אל הקובץ הניתן להרצה בכונן הקשיח של המחשב שלך, וטוענת את התוכנית לזיכרון. לאחר מכן היא מקצה לה מזהה תהליך ומתחילה להריץ את התוכנית. ברגע זה, התוכנית שלך הופכת להיות תהליך.
כאשר התהליך פועל, מזהה התהליך שלו מתווסף לרשימת התהליכים של מערכת ההפעלה וניתן לראות אותו בעזרת כלים כמו htop
, top
, או ps
. הכלים מספקים פרטים נוספים על התהליכים, וגם אפשרויות לעצירה או העדפה להם.
כדי לקבל תקציר מהיר של תהליך Node, לחץ ENTER
בטרמינל כדי לקבל את הפרומט של פרומט. לאחר מכן, הפעל את הפקודה ps
כדי לראות את תהליכי ה-Node:
- ps |grep node
הפקודה ps
מפרטת את כל התהליכים הקשורים למשתמש הנוכחי במערכת. מפעיל את אופרטור הצינון |
כדי להעביר את כל פלט ה-ps
ל-grep
ולסנן את התהליכים כדי לרשום רק תהליכי Node.
הרצת הפקודה תחזיר פלט דומה לזה שלמטה:
Output7754 pts/0 00:21:49 node
אתה יכול ליצור תהליכים בלתי סופיים מתוך תכנית יחידה. לדוגמה, השתמש בפקודה הבאה כדי ליצור שלושה תהליכים נוספים עם ארגומנטים שונים ולהניח אותם ברקע:
- node process.js B & node process.js C & node process.js D &
בפקודה יצרת שלושה מופעים נוספים של תוכנית process.js
. הסימן &
מניח כל תהליך ברקע.
בעת הרצת הפקודה, הפלט יראה דומה לזה שלמטה (אף על פי שהסדר עשוי להשתנות):
Output[2] 7821
[3] 7822
[4] 7823
D: 2000
D: 4000
B: 2000
B: 4000
C: 2000
C: 4000
כפי שניתן לראות בפלט, כל תהליך רשם את שם התהליך לטרמינל כאשר המונה הגיע ל-2000
ול-4000
. כל תהליך לא מודע לשום תהליך אחר הרצים: התהליך D
לא מודע לתהליך C
, ולהיפך. כל מה שקורה בתהליך אחד לא ישפיע על תהליכי Node.js אחרים.
אם תבחן את הפלט בקרבה, תראה שסדר הפלט אינו זהה לסדר שהיה לך כאשר יצרת את שלושת התהליכים. בעת ביצוע הפקודה, טיב התכונות של התהליכים היה לפי הסדר של B
, C
ו־D
. אך כעת, הסדר הוא D
, B
ו־C
. הסיבה היא שבמערכת ההפצה יש אלגוריתמי סידור שמחליטים איזה תהליך להפעיל על יחידת המעבד בכל זמן נתון.
על מכונה עם ליבה אחת, התהליכים מבצעים באופן מקביל. זאת אומרת, מערכת ההפעלה מחליפה בין התהליכים בקצבים קבועים. לדוגמה, התהליך D
מבצע לזמן מוגבל, אז מצבו נשמר באיזשהו מקום ומערכת ההפעלה מקבעת להפעיל את התהליך B
לזמן מוגבל, וכן הלאה. התהליך ממשיך כך בהתחלה ובחזרה עד שכל המשימות הושלמו. מהפלט, יכול להיראות כאילו כל תהליך רץ עד סיומו, אך בפועל, מתגלגלת מערכת הפלט של המערכת ביניהם באופן קבוע.
על מערכת עם מרבית ליבות – בהנחה שיש לך ארבע ליבות – מערכת ההפעלה מקבעת שכל תהליך יופעל על כל ליבה באותו זמן. זה נקרא פרלליזם. אם עם זאת, תיצור ארבעה תהליכים נוספים (כך שיהיה לך שמונה בסך הכל), כל ליבה תפעיל שני תהליכים באופן מקביל עד שהם יסתיימו.
תהליכי תם
החוטים דומים לתהליכים: יש להם מחזיר הוראות משלהם ויכולים לבצע משימת JavaScript אחת בכל פעם. לעומת תהליכים, החוטים אין להם זיכרון משלהם. במקום זאת, הם מתגוררים בתוך זיכרון התהליך. כאשר אתה יוצר תהליך, ניתן ליצור מספר רב של חוטים בעזרת המודול worker_threads
שמבצעים קוד JavaScript במקביל. בנוסף, החוטים יכולים לתקשר זה עם זה באמצעות העברת הודעות או שיתוף נתונים בזיכרון התהליך. זה מפקיד אותם קלים לעומת התהליכים, מאחר וייצור חוט לא מבקש זיכרון נוסף ממערכת ההפעלה.
כאשר מדובר בביצוע של חוטים, הם מתנהגים בדיוק כמו תהליכים. אם יש לך מספר רב של חוטים הרצים על מערכת ליבה יחידה, מערכת ההפעלה תחליף ביניהם במרווחים רגילים, נותנת לכל חוט הזדמנות לבצע ישירות על יחידת המעבד היחידה. במערכת עם מספר ליבות, המערכת הפעילה תזמן את החוטים על כל הליבות ותבצע את קוד ה-JavaScript בו זמנית. אם תיצור יותר חוטים מאשר יש ליבות זמינות, כל ליבה תבצע מספר רב של חוטים בו זמנית.
עם זאת, לחץ ENTER
, ואז עצור את כל התהליכים של Node שרצים כעת באמצעות פקודת kill
:
- sudo kill -9 `pgrep node`
pgrep
מחזיר את זיהויי התהליכים של כל ארבעת תהליכי Node לפקודת kill
. האפשרות -9
מדריך את kill
לשלוח אות אותיות מחלה SIGKILL.
כאשר אתה מריץ את הפקודה, תראה פלט דומה לזה שלמטה:
Output[1] Killed node process.js A
[2] Killed node process.js B
[3] Killed node process.js C
[4] Killed node process.js D
לפעמים הפלט עשוי להתעכב ולהופיע כאשר אתה מפעיל פקודה אחרת מאוחר יותר.
עכשיו, כשאתה מבין את ההבדל בין תהליך לחוט, תעבוד עם חוטים חבויים ב-Node.js בפרק הבא.
הבנת חוטים חבויים ב-Node.js
Node.js אכן מספק חוטים נוספים, ולכן הוא נחשב לרב חוטי. בפרק זה, תבחן חוטים חבויים ב-Node.js, אשר עוזרים להפוך פעולות קלט/פלט ללא חוסמות.
כפי שנאמר בהקדמה, JavaScript הוא חד-חוטי וכל קוד ה-JavaScript מתבצע בחוט יחיד. זה כולל את קוד המקור של התוכנית שלך וספריות צד שלישי שאתה מכליל בתוכנית שלך. כאשר תוכנית מבצעת פעולת קלט/פלט כדי לקרוא קובץ או בקשת רשת, זה חוסם את החוט הראשי.
אך Node.js מיישמת את ספריית libuv
, אשר מספקת ארבעה תהליכים נוספים לתהליך של Node.js. עם התהליכים הללו, פעולות הקלט/פלט מטופלות בנפרד, וכאשר הן מסתיימות, לולאת האירועים מוסיפה את הקולבק המשוייך עם המשימה של פעולת הקלט/פלט בתורת המיקרו. כאשר הערימה של הקריאות בתהליך הראשי ריקה, הקולבק מונח על הערימה של הקריאות ואז הוא מתבצע. כדי להבהיר את זה, הקולבק המשוייך עם משימת הקלט/פלט הנתונה לא מתבצע במקביל; אך משימת העצמה של קריאת קובץ או בקשת רשת מתבצעת במקביל עם עזרת התהליכים. לאחר שמשימת הקלט/פלט מסתיימת, הקולבק רץ בתהליך הראשי.
בנוסף לתהליכים הארבעה הללו, מנוע ה-V8 מספק גם שני תהליכים לטיפול בדברים כמו איסוף אשפה אוטומטי. זה מביא לסך הכול שבעה תהליכים בתהליך: תהליך ראשי אחד, ארבעה תהליכי Node.js, ושני תהליכי V8.
כדי לאשר שכל תהליך של Node.js מכיל שבעה תהליכים, הריצו שוב את קובץ ה-process.js
והעבירו אותו לרקע:
- node process.js A &
הטרמינל תקלוט את מזהה התהליך, כמו גם את הפלט מהתוכנית:
Output[1] 9933
A: 2000
A: 4000
שימו לב למזהה התהליך במקום מסוים ולחצו ENTER
כדי שתוכלו להשתמש שוב בהזמנה.
כדי לראות את התהליכים, הריצו את הפקודה top
ועברו אליה את מזהה התהליך שמוצג בפלט:
- top -H -p 9933
-H
מורה ל-top
להציג תהליכים בתהליך. הדגל -p
מורה ל-top
למצוא את הפעילות בלבד במזהה התהליך שסופר.
כאשר אתם מפעילים את הפקודה, הפלט שלכם ייראה דומה לדוגמא הבאה:
Outputtop - 09:21:11 up 15:00, 1 user, load average: 0.99, 0.60, 0.26
Threads: 7 total, 1 running, 6 sleeping, 0 stopped, 0 zombie
%Cpu(s): 24.8 us, 0.3 sy, 0.0 ni, 75.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7951.2 total, 6756.1 free, 248.4 used, 946.7 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7457.4 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
9933 node-us+ 20 0 597936 51864 33956 R 99.9 0.6 4:19.64 node
9934 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node
9935 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.84 node
9936 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node
9937 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.93 node
9938 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node
9939 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node
כפי שניתן לראות בפלט, תהליך ה-Node.js מכיל שבעה תהליכים בסך הכול: תהליך ראשי אחד לביצוע קוד JavaScript, ארבעה תהליכי Node.js, ושני תהליכי V8.
כפי שדובר קודם, ארבעת תהליכי ה-Node.js משמשים לפעולות קלט/פלט כדי לעשות אותן ללא חסימה. הם עובדים טוב למשימה זו, ויצירת תהליכים בעצמך עבור פעולות קלט/פלט עשויה אף להרעין את ביצועי היישום שלך. הדבר אינו נכון בקשר למשימות המקושרות למעבד. משימה המקושרת למעבד לא משתמשת בתהליכים נוספים שזמינים בתהליך ומחסירה את התהליך הראשי.
כעת לחץ על q
כדי לצאת מהתוכנית top
ולעצור את תהליך ה-Node עם הפקודה הבאה:
- kill -9 9933
עכשיו שאתה מכיר את התהליכים בתהליך של Node.js, תכתוב משימה המקושרת למעבד בקטע הבא ותצפה איך היא משפיעה על התהליך הראשי.
יצירת משימה המקושרת למעבד בלי תהליכי עובד
בקטע זה, תבנה אפליקציית Express שיש לה מסלול לא מחסיר ומסלול שמוחסר שבו מתבצעת משימה המקושרת למעבד.
ראשית, פתח את index.js
בעורך הטקסט שלך:
- nano index.js
בקובץ index.js
שלך, הוסף את הקוד הבא כדי ליצור שרת בסיסי:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
בקטע הקוד הבא, אתה יוצר שרת HTTP באמצעות Express. בשורה הראשונה, אתה מייבא את המודול express
. לאחר מכן, אתה מגדיר את המשתנה app
כדי להחזיק במופע של Express. לאחר מכן, אתה מגדיר את המשתנה port
, שמחזיק במספר הפורט שבו השרת ישמיע.
לאחר מכן, אתה משתמש ב-app.get('/non-blocking')
כדי להגדיר את הנתיב שבו יש לשלוח בקשות מסוג GET
. לבסוף, אתה קורא לשיטה app.listen()
כדי להורות לשרת להתחיל להאזין בפורט 3000
.
בהמשך, מגדיר נתיב נוסף, /blocking/
, שיכיל משימה הכבאותית למעבד:
...
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
אתה מגדיר את הנתיב /blocking
באמצעות app.get("/blocking")
, המקבל קריאת חזרה אסינכרונית שמתחילה במשימה הכבאותית. בתוך הקריאה לחזרה, אתה יוצר לולאת for
שעוברת 20 מיליארד פעמים ובכל איטרציה, היא מגדילה את המשתנה counter
ב-1
. משימה זו רצה על המעבד ותקח כמה שניות להשלימה.
בנקודה זו, קובץ ה-index.js
שלך ייראה כך:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
שמור וצא מהקובץ, ואז התחל את השרת עם הפקודה הבאה:
- node index.js
כאשר אתה מפעיל את הפקודה, תראה פלט דומה לזה:
OutputApp listening on port 3000
זה מראה שהשרת רץ ומוכן לשרת.
עכשיו, בקר ב-http://localhost:3000/non-blocking
בדפדפן המועדף עליך. תראה תגובה מיידית עם ההודעה This page is non-blocking
.
**הערה:** אם אתה עוקב אחר המדריך בשרת רחוק, תוכל להשתמש בהפניית פורט כדי לבדוק את האפליקציה בדפדפן.
כאשר שרת Express עדיין רץ, פתח טרמינל נוסף על המחשב המקומי שלך והזן את הפקודה הבאה:
- ssh -L 3000:localhost:3000 your-non-root-user@yourserver-ip
בעת התחברות לשרת, נווט ל־http://localhost:3000/non-blocking
בדפדפן האינטרנט של המחשב המקומי שלך. שמור על הטרמינל השני פתוח לאורך שאר המדריך.
באשכול חדש, פתח כרטיסייה חדשה ובקר את http://localhost:3000/blocking
. בעוד שהעמוד נטען, פתח מהר שתי כרטיסיות נוספות ובקר שוב ב־http://localhost:3000/non-blocking
. תראה שאין תגובה מיידית, והעמודים ימשיכו לנסות להיטען. רק לאחר שנתיב ה־/blocking
מסיים להיטען ומחזיר תגובה result is 20000000000
, שאר הנתיבים יחזירו תגובה.
הסיבה שבגללה כל נתיבי ה־/non-blocking
לא עובדים כאשר נתיב ה־/blocking
נטען היא בגלל לולאת ה־for
הקשורה ב־CPU, שחוסמת את תהליך הראשי. כאשר תהליך הראשי חסום, Node.js לא יכול לשרת בקשות עד שהמשימה הקשורה ב־CPU תסיים. לכן אם לאפליקציה שלך יש אלפי בקשות GET סימולטניות לנתיב ה־/non-blocking
, ביקור אחד בלבד בנתיב ה־/blocking
מספיק כדי להפוך את כל נתיבי האפליקציה ללא תגובה.
אם נשים לב, חסימת תהליך הראשי יכולה לפגוע בחוויית המשתמש באפליקציה שלך. כדי לפתור את הבעיה הזו, יהיה עליך להעביר את המשימה הקשורה למעבד ה-CPU לתהליך אחר כדי שהתהליך הראשי יוכל להמשיך לטפל בבקשות HTTP אחרות.
עם זאת, עצור את השרת על ידי לחיצה על CTRL+C
. תתחיל שוב את השרת בחלק הבא לאחר שתבצע שינויים נוספים בקובץ index.js
. הסיבה שהשרת מופסק היא ש-Node.js לא מרענן באופן אוטומטי כשיש שינויים חדשים בקובץ.
עכשיו שהבנת את ההשפעה השלילית שיש למשימות הקשורות ל-CPU על האפליקציה שלך, אתה כעת תנסה למנוע חסימת התהליך הראשי על ידי שימוש ב-promises.
העברת משימה קשורה ל-CPU באמצעות Promises
בדרך כלל, כשמפתחים לומדים על השפעת החסימה ממשימות הקשורות ל-CPU, הם פונים ל-promises כדי להפוך את הקוד לא חוסם. האזן זו נגזרת מהידע בשימוש בשיטות I/O מבוססות על promises, כמו readFile()
ו- writeFile()
. אך כפי שלמדת, פעולות ה-I/O משתמשות בתהליכים נסתרים של Node.js, שאינם נעשים על ידי משימות הקשורות ל-CPU. עם זאת, בסעיף זה תעטוף את המשימה הקשורה ל-CPU ב-promises כניסיון לניסיון להפוך אותה ללא חוסמת. זה לא יעבוד, אך זה יעזור לך לראות את הערך שבשימוש בתהליכי עובדים, שאתה תעשה בקטע הבא.
פתח מחדש את קובץ index.js
בעורך שלך:
- nano index.js
בקובץ ה-index.js
שלך, הסר את הקוד המודגש המכיל את המשימה הקשות ב-CPU:
...
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
...
בשלב הבא, הוסף את הקוד המודגש הבא המכיל פונקציה שמחזירה מובטח:
...
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
res.status(200).send(`result is ${counter}`);
}
הפונקציה calculateCount()
מכילה כעת את החישובים שהיו לך בפונקציית הטיפול ב-/blocking
. הפונקציה מחזירה מובטח, שמאתחל בתחביר new Promise
. המובטח מקבל קולבק עם פרמטרי resolve
ו־reject
, שטופלים בהצלחה או כישלון. כאשר הלולאת for
מסיימת לרוץ, המובטח מפתח עם הערך במשתנה counter
.
בשלב הבא, קרא לפונקציית calculateCount()
בפונקציית טיפול ב־/blocking/
בקובץ ה-index.js
:
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
כאן אתה קורא לפונקציית calculateCount()
עם המילה המפתח await
לצד כדי לחכות למובטח להתקשר. לאחר שהמובטח מקבל, המשתנה counter
מוגדר לערך המוחזר.
הקוד השלם שלך כעת ייראה כך:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
שמור וצא מהקובץ שלך, ואז התחל את השרת שוב:
- node index.js
בדפדפן האינטרנט שלך, בקר בכתובת http://localhost:3000/blocking
וכאשר הוא נטען, רענן מהר את כרטיסיית ה־http://localhost:3000/non-blocking
. כפי שתראה, הנתיבים ה־non-blocking
עדיין מושפעים וכולם יחכו להשלמת טעינת הנתיב /blocking
. מכיוון שהנתיבים עדיין מושפעים, מובטחים לא מבצעים קוד JavaScript במקביל ולא ניתן להשתמש בהם כדי להפוך משימות שדורשות שימוש ב-CPU ללא חסינות.
עם זאת, עצר את שרת היישום עם CTRL+C
.
עכשיו שאתה יודע שהבטחות לא מספקות שום מנגנון להפוך משימות הקשורות למעבד למשימות לא בלוקינג, תשתמש ב-Node.js worker-threads
מודול כדי להעביר משימה קשורה למעבד לתהליך נפרד.
העברת משימה קשורה למעבד עם מודול worker-threads
במקטע זה, תעביר משימה אינטנסיבית מאוד למעבד לתהליך אחר באמצעות מודול worker-threads
כדי למנוע חסימה של התהליך הראשי. על מנת לעשות זאת, תיצור קובץ worker.js
שיכיל את המשימה האינטנסיבית למעבד. בקובץ index.js
, תשתמש במודול worker-threads
כדי לאתחל את התהליך ולהתחיל את המשימה בקובץ worker.js
כך שהיא תרוץ במקביל לתהליך הראשי. לאחר שהמשימה הושלמה, התהליך העובד ישלח הודעה המכילה את התוצאה חזרה לתהליך הראשי.
להתחלה, וודא שיש לך 2 או יותר ליבות באמצעות הפקודה nproc
:
- nproc
Output4
אם הוא מראה שתי ליבות או יותר, תוכל להמשיך לשלב זה.
בשלב הבא, צור ופתח את קובץ worker.js
בעורך הטקסט שלך:
- nano worker.js
בקובץ worker.js
שלך, הוסף את הקוד הבא כדי לייבא את מודול worker-threads
ולבצע את המשימה האינטנסיבית למעבד:
const { parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
השורה הראשונה טוענת את המודול worker_threads
ומחלצת את המחלקה parentPort
. המחלקה מספקת שיטות שבאפשרותך להשתמש בהן כדי לשלוח הודעות לתהליך הראשי. לאחר מכן, יש לך את המשימה המרבית בשימוש במעבד המרכזי שכרגע נמצאת בפונקצית calculateCount()
בקובץ index.js
. מאוחר יותר בשלב זה, תמחק את הפונקציה הזו מתוך index.js
.
לאחר מכן, הוסף את הקוד המודגש למטה:
const { parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
parentPort.postMessage(counter);
כאן אתה קורא לשיטת postMessage()
של המחלקה parentPort
, אשר שולחת הודעה לתהליך הראשי המכילה את תוצאת המשימה המבוססת על מעבד המרכזי המאוחסנת במשתנה counter
.
שמור וצא מהקובץ שלך. פתח את index.js
בעורך הטקסט שלך:
- nano index.js
מכיוון שכבר יש לך את המשימה המבוססת על מעבד המרכזי בתוך worker.js
, הסר את הקוד המודגש מתוך index.js
:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
באופן נוסף, בתוך ה- callback app.get("/blocking")
, הוסף את הקוד הבא כדי לאתחל את התהליך:
const express = require("express");
const { Worker } = require("worker_threads");
...
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
...
לראשונה, יש לייבא את המודול worker_threads
ולפתוח את המחלקה Worker
. בתוך ה- callback app.get("/blocking")
, אני יוצר מופע של המחלקה Worker
באמצעות המילה השמורה new
שעוקבת אחריה קריאה ל- Worker
עם נתיב הקובץ worker.js
כארגומנט שלו. פעולה זו יוצרת תהליך חדש והקוד בקובץ worker.js
מתחיל לרוץ בתהליך בליבה אחרת.
אחרי זאת, אתה מצרף אירוע למופע ה-worker
באמצעות שיטת on("message")
כדי להאזין לאירוע הודעה. כאשר הודעה מתקבלת המכילה את התוצאה מקובץ ה-worker.js
, היא מועברת כפרמטר לקולבק של השיטה, שמחזיר תגובה למשתמש המכילה את תוצאת משימת ה-CPU-bound.
לאחר מכן, אתה מצרף אירוע נוסף למופע של ה-worker באמצעות השיטה on("error")
כדי להאזין לאירוע שגיאה. אם אירעה שגיאה, הקולבק מחזיר תגובת 404
המכילה את ההודעת שגיאה חזרה למשתמש.
קובץ ה-
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
שלך יראה כעת כך:
- node index.js
שמור וצא מהקובץ שלך, ואז הפעל את השרת:
בקר בכרטיסיית http://localhost:3000/blocking
שוב בדפדפן האינטרנט שלך. לפני שהיא תסיים לטעון, רענן את כל הכרטיסיות של http://localhost:3000/non-blocking
. כעת אתה צריך להבחין שהן נטענות מיידית מבלי לחכות לסיום טעינת הנתיב /blocking
. זה קורה משום שמשימת ה-CPU-bound מועברת לתהליך אחר, והתהליך הראשי טופל בכל הבקשות הנכנסות.
עכשיו, עצור את השרת שלך באמצעות CTRL+C
.
שיפור משימה עמידה במעבד באמצעות ארבעה תהליכי עובד
בסעיף זה, תחלק את משימת העומס במעבד לבין ארבעה תהליכי עובד כך שיהיה ניתן לסיים את המשימה במהירות ולקצר את זמן הטעינה של הנתיב /blocking
.
כדי להשיג יותר תהליכי עובד העובדים על אותה משימה, עליך לחלק את המשימות. מאחר והמשימה כוללת לולאה של 20 מיליארד פעמים, עליך לחלק 20 מיליארד במספר התהליכים שברצונך להשתמש בהם. במקרה זה, זהו 4
. חישוב 20_000_000_000 / 4
ייתן תוצאה של 5_000_000_000
. לכן כל תהליך יעבור בלולאה מ־0
עד 5_000_000_000
ויגדיל את counter
ב־1
. כאשר כל תהליך מסיים, הוא ישלח הודעה לתהליך הראשי המכילה את התוצאה. לאחר שהתהליך הראשי מקבל הודעות מכל ארבעת התהליכים בנפרד, תשלב את התוצאות ותשלח תגובה למשתמש.
באפשרותך גם להשתמש בגישה זו אם יש לך משימה שעוברת על מערכות מערכים גדולות. לדוגמה, אם ברצונך לשנות את גודלם של 800 תמונות בספרייה, תוכל ליצור מערך המכיל את כל נתיבי הקבצים של התמונות. לאחר מכן, תחלק 800
ב־4
(מספר התהליכים) ותקצה לכל תהליך לעבוד על טווח. התהליך הראשון ישנה את גודל התמונות מאינדקס המערך 0
עד 199
, התהליך השני מאינדקס 200
עד 399
, וכן הלאה.
First, verify that you have four or more cores:
- nproc
Output4
עשה עותק של קובץ worker.js
באמצעות פקודת cp
:
- cp worker.js four_workers.js
קבצי ה־index.js
ו־worker.js
הנוכחיים יישארו בלתי פגועים כך שתוכל להפעיל אותם שוב כדי להשוות את ביצועיהם לאחר שינויים במקטע זה מאוחר יותר:
לאחר מכן, פתח את קובץ ה־four_workers.js
בעורך הטקסט שלך:
- nano four_workers.js
בקובץ ה־four_workers.js
שלך, הוסף את הקוד המודגש לייבוא את אובייקט ה־workerData
:
const { workerData, parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000 / workerData.thread_count; i++) {
counter++;
}
parentPort.postMessage(counter);
לכלי את אובייקט ה־WorkerData
, המכיל את הנתונים שנשלחים מהלשונית הראשית כאשר הלשונית מתאתחלת (שתעשה בקרוב בקובץ ה־index.js
). לאובייקט יש מאפיין של thread_count
המכיל את מספר הלשוניות, שהוא 4
. לאחר מכן בלולאת ה־for
, הערך 20_000_000_000
מתחלק ב־4
, תוצאת החילוק היא 5_000_000_000
:
שמור וסגור את הקובץ שלך, ואז העתק את קובץ ה־index.js
:
- cp index.js index_four_workers.js
פתח את קובץ ה־index_four_workers.js
בעורך שלך:
- nano index_four_workers.js
בקובץ ה־index_four_workers.js
שלך, הוסף את הקוד המודגש ליצירת מופע של לשונית:
...
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
...
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
});
}
app.get("/blocking", async (req, res) => {
...
})
...
לפני כל דבר, הגדר את קבוע ה־THREAD_COUNT
המכיל את מספר הלשוניות שברצונך ליצור. בעת שיש לך עוד ליבות על השרת שלך, סקלינג יכלול שינוי בערך של THREAD_COUNT
למספר הלשוניות שברצונך להשתמש בהן.
התוך createWorker()
יוצר ומחזיר הבטחה. בתוך קולבק ההבטחה, אתה מאתחל תהליך חדש על ידי מעבר למחלקת Worker
את נתיב הקובץ לקובץ four_workers.js
כארגומנט הראשון. אז אתה מעביר אובייקט כארגומנט השני. לאחר מכן, אתה משייך לאובייקט את המאפיין workerData
שיש לו אובייקט נוסף כערך שלו. לבסוף, אתה משייך לאובייקט את מאפיין thread_count
שערכו הוא מספר התהליכים בקבוע THREAD_COUNT
. האובייקט workerData
הוא אותו שהתייחסת אליו בקובץ workers.js
לפני כן.
כדי לוודא שההבטחה מסיימת או מזריקה שגיאה, הוסף את השורות המודגשות הבאות:
...
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
worker.on("message", (data) => {
resolve(data);
});
worker.on("error", (msg) => {
reject(`An error ocurred: ${msg}`);
});
});
}
...
כאשר התהליך של העובד שולח הודעה לתהליך הראשי, ההבטחה מסיימת עם הנתונים שנשלחו. אך אם מתרחשת שגיאה, ההבטחה מחזירה הודעת שגיאה.
עכשיו שהגדרת את הפונקציה שמאתחלת תהליך חדש ומחזירה את הנתונים מהתהליך, תשתמש בפונקציה בתוך app.get("/blocking")
כדי לייצר תהליכים חדשים.
אבל תחילה, הסר את הקוד המודגש הבא, מאחר שכבר הגדרת את הפונקציונליות הזו בפונקציה createWorker()
:
...
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error ocurred: ${msg}`);
});
});
...
עם הקוד שנמחק, הוסף את הקוד הבא כדי לאתחל ארבעה תהליכי עבודה:
...
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
});
...
ראשית, צור משתנה בשם workerPromises
, המכיל מערך ריק. לאחר מכן, חזור בלולאה כמספר הופעות של THREAD_COUNT
, שהוא 4
. בכל התרחשות של הלולאה, הפעל את הפונקציה createWorker()
כדי ליצור תהליך חדש. לאחר מכן, הכנס את אובייקט ה- promise שהפונקציה מחזירה אל תוך מערך workerPromises
באמצעות מתודת ה- push
של JavaScript. כשהלולאה מסתיימת, למערך workerPromises
יהיו ארבעה promise objects, כל אחד מתוך קריאה לפונקציה createWorker()
ארבע פעמים.
עכשיו, הוסף את הקוד המסומן למטה כדי לחכות לפתרון ה promises ולהחזיר תגובה למשתמש:
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
const thread_results = await Promise.all(workerPromises);
const total =
thread_results[0] +
thread_results[1] +
thread_results[2] +
thread_results[3];
res.status(200).send(`result is ${total}`);
});
מכיוון שבמערך workerPromises
יש promises שהתקבלו מקריאה ל createWorker()
, הקדם את מתודת ה- Promise.all()
בתחביב ה- await
וקרא למתודת ה- all()
עם workerPromises
כארגומנט. מתודת ה- Promise.all()
מחכה שכל הpromises במערך יתקבלו. כאשר זה קורה, המשתנה thread_results
מכיל את הערכים שהpromises פתרו. מאחר והחישובים הופצו בין ארבעה תהליכים, הוסף את כל הערכים יחד על ידי קבלת כל ערך מ dent thread_results
באמצעות תחביב הסוגריים. לאחר הוספתם, החזר את הערך הכולל לדף.
כעת, הקובץ השלם שלך צריך להיראות כך:
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
worker.on("message", (data) => {
resolve(data);
});
worker.on("error", (msg) => {
reject(`An error ocurred: ${msg}`);
});
});
}
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
const thread_results = await Promise.all(workerPromises);
const total =
thread_results[0] +
thread_results[1] +
thread_results[2] +
thread_results[3];
res.status(200).send(`result is ${total}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
שמור וסגור את הקובץ. לפני שתפעיל את הקובץ הזה, הרץ תחילה את index.js
כדי למדוד את זמן התגובה שלו:
- node index.js
הבא, פתח טרמינל חדש על המחשב המקומי שלך והזן את הפקודה הבאה curl
, שמודדת כמה זמן נדרש כדי לקבל תגובה מנתיב /blocking
:
- time curl --get http://localhost:3000/blocking
פקודת time
מודדת כמה זמן הפקודה curl
רצה. הפקודה curl
שולחת בקשת HTTP ל-URL הנתון והאפשרות --get
מורה ל-curl
לבצע בקשת GET
.
כאשר הפקודה רצה, הפלט שלך יראה דומה לזה:
Outputreal 0m28.882s
user 0m0.018s
sys 0m0.000s
הפלט המודגש מראה שנדרשות כ-28 שניות כדי לקבל תגובה, שעשויה להשתנות במחשב שלך.
הבא, עצור את השרת עם CTRL+C
והרץ את קובץ ה-index_four_workers.js
:
- node index_four_workers.js
בקר בנתיב /blocking
שוב בטרמינל השני שלך:
- time curl --get http://localhost:3000/blocking
תראה פלט עקבי עם משהו דומה לזה:
Outputreal 0m8.491s
user 0m0.011s
sys 0m0.005s
הפלט מראה שנדרשות כ-8 שניות, מה שאומר שהצלחת להפחית את זמן הטעינה בערך 70%.
הצלחת לייעל בהצלחה את המשימה הקשורה ל-CPU באמצעות ארבעה תהליכי עבודה. אם יש לך מחשב עם יותר מארבעה ליבות, עדכן את THREAD_COUNT
למספר הזה ותוכל לחתוך זמן הטעינה עוד יותר.
מסקנה
במאמר זה, בנית אפליקציית Node עם משימה המצריכה הרבה מעבד וחוסמת את הלולאה הראשית. לאחר מכן, ניסית להפוך את המשימה לא חוסמת באמצעות promises, אך זה לא הצליח. לאחר מכן, השתמשת במודול worker_threads
כדי להעביר את המשימה הקשורה למעבד לתהליך אחר ולהפוך אותה ללא חוסמת. לבסוף, השתמשת במודול worker_threads
כדי ליצור ארבעה תהליכים כדי להאיץ את המשימה הכבידה על המעבד.
כשלב הבא, ראה את התיעוד של Node.js Worker threads כדי ללמוד עוד על האפשרויות. בנוסף, תוכל לבדוק את הספרייה piscina
, אשר מאפשרת לך ליצור מבנה עם עובדים למשימות שצורכות הרבה מעבד. אם ברצונך להמשיך ללמוד על Node.js, ראה את סדר ההדרכה, איך לכתוב ב-Node.js.
Source:
https://www.digitalocean.com/community/tutorials/how-to-use-multithreading-in-node-js