שיפור הביצועים באמצעות שרשורים

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

Thread ראשי

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

פנימי

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

כמעט כלשהו בלוק הקוד שהאפליקציה עורכת מקושר לקריאה חוזרת (callback) של אירוע, כמו קלט, או לצייר. כשמשהו מפעיל אירוע, השרשור שבו האירוע גורם לכך שהאירוע יוצא מעצמו לתוך ההודעה של השרשור הראשי לרשימת 'הבאים בתור'. לאחר מכן, ה-thread הראשי יוכל לטפל באירוע.

בזמן שאנימציה או עדכון מסך מתרחשים, המערכת מנסה לבצע כל 16 אלפיות השנייה (שאחראית לשרטוט המסך), כדי לעבד באופן חלק ב-60 FPS. כדי שהמערכת תגיע ליעד הזה, צריך צריך להתעדכן בשרשור הראשי. אבל כשתור ההודעות בשרשור הראשי מכיל משימות מרובות או ארוכות מדי מכדי שהשרשור הראשי ישלים את העדכון מהר מספיק, האפליקציה צריכה להעביר את העבודה לעובד אחר של שרשור. אם ה-thread הראשי לא יכול לסיים לבצע בלוקים של עבודה תוך 16 אלפיות השנייה, המשתמש עשוי להבחין בעיכובים, בעיכובים או בחוסר תגובה של ממשק המשתמש לקלט. אם ה-thread הראשי חסום למשך כחמש שניות, המערכת תציג היישום תיבת דו-שיח 'לא מגיב' (ANR), שמאפשרת למשתמש לסגור את האפליקציה ישירות.

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

שרשורים והפניות לאובייקטים בממשק המשתמש

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

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

הפניות בוטות

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

לדוגמה, נבחן אפליקציה שמכילה הפניה ישירה לאובייקט של ממשק משתמש thread עובד. האובייקט בשרשור של העובד עשוי להכיל הפניה אל View; אבל לפני שהעבודה מסתיימת, View הוסרה מהיררכיית התצוגה. כששתי הפעולות האלה מתרחשות בו-זמנית, קובץ העזר שומר את האובייקט View בזיכרון ומגדיר בו מאפיינים. עם זאת, המשתמש אף פעם לא יראה של האובייקט הזה, והאפליקציה מוחקת את האובייקט ברגע שההפניה אליו נעלמת.

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

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

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

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

הפניות מרומזות

ניתן לראות ליקוי נפוץ בעיצוב קוד של אובייקטים משורשרים בקטע הקוד שלמטה:

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

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

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

פתרון נוסף הוא תמיד לבטל משימות ברקע ולנקות אותן קריאה חוזרת (callback) של Activity במחזור החיים, למשל onDestroy. בגישה הזאת אפשר אבל עם הרבה טעויות. ככלל, לא כדאי להשתמש בלוגיקה מורכבת שלא קשורה לממשק משתמש ישירות בפעילויות. בנוסף, הכלי AsyncTask הוצא משימוש לא מומלץ לשימוש בקוד חדש. למידע נוסף על שרשורים ב-Android לקבלת פרטים נוספים על עקרונות בו-זמניות שזמינים לכם.

שרשורים ומחזורי חיים של פעילות באפליקציות

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

שרשורים קבועים

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

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

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

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

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

רמת העדיפות של השרשור

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

בכל פעם שיוצרים שרשור, צריך להתקשר setThreadPriority() ה-thread של המערכת המתזמנים נותנים עדיפות לשרשורים עם עדיפות גבוהה, ומאזן ביניהם עם הצורך לבצע את כל העבודה בסופו של דבר. באופן כללי, שרשורים בקדמת בחזית הקבוצה מקבלת כ-95% מזמן הביצוע הכולל מהמכשיר, מקבלת כ-5% מהרקע.

בנוסף, המערכת מקצה לכל שרשור ערך עדיפות משלו, באמצעות כיתה אחת (Process).

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

Process עוזר להפחית את המורכבות בהקצאת ערכי עדיפות על ידי מתן קבוצת קבועים שהאפליקציה יכולה להשתמש בהם כדי להגדיר עדיפויות לשרשורים. לדוגמה, THREAD_PRIORITY_DEFAULT מייצג את ערך ברירת המחדל לשרשור. האפליקציה שלך צריכה להגדיר את עדיפות השרשור ל- THREAD_PRIORITY_BACKGROUND לשרשורים עם עבודה פחות דחופה.

האפליקציה יכולה להשתמש בTHREAD_PRIORITY_LESS_FAVORABLE ו-THREAD_PRIORITY_MORE_FAVORABLE קבועים כדי לקבוע סדר עדיפויות כדי להגדיר עדיפויות יחסיות. לרשימה של את סדר העדיפויות של השרשורים, THREAD_PRIORITY קבועים ב- הכיתה Process.

מידע נוסף על ניהול שרשורים, עיינו במאמרי העזרה Thread ו-Process כיתות.

קורסים לעזרה בשרשורים

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

ה-framework גם מספק את אותם מחלקות Java ופרימיטיביות כדי להקל על שרשורים, כמו Thread, Runnable ו-Executors כיתות, וגם נוספים כמו HandlerThread. מידע נוסף זמין במאמר Threading ב-Android.

המחלקה HandlerThread

שרשור של handler הוא למעשה שרשור ארוך, שתופס עבודה מהתור ופועל עליו.

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

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

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

המחלקה ThreadPoolExecutor

יש סוגים מסוימים של עבודה שאפשר לצמצם במקבילות מאוד, של משימות מבוזרות. לדוגמה, משימה כזו היא חישוב מסנן בלוק 8x8 של תמונה בגודל 8 מגה-פיקסלים. בגלל הנפח העצום של חבילות העבודה, יוצרת, HandlerThread היא לא הכיתה המתאימה לשימוש.

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

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

כמה שרשורים צריך ליצור?

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

בפועל, יש מספר משתנים שאחראים לכך, אבל לבחור ערך (למשל 4, בתור התחלה) ולבדוק אותו באמצעות Systrace הוא כמו לגבש אסטרטגיה כמו כל אסטרטגיה אחרת. אפשר להשתמש בניסוי ובשגיאה כדי לגלות המספר המינימלי של שרשורים שאפשר להשתמש בהם בלי להיתקל בבעיות.

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

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