超牛逼的異步協程爬蟲

寫在前面:
本來這篇文章只是用來記錄一下學習異步協程爬蟲的筆記,感謝CSDN的大力支持,引來了很多關注和瀏覽,也有很多大佬的批評指針。
事先聲明:本文只是學習使用,在爬蟲的實戰應用中還要添加諸多限制,比如UA僞裝,添加timeout,設置代理等等。
學習爬蟲過程中的代碼都放在了GitHub上:https://github.com/koking0/Spider
在此感謝以下大佬的批評指針:
血色v殘陽
熱愛造輪子的程序員

一、引入

如果因爲 IO 阻塞導致被操作系統強行剝奪走 CPU 的執行權限,程序的執行效率會降低了下來。

想要解決這個問題,我們可以自己從應用程序級別檢測 IO 阻塞,如果阻塞就切換到程序的其它任務,這樣就可以將程序的 IO 降到最低,程序處於就緒態就會增多,以此來迷惑操作系統。

操作系統會以爲我們的程序是 IO 較少的程序,從而會儘可能多的分配到 CPU,這樣也就達到了提升程序執行效率的目的。

在 Python 3.4 之後新增了 asyncio 模塊,可以幫助我們檢測 IO 阻塞,通過它可以幫助我們實現異步 IO。

注意:asyncio 只能發 TCP 級別的請求,不能發 HTTP 協議的請求。

  1. 什麼是異步 IO
    所謂的異步 IO,就是發起一個 IO 阻塞的操作,但是不用等到它結束,可以在它執行 IO 的過程中繼續做別的事情,當 IO 執行完畢之後會收到它的通知。

  2. 實現異步 IO 的方式

通過單線程+異步協程的方式可以實現異步 IO 操作。

二、異步協程

在將異步協程之前,我們需要了解以下幾個概念:

1. event_loop

事件循環,相當於一個無限循環。我們可以把一些函數註冊到這個事件循環上,當滿足某些條件的時候,函數就會被循環執行。

程序是按照設定的順序從頭執行到尾,運行的次數也是完全按照設定。在編寫程序時,如果有一部分程序的運行耗時是比較久的,需要先讓出其控制權,讓它在後臺運行,其它的程序可以先運行起來。

當後臺運行的程序完成後,也需要及時通知主程序已經完成任務可以進行下一步操作,但這個過程所需的時間是不確定的,需要主程序不斷的監聽狀態,一旦收到了任務完成的消息,就開始進行下一步。

loop就是這個持續不斷的監視器。

2. coroutine

中文翻譯叫協程,在 Python 中昌指代爲協程對象類型,可以將協程對象註冊到時間循環中被調用。使用 async 關鍵字來定義的方法在調用時不會立即執行,而是返回一個協程對象。

# 首先引入 asyncio 包,這樣才能使用 async 和 await
import asyncio


# 使用 async 定義一個 execute 方法,接收一個參數並打印
async def execute(x):
	print("Number = ", x)

# 此時調用 execute 函數並不會執行,而是返回一個協程對象
coroutine = execute(1)
print("coroutine:", coroutine)
print("After calling execute.")

# 然後使用 get_event_loop 方法創建一個事件循環 loop
loop = asyncio.get_event_loop()
# 之後調用 loop 對象的 run_until_complete 方法將協程對象註冊到事件循環 loop 中並啓動,函數才能運行
loop.run_until_complete(coroutine)
print("After calling loop.")

執行結果爲:

coroutine: <coroutine object execute at 0x000001C714A91A48>
After calling execute.
Number =  1
After calling loop.

3. task

任務,它是對協程對象的進一步封裝,包含了任務的各個狀態,比如 running、finished 等,可以用這些狀態來獲取協程對象的執行情況。

import asyncio


async def execute(x):
    print("Number = ", x)
    return x


if __name__ == '__main__':
    coroutine = execute(1)
    print("Coroutine:", coroutine)
    print("After calling execute.")

    loop = asyncio.get_event_loop()
    task = loop.create_task(coroutine)
    print("Task:", task)
    loop.run_until_complete(task)
    print("Task:", task)
    print("After calling loop.")

執行結果爲:

Coroutine: <coroutine object execute at 0x0000022105D1FB48>
After calling execute.
Task: <Task pending coro=<execute() running at G:/Python/Spider/4.高性能異步爬蟲/02.協程/2.第一個task.py:16>>
Number =  1
Task: <Task finished coro=<execute() done, defined at G:/Python/Spider/4.高性能異步爬蟲/02.協程/2.第一個task.py:16> result=1>
After calling loop.

4. future

代表將來執行或還沒有執行的任務,實際上和 task 沒有本質區別。通過 asyncio 的 ensure_future() 方法也可以返回一個 task 對象,這樣可以不藉助於 loop 來定義。

import asyncio


async def execute(x):
    print("Number = ", x)


if __name__ == '__main__':
    coroutine = execute(1)
    print("Coroutine:", coroutine)
    print("After calling execute.")

    task = asyncio.ensure_future(coroutine)
    print("Task:", task)
    loop = asyncio.get_event_loop()
    loop.run_until_complete(task)
    print("Task:", task)
    print("After calling loop.")

執行結果爲:

Coroutine: <coroutine object execute at 0x000001A6BD67FC48>
After calling execute.
Task: <Task pending coro=<execute() running at G:/Python/Spider/4.高性能異步爬蟲/02.協程/3.第一個ensure_future.py:16>>
Number =  1
Task: <Task finished coro=<execute() done, defined at G:/Python/Spider/4.高性能異步爬蟲/02.協程/3.第一個ensure_future.py:16> result=None>
After calling loop.

5. 綁定回調

可以爲某個 task 綁定一個回調方法。

import asyncio
import requests


async def request():
    url = "https://www.baidu.com"
    status = requests.get(url=url).status_code
    return status


def callback(task):
    print("Status:", task.result())


if __name__ == '__main__':
    coroutine = request()
    task = asyncio.ensure_future(coroutine)
    task.add_done_callback(callback)
    print("Task:", task)

    loop = asyncio.get_event_loop()
    loop.run_until_complete(task)
    print("Task:", task)

通過 requests() 方法請求百度,接收其返回的狀態碼,然後定義一個 callback() 方法,接收一個 task 對象,通過 result() 方法打印其返回結果,最後調用 add_done_callback() 方法就可以給 coroutine 對象添加回調函數了,當 coroutine 對象執行完畢之後,就會執行其回調方法。

執行結果爲:

Task: <Task pending coro=<request() running at G:/Python/Spider/4.高性能異步爬蟲/02.協程/4.綁定回調.py:17> cb=[callback() at G:/Python/Spider/4.高性能異步爬蟲/02.協程/4.綁定回調.py:23]>
Status: 200
Task: <Task finished coro=<request() done, defined at G:/Python/Spider/4.高性能異步爬蟲/02.協程/4.綁定回調.py:17> result=200>

三、多任務協程

目前爲止,我們的協程還是隻執行一個任務,我們的目的是想它能夠同時執行多個任務,爲此我們可以定義一個 task 列表存放多個任務對象。

import time
import asyncio
import requests


async def getPage(name, url):
	print("正在爬取%s......" % name)
	response = requests.get(url=url).text
	with open("%s.html" % name, "w", encoding="utf-8") as fp:
		fp.write(response)
	print("%s爬取完畢......" % name)


if __name__ == '__main__':
	startTime = time.time()
	urlDict = {
		"百度搜索": "https://www.baidu.com/",
		"百度翻譯": "https://fanyi.baidu.com/",
		"CSDN": "https://www.csdn.net/",
		"博客園": "https://www.cnblogs.com/",
		"嗶哩嗶哩": "https://www.bilibili.com/",
		"碼雲": "https://gitee.com/",
		"拉勾網": "https://www.lagou.com/",
	}
	taskList = []
	for key, value in urlDict.items():
		request = getPage(key, value)
		task = asyncio.ensure_future(request)
		taskList.append(task)

	loop = asyncio.get_event_loop()
	loop.run_until_complete(asyncio.wait(taskList))
	print("Time consuming:", time.time() - startTime)

輸出結果爲:

正在爬取百度搜索......
百度搜索爬取完畢......
正在爬取百度翻譯......
百度翻譯爬取完畢......
正在爬取CSDN......
CSDN爬取完畢......
正在爬取博客園......
博客園爬取完畢......
正在爬取嗶哩嗶哩......
嗶哩嗶哩爬取完畢......
正在爬取碼雲......
碼雲爬取完畢......
正在爬取拉勾網......
拉勾網爬取完畢......
Time consuming: 2.6479198932647705

總耗時大概是2.65秒,你是不是覺得這就很快了?其實還有更快的代碼:

import time
import asyncio
import aiohttp


async def getPage(name, url):
	print("正在爬取%s......" % name)
	async with aiohttp.ClientSession() as session:
		async with await session.get(url) as response:
			responseText = await response.text()
			save(name, responseText)
	print("%s爬取完畢......" % name)


def save(name, response):
	with open("%s.html" % name, "w", encoding="utf-8") as fp:
		fp.write(response)


if __name__ == '__main__':
	startTime = time.time()
	urlDict = {
		"百度搜索": "https://www.baidu.com/",
		"百度翻譯": "https://fanyi.baidu.com/",
		"CSDN": "https://www.csdn.net/",
		"博客園": "https://www.cnblogs.com/",
		"嗶哩嗶哩": "https://www.bilibili.com/",
		"碼雲": "https://gitee.com/",
		"拉勾網": "https://www.lagou.com/",
	}
	taskList = []
	for key, value in urlDict.items():
		request = getPage(key, value)
		task = asyncio.ensure_future(request)
		taskList.append(task)

	loop = asyncio.get_event_loop()
	loop.run_until_complete(asyncio.wait(taskList))
	print("Time consuming:", time.time() - startTime)

輸出結果爲:

正在爬取百度搜索......
正在爬取百度翻譯......
正在爬取CSDN......
正在爬取博客園......
正在爬取嗶哩嗶哩......
正在爬取碼雲......
正在爬取拉勾網......
百度搜索爬取完畢......
博客園爬取完畢......
百度翻譯爬取完畢......
碼雲爬取完畢......
嗶哩嗶哩爬取完畢......
拉勾網爬取完畢......
CSDN爬取完畢......
Time consuming: 0.9793801307678223

大約0.98秒就可以爬完所有的網頁。

這是因爲第一種方法並不是真正的異步請求,在異步協程中如果出現同步模塊相關的代碼則無法實現異步,比如requests.get()屬於同步模塊的代碼。

要想實現真正的異步協程爬蟲必須使用基於異步的網絡請求模塊,所以要使用 aiohttp 模塊,這個模塊需要安裝:

pip install -i http://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com aiohttp

它的使用與 requests 模塊類似,需要注意的是,aiohttp 獲取響應數據操作之前一定要使用 await 進行掛起。

在執行協程的時候,如果遇到了 await,那麼就會將當前協程掛起,轉而執行其它的協程,直到其它協程也掛起或執行完畢,再進行下一個協程的執行。

異步協程的便捷之處就在於,當遇到阻塞操作時,任務被掛起,程序接着執行其它的任務,這樣可以充分利用 CPU 時間片,而不必把時間都浪費在等待 IO 上,把這個運用在爬蟲上則可以在相同的時間內實現成百上千此的網絡請求。


小生才疏學淺,如有謬誤,恭請指正。
寫在最後:
通過寫爬蟲來練習協程的時候是可以的,實際應用中爬蟲線程池很香
歡迎來學習哦
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章