مقدمه
فرض کنید یک API ساده نوشتهاید که قرار است از یک پایگاه داده اطلاعات بگیرد و به کاربر برگرداند. وقتی تعداد کاربران از ۱۰ نفر به ۱۰۰۰ نفر میرسد، ناگهان متوجه میشوید که پاسخدهی از ۱۰۰ میلیثانیه به ۱۰ ثانیه افزایش یافته است. مشکل از کجاست؟
واقعیت این است که بسیاری از برنامهنویسان، کدها را به صورت ترتیبی مینویسند؛ یعنی خط اول اجرا میشود، بعد خط دوم، بعد خط سوم و الی آخر. این روش برای پروژههای کوچک عالی است، اما در دنیای واقعی که با هزاران درخواست همزمان سر و کار داریم، کاملاً شکست میخورد.
در این مقاله، با سه روش قدرتمند برای اجرای همزمان کدها آشنا میشوید: Multithreading، Multiprocessing و Asynchronous Programming. هر کدام از این روشها برای نوع خاصی از کاربردها طراحی شدهاند و انتخاب اشتباه، نه تنها کمکی نمیکند، بلکه کد شما را کندتر هم میکند.
درک دو مفهوم کلیدی: I/O Bound و CPU Bound
قبل از اینکه سراغ کدنویسی برویم، باید بدانیم دقیقاً چه چیزی باعث کندی برنامهی ما میشود. دو نوع محدودیت اصلی وجود دارد.
محدودیت ورودی و خروجی (I/O Bound) زمانی رخ میدهد که برنامهی شما بیشتر وقت خود را صرف منتظر ماندن برای ورودی یا خروجی میکند. مثل خواندن یا نوشتن فایل روی دیسک، ارسال درخواست به یک API خارجی، اتصال به پایگاه داده و اجرای کوئری، یا دریافت اطلاعات از شبکه. در این حالت، پردازنده تقریباً بیکار نشسته و منتظر پاسخ دستگاههای جانبی است.
محدودیت پردازشی (CPU Bound) زمانی رخ میدهد که برنامهی شما بیشتر وقت خود را صرف محاسبات سنگین میکند. مثل پردازش تصاویر و ویدئو، رمزنگاری و هش کردن دادهها، حلقههای طولانی ریاضی، یا فشردهسازی فایلها. در این حالت، CPU با حداکثر توان خود کار میکند و منتظر هیچکس نیست.
نکته طلایی: راهحل هر کدام از این دو نوع محدودیت، کاملاً متفاوت است. اگر برای یک کار I/O Bound از Multiprocessing استفاده کنید، فقط منابع خود را هدر دادهاید. اگر برای یک کار CPU Bound از Async استفاده کنید، هیچ پیشرفتی نمیکنید.
تفاوت Concurrent و Parallel
خیلی از برنامهنویسان این دو واژه را به غلط به جای هم استفاده میکنند.
Concurrency (همزمانی): یعنی یک نفر که ۴ تا غذا را همزمان روی اجاق دارد. او یک لحظه به قابلمهی اول سر میزند، بعد قابلمهی دوم، بعد سوم و بعد چهارم. در هر لحظه فقط یک کار انجام میشود، اما همهی کارها در حال پیشرفت هستند.
Parallelism (موازیسازی): یعنی ۴ نفر که هرکدام یک غذا را همزمان میپزند. در هر لحظه، ۴ کار با هم انجام میشود.
در دنیای برنامهنویسی، Concurrency با Threadها در یک هسته CPU انجام میشود و Parallelism با Processها روی چند هسته CPU انجام میشود.
چالش بزرگ پایتون: GIL
اگر برنامهنویس پایتون هستید، باید با مفهوم GIL یا Global Interpreter Lock آشنا شوید. GIL یک قفل در سطح مفسر پایتون است که اجازه نمیدهد بیش از یک Thread در یک لحظه، کد پایتون را اجرا کند. یعنی حتی اگر CPU شما ۱۶ هسته داشته باشد، Threadهای پایتون نمیتوانند واقعاً موازی اجرا شوند.
نتیجهی مهم: برای کارهای I/O Bound، Threadها عالی کار میکنند چون وقتی منتظر پاسخ شبکه یا دیسک هستند، GIL آزاد میشود. اما برای کارهای CPU Bound، استفاده از Thread در پایتون بینتیجه است و حتی ممکن است کندتر از حالت عادی شود.
روش اول: Multithreading برای کارهای I/O محور
فرض کنید میخواهید از ۵۰ وبسایت مختلف، اطلاعات دریافت کنید. اگر این کار را به صورت ترتیبی انجام دهید، شاید ۳۰ ثانیه طول بکشد. با Threadها، این زمان به کمتر از ۲ ثانیه میرسد.
برای استفاده از Multithreading در پایتون، سادهترین روش استفاده از ThreadPoolExecutor است. این کلاس یک صف از وظایف درست میکند و آنها را بین Threadهای موجود توزیع میکند. وقتی یک Thread منتظر پاسخ سرور است، GIL آزاد میشود و Thread دیگر شروع به کار میکند.
مثال عملی: فرض کنید لیستی از ۵۰ آدرس وبسایت دارید. با استفاده از ThreadPoolExecutor(max_workers=10) میتوانید ۱۰ Thread همزمان ایجاد کنید و هر کدام از آنها یک درخواست HTTP ارسال کنند. نتیجه این است که همهی درخواستها تقریباً همزمان ارسال میشوند و کل زمان اجرا به جای ۳۰ ثانیه به حدود ۲ ثانیه کاهش مییابد.
روش دوم: Multiprocessing برای کارهای سنگین پردازشی
حالا فرض کنید میخواهید یک لیست بزرگ از اعداد را فیلتر کنید یا محاسبات سنگین ریاضی انجام دهید. اینجا وقت استفاده از Multiprocessing است.
در این روش، هر Process یک مفسر پایتون مجزا و حافظهی جداگانه دارد. بنابراین GIL دیگر مانعی ایجاد نمیکند و میتوانید از تمام هستههای CPU خود استفاده کنید. برای این کار از کلاس Pool در ماژول multiprocessing استفاده میشود.
مثال عملی: فرض کنید ۸ کار سنگین دارید که هرکدام یک حلقهی ریاضی طولانی را اجرا میکنند. با استفاده از Pool(processes=4) میتوانید این ۸ کار را روی ۴ هستهی CPU توزیع کنید. هر هسته دو کار را به صورت موازی انجام میدهد و زمان اجرا به یکچهارم کاهش مییابد.
نکتهی مهم: ایجاد Processها سنگینتر از Threadهاست و حافظهی بیشتری مصرف میکند. بنابراین برای سیستمهای با حافظهی کم، تعداد Processها را بیشتر از تعداد هستههای CPU نکنید.
روش سوم: Asynchronous Programming
اگر پروژهی شما تعداد بسیار زیادی درخواست I/O دارد، مثلاً ۵۰۰۰ اتصال وبسوکت همزمان، Threadها هم جوابگو نیستند و حافظه را اشغال میکنند. اینجا وارد دنیای Asynchronous میشویم.
در این روش، یک حلقهی رویداد یا Event Loop همهی کارها را در یک Thread واحد مدیریت میکند. وقتی کاری منتظر I/O است، از آن خارج شده و سراغ کار بعدی میرود. این روش بسیار سبک است و میتواند هزاران اتصال همزمان را با حداقل مصرف حافظه مدیریت کند.
در پایتون، برای برنامهنویسی Async از کلمات کلیدی async و await استفاده میشود. کتابخانههایی مثل aiohttp برای درخواستهای HTTP و asyncio برای مدیریت حلقهی رویداد طراحی شدهاند.
مثال عملی: فرض کنید میخواهید ۱۰۰ درخواست HTTP همزمان ارسال کنید. با استفاده از asyncio.gather() میتوانید همهی این درخواستها را همزمان اجرا کنید. نتیجه این است که کل زمان اجرا تقریباً برابر با زمان طولانیترین درخواست خواهد بود، نه مجموع همهی آنها.
مقایسه و انتخاب روش مناسب
برای انتخاب روش مناسب، باید نوع کاربرد خود را بشناسید:
- اگر کار شما I/O محور است (درخواست وب، خواندن فایل، کوئری پایگاه داده) → از Async (برای تعداد بالا) یا Threading (برای تعداد متوسط) استفاده کنید.
- اگر کار شما CPU محور است (حلقههای سنگین، پردازش داده، رمزنگاری) → از Multiprocessing استفاده کنید.
- اگر هم I/O دارید و هم پردازش → از ترکیب Async برای لایهی شبکه و ProcessPoolExecutor برای پردازشهای سنگین استفاده کنید.
- اگر برنامهی شما قرار است روی سرورهای Cloud با تعداد هستههای بالا اجرا شود → حتماً
os.cpu_count()را بخوانید و تعداد Workers را پویا تنظیم کنید.
اشتباهات رایج در برنامهنویسی همزمان
۱. Race Condition (شرایط رقابتی): وقتی دو Thread همزمان به یک متغیر مشترک دسترسی پیدا میکنند و نتیجهی نهایی به ترتیب اجرای آنها بستگی دارد. برای جلوگیری از این مشکل، از Lock استفاده کنید تا فقط یک Thread در آن واحد به متغیر دسترسی داشته باشد.
۲. Deadlock (قفلشدگی): وقتی Thread شماره ۱ منتظر قفلی است که Thread شماره ۲ در اختیار دارد و Thread شماره ۲ هم منتظر قفل Thread شماره ۱ است رخ میدهد. برای جلوگیری از این مشکل، از timeout برای قفلها استفاده کنید و هرگز قفلهای تو در تو ننویسید.
جمعبندی
بهینهسازی عملکرد، یک هنر است. هیچ جایگزینی برای اندازهگیری و پروفایلینگ کد وجود ندارد. همیشه قبل از هر تغییری، کد خود را با ابزارهایی مثل cProfile یا line_profiler آزمایش کنید و سپس یک روش مناسب را انتخاب کنید.
یادتان باشد که کد درست، همیشه کد سریع نیست. اما کد سریع، وقتی درست نوشته شده باشد، هنر است.
تمرین عملی
حالا نوبت شماست. یک برنامه بنویسید که همزمان:
۱. از ۵۰ وبسایت مختلف، محتوای صفحهی اصلی را دریافت کند (I/O Bound)
۲. بعد از دریافت، محتوای هر صفحه را با یک تابع هش مثل SHA-256 تبدیل کند (CPU Bound)
سوال: از کدام ترکیب روشها استفاده میکنید؟ چرا؟
راهنمایی: میتوانید از ThreadPoolExecutor برای دریافت صفحات و ProcessPoolExecutor برای هش کردن استفاده کنید.
موفق و پیروز باشید!

