חבילת httpx בפייתון היא סוג של הדור הבא של requests עם תיעוד יותר מעודכן ותמיכה בכתיבה אסינכרונית. בואו נראה איך זה עובד דרך תוכנית קצרה וגם נשווה זמנים בין גרסה טורית, מקבילית ואסינכרונית.
בשביל הדוגמה לקחתי 4 ספרים מפרויקט גטנברג ובניתי 4 פונקציות שמורידות את ארבעת הספרים, כל אחת בדרך אחרת. הקוד מתחיל ב import-ים והגדרת רשימת הספרים:
import httpx
from timeit import timeit
from multiprocessing.dummy import Pool
import asyncio
book_urls = [
"https://www.gutenberg.org/cache/epub/84/pg84.txt",
"https://www.gutenberg.org/cache/epub/45304/pg45304.txt",
"https://www.gutenberg.org/cache/epub/2701/pg2701.txt",
"https://www.gutenberg.org/cache/epub/1342/pg1342.txt"
]
הפונקציה הראשונה פשוט מפעילה get ארבע פעמים אחת אחרי השניה:
def download_and_print_size(client, url):
r = client.get(url, timeout=60.0)
size = len(r.text)
print(f"Url: {url}; size: {size}")
def serial():
print("=== serial ===")
for url in book_urls:
download_and_print_size(httpx, url)
נשים לב ש httpx מגדירה timeout ברירת מחדל של 5 שניות ועבור פרויקט גטנברג זה לא תמיד מספיק אז הגדלתי לדקה. הגדרתי גם פונקציית הורדה נפרדת כדי לחסוך כפל קוד בדוגמאות הבאות.
פונקציה שניה כבר משתמשת ב client של httpx שזה מנגנון שלהם ששומר את החיבור לאתר פתוח כדי להוריד מספר קבצים:
def client_serial():
print("=== client_serial ===")
with httpx.Client() as client:
try:
for url in book_urls:
download_and_print_size(client, url)
finally:
client.close()
בגרסה השלישית רציתי לראות איך הסיפור הזה יעבוד מכמה תהליכונים במקביל כי שמעתי שכשמורידים קובץ במקביל הזמנים מתקצרים. פה כתבתי שתי גרסאות אחת עם client והשניה בלי:
def parallel():
print("=== parallel ===")
with Pool(5) as p:
p.map(lambda url: download_and_print_size(httpx, url), book_urls)
def client_parallel_threads():
print("=== client_parallel_threads ===")
with httpx.Client() as client:
try:
with Pool(5) as p:
p.map(lambda url: download_and_print_size(client, url), book_urls)
finally:
client.close()
ולסיום הורדה אסינכרונית. כאן לא ראיתי דרך בלי client אז הגדרתי אותו וגם בגלל שמדובר בקלאיינט אסינכרוני כבר אי אפשר היה להשתמש באותה פונקציית download שכבר כתבתי ולכן בניתי גרסה אסינכרונית שלה גם:
async def download_and_print_size_async(client, url):
r = await client.get(url, timeout=60.0)
size = len(r.text)
print(f"Url: {url}; size: {size}")
async def client_parallel():
print("=== client_parallel ===")
async with httpx.AsyncClient() as client:
try:
await asyncio.gather(*[download_and_print_size_async(client, url) for url in book_urls])
finally:
await client.aclose()
לסיום פונקציית main שמריצה את כל העסק וגם מודדת זמנים:
def main():
print(timeit(lambda: serial(), number=1))
print(timeit(lambda: client_serial(), number=1))
print(timeit(lambda: parallel(), number=1))
print(timeit(lambda: client_parallel_threads(), number=1))
print(timeit(lambda: asyncio.run(client_parallel()), number=1))
if __name__ == "__main__":
main()
אלה התוצאות:
=== serial ===
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
32.953853541985154
=== client_serial ===
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
12.404891041107476
=== parallel ===
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
6.713048083242029
=== client_parallel_threads ===
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
11.749367957934737
=== client_parallel ===
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
7.422760541085154
מופתעים? גם אני הופתעתי קצת. כלומר ברור למה הגרסה הראשונה מאוד איטית אבל אני מודה שגרסת התהליכונים בלי client הפתיעה לטובה ועקפה גם את הגרסה האסינכרונית. זה לא החזיק מעמד ובהרצה אחרת קיבלתי את התוצאה הזו:
=== serial ===
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
24.061222000047565
=== client_serial ===
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
14.5340595417656
=== parallel ===
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
8.315202291123569
=== client_parallel_threads ===
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
7.089039749931544
=== client_parallel ===
Url: https://www.gutenberg.org/cache/epub/1342/pg1342.txt; size: 763083
Url: https://www.gutenberg.org/cache/epub/84/pg84.txt; size: 446583
Url: https://www.gutenberg.org/cache/epub/45304/pg45304.txt; size: 1356681
Url: https://www.gutenberg.org/cache/epub/2701/pg2701.txt; size: 1260594
5.17865683324635
סך הכל הניסוי מוכיח מה שכבר ידענו - שעדיף להוריד קבצים במקביל מאשר בטור אבל שאין יתרון משמעותי להרצה אסינכרונית על פני שימוש בתהליכונים.
מעניין גם לשים לב לתקורה בשימוש בקוד אסינכרוני: גם הרצה עם asyncio.run, גם שימוש ב asyncio.gather בשביל להריץ דברים במקביל וגם אי אפשר היה להשתמש בקוד הסינכרוני הקיים וצריך היה לכתוב מחדש את פונקציית ההורדה. אני הרצתי על פייתון 3.14 והרצתי כמה פעמים התוצאות כל הרצה כמובן קצת שונות אבל מבחינת השוואת מהירות בין האופציות התוצאה עקבית.