所謂併發編程就是讓程序中有多個部分能夠併發或同時執行,併發編程帶來的好處不言而喻,其中最爲關鍵的兩點是提升了執行效率和改善了用戶體驗。下面簡單闡述一下Python中實現併發編程的三種方式:
-
多線程:Python中通過
threading
模塊的Thread
類並輔以Lock
、Condition
、Event
、Semaphore
和Barrier
等類來支持多線程編程。Python解釋器通過GIL(全局解釋器鎖)來防止多個線程同時執行本地字節碼,這個鎖對於CPython(Python解釋器的官方實現)是必須的,因爲CPython的內存管理並不是線程安全的。因爲GIL的存在,Python的多線程並不能利用CPU的多核特性。 -
多進程:使用多進程可以有效的解決GIL的問題,Python中的
multiprocessing
模塊提供了Process
類來實現多進程,其他的輔助類跟threading
模塊中的類類似,由於進程間的內存是相互隔離的(操作系統對進程的保護),進程間通信(共享數據)必須使用管道、套接字等方式,這一點從編程的角度來講是比較麻煩的,爲此,Python的multiprocessing
模塊提供了一個名爲Queue
的類,它基於管道和鎖機制提供了多個進程共享的隊列。""" 用下面的命令運行程序並查看執行時間,例如: time python3 example06.py real 0m20.657s user 1m17.749s sys 0m0.158s 使用多進程後實際執行時間爲20.657秒,而用戶時間1分17.749秒約爲實際執行時間的4倍 這就證明我們的程序通過多進程使用了CPU的多核特性,而且這臺計算機配置了4核的CPU """ import concurrent.futures import math PRIMES = [ 1116281, 1297337, 104395303, 472882027, 533000389, 817504243, 982451653, 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419 ] * 5 def is_prime(num): """判斷素數""" assert num > 0 for i in range(2, int(math.sqrt(num)) + 1): if num % i == 0: return False return num != 1 def main(): """主函數""" with concurrent.futures.ProcessPoolExecutor() as executor: for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)): print('%d is prime: %s' % (number, prime)) if __name__ == '__main__': main()
-
異步編程(異步I/O):所謂異步編程是通過調度程序從任務隊列中挑選任務,調度程序以交叉的形式執行這些任務,我們並不能保證任務將以某種順序去執行,因爲執行順序取決於隊列中的一項任務是否願意將CPU處理時間讓位給另一項任務。異步編程通常通過多任務協作處理的方式來實現,由於執行時間和順序的不確定,因此需要通過鉤子函數(回調函數)或者
Future
對象來獲取任務執行的結果。目前我們使用的Python 3通過asyncio
模塊以及await
和async
關鍵字(Python 3.5中引入,Python 3.7中正式成爲關鍵字)提供了對異步I/O的支持。import asyncio async def fetch(host): """從指定的站點抓取信息(協程函數)""" print(f'Start fetching {host}\n') # 跟服務器建立連接 reader, writer = await asyncio.open_connection(host, 80) # 構造請求行和請求頭 writer.write(b'GET / HTTP/1.1\r\n') writer.write(f'Host: {host}\r\n'.encode()) writer.write(b'\r\n') # 清空緩存區(發送請求) await writer.drain() # 接收服務器的響應(讀取響應行和響應頭) line = await reader.readline() while line != b'\r\n': print(line.decode().rstrip()) line = await reader.readline() print('\n') writer.close() def main(): """主函數""" urls = ('www.sohu.com', 'www.douban.com', 'www.163.com') # 獲取系統默認的事件循環 loop = asyncio.get_event_loop() # 用生成式語法構造一個包含多個協程對象的列表 tasks = [fetch(url) for url in urls] # 通過asyncio模塊的wait函數將協程列表包裝成Task(Future子類)並等待其執行完成 # 通過事件循環的run_until_complete方法運行任務直到Future完成並返回它的結果 loop.run_until_complete(asyncio.wait(tasks)) loop.close() if __name__ == '__main__': main()
說明:目前大多數網站都要求基於HTTPS通信,因此上面例子中的網絡請求不一定能收到正常的響應,也就是說響應狀態碼不一定是200,有可能是3xx或者4xx。當然我們這裏的重點不在於獲得網站響應的內容,而是幫助大家理解
asyncio
模塊以及async
和await
兩個關鍵字的使用。
我們對三種方式的使用場景做一個簡單的總結。
以下情況需要使用多線程:
- 程序需要維護許多共享的狀態(尤其是可變狀態),Python中的列表、字典、集合都是線程安全的,所以使用線程而不是進程維護共享狀態的代價相對較小。
- 程序會花費大量時間在I/O操作上,沒有太多並行計算的需求且不需佔用太多的內存。
以下情況需要使用多進程:
- 程序執行計算密集型任務(如:字節碼操作、數據處理、科學計算)。
- 程序的輸入可以並行的分成塊,並且可以將運算結果合併。
- 程序在內存使用方面沒有任何限制且不強依賴於I/O操作(如:讀寫文件、套接字等)。
最後,如果程序不需要真正的併發性或並行性,而是更多的依賴於異步處理和回調時,異步I/O就是一種很好的選擇。另一方面,當程序中有大量的等待與休眠時,也應該考慮使用異步I/O。
擴展:關於進程,還需要做一些補充說明。首先,爲了控制進程的執行,操作系統內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程使之繼續執行,這種行爲被稱爲進程切換(也叫調度)。進程切換是比較耗費資源的操作,因爲在進行切換時首先要保存當前進程的上下文(內核再次喚醒該進程時所需要的狀態,包括:程序計數器、狀態寄存器、數據棧等),然後還要恢復準備執行的進程的上下文。正在執行的進程由於期待的某些事件未發生,如請求系統資源失敗、等待某個操作完成、新數據尚未到達等原因會主動由運行狀態變爲阻塞狀態,當進程進入阻塞狀態,是不佔用CPU資源的。這些知識對於理解到底選擇哪種方式進行併發編程也是很重要的。
對於一次I/O操作(以讀操作爲例),數據會先被拷貝到操作系統內核的緩衝區中,然後從操作系統內核的緩衝區拷貝到應用程序的緩衝區(這種方式稱爲標準I/O或緩存I/O,大多數文件系統的默認I/O都是這種方式),最後交給進程。所以說,當一個讀操作發生時(寫操作與之類似),它會經歷兩個階段:(1)等待數據準備就緒;(2)將數據從內核拷貝到進程中。
由於存在這兩個階段,因此產生了以下幾種I/O模式:
- 阻塞 I/O(blocking I/O):進程發起讀操作,如果內核數據尚未就緒,進程會阻塞等待數據直到內核數據就緒並拷貝到進程的內存中。
- 非阻塞 I/O(non-blocking I/O):進程發起讀操作,如果內核數據尚未就緒,進程不阻塞而是收到內核返回的錯誤信息,進程收到錯誤信息可以再次發起讀操作,一旦內核數據準備就緒,就立即將數據拷貝到了用戶內存中,然後返回。
- 多路I/O複用( I/O multiplexing):監聽多個I/O對象,當I/O對象有變化(數據就緒)的時候就通知用戶進程。多路I/O複用的優勢並不在於單個I/O操作能處理得更快,而是在於能處理更多的I/O操作。
- 異步 I/O(asynchronous I/O):進程發起讀操作後就可以去做別的事情了,內核收到異步讀操作後會立即返回,所以用戶進程不阻塞,當內核數據準備就緒時,內核發送一個信號給用戶進程,告訴它讀操作完成了。
通常,我們編寫一個處理用戶請求的服務器程序時,有以下三種方式可供選擇:
- 每收到一個請求,創建一個新的進程,來處理該請求;
- 每收到一個請求,創建一個新的線程,來處理該請求;
- 每收到一個請求,放入一個事件列表,讓主進程通過非阻塞I/O方式來處理請求
第1種方式實現比較簡單,但由於創建進程開銷比較大,會導致服務器性能比較差;第2種方式,由於要涉及到線程的同步,有可能會面臨競爭、死鎖等問題;第3種方式,就是所謂事件驅動的方式,它利用了多路I/O複用和異步I/O的優點,雖然代碼邏輯比前面兩種都複雜,但能達到最好的性能,這也是目前大多數網絡服務器採用的方式。