• בלוג
  • רמאויות של תכנות מרובה תהליכים בפייתון

רמאויות של תכנות מרובה תהליכים בפייתון

23/05/2018

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

נתבונן בקוד הבא:

class Counter:
    def __init__(self, total):
        self.count = total

    def take(self, n=1):
        val = self.count
        self.count = val - n
        print(f"Remaining: {self.count}")

יש לכם רעיון איך לגרום למונה לטעות בספירה?

1. שימוש לא נכון במונה

המפתח נמצא כמובן בהרצת המונה מכמה תהליכונים במקביל. בהיעדר נעילה קל לדמיין מצב בו תהליכון אחד טוען את הערך מ self.count למשתנה val ובדיוק אז השליטה עוברת לתהליכון שני שיבצע את take במלואה ויקטין את self.count ב-1 (או בכמה שצריך). כשהתהליכון הראשון ימשיך את take הערך שיש לו ביד במשתנה val הוא הערך לפני העדכון ולכן העדכון של התהליכון הראשון ידרוס את העדכון שכבר בוצע על ידי התהליכון השני.

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

from threading import Thread
import time

class Counter:
    def __init__(self, total):
        self.count = total

    def take(self, n=1):
        val = self.count
        time.sleep(0)
        self.count = val - n
        print(f"Remaining: {self.count}")

class Consumer(Thread):
    def __init__(self, count, counter):
        super().__init__()
        self.count = count
        self.counter = counter

    def run(self):
        for i in range(self.count):
            self.counter.take()
            self.count -= 1

c = Counter(100)
consumers = []
for i in range(10):
    consumers.append(Consumer(10, c))
    consumers[-1].start()

[t.join() for t in consumers]
print(c.count)

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

# still wrong...
def take(self, n=1):
    self.count -= n
    print(f"Remaining: {self.count}")

2. תיקון באמצעות מנעולים

זוכרים את המנעול מהמודול threading? מסתבר שהוא לא בטעות שם. בעזרתו נוכל לבנות מונה הרבה יותר מדויק. התוכנית הבאה כבר מתמודדת יפה עם התנגשויות מכל הסוגים:

from threading import Thread, Lock
import time

class Counter:
    def __init__(self, total):
        self.count = total
        self.lock = Lock()

    def take(self, n=1):
        with self.lock:
            val = self.count
            time.sleep(0)
            self.count = val - n
        print(f"Remaining: {self.count}")

class Consumer(Thread):
    def __init__(self, count, counter):
        super().__init__()
        self.count = count
        self.counter = counter

    def run(self):
        for i in range(self.count):
            self.counter.take()
            self.count -= 1

c = Counter(100)
consumers = []
for i in range(10):
    consumers.append(Consumer(10, c))
    consumers[-1].start()

[t.join() for t in consumers]
print(c.count)

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