יום שבת, 13 בדצמבר 2014

Get off my main thread - חלק 2

במאמר הקודם התחלנו לסקור שיטות לביצוע פעולות ברגע, דיברנו על Thread חיצוני ועל AsyncTask, המשותף לשניהם הוא הרצה ב-Worker Thread חיצוני אחד. במאמר הזה ובמאמר הבא נדבר על השיטות להריץ עבודה ברקע במספר Threads במקביל.

ThreadPoolExecutor (פתרון של ג׳אווה - נמצא תחת java.util.concurrent): הזכרתי כבר שג׳אווה מעודדת מחזור Threads, כחלק מהעידוד גם מסופק לנו מחוץ לקופסא מחלקה שעושה את מרבית העבודה עבורנו. אנסה לעשות סדר לגבי איך עובד ThreadPoolExecutor בעזרת ציורים משובבי נפש שהכנתי - פחות או יותר ננסה לדמיין את המחלקה כך:


ל-ThreadPoolExecutor שלושה רכיבים עיקריים, שני מאגרי Threads ותור של משימות. משימות נכנסות בצורת Runnable על ידי execute או submit, ועוברות לטיפול ב-Core Poll. במידה וה-Core Poll לא מלא הוא יצור Thread חדש לטובת כל משימה שתגיע, כברירת מחדל, ה-Threads אשר נוצרים ב-Core Poll ישארו שם ולא ישוחררו עד לקריאת shutdown.


באיור העיגולים יהיו המשימות הנכנסות, הריבועים הנוצרים יהיו ה-Threads, והמצב ב-ThreadPoolExecutor שלנו הוא כזה שכבר נכנסו מספר משימות (בדיוק 10), ולכן 10 Threads נוצרו. מבין עשרת ה-Threads שניים כבר סיימו את עבודתם. הכלל של Core Poll עדיין תקף - לכל משימה שתיכנס יווצר Thread חדש עבורה, על אף שחלק מה-Threads סיימו את עבודתם. כאשר מספר ה-Threads ב-Core Poll יגיע למקסימום, משימות חדשות יכנסו ל-Threads פנויים, אלא אם אין כאלה.

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



כאשר כל התכולה של התור מלאה גם היא, משימות יתחילו להשלח אל ה-Backup Poll, ההבדל בינו לבין ה-Core Poll הוא ש-Threads אשר סיימו את עבודתם ב-Backup Poll ימתינו x זמן ואז ישוחררו - ולכן השארתי שם פתח ניקוז קטן. הזמן אשר יעבור בין סיום המשימה להריסת ה-Thread נתון אף הוא בידי המפתח.


במצב בו כל ה-Threads מלאים ועסוקים, התור מלא אף הוא ומשימה חדשה נכנסת, יוקפץ Rejected Execution Exception.

מבחינה תיאורטית ThreadPoolExecutor יעבוד כפי שמתואר כאן, בפועל המצב בו גדלי התור ושני ה-Polls אינם 0 ואינם אינסופיים נדיר, שני שימושים נפוצים מאוד, אשר בשניהם לא נמצא RejectedExecutionException הם:

1) FixedThreadPool -
  Core Poll size = n 
  Queue Capacity = infinite
  Backup Poll size = 0


ב-FixedThreadPool מספר ה-Threads הפעיל קבוע והתור הוא אינסופי, כך שיש לנו מספר סופי של פועלים המנסים לסיים את המשימות.
2) CachedThreadPool -
  Core Poll size = 0
  Queue Capacity = 0
  Backup Poll size = infinite



ב-CachedThreadPool יכולות הקיבול של התור ושל ה-Core Poll הם אפס וה-Backup Poll size הוא אינסופי, כך שכל משימה שתיכנס תקבל מיידית Thread שיעבוד עליה, ה-Threads אשר סיימו את עבודתם ולא קיבלו עבודה חדשה למשך דקה, ישוחררו, בשיטה זו לא יכול להווצר deadlock.
בשביל כל סגנון אחר של ThreadPoolExecutor המפתח יגדיר בעצמו את הנתונים.

הערה:
ב-Android Lollipop התווסף סוג נוסף של ThreadPoolExecutor - כזה המתאים בעיקר לפעולות חישוביות רקורסיביות - ForkJoinPool. סוג זה יודע לפצל כל משימה למשימות נוספות אשר יעזרו לה ולהמנע מ-deadlock, הוא בשימוש מגא׳ווה 1.8. סוג זה עדיין לא בשימוש ב-Android, על אף שהקוד נמצא, הוא נסתר תחת hide@
 
/** 
* @param parallelism the targeted parallelism level
* @return the newly created thread pool
* @since 1.8
* @hide
*/
public static ExecutorService newWorkStealingPool(int parallelism) {
    return new ForkJoinPool(parallelism, 
        ForkJoinPool.defaultForkJoinWorkerThreadFactory, null, true);
}
 
מתי נשתמש בכל אחד מהשיטות?
בכללי נבחן כל מקרה לגופו, השימוש הטוב ביותר תלוי בחומרה שלנו ובמשימה שלנו, הדבר הנכון ביותר יהיה למדוד זכרון וזמני ריצה וכך לבדוק מה השיטה הנכונה ביותר. על פי הדוקמנטציה של גוגל - ב-CachedThreadPool נשתמש כאשר נרצה לבצע הרבה פעולות קצרות ובמקביל.
לקוח מתוך Abstractivate: Choosing an ExecutorService

כיצד נשתמש בפועל ב-ThreadPoolExecutor?
הבנאי של ThreadPoolExecutor נראה כך:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
                                        TimeUnit unit, BlockingQueue<Runnable> workQueue,
                                        ThreadFactory threadFactory, RejectedExecutionHandler handler)
 
נגדיר בו את ה-corePoolSize, את ה-Backup Poll size שיהיה 
(maximumPoolSize - corePoolSize), נגדיר את הזמן בו Threads ב-Backup Poll יחיו ללא משימה, workQueue הוא התור שלנו, ThreadFactory אחראי לאתחולים של ה-Threads (שמות, Priority וכו׳) ו-handler שיטפל במצב של RefectedExecutionException. לשניים האחרונים יש אופציות ברירת מחדל אם לא נבחרו.
ברוב המקרים לא נקרא לבנאי הנ״ל בעצמנו אלא נשתמש במחלקת Executors שמספקת לנו בין היתר את הפונקציות הבאות:


public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
 
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}
 
כפי שציינתי קודם, את כל הקריאות שנבצע במחלקה הזאת נסיים בקריאה ל-shutDown, פעולה זו תאסור על משימות חדשות להכנס לתור, תאפשר למשימות הקיימות להסתיים ותשחרר את כל ה-Threads בסיום.
נמשיך עם הבעיה שהצגנו במאמר הקודם והפעם נפתור אותה עם ThreadExecutorService
 
public void onStartProgressButtonClicked(View view) {
    final AtomicInteger progress = new AtomicInteger(0);
    final ThreadPoolExecutor ex = (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
    final Handler uiHandler = new Handler(Looper.getMainLooper()) {

        @Override
        public void dispatchMessage(Message msg) {
            setProgressPercent(msg.what);
        }
    };
    for (int i = 1; i <= 100; i++) {
        ex.execute(new Runnable() {

            @Override
            public void run() {
                doDummyWork();
                int myProgress = progress.incrementAndGet();
                uiHandler.obtainMessage(myProgress).sendToTarget();
            }
        });
    }
    ex.shutdown();
}
במקרה הזה בחרתי לפתור את הבעיה עם FixedThreadPool של 5 Threads, למה? סתם. מכיוון שבחרתי ללא סיבה ובנוסף אני מבצע הרבה פעולות קטנות אשר על פי הדוקמנטציה עדיף להשתמש ב-CachedThreadPool, אבדוק את עצמי. הרצתי את הקוד הנ״ל 100 פעמים כאשר בכל פעם בחרתי מספר Threads שונה (בין 1 ל 100) ומדדתי זמנים. 
התוצאות:
על פי התוצאות,  היה עדיף להתשמש ב-CachedThreadPool.

הערה:
החסרון אשר הוצג לשתי השיטות מהמאמר הקודם תקפה גם לגבי ThreadsPoolExecutors.

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

לקריאה נוספת:

אין תגובות:

הוסף רשומת תגובה