多進程、多線程的概念和基本用法

1. 引言

本科學C語言和計算機基礎的時候,一些老師或者同學偶爾會提到“線程”、“進程”。對此我感覺很奇怪:沒有這些東西,我的程序也可以計算東西啊,討論它們幹啥。

參加工作後,我發現生活和生產中到處有響應時間受限的場景,比如搜索工具的響應時間超過0.5秒,用戶就會有明顯的等待感。而提升軟件系統響應速度的常見做法,就是提升任務的並行度——通常這是基於線程或者進程來實現的,比如讓搜索工具的分詞器使用多個CPU核心來實施計算。因此,線程和進程是編程技術的一個重點,討論它們的人是懂行的。

這幾年裡,每隔一段時間,我會需要寫一個並行化的程序來加速一個任務。悲催的是,這段時間的長度,足以讓我忘記進程和線程的使用方式。因此,我在這裡把python中進程和線程的具體使用方式記錄下來,以便以後查詢。

2. 進程和線程簡介

進程和線程的詳細結構是比較復雜的,這裡的理解方式經過瞭大幅度的簡化。

2.1. 進程在計算機中的存在方式

進程(Process)是計算機中的程序關於某數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,是操作系統結構的基礎[多進程的百度百科詞條]。系統使用進程分配和調度的資源包括cpu、內存、數據等。

計算機系統會為一個進程分配(相對)獨立的一片內存,在這個內存空間裡,計算機程序的解釋器、命名空間、變量等等事物都是專有的。假設A程序和B程序都在同一個內存地址保存瞭數據,那麼A程序修改這份數據時,可能導致B程序的計算因為使用瞭錯誤的數據而產生錯誤的結果。如果我們把這兩個程序分別放在2個進程裡取執行,那麼兩個進程的專屬內存空間的存在,保證瞭A程序和B程序的數據存儲地址區域會被較好地的隔離開,從而保證兩個程序之間不會相互幹擾。

圖2-1 並行執行2個程序時的理想過程和錯誤過程。如果兩個程序的內存空間有交叉,則存在沖突導致計算結果錯誤的風險

專屬內存空間的存在,允許計算機系統放心地執行這樣一種操作:使用兩個CPU核心分別執行兩個進程(裡的程序),而不擔心兩個程序的相互幹擾。這種操作,就是我們常說的“並行計算”。而利用多核CPU實施並行計算的計算機程序編寫方式,就是我們常聽說的“多進程編程”,簡稱“多進程”。通常來說,我們會使用多進程的策略提升數值計算、數據預處理等計算密集型、數據密集型任務中,程序的響應速度和吞吐能力。

2.2. 父進程與子進程的概念

父進程和子進程是一對相對的概念。軟件系統的啟動函數,由進程1執行。如果我們在啟動函數中啟動一個進程2,操作系統會在進程1所轄內存地址之外、再分配一片獨立的內存交由進程2使用——這個進程2看起來就像是進程1“生出來”的一樣,通常被稱為進程1的子進程。相對的,進程1是進程2的父進程。從內存地址范圍的角度看,子進程所管轄的內存空間,與父進程的內存空間獨立,如圖2-2所示。

圖2-2 父進程和子進程在內存地址范圍方面的關系

2.3. 線程是簡化版的進程

如果對進程進行簡化,即取消同級進程之間、子進程與父進程之間的“內存分界線”,一個進程內的進程共享該進程的內存空間,我們就得到瞭一種資源消耗較小的計算機系統資源分配和調度單位。這個新的單位,被稱為線程。

圖2-3 進程與線程的內存空間關系:線程可以訪問父進程的內存空間,而不能直接訪問其他進程的內存空間。

2.4. 進程和線程的聯系和區別

對一個進程來說,父進程、其他進程中的數據是不可見的。

對一個線程來說,父進程裡的所有數據都是可見的。

2.5. 編程中進程和線程的價值

進程、線程可以在很多場景中幫我們節省時間,比如:

(1) 處理預訓練語言模型所需的若幹G或者若幹T訓練語料時,最好使用多進程或多線程來提升預處理程序的性能。當然也可以使用Spark這樣的(使用瞭多進程策略的)分佈式計算框架。

(2) 我們的服務需要支持較多用戶的並發請求,可以使用多進程或多線程來提升單位時間內的吞吐量,進而降低用戶端的時延。Flask這樣的web應用框架提供瞭配置非常方便的多進程模式,我們也可以直接使用這些庫的並發模式。

(3) NLP模型經常需要對一個段落內的若幹個句子分別進行處理(分詞、命名實體識別等),可以使用多進程或多線程的方式來降低計算耗時。使用C、C++或Java這些擁有“真線程”的語言編寫多線程形式的NLP模型推理過程,可以獲得非常好的推理性能。

(4) 等等。

2.6. Python中進程與線程的區別

如何判斷自己的任務適合使用進程還是線程呢?如表2-1所示,python中的進程和線程有很多區別。在需要對任務進行並行化改造時,我們需要結合任務的特點,以及進程和線程的特點,來決定選擇進程還是線程。簡單來說,CPU操作較少的任務(通常是IO密集型),比如文件上傳下載、遠程服務調用等,優先使用線程;其他場景裡,優先使用進程。實際上,進程和線程之間的選擇,比較復雜,不是現在的我能說清楚的。

表2-1 python中進程與線程的區別

進程 線程
多核能力
初始化速度 有點慢
消耗資源 有點多
數據交互方便程度 不方便 方便
實現難度 較高 一般高

2.6.1. Python、Java、C/C++等中線程的區別

Python中存在一個叫做全局線程鎖(Global Interpreter Lock, GIL)的機制,可以保證一個時間步(CPU時鐘的一個周期)內,使用同一個解釋器的線程中,隻有一個線程可以執行一行代碼。這樣,python就實現瞭線程安全,即避免多個線程產生相互沖突的結果。GIL不僅帶來瞭線程安全,也帶來瞭一個缺陷,即Python的多個線程無法在同一時間步運行。這導致瞭,python的多線程無法使用CPU的多個核心進行計算,是“偽多線程”。

Java和C/C++中則沒有GIL,而是要求程序員自己解決(避免)線程之間的沖突。因此,Java和C語言的多線程可以利用CPU的多個核心實施計算,是“真多線程”。

3. Python進程的用法

這裡以python內置庫multiprocessing的工具為例進行介紹。

3.1. 多進程的基本用法

一般來說,我們可以在當前進程中,手動地、顯示地啟動多個進程去並行執行多個子任務;當然,也可以使用python的multiprocessing等庫提供的進程池類,來實現半自動的進程創建、啟動和銷毀。

3.1.1. 多個進程

import multiprocessing
import time

#一個虛構的計算任務
def worker(worker_id):
for i in range(5):
print(f"進程{worker_id}的任務完成進度是{i+1}/5")
time.sleep(0.5)

if __name__ == '__main__':
process_num = 2#進程個數
process_handle_list = []#用於緩存進程實例
for worker_id in range(process_num):
a_process = multiprocessing.Process(target=worker, args=(worker_id, ))#初始化一個進程實例
a_process.start()#啟動進程
process_handle_list.append(a_process)

for p in process_handle_list:
p.join()#在該進程退出之前,保持阻塞

print("所有進程都完成瞭自己的任務。")

赞(0)