Python線程5分鐘完全解讀

線程,有時被稱為輕量進程,是程序執行流的最小單元。一個標準的線程由線程ID,當前指令指針(PC),寄存器集合和堆棧組成。線程是進程中的一個實體,是被系統獨立調度和分派的基本單位,線程不擁有私有的系統資源,但它可與同屬一個進程的其它線程共享進程所擁有的全部資源。一個線程可以創建和撤消另一個線程,同一進程中的多個線程之間可以併發執行。

線程是程序中一個單一的順序控制流程。進程內有一個相對獨立的、可調度的執行單元,是系統獨立調度和分派CPU的基本單位指令運行時的程序的調度單位。在單個程序中同時運行多個線程完成不同的工作,稱為多線程。Python多線程用於I/O操作密集型的任務,如SocketServer網絡併發,網絡爬蟲。

現代處理器都是多核的,幾核處理器只能同時處理幾個線程,多線程執行程序看起來是同時進行,實際上是CPU在多個線程之間快速切換執行,這中間就涉及到上下問切換,所謂的上下文切換就是指一個線程Thread被分配的時間片用完了之後,線程的信息被保存起來,CPU執行另外的線程,再到CPU讀取線程Thread的信息並繼續執行Thread的過程。

線程模塊

Python的標準庫提供了兩個模塊:_thread和threading。_thread 提供了低級別的、原始的線程以及一個簡單的互斥鎖,它相比於 threading 模塊的功能還是比較有限的。Threading模塊是_thread模塊的替代,在實際的開發中,絕大多數情況下還是使用高級模塊threading,因此本書著重介紹threading高級模塊的使用。

Python創建Thread對象語法如下:

import threadingthreading.Thread(target=None, name=None, args=())

主要參數說明:

  • target 是函數名字,需要調用的函數。
  • name 設置線程名字。
  • args 函數需要的參數,以元祖( tuple)的形式傳入
  • Thread對象主要方法說明:
  • run(): 用以表示線程活動的方法。
  • start():啟動線程活動。
  • join(): 等待至線程中止。
  • isAlive(): 返回線程是否活動的。
  • getName(): 返回線程名。
  • setName(): 設置線程名。

Python中實現多線程有兩種方式:函數式創建線程和創建線程類。

第一種創建線程方式:

創建線程的時候,只需要傳入一個執行函數和函數的參數即可完成threading.Thread實例的創建。下面的例子使用Thread類來產生2個子線程,然後啟動2個子線程並等待其結束,

import threading
import time,random,math

# idx 循環次數
def printNum(idx): 
for num in range(idx ):
#打印當前運行的線程名字
 print("{0}\tnum={1}".format(threading.current_thread().getName(), num) ) delay = math.ceil(random.random() * 2)
 time.sleep(delay)
if __name__ == '__main__':
 th1 = threading.Thread(target=printNum, args=(2,),name="thread1" ) 
 th2 = threading.Thread(target=printNum, args=(3,),name="thread2" )
#啟動2個線程
th1.start()
 th2.start()
#等待至線程中止
 th1.join()
 th2.join()
 print("{0}
 線程結束".format(threading.current_thread().getName()))

運行腳本得到以下結果。

thread1 num=0
thread2 num=0
thread1 num=1
thread2 num=1
thread2 num=2

MainThread 線程結束

運行腳本默認會啟動一個線程,把該線程稱為主線程,主線程有可以啟動新的線程,Python的threading模塊有個current_thread()函數,它將返回當前線程的示例。從當前線程的示例可以獲得前運行線程名字,核心代碼如下。

threading.current_thread().getName()

啟動一個線程就是把一個函數和參數傳入並創建Thread實例,然後調用start()開始執行

th1 = threading.Thread(target=printNum, args=(2,),name="thread1" )
th1.start()

從返回結果可以看出主線程示例的名字叫MainThread,子線程的名字在創建時指定,本例創建了2個子線程,名字叫thread1和thread2。如果沒有給線程起名字,Python就自動給線程命名為Thread-1,Thread-2…等等。在本例中定義了線程函數printNum(),打印idx次記錄後退出,每次打印使用time.sleep()讓程序休眠一段時間。

第二種創建線程方式:創建線程類

直接創建threading.Thread的子類來創建一個線程對象,實現多線程。通過繼承Thread類,並重寫Thread類的run()方法,在run()方法中定義具體要執行的任務。在Thread類中,提供了一個start()方法用於啟動新進程,線程啟動後會自動調用run()方法。

import threading
import time,random,math

class MutliThread(threading.Thread):

 def __init__(self, threadName,num): 
 threading.Thread.__init__(self)
 self.name = threadName
 self.num = num
 def run(self):
 for i in range(self.num):
 print("{0} i={1}".format(threading.current_thread().getName(), i)) 
 delay = math.ceil(random.random() * 2)
 time.sleep(delay)
if __name__ == '__main__': 
 thr1 = MutliThread("thread1",3) 
 thr2 = MutliThread("thread2",2) 
 # 啟動線程
 thr1.start()
 thr2.start() 
 # 等待至線程中止
 thr1.join() 
 thr2.join() 
 print("{0} 線程結束".format(threading.current_thread().getName()))運行腳本得到以下結果。
thread1 i=0
thread2 i=0
thread1 i=1
thread2 i=1
thread1 i=2

MainThread 線程結束

從返回結果可以看出,通過創建Thread類來產生2個線程對象thr1和thr2,重寫Thread類的run()函數,把業務邏輯放入其中,通過調用線程對象的start()方法啟動線程。通過調用線程對象的join()函數,等待該線程完成,在繼續下面的操作。

在本例中,主線程MainThread等待子線程thread1和thread2線程運行結束後才輸出” MainThread 線程結束”。如果子線程thread1和thread2不調用join()函數,那麼主線程MainThread和2個子線程是並行執行任務的,2個子線程加上join()函數後,程序就變成順序執行了。所以子線程用到join()的時候,通常都是主線程等到其他多個子線程執行完畢後再繼續執行,其他的多個子線程並不需要互相等待。

守護線程

在線程模塊中,使用子線程對象用到join()函數,主線程需要依賴子線程執行完畢後才繼續執行代碼。如果子線程不使用join()函數,主線程和子線程是並行運行的,沒有依賴關係,主線程執行了,子線程也在執行。

在多線程開發中,如果子線程設定為了守護線程,守護線程會等待主線程運行完畢後被銷燬。一個主線程可以設置多個守護線程,守護線程運行的前提是,主線程必須存在,如果主線程不存在了,守護線程會被銷燬。

在本例中創建1個主線程3個子線程,讓主線程和子線程並行執行。內容如下。

import threading, time

def run(taskName): 
 print("任務:", taskName)
 time.sleep(2)
 print("{0} 任務執行完畢".format(taskName)) # 查看每個子線程
if __name__ == '__main__':
 start_time = time.time()
 for i in range(3): 
 thr = threading.Thread(target=run, args=("task-{0}".format(i),))
 # 把子線程設置為守護線程
 thr.setDaemon(True) 
 thr.start()
 # 查看主線程和當前活動的所有線程數 
 print("{0}線程結束,當線程數量={1}".format( threading.current_thread().getName(), threading.active_count())) print("消耗時間:", time.time() - start_time)
運行腳本得到以下結果:
任務: task-0
任務: task-1
任務: task-2
MainThread線程結束,當線程數量=4消耗時間: 0.0009751319885253906
task-2 任務執行完畢
task-0 任務執行完畢
task-1 任務執行完畢

從返回結果可以看出,當前的線程個數是4,線程個數=主線程數 + 子線程數,在本例中有1個主線程和3個子線程。主線程執行完畢後,等待子線程執行完畢,程序才會退出。

在本例的基礎上,把所有的子線程都設置為守護線程。子線程變成守護線程後,只要主線程執行完畢,程序不管子線程有沒有執行完畢,程序都會退出。使用線程對象的setDaemon(True)函數來設置守護線程。

import threading, time

def run(taskName):
 print("任務:", taskName)
 time.sleep(2)
 print("{0} 任務執行完畢".format(taskName))
if __name__ == '__main__':
 start_time = time.time() 
 for i in range(3): 
 thr = threading.Thread(target=run, args=("task-{0}".format(i),))
 # 把子線程設置為守護線程,在啟動線程前設置
thr.setDaemon(True) 
 thr.start()
 # 查看主線程和當前活動的所有線程數
 thrName = threading.current_thread().getName()
 thrCount = threading.active_count()
 print("{0}線程結束,當線程數量={1}".format(thrName, thrCount)) 
 print("消耗時間:", time.time() - start_time)
運行腳本得到以下結果。
任務: task-0
任務: task-1
任務: task-2
MainThread線程結束,當線程數量=4
消耗時間: 0.0010023117065429688

從本例的返回結果可以看出,主線程執行完畢後,程序不會等待守護線程執行完畢後就退出了。設置線程對象為守護線程,一定要在線程對象調用start()函數前設置。

多線程的鎖機制

多線程編程訪問共享變量時會出現問題,但是多進程編程訪問共享變量不會出現問題。因為多進程中,同一個變量各自有一份拷貝存在於每個進程中,互不影響,而多線程中,所有變量都由所有線程共享。

多個進程之間對內存中的變量不會產生衝突,一個進程由多個線程組成,多線程對內存中的變量進行共享時會產生影響,所以就產生了死鎖問題,怎麼解決死鎖問題是本節主要介紹的內容。

1、變量的作用域

一般在函數體外定義的變量稱為全局變量,在函數內部定義的變量稱為局部變量。全局變量所有作用域都可讀,局部變量只能在本函數可讀。函數在讀取變量時,優先讀取函數本身自有的局部變量,再去讀全局變量。

內容如下。

# 全局變量
balance = 1
def change():
 # 定義全局變量
 global balance
 balance = 100
 # 定義局部變量
 num = 20
 print("change() balance={0}".format(balance) )
if __name__ == "__main__" : 
 change()
 print("修改後的 balance={0}".format(balance) )
運行腳本得到以下結果。
change() balance=100
修改後的 balance=100

如果註釋掉change()函數里的 global

v1,那麼得到的返回值是。change() balance=100修改後的 balance=1

在本例中在change()函數外定義的變量balance是全局變量,在change()函數內定義的變量num是局部變量,全局變量默認是可讀的,可以在任何函數中使用,如果需要改變全局變量的值,需要在函數內部使用global定義全局變量,本例中在change()函數內部使用global定義全局變量balance,在函數里就可以改變全局變量了。

在函數里可以使用全局變量,但是在函數里不能改變全局變量。想實現多個線程共享變量,需要使用全局變量。在方法里加上全局關鍵字 global定義全局變量,多線程才可以修改全局變量來共享變量。

2、多線程中的鎖

多線程同時修改全局變量時會出現數據安全問題,線程不安全就是不提供數據訪問保護,有可能出現多個線程先後更改數據造成所得到的數據是髒數據。在本例中我們生成2個線程同時修改change()函數里的全局變量balance時,會出現數據不一致問題。

本案例文件名為PythonFullStack\Chapter03\threadDemo03.py,內容如下。

import threading

balance = 100

def change(num, counter):
 global balance
 for i in range(counter):
 balance += num
 balance -= num
 if balance != 100: 
 # 如果輸出這句話,說明線程不安全 
 print("balance=%d" % balance)
break
if __name__ == "__main__":
 thr1 = threading.Thread(target=change,args=(100,500000),name='t1')
 thr2 = threading.Thread(target=change,args=(100,500000),name='t2') thr1.start()
 thr2.start()
 thr1.join()
 thr2.join()
 print("{0} 線程結束".format(threading.current_thread().getName()))運行以上腳本,當2個線程運行次數達到500000次時,會出現以下結果。
balance=200
MainThread 線程結束

在本例中定義了一個全局變量balance,初始值為100,當啟動2個線程後,先加後減,理論上balance應該為100。線程的調度是由操作系統決定的,當線程t1和t2交替執行時,只要循環次數足夠多,balance結果就不一定是100了。從結果可以看出,在本例中線程t1和t2同時修改全局變量balance時,會出現數據不一致問題。

注意

在多線程情況下,所有的全局變量有所有線程共享。所以,任何一個變量都可以被任何一個線程修改,因此,線程之間共享數據最大的危險在於多個線程同時改一個變量,把內容給改亂了。

在多線程情況下,使用全局變量並不會共享數據,會出現線程安全問題。線程安全就是多線程訪問時,採用了加鎖機制,當一個線程訪問該類的某個數據時,進行保護,其他線程不能進行訪問直到該線程讀取完,其他線程才可使用。不會出現數據不一致

在單線程運行時沒有代碼安全問題。寫多線程程序時,生成一個線程並不代表多線程。在多線程情況下,才會出現安全問題。

針對線程安全問題,需要使用”互斥鎖”,就像數據庫裡操縱數據一樣,也需要使用鎖機制。某個線程要更改共享數據時,先將其鎖定,此時資源的狀態為“鎖定”,其他線程不能更改;直到該線程釋放資源,將資源的狀態變成“非鎖定”,其他的線程才能再次鎖定該資源。互斥鎖保證了每次只有一個線程進行寫入操作,從而保證了多線程情況下數據的正確性。

互斥鎖的核心代碼如下:

# 創建鎖
mutex = threading.Lock()

# 鎖定
mutex.acquire()

# 釋放
mutex.release()

如果要確保balance計算正確,使用threading.Lock()來創建鎖對象lock,把 lock.acquire()和lock.release()加在同步代碼塊裡,本例的同步代碼塊就是對全局變量balance進行先加後減操作。

當某個線程執行change()函數時,通過lock.acquire()獲取鎖,那麼其他線程就不能執行同步代碼塊了,只能等待知道鎖被釋放了,獲得鎖才能執行同步代碼塊。由於鎖只有一個,無論多少線程,同一個時刻最多隻有一個線程持有該鎖,所以修改全局變量balance不會產生衝突。改良後的代碼內容如下。

import threading

balance = 100
lock = threading.Lock()

def change(num, counter): 
 global balance
 for i in range(counter):
# 先要獲取鎖
 lock.acquire()
 balance += num
 balance -= num
# 釋放鎖
lock.release()

 if balance != 100:
 # 如果輸出這句話,說明線程不安全
 print("balance=%d" % balance)
 break

if __name__ == "__main__":
 thr1 = threading.Thread(target=change,args=(100,500000),name='t1')
 thr2 = threading.Thread(target=change,args=(100,500000),name='t2')
 thr1.start()
 thr2.start()
 thr1.join()
 thr2.join()
 print("{0} 線程結束".format(threading.current_thread().getName()))

在本例中2個線程同時運行lock.acquire()時,只有一個線程能成功的獲取鎖,然後執行代碼,其他線程就繼續等待直到獲得鎖位置。獲得鎖的線程用完後一定要釋放鎖,否則其他線程就會一直等待下去,成為死線程。

在運行上面腳本就不會產生輸出信息,證明代碼是安全的。把 lock.acquire()和lock.release()加在同步代碼塊裡,還要注意鎖的力度不要加的太大了。第一個線程只有運行完了,第二個線程才能運行,所以鎖要在需要同步代碼里加上。

歡迎大家關注我的頭條號,私信“python",學習資料包免費分享給需要的朋友,另有python學習交流群,可以交流學習拓展人脈。


分享到:


相關文章: