什么是進程,什么是線程宫盔,進程和線程的關(guān)系這里就不說了,直接講將Python中如何創(chuàng)建多進程與多線程吧享完。
多進程
Unix/Linux操作系統(tǒng)提供了一個fork()系統(tǒng)調(diào)用灼芭,它非常特殊。普通的函數(shù)調(diào)用般又,調(diào)用一次彼绷,返回一次,但是fork()調(diào)用一次茴迁,返回兩次寄悯,因為操作系統(tǒng)自動把當前進程(稱為父進程)復(fù)制了一份(稱為子進程),然后堕义,分別在父進程和子進程內(nèi)返回猜旬。
子進程永遠返回0,而父進程返回子進程的ID倦卖。這樣做的理由是洒擦,一個父進程可以fork出很多子進程,所以怕膛,父進程要記下每個子進程的ID熟嫩,而子進程只需要調(diào)用getppid()就可以拿到父進程的ID。
Python的os模塊封裝了常見的系統(tǒng)調(diào)用褐捻,其中就包括fork掸茅,可以在Python程序中輕松創(chuàng)建子進程椅邓,但是這種方法只適合Linux或者以Unix為內(nèi)核的平臺,如果想要在Window上昧狮,就需要multiprocessing模塊就是跨平臺版本的多進程模塊景馁。multiprocessing模塊提供了一個Process類來代表一個進程對象,一下示例代碼:
import os
# 以下代碼可以在Linux或者Unix上運行陵且,創(chuàng)建進程
# 獲得進程的pid
print('Process (%s) start...' % os.getpid())
# 創(chuàng)建一個新的進程,以下代碼只適合在Linux或Unix个束,Mac上,由于Windows沒有fork調(diào)用慕购,上面的代碼在Windows上無法運行
pid = os.fork()
if pid == 0:
# getppid()是獲取子進程的pid
print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:
print('I (%s) just created a child process (%s).' % (os.getpid(), pid))
from multiprocessing import Process
# 以下代碼可以在Windows上運行,創(chuàng)建進程
# 子進程要執(zhí)行的代碼
def run_proc(name):
print('Run child process %s (%s)...' % (name, os.getpid()))
if __name__ == '__main__':
print('Parent process %s.' % os.getpid())
# 創(chuàng)建子進程時茬底,只需要傳入一個執(zhí)行函數(shù)和函數(shù)的參數(shù)沪悲,創(chuàng)建一個Process實例,用start()方法啟動阱表,這樣創(chuàng)建進程比fork()還要簡單殿如。
p = Process(target=run_proc, args=('test',))
print('Child process will start.')
p.start()
p.join()
print('Child process end.')
# 使用進程池來批量創(chuàng)建子進程
from multiprocessing.pool import Pool
import time, random
# 子進程執(zhí)行的方法
def long_time_task(name):
print('Run task %s (%s)...' % (name, os.getpid()))
start = time.time()
time.sleep(random.random() * 3)
end = time.time()
print('Task %s runs %0.2f seconds.' % (name, (end - start)))
if __name__ == '__main__':
print('Parent process %s.' % os.getpid())
# 請注意輸出的結(jié)果,task 0最爬,1涉馁,2,3是立刻執(zhí)行的爱致,而task 4要等待前面某個task完成后才執(zhí)行烤送,這是因為Pool的默認大小在我的電腦上是4,
# 因此糠悯,最多同時執(zhí)行4個進程帮坚。這是Pool有意設(shè)計的限制,并不是操作系統(tǒng)的限制互艾。
p = Pool(4)
for i in range(5):
# 子線程開始執(zhí)行方法
p.apply_async(long_time_task, args=(i,))
print('Waiting for all subprocesses done...')
# 對Pool對象調(diào)用join()方法會等待所有子進程執(zhí)行完畢试和,調(diào)用join()之前必須先調(diào)用close(),調(diào)用close()之后就不能繼續(xù)添加新的Process了
p.close()
p.join()
print('All subprocesses done.')
子進程
很多時候纫普,子進程并不是自身阅悍,而是一個外部進程。我們創(chuàng)建了子進程后昨稼,如何控制子進程的輸入和輸出溉箕?看以下示例:
import subprocess
# 啟動一個子線程,執(zhí)行nslookup命令悦昵,監(jiān)控網(wǎng)絡(luò)dns解析域名肴茄,控制了其輸入輸出
r = subprocess.call(['nslookup', 'www.python.org'])
# 如果子進程還需要輸入,則可以通過communicate()方法輸入
print('$ nslookup')
# 參數(shù)1是字符串或者列表也就是要執(zhí)行的程序但指, 后面的參數(shù) 指定創(chuàng)建管道
p = subprocess.Popen(['nslookup'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# 這段代碼相當于給nslookup 設(shè)置set q=mx 寡痰,值為python.org 和exit 兩個
output, err = p.communicate(b'set q=mx\npython.org\nexit\n')
print(output.decode('gbk'))
print('Exit code:', p.returncode)
進程間通信
Process之間肯定是需要通信的抗楔,操作系統(tǒng)提供了很多機制來實現(xiàn)進程間的通信。Python的multiprocessing模塊包裝了底層的機制拦坠,提供了Queue连躏、Pipes等多種方式來交換數(shù)據(jù)。都是通過這個東西來把數(shù)據(jù)放到約定的一處地方贞滨,然后不同進程可以去取里面的數(shù)據(jù)入热。這個思路其實和Android里面的aidl思路是一致的。
我們以Queue為例晓铆,在父進程中創(chuàng)建兩個子進程勺良,一個往Queue里寫數(shù)據(jù),一個從Queue里讀數(shù)據(jù):
from multiprocessing import Process, Queue
import os, time, random
# 寫數(shù)據(jù)進程執(zhí)行的代碼:
def write(q):
print('Process to write: %s' % os.getpid())
for value in ['A', 'B', 'C']:
print('Put %s to queue...' % value)
q.put(value)
time.sleep(random.random())
# 讀數(shù)據(jù)進程執(zhí)行的代碼:
def read(q):
print('Process to read: %s' % os.getpid())
while True:
value = q.get(True)
print('Get %s from queue.' % value)
if __name__ == '__main__':
# 父進程創(chuàng)建Queue骄噪,并傳給各個子進程:
q = Queue()
pw = Process(target=write, args=(q,))
pr = Process(target=read, args=(q,))
# 啟動子進程pw怀各,寫入:
pw.start()
# 啟動子進程pr焦蘑,讀取:
pr.start()
# 等待pw結(jié)束:
pw.join()
# pr進程里是死循環(huán)印颤,無法等待其結(jié)束馒疹,只能強行終止:
pr.terminate()
多線程
進程是由若干線程組成的,一個進程至少有一個線程滔韵。
由于線程是操作系統(tǒng)直接支持的執(zhí)行單元逻谦,因此,高級語言通常都內(nèi)置多線程的支持陪蜻,Python也不例外跨跨,并且,Python的線程是真正的Posix Thread囱皿,而不是模擬出來的線程勇婴。
Python的標準庫提供了兩個模塊:_thread和threading,_thread是低級模塊嘱腥,threading是高級模塊耕渴,對_thread進行了封裝。絕大多數(shù)情況下齿兔,我們只需要使用threading這個高級模塊橱脸。下面示例代碼演示如何創(chuàng)建一個線程,和執(zhí)行相應(yīng)的任務(wù):
import time, threading
# 任務(wù)代碼
def task():
print('thread %s is running...' % threading.current_thread().name)
n = 0
while n < 5:
n = n + 1
print('線程 %s >>> %s' % (threading.current_thread().name, n))
time.sleep(1)
print('線程 %s 任務(wù)結(jié)束.' % threading.current_thread().name)
# 獲取當前正在執(zhí)行的線程名字
print('thread %s is running...' % threading.current_thread().name)
# 創(chuàng)建一個新的線程分苇,名字叫做taskThread
t = threading.Thread(target=task, name='taskThread')
t.start()
# join()方法可以等待線程結(jié)束后再繼續(xù)往下運行添诉,通常用于線程間的同步。
t.join()
# 由于任何進程默認就會啟動一個線程医寿,我們把該線程稱為主線程栏赴,主線程又可以啟動新的線程,
# Python的threading模塊有個current_thread()函數(shù)靖秩,它永遠返回當前線程的實例须眷。主線程實例的名字叫MainThread
print('線程 %s 任務(wù)結(jié)束.' % threading.current_thread().name)
線程安全問題
多線程和多進程最大的不同在于竖瘾,多進程中,同一個變量花颗,各自有一份拷貝存在于每個進程中捕传,互不影響,而多線程中扩劝,所有變量都由所有線程共享庸论,所以,任何一個變量都可以被任何一個線程修改棒呛,因此聂示,線程之間共享數(shù)據(jù)最大的危險在于多個線程同時改一個變量,把內(nèi)容給改亂了条霜。那么如何解決催什?我們在java里面多線程可以加鎖來解決涵亏,在Python也是一樣可以的宰睡,以下示例演示如何解決這個問題:
import time, threading
# 假定這是你的銀行存款:
balance = 0
def change_it(n):
# 先存后取,結(jié)果應(yīng)該為0:
global balance
balance = balance + n
balance = balance - n
def run_thread(n):
for i in range(100000):
change_it(n)
t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)
# 正常運行結(jié)果:
初始值 balance = 0
t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0
t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2 # balance = 0
結(jié)果 balance = 0
# 但是t1和t2是交替運行的气筋,如果操作系統(tǒng)以下面的順序執(zhí)行t1拆内、t2:
初始值 balance = 0
t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8
t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0
t2: x2 = balance - 8 # x2 = 0 - 8 = -8
t2: balance = x2 # balance = -8
結(jié)果 balance = -8
那么如何修改?究其原因宠默,是因為修改balance需要多條語句麸恍,而執(zhí)行這幾條語句時,線程可能中斷搀矫,從而導(dǎo)致多個線程把同一個對象的內(nèi)容改亂了抹沪。
兩個線程同時一存一取,就可能導(dǎo)致余額不對瓤球,你肯定不希望你的銀行存款莫名其妙地變成了負數(shù)融欧,所以,我們必須確保一個線程在修改balance的時候卦羡,別的線程一定不能改噪馏。
如果我們要確保balance計算正確,就要給change_it()上一把鎖绿饵,當某個線程開始執(zhí)行change_it()時欠肾,我們說,該線程因為獲得了鎖拟赊,因此其他線程不能同時執(zhí)行change_it()刺桃,只能等待,直到鎖被釋放后吸祟,獲得該鎖以后才能改虏肾。由于鎖只有一個廓啊,無論多少線程,同一時刻最多只有一個線程持有該鎖封豪,所以谴轮,不會造成修改的沖突。創(chuàng)建一個鎖就是通過threading.Lock()來實現(xiàn):
import time, threading
# 假定這是你的銀行存款:
balance = 0
def change_it(n):
# 先存后取吹埠,結(jié)果應(yīng)該為0:
global balance
balance = balance + n
balance = balance - n
lock = threading.Lock()
def run_thread(n):
for i in range(100000):
# 先要獲取鎖:當多個線程同時執(zhí)行l(wèi)ock.acquire()時第步,只有一個線程能成功地獲取鎖,然后繼續(xù)執(zhí)行代碼缘琅,其他線程就繼續(xù)等待直到獲得鎖為止
lock.acquire()
try:
# 放心地改吧:
change_it(n)
finally:
# 改完了一定要釋放鎖:
lock.release()
t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
# 我們定義了一個共享變量balance粘都,初始值為0,并且啟動兩個線程刷袍,先存后取翩隧,理論上結(jié)果應(yīng)該為0,但是呻纹,
# 由于線程的調(diào)度是由操作系統(tǒng)決定的堆生,當t1、t2交替執(zhí)行時雷酪,只要循環(huán)次數(shù)足夠多淑仆,balance的結(jié)果就不一定是0了。
print(balance)
鎖的好處就是確保了某段關(guān)鍵代碼只能由一個線程從頭到尾完整地執(zhí)行哥力,壞處當然也很多蔗怠,首先是阻止了多線程并發(fā)執(zhí)行,包含鎖的某段代碼實際上只能以單線程模式執(zhí)行吩跋,效率就大大地下降了寞射。其次,由于可以存在多個鎖锌钮,不同的線程持有不同的鎖桥温,并試圖獲取對方持有的鎖時,可能會造成死鎖轧粟,導(dǎo)致多個線程全部掛起策治,既不能執(zhí)行,也無法結(jié)束兰吟,只能靠操作系統(tǒng)強制終止通惫。
Python的多核處理:
試試以下代碼的結(jié)果:
import threading, multiprocessing
def loop():
x = 0
while True:
x = x ^ 1
for i in range(multiprocessing.cpu_count()):
t = threading.Thread(target=loop)
t.start()
啟動與CPU核心數(shù)量相同的N個線程,在4核CPU上可以監(jiān)控到CPU占用率僅有102%混蔼,也就是僅使用了一核履腋。
但是用C、C++或Java來改寫相同的死循環(huán),直接可以把全部核心跑滿遵湖,4核就跑到400%悔政,8核就跑到800%,為什么Python不行呢延旧?
因為Python的線程雖然是真正的線程谋国,但解釋器執(zhí)行代碼時,有一個GIL鎖:Global Interpreter Lock迁沫,任何Python線程執(zhí)行前芦瘾,必須先獲得GIL鎖,然后集畅,每執(zhí)行100條字節(jié)碼近弟,解釋器就自動釋放GIL鎖,讓別的線程有機會執(zhí)行挺智。這個GIL全局鎖實際上把所有線程的執(zhí)行代碼都給上了鎖祷愉,所以,多線程在Python中只能交替執(zhí)行赦颇,即使100個線程跑在100核CPU上二鳄,也只能用到1個核。
GIL是Python解釋器設(shè)計的歷史遺留問題沐扳,通常我們用的解釋器是官方實現(xiàn)的CPython泥从,要真正利用多核句占,除非重寫一個不帶GIL的解釋器沪摄。
所以,在Python中纱烘,可以使用多線程杨拐,但不要指望能有效利用多核。如果一定要通過多線程利用多核擂啥,那只能通過C擴展來實現(xiàn)哄陶,不過這樣就失去了Python簡單易用的特點。
不過哺壶,也不用過于擔心屋吨,Python雖然不能利用多線程實現(xiàn)多核任務(wù),但可以通過多進程實現(xiàn)多核任務(wù)山宾。多個Python進程有各自獨立的GIL鎖至扰,互不影響。
ThreadLocal
先看看以下代碼:
def process_student(name):
std = Student(name)
# std是局部變量资锰,但是每個函數(shù)都要用它敢课,因此必須傳進去:
do_task_1(std)
do_task_2(std)
def do_task_1(std):
do_subtask_1(std)
do_subtask_2(std)
def do_task_2(std):
do_subtask_2(std)
do_subtask_2(std)
每個函數(shù)一層一層調(diào)用都這么傳參數(shù),ThreadLocal應(yīng)運而生,就是解決這種繁雜的事情:
import threading
# 創(chuàng)建全局ThreadLocal對象:
local_school = threading.local()
def process_student():
# 獲取當前線程關(guān)聯(lián)的student:
std = local_school.student
print('Hello, %s (in %s)' % (std, threading.current_thread().name))
def process_thread(name):
# 綁定ThreadLocal的student:
local_school.student = name
process_student()
# 使用threading.local()解決了不停地根據(jù)對象參數(shù)不同而創(chuàng)造對象傳入到子線程中的問題直秆,注意對象是動態(tài)創(chuàng)建的
t1 = threading.Thread(target=process_thread, args=('Alice',), name='Thread-A')
t2 = threading.Thread(target=process_thread, args=('Bob',), name='Thread-B')
t1.start()
t2.start()
t1.join()
t2.join()
全局變量local_school就是一個ThreadLocal對象濒募,每個Thread對它都可以讀寫student屬性,但互不影響圾结。你可以把local_school看成全局變量瑰剃,但每個屬性如local_school.student都是線程的局部變量,可以任意讀寫而互不干擾筝野,也不用管理鎖的問題培他,ThreadLocal內(nèi)部會處理。
可以理解為全局變量local_school是一個dict遗座,不但可以用local_school.student舀凛,還可以綁定其他變量,如local_school.teacher等等途蒋。
ThreadLocal最常用的地方就是為每個線程綁定一個數(shù)據(jù)庫連接猛遍,HTTP請求,用戶身份信息等号坡,這樣一個線程的所有調(diào)用到的處理函數(shù)都可以非常方便地訪問這些資源懊烤。
進程和線程的選擇
多進程模式最大的優(yōu)點就是穩(wěn)定性高,因為一個子進程崩潰了宽堆,不會影響主進程和其他子進程腌紧。(當然主進程掛了所有進程就全掛了,但是Master進程只負責分配任務(wù)畜隶,掛掉的概率低)著名的Apache最早就是采用多進程模式壁肋。
多進程模式的缺點是創(chuàng)建進程的代價大,在Unix/Linux系統(tǒng)下籽慢,用fork調(diào)用還行浸遗,在Windows下創(chuàng)建進程開銷巨大。另外箱亿,操作系統(tǒng)能同時運行的進程數(shù)也是有限的跛锌,在內(nèi)存和CPU的限制下,如果有幾千個進程同時運行届惋,操作系統(tǒng)連調(diào)度都會成問題髓帽。
多線程模式通常比多進程快一點,但是也快不到哪去脑豹,而且郑藏,多線程模式致命的缺點就是任何一個線程掛掉都可能直接造成整個進程崩潰,因為所有線程共享進程的內(nèi)存晨缴。在Windows上译秦,如果一個線程執(zhí)行的代碼出了問題,你經(jīng)常可以看到這樣的提示:“該程序執(zhí)行了非法操作筑悴,即將關(guān)閉”们拙,其實往往是某個線程出了問題,但是操作系統(tǒng)會強制結(jié)束整個進程阁吝。
在Windows下砚婆,多線程的效率比多進程要高,所以微軟的IIS服務(wù)器默認采用多線程模式突勇。由于多線程存在穩(wěn)定性的問題装盯,IIS的穩(wěn)定性就不如Apache。為了緩解這個問題甲馋,IIS和Apache現(xiàn)在又有多進程+多線程的混合模式埂奈,真是把問題越搞越復(fù)雜。
- 計算密集型 vs. IO密集型
是否采用多任務(wù)的第二個考慮是任務(wù)的類型定躏。我們可以把任務(wù)分為計算密集型和IO密集型账磺。
計算密集型任務(wù)的特點是要進行大量的計算,消耗CPU資源痊远,比如計算圓周率垮抗、對視頻進行高清解碼等等,全靠CPU的運算能力碧聪。這種計算密集型任務(wù)雖然也可以用多任務(wù)完成冒版,但是任務(wù)越多,花在任務(wù)切換的時間就越多逞姿,CPU執(zhí)行任務(wù)的效率就越低辞嗡,所以,要最高效地利用CPU哼凯,計算密集型任務(wù)同時進行的數(shù)量應(yīng)當?shù)扔贑PU的核心數(shù)欲间。
計算密集型任務(wù)由于主要消耗CPU資源楚里,因此断部,代碼運行效率至關(guān)重要。Python這樣的腳本語言運行效率很低班缎,完全不適合計算密集型任務(wù)蝴光。對于計算密集型任務(wù),最好用C語言編寫达址。
第二種任務(wù)的類型是IO密集型蔑祟,涉及到網(wǎng)絡(luò)、磁盤IO的任務(wù)都是IO密集型任務(wù)沉唠,這類任務(wù)的特點是CPU消耗很少疆虚,任務(wù)的大部分時間都在等待IO操作完成(因為IO的速度遠遠低于CPU和內(nèi)存的速度)。對于IO密集型任務(wù),任務(wù)越多径簿,CPU效率越高罢屈,但也有一個限度。常見的大部分任務(wù)都是IO密集型任務(wù)篇亭,比如Web應(yīng)用缠捌。
IO密集型任務(wù)執(zhí)行期間,99%的時間都花在IO上译蒂,花在CPU上的時間很少曼月,因此,用運行速度極快的C語言替換用Python這樣運行速度極低的腳本語言柔昼,完全無法提升運行效率哑芹。對于IO密集型任務(wù),最合適的語言就是開發(fā)效率最高(代碼量最少)的語言捕透,腳本語言是首選绩衷,C語言最差。
- 異步IO
考慮到CPU和IO之間巨大的速度差異激率,一個任務(wù)在執(zhí)行的過程中大部分時間都在等待IO操作咳燕,單進程單線程模型會導(dǎo)致別的任務(wù)無法并行執(zhí)行,因此乒躺,我們才需要多進程模型或者多線程模型來支持多任務(wù)并發(fā)執(zhí)行招盲。
現(xiàn)代操作系統(tǒng)對IO操作已經(jīng)做了巨大的改進,最大的特點就是支持異步IO嘉冒。如果充分利用操作系統(tǒng)提供的異步IO支持曹货,就可以用單進程單線程模型來執(zhí)行多任務(wù),這種全新的模型稱為事件驅(qū)動模型讳推,Nginx就是支持異步IO的Web服務(wù)器顶籽,它在單核CPU上采用單進程模型就可以高效地支持多任務(wù)。在多核CPU上银觅,可以運行多個進程(數(shù)量與CPU核心數(shù)相同)礼饱,充分利用多核CPU。由于系統(tǒng)總的進程數(shù)量十分有限究驴,因此操作系統(tǒng)調(diào)度非常高效镊绪。用異步IO編程模型來實現(xiàn)多任務(wù)是一個主要的趨勢。
對應(yīng)到Python語言洒忧,單線程的異步編程模型稱為協(xié)程蝴韭,有了協(xié)程的支持,就可以基于事件驅(qū)動編寫高效的多任務(wù)程序熙侍。我們會在后面討論如何編寫協(xié)程