שלום אורח התחבר

הבלוג של ינון פרק

טיפים קצרים וחדשות למתכנתים

מעדיפים לקרוא מהטלגרם? בקרו אותנו ב:@tocodeil

או הזינו את כתובת המייל וקבלו את הפוסט היומי בכל בוקר אליכם לתיבה:

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

המשך קריאה...

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

>>> 2 == 2 == 2

והוא ענה לי True.

אז הלכתי לכתוב לו שיר אהבה קצר מרוב אושר, ופייתון אפילו חייך אליי בחזרה:

x = 30
if 20 < x < 50:
    print("I \U00002764 Python")

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

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

הכיוון הראשון שעבד נראה כך:

input_range = range(145852, 616942)
total = 0

for candidate in input_range:
    flag_all_up = True
    flag_same = False
    cand_str = str(candidate)
    for i in range(5):
        if cand_str[i] == cand_str[i+1]:
            flag_same = True
        elif cand_str[i] > cand_str[i+1]:
            flag_all_up = False
            break

    if flag_all_up and flag_same:
        total += 1

print(total)

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

def all_up(candidate):
    cand_str = str(candidate)
    for i in range(5):
        if cand_str[i] > cand_str[i+1]:
            return False

    return True

def has_two_adjacent_digits(candidate):
    cand_str = str(candidate)
    for i in range(5):
        if cand_str[i] == cand_str[i+1]:
            return True
    return False

total = sum(
        map(lambda candidate: all_up(candidate) and has_two_adjacent_digits(candidate),
            input_range))
print(total)

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

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

class TwoAdjacentDigitsPredicate:
    def __init__(self):
        self.result = False

    def process(self, a, b):
        if a == b:
            self.result = True

class AllUpPredicate:
    def __init__(self):
        self.result = True

    def process(self, a, b):
        if a > b:
            self.result = False

def apply_predicates(val, *predicates):
    val = str(val)
    for i in range(5):
        for p in predicates:
            p.process(val[i], val[i+1])
    return all([p.result for p in predicates])

total = filter(lambda candidate: apply_predicates(
            candidate,
            TwoAdjacentDigitsPredicate(), AllUpPredicate()), input_range)

print(sum(1 if x else 0 for x in total))

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

יש לכם כבר ניחוש מה זמני הריצה לכל גירסא? הנה זה בא:

# First version - single function
python3 aoc.py  0.81s user 0.02s system 85% cpu 0.965 total
# Second version - multiple functions
python3 aoc.py  0.54s user 0.01s system 97% cpu 0.568 total
# Third version - Object Oriented
python3 aoc.py  2.52s user 0.04s system 83% cpu 3.072 total

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

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

והמסקנה? בביצועים כמו בביצועים כדאי לבדוק כל דבר. תכנות מונחה עצמים כמעט תמיד יאט אתכם (אבל לפעמים הוא שווה את המחיר) ובכל מקרה מומלץ לא ליצור יותר מדי אוביקטים.

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

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

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

הנה התוכנית ב-4 שורות.

from gtts import gTTS

google_voice = gTTS(text="Hello world", lang='en')
google_voice.save('sound.mp3')

לפני שנעבור על הקוד שורה אחרי שורה קחו שתי דקות להקשיב לו. הכנסו לקישור: https://repl.it/@ynonp/AjarFatalCallbacks#main.py

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

בתוך הרפליט תוכלו ללחוץ על כפתור Run כדי להריץ את התוכנית, ואז בצד שמאל תראו שנוצר קובץ חדש בשם sound.mp3. לחצו עליו ותשמעו את גוגל אומר Hello world. ואפשר גם לשחק עם הקוד: שנו את הטקסט בתוכנית מ Hello world לכל משפט אחר, הריצו מחדש ותוכלו לשמוע את גוגל אומר את המשפט החדש.

המשך קריאה...

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

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

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

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

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

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

מסלול בוטקמפ ללימוד ריאקט ייפתח בתאריך 14.06.2020 למשך שלושה שבועות. מפגשי העבודה יתקיימו בימי ראשון ורביעי בין השעות 9:00 ל-11:00 בבוקר בתאריכים: 14/06, 17/06, 21/06, 24/06, 28/06, 01/07. מחיר הקורס 970 ש"ח וזה כולל חודש מנוי לתקופה של הקורס (אם אתם כבר מנויים החודש הזה יתווסף לכם אוטומטית לתקופת המנוי). הצטרפות לבוטקמפ בקישור: https://www.tocode.co.il/quickjoin2?term=15.

מסלול בוטקמפ ללימוד פייתון יפתח בתאריך 14.06.2020 למשך שלושה שבועות. מפגשי העבודה יתקיימו בימי ראשון ורביעי בין השעות 13:00 ל-15:00 בצהריים בתאריכים: 14/06, 17/06, 21/06, 24/06, 28/06, 01/07. מחיר הקורס 970 ש"ח וזה כולל חודש מנוי לתקופה של הקורס (אם אתם כבר מנויים החודש מתווסף אוטומטית לתקופת המנוי). הצטרפות לבוטקמפ פייתון בקישור: https://www.tocode.co.il/quickjoin2?term=16.

מוזמנים לשלוח מייל או להשאיר הודעה באתר לכל שאלה.

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

המשך קריאה...

המודול random של Python מציע שתי דרכים לערבב אלמנטים ברשימה: הראשונה היא הפונקציה sample והשניה הפונקציה shuffle. בואו נראה את ההבדל ומתי נשתמש בכל שיטה.

בפייתון ברוב הפעמים שנרצה לשנות רשימה נשתמש במתודות של אוביקט הרשימה עצמו - לדוגמא arr.append, arr.reverse ו arr.clear. אחת הפקודות המעניינות של רשימות בהקשר זה היא [].sort שממיינת את הרשימה בסדר עולה:

arr = [10, 20, 7, 30] 
arr.sort()
arr

Out[23]: [7, 10, 20, 30]

והחברה שלה היא הפונקציה sorted שמקבלת רשימה ומחזירה רשימה חדשה ממוימת:

arr = [10, 20, 7, 30]
sorted(arr)
Out[30]: [7, 10, 20, 30]

הפקודה arr.sort שינתה את הרשימה, ולעומתה sorted(arr) לא שינתה את הרשימה המקורית ורק החזירה רשימה חדשה.

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

הפונקציה הראשונה random.shuffle מקבלת רשימה ומשנה אותה במקום, כלומר:

In [31]: arr = [10, 20, 7, 30]                                           
In [32]: random.shuffle(arr)                                             
In [33]: arr                                                             

Out[33]: [20, 30, 10, 7]

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

הפונקציה random.sample לעומתה יודעת להחזיר רשימה של "מדגם" בכל אורך מתוך מערך. הפרמטר k קובע את גודל המדגם. הנה כמה דוגמאות:

In [34]: random.sample(arr, k=2)                                         
Out[34]: [10, 7]

In [35]: random.sample(arr, k=3)                                         
Out[35]: [30, 20, 10]

In [36]: random.sample(arr, k=2)                                         
Out[36]: [10, 30]
In [37]: random.sample(arr, k=2)                                         
Out[37]: [7, 30]

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

In [38]: random.sample(arr, k=len(arr))
Out[38]: [30, 7, 10, 20]

ומה לגבי זמן ריצה? לא מפתיע לגלות שהגירסא שמשנה את הרשימה In Place היא קצת יותר מהירה בעבודה על רשימות גדולות:

In [43]: a = [random.randint(0, 100) for _ in range(1_000_000)]          

In [44]: %timeit random.sample(a, k=len(a))                              
629 ms ± 24.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [45]: %timeit random.shuffle(a)                                       
538 ms ± 20.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

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

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

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

import aiohttp, asyncio, aiofiles

async def download(url, to):
    print(f"Download {url} to {to}")
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            if resp.status == 200:
                f = await aiofiles.open(to, mode='wb')
                await f.write(await resp.read())
                await f.close()

הפונקציה עובדת ואפילו אפשר להשתמש בה כדי להוריד כמה URL-ים במקביל, למשל הקוד הבא מוריד מספר פוסטים מהבלוג:

async def main():
    await asyncio.wait([
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset', 'post1.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-keep-on-learning', 'post2.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-python-memory-management', 'post3.html')),
        ])

asyncio.run(main())

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

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

# DO NOT USE - CODE WITH BUG
import aiohttp, asyncio, aiofiles

throttle = asyncio.Semaphore(2)

async def download(url, to):
    async with throttle:
        print(f"Download {url} to {to}")
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as resp:
                if resp.status == 200:
                    f = await aiofiles.open(to, mode='wb')
                    await f.write(await resp.read())
                    await f.close()

async def main():
    await asyncio.wait([
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset', 'post1.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-keep-on-learning', 'post2.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-python-memory-management', 'post3.html')),
        ])

asyncio.run(main())

והשגיאה לא איחרה לבוא:

Download https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset to post1.html
Download https://www.tocode.co.il/blog/2019-12-keep-on-learning to post2.html
Task exception was never retrieved
future: <Task finished name='Task-4' coro=<download() done, defined at post.py:5> exception=RuntimeError("Task <Task pending name='Task-4' coro=<download() running at post.py:6> cb=[_wait.<locals>._on_completion() at /Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/tasks.py:507]> got Future <Future pending> attached to a different loop")>
Traceback (most recent call last):
  File "post.py", line 6, in download
    async with throttle:
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/locks.py", line 97, in __aenter__
    await self.acquire()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/locks.py", line 496, in acquire
    await fut
RuntimeError: Task <Task pending name='Task-4' coro=<download() running at post.py:6> cb=[_wait.<locals>._on_completion() at /Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/tasks.py:507]> got Future <Future pending> attached to a different loop

מה קרה כאן? ההודעה אומרת שיש לנו Future Object שמחובר ל Event Loop אחר. פה המקום להזכיר ש asyncio מאפשר לנו ליצור מספר Event Loops במקביל. כל פעם שאנחנו יוצרים Task או Future Object אותו אוביקט מחובר לאיזושהי Event Loop. הקוד הראשי:

asyncio.run(main())

יוצר את ה Event Loop הראשון במערכת ומריץ בתוכה את הפונקציה main.

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

ואיך Semaphore (או כל אוביקט אחר) יודע לאיזה Event Loop הוא מחובר? ל asyncio יש פונקציה גלובלית בשם get_event_loop() שמחזירה בדיוק את זה. הבנאי של Semaphore קורא לפונקציה זו ושומר את ה Event Loop. אני מעדכן קצת את הקוד כדי לראות מה ה Event Loop שאותו בנאי יקבל ולהשוות אותה ל Event Loop הראשית של התוכנית:

import aiohttp, asyncio, aiofiles

print("Event Loop when creating the semaphore: ", id(asyncio.get_event_loop()))
throttle = asyncio.Semaphore(2)

async def main():
    print("Event Loop in main()", id(asyncio.get_event_loop()))

asyncio.run(main())

והנה התוצאה:

Event Loop when creating the semaphore:  4516635360
Event Loop in main() 4533746416

עכשיו הסיפור ברור: יצרתי את ה Semaphore מוקדם מדי ולכן הוא מחובר ל Event Loop שונה מזו של ה main. בשביל לתקן את הקוד צריך רק להזיז את יצירת ה Semaphore פנימה לתוך ה main:

import aiohttp, asyncio, aiofiles

throttle = None

async def download(url, to):
    async with throttle:
        print(f"Download {url} to {to}")
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as resp:
                if resp.status == 200:
                    f = await aiofiles.open(to, mode='wb')
                    await f.write(await resp.read())
                    await f.close()

async def main():
    global throttle
    throttle = asyncio.Semaphore(2)
    await asyncio.wait([
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset', 'post1.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-keep-on-learning', 'post2.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-python-memory-management', 'post3.html')),
        ])

asyncio.run(main())

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

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

המעבר לשפות שמנהלות לעצמן את הזיכרון תיקן חלקית את הבעיה אבל גם שפות שמנהלות לעצמן את הזיכרון עשויות לטעות ולייצר זליגות זיכרון. שני המודלים המרכזיים בהם שפות מנהלות לעצמן את הזיכרון נקראים Reference Count ו Garbage Collection.

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

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

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

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

a = []
a.append(a)

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

>>> len(a)
1
>>> len(a[0])
1
>>> len(a[0][0])
1
>>> len(a[0][0][0])
1
>>> len(a[0][0][0][0])
1
>>> len(a[0][0][0][0][0])
1

אבל החיסרון הוא שמנגנון Reference Count כבר לא יכול לעבוד. נלך לראות מה פייתון חושב על הקוד שלנו.

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

>>> a = []
>>> sys.getrefcount(a)
2

>>> b = {}
>>> sys.getrefcount(b)
2

>>> a = 10
>>> sys.getrefcount(a)
12

שני הראשונים די מסבירים את עצמם: השם a הוא השם הראשון של הרשימה, ואז בתוך הפונקציה getrefcount יש לרשימה זו שם נוסף (זה שם הפרמטר שהופיע בסוגריים בהגדרת הפונקציה).

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

>>> a = 19821
>>> sys.getrefcount(a)
2

עכשיו אפשר לחזור למעגל שלנו:

>>> a = []
>>> a.append(a)
>>> sys.getrefcount(a)
3

לאוביקט יש הפעם 3 שמות: השניים הראשונים מוכרים לנו מהדוגמאות הקודמות והשם השלישי הוא ההצבעה הפנימית. מנגנון Reference Count לעולם לא יוכל למחוק משתנה זה מהזיכרון כי מספר השמות שלו לא ירד אף פעם ל-0. ההצבעה הפנימית תימחק רק כשנאפס אותה ידנית.

לכן פייתון משתמש במנגנון משלים שנקרא Garbage Collection. במנגנון זה פייתון יחפש בצורה פעילה משתנים שאי אפשר להגיע אליהם וימחק אותם מהזיכרון.

נתחיל בחיסול האוביקט שלנו וננסה לראות אם פייתון מחק אותו לבד מהזיכרון:

import sys, gc
a = []
a.append(a)
ida = id(a)
a = 10

# is there still an object with id == i ?
>>> ida in [id(x) for x in gc.get_objects()]
True

הפקודה האחרונה משתמשת במנגנון ה Garbage Collector כדי לזהות האם עדיין יש בזיכרון אוביקט שה id שלו הוא מה שהיה ה id של הרשימה שלנו שהצביעה לעצמה. התשובה היא כמובן True.

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

>>> gc.collect()
1

>>> ida in [id(x) for x in gc.get_objects()]
False

ועכשיו הרשימה עם המעגל נמחקה מהזיכרון.

בזכות השילוב בין שני המנגנונים הרבה יותר קשה לייצר זליגות זיכרון בתוכניות פייתון. כדאי לשים לב עם זאת שמנגנון ה Garbage Collector מתעורר בזמנים לא צפויים ועלול לפגוע בביצועים של התוכנית אם אנחנו רוצים לכתוב תוכנית שתגיב בזמן אמת לאירועים חיצוניים. לכן קיימת בפייתון הפקודה gc.disable שמבטלת את מנגנון ה Garbage Collection ומשאירה אותנו רק עם Reference Count. אם אתם בטוחים שאין לכם הצבעות מעגליות באפליקציה וממש צריכים ביצועי Real Time אולי זה משהו ששווה לבדוק אותו.

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

הספריה pytest-asyncio היא פלאגין ל pytest שמוסיפה אפשרות לסמן בדיקות או Fixtures בתור בדיקות אסינכרוניות ובאופן אוטומטי תפעיל את הבדיקות האלה בתור קורוטינות ותעביר להן את ה event loop.

בשביל לשלב אותה בקוד הבדיקות שלכם מספיק להתקין מ pip ואז לסמן את הבדיקה בתור בדיקה אסינכרונית באופן הבא:

@pytest.mark.asyncio
async def test_luke_name(aiosession):
    data = await fetch(aiosession, 'https://swapi.co/api/people/1/')
    assert data['name'] == 'Luke Skywalker'

בדיקה זו משתמשת בפונקציית עזר בשם fetch (אסינכרונית גם היא) וב Fixture אסינכרוני בשם aiosession. הנה הקוד לשניהם:

@pytest.fixture()
async def aiosession():
    async with aiohttp.ClientSession() as session:
        yield session

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.json()

כל Fixture אסינכרוני מוגדר באופן אוטומטי להיות בעל תחום הגדרה של פונקציה. בשביל להשתמש בתחום הגדרה גדול יותר אנחנו צריכים לדרוס את ה Fixture שנקרא event_loop של ספריית pytest-asyncio ולתת לו תחום הגדרה גדול יותר. בדוגמא הבאה אני מגדיל את תחום ההגדרה של aiosession כך שיישמר לאורך כל חיי תוכנית הבדיקה:

@pytest.fixture(scope='session')
def event_loop():
    loop = asyncio.get_event_loop()
    yield loop
    loop.close()


@pytest.fixture(scope='session')
async def aiosession():
    async with aiohttp.ClientSession() as session:
        yield session

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