天天看點

python線程安全操作_如何編寫快速且線程安全的Python代碼

概述

如今我也是使用Python寫代碼好多年了,但是我卻很少關心GIL的内部機制,導緻在寫Python多線程程式的時候。今天我們就來看看CPython的源代碼,探索一下GIL的源碼,了解為什麼Python裡要存在這個GIL,過程中我會給出一些示例來幫助大家更好的了解GIL。

GIL概覽

有如下代碼:

static PyThread_type_lock interpreter_lock = 0;

這行代碼位于Python2.7源碼ceval.c檔案裡。在類Unix作業系統中,PyThread_type_lock對應C語言裡的mutex_t類型。在Python解釋器開始運作時初始化這個變量

void

PyEval_InitThreads(void)

{

interpreter_lock = PyThread_allocate_lock();

PyThread_acquire_lock(interpreter_lock);

}

所有Python解釋器裡執行的c代碼都必須擷取這個鎖,作者一開始為求簡單,是以使用這種單線程的方式,後來每次想移除時,都發現代價太高了。

GIL對程式中的線程的影響很簡單,你可以在手背上寫下這個原則:“一個線程運作Python,而另外一個線程正在等待I / O.”Python代碼可以使用threading.Lock或者其他同步對象,來釋放CPU占用,讓其他程式得以執行。

什麼時候線程切換? 每當線程開始休眠或等待網絡I / O時,另一個線程都有機會擷取GIL并執行Python代碼。CPython還具有搶先式多任務處理:如果一個線程在Python 2中不間斷地運作1000個位元組碼指令,或者在Python 3中運作15毫秒,那麼它就會放棄GIL而另一個線程可能會運作。

協作式多任務

每當運作一個任務,比如網絡I/O,持續的時間很長或者無法确定運作時間,這時可以放棄GIL,這樣另一個線程就可以接受并運作Python。 這種行為稱為協同多任務,它允許并發; 許多線程可以同時等待不同的事件。

假設有兩個連結socket的線程

def do_connect():

s = socket.socket()

s.connect(('python.org', 80)) # drop the GIL

for i in range(2):

t = threading.Thread(target=do_connect)

t.start()

這兩個線程中一次隻有一個可以執行Python,但是一旦線程開始連接配接,它就會丢棄GIL,以便其他線程可以運作。這意味着兩個線程都可以等待它們的套接字同時連接配接,他們可以在相同的時間内完成更多的工作。

接下來,讓我們打開Python的源碼,來看看内部是如何實作的(位于socketmodule.c檔案裡):

static PyObject *

sock_connect(PySocketSockObject *s, PyObject *addro)

{

sock_addr_t addrbuf;

int addrlen;

int res;

getsockaddrarg(s, addro, SAS2SA(&addrbuf), &addrlen);

Py_BEGIN_ALLOW_THREADS

res = connect(s->sock_fd, addr, addrlen);

Py_END_ALLOW_THREADS

}

Py_BEGIN_ALLOW_THREADS宏指令用于釋放GIL,他的定義很簡單:

PyThread_release_lock(interpreter_lock);

Py_END_ALLOW_THREADS用于擷取GIL鎖,這時,目前現在有可能會卡住,等待其他現在釋放GIL鎖。

優先權式多任務

Python線程可以自願釋放GIL,但它也可以搶先擷取GIL。

讓我們回顧一下如何執行Python。 您的程式分兩個階段運作。 首先,您的Python文本被編譯為更簡單的二進制格式,稱為位元組碼。 其次,Python解釋器的主循環,一個名為PyEval_EvalFrameEx()的函數,讀取位元組碼并逐個執行其中的指令。當解釋器逐漸執行您的位元組碼時,它會定期删除GIL,而不會詢問正在執行其代碼的線程的權限,是以其他線程可以運作:

for (;;) {

if (--ticker < 0) {

ticker = check_interval;

PyThread_release_lock(interpreter_lock);

PyThread_acquire_lock(interpreter_lock, 1);

}

bytecode = *next_instr++;

switch (bytecode) {

}

}

預設情況下,檢查間隔為1000個位元組碼。 所有線程都運作相同的代碼,并以相同的方式定期從它們擷取鎖定。 在Python 3中,GIL的實作更複雜,檢查間隔不是固定數量的位元組碼,而是15毫秒。 但是,對于您的代碼,這些差異并不重要。

Python線程安全

如果某個線程在任何時候都可能丢失GIL,那麼您必須使代碼具有線程安全性。 然而,Python程式員對線程安全的看法與C或Java程式員的不同,因為許多Python操作都是原子的。

原子操作的一個示例是在清單上調用sort()。 線程不能在排序過程中被中斷,其他線程永遠不會看到部分排序的清單,也不會在清單排序之前看到過時的資料。 原子操作簡化了我們的生活,但也有驚喜。 例如,+ =似乎比sort()簡單,但+ =不是原子的。 那我們怎麼知道哪些操作是原子的,哪些不是?

例如有代碼如下:

n = 0

def foo():

global n

n += 1

我們可以使用python的dis子產品擷取這段代碼對應的位元組碼:

>>> import dis

>>> dis.dis(foo)

LOAD_GLOBAL 0 (n)

LOAD_CONST 1 (1)

INPLACE_ADD

STORE_GLOBAL 0 (n)

可以看出,n += 1這行代碼,編譯出了4個位元組碼:

将n的值加載到堆棧上

将常量1加載到堆棧上

将堆棧頂部的兩個值相加

将總和存回n

請記住,一個線程的每1000個位元組碼被解釋器中斷以釋放GIL。 如果線程不幸運,這可能發生在它将n的值加載到堆棧上以及何時将其存儲回來之間。這樣就容易導緻資料丢失:

threads = []

for i in range(100):

t = threading.Thread(target=foo)

threads.append(t)

for t in threads:

t.start()

for t in threads:

t.join()

print(n)

通常這段代碼列印100,因為100個線程中的每一個都增加了1。 但有時你會看到99或98,這就是其中一個線程的更新被另一個線程覆寫。是以,盡管有GIL,你仍然需要鎖來保護共享的可變狀态:

n = 0

lock = threading.Lock()

def foo():

global n

with lock:

n += 1

同樣的,如果我們使用sort()函數:

lst = [4, 1, 3, 2]

def foo():

lst.sort()

翻譯成位元組碼如下:

>>> dis.dis(foo)

LOAD_GLOBAL 0 (lst)

LOAD_ATTR 1 (sort)

CALL_FUNCTION 0

可以看出,sort()函數被翻譯成了一條指令,執行過程不會被打斷。

将lst的值加載到堆棧上

将其排序方法加載到堆棧上

調用排序方法

即使lst.sort()需要幾個步驟,sort調用本身也是一個位元組碼,是以不會被打斷。 我們可以得出結論,我們不需要鎖定sort()。 或者,請遵循一個簡單的規則:始終鎖定共享可變狀态的讀寫。 畢竟,擷取Python中的threading.Lock花銷很低。

雖然GIL不能免除鎖的需要,但它确實意味着不需要細粒度的鎖定。 在像Java這樣的自由線程語言中,程式員努力在盡可能短的時間内鎖定共享資料,以減少線程争用并允許最大并行度。 但是,由于線程無法并行運作Python,是以細粒度鎖定沒有任何優勢。 隻要沒有線程在休眠時持有鎖,I / O或其他一些GIL丢棄操作,你應該使用最粗糙,最簡單的鎖。 無論如何,其他線程無法并行運作。

并發提供更好的性能

在諸如網絡請求等I/O型的場景中,使用Python多線程可以帶來很高的性能提升,因為在I/O場景中,大多數線程都在等待I/O以進行接下來的操作,是以即使單CPU,也能大大提高性能。比如下面這樣的代碼:

import threading

import requests

urls = [...]

def worker():

while True:

try:

url = urls.pop()

except IndexError:

break # Done.

requests.get(url)

for _ in range(10):

t = threading.Thread(target=worker)

t.start()

如上所述,這些線程在等待通過HTTP擷取URL所涉及的每個套接字操作時丢棄GIL,是以它們比單個線程性能更高。

并行

如果你的任務一定要多線程才能更好的完成,那麼,對于Python來說,多線程是不合适的,這種情況下,你得使用多程序,因為每個程序都是單獨的運作環境,并且可以使用多核,但這會帶來更高的性能開銷。下面的代碼就是使用多程序來運作任務,每個程序裡隻有一個線程。

import os

import sys

nums =[1 for _ in range(1000000)]

chunk_size = len(nums) // 10

readers = []

while nums:

chunk, nums = nums[:chunk_size], nums[chunk_size:]

reader, writer = os.pipe()

if os.fork():

readers.append(reader) # Parent.

else:

subtotal = 0

for i in chunk: # Intentionally slow code.

subtotal += i

print('subtotal %d' % subtotal)

os.write(writer, str(subtotal).encode())

sys.exit(0)

# Parent.

total = 0

for reader in readers:

subtotal = int(os.read(reader, 1000).decode())

total += subtotal

print("Total: %d" % total)

因為每個程序都擁有單獨的GIL,是以這段代碼可以在多核CPU上并行執行。

總結

由于Python GIL的存在,導緻Python中一個程序下的多個線程無法并行執行,在I/O密集型的場景中,多線程依然能帶來比較好的性能,但是在CPU密集型的場景中,多線程無法帶來性能的提升。但同時也是由于GIL的存在,我們在單程序中,線程安全也比較容易達到。