Skip to content

Latest commit

 

History

History
140 lines (105 loc) · 9.15 KB

61.預備知識.md

File metadata and controls

140 lines (105 loc) · 9.15 KB

預備知識

併發編程

所謂併發編程就是讓程序中有多個部分能夠併發或同時執行,併發編程帶來的好處不言而喻,其中最爲關鍵的兩點是提升了執行效率和改善了用戶體驗。下面簡單闡述一下Python中實現併發編程的三種方式:

  1. 多線程:Python中通過threading模塊的Thread類並輔以LockConditionEventSemaphoreBarrier等類來支持多線程編程。Python解釋器通過GIL(全局解釋器鎖)來防止多個線程同時執行本地字節碼,這個鎖對於CPython(Python解釋器的官方實現)是必須的,因爲CPython的內存管理並不是線程安全的。因爲GIL的存在,Python的多線程並不能利用CPU的多核特性。

  2. 多進程:使用多進程可以有效的解決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()
  3. 異步編程(異步I/O):所謂異步編程是通過調度程序從任務隊列中挑選任務,調度程序以交叉的形式執行這些任務,我們並不能保證任務將以某種順序去執行,因爲執行順序取決於隊列中的一項任務是否願意將CPU處理時間讓位給另一項任務。異步編程通常通過多任務協作處理的方式來實現,由於執行時間和順序的不確定,因此需要通過鉤子函數(回調函數)或者Future對象來獲取任務執行的結果。目前我們使用的Python 3通過asyncio模塊以及awaitasync關鍵字(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模塊以及asyncawait兩個關鍵字的使用。

我們對三種方式的使用場景做一個簡單的總結。

以下情況需要使用多線程:

  1. 程序需要維護許多共享的狀態(尤其是可變狀態),Python中的列表、字典、集合都是線程安全的,所以使用線程而不是進程維護共享狀態的代價相對較小。
  2. 程序會花費大量時間在I/O操作上,沒有太多並行計算的需求且不需佔用太多的內存。

以下情況需要使用多進程:

  1. 程序執行計算密集型任務(如:字節碼操作、數據處理、科學計算)。
  2. 程序的輸入可以並行的分成塊,並且可以將運算結果合併。
  3. 程序在內存使用方面沒有任何限制且不強依賴於I/O操作(如:讀寫文件、套接字等)。

最後,如果程序不需要真正的併發性或並行性,而是更多的依賴於異步處理和回調時,異步I/O就是一種很好的選擇。另一方面,當程序中有大量的等待與休眠時,也應該考慮使用異步I/O。

擴展:關於進程,還需要做一些補充說明。首先,爲了控制進程的執行,操作系統內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程使之繼續執行,這種行爲被稱爲進程切換(也叫調度)。進程切換是比較耗費資源的操作,因爲在進行切換時首先要保存當前進程的上下文(內核再次喚醒該進程時所需要的狀態,包括:程序計數器、狀態寄存器、數據棧等),然後還要恢復準備執行的進程的上下文。正在執行的進程由於期待的某些事件未發生,如請求系統資源失敗、等待某個操作完成、新數據尚未到達等原因會主動由運行狀態變爲阻塞狀態,當進程進入阻塞狀態,是不佔用CPU資源的。這些知識對於理解到底選擇哪種方式進行併發編程也是很重要的。

I/O模式和事件驅動

對於一次I/O操作(以讀操作爲例),數據會先被拷貝到操作系統內核的緩衝區中,然後從操作系統內核的緩衝區拷貝到應用程序的緩衝區(這種方式稱爲標準I/O或緩存I/O,大多數文件系統的默認I/O都是這種方式),最後交給進程。所以說,當一個讀操作發生時(寫操作與之類似),它會經歷兩個階段:(1)等待數據準備就緒;(2)將數據從內核拷貝到進程中。

由於存在這兩個階段,因此產生了以下幾種I/O模式:

  1. 阻塞 I/O(blocking I/O):進程發起讀操作,如果內核數據尚未就緒,進程會阻塞等待數據直到內核數據就緒並拷貝到進程的內存中。
  2. 非阻塞 I/O(non-blocking I/O):進程發起讀操作,如果內核數據尚未就緒,進程不阻塞而是收到內核返回的錯誤信息,進程收到錯誤信息可以再次發起讀操作,一旦內核數據準備就緒,就立即將數據拷貝到了用戶內存中,然後返回。
  3. 多路I/O複用( I/O multiplexing):監聽多個I/O對象,當I/O對象有變化(數據就緒)的時候就通知用戶進程。多路I/O複用的優勢並不在於單個I/O操作能處理得更快,而是在於能處理更多的I/O操作。
  4. 異步 I/O(asynchronous I/O):進程發起讀操作後就可以去做別的事情了,內核收到異步讀操作後會立即返回,所以用戶進程不阻塞,當內核數據準備就緒時,內核發送一個信號給用戶進程,告訴它讀操作完成了。

通常,我們編寫一個處理用戶請求的服務器程序時,有以下三種方式可供選擇:

  1. 每收到一個請求,創建一個新的進程,來處理該請求;
  2. 每收到一個請求,創建一個新的線程,來處理該請求;
  3. 每收到一個請求,放入一個事件列表,讓主進程通過非阻塞I/O方式來處理請求

第1種方式實現比較簡單,但由於創建進程開銷比較大,會導致服務器性能比較差;第2種方式,由於要涉及到線程的同步,有可能會面臨競爭、死鎖等問題;第3種方式,就是所謂事件驅動的方式,它利用了多路I/O複用和異步I/O的優點,雖然代碼邏輯比前面兩種都複雜,但能達到最好的性能,這也是目前大多數網絡服務器採用的方式。