一文搞清楚 Multiprocess 和 Multithread 到底差在哪!

前陣子工作上遇到需要做到 multithread 的系統,連什麼是 thread、什麼是 process 都搞不清楚的我,花了一堆時間在研究這個東西。

今天我們就來講解一下什麼是 multiprocess,什麼是 multithread 吧!

基本名詞介紹

在研究更深入的 multiprocess、multithread 觀念之前,我們先來介紹一下基本的專有名詞!

我們會用比喻的方式來介紹,我相信這樣會更好懂!

程式 Program

我們可以將程式 Program 想像成菜譜。菜譜會一步一步告訴我們該如何做出一道菜,程式則會透過一連串的指示告訴電腦該做什麼事。

比如說,當我們開啟網頁時,我們就是開啟了一個可以顯示網頁、處理我們的點擊資訊的程式。

行程 Process

我們可以將行程 Process 想像成在廚房中根據菜譜做菜的大廚。這個大廚有自己的工具(CPU 時間、必要文件)和食材(記憶體)。

每個大廚所擁有的工具與食材都是屬於他自己的。因此如果有多個大廚(多個 Process),他們可以做不同的菜(任務),彼此不互相干擾!

線程 Thread

在一個 Process 中,可以有很多個線程 Thread。我們可以將 Thread 想像成大廚(Process)的助手,每個助手負責不同的任務,比如說切菜或是煮湯。

這些助手因為在同一個廚房內工作(共享 Process 的記憶體空間),因此他們可以輕鬆地溝通並合作完成任務。

I/O bound v.s. CPU bound

一般來說,電腦所執行的任務分為兩種:

  1. I/O bound
  2. CPU bound

我們想像一下,當我們今天要準備一個大餐(執行一個程式),我們大概會有許多菜(任務)需要準備。

I/O bound

當某個任務需要與外部有大量的存取時,比如說讀寫資料、網路連線溝通等等,這就是一個 I/O 導向的任務。

我們可以將 I/O bound 類型的任務想像成是叫外賣或等待食材送達。

這不需要太多的烹飪(CPU),只是等待某些東西到來(輸入 Input /輸出 Output)。

CPU bound

當某個任務需要用到大量的 CPU 計算能力時,這就是一個 CPU 導向的任務。

我們可以將 CPU bound 類型的任務想像成從頭開始做一個蛋糕。

這需要大量工作(CPU計算)來完成,比如說混合食材、烘烤和裝飾蛋糕等等。

multiprocess v.s. multithread

認識完了基本的名詞後,我們終於可以來討論正題啦!

多行程 Multiprocess

前面講到行程 Process 就像是一個大廚,專門負責一道菜(任務)。

那假設我們今天有非常多道菜需要烹煮,一個大廚當然可以做完,但這會相當耗費時間。

我們可以雇用多個大廚(Process),每個大廚負責不同的菜。這樣一來,就可以大大加速烹煮速度。

用更專業一點的話來講,多行程 Multiprocess 實現了平行處理。多個 Process 在多個 CPU 上運行,它們之間彼此不共享資源。每個行程可以在自己的記憶體空間中運行多個線程 Thread。

多線程 Multithread

前面說到,線程 thread 就像是大廚的助手。一個大廚至少有一個助手(Main thread)幫助他完成任務。

多線程就是讓一個大廚擁有多個助手,讓烹煮過程更有效率。然而,這裡有一個重點,就是這些助手並不能同時工作!因此 multithread 並非真正的平行處理!

但是更多助手依然可以提高烹煮效率,這是因為我們可以在一個助手在等待食材時,讓另一個助手開始切菜!

下面這張圖可以讓我們更容易了解 multiprocess 和 multithread 的意義:

範例

我認為最輕鬆快速了解一個概念的方式就是透過實際範例來了解!

這邊我們提供一個 Python 範例給大家,相信大家看完之後一定會非常了解 multiprocess 和 multithread 的基本概念!

建立 I/O bound 和 CPU bound 任務

首先,我們先來建立兩個函數,一個是 I/O bound,另一個是 CPU bound。

這是為了要讓我們更了解不同類型的任務對於不同工作方式的影響。

import time
import requests
import multiprocessing
import threading

# I/O bound 任務 (模擬網路溝通)
def io_bound_task(url):
    response = requests.get(url)
    print(f"Downloaded {len(response.content)} bytes from {url}")

# CPU bound 任務 (模擬複雜計算)
def cpu_bound_task(n):
    result = 0
    for i in range(1, n+1):
        result += i
    print(f"CPU bound task result for n={n}: {result}")

連續執行 I/O bound 任務

接著,我們來看看如果依照一般情況,連續執行兩次 I/O bound 的任務:

start_time = time.time()
io_bound_task("https://google.com")
io_bound_task("https://google.com")
end_time = time.time()
print(f"連續執行兩次 I/O bound 任務的總時長: {end_time - start_time} 秒")

執行後,我們會看到類似這樣的輸出:

Downloaded 18935 bytes from https://google.com
Downloaded 19048 bytes from https://google.com
連續執行兩次 I/O bound 任務的總時長: 1.4581623077392578 秒

利用 multithread 執行 I/O bound 任務

接著,我們利用 multithread 來再次執行兩個 I/O bound 的任務:

start_time = time.time()
thread1 = threading.Thread(target=io_bound_task, args=("https://google.com",))
thread2 = threading.Thread(target=io_bound_task, args=("https://google.com",))
thread1.start()
thread2.start()
thread1.join()
thread2.join()
end_time = time.time()
print(f"透過 thread 執行 I/O bound 任務的總時長: {end_time - start_time} 秒")

執行後,我們可以看到類似這樣的輸出:

Downloaded 18991 bytes from https://google.com
Downloaded 18960 bytes from https://google.com
透過 thread 執行 I/O bound 任務的總時長: 0.828300952911377 秒

我們可以看到,利用 thread,我們將程式運行的時間從 1.4 秒變成 0.8 秒,整整加快了約 42% 的效率!

這是因為在前一個例子中,第二個任務只有在第一個任務確實完成後,才能開始執行。

而這個例子中,第一個任務在等待網頁回應時,第二個任務就可以開始執行!這是因為兩個任務分別是在不同的 thread 上面運作的!

連續執行 CPU bound 任務

接著我們來看看 CPU bound 的任務:

start_time = time.time()
cpu_bound_task(1000000000)
cpu_bound_task(2000000000)
end_time = time.time()
print(f"連續執行兩次 CPU bound 任務的總時長: {end_time - start_time} 秒")

我們可以看到類似這樣的輸出:

CPU bound task result for n=1000000000: 500000000500000000
CPU bound task result for n=2000000000: 2000000001000000000
連續執行兩次 CPU bound 任務的總時長: 88.73309183120728 

利用 multithread 執行 CPU bound 任務

我們來看看利用 multithread 可不可以加速 CPU bound 任務的運行效率。

start_time = time.time()
thread1 = threading.Thread(target=cpu_bound_task, args=(1000000000,))
thread2 = threading.Thread(target=cpu_bound_task, args=(2000000000,))
thread1.start()
thread2.start()
thread1.join()
thread2.join()
end_time = time.time()
print(f"透過 thread 執行 CPU bound 任務總時長: {end_time - start_time} 秒")

我們可以看到類似這樣的輸出:

CPU bound task result for n=1000000000: 500000000500000000
CPU bound task result for n=2000000000: 2000000001000000000
透過 thread 執行 CPU bound 任務總時長: 89.55286383628845 秒

誒你會發現,運行時間不減反增!這是為什麼??

這是因為在 Python 中,有一個機制叫做 Global Interpreter Lock (GIL)。這個機制防止了第二個任務在第一個任務完成前使用 CPU,只有在第一個任務完成後,這個「鎖」才會被解開,第二個任務才會被執行。

而這個鎖起來又解鎖的動作,也會使用到多餘的時間,因此,雖然我們利用了 multithread 沒錯,但是運行起來其實和 single thread 沒什麼差別!

利用 multiprocess 執行 CPU bound 任務

還記得我們前面說,multiprocess 才是真正實行了平行處理嗎?

那我們現在來用看看 multiprocess,看這個東西會不會幫助我們加速 CPU bound 任務的運行。

start_time = time.time()
process1 = multiprocessing.Process(target=cpu_bound_task, args=(1000000000,))
process2 = multiprocessing.Process(target=cpu_bound_task, args=(2000000000,))
process1.start()
process2.start()
process1.join()
process2.join()
end_time = time.time()
print(f"透過 process 執行 CPU bound 任務總時長: {end_time - start_time} 秒")

運行後,我們可以看到這樣的輸出:

CPU bound task result for n=1000000000: 500000000500000000
CPU bound task result for n=2000000000: 2000000001000000000
透過 process 執行 CPU bound 任務總時長: 59.898000955581665 秒

可以發現運行速度真的被提高了!整體效率進步了大約 32%!

這是因為兩個任務現在分別被兩個不同的 CPU 運行,因為 CPU 彼此不互相干擾,所以兩個任務是同時被運行的!

利用 multiprocess 執行 I/O bound 任務

聰明的你可能會想了,既然 multiprocess 可以加速 CPU bound 任務,那他可不可以也加速 I/O bound 的任務呢?

我們這就來試試看!

start_time = time.time()
process1 = multiprocessing.Process(target=io_bound_task, args=("https://google.com",))
process2 = multiprocessing.Process(target=io_bound_task, args=("https://google.com",))
process1.start()
process2.start()
process1.join()
process2.join()
end_time = time.time()
print(f"透過 process 執行 I/O bound 任務總時長: {end_time - start_time} 秒")

執行後,我們可以看到這樣的輸出:

Downloaded 18904 bytes from https://google.com
Downloaded 18962 bytes from https://google.com
透過 process 執行 I/O bound 任務總時長: 0.6969478130340576

確實!利用不同的 process 一樣可以加快 I/O bound 任務的運行!

那我們什麼時候該用 multithread 什麼時候該用 multiprocess 呢?

其實,相比於建立一個 thread,建立一個 process 並不是一個容易的工作。況且,一個 process 所需要用到的資源也比一個 thread 來的多。

因此,對於簡單 I/O bound 的任務,使用 multithread 會比 multiprocess 來的更有效率!

總結

這章我們了解了什麼是 multiprocess 與 multithread。

當然啦,這章介紹的觀念是基本中的基本,更進階的觀念還有 GIL (Global Interpreter Lock)、Deadlock、Coroutine 等等。

這些以後有時間的話我們會再專門寫幾篇給大家介紹~

那這章就到這裡啦~

如果有學到東西的話可以留個五星評價喔!