کسی که میخواهد اسکرپر را سریعتر کند، معمولاً اول به «هستههای بیشتر» فکر میکند: کار را میان پردازهها پخش کند یا سرور بزرگتری بگیرد. اما در یک کار اسکرپینگ معمول، پردازنده بیشتر زمان بیکار است. برنامه ثانیهها منتظر پاسخ سرور مقصد، handshake مربوط به TLS و باز شدن تونل پروکسی میماند. شیوه بهرهگیری از این زمان انتظار همروندی (concurrency) و شیوه بهرهگیری از توان پردازنده موازیسازی (parallelism) نام دارد و این دو، مسئلههای متفاوتی را حل میکنند.
در این نوشته تفاوت دو مفهوم، دلیل اینکه اسکرپینگ بیشتر کاری وابسته به ورودی و خروجی (I/O) است و اینکه asyncio، رشتهها و پردازهها در پایتون کی سودمندند را توضیح میدهیم. حلقه رویداد Node.js، آنچه واقعاً سرعت را محدود میکند و روش تعیین مقدار همروندی را هم بررسی میکنیم. کدهای نمونه را روی سرور محلی با تأخیر مصنوعی اجرا کردهایم و اسکلتی برای سنجش در اختیارتان میگذاریم تا کار خودتان را بسنجید.
همروندی چیست؟
همروندی توانایی چند کار برای پیش رفتن در یک بازه زمانی است. لازم نیست کارها در یک لحظه اجرا شوند؛ وقتی یکی منتظر است، دیگری اجرا میشود. آشپزی که تا جوش آمدن آب ماکارونی سالاد را خرد میکند همروندی دارد: یک نفر دو کار را در همان مدت تمام میکند.
در برنامهنویسی، این کار با واگذاری کنترل از یک کار به کار دیگر در لحظه انتظار برای پاسخ انجام میشود. asyncio در پایتون و حلقه رویداد در Node.js همینطور کار میکنند. یک هسته پردازنده و یک رشته میتواند صدها درخواست شبکه را همزمان «باز» نگه دارد، چون هیچکدام از این درخواستها هنگام انتظار از پردازنده استفاده نمیکنند.
موازیسازی چیست؟
موازیسازی یعنی اجرای چند کار بهطور فیزیکی در یک لحظه روی هستههای مختلف پردازنده. در مثال آشپزخانه، دو آشپز همزمان دو غذای متفاوت میپزند. سرعت کار مستقیم با تعداد کارگرها زیاد میشود، اما هر کارگر میز و مواد خودش را لازم دارد.
در برنامهنویسی، موازیسازی معمولاً با چند پردازه یا با رشتههایی که واقعاً همزمان اجرا میشوند به دست میآید. سود آن وقتی دیده میشود که کار پردازنده را زیاد به کار بگیرد: تحلیل سند HTML بزرگ، پردازش تصویر، فشردهسازی داده.
| معیار | همروندی | موازیسازی |
|---|---|---|
| فکر اصلی | همپوشانی زمانهای انتظار | پخش کار میان هستهها |
| هسته لازم | یک هسته کافی است | چند هسته |
| کار مناسب | وابسته به I/O: شبکه، دیسک، پایگاه داده | پرمصرف پردازنده: تحلیل، محاسبه |
| ابزار پایتون | asyncio، رشتهها | multiprocessing، ProcessPoolExecutor |
| ابزار Node.js | حلقه رویداد، Promise | worker_threads، چند پردازه |
| هزینه حافظه | کم، برای هر کار ناچیز | زیاد، حافظه جداگانه برای هر پردازه |
| نقش در اسکرپینگ | فرستادن درخواست و دریافت پاسخ | تحلیل صفحه، اجرای مرورگر headless |
| اشتباه رایج | فشار آوردن به مقصد با همروندی نامحدود | پخش کار I/O میان پردازهها و هدر دادن منابع |
این دو مفهوم جایگزین یکدیگر نیستند. یک سامانه اسکرپینگ بزرگ معمولاً هر دو را به کار میبرد: هر پردازه صدها درخواست را همروند مدیریت میکند و پردازهها روی هستههای مختلف موازی اجرا میشوند.
چرا اسکرپینگ وابسته به I/O است؟
نگاهی به مراحل یک درخواست صفحه نشان میدهد پردازنده چقدر کم کار میکند:
- resolve کردن DNS. تبدیل نام دامنه به آدرس IP؛ یک پرسوجو و یک پاسخ در شبکه.
- اتصال به پروکسی و ساخت تونل. اتصال TCP به پروکسی، درخواست
CONNECTو سپس انتظار تا پروکسی به مقصد وصل شود. - handshake مربوط به TLS. اتصال رمزنگاریشده با سرور مقصد که چند رفتوبرگشت لازم دارد.
- فرستادن درخواست و انتظار پاسخ. سرور مقصد صفحه را آماده میکند؛ در صفحههای دارای پرسوجوی پایگاه داده این کار طول میکشد.
- دانلود پاسخ. به اندازه صفحه و باند بستگی دارد.
- تحلیل. استخراج داده از HTML؛ تنها گامی که پردازنده را به کار میگیرد.
در پنج گام نخست، برنامه منتظر بایتهای شبکه است. در اسکریپتی که صفحهها را پشت سر هم دانلود میکند، تحلیل بخش کوچکی از کل زمان است؛ اسکریپت بیشتر وقتش را در انتظار میگذراند و پردازنده بیکار است.
اما این نسبت ثابت نیست. روی سرور محلی که هر پاسخ را 300 میلیثانیه به تأخیر میانداخت، 40 صفحه را که هر کدام 400 کارت محصول داشت دانلود و تحلیل کردیم. افزایش همروندی از 1 به 5 و 20 زمان دانلود را بیش از ده برابر کوتاه کرد. با همروندی 20، تحلیل همان صفحهها روی یک هسته از دانلودشان طولانیتر شد؛ رفتن به مخزن پردازهها این گام را تقریباً نصف کرد. پس با بالا رفتن همروندی، گلوگاه ممکن است از شبکه به پردازنده منتقل شود. عددها برای مقصدهای شما متفاوت خواهد بود؛ پیشنهاد میکنیم کار خودتان را با اسکلت سنجش پایین بسنجید.
تفاوت asyncio، رشتهها و پردازهها در پایتون چیست؟
پایتون سه ابزار دارد و کلید انتخاب میان آنها GIL (Global Interpreter Lock) است. در مفسر استاندارد CPython، قفل GIL اجازه میدهد در هر لحظه فقط یک رشته کد پایتون اجرا کند. اما رشته هنگام انتظار برای داده شبکه GIL را آزاد میکند. در نتیجه:
asyncio: حلقه رویدادی که در یک رشته کار میکند. کارها در نقطههایawaitکنترل را به یکدیگر میسپارند. سبکترین گزینه برای صدها یا حتی هزاران درخواست شبکه همروند است. مستندات asyncio اجزای این مدل را شرح میدهد. کتابخانه هم باید ناهمگام باشد:AsyncClientدر HTTPX یا AIOHTTP.- رشتهها (
ThreadPoolExecutor): چون GIL هنگام انتظار شبکه آزاد میشود، رشتهها در کار I/O سرعت واقعی میدهند. سادهترین راه افزودن همروندی به کتابخانههای همگامی مانند Requests هستند. هر رشته از یک کارasyncioحافظه بیشتری مصرف میکند، پس پس از چند صد درخواست همروند کارایی کم میشود. - پردازهها (
ProcessPoolExecutor،multiprocessing): هر پردازه با مفسر و GIL خودش اجرا میشود، پس در کار پرمصرف پردازنده موازیسازی واقعی میدهد. راهاندازی پردازه و جابهجایی داده میان آنها پرهزینه است. مستندات concurrent.futures رابط مشترک مخزن رشته و مخزن پردازه را تعریف میکند.
پایتون 3.13 نسخه آزمایشیای هم آورد که میتواند بدون GIL اجرا شود؛ اما چون سازگاری کتابخانهها هنوز محدود است، نسخه استاندارد و سه ابزار بالا در کارهای اسکرپینگ واقعی رایج ماندهاند.
| ابزار | آیا موازیسازی میدهد؟ | کار مناسب | کاربرد در اسکرپینگ |
|---|---|---|---|
asyncio همراه HTTPX یا AIOHTTP | نه، یک رشته | درخواستهای شبکه فراوان | دانلود صفحهها |
ThreadPoolExecutor همراه Requests | در عمل بله در هنگام I/O | تعداد متوسط درخواست، کد همگام | سریعتر کردن کد Requests موجود |
ProcessPoolExecutor | بله | کار پرمصرف پردازنده | تحلیل صفحههای بزرگ |
نمونه 1: همروندی محدود با asyncio.Semaphore
کد زیر صفحهها را همروند دانلود میکند، اما تعداد درخواستهای باز در هر لحظه را با مقدار limit محدود میکند. بدون Semaphore، تابع asyncio.gather همه درخواستها را یکجا آغاز میکند و این هم به مخزن اتصال خودتان و هم به سایت مقصد فشار میآورد.
import asyncio
import httpx
async def fetch(client, sem, url):
async with sem:
response = await client.get(url)
response.raise_for_status()
return response.text
async def fetch_all(urls, limit=10, proxy=None):
sem = asyncio.Semaphore(limit)
async with httpx.AsyncClient(proxy=proxy, timeout=30) as client:
return await asyncio.gather(*(fetch(client, sem, u) for u in urls), return_exceptions=True)
urls = [f"https://example.com/product/{i}" for i in range(100)]
pages = asyncio.run(fetch_all(urls, limit=10, proxy="http://user:pass@pr.proxynet.io:8000"))یک AsyncClient میان همه درخواستها مشترک است. به این ترتیب اتصالها دوباره به کار میروند و در هر درخواست handshake تازه TLS انجام نمیشود.
نمونه 2: دانلود همروند، تحلیل موازی
اگر صفحهها بزرگاند و تحلیل زمان محسوسی میگیرد، ترکیب دو مدل منطقی است: دانلود با asyncio و تحلیل در مخزن پردازهها.
import asyncio
from concurrent.futures import ProcessPoolExecutor
from bs4 import BeautifulSoup
def parse(html):
soup = BeautifulSoup(html, "html.parser")
return [(p.h2.get_text(strip=True), p.select_one(".price").get_text(strip=True)) for p in soup.select(".product")]
async def scrape(urls, limit=10, proxy=None):
pages = await fetch_all(urls, limit=limit, proxy=proxy)
html_pages = [p for p in pages if isinstance(p, str)]
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
return await asyncio.gather(*(loop.run_in_executor(pool, parse, html) for html in html_pages))
if __name__ == "__main__":
results = asyncio.run(scrape(urls, limit=10))خط if __name__ == "__main__": در ویندوز و macOS الزامی است: مخزن پردازهها پردازههای تازه را با بارگذاری ماژول از ابتدا آغاز میکند و بدون این محافظ، کد بارها خودش را اجرا میکند.
مهم است بدانید این ساختار همیشه سرعت نمیدهد. راهاندازی پردازهها و جابهجایی HTML میان آنها هزینه دارد؛ اگر صفحهها کوچک و کم باشند، این هزینه ممکن است از خود تحلیل بیشتر شود. در آزمون بالا مخزن پردازهها تحلیل صفحههای 400 کارتی را آشکارا کوتاه کرد، اما هزینه راهاندازی پردازهها به کل زمان دانلود و تحلیل افزوده شد. پیش از تصمیم، با صفحههای خودتان بسنجید.
حلقه رویداد Node.js چگونه کار میکند؟
Node.js کد جاوااسکریپت را در یک رشته اجرا میکند و عملیات شبکه را با حلقه رویداد مدیریت میکند. وقتی fetch را صدا میزنید، Node.js درخواست را به سیستمعامل میسپارد و کد جاوااسکریپت ادامه مییابد؛ وقتی پاسخ برسد، callback مربوط در صف قرار میگیرد. راهنمای حلقه رویداد در Node.js مراحل این حلقه را مفصل توضیح میدهد.
برای اسکرپینگ این سه پیامد را دارد:
- درخواستهای شبکه ذاتاً همروندند. هر درخواست مبتنی بر
Promiseهنگام انتظار، بقیه را متوقف نمیکند. شبیه مدلasyncioدر پایتون است. - کد پرمصرف پردازنده کل حلقه را متوقف میکند. وقتی تابعی همگام سند HTML بزرگی را تحلیل میکند، هیچ پاسخ دیگری پردازش نمیشود. برای چنین کاری از رشتههای جداگانه با ماژول
worker_threadsیا پردازههای جداگانه استفاده کنید. - resolve نام دامنه میتواند گلوگاهی پنهان باشد. تابع پیشفرض
dns.lookupدر Node.js حلکننده سیستمعامل را در مخزن رشته کوچک libuv اجرا میکند. در اسکریپتی که همزمان به دامنههای فراوان وصل میشود، این مخزن ممکن است پر شود. با پروکسی، دامنههای مقصد در سمت پروکسی resolve میشوند و اثر کمتر میشود.
برای محدود کردن همروندی به کتابخانه اضافه نیاز ندارید؛ یک محدودکننده ساده کافی است:
function limiter(max) {
let active = 0;
const queue = [];
const next = () => {
if (active >= max || queue.length === 0) return;
active++;
const { task, resolve, reject } = queue.shift();
task().then(resolve, reject).finally(() => {
active--;
next();
});
};
return (task) => new Promise((resolve, reject) => {
queue.push({ task, resolve, reject });
next();
});
}
const limit = limiter(10);
const results = await Promise.allSettled(
urls.map((url) => limit(() => fetch(url).then((r) => r.text()))),
);شیوه دادن پروکسی در Node.js همراه نمونهای با چرخش و تلاش دوباره در استفاده از پروکسی در Node.js آمده است.
چه چیزی واقعاً سرعت را محدود میکند؟
افزایش همروندی تا جایی سرعت را بالا میبرد؛ پس از آن یا اثری ندارد یا کار را کند میکند. در اسکرپینگ سقف را معمولاً نه پردازنده، بلکه این عوامل تعیین میکنند:
- محدودیت نرخ سایت مقصد. اگر سایت در دقیقه تعداد مشخصی درخواست از یک منبع را مجاز بداند، افزایش همروندی فقط پاسخهای
429بیشتری میسازد. شیوه برخورد با این کدها در کدهای وضعیت HTTP در وب اسکرپینگ آمده است. - ظرفیت سرور مقصد. وقتی سرور یک سایت کوچک کند شود، همروندی شما زمان پاسخ آن را طولانیتر میکند؛ هم کار شما و هم بازدیدکنندگان واقعی سایت کند میشوند.
- ظرفیت پروکسی. سقف اتصال همزمان پلن، تعداد IPهای مخزن و زمان پاسخ نقاط خروج. با پروکسی چرخشی که از یک آدرس IPهای خروجی فراوان میدهد، درخواستها میان آدرسهای مختلف پخش میشوند، اما سقف اتصال پلن همچنان پابرجاست.
- باند. هنگام دانلود صفحههای بزرگ و پاسخهای پرتصویر، سقف اتصال خودتان یا ترافیک پروکسی به میان میآید.
- هزینه برقراری اتصال. اگر در هر درخواست اتصال تازه با handshake تازه TLS برقرار شود، زمان بیشتر میشود. اشتراک کلاینت و استفاده دوباره از اتصال این هزینه را کم میکند.
- فاصله. تأخیر میان نقطه خروج پروکسی و سرور مقصد به هر رفتوبرگشت افزوده میشود. در آدرسهای پروکسی مسکونی که از اتصالهای خانگی واقعی خارج میشوند، این زمان بسته به موقعیت و خط متغیر است.
- پردازنده. فقط هنگام اجرای مرورگر headless، تحلیل سندهای بسیار بزرگ یا پردازش سنگین داده روی همان ماشین تعیینکننده میشود.
راههای دیگر تنظیم سرعت بدون مسدود شدن و آسیب رساندن به سایت را در وب اسکرپینگ بدون مسدود شدن آوردهایم.
مقدار همروندی را چند بگذاریم؟
عدد یگانهای برای همه کارها وجود ندارد؛ مقدار درست با سنجش پیدا میشود. روشی که پیشنهاد میکنیم:
- با مقدار کم شروع کنید. چند درخواست همزمان برای یک سایت مقصد.
- سه چیز را بسنجید. صفحههای تکمیلشده در دقیقه، میانگین زمان پاسخ و نرخ خطا (
429،503، timeout). - مقدار را کمکم بالا ببرید. در هر گام همان سنجش را تکرار کنید.
- نقطه چرخش را بیابید. وقتی صفحههای تکمیلشده دیگر زیاد نمیشوند و زمان پاسخ یا نرخ خطا بالا میرود، به مقدار قبلی برگردید.
- برای هر سایت سقف بگذارید. اگر همزمان چند سایت را میخزید، همروندی کل میتواند زیاد باشد، اما سهم هر سایت باید کم بماند.
- بهطور دورهای دوباره بسنجید. زیرساخت و محدودیت نرخ سایتهای مقصد با زمان تغییر میکند.
اسکلتی ساده برای سنجش:
import asyncio
import time
def measure(label, fn):
start = time.perf_counter()
result = fn()
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.2f} s")
return result
for limit in (1, 5, 10, 20):
pages = measure(f"limit={limit}", lambda: asyncio.run(fetch_all(urls, limit=limit)))
errors = sum(isinstance(p, Exception) for p in pages)
print(f" errors: {errors} / {len(pages)}")time.perf_counter() شمارندهای با وضوح بالاست که از تغییر ساعت سیستم اثر نمیپذیرد و برای سنجش مدت از time.time() مناسبتر است. سنجش را با فهرست کوچکی از URLها که به سایت مقصد فشار نیاورد و در صورت امکان در ساعتهای کمترافیک سایت انجام دهید.
کاربردها
- تعداد محدودی صفحه از یک سایت: Requests همگام با فاصله میان درخواستها. همروندی لازم نیست.
- جمعآوری قیمت و کاتالوگ از سایتهای فراوان: همروندی با سقف کوچک برای هر سایت با
asyncioو پروکسی چرخشی برای پخش بار. ساختار کلی در صفحه راهحل استخراج داده آمده است. - خزش در مقیاس بزرگ: چند پردازه که هر کدام
asyncioاجرا میکند و صفی برای هر دامنه. بخش مقیاسپذیری در صفحه راهحل وب کراولر آمده است. - سریعتر کردن اسکریپت Requests موجود: با
ThreadPoolExecutorو بدون بازنویسی کد. برای ساختار چرخشی با Requests، چرخش پروکسی در پایتون را ببینید. - صفحههایی که با جاوااسکریپت بارگذاری میشوند: چون مرورگر headless پردازنده و حافظه زیادی مصرف میکند، تعداد مرورگرهای همزمان را هستهها و حافظه محدود میکنند.
اشتباهات رایج
- پخش کار وابسته به I/O میان پردازهها. هزینه راهاندازی و حافظه پردازهها را میپردازید اما سرعت بهتر نمیشود، چون گلوگاه شبکه است.
- استفاده از کتابخانه همگام درون
asyncio. فراخوانیrequests.get()درونasync defحلقه رویداد را متوقف میکند و همه کارها پشت سر هم اجرا میشوند. gatherیاPromise.allنامحدود. آغاز هزاران درخواست یکجا به خطای اتصال و محدودیت نرخ سایت مقصد میانجامد.- ساختن کلاینت تازه برای هر درخواست. اتصالها دوباره به کار نمیروند و هر درخواست handshake تازه TLS انجام میدهد.
- نگاه به زمان کل بدون نگاه به نرخ خطا. کاری که سریعتر میشود اما نرخ
429آن بالا میرود، داده کمتری جمع میکند. - تحلیل در رشته اصلی Node.js. در سندهای بزرگ حلقه رویداد متوقف میشود و پاسخهای منتظر ممکن است timeout شوند.
راهنمای انتخاب
| وضعیت شما | پیشنهاد |
|---|---|
| صفحههای فراوان، HTML کوچک | asyncio همراه HTTPX یا AIOHTTP، محدود با Semaphore |
| کد Requests همگام موجود | ThreadPoolExecutor |
| صفحههای بزرگ، تحلیل سنگین | دانلود با asyncio، تحلیل با ProcessPoolExecutor |
| دانلود صفحه با Node.js | Promise همراه محدودکننده همروندی |
| تحلیل سنگین در Node.js | worker_threads |
| مرورگر headless | چند مرورگر همزمان متناسب با هستهها و حافظه |
نرخ 429 بالا میرود | همروندی را کم کنید، برای هر سایت سقف بگذارید |
| سرعت بهتر نمیشود و خطایی هم نیست | گلوگاه را بسنجید: پروکسی، باند، سرور مقصد |
پرسشهای متداول
آیا همروندی و موازیسازی یکیاند؟
نه. همروندی یعنی کارها در یک بازه زمانی پیش بروند و روی یک هسته هم ممکن است. موازیسازی یعنی کارها واقعاً همزمان روی چند هسته اجرا شوند. هر سامانه موازی همروند است، اما هر سامانه همروندی موازی نیست.
برای اسکرپینگ asyncio بهتر است یا رشتهها؟
اگر از صفر مینویسید و درخواستهای فراوانی خواهید فرستاد، asyncio با منابع کمتر درخواستهای همروند بیشتری را مدیریت میکند. اگر کد موجودتان با Requests نوشته شده و درخواستهای همروند از چند صد بیشتر نمیشود، ThreadPoolExecutor سادهترین راه سریعتر کردن آن بدون بازنویسی است.
آیا GIL اسکرپینگ را کند میکند؟
چون GIL هنگام درخواستهای شبکه آزاد میشود، در عمل دانلود صفحهها را کند نمیکند. در گامهای پرمصرف پردازنده مانند تحلیل، رشتهها نمیتوانند همزمان کد پایتون اجرا کنند؛ برای این گامها مخزن پردازه به کار میرود.
آیا IPهای پروکسی بیشتر سرعت را بالا میبرد؟
اگر سایت مقصد محدودیت نرخ را بر اساس IP بشمارد، پخش بار ممکن است به شما اجازه دهد در همان زمان به صفحههای بیشتری برسید. اما اگر سایت محدودیت را با معیارهای دیگر اعمال کند یا گلوگاه باند یا ظرفیت سرور مقصد باشد، تعداد IP نتیجه را تغییر نمیدهد. آسیب نرساندن به سایت مسئولیتی است که به تعداد IPهای شما بستگی ندارد.
اگر همروندی را بیش از حد بالا بگذارم چه میشود؟
در سمت شما به سقف مخزن اتصال و دستگیره فایل میرسید؛ در سمت مقصد پاسخهای 429، timeout و سایتی کندتر پدید میآید. از جایی به بعد صفحههای تکمیلشده دیگر زیاد نمیشوند و خطاها زیاد میشوند.
همروندی در مرورگرهای headless چگونه تنظیم میشود؟
هر نمونه مرورگر حافظه و پردازنده قابل توجهی مصرف میکند، پس تعداد صفحههای همزمان را منابع ماشین محدود میکنند نه شبکه. با چند نمونه مرورگر شروع کنید و با زیر نظر گرفتن مصرف حافظه و پردازنده افزایش دهید. اینکه مرورگر headless کی واقعاً لازم است در صفحههای ایستا و پویا آمده است.
خلاصه
همروندی از زمان انتظار و موازیسازی از هستههای پردازنده بهره میگیرد. چون در اسکرپینگ بیشتر زمان صرف انتظار برای پاسخ شبکه میشود، سرعت بیشتر با همروندی محدودی بالا میرود که با asyncio، رشتهها یا حلقه رویداد Node.js ساخته شود؛ مخزن پردازه فقط برای گامهای پرمصرف پردازنده مانند تحلیل سنگین و مرورگر headless لازم است. سقف را معمولاً محدودیت نرخ سایت مقصد، ظرفیت پروکسی و باند تعیین میکنند. همروندی را با سنجش تعیین کنید نه با حدس، و برای هر سایت سقف بگذارید. برای جمعآوری داده در مقیاس بزرگ، نگاهی به خدمات پروکسی ما بیندازید.




