وبلاگ شخصی
محمّدرضا
علی حسینی

جایی که تجربیات, علایق
و چیزهایی که یادگرفته‌ام را
با هم مرور می‌کنیم.

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

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

توی برنامه باید به چندجای مختلف درخواست ارسال کنی و منتظر باشی تا API های کند آن‌ها پاسخ را برایت ارسال کنند تا بتوانی جواب کاربر را بدهی؟

یا باید کارهای تکراری زیادی را روی داده انجام بدهی، ولی ذخیره‌ی نتایج قبلی و مدیریت آن‌ها کثیف‌کاری زیادی دارد؟

هروقت که می‌بینیم در جایی از برنامه باید منتظر دریافت داده از جای دیگری باشیم، اوّلین چیزی که برای بهبود سرعت اجرا به ذهن‌مان می‌رسد، استفاده از 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 را هربار محاسبه کنیم و زمان کم‌تری از ما تلف خواهد شد.

استفاده از cache به ما کمک می‌کند که با محاسبه‌ی مجدد مقادیر یا انتظار برای داده‌های تکراری، برنامه‌ی خودمان را کند نکنیم. ولی حواس‌تان باشد که هنگام استفاده از cache ما داریم حافظه‌ی بیشتری را مصرف می‌کنیم. به علاوه همیشه حواس‌تان باشد که اگر داده‌ای که ممکن است تغییر کند را طولانی‌مدّت نگهداری کنیم، ممکن است داده‌ی قدیمی را به کاربر بدهیم.

«نوشته‌های مرتبط»

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

«نوشته‌های ویژه»

«نوشته‌های محبوب»

«دیدگاه کاربران»