[英]aiohttp: rate limiting parallel requests
API 通常有用戶必須遵守的速率限制。 例如,讓我們以 50 個請求/秒為例。 順序請求需要 0.5-1 秒,因此太慢而無法接近該限制。 但是,使用 aiohttp 的並行請求超出了速率限制。
為了盡可能快地輪詢 API,需要限制並行調用的速率。
到目前為止,我發現的示例裝飾session.get
,大致如下:
session.get = rate_limited(max_calls_per_second)(session.get)
這適用於順序調用。 嘗試在並行調用中實現這一點無法按預期工作。
這里有一些代碼作為例子:
async with aiohttp.ClientSession() as session:
session.get = rate_limited(max_calls_per_second)(session.get)
tasks = (asyncio.ensure_future(download_coroutine(
timeout, session, url)) for url in urls)
process_responses_function(await asyncio.gather(*tasks))
這樣做的問題是它會對任務的排隊進行速率限制。 使用gather
的執行仍或多或少會同時發生。 兩全其美;-)。
是的,我在這里發現了一個類似的問題aiohttp: set maximum number of requests per second ,但兩個回復都沒有回答限制請求率的實際問題。 此外,來自 Quentin Pradet 的博客文章僅適用於限制排隊的速率。
要包起來:一個如何限制每秒請求數並行的數量aiohttp
請求?
如果我理解你,你想限制同時請求的數量?
asyncio
有一個名為Semaphore
的對象,它的工作方式類似於異步RLock
。
semaphore = asyncio.Semaphore(50)
#...
async def limit_wrap(url):
async with semaphore:
# do what you want
#...
results = asyncio.gather([limit_wrap(url) for url in urls])
假設我發出 50 個並發請求,它們都在 2 秒內完成。 所以,它沒有觸及限制(每秒只有 25 個請求)。
這意味着我應該發出 100 個並發請求,它們也都在 2 秒內完成(每秒 50 個請求)。 但是在您實際提出這些請求之前,您如何確定它們將完成多長時間?
或者,如果您不介意每秒完成的請求數,而是每秒發出的請求數。 你可以:
async def loop_wrap(urls):
for url in urls:
asyncio.ensure_future(download(url))
await asyncio.sleep(1/50)
asyncio.ensure_future(loop_wrap(urls))
loop.run_forever()
上面的代碼將每1/50
秒創建一個Future
實例。
我通過使用基於漏桶算法的aiohttp.ClientSession()
創建aiohttp.ClientSession()
的子類來解決這個問題。 我使用asyncio.Queue()
而不是Semaphores
進行asyncio.Queue()
。 我只覆蓋了_request()
方法。 我發現這種方法更清晰,因為您只將session = aiohttp.ClientSession()
替換為session = ThrottledClientSession(rate_limit=15)
。
class ThrottledClientSession(aiohttp.ClientSession):
"""Rate-throttled client session class inherited from aiohttp.ClientSession)"""
MIN_SLEEP = 0.1
def __init__(self, rate_limit: float =None, *args,**kwargs) -> None:
super().__init__(*args,**kwargs)
self.rate_limit = rate_limit
self._fillerTask = None
self._queue = None
self._start_time = time.time()
if rate_limit != None:
if rate_limit <= 0:
raise ValueError('rate_limit must be positive')
self._queue = asyncio.Queue(min(2, int(rate_limit)+1))
self._fillerTask = asyncio.create_task(self._filler(rate_limit))
def _get_sleep(self) -> list:
if self.rate_limit != None:
return max(1/self.rate_limit, self.MIN_SLEEP)
return None
async def close(self) -> None:
"""Close rate-limiter's "bucket filler" task"""
if self._fillerTask != None:
self._fillerTask.cancel()
try:
await asyncio.wait_for(self._fillerTask, timeout= 0.5)
except asyncio.TimeoutError as err:
print(str(err))
await super().close()
async def _filler(self, rate_limit: float = 1):
"""Filler task to fill the leaky bucket algo"""
try:
if self._queue == None:
return
self.rate_limit = rate_limit
sleep = self._get_sleep()
updated_at = time.monotonic()
fraction = 0
extra_increment = 0
for i in range(0,self._queue.maxsize):
self._queue.put_nowait(i)
while True:
if not self._queue.full():
now = time.monotonic()
increment = rate_limit * (now - updated_at)
fraction += increment % 1
extra_increment = fraction // 1
items_2_add = int(min(self._queue.maxsize - self._queue.qsize(), int(increment) + extra_increment))
fraction = fraction % 1
for i in range(0,items_2_add):
self._queue.put_nowait(i)
updated_at = now
await asyncio.sleep(sleep)
except asyncio.CancelledError:
print('Cancelled')
except Exception as err:
print(str(err))
async def _allow(self) -> None:
if self._queue != None:
# debug
#if self._start_time == None:
# self._start_time = time.time()
await self._queue.get()
self._queue.task_done()
return None
async def _request(self, *args,**kwargs):
"""Throttled _request()"""
await self._allow()
return await super()._request(*args,**kwargs)
```
我喜歡 @sraw 用 asyncio 解決這個問題,但他們的回答對我來說並沒有完全切合實際。 因為我不知道我的下載調用是否會比速率限制更快或更慢,所以我希望可以選擇在請求緩慢時並行運行多個請求,並在請求非常快時一次運行一個我總是正確的速率限制。
我通過使用一個帶有生產者的隊列來以速率限制生成新任務,然后許多消費者要么全部等待下一個工作,如果他們很快,要么在隊列中備份工作,如果他們是慢,並且會以處理器/網絡允許的速度運行:
import asyncio
from datetime import datetime
async def download(url):
# download or whatever
task_time = 1/10
await asyncio.sleep(task_time)
result = datetime.now()
return result, url
async def producer_fn(queue, urls, max_per_second):
for url in urls:
await queue.put(url)
await asyncio.sleep(1/max_per_second)
async def consumer(work_queue, result_queue):
while True:
url = await work_queue.get()
result = await download(url)
work_queue.task_done()
await result_queue.put(result)
urls = range(20)
async def main():
work_queue = asyncio.Queue()
result_queue = asyncio.Queue()
num_consumer_tasks = 10
max_per_second = 5
consumers = [asyncio.create_task(consumer(work_queue, result_queue))
for _ in range(num_consumer_tasks)]
producer = asyncio.create_task(producer_fn(work_queue, urls, max_per_second))
await producer
# wait for the remaining tasks to be processed
await work_queue.join()
# cancel the consumers, which are now idle
for c in consumers:
c.cancel()
while not result_queue.empty():
result, url = await result_queue.get()
print(f'{url} finished at {result}')
asyncio.run(main())
我想我有一部分解決方案。 在這里,我用 url 填充一個隊列,然后產生 25 個協程,每個協程在隊列上循環。 我正在向 scraperAPI.com 發出請求,並且它們有 25 個並發線程限制,因此我使用了信號量。 正如您提到的,問題之一是當您運行 gather() 時,它會立即執行所有內容。 您需要做的是在每個任務之前使用帶有等待的 create_task ,這樣它們就不會同時被執行。 使用 create_task 創建的任何任務都會立即運行。
注意:我意識到我每次調用都會創建一個客戶端會話,如果您只想在每個循環中創建一個,那么您可以將async with ClientSession(connector=aiohttp.TCPConnector(limit=1), timeout=ClientTimeout(total=40), raise_for_status=True) as session:
例如在 while 之前的 getData() 函數中。
通過這種設置,我可以運行 25 個協程,並且不會違反 25 個並發連接規則。
就問題而言,這里的關鍵是在await asyncio.sleep(1.1)
每次 create_task() 調用之前的await asyncio.sleep(1.1)
)。
async def send_request(sem, url, routine):
start_time = time.time()
print(f"{routine}, sending request: {datetime.now()}")
params = {
'api_key': 'nunya',
'url': '%s' % url,
'render_js': 'false',
'premium_proxy': 'false',
'country_code':'us'
}
try:
async with sem:
async with ClientSession(connector=aiohttp.TCPConnector(limit=1), timeout=ClientTimeout(total=40), raise_for_status=True) as session:
async with session.get(url='http://api.scraperapi.com',params=params,) as response:
data = await response.content.read()
print(f"{routine}, done request: {time.time() - start_time} seconds")
return data
except Exception as e:
errors.append(url)
print(f"here is the error: {e}, {datetime.now()}, {routine} {url}, {time.time() - start_time} seconds")
async def getData(sem, q, test):
while True:
if not q.empty():
url = q.get_nowait()
resp = await send_request(sem, url ,test)
##non async call which parses the data
processData(resp, test, url)
else:
print('done')
break
async def async_payload_wrapper(sem):
tasks = []
q = asyncio.Queue()
for url in urls:
await q.put(url)
for i in range(THREADS):
await asyncio.sleep(1.1)
tasks.append(
asyncio.create_task(getData(sem, q, ''.join(random.choice(string.ascii_lowercase) for i in range(10))))
)
await asyncio.gather(*tasks)
if __name__ == '__main__':
sem = asyncio.Semaphore(25)
asyncio.run(async_payload_wrapper(sem))
我開發了一個名為 octopus-api ( https://pypi.org/project/octopus-api/ ) 的庫,它使您能夠在后台使用 aiohttp 限制速率並設置對端點的並發 api 調用數。 它的目標是簡化所有需要的 aiohttp 設置。
下面是一個如何使用它的例子,其中 get_ethereum 是用戶定義的請求函數:
from octopus_api import TentacleSession, OctopusApi
from typing import Dict, List
if __name__ == '__main__':
async def get_ethereum(session: TentacleSession, request: Dict):
async with session.get(url=request["url"], params=request["params"]) as response:
body = await response.json()
return body
client = OctopusApi(rate=50, resolution="sec", concurrency=6)
result: List = client.execute(requests_list=[{
"url": "https://api.pro.coinbase.com/products/ETH-EUR/candles?granularity=900&start=2021-12-04T00:00:00Z&end=2021-12-04T00:00:00Z",
"params": {}}] * 1000, func=get_ethereum)
print(result)
TentacleSession 的工作方式與您為 aiohttp 編寫 POST、GET、PUT 和 PATCH 的方式相同。
讓我知道它是否有助於您解決與速率限制和並行調用相關的問題。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.