چگونه با استفاده از Cache در پایتون زمان برنامه را کمتر تلف کنیم؟

یا باید کارهای تکراری زیادی را روی داده انجام بدهی، ولی ذخیرهی نتایج قبلی و مدیریت آنها کثیفکاری زیادی دارد؟
هروقت که میبینیم در جایی از برنامه باید منتظر دریافت داده از جای دیگری باشیم، اوّلین چیزی که برای بهبود سرعت اجرا به ذهنمان میرسد، استفاده از cache است.
استفاده از cache کار خیلی رایجی است، امّا تا حالا شده که به این فکرکنید که از cache درون خود کدتان، آن هم برای کاری به جز ارتباط با دیتابیس استفاده کنید؟
یکی از چیزهای جالبی که زبان پایتون از نسخهی ۳٫۲ در اختیار ما قرار داده است، امکان استفاده از cache از نوع LRU است. به علاوه ما یک قابلیّت جالب هم برای cache کردن property
ها در پایتون داریم.
بیایید با هم ببینیم که چطوری بدون زحمت اضافی میتوانیم خروجیهای کدمان را cache کنیم.
فهرست مطالب
تعریف Cache در ۱۴ ثانیه
به بخشی که به صورت نرمافزاری یا سختافزاری دادهها را ذخیره میکند تا موقع استفادهی مجدّد، سریعتر به آن داده دسترسی داشته باشیم، cache میگویند.
وقتی که دادهای که به آن نیازداریم درون cache وجود باشد، میگوییم که یک برخورد (Heat) رخداده است. هنگامی هم که داده درون cache نباشد، میگوییم که برخورد را از دست دادهایم و شکستخوردهایم (Miss).
طبیعتاً هرچقدر که نسبت برخوردها به شکستها بیشتر باشد، عملکرد cache ما بهتر است.
شیوهی عملکرد کش LRU چیست؟
حافظهی cache محدود است. پس ما باید روشی را برای مدیریت آن در نظر بگیریم تا تنها دادههایی که به آنها نیاز داریم درون cache بمانند و دادههایی که زمان استفادهی آنها تمام شده است بیرون ریخته شوند.
ما میتوانیم عمل cache کردن را با روشهای مختلفی انجام بدهیم. مثلاً میتوانیم دادهای که از همه زودتر وارد شده است را دور بریزیم یا به صورت تصادفی وقتی که جا کمآمد، یکی از دادهها را حذف کنیم.
یکی از روشهای مدیریت حافظهی cache، روش LRU است که مخفّف عبارت Least Recently Used است. این روش در حقیقت خانوادهای از روشهای مدیریت cache است، امّا ما میتوانیم نحوهی عملکرد آن را اینگونه در نظر بگیریم:
هرگاه که فضای کافی برای قرار دادن دادهی جدید در cache وجود نداشت، دادهای که از آخرین استفادهی آن زمان بیشتری گذشته است را از حافظه بیرون میکنیم.
استفاده از LRU Cache در پایتون
درون ماژول functools
یک دکوراتور به نام lru_cache
وجود دارد. اضافه کردن یک کش LRU به یک تابع، به سادگی افزودن این دکوراتور پیش از تعریف آن تابع است.
امَا بیایید قبل از اینکه سراغ lru_cache
برویم، حالت عادی را امتحان کنیم.
فرضکنید که شما یک برنامه دارید که باید با توجّه به ورودی کاربر، به یک آدرس درخواست بفرستد و نتیجهی آن را دریافت کند. سپس با پردازش جواب آن درخواست، پاسخ کاربر را بدهد.
ما نمیخواهیم که اینجا درگیر فراخوانی واقعی API ها و… شویم. به همین خاطر یک تابع الکی به نام fake_api_caller
میسازیم. این تابع یک آدرس اینترنتی را به عنوان ورودی میگیرد. برای اینکه تأخیر فراخوانی API را شبیهسازی کنیم، ابتدا درون این تابع به اندازهی طول رشتهی ورودی (البته به میلیثانیه) sleep
میکنیم و بعد یک نمونه از شئ Response
را برمیگردانیم.
محتویات این شئ هم برای ما مهم نیستند. صرفاً یک Data Class به نام Response
ساختهایم و درونش ۳ مقدار قرار دادهایم.
کد ما این شکلی است:
from dataclasses import dataclass from typing import List from time import time, sleep @dataclass class Response: status_code: int execution_time: int content: str def fake_api_caller(api_url: str) -> Response: url_length: int = len(api_url) sleep(url_length / 1000) return Response(status_code=200, execution_time=url_length, content=api_url * url_length)
همانطوری که میبینید، ابتدا تابع به اندازهی لازم میخوابد و سپس یک Response
قلّابی درست میکند. مقدار status_code
همیشه برابر با 200
است. زمان اجرا را برابر با طول رشته گذاشتهایم و محتوای Response
را برابر تکرار api_url
به تعداد طول آن قرار دادهایم.
یعنی اگر ورودی رشتهی "abc"
باشد، مقدار content
رشتهی: "abcabcabc"
خواهد بود.
حالا باید یک کدی بنویسیم که شبیهساز تعامل برنامهی ما با کاربر باشد:
urls: List[str] = [ "https://blog.alihoseiny.ir", "https://www.google.com", "https://www.twitter.com", "https://www.example.com", "https://www.fedoraproject.org", "https://www.github.com/alihoseiny", ] start: float = time() for i in range(100): for url in urls: result: Response = fake_api_caller(url) for url in urls[::-1]: result: Response = fake_api_caller(url) print(f"average time: {(time() - start) / 100}")
در دنیای واقعی ورودیهای ما از کاربر احتمالاً تصادفی خواهند بود. یعنی اینکه وقتی که کاربر اوّل ورودی A را وارد کرد، کاربر بعدی ممکن است دوباره ورودی A را به ما بدهد یا یک ورودی جدید را وارد کند.
ما نمیتوانیم موقع تست کردن برنامه ورودیها را تصادفی در نظر بگیریم. نه خودشان را نه ترتیب آنها را. چون قرار است که حالت معمولی را با حالت دارای cache مقایسه کنیم. اگر قرار است که نتایج مقایسهی ما معنیدار باشد، باید دادهها و عملگرها در هر دو حالت عیناً مشابه باشند.
به همین خاطر ما یک لیست ثابت از URL ها را آماده کردهایم و درون متغیّر urls
قرار دادهایم.
کاری که میکنیم این است که یک بار تکتک مقادیر درون این لیست را به ترتیب از اوّل به آخر به عنوان ورودی به fake_api_caller
میدهیم. سپس بلافاصله همین کار را به صورت برعکس انجام میدهیم. یعنی مقادیر درون urls
را از آخر به اوّل به عنوان ورودی به fake_api_caller
میدهیم.
هدف از این کار این است که تا حدّی رفتار کاربران را در وارد کردن ورودی مشابه کاربر قبلی شبیهسازی کنیم. بعضی وقتها کاربر بعدی، ورودیای مشابه کاربر قبلی میدهد (چند مقدار ابتدایی وقتی که داریم لیست urls
را برعکس پیمایش میکنیم، این کار را برای ما میکنند) و بعضی اوقات هم ورودی گرفتهشده از کاربر، مقداری جدید است.
برای رسیدن به عددی قابل قبولتر درمورد زمان اجرا، ما این فرآیند را صدبار تکرار میکنیم و میانگین زمان اجرای آن را به دست میآوریم.
میانگین زمان ما میشود مقدار زیر:
average time: 0.31419551610946655
خب حالا زمان آن رسیده که از cache استفاده کنیم. برای این کار کافی است که fake_api_caller
را به شکل زیر تغییر بدهیم:
@lru_cache(maxsize=4) def fake_api_caller(api_url: str) -> Response: url_length: int = len(api_url) sleep(url_length / 1000) return Response(status_code=200, execution_time=url_length, content=api_url * url_length)
ما اینجا گفتهایم که حداکثر ۴ داده درون cache قرار بگیرند. کد اجرای تست را هم به شکل زیر تغییر میدهیم:
start: float = time() for i in range(100): for url in urls: result: Response = fake_api_caller(url) for url in urls[::-1]: result: Response = fake_api_caller(url) print(f"average time: {(time() - start) / 100}") print(fake_api_caller.cache_info()) fake_api_caller.cache_clear()
حالا برنامه را اگر اجرا کنیم، خروجی زیر را میگیریم:
average time: 0.11169506072998046 CacheInfo(hits=796, misses=404, maxsize=4, currsize=4)
همانطوری که میبینید زمان اجرای برنامه ۱۱۲٪ کاهش داشته است. دلیلش هم این است که ۷۹۶ تا برخورد داشتهایم. یعنی ۷۹۶ بار به جای اینکه منتظر گرفتن پاسخ از منبع خارجی باشیم، بلافاصله پاسخ را به کاربر برگرداندهایم. عالی نیست؟
وقتی که lru_cache
را ابتدای یک تابع قرار میدهیم، چند متد مختلف به آن تابع اضافه میشود (یادتان نرود که توابع هم خودشان در پایتون شئ هستند).
یکی از این متدها cache_info
است. همانطوری که در کد بالا میبینید، خروجی این تابع یک دیکشنری است که اطّلاعاتی درمورد عملکرد cache را به ما میدهد.
متد دیگر، متد cache_clear
است. وقتی که این متد را اجرا کنیم، تمام مقادیر cache مربوط به این تابع نامعتبر میشوند و انگار که هیچ دادهای را تا الان cache نکردهایم.
شیوهی مدیریت رفتار lru_cache
ما موقعی که lru_cache
را برای یک تابع تعریف میکنیم، میتوانیم به آن دو ورودی بدهیم.
ورودی اوّل پارامتر maxsize
است که مقدار پیشفرض آن عدد ۱۲۸ است. مقدار این پارامتر مشخّص میکند که ما میخواهیم حدّاکثر چندتا داده درون cache قرار بگیرند.
هرچقدر این عدد بزرگتر باشد، یعنی ما دادههای بیشتری را cache میکنیم و احتمال رخدادن برخورد را «احتمالاً» افزایش میدهیم. امّا از طرفی، هرچقدر این عدد بزرگتر باشد، برنامه به حافظهی بیشتری احتیاج خواهد داشت و احتمال اینکه دادههایی که مورد نیاز نیستند را الکی cache کنیم بیشتر میشود.
رسیدن به عدد مناسب برای اندازهی cache کار سخت و البته مهمی است. شما باید مقادیر مختلف را امتحان کنید تا با توجّه به الگوی دادههای ورودی، به عدد مناسب برسید.
اگر مقدار maxsize
را برابر با None
قرار بدهیم عملاً cache را غیر فعّال کردهایم. چون اندازهی cache بینهایت میشود.
پارامتر بعدی، پارامتر typed
است. مقدار این پارامتر به صورت پیشفرض برابر با False
است. اگر این پارامتر را برابر با True
قرار بدهیم، ورودیهای تابع که نوع متفاوتی دارند، به عنوان بخشهای مجزا در cache قرار میگیرند. یعنی اگر تابع ما یک بار با ورودی 3
فراخوانی شود و یکبار با ورودی 3.0
، حتّی اگر خروجیهای تابع برای این دوتا یکسان باشد، به عنوان دو دادهی متفاوت در cache ذخیره میشوند.
یک چیزی که باید حواستان به آن باشد این است که در سطح پیادهسازی، cache دارد درون یک دیکشنری ذخیره میشود.
یعنی از ورودیهای تابع کلید ساخته میشود و خروجی تابع به عنوان مقدار مرتبط با این کلید، درون دیکشنری ذخیره میشود. به همین خاطر ورودیهای تابع باید hashable باشند.
کاربردهای lru_cache
به زمانهایی که داریم از I/O استفاده میکنیم محدود نمیشود. شما میتوانید از آن مثلاً در توابع بازگشتی هم استفاده کنید.
مثلاً میتوانید خودتان تفاوت عملکرد تابع بازگشتی فیبوناچی را با و بدون lru_cache
ببینید.
استفاده از cached_property برای cache کردن ویژگیها
ما در پایتون میتوانیم با افزودن دکوراتور @property
به ابتدای یک متد، آن را تبدیل به یک property کنیم. مثلاً کلاس زیر را ببینید:
class Person: def __init__(self, name: str, birth_date: date): self.name: str = name self.birt_date: date = birth_date @property def age(self) -> int: now: date = date.today() return now.year - self.birt_date.year
ما در این کلاس ۳ ویژگی برای هر شخص داریم. اوّلین ویژگی name
است که نام فرد در آن قرار میگیرد. دومین ویژگی هم تاریخ تولّد او است.
حالا ما یک ویژگی سوم هم به نام age
داریم. این ویژگی به صورت پویا ساخته میشود. یعنی اینکه تاریخ امروز را میگیریم و امسال را از سال تولّد آن شخص کم میکنیم.
مثلاً اگر برنامهی زیر را اجرا کنیم، خروجی (در سال ۲۰۲۰) عدد ۲۰ خواهد بود:
asghar: Person = Person("Asghar", date(2000, 1, 1)) print(asghar.age)
ما از نسخهی ۳٫۸ پایتون، درون ماژول functools
یک دکوراتور دیگر به نام cached_property
داریم. کاری که این دکوراتور انجام میدهد این است که نتیجهی محاسبات را بار اوّل در خود نگهداری میکند و دفعات بعدی که میخواهیم مقدار آن property را بگیریم، مقدار ذخیره شده را بدون محاسبهی مجدد برمیگرداند.
from datetime import date from functools import cached_property class Person: def __init__(self, name: str, birth_date: date): self.name: str = name self.birt_date: date = birth_date @cached_property def age(self) -> int: now: date = date.today() return now.year - self.birt_date.year asghar: Person = Person("Asghar", date(2000, 1, 1)) print(asghar.age)
خوبی استفاده از cached_property
این است که دیگر لازم نیست الکی مقدار یک property را هربار محاسبه کنیم و زمان کمتری از ما تلف خواهد شد.
The form you have selected does not exist.
استفاده از cache به ما کمک میکند که با محاسبهی مجدد مقادیر یا انتظار برای دادههای تکراری، برنامهی خودمان را کند نکنیم. ولی حواستان باشد که هنگام استفاده از cache ما داریم حافظهی بیشتری را مصرف میکنیم. به علاوه همیشه حواستان باشد که اگر دادهای که ممکن است تغییر کند را طولانیمدّت نگهداری کنیم، ممکن است دادهی قدیمی را به کاربر بدهیم.
یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.