改善 Python 程式的 91 個建議(六)

馭風者發表於2017-05-17

建議 87:充分利用 set 的優勢

Python 中集合是通過 Hash 演算法實現的無序不重複的元素集。

我們來做一些測試:

$ python -m timeit -n 1000 "[x for x in range(1000) if x in range(600, 1000)]"
1000 loops, best of 3: 6.44 msec per loop
$ python -m timeit -n 1000 "set(range(100)).intersection(range(60, 100))"   
1000 loops, best of 3: 9.18 usec per loop

實際上 set 的 union、intersection、difference 等操作要比 list 的迭代要快。因此如果涉及求 list 交集、並集或者差等問題可以轉換為 set 來操作。

建議 88:使用 multiprocess 克服 GIL 的缺陷

多程式 Multiprocess 是 Python 中的多程式管理包,在 Python2.6 版本中引進的,主要用來幫助處理程式的建立以及它們之間的通訊和相互協調。它主要解決了兩個問題:一是儘量縮小平臺之間的差異,提供高層次的 API 從而使得使用者忽略底層 IPC 的問題;二是提供對複雜物件的共享支援,支援本地和遠端併發。

類 Process 是 multiprocess 中較為重要的一個類,使用者建立程式,其建構函式如下:

Process([group[, target[, name[, args[, kwargs]]]]])

其中,引數 target 表示可呼叫物件;args 表示呼叫物件的位置引數元組;kwargs 表示呼叫物件的字典;name 為程式的名稱;group 一般設定為 None。該類提供的方法與屬性基本上與 threading.Thread 一致,包括 is_alive()、join([timeout])、run()、start()、terminate()、daemon(要通過 start() 設定)、exitcode、name、pid 等。

不同於執行緒,每個程式都有其獨立的地址空間,程式間的資料空間也相互獨立,因此程式之間資料的共享和傳遞不如執行緒來得方便。慶幸的是 multiprocess 模組中都提供了相應的機制:如程式間同步操作原語 Lock、Event、Condition、Semaphore,傳統的管道通訊機制 pipe 以及佇列 Queue,用於共享資源的 multiprocess.Value 和 multiprocess.Array 以及 Manager 等。

Multiprocessing 模組在使用上需要注意以下幾個要點:

  1. 程式之間的的通訊優先考慮 Pipe 和 Queue,而不是 Lock、Event、Condition、Semaphore 等同步原語。程式中的類 Queue 使用 pipe 和一些 locks、semaphores 原語來實現,是程式安全的。該類的建構函式返回一個程式的共享佇列,其支援的方法和執行緒中的 Queue 基本類似,除了方法 task_done()和 join() 是在其子類 JoinableQueue 中實現的以外。需要注意的是,由於底層使用 pipe 來實現,使用 Queue 進行程式之間的通訊的時候,傳輸的物件必須是可以序列化的,否則 put 操作會導致 PicklingError。此外,為了提供 put 方法的超時控制,Queue 並不是直接將物件寫到管道中而是先寫到一個本地的快取中,再將其從快取中放入 pipe 中,內部有個專門的執行緒 feeder 負責這項工作。由於 feeder 的存在,Queue 還提供了以下特殊方法來處理程式退出時快取中仍然存在資料的問題。

    • close():表明不再存放資料到 queue 中。一旦所有緩衝的資料重新整理到管道,後臺執行緒將退出。

    • join_thread():一般在 close 方法之後使用,它會阻止直到後臺執行緒退出,確保所有緩衝區中的資料已經重新整理到管道中。

    • cancel_join_thread():需要立即退出當前程式,而無需等待排隊的資料重新整理到底層管道的時候可以使用該方法,表明無須阻止到後臺程式的退出。

    Multiprocessing 中還有個 SimpleQueue 佇列,它是實現了鎖機制的 pipe,內部去掉了 buffer,但沒有提供 put 和 get 的超時處理,兩個動作都是阻塞的。

    除了 multiprocessing.Queue 之外,另一種很重要的通訊方式是 multiprocessing.Pipe。它的建構函式為 multiprocess.Pipe([duplex]),其中 duplex 預設為 True,表示為雙向管道,否則為單向。它返回一個 Connection 物件的組(conn1, conn2),分別表示管道的兩端。Pipe 不支援程式安全,因此當有多個程式同時對管道的一端進行讀操作或者寫操作的時候可能會導致資料丟失或者損壞。因此在程式通訊的時候,如果是超過 2 個以上的執行緒,可以使用 queue,但對於兩個程式之間的通訊而言 Pipe 效能更快。

    from multiprocessing import Process, Pipe, Queue
    import time
    
    def reader_pipe(pipe):
        output_p, input_p = pipe    # 返回管道的兩端
        inout_p.close()
        while True:
            try:
                msg = output_p.recv()    # 從 pipe 中讀取訊息
            except EOFError:
                    break
    
    def writer_pipe(count, input_p):    # 寫訊息到管道中
        for i in range(0, count):
            input_p.send(i)                # 傳送訊息
    
    def reader_queue(queue):            # 利用佇列來傳送訊息
        while True:
            msg = queue.get()            # 從佇列中獲取元素
            if msg == "DONE":
                break
    
    def writer_queue(count, queue):
        for ii in range(0, count):
            queue.put(ii)                # 放入訊息佇列中
        queue.put("DONE")
    
    if __name__ == "__main__":
        print("testing for pipe:")
        for count in [10 ** 3, 10 ** 4, 10 ** 5]:
            output_p, input_p = Pipe()
            reader_p = Process(target=reader_pipe, args=((output_p, input_p),))
            reader_p.start()            # 啟動程式
            output_p.close()
            _start = time.time()
            writer_pipe(count, input_p)    # 寫訊息到管道中
            input_p.close()
            reader_p.join()                # 等待程式處理完畢
            print("Sending {} numbers to Pipe() took {} seconds".format(count, (time.time() - _start)))
    
        print("testsing for queue:")
        for count in [10 ** 3, 10 ** 4, 10 ** 5]:
            queue = Queue()                # 利用 queue 進行通訊
            reader_p = Process(target=reader_queue, args=((queue),))
            reader_p.daemon = True
            reader_p.start()
    
            _start = time.time()
            writer_queue(count, queue)    # 寫訊息到 queue 中
            reader_p.join()
            print("Seding {} numbers to Queue() took {} seconds".format(count, (time.time() - _start)))
    

    上面程式碼分別用來測試兩個多執行緒的情況下使用 pipe 和 queue 進行通訊傳送相同資料的時候的效能,從函式輸出可以看出,pipe 所消耗的時間較小,效能更好。

  2. 儘量避免資源共享。相比於執行緒,程式之間資源共享的開銷較大,因此要儘量避免資源共享。但如果不可避免,可以通過 multiprocessing.Value 和 multiprocessing.Array 或者 multiprocessing.sharedctpyes 來實現記憶體共享,也可以通過伺服器程式管理器 Manager() 來實現資料和狀態的共享。這兩種方式各有優勢,總體來說共享記憶體的方式更快,效率更高,但伺服器程式管理器 Manager() 使用起來更為方便,並且支援本地和遠端記憶體共享。

    # 示例一
    import time
    from multiprocessing import Process, Value
    
    def func(val):    # 多個程式同時修改 val
        for i in range(10):
            time.sleep(0.1)
            val.value += 1
    
    if __name__ == "__main__":
        v = Value("i", 0)    # 使用 value 來共享記憶體
        processList = [Process(target=func, args=(v,)) for i in range(10)]
        for p in processList: p.start()
        for p in processList: p.join()
        print v.value
    # 修改 func 函式,真正控制同步訪問
    def func(val):
        for i in range(10):
            time.sleep(0.1)
            with val.get_lock():    # 仍然需要使用 get_lock 方法來獲取鎖物件
                val.value += 1
    # 示例二
    import multiprocessing
    def f(ns):
        ns.x.append(1)
        ns.y.append("a")
    
    if __name__ == "__main__":
        manager = multiprocessing.Manager()
        ns = manager.Namespace()
        ns.x = []    # manager 內部包括可變物件
        ns.y = []
    
        print("before process operation: {}".format(ns))
        p = multiprocessing.Process(target=f, args=(ns,))
        p.start()
        p.join()
        print("after process operation {}".format(ns))    # 修改根本不會生效
    # 修改
    import multiprocessing
    def f(ns, x, y):
        x.append(1)
        y.append("a")
        ns.x = x    # 將可變物件也作為引數傳入
        ns.y = y
    
    if __name__ == "__main__":
        manager = multiprocessing.Manager()
        ns = manager.Namespace()
        ns.x = []    # manager 內部包括可變物件
        ns.y = []
    
        print("before process operation: {}".format(ns))
        p = multiprocessing.Process(target=f, args=(ns, ns.x, ns.y))
        p.start()
        p.join()
        print("after process operation {}".format(ns))
    
  3. 注意平臺之間的差異。由於 Linux 平臺使用 fork() 來建立程式,因此父程式中所有的資源,如資料結構、開啟的檔案或者資料庫的連線都會在子程式中共享,而 Windows 平臺中父子程式相對獨立,因此為了更好地保持平臺的相容性,最好能夠將相關資源物件作為子程式的建構函式的引數傳遞進去。要避免如下方式:

    f = None
    def child(f):
        # do something
    
    if __name__ == "__main__":
        f = open(filename, mode)
        p = Process(target=child)
        p.start()
        p.join()
    # 推薦的方式
    def child(f):
        print(f)
    
    if __name__ == "__main__":
        f = open(filename, mode)
        p = Process(target=child, args=(f, ))    # 將資源物件作為建構函式引數傳入
        p.start()
        p.join()
    

    需要注意的是,Linux 平臺上 multiprocessing 的實現是基於 C 庫中的 fork(),所有子程式與父程式的資料是完全相同,因此父程式中所有的資源,如資料結構、開啟的檔案或者資料庫的連線都會在子程式中共享。但 Windows 平臺上由於沒有 fork() 函式,父子程式相對獨立,因此保持了平臺的相容性,最好在指令碼中加上 if __name__ == "__main__" 的判斷,這樣可以避免出現 RuntimeError 或者死鎖。

  4. 儘量避免使用 terminate() 方式終止程式,並且確保 pool.map 中傳入的引數是可以序列化的。

    import multiprocessing
    def unwrap_self_f(*args, **kwargs):
        return calculate.f(*args, **kwargs)    # 返回一個物件
    
    class calculate(object):
        def f(self, x):
            return x * x
        def run(self):
            p = multiprocessing.Pool()
            return p.map(unwrap_self_f, zip([self] * 3, [1, 2, 3]))
    
    if __name__ == "__main__":
        c1 = calculate()
        print(c1.run())
    

建議 89:使用執行緒池提高效率

我們知道執行緒的生命週期分為 5 個狀態:建立、就緒、執行、阻塞和終止。自執行緒建立到終止,執行緒便不斷在執行、就緒和阻塞這 3 個狀態之間轉換直至銷燬。而真正佔有 CPU 的只有執行、建立和銷燬這 3 個狀態。一個執行緒的執行時間由此可以分為 3 部分:執行緒的啟動時間(Ts)、執行緒體的執行時間(Tr)以及執行緒的銷燬時間(Td)。在多執行緒處理的情境中,如果執行緒不能夠被重用,就意味著每次建立都需要經過啟動、銷燬和執行這 3 個過程。這必然會增加系統的相應時間,降低效率。而執行緒體的執行時間 Tr 不可控制,在這種情況下要提高執行緒執行的效率,執行緒池便是一個解決方案。

執行緒池通過實現建立多個能夠執行任務的執行緒放入池中,所要執行的任務通常被安排在佇列中。通常情況下,需要處理的任務比執行緒的數目要多,執行緒執行完當前任務後,會從佇列中取下一個任務,直到所有的任務已經完成。

由於執行緒預先被建立並放入執行緒池中,同時處理完當前任務之後並不銷燬而是被安排處理下一個任務,因此能夠避免多次建立執行緒,從而節省執行緒建立和銷燬的開銷,帶來更好的效能和系統穩定性。執行緒池技術適合處理突發性大量請求或者需要大量執行緒來完成任務、但任務實際處理時間較短的應用場景,它能有效避免由於系統中建立執行緒過多而導致的系統效能負載過大、響應過慢等問題。

Python 中利用執行緒池有兩種解決方案:一是自己實現執行緒池模式,二是使用執行緒池模組。

先來看一個執行緒池模式的簡單實現:執行緒池程式碼

本段程式碼偏長,編輯到知乎上始終無法儲存,只好貼我的部落格地址,各位可轉到部落格去看。

自行實現執行緒,需要定義一個 Worker 處理工作請求,定義 WorkerManager 來進行執行緒池的管理和建立,它包含一個工作請求佇列和執行結果佇列,具體的下載工作通過 download_file 方法實現。

相比自己實現的執行緒池模型,使用現成的執行緒池模組往往更簡單。Python 中執行緒池模組的下載地址。該模組提供了以下基本類和方法:

  • threadpool.ThreadPool:執行緒池類,主要的作用是用來分派任務請求和收集執行結果。主要有以下方法:

    • __init__(self, num_workers, q_size=0, resq_size=0, poll_timeout=5):建立執行緒池,並啟動對應 num_workers 的執行緒;q_size 表示任務請求佇列的大小,resq_size 表示存放執行結果佇列的大小。

    • createWorkers(self, num_workers, poll_timeout=5):將 num_workers 數量對應的執行緒加入執行緒池中。

    • dismissWorkers(self, num_workers, do_join=False):告訴 num_workers 數量的工作執行緒當執行完當前任務後退出

    • joinAllDismissedWorkers(self):在設定為退出的執行緒上執行 Thread.join

    • putRequest(self, request, block=True, timeout=None):將工作請求放入佇列中

    • poll(self, block=False):處理任務佇列中新的請求wait(self):阻塞用於等待所有執行結果。注意當所有執行結果返回後,執行緒池內部的執行緒並沒有銷燬,而是在等待新的任務。因此,wait() 之後仍然可以再次呼叫 pool.putRequests()往其中新增任務

  • threadpool.WorkRequest:包含有具體執行方法的工作請求類

  • threadpool.WorkerThread:處理任務的工作執行緒,主要有 run() 方法以及 dismiss() 方法。

  • makeRequests(callable_, args_list, callback=None, exec_callback=_handle_thread_exception):主要函式,作用是建立具有相同的執行函式但引數不同的一系列工作請求。

再來看一個執行緒池實現的例子:

import urllib2
import os
import time
import threadpool

def download_file(url):
    print("begin download {}".format(url ))
    urlhandler = urllib2.urlopen(url)
    fname = os.path.basename(url) + ".html"
    with open(fname, "wb") as f:
        while True:
            chunk = urlhandler.read(1024)
            if not chunk:
                break
            f.write(chunk)

urls = ["http://wiki.python.org/moni/WebProgramming",
       "https://www.createspace.com/3611970",
       "http://wiki.python.org/moin/Documention"]
pool_size = 2
pool = threadpool.ThreadPool(pool_size)    # 建立執行緒池,大小為 2
requests = threadpool.makrRequests(download_file, urls)    # 建立工作請求
[pool.putRequest(req) for req in requests]

print("putting request to pool")
pool.putRequest(threadpool.WorkRequest(download_file, args=["http://chrisarndt.de/projects/threadpool/api/",]))    # 將具體的請求放入執行緒池
pool.putRequest(threadpool.WorkRequest(download_file, args=["https://pypi.python.org/pypi/threadpool",]))
pool.poll()    # 處理任務佇列中的新的請求
pool.wait()
print("destory all threads before exist")
pool.dismissWorkers(pool_size, do_join=True)    # 完成後退出

建議 90:使用 C/C++ 模組擴充套件高效能

Python 具有良好的可擴充套件性,利用 Python 提供的 API,如巨集、型別、函式等,可以讓 Python 方便地進行 C/C++ 擴充套件,從而獲得較優的執行效能。所有這些 API 卻包含在 Python.h 的標頭檔案中,在編寫 C 程式碼的時候引入該標頭檔案即可。

來看一個簡單的例子:

1、先用 C 實現相關函式,實現素數判斷,也可以直接使用 C 語言實現相關函式功能後再使用 Python 進行包裝。

#include "Python.h"
static PyObject * pr_isprime(PyObject, *self, PyObject * args) {
  int n, num;
  if (!PyArg_ParseTuple(args, "i", &num))    // 解析引數
    return NULL;
  if (num < 1) {
    return Py_BuildValue("i", 0);    // C 型別的資料結構轉換成 Python 物件
  }
  n = num - 1;
  while (n > 1) {
    if (num % n == 0) {
      return Py_BuildValue("i", 0);
      n--;
    }
  }
  return Py_BuildValue("i", 1);
}

static PyMethodDef PrMethods[] = {
  {"isPrime", pr_isprime, METH_VARARGS, "check if an input number is prime or not."},
  {NULL, NULL, 0, NULL}
};

void initpr(void) {
  (void) Py_InitModule("pr", PrMethods);
}

上面的程式碼包含以下 3 部分:

  • 匯出函式:C 模組對外暴露的介面函式 pr_isprime,帶有 self 和 args 兩個引數,其中引數 args 中包含了 Python 直譯器要傳遞給 C 函式的所有引數,通常使用函式 PyArg_ParseTuple() 來獲得這些引數值

  • 初始化函式:以便 Python 直譯器能夠對模組進行正確的初始化,初始化時要以 init 開頭,如 initp

  • 方法列表:提供給外部的 Python 程式使用的一個 C 模組函式名稱對映表 PrMethods。它是一個 PyMethodDef 結構體,其中成員依次表示方法名、匯出函式、引數傳遞方式和方法描述。看下面的例子:

 struct PyMethodDef {
    char * m1_name;        // 方法名
    PyCFunction m1_meth;    // 匯出函式
    int m1_flags;            // 引數傳遞方法
    char * m1_doc;        // 方法描述
 }

引數傳遞方法一般設定為 METH_VARARGS,如果想傳入關鍵字引數,則可以將其與 METH_KEYWORDS 進行或運算。若不想接受任何引數,則可以將其設定為 METH_NOARGS。該結構體必須與 {NULL, NULL, 0, NULL} 所表示的一條空記錄來結尾。

2、編寫 setup.py 指令碼。

from distutils.core import setup, Extension
module = Extension("pr", sources=["testextend.c"])
setup(name="Pr test", version="1.0", ext_modules=[module])

3、使用 python setup.py build 進行編譯,系統會在當前目錄下生成一個 build 子目錄,裡面包含 pr.so 和 pr.o 檔案。

4、將生成的檔案 py.so 複製到 Python 的 site_packages 目錄下,或者將 pr.so 所在目錄的路徑新增到 sys.path 中,就可以使用 C 擴充套件的模組了。

更多關於 C 模組擴充套件的內容請讀者參考

建議 91:使用 Cython 編寫擴充套件模組

Python-API 讓大家可以方便地使用 C/C++ 編寫擴充套件模組,從而通過重寫應用中的瓶頸程式碼獲得效能提升。但是,這種方式仍然有幾個問題:

  1. 掌握 C/C++ 程式語言、工具鏈有巨大的學習成本

  2. 即便是 C/C++ 熟手,重寫程式碼也有非常多的工作,比如編寫特定資料結構、演算法的 C/C++ 版本,費時費力還容易出錯

所以整個 Python 社群都在努力實現一個 ”編譯器“,它可以把 Python 程式碼直接編譯成等價的 C/C++ 程式碼,從而獲得效能提升,如 Pyrex、Py2C 和 Cython 等。而從 Pyrex 發展而來的 Cython 是其中的集大成者。

Cython 通過給 Python 程式碼增加型別宣告和直接呼叫 C 函式,使得從 Python 程式碼中轉換的 C 程式碼能夠有非常高的執行效率。它的優勢在於它幾乎支援全部 Python 特性,也就是說,基本上所有的 Python 程式碼都是有效的 Cython 程式碼,這使得將 Cython 技術引入專案的成本降到最低。除此之外,Cython 支援使用 decorator 語法宣告型別,甚至支援專門的型別宣告檔案,以使原有的 Python 程式碼能夠繼續保持獨立,這些特性都使得它得到廣泛應用,比如 PyAMF、PyYAML 等庫都使用它編寫自己的高效率版本。

# 安裝
$ pip install -U cython
# 生成 .c 檔案
$ cython arithmetic.py
# 提交編譯器
$ gcc -shared -pthread -fPIC -fwrapv -02 -Wall -fno-strict-aliasing -I /usr/include/python2.7 -o arithmetic.so arithmetic.c
# 這時生成了 arithmetic.so 檔案
# 我們就可以像 import 普通模組一樣使用它

每一次都需要編譯、等待有點麻煩,所以 Cython 很體貼地提供了無需顯式編譯的方案:pyximport。只要將原有的 Python 程式碼字尾名從 .py 改為 .pyx 即可。

$ cp arithmetic.py arithmetic.pyx
$ cd ~
$ python
>>> import pyximport; pyximport.install()
>>> import arithmetic
>>> arithmetic.__file__

從 __file__ 屬性可以看出,這個 .pyx 檔案已經被編譯連結為共享庫了,pyximport 的確方便啊!

接下來我們看看 Cython 是如何提升效能的。

在 GIS 中,經常需要計算地球表面上兩點之間的距離:

import math
def great_circle(lon1, lat1, lon2, lat2):
    radius = 3956    # miles
    x = math.pi / 180.0
    a = (90.0 - lat1) * (x)
    b = (90.0 - lat2) * (x)
    theta = (lon2 - lon1) * (x)
    c = math.acos(math.cos(a) * math.cos(b)) + (math.sin(a) * math.sin(b) * math.cos(theta))
    return radius * c

接下來嘗試 Cython 進行改寫:

import math
def great_circle(float lon1, float lat1, float lon2, float lat2):
    cdef float radius = 3956.0
    cdef float pi = 3.14159265
    cdef float x = pi / 180.0
    cdef float a, b, theta, c
    a = (90.0 - lat1) * (x)
    b = (90.0 - lat2) * (x)
    theta = (lon2 - lon1) * (x)
    c = math.acos(math.cos(a) * math.cos(b)) + (math.sin(a) * math.sin(b) * math.cos(theta))
    return radius * c

通過給 great_circle 函式的引數、中間變數增加型別宣告,Cython 程式碼業務邏輯程式碼一行沒改。使用 timeit 庫可以測定提速將近 2 成,說明型別宣告對效能提升非常有幫助。這時候,還有一個效能瓶頸,呼叫的 math 庫是一個 Python 庫,效能較差,可以直接呼叫 C 函式來解決:

cdef extern from "math.h":
    float cosf(float theta)
    float sinf(float theta)
    float acosf(float theta)

def greate_circle(float lon1, float lat1, float lon2, float lat2):
    cdef float radius = 3956.0
    cdef float pi = 3.14159265
    cdef float x = pi / 180.0
    cdef float a, b, theta, c
    a = (90.0 - lat1) * (x)
    b = (90.0 - lat2) * (x)
    theta = (lon2 - lon1) * (x)
    c = acosf((cosf(a) * cosf(b)) + (sinf(a) * sinf(b) * cosf(theta)))
    return radius * c

Cython 使用 cdef extern from 語法,將 math.h 這個 C 語言庫標頭檔案裡宣告的 cofs、sinf、acosf 等函式匯入程式碼中。因為減少了 Python 函式呼叫和呼叫時產生的型別轉換開銷,使用 timeit 測試這個版本的程式碼效能提升了 5 倍有餘。

通過這個例子,可以掌握 Cython 的兩大技能:型別宣告和直接呼叫 C 函式。比起直接使用 C/C++ 編寫擴充套件模組,使用 Cython 的方法方便得多。

除了使用 Cython 編寫擴充套件模組提升效能之外,Cython 也可以用來把之前編寫的 C/C++ 程式碼封裝成 .so 模組給 Python 呼叫(類似 boost.python/SWIG 的功能),Cython 社群已經開發了許多自動化工具。

相關文章