一、併發和並行
併發(concurrency)和並行( parallelism)是兩個相似的概念。 引用一個比較容易理解的說法,併發是指在一個時間段內發生若干事件的情況,並行是指在同一時刻發生若干事件的情況。
這個概念用單核CPU和多核CPU比較容易說明。在使用單核CPU時,多個工作任務是以併發的方式運行的,因爲只有一個CPU,所以各個任務會分別佔用CPU的一段時間依次執行。如果在自己分得的時間段沒有完成任務,就會切換到另一個任務,然後在下一次得到CPU使用權的時候再繼續執行,直到完成。在這種情況下,因爲各個任務的時間段很短、經常切換,所以給我們的感覺是“同時”進行。在使用多核CPU時,在各個核的任務能夠同時運行,這是真正的同時運行,也就是並行。
二、同步和異步
同步和異步也是兩個值得比較的概念。下面在併發和並行框架的基礎上理解同步和異步。
同步就是併發或並行的各個任務不是獨自運行的,任務之間有一定的交替順序,可能在運行完一個任務得到結果後,另一個任務纔會開始運行。就像接力賽跑一樣,要拿到交接棒之後下一個選手纔可以開始跑。
異步則是併發或並行的各個任務可以獨立運行,一個任務的運行不受另一個任務影響,任務之間就像比賽的各個選手在不同的賽道比賽一樣, 跑步的速度不受其他賽道選手的影響。
在網絡爬蟲中,假設你需要打開4個不同的網站,IO過程就相當於你打開網站的過程,CPU就是你單擊的動作。你單擊的動作很快,但是網站卻打開得很慢,同步IO是指你每單擊一個網址,要等待該網站徹底顯示纔可以單擊下一個網站。異步IO是指你單擊定一個網址,不用等對方服務器返回結果,立馬可以用新打開的測覽器窗口打開另一個網址,最後同時等待4個網站徹底打開。
很明顯,異步的速度要快得多。
三、多線程爬蟲
多線程爬蟲是以併發的方式執行的。也就是說,多個線程並不能真正的同時執行,而是通過進程的快速切換加快網絡爬蟲速度的。
Python本身的設計對多線程的執行有所限制。在Python設計之初,爲了數據安全所做的決定設置有GIL(Global Interpreter Lock,全局解釋器鎖)。在Python中,一個線程的執行過程包括獲取GIL、執行代碼直到掛起和釋放GIL。
例如,某個線程想要執行,必須先拿到GIL,我們可以把GIL看成“通行證”,並且在一個Python進程中,GIL只有一個。拿不到通行證的進程就不允許進入CPU執行。
每次釋放GIL鎖,線程之間都會進行鎖競爭,而切換線程會消耗資源。由於GIL鎖的存在,Python 裏一個進程永遠只能同時執行一個線程(拿到GIL的線程才能執行),這就是在多核CPU上Python的多線程效率不高的原因。
由於GIL的存在,多線程是不是就沒用了呢?
以網絡爬蟲來說,網絡爬蟲是IO密集型,多線程能夠有效地提升效率,因爲單線程下有IO操作會進行IO等待,所以會造成不必要的時間浪費,而開啓多線程能在線程A等待時自動切換到線程B,可以不浪費CPU的資源,從而提升程序執行的效率。
Python的多線程對於IO密集型代碼比較友好,網絡爬蟲能夠在獲取網頁的過程中使用多線程,從而加快速度。
下面將以獲取訪問量最大的1000箇中文網站的速度爲例,通過和單線程的爬蟲比較,證實多線程方法的速度提升。
1000個網址寫在alexa.txt文件中。
3.1 簡單單線程爬蟲
import requests
import time
link_list = []
with open('alexa.txt', 'r') as file:
file_list = file.readlines()
for eachone in file_list:
link = eachone.split('\t')[1]
link = link.replace('\n','')
link_list.append(link)
start = time.time()
for eachone in link_list:
try:
r = requests.get(eachone)
print(r.status_code, eachone)
except Exception as e:
print("Error: ", e)
end = time.time()
print("串行的總時間爲:", end-start)
得到的時間結果大概是:2030.428秒
3.2 簡單的多線程爬蟲
import threading
import time
import requests
link_list = []
with open('alexa.txt', 'r') as file:
file_list = file.readlines()
for eachone in file_list:
link = eachone.split('\t')[1]
link = link.replace('\n','')
link_list.append(link)
start = time.time()
class MyThread(threading.Thread):
def __init__(self, name, link_range):
threading.Thread.__init__(self)
self.name = name
self.link_range = link_range
def run(self):
print("Starting " + self.name)
crawler(self.name, self.link_range)
print("Exiting " + self.name)
def crawler(threadName, link_range):
for i in range(link_range[0], link_range[1]+1):
try:
r = requests.get(link_list[i], timeout=20)
print(threadName, r.status_code, link_list[i])
except Exception as e:
print(threadName, 'Error: ', e)
thread_list = []
link_range_list = [(0,200), (201,400), (401,600), (601,800), (801,1000)]
#創建新線程
for i in range(1,6):
thread = MyThread("Thread-" + str(i), link_range_list[i-1])
thread.start()
thread_list.append(thread)
#等待所有線程完成
for thread in thread_list:
thread.join()
end = time.time()
print("簡單多線程爬蟲總時間爲:", end-start)
print("Exiting Main Thread")
結果大概是532.81秒,明顯快了很多。
3.3 使用Queue的多線程爬蟲
這裏,我們不再分給每個線程分配固定個數個網址,因爲某個線程可能先於其他線程完成任務,然後就會空閒,這就造成了浪費。我們用一個隊列存儲所有網址,每個線程每次都從隊列裏取一個網址來執行。
import threading
import time
import requests
import queue as Queue
link_list = []
with open('alexa.txt', 'r') as file:
file_list = file.readlines()
for eachone in file_list:
link = eachone.split('\t')[1]
link = link.replace('\n','')
link_list.append(link)
start = time.time()
class MyThread(threading.Thread):
def __init__(self, name, q):
threading.Thread.__init__(self)
self.name = name
self.q = q
def run(self):
print("Starting " + self.name)
while True:
try:
crawler(self.name, self.q)
except:
break
print("Exiting " + self.name)
def crawler(threadName, q):
url = q.get(timeout=2)
try:
r = requests.get(url, timeout=20)
print(q.qsize(), threadName, r.status_code, url)
except Exception as e:
print(q.qsize(), threadName, url, 'Error: ', e)
threadList = ["Thread-1", "Thread-2", "Thread-3", "Thread-4", "Thread-5"]
workQueue = Queue.Queue(1000)
threads = []
for url in link_list:
workQueue.put(url)
#創建新線程
for tName in threadList:
thread = MyThread(tName, workQueue)
thread.start()
threads.append(thread)
#等待所有線程完成
for thread in threads:
thread.join()
end = time.time()
print("Queue多線程爬蟲總時間爲:", end-start)
print("Exiting Main Thread")
結果大概是441.40秒,又快了很多。
四、Python使用多線程的兩種方法
(1)函數式:調用_thread模塊中的start_new_thread()函數產生新線程:
import _thread
import time
#爲線程定義一個函數
def print_time(threadName, delay):
count = 0
while count < 3:
time.sleep(delay)
count += 1
print(threadName, time.ctime())
_thread.start_new_thread(print_time, ("Thread-1", 1))
_thread.start_new_thread(print_time, ("Thread-2", 2))
print("Main Finished")
輸出結果如下:
Main Finished
Thread-1 Wed Apr 8 21:44:57 2020
Thread-2 Wed Apr 8 21:44:58 2020
Thread-1 Wed Apr 8 21:44:58 2020
Thread-1 Wed Apr 8 21:44:59 2020
Thread-2 Wed Apr 8 21:45:00 2020
Thread-2 Wed Apr 8 21:45:02 2020
(2)類包裝式:調用Threading庫創建線程,從threading.Thread繼承
- run():用以表示線程活動的方法。
- start():啓動線程活動。
- join([time]):等待至線程至終止。阻塞調用線程直至線程的join()方法被調用爲止。
- isAlive():返回線程是否是活動的。
- getName():返回線程名。
- setName():設置線程名。
import threading
import time
class MyThread(threading.Thread):
def __init__(self, name, delay):
threading.Thread.__init__(self)
self.name = name
self.delay = delay
def run(self):
print("Starting " + self.name)
print_time(self.name, self.delay)
print("Exiting " + self.name)
def print_time(threadingName, delay):
counter = 0
while counter < 3:
time.sleep(delay)
print(threadingName, time.ctime())
counter += 1
threads = []
#創建新線程
thread1 = MyThread("Thread-1", 1)
thread2 = MyThread("Thread-2", 2)
#開啓新線程
thread1.start()
thread2.start()
#添加線程到線程列表
threads.append(thread1)
threads.append(thread2)
#等待所有線程完成
for t in threads:
t.join()
print("Exiting Main Thread")
結果如下:
Starting Thread-1
Starting Thread-2
Thread-1 Wed Apr 8 21:53:14 2020
Thread-1 Wed Apr 8 21:53:15 2020
Thread-2 Wed Apr 8 21:53:15 2020
Thread-1 Wed Apr 8 21:53:16 2020
Exiting Thread-1
Thread-2 Wed Apr 8 21:53:17 2020
Thread-2 Wed Apr 8 21:53:19 2020
Exiting Thread-2
Exiting Main Thread