FA-TOOLS — Header Component

آموزش جامع concurrent.futures در پایتون: راهنمای عملی همزمانی و موازی‌سازی

یکی از بزرگ‌ترین چالش‌هایی که توسعه‌دهندگان پایتون با آن مواجه می‌شوند، سرعت پایین برنامه‌ها در اجرای کارهای سنگین یا عملیات ورودی/خروجی (I/O) است. به صورت پیش‌فرض، پایتون کدها را به صورت تک‌نخی و خط به خط اجرا می‌کند. اما اگر نیاز داشته باشید صدها تصویر را دانلود کنید، هزاران درخواست به APIهای مختلف بفرستید یا محاسبات ریاضی پیچیده‌ای انجام دهید چه؟ اینجاست که استفاده از قدرت همزمانی (Concurrency) و موازی‌سازی (Parallelism) اهمیت پیدا می‌کند. در این مقاله، به زبان بسیار ساده و کاربردی یاد می‌گیریم که چطور با استفاده از کتابخنه concurrent.futures در پایتون، سرعت برنامه‌هایمان را تا چندین برابر افزایش دهیم.

⚡ خلاصه سریع: چه زمانی از کدام ابزار استفاده کنیم؟

ThreadPoolExecutor (نخ‌ها)

مناسب برای: کارهای ورودی/خروجی (I/O-Bound) مثل دانلود فایل، وب‌اسکرپینگ، کار با دیتابیس و درخواست‌های شبکه.

ویژگی: مصرف حافظه کم، اما تحت تاثیر محدودیت GIL پایتون.

ProcessPoolExecutor (فرآیندها)

مناسب برای: کارهای محاسباتی سنگین (CPU-Bound) مثل پردازش تصویر، رمزگذاری، محاسبات ریاضی و کار با داده‌های بزرگ.

ویژگی: دور زدن کامل GIL، اجرای واقعی روی هسته‌های مختلف پردازنده.

۱. تفاوت همزمانی و موازی‌سازی به زبان ساده

آموزش concurrent.futures در پایتون — تصویر 1

قبل از اینکه سراغ نوشتن کد برویم، باید درک کنیم که در پشت صحنه چه اتفاقی رخ می‌دهد. در دنیای کامپیوتر و برنامه‌نویسی، ما با دو مفهوم کلیدی سر و کار داریم:

  • همزمانی (Concurrency): مانند این است که یک آشپز همزمان پیازها را خرد کند و حواسش به قابلمه روی گاز هم باشد. او در یک لحظه فقط یک کار انجام می‌دهد، اما با جابجا شدن سریع بین کارها، باعث می‌شود همه‌چیز با هم جلو برود. در پایتون، این کار با Threading انجام می‌شود.
  • موازی‌سازی (Parallelism): مانند این است که دو یا چند آشپز همزمان در آشپزخانه کار کنند؛ یکی پیاز خرد می‌کند و دیگری سس را آماده می‌سازد. کارها واقعاً در یک لحظه به صورت همزمان انجام می‌شوند. در پایتون، این کار با Multiprocessing انجام می‌شود.

شاید بپرسید چرا پایتون مستقیماً همه چیز را موازی اجرا نمی‌کند؟ دلیل آن وجود مکانیزمی به نام GIL (Global Interpreter Lock) در مفسر استاندارد پایتون (CPython) است. GIL اجازه نمی‌دهد در یک لحظه، بیش از یک نخ (Thread) کدهای پایتون را روی هسته‌های سیصتم شما اجرا کند. برای کارهای تحت شبکه این موضوع مشکلی ایجاد نمی‌کند، اما برای محاسبات سنگین ریاضی، GIL یک مانع بزرگ است که باید آن را با فرآیندها (Processes) دور بزنیم.

اگر علاوه بر توسعه برنامه‌های پایتونی به مباحث طراحی وب نیز علاقه‌مند هستید، می‌توانید بخش کاربردی اسنیپت‌های پایتون را بررسی کنید که کدهای آماده و بهینه‌ای را در اختیارتان می‌گذارد. همچنین برای توسعه رابط کاربری پروژه‌هایتان، استفاده از کدهای آماده CSS به شدت توصیه می‌شود.

۲. معرفی ماژول concurrent.futures و مزایای آن

آموزش concurrent.futures در پایتون — تصویر 2

در گذشته، برای کار با نخ‌ها و فرآیندها در پایتون باید از ماژول‌های سطح پایین مانند threading و multiprocessing استفاده می‌کردیم که مدیریت کدهای پیچیده و دریافت خروجی از آن‌ها بسیار سخت و پر از باگ بود.

از پایتون ۳.۲ به بعد، ماژول concurrent.futures معرفی شد. این کتابخانه یک لایه انتزاعیِ بسیار ساده و شیک (High-level Interface) روی نخ‌ها و فرآیندها می‌کشد. مزیت‌های اصلی این ماژول عبارتند از:

  • سینتکس یکپارچه: برای کار با نخ یا فرآیند از یک ساختار کدنویسی کاملاً مشابه استفاده می‌کنید.
  • مدیریت آسان منابع: با استفاده از Context Manager (عبارت with)، تخصیص و آزادسازی حافظه و پردازنده‌ها به صورت خودکار انجام می‌شود.
  • شیء Future: این شیء نماینده عملیاتی است که در آینده به پایان می‌رسد. به شما اجازه می‌دهد وضعیت اجرای کار را بررسی کنید، آن را لغو کنید یا نتیجه را دریافت نمایید.

اگر به دنبال الگوها و کدهای آماده بیشتری در زمینه وب هستید، می‌توانید از بخش آرشیو اسنیپت‌های آماده دیدن کنید تا سرعت توسعه کدهای خود را افزایش دهید.

۳. کار با ThreadPoolExecutor (همزمانی شبکه و دیسک)

آموزش concurrent.futures در پایتون — تصویر 3

کارهای مربوط به شبکه یا خواندن و نوشتن روی حافظه هارد (I/O-Bound)، بیشتر وقت خود را صرف «انتظار» برای پاسخ سرور یا دیسک می‌کنند. در این مواقع، نخ‌ها عالی هستند؛ زیرا وقتی یک نخ منتظر پاسخ است، مفسر پایتون بلافاصله کنترل را به نخ دیگری می‌دهد.

بیایید با یک مثال واقعی ببینیم چطور می‌توانیم چند آدرس وب را به صورت همزمان بررسی کنیم:

import concurrent.futures
import urllib.request
import time

URLS = [
    'https://www.wikipedia.org/',
    'https://www.python.org/',
    'https://github.com/'
]

def load_url(url, timeout):
    with urllib.request.urlopen(url, timeout=timeout) as conn:
        return conn.read()

# استفاده از Context Manager برای مدیریت ThreadPool
start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
    # ارسال کارها به صورت همزمان
    future_to_url = {executor.submit(load_url, url, 60): url for url in URLS}
    
    for future in concurrent.futures.as_completed(future_to_url):
        url = future_to_url[future]
        try:
            data = future.result()
            print(f"{url} fetched successfully with {len(data)} bytes")
        except Exception as exc:
            print(f"{url} generated an exception: {exc}")

print(f"Execution Time: {time.time() - start_time:.2f} seconds")

در این کد، متد executor.submit() تابع و آرگومان‌های آن را دریافت کرده و کار را در صف اجرا قرار می‌دهد. سپس یک شیء Future بازمی‌گرداند. متد as_completed() مانند یک ژنراتور عمل کرده و به محض اینکه هر کدام از دانلودها با موفقیت به پایان برسد، شیء Future مربوط به آن را در اختیار ما قرار می‌دهد تا بلافاصله خروجی را بررسی کنیم.

۴. کار با ProcessPoolExecutor (موازی‌سازی محاسبات سنگین)

زمانی که صحبت از کارهایی با بار پردازشی شدید روی CPU است، نخ‌ها دیگر به درد ما نمی‌خورند چون قفل GIL مانع موازی سزای واقعی می‌شود. در این حالت باید از فرآیندها استفاده کنیم. هر فرآیند در سیستم‌عامل، یک نمونه مستقل از مفسر پایتون را اجرا کرده و حافظه اختصاصی خود را دارد. بدین ترتیب می‌توانیم کارهای محاسباتی سنگین را روی تمام هسته‌های فیزیکی پردازنده‌مان پخش کنیم.

به نمونه کد زیر که برای محاسبه اعداد فیبوناچی به روش غیر بهینه (محاسبات شدید CPU) نوشته شده توجه کنید:

import concurrent.futures
import time

# تابعی با بار پردازشی سنگین برای تست CPU
def cpu_bound_task(n):
    if n <= 1:
        return n
    return cpu_bound_task(n-1) + cpu_bound_task(n-2)

def main():
    numbers = [35, 36, 37, 38]
    
    start_time = time.time()
    
    # استفاده از ProcessPool برای توزیع کارها روی هسته‌ها
    with concurrent.futures.ProcessPoolExecutor() as executor:
        # استفاده از متد map برای اعمال تابع روی کل لیست
        results = executor.map(cpu_bound_task, numbers)
        
        for num, result in zip(numbers, results):
            print(f"Fibonacci({num}) = {result}")
            
    print(f"Total time elapsed: {time.time() - start_time:.2f} seconds")

if __name__ == "__main__":
    main()

نکته حیاتی برای ویندوز: در سیستم‌عامل ویندوز حتماً باید کدهای مربوط به ProcessPoolExecutor را در بلوک شرطی if __name__ == "__main__": قرار دهید. در غیر این صورت، فرآیندها به صورت بی‌پایان تکثیر شده و سیستم شما کرش خواهد کرد.

برای یادگیری نحوه ساخت صفحات وب جذاب متصل به کدهای پایتونی خود، می‌توانید از آموزش‌ها و اسنیپت‌های مرتبط با ساختارهای پایه HTML و همچنین برنامه‌نویسی جاوااسکریپت استفاده کنید تا برنامه‌هایتان را از حالت متنی به وب اپلیکیشن‌های تعاملی تبدیل کنید.

۵. متدهای طلایی: submit در برابر map

در ماژول concurrent.futures دو متد اصلی برای ارسال کارها به استخر نخ‌ها یا پردازنده‌ها وجود دارد که رفتار کاملاً متفاوتی دارند:

ویژگی متد submit متد map
نوع کنترل بسیار دقیق و تک‌به‌تک روی هر تسک اجرا روی کل مجموعه‌ای از داده‌ها به صورت یکجا
ترتیب خروجی‌ها هر کدام سریع‌تر تمام شد (غیرترتیبی) کاملاً حفظ شده بر اساس لیست ورودی
نوع خروجی یک شیء Future اختصاصی برای هر تسک یک ژنراتور حاوی مستقیم نتایج به دست آمده
مدیریت آرگومان‌ها امکان ارسال انواع آرگومان‌های متفاوت فقط اعمال یک تابع یکسان روی تکرارشونده‌ها

قاعده کلی برای تصمیم‌گیری: اگر یک تابع ساده دارید که می‌خواهید روی تعداد زیادی داده مشابه اعمال کنید و ترتیب خروجی برای شما مهم است، از map استفاده کنید. اما اگر کارهای متفاوتی دارید، به پارامترهای گوناگون نیاز دارید یا می‌خواهید خطاها را با دقت بالایی مدیریت کنید، submit بهترین انتخاب است.

۶. مدیریت خطاها و عیب‌یابی در کارهای همزمان

یک اشتباه رایج برنامه‌نویسان در زمان استفاده از همزمانی این است که فراموش می‌کنند خطاها را به درستی لاگ کرده و کنترل نمایند. وقتی کدی درون یک نخ یا فرآیند با خطا مواجه می‌شود، برنامه شما کرش نمی‌کند؛ بلکه خطا درون شیء Future ذخیره شده و ساکت می‌ماند تا زمانی که شما تلاش کنید خروجی را با متد result() بخوانید.

برای حل این مشکل، همیشه باید دسترسی به خروجی‌ها را در بلوک‌های try/except قرار دهید تا بتوانید جلوی توقف برنامه را بگیرید:

import concurrent.futures

def divide(a, b):
    return a / b

with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
    # ارسال محاسبات که یکی از آن‌ها تقسیم بر صفر است
    future_1 = executor.submit(divide, 10, 2)
    future_2 = executor.submit(divide, 10, 0) # این کار خطا تولید می‌کند

    # دریافت ایمن نتایج
    for future in [future_1, future_2]:
        try:
            res = future.result()
            print(f"Result: {res}")
        except ZeroDivisionError:
            print("Error: Attempted to divide by zero in a thread!")

همچنین اگر در اکوسیستم وردپرس فعالیت می‌کنید و قصد دارید وب‌سایت خود را با ابزارهای مختلف یا کدهای سفارشی توسعه دهید، مشاهده و استفاده از آرشیو عمیق اسنیپت‌های وردپرس می‌تواند فرآیند شخصی‌سازی قالب و افزونه‌های شما را به شدت هموار کند.

۷. عیب‌یابی سریع (Troubleshooting)

هنگام استفاده از برنامه‌نویسی موازی و همزمان در پروژه‌های واقعی ممکن است با ارورها یا رفتارهای عجیبی مواجه شوید. در جدول زیر راه‌حل‌های سریعِ متداول‌ترین چالش‌های این ماژول را برای شما جمع‌آوری کرده‌ایم:

💡 راهنمای رفع مشکلات رایج

  • مشکل: دریافت ارور TypeError: cannot pickle …

    علت و راه‌حل: در ProcessPool داده‌ها باید بین فرآیندها سریال‌سازی (Pickled) شوند. شما نمی‌توانید توابع تودرتو یا اشیاء قفل‌شده را به فرآیندها بفرستید. کدهای فرستاده شده باید در بالاترین سطح فایل تعریف شده باشند.
  • مشکل: سرعت اجرای ThreadPool از حالت عادی کندتر است!

    علت و راه‌حل: احتمالاً از نخ‌ها برای کارهای پردازشی شدید استفاده کرده‌اید. به دلیل وجود GIL، نخ‌ها در پایتون کارهای سنگین محاسباتی را به صورت نوبتی انجام می‌دهند و سربار مدیریت نخ‌ها باعث افت شدیدتر اجار (اجرای) برنامه‌تان می‌شود. برای حل این مشکل، حتماً از ProcessPoolExecutor استفاده کنید.
  • مشکل: کل سیستم من بعد از اجرا قفل یا منجمد می‌شود!

    علت و راه‌حل: مقدار max_workers را بیش از حد بالا انتخاب کرده‌اید. به طور پیش‌فرض برای پردازش‌های سنگین پیشنهاد می‌شود تعداد ورکِرها را برابر با تعداد هسته‌های فیزیکی سیستم خود بگذارید. (os.cpu_count())

۸. سوالات متداول کاربران (FAQ)

تفاوت اصلی ThreadPool و ProcessPool در چیست؟

نخ‌ها (ThreadPool) درون یک فرآیند واحد قرار دارند و فضای حافظه یکسانی را به اشتراک می‌گذارند که آن‌ها را سبک‌تر و با مصرف کمتر حافظه تبدیل می‌کند؛ اما تحت تاثیر محدودیت GIL قرار دارند. فرآیندها (ProcessPool) مجزا بوده و حافظه مستقل دارند و محدودیت GIL را کاملاً حل می‌کنند اما سنگین‌تر هستند.

چه زمان باید از کتابخانه asyncio بجای concurrent.futures استفاده کنیم؟

اگر در حال توسعه برنامه‌ای تماماً ناهمگام (Async) بر بستر پروتکل‌های تحت وب و وب‌سوکت‌ها هستید، asyncio به شدت کارآمدتر است. اما اگر از قبل کدهای همگام (Sync) دارید و بدون بازنویسی کل ساختار می‌خواهید فقط بخش‌هایی از آن را سریعاً موازی کنید، concurrent.futures سریع‌ترین راه‌حل ممکن است.

چگونه می‌توان یک عملیات در حال اجرا را کنسل کرد؟

می‌توانید از متد future.cancel() استفاده کنید. توجه داشته باشید که این متد فقط روی تسک‌هایی که هنوز شروع نشده‌اند و در صف انتظار هستند کارساز است و کارهای در حال اجرا را متوقف نمی‌کند.

Table of Contents

آخرین نوشته‌ها