העקרונות הבסיסיים של Web Workers

הבעיה: בו-זמניות ב-JavaScript

יש כמה צווארי בקבוק שמונעים העברה של אפליקציות מעניינות (למשל, מהטמעות עמוסות בשרת) אל JavaScript בצד הלקוח. חלק מהפתרונות כוללים תאימות לדפדפן, הקלדה סטטית, נגישות וביצועים. למרבה המזל, האפשרות השנייה הפכה במהירות לנחלת הכלל, כי ספקי דפדפנים משפרים במהירות את המהירות של מנועי ה-JavaScript שלהם.

דבר אחד שמפריע ל-JavaScript הוא למעשה השפה עצמה. JavaScript הוא סביבה עם שרשור יחיד, כלומר כמה סקריפטים לא יכולים לפעול בו-זמנית. לדוגמה, נניח אתר שצריך לטפל באירועי ממשק משתמש, לשלוח שאילתות ולעבד כמויות גדולות של נתוני API, ולבצע שינויים ב-DOM. די נפוץ, נכון? לצערנו, אי אפשר לעשות את כל זה בו-זמנית בגלל מגבלות בסביבת זמן הריצה של JavaScript בדפדפנים. הפעלת הסקריפט מתבצעת בשרשור אחד.

מפתחים שמחקים 'בו-זמניות' באמצעות טכניקות כמו setTimeout(), setInterval(), XMLHttpRequest וגורמים מטפלים באירועים. כן, כל התכונות האלה פועלות באופן אסינכרוני, אבל אי אפשר לחסום בהכרח בו-זמנית. אירועים אסינכרוניים מעובדים אחרי סיום הסקריפט שמבצע את ההפעלה הנוכחית. החדשות הטובות הן ש-HTML5 מספק לנו משהו טוב יותר מהפריצות האלה!

חדש: Web Workers: הוספת שרשור ל-JavaScript

המפרט של Web Workers מגדיר API ליצירת סקריפטים ברקע באפליקציית האינטרנט שלכם. באמצעות Web Workers אפשר לבצע פעולות כמו הפעלת סקריפטים ממושכים על מנת לטפל במשימות אינטנסיביות ממוחשבות, אבל בלי לחסום את ממשק המשתמש או סקריפטים אחרים לטיפול באינטראקציות של משתמשים. הם יעזרו לסיים את הדו-שיח הגרוע של 'תסריט לא מגיב' שכולנו מכירים ואוהבים:

תיבת דו-שיח של סקריפט ללא תגובה
תיבת דו-שיח נפוצה של סקריפטים שאינם מגיבים.

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

סוגי עובדים באינטרנט

חשוב לציין שהמפרט עוסק בשני סוגים של Web Workers, Dedicated Workers ו-Shared Workers. המאמר הזה עוסק רק לעובדים ייעודיים. אקרא להם 'עובדי אינטרנט' או 'עובדים' בכל המקרים.

איך מתחילים

Web Workers פועלים בשרשור מבודד. כתוצאה מכך, הקוד שהם מפעילים צריך להיכלל בקובץ נפרד. אבל לפני שנעשה את זה, קודם צריך ליצור אובייקט Worker חדש בדף הראשי. ה-builder מקבל את שם הסקריפט של ה-worker:

var worker = new Worker('task.js');

אם הקובץ שצוין קיים, הדפדפן יפתח שרשור worker חדש, שאותו מורידים באופן אסינכרוני. ה-worker לא יתחיל לפעול עד שהקובץ יורד ויבוצע במלואו. אם הנתיב לעובד מחזיר קוד 404, העובד ייכשל באופן שקט.

אחרי יצירת קובץ ה-worker, צריך להפעיל אותו באמצעות קריאה לשיטה postMessage():

worker.postMessage(); // Start the worker.

תקשורת עם העובד באמצעות העברת הודעות

התקשורת בין העבודה לבין דף ההורה שלה מתבצעת באמצעות מודל אירוע וב-method postMessage(). בהתאם לדפדפן או לגרסה שלך, postMessage() יכול לקבל מחרוזת או אובייקט JSON כארגומנט יחיד. הגרסאות האחרונות של הדפדפנים המודרניים תומכות בהעברת אובייקט JSON.

לפניכם דוגמה לשימוש במחרוזת כדי להעביר את 'Hello World' לעובד ב-doWork.js. העובד פשוט מחזיר את ההודעה שמועברת אליו.

סקריפט ראשי:

var worker = new Worker('doWork.js');

worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);

worker.postMessage('Hello World'); // Send data to our worker.

doWork.js (העובד):

self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);

כשמתבצעת קריאה ל-postMessage() מהדף הראשי, העובד שלנו מטפל בהודעה הזו על ידי הגדרת handler של onmessage לאירוע message. ניתן לגשת למטען הייעודי (payload) של ההודעה (במקרה הזה, 'Hello World') ב-Event.data. הדוגמה הזו לא מרגשת במיוחד, אבל היא מוכיחה ש-postMessage() הוא גם האמצעי להעברת נתונים ל-thread הראשי. נוח!

הודעות שמועברות בין הדף הראשי לבין העובדים יועתקו, לא ישותפו. לדוגמה, בדוגמה הבאה אפשר לגשת למאפיין 'msg' של הודעת ה-JSON בשני המיקומים. נראה שהאובייקט מועבר ישירות לעובד, למרות שהוא פועל במרחב ייעודי נפרד. בפועל, זה תהליך של עריכה בסדרה של האובייקט בזמן שהוא נמסר לעובד, ואז עוברים עריכה טורית בצד השני. הדף והעובד לא חולקים את אותו מכונה, ולכן התוצאה הסופית היא שנוצרת כפילות בכל כרטיס. רוב הדפדפנים מטמיעים את התכונה הזו על ידי קידוד/פענוח של הערך בפורמט JSON באופן אוטומטי בשני הצדדים.

הדוגמה הבאה מורכבת יותר מהעברת הודעות באמצעות אובייקטים של JSON.

סקריפט ראשי:

<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>

<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}

function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}

function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}

var worker = new Worker('doWork2.js');

worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>

doWork2.js:

self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    self.postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
    self.postMessage('WORKER STOPPED: ' + data.msg +
                    '. (buttons will no longer work)');
    self.close(); // Terminates the worker.
    break;
default:
    self.postMessage('Unknown command: ' + data.msg);
};
}, false);

אובייקטים ניתנים להעברה

ברוב הדפדפנים מיושם אלגוריתם של שכפול מובנה, שמאפשר להעביר סוגים מורכבים יותר של worker או מחוץ להם, כמו אובייקטים של File, Blob, ArrayBuffer ו-JSON. עם זאת, כשמעבירים את סוגי הנתונים האלה באמצעות postMessage(), עדיין נוצר עותק. לכן, אם אתם מעבירים קובץ גדול בגודל 50MB (לדוגמה), יש תקורה משמעותית בהעברת הקובץ בין ה-worker ל-thread הראשי.

שכפול מובנה הוא דבר נהדר, אבל עותק יכול להימשך מאות אלפיות שנייה. כדי להיאבק בפגיעה בביצועים, אפשר להשתמש באובייקטים שניתנים להעברה.

עם אובייקטים ניתנים להעברה, הנתונים מועברים מהקשר אחד לאחר. מדובר בעותק אפס, שמשפר משמעותית את הביצועים של שליחת נתונים לעובד. אם אתם מגיעים מעולם C/C++ , אפשר להתייחס לזה כאל קובץ עזר. עם זאת, בניגוד להעברה לאחר הפניה, ה 'גרסה' מהקשר הקריאה לא תהיה זמינה יותר אחרי ההעברה להקשר החדש. לדוגמה, כשמעבירים ArrayBuffer מהאפליקציה הראשית ל-Worker, ה-ArrayBuffer המקורי נמחק ולא ניתן יותר להשתמש בו. התוכן שלו מועבר (פשוטו כמשמעו) להקשר של העובד.

כדי להשתמש באובייקטים שניתנים להעברה, צריך להשתמש בחתימה שונה מעט של postMessage():

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

במקרה של העובד, הארגומנט הראשון הוא הנתונים והשני הוא רשימת הפריטים שצריך להעביר. הארגומנט הראשון לא חייב להיות ArrayBuffer, דרך אגב. לדוגמה, הוא יכול להיות אובייקט JSON:

worker.postMessage({data: int8View, moreData: anotherBuffer},
                [int8View.buffer, anotherBuffer]);

הנקודה החשובה היא: הארגומנט השני חייב להיות מערך של ArrayBuffer. זו רשימת הפריטים שניתנים להעברה.

למידע נוסף על פריטים שניתנים להעברה, עיין בפוסט שלנו בכתובת developer.chrome.com.

סביבת העובד

היקף העובד

בהקשר של עובד, גם self וגם this מתייחסים להיקף הגלובלי של העובד. כך, הדוגמה הקודמת יכולה להיכתב גם כך:

addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
...
}, false);

לחלופין, אפשר להגדיר את הגורם המטפל באירועים של onmessage באופן ישיר (אף על פי שמומחי נינג'ה של JavaScript תמיד מעודדים את addEventListener).

onmessage = function(e) {
var data = e.data;
...
};

תכונות שזמינות לעובדים

בשל ההתנהגות עם ריבוי שרשורים, ל-Web Workers יש גישה רק לקבוצת משנה של התכונות של JavaScript:

  • האובייקט navigator
  • האובייקט location (לקריאה בלבד)
  • XMLHttpRequest
  • setTimeout()/clearTimeout() וגם setInterval()/clearInterval()
  • המטמון של האפליקציה
  • ייבוא סקריפטים חיצוניים באמצעות השיטה importScripts()
  • הצגת עובדי אינטרנט אחרים

לעובדים אין גישה אל:

  • ה-DOM (הוא לא בטוח לשרשורים)
  • האובייקט window
  • האובייקט document
  • האובייקט parent

סקריפטים חיצוניים בטעינה

אפשר לטעון ספריות או קובצי סקריפט חיצוניים ב-worker באמצעות הפונקציה importScripts(). השיטה משתמשת באפס מחרוזות או יותר שמייצגות את שמות הקבצים של המשאבים לייבוא.

בדוגמה הזו מתבצעת טעינה של script1.js ושל script2.js ב-worker:

worker.js:

importScripts('script1.js');
importScripts('script2.js');

ניתן לכתוב אותו גם כהצהרת ייבוא אחת:

importScripts('script1.js', 'script2.js');

תת-עובדים

לעובדים יש יכולת להורות עובדי ילדים. זה פתרון מעולה לפיצול נוסף של משימות גדולות בזמן הריצה. עם זאת, לתת-עובדים יש כמה אזהרות:

  • תת-עובדים חייבים להתארח באותו מקור של דף ההורה.
  • זיהוי מזהי URI בתוך תת-עובדים נקבע ביחס למיקום של עובד ההורה שלהם (בניגוד לדף הראשי).

חשוב לזכור שרוב הדפדפנים יוצרים תהליכים נפרדים לכל אחד מהעובדים. לפני שפותחים חוות עובדים, צריך להיזהר שלא תופסים יותר מדי משאבי מערכת של המשתמשים. אחת מהסיבות לכך היא שהודעות שמועברות בין הדפים הראשיים לבין עובדים מועתקות, לא משותפות. בקטע הזה מוסבר איך לתקשר עם עובד באמצעות העברת הודעות.

כדי לראות דוגמה איך ליצור תת-עובד, עיינו בדוגמה במפרט.

עובדים מוטבעים

מה קורה אם רוצים ליצור סקריפט של worker במהירות, או ליצור דף בפני עצמו ללא צורך ביצירת קבצים נפרדים לעובדים? באמצעות Blob(), אפשר להטביע את ה-worker באותו קובץ HTML כמו הלוגיקה הראשית, על-ידי יצירת כינוי לכתובת URL לקוד ה-worker כמחרוזת:

var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);

// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);

var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.

כתובות URL של blob

הקסם מתרחש בקריאה אל window.URL.createObjectURL(). השיטה הזו יוצרת מחרוזת פשוטה של כתובת URL שאפשר להשתמש בה כדי להפנות לנתונים שמאוחסנים באובייקט File או Blob של DOM. למשל:

blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

כתובות URL של blob הן ייחודיות ונמשכות לכל משך החיים של האפליקציה (למשל, עד להסרת ה-document). אם אתם יוצרים הרבה כתובות URL של Blob, כדאי לפרסם הפניות שכבר לא נחוצות. כדי לשחרר כתובות URL של Blob באופן מפורש, אפשר להעביר אותן אלwindow.URL.revokeObjectURL():

window.URL.revokeObjectURL(blobURL);

ב-Chrome יש דף נחמד להצגה של כל כתובות ה-URL של blobs שנוצרו: chrome://blob-internals/.

דוגמה מלאה

אם נמשיך את השלב הזה, נהיה נבוןים יותר להבין איך קוד ה-JS של העובד מוטמע בדף שלנו. בשיטה הזו נעשה שימוש בתג <script> כדי להגדיר את ה-worker:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<div id="log"></div>

<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
    self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>

<script>
function log(msg) {
    // Use a fragment: browser will only render/reflow once.
    var fragment = document.createDocumentFragment();
    fragment.appendChild(document.createTextNode(msg));
    fragment.appendChild(document.createElement('br'));

    document.querySelector("#log").appendChild(fragment);
}

var blob = new Blob([document.querySelector('#worker1').textContent]);

var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
    log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>

לדעתי, הגישה החדשה הזו נקייה וקריאה יותר. הוא מגדיר תג סקריפט באמצעות id="worker1" ו-type='javascript/worker' (כך שהדפדפן לא מנתח את ה-JS). הקוד מחולץ כמחרוזת באמצעות document.querySelector('#worker1').textContent ומועבר אל Blob() כדי ליצור את הקובץ.

סקריפטים חיצוניים בטעינה

כשמשתמשים בשיטות האלה כדי להטביע את קוד ה-worker, הפונקציה importScripts() תפעל רק אם מציינים URI מוחלט. אם תנסו להעביר URI יחסי, הדפדפן יתלונן על שגיאת אבטחה. הסיבה לכך היא שהפקודה (שנוצרה עכשיו מכתובת URL של blob) תטופל באמצעות קידומת blob:, והאפליקציה תפעל מסכימה אחרת (שככל הנראה http://). לכן הכשל יהיה כתוצאה מהגבלות בין מקורות שונים.

אחת מהדרכים להשתמש ב-importScripts() ב-worker משולב היא "להחדיר" את כתובת ה-URL הנוכחית של הסקריפט הראשי. כדי לעשות את זה, מעבירים אותה ל-worker המוטבע ויוצרים את כתובת ה-URL המוחלטת באופן ידני. כך תוכלו להבטיח שהסקריפט החיצוני ייובא מאותו מקור. בהנחה שהאפליקציה הראשית פועלת מ-http://example.com/index.html:

...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;

if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
    url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>

טיפול בשגיאות

כמו בכל לוגיקה של JavaScript, מומלץ לטפל בשגיאות שהמערכת תמשוך ב-workers שלכם. אם מתרחשת שגיאה בזמן שעובד מבצע, ה-ErrorEvent מופעל. הממשק מכיל שלושה מאפיינים שימושיים כדי להבין מה השתבש: filename – שם הסקריפט של worker שגרם לשגיאה, lineno – מספר השורה שבה אירעה השגיאה ו-message – תיאור משמעותי של השגיאה. דוגמה להגדרת הגורם המטפל באירועים של onerror כדי להדפיס את מאפייני השגיאה:

<output id="error" style="color: red;"></output>
<output id="result"></output>

<script>
function onError(e) {
document.getElementById('error').textContent = [
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}

function onMsg(e) {
document.getElementById('result').textContent = e.data;
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>

דוגמה: workerWithError.js מנסה לבצע את הפקודה 1/x, כאשר x לא מוגדר.

// TODO: DevSite - דוגמת הקוד הוסרה מכיוון שנעשה בה שימוש ברכיבי handler של אירועים מוטבעים

workerWithError.js:

self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};

כמה מילים על אבטחה

הגבלות בגישה מקומית

בגלל הגבלות האבטחה של Google Chrome, עובדים לא יפעלו באופן מקומי (לדוגמה, מ-file://) בגרסאות האחרונות של הדפדפן. במקום זאת, הם נכשלים בשקט! כדי להריץ את האפליקציה עם הסכמה file://, מריצים את Chrome כאשר הדגל --allow-file-access-from-files מוגדר.

דפדפנים אחרים לא מציבים את אותה הגבלה.

שיקולים מאותו מקור

סקריפטים של worker חייבים להיות קבצים חיצוניים באותה סכימה של דף השיחות שלהם. לכן לא ניתן לטעון סקריפט מכתובת URL של data: או מכתובת URL של javascript:, ודף https: לא יכול להפעיל סקריפטים של worker שמתחילים בכתובות URL של http:.

תרחישים לדוגמה

אז איזה סוג יישום ישתמש בעובדי אינטרנט? הנה עוד כמה רעיונות שיעזרו לך לדחוף את המוח:

  • שליפה מראש (prefetch) ו/או שמירת נתונים במטמון לשימוש במועד מאוחר יותר.
  • הדגשת תחביר של קוד או עיצוב אחר של טקסט בזמן אמת.
  • בדיקת איות.
  • ניתוח של נתוני וידאו או אודיו.
  • קלט/פלט (I/O) ברקע של שירותי אינטרנט.
  • עיבוד מערכים גדולים או תגובות JSON מוגזמות.
  • סינון תמונות ב<canvas>.
  • עדכון שורות רבות במסד נתונים מקומי של אינטרנט.

למידע נוסף על תרחישים לדוגמה הקשורים ל-Web Workers API, ראו סקירה כללית של Workers.

הדגמות

קובצי עזר