【Python爬蟲】—— 多進程基本原理

多進程的含義

進程(Process)
是具有一定獨立功能的程序關於某個數據集合上的一次運行活動,是系統進行資源分配和調度的一個獨立單位

多進程就是啓用多個進程同時運行。


Python 多進程的優勢

由於進程中 GIL 的存在,Python 中的多線程並不能很好地發揮多核優勢,一個進程中的多個線程,同一時刻只能有一個線程運行

在 Python 多線程下,每個線程的執行方式如下:

  • 獲取 GIL
  • 執行對應線程的代碼
  • 釋放 GIL

對於多進程來說,每個進程都有屬於自己的 GIL,所以在多核處理器下,多進程的運行是不會受 GIL 的影響的,多進程能更好地發揮多核的優勢

Python 的多進程整體來看是比多線程更有優勢,在條件允許的情況下,能用多進程就儘量用多進程。

由於進程是系統進行資源分配和調度的一個獨立單位,所以各個進程之間的數據是無法共享的。


多進程的實現

  • 直接使用Process類

在 multiprocessing 中,每進程都用一個 Process 類來表示。

API 調用:Process([group [, target [, name [, args [, kwargs]]]]])

  • target 表示調用對象,可以傳入方法的名字
  • args 表示被調用對象的位置參數元組
    比如 target 是函數 func,它有兩個參數 m,n,那麼 args 就傳入 [m, n] 即可。
  • kwargs 表示調用對象的字典
  • name 是別名,相當於給這個進程取一個名字
  • group 分組

實例:
import multiprocessing


def process(index):
    print(f'Process: {index}')


if __name__ == '__main__':
    for i in range(5):
        p = multiprocessing.Process(target=process, args=(i,))
        p.start()

運行結果:

Process: 0
Process: 1
Process: 2
Process: 3
Process: 4

multiprocessing 還提供了幾個比較有用的方法:

  • 通過 cpu_count 方法來獲取當前機器 CPU 的核心數量
  • 通過 active_children 方法獲取當前還在運行的所有進程
實例:
import multiprocessing
import time


def process(index):
    time.sleep(index)
    print(f'Process: {index}')


if __name__ == '__main__':
    for i in range(5):
        p = multiprocessing.Process(target=process, args=[i])
        p.start()
    print(f'CPU number: {multiprocessing.cpu_count()}')
    
    for p in multiprocessing.active_children():
        print(f'Child process name: {p.name} id: {p.pid}')
    print('Process Ended')

運行結果:

Process: 0
CPU number: 8
Child process name: Process-5 id: 73595
Child process name: Process-2 id: 73592
Child process name: Process-3 id: 73593
Child process name: Process-4 id: 73594
Process Ended
Process: 1
Process: 2
Process: 3
Process: 4
  • 繼承Process類

創建進程的方式不止一種,也可以像線程 Thread 一樣,通過繼承的方式創建一個進程類,進程的基本操作在子類的 run 方法中實現即可。

實例:
from multiprocessing import Process
import time


class MyProcess(Process):
    def __init__(self, loop):
        Process.__init__(self)
        self.loop = loop

    def run(self):
        for count in range(self.loop):
            time.sleep(1)
            print(f'Pid: {self.pid} LoopCount: {count}')


if __name__ == '__main__':
    for i in range(2, 5):
        p = MyProcess(i)
        p.start()

這裏進程的執行邏輯需要在 run 方法中實現,啓動進程需要調用 start 方法,調用之後 run 方法便會執行。

運行結果:

Pid: 73667 LoopCount: 0
Pid: 73668 LoopCount: 0
Pid: 73669 LoopCount: 0
Pid: 73667 LoopCount: 1
Pid: 73668 LoopCount: 1
Pid: 73669 LoopCount: 1
Pid: 73668 LoopCount: 2
Pid: 73669 LoopCount: 2
Pid: 73669 LoopCount: 3

這裏的進程 pid 代表進程號,不同機器、不同時刻運行結果可能不同。

通過上面的方式,我們也非常方便地實現了一個進程的定義。
爲了複用方便,我們可以把一些方法寫在每個進程類裏封裝好,在使用時直接初始化一個進程類運行即可。


守護進程

在多進程中,同樣存在守護進程的概念,如果一個進程被設置爲守護進程,當父進程結束後,子進程會自動被終止,我們可以通過設置 daemon 屬性來控制是否爲守護進程。

from multiprocessing import Process
import time


class MyProcess(Process):
    def __init__(self, loop):
        Process.__init__(self)
        self.loop = loop

    def run(self):
        for count in range(self.loop):
            time.sleep(1)
            print(f'Pid: {self.pid} LoopCount: {count}')


if __name__ == '__main__':
    for i in range(2, 5):
        p = MyProcess(i)
        p.daemon = True
        p.start()


print('Main Process ended')

運行結果:

Main Process ended

結果很簡單,因爲主進程沒有做任何事情,直接輸出一句話結束,所以在這時也直接終止了子進程的運行。

這樣可以有效防止無控制地生成子進程。這樣的寫法可以讓我們在主進程運行結束後無需額外擔心子進程是否關閉,避免了獨立子進程的運行。


進程等待

上面的運行效果其實不太符合我們預期:主進程運行結束時,子進程(守護進程)也都退出了,子進程什麼都沒來得及執行。

加入 join 方法即可讓所有子進程都執行完再結束:

processes = []

for i in range(2, 5):
    p = MyProcess(i)
    processes.append(p)
    p.daemon = True
    p.start()
    
for p in processes:
    p.join()

運行結果:

Pid: 40866 LoopCount: 0
Pid: 40867 LoopCount: 0
Pid: 40868 LoopCount: 0
Pid: 40866 LoopCount: 1
Pid: 40867 LoopCount: 1
Pid: 40868 LoopCount: 1
Pid: 40867 LoopCount: 2
Pid: 40868 LoopCount: 2
Pid: 40868 LoopCount: 3
Main Process ended

在調用 start 和 join 方法後,父進程就可以等待所有子進程都執行完畢後,再打印出結束的結果。

默認情況下,join 是無限期的。也就是說,如果有子進程沒有運行完畢,主進程會一直等待。這種情況下,如果子進程出現問題陷入了死循環,主進程也會無限等待下去。怎麼解決這個問題呢?可以給 join 方法傳遞一個超時參數,代表最長等待秒數。如果子進程沒有在這個指定秒數之內完成,會被強制返回,主進程不再會等待。也就是說這個參數設置了主進程等待該子進程的最長時間。

例如這裏我們傳入 1,代表最長等待 1 秒,代碼改寫如下:

processes = []

for i in range(3, 5):
    p = MyProcess(i)
    processes.append(p)
    p.daemon = True
    p.start()
    
for p in processes:
    p.join(1)

運行結果:

Pid: 40970 LoopCount: 0
Pid: 40971 LoopCount: 0
Pid: 40970 LoopCount: 1
Pid: 40971 LoopCount: 1
Main Process ended

可以看到,有的子進程本來要運行 3 秒,結果運行 1 秒就被強制返回了,由於是守護進程,該子進程被終止了。


終止進程

終止進程不止有守護進程這一種做法,也可以通過 terminate 方法來終止某個子進程,另外還可以通過 is_alive 方法判斷進程是否還在運行:

import multiprocessing
import time


def process():
    print('Starting')
    time.sleep(5)
    print('Finished')


if __name__ == '__main__':
    p = multiprocessing.Process(target=process)
    print('Before:', p, p.is_alive())

    p.start()
    print('During:', p, p.is_alive())

    p.terminate()
    print('Terminate:', p, p.is_alive())

    p.join()
    print('Joined:', p, p.is_alive())

此處用 Process 創建了一個進程,接着調用 start 方法啓動這個進程,然後調用 terminate 方法將進程終止,最後調用 join 方法。

另外,在進程運行不同的階段,還通過 is_alive 方法判斷當前進程是否還在運行。

運行結果:

Before: <Process(Process-1, initial)> False
During: <Process(Process-1, started)> True
Terminate: <Process(Process-1, started)> True
Joined: <Process(Process-1, stopped[SIGTERM])> False

這裏有一個值得注意的地方,在調用 terminate 方法之後,用 is_alive 方法獲取進程的狀態發現依然還是運行狀態。在調用 join 方法之後,is_alive 方法獲取進程的運行狀態才變爲終止狀態。

所以,在調用 terminate 方法之後,記得要調用一下 join 方法,這裏調用 join 方法可以爲進程提供時間來更新對象狀態,用來反映出最終的進程終止效果。


進程互斥鎖

在上面的一些實例中,可能會遇到如下的運行結果:

Pid: 73993 LoopCount: 0
Pid: 73993 LoopCount: 1
Pid: 73994 LoopCount: 0Pid: 73994 LoopCount: 1

Pid: 73994 LoopCount: 2
Pid: 73995 LoopCount: 0
Pid: 73995 LoopCount: 1
Pid: 73995 LoopCount: 2
Pid: 73995 LoopCount: 3
Main Process ended

有的輸出結果沒有換行。

這種情況是由多個進程並行執行導致的,兩個進程同時進行了輸出,結果第一個進程的換行沒有來得及輸出,第二個進程就輸出了結果,導致最終輸出沒有換行。

如果能保證多個進程運行期間的任一時間,只能一個進程輸出,其他進程等待,等剛纔那個進程輸出完畢之後,另一個進程再進行輸出,這樣就不會出現輸出沒有換行的現象了。

這種解決方案實際上就是實現了進程互斥,避免了多個進程同時搶佔臨界區(輸出)資源。

可以通過 multiprocessing 中的 Lock 來實現。

Lock,即鎖,在一個進程輸出時,加鎖,其他進程等待。等此進程執行結束後,釋放鎖,其他進程可以進行輸出。

首先實現一個不加鎖的實例:

from multiprocessing import Process, Lock
import time


class MyProcess(Process):
    def __init__(self, loop, lock):
        Process.__init__(self)
        self.loop = loop
        self.lock = lock


    def run(self):
        for count in range(self.loop):
            time.sleep(0.1)
            # self.lock.acquire()
            print(f'Pid: {self.pid} LoopCount: {count}')
            # self.lock.release()


if __name__ == '__main__':
    lock = Lock()
    for i in range(10, 15):
        p = MyProcess(i, lock)
        p.start()

運行結果:

Pid: 74030 LoopCount: 0
Pid: 74031 LoopCount: 0
Pid: 74032 LoopCount: 0
Pid: 74033 LoopCount: 0
Pid: 74034 LoopCount: 0
Pid: 74030 LoopCount: 1
Pid: 74031 LoopCount: 1
Pid: 74032 LoopCount: 1Pid: 74033 LoopCount: 1

Pid: 74034 LoopCount: 1
Pid: 74030 LoopCount: 2
...

可以看到運行結果中有些輸出已經出現了不換行的問題。

對其加鎖,取消掉剛纔代碼中的兩行註釋,重新運行。

運行結果:

Pid: 74061 LoopCount: 0
Pid: 74062 LoopCount: 0
Pid: 74063 LoopCount: 0
Pid: 74064 LoopCount: 0
Pid: 74065 LoopCount: 0
Pid: 74061 LoopCount: 1
Pid: 74062 LoopCount: 1
Pid: 74063 LoopCount: 1
Pid: 74064 LoopCount: 1
Pid: 74065 LoopCount: 1
Pid: 74061 LoopCount: 2
Pid: 74062 LoopCount: 2
Pid: 74064 LoopCount: 2
...

這時輸出效果就正常了。

所以,在訪問一些臨界區資源時,使用 Lock 可以有效避免進程同時佔用資源而導致的一些問題。


信號量

進程互斥鎖可以使同一時刻只有一個進程能訪問共享資源,如上面的例子所展示的那樣,在同一時刻只能有一個進程輸出結果。但有時候需要允許多個進程來訪問共享資源,同時還需要限制能訪問共享資源的進程的數量。

這種需求該如何實現呢?可以用信號量,信號量是進程同步過程中一個比較重要的角色。它可以控制臨界資源的數量,實現多個進程同時訪問共享資源,限制進程的併發量。

可以用 multiprocessing 庫中的 Semaphore 來實現信號量。

進程之間利用 Semaphore 做到多個進程共享資源,同時又限制同時可訪問的進程數量:

from multiprocessing import Process, Semaphore, Lock, Queue
import time


buffer = Queue(10)
empty = Semaphore(2)
full = Semaphore(0)
lock = Lock()


class Consumer(Process):
    def run(self):
        global buffer, empty, full, lock
        while True:
            full.acquire()
            lock.acquire()
            buffer.get()
            print('Consumer pop an element')
            time.sleep(1)
            lock.release()
            empty.release()


class Producer(Process):
    def run(self):
        global buffer, empty, full, lock
        while True:
            empty.acquire()
            lock.acquire()
            buffer.put(1)
            print('Producer append an element')
            time.sleep(1)
            lock.release()
            full.release()


if __name__ == '__main__':
    p = Producer()
    c = Consumer()
    p.daemon = c.daemon = True
    p.start()
    c.start()
    p.join()
    c.join()
    print('Main Process Ended')

如上代碼實現了經典的生產者和消費者問題。它定義了兩個進程類,一個是消費者,一個是生產者。

另外,這裏使用 multiprocessing 中的 Queue 定義了一個共享隊列,然後定義了兩個信號量 Semaphore,一個代表緩衝區空餘數,一個表示緩衝區佔用數。

生產者 Producer 使用 acquire 方法來佔用一個緩衝區位置,緩衝區空閒區大小減 1,接下來進行加鎖,對緩衝區進行操作,然後釋放鎖,最後讓代表佔用的緩衝區位置數量加 1,消費者則相反。

運行結果:

Producer append an element
Producer append an element
Consumer pop an element
Consumer pop an element
Producer append an element
Producer append an element
Consumer pop an element
Consumer pop an element
Producer append an element
Producer append an element
Consumer pop an element
Consumer pop an element
Producer append an element
Producer append an element

可以發現兩個進程在交替運行,生產者先放入緩衝區物品,然後消費者取出,不停地進行循環。 可以通過上面的例子來體會信號量 Semaphore 的用法,通過 Semaphore 很好地控制了進程對資源的併發訪問數量。


隊列

在上面的例子中使用 Queue 作爲進程通信的共享隊列使用。

而如果把上面程序中的 Queue 換成普通的 list,是完全起不到效果的,因爲進程和進程之間的資源是不共享的。即使在一個進程中改變了這個 list,在另一個進程也不能獲取到這個 list 的狀態,所以聲明全局變量對多進程是沒有用處的。

那進程如何共享數據呢?可以用 Queue,即隊列。這裏的隊列指的是 multiprocessing 裏面的 Queue。

依然用上面的例子,一個進程向隊列中放入隨機數據,然後另一個進程取出數據:

from multiprocessing import Process, Semaphore, Lock, Queue
import time
from random import random


buffer = Queue(10)
empty = Semaphore(2)
full = Semaphore(0)
lock = Lock()


class Consumer(Process):
    def run(self):
        global buffer, empty, full, lock
        while True:
            full.acquire()
            lock.acquire()
            print(f'Consumer get {buffer.get()}')
            time.sleep(1)
            lock.release()
            empty.release()


class Producer(Process):
    def run(self):
        global buffer, empty, full, lock
        while True:
            empty.acquire()
            lock.acquire()
            num = random()
            print(f'Producer put {num}')
            buffer.put(num)
            time.sleep(1)
            lock.release()
            full.release()


if __name__ == '__main__':
    p = Producer()
    c = Consumer()
    p.daemon = c.daemon = True
    p.start()
    c.start()
    p.join()
    c.join()
    print('Main Process Ended')

運行結果:

Producer put  0.719213647437
Producer put  0.44287326683
Consumer get 0.719213647437
Consumer get 0.44287326683
Producer put  0.722859424381
Producer put  0.525321338921
Consumer get 0.722859424381
Consumer get 0.525321338921

在上面的例子中聲明瞭兩個進程,一個進程爲生產者 Producer,另一個爲消費者 Consumer,生產者不斷向 Queue 裏面添加隨機數,消費者不斷從隊列裏面取隨機數。

生產者在放數據的時候調用了 Queue 的 put 方法,消費者在取的時候使用了 get 方法,這樣就通過 Queue 實現兩個進程的數據共享了。


管道

剛纔使用 Queue 實現了進程間的數據共享,那麼進程之間直接通信,如收發信息,用什麼比較好呢?可以用 Pipe,管道。

管道,可以把它理解爲兩個進程之間通信的通道。管道可以是單向的,即 half-duplex:一個進程負責發消息,另一個進程負責收消息;也可以是雙向的 duplex,即互相收發消息。

默認聲明 Pipe 對象是雙向管道,如果要創建單向管道,可以在初始化的時候傳入 deplex 參數爲 False。

實例:

from multiprocessing import Process, Pipe

class Consumer(Process):
    def __init__(self, pipe):
        Process.__init__(self)
        self.pipe = pipe

    def run(self):
        self.pipe.send('Consumer Words')
        print(f'Consumer Received: {self.pipe.recv()}')

class Producer(Process):
    def __init__(self, pipe):
        Process.__init__(self)
        self.pipe = pipe

    def run(self):
        print(f'Producer Received: {self.pipe.recv()}')
        self.pipe.send('Producer Words')

if __name__ == '__main__':
    pipe = Pipe()
    p = Producer(pipe[0])
    c = Consumer(pipe[1])
    p.daemon = c.daemon = True
    p.start()
    c.start()
    p.join()
    c.join()
    print('Main Process Ended')

在這個例子裏聲明瞭一個默認爲雙向的管道,然後將管道的兩端分別傳給兩個進程。兩個進程互相收發。

運行結果:

Producer Received: Consumer Words
Consumer Received: Producer Words
Main Process Ended

管道 Pipe 就像進程之間搭建的橋樑,利用它就可以很方便地實現進程間通信了。


進程池

根據前面知道了可以使用 Process 來創建進程,同時也知道了如何用 Semaphore 來控制進程的併發執行數量。

假如現在有 10000 個任務,每個任務需要啓動一個進程來執行,並且一個進程運行完畢之後要緊接着啓動下一個進程,同時還需要控制進程的併發數量,不能併發太高,不然 CPU 處理不過來(如果同時運行的進程能維持在一個最高恆定值當然利用率是最高的)。

那麼該如何來實現這個需求呢?

用 Process 和 Semaphore 可以實現,但是實現起來比較煩瑣。而這種需求在平時又是非常常見的。此時就可以派上進程池了,即 multiprocessing 中的 Pool。

Pool 可以提供指定數量的進程,供用戶調用,當有新的請求提交到 pool 中時,如果池還沒有滿,就會創建一個新的進程用來執行該請求;但如果池中的進程數已經達到規定最大值,那麼該請求就會等待,直到池中有進程結束,纔會創建新的進程來執行它。

實例:

from multiprocessing import Pool
import time


def function(index):
    print(f'Start process: {index}')
    time.sleep(3)
    print(f'End process {index}', )


if __name__ == '__main__':
    pool = Pool(processes=3)
    for i in range(4):
        pool.apply_async(function, args=(i,))

    print('Main Process started')
    pool.close()
    pool.join()
    print('Main Process ended')

在這個例子中聲明瞭一個大小爲 3 的進程池,通過 processes 參數來指定,如果不指定,那麼會自動根據處理器內核來分配進程數。接着使用 apply_async 方法將進程添加進去,args 可以用來傳遞參數。

運行結果:

Main Process started
Start process: 0
Start process: 1
Start process: 2
End process 0
End process 1
End process 2
Start process: 3
End process 3
Main Process ended

進程池大小爲 3,所以最初可以看到有 3 個進程同時執行,第4個進程在等待,在有進程運行完畢之後,第4個進程馬上跟着運行,出現瞭如上的運行效果。

最後,要記得調用 close 方法來關閉進程池,使其不再接受新的任務,然後調用 join 方法讓主進程等待子進程的退出,等子進程運行完畢之後,主進程接着運行並結束。

不過上面的寫法多少有些煩瑣,這裏再介紹進程池一個更好用的 map 方法,可以將上述寫法簡化很多。

map 方法是怎麼用的呢?第一個參數就是要啓動的進程對應的執行方法,第 2 個參數是一個可迭代對象,其中的每個元素會被傳遞給這個執行方法。

舉個例子:現在有一個 list,裏面包含了很多 URL,另外也定義了一個方法用來抓取每個 URL 內容並解析,那麼可以直接在 map 的第一個參數傳入方法名,第 2 個參數傳入 URL 數組。

實例:

from multiprocessing import Pool
import urllib.request
import urllib.error


def scrape(url):
    try:
        urllib.request.urlopen(url)
        print(f'URL {url} Scraped')
    except (urllib.error.HTTPError, urllib.error.URLError):
        print(f'URL {url} not Scraped')


if __name__ == '__main__':
    pool = Pool(processes=3)
    urls = [
        'https://www.baidu.com',
        'http://www.meituan.com/',
        'http://blog.csdn.net/',
        'http://xxxyxxx.net'
    ]
    pool.map(scrape, urls)
    pool.close()

這個例子中先定義了一個 scrape 方法,它接收一個參數 url,這裏就是請求了一下這個鏈接,然後輸出爬取成功的信息,如果發生錯誤,則會輸出爬取失敗的信息。

首先要初始化一個 Pool,指定進程數爲 3。然後聲明一個 urls 列表,接着調用了 map 方法,第 1 個參數就是進程對應的執行方法,第 2 個參數就是 urls 列表,map 方法會依次將 urls 的每個元素作爲 scrape 的參數傳遞並啓動一個新的進程,加到進程池中執行。

運行結果:

URL https://www.baidu.com Scraped
URL http://xxxyxxx.net not Scraped
URL http://blog.csdn.net/ Scraped
URL http://www.meituan.com/ Scraped

這樣就可以實現 3 個進程並行運行。不同的進程相互獨立地輸出了對應的爬取結果。

可以看到,利用 Pool 的 map 方法非常方便地實現了多進程的執行。


Reference:https://kaiwu.lagou.com/course/courseInfo.htm?courseId=46#/detail/pc?id=1667

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