Python學習筆記 - day13 - 進程與線程
概述
我們都知道windows是支持多任務的操作系統。
什麽叫“多任務”呢?簡單地說,就是操作系統可以同時運行多個任務。打個比方,你一邊在用瀏覽器上網,一邊在聽MP3,一邊在用Word趕作業,這就是多任務,至少同時有3個任務正在運行。還有很多任務悄悄地在後臺同時運行著,只是桌面上沒有顯示而已。
現在,多核CPU已經非常普及了,但是,即使過去的單核CPU,也可以執行多任務。由於CPU執行代碼都是順序執行的,那麽,單核CPU是怎麽執行多任務的呢?
答案就是操作系統輪流讓各個任務交替執行,任務1執行0.01秒,切換到任務2,任務2執行0.01秒,再切換到任務3,執行0.01秒……這樣反復執行下去。表面上看,每個任務都是交替執行的,但是,由於CPU的執行速度實在是太快了,我們感覺就像所有任務都在同時執行一樣。
真正的並行執行多任務只能在多核CPU上實現,但是,由於任務數量遠遠多於CPU的核心數量,所以,操作系統也會自動把很多任務輪流調度到每個核心上執行。
對於操作系統來說,一個任務就是一個進程(Process),比如打開一個瀏覽器就是啟動一個瀏覽器進程,打開一個記事本就啟動了一個記事本進程,打開兩個記事本就啟動了兩個記事本進程,打開一個Word就啟動了一個Word進程。
有些進程還不止同時幹一件事,比如Word,它可以同時進行打字、拼寫檢查、打印等事情。在一個進程內部,要同時幹多件事,就需要同時運行多個“子任務”,我們把進程內的這些“子任務”稱為線程(Thread)。
由於每個進程至少要幹一件事,所以,一個進程至少有一個線程。當然,像Word這種復雜的進程可以有多個線程,多個線程可以同時執行,多線程的執行方式和多進程是一樣的,也是由操作系統在多個線程之間快速切換,讓每個線程都短暫地交替運行,看起來就像同時執行一樣。當然,真正地同時執行多線程需要多核CPU才可能實現。
我們前面編寫的所有的Python程序,都是執行單任務的進程,也就是只有一個線程。如果我們要同時執行多個任務怎麽辦?有兩種解決方案:
- 一種是啟動多個進程,每個進程雖然只有一個線程,但多個進程可以一塊執行多個任務。
- 一種方法是啟動一個進程,在一個進程內啟動多個線程,這樣,多個線程也可以一塊執行多個任務。
當然還有第三種方法,就是啟動多個進程,每個進程再啟動多個線程,這樣同時執行的任務就更多了,當然這種模型更復雜,實際很少采用。
總結一下就是,多任務的實現有3種方式:
-
- 多進程模式;
- 多線程模式;
- 多進程+多線程模式
同時執行多個任務通常各個任務之間並不是沒有關聯的,而是需要相互通信和協調,有時,任務1必須暫停等待任務2完成後才能繼續執行,有時,任務3和任務4又不能同時執行,所以,多進程和多線程的程序的復雜度要遠遠高於我們前面寫的單進程單線程的程序。
Python既支持多進程,又支持多線程。
進程
正在進行的一個過程或者說一個任務。而負責執行任務的則是CPU
由於現在計算計算機都是多任務同時進行的,比如:打開了QQ,然後聽著音樂,後面下載者片兒,那麽這些都是怎麽完成的呢?答案是通過多進程。操作系統會對CPU的時間進行規劃,每個進程執行一個任務(功能),CPU會快速的在這些進行之間進行切換已達到同時進行的目的(單核CPU的情況)
進程與程序
程序:一堆代碼的集合體。 進程:指的是程序運行的過程。 註意的是:一個程序執行兩次,那麽會產生兩個互相隔離的進程。
並發與並行
並行:同時運行,只有具備多個CPU才能實現並行 並發:是偽並行,即看起來是同時運行。單個CPU+多道技術就可以實現並發。(並行也屬於並發)
同步與異步
同步指一個進程在執行某個請求的時候,若該請求需要一段時間才能返回信息,那麽這個進程將會一直等待下去,直到返回西南喜才繼續執行下去。 異步是指進程不需要一直等下去,而是繼續執行下面的操作,不管其他進程的狀態。當有消息返回時系統會通知進程處理,這樣可以提高執行的效率。 例子:打電話就是同步,發短信就是異步
進程的創建
主要分為4種: 1、系統初始化:(查看進程Linux中用ps命令,windows中用任務管理器,前臺進程負責與用戶交互,後臺運行的進程與用戶無關,運行在後臺並且只有在需要時才喚醒的進程,成為守護進程,如電子郵件,web頁面,新聞,打印等) 2、一個進程在運行過程中開啟了子進程(如nginx開啟多線程,操作系統os.fork(),subprocess.Popen等) 3、用戶的交互請求,而創建一個新的進程(如用戶雙擊QQ) 4、一個批處理作業的開始(只在大型批處理系統中應用)
以上四種其實都是由一個已經存在了的進程執行了一個用於創建進程的系統調用而創建的。
- 在unix/Linux系統中該調用是:fork,它非常特殊。普通的函數調用,調用一次,返回一次,但是
fork()
調用一次,返回兩次,因為操作系統自動把當前進程(稱為父進程)復制了一份(稱為子進程),然後,分別在父進程和子進程內返回。子進程返回0,父進程返回子進程的PID。 - 在winodws中調用的是createProcess,CreateProcess既處理進程的創建,也負責把正確的程序裝入新進程。
註意:
- 進程創建後父進程和子進程有各自不同的地址空間(多道技術要求物理層面實現進程之間內存的隔離),任何一個進程的在其地址空間中的修改都不會影響到另外的進程。
- 在Unix/linux,子進程的初始地址空間是父進程的一個副本,子進程和父進程是可以有只讀的共享內存區的。但是對於Winodws系統來說,從一開始父進程與子進程的地址空間就是不同的。
進程之間共享終端,共享一個文件系統
進程的狀態
進程的狀態主要分為三種:進行、阻塞、就緒
線程
在傳統的操作系統中,每個進程有一個地址空間,而且默認就有一個控制線程,多線程(及多個控制線程)的概念是,在一個進程中存在多個控制線程,多個控制線程共享該進程的地址空間,進程只是用來把資源集中到一起(進程只是一個資源單位,或者說資源集合),而線程才是CPU的執行單位。
為何要用多線程
多線程指的是,在一個進程中開啟多個線程,簡單來說:如果多個任務公用一塊地址空間,那麽必須在一個進程內開啟多個線程。 1、多線程共享一個進程的地址空間 2、線程比進程更輕量級,線程比進程更容易創建和撤銷,在許多操作系統中,創建一個線程比創建一個進程要快10-100倍 3、對於CPU密集型的應用,多線程並不能提升性能,但對於I/O密集型,使用多線程會明顯的提升速度(I/O密集型,根本用不上多核優勢) 4、在多CPU系統中,為了最大限度的利用多核,可以開啟多個線程(比開進程開銷要小的多) --> 針對其他語言 註意: Python中的線程比較特殊,其他語言,1個進程內4個線程,如果有4個CPU的時候,是可以同時運行的,而Python在同一時間1個進程內,只有一個線程可以工作。(就算你有再多的CPU,對Python來說用不上)
線程與進程的區別
1、線程共享創建它的進程的地址空間,進程擁有自己的地址空間 2、線程可以直接訪問進程的數據,進程擁有它父進程內存空間的拷貝 3、線程可以和同一進程內其他的線程直接通信,進程必須interprocess communicateion(IPC機制)進行通信 4、線程可以被很容易的創建,而進程依賴於父進程內存空間的拷貝 5、線程可以直接控制同一進程內的其他線程,進程只能控制自己的子進程 6、改變主線程(控制)可能會影響其他線程,改變主進程不會影響它的子進程
multiprocessing模塊
python中的多線程無法利用多核優勢,如果想要充分地使用多核CPU的資源(os.cpu_count()查看),在python中大部分情況需要使用多進程。Python提供了multiprocessing,該模塊用來開啟子進程,並在子進程中執行我們定制的任務(比如函數),該模塊與多線程模塊threading的編程接口類似。
multiprocessing模塊的功能眾多:支持子進程、通信和共享數據、執行不同形式的同步,提供了Process、Queue、Pipe、Lock等組件。
需要再次強調的一點是:與線程不同,進程沒有任何共享狀態,進程修改的數據,改動僅限於該進程內。
Process類和使用
註意:在windows中Process()必須放到# if __name__ == ‘__main__‘:下
利用Process創建進程的類:
Process([group [, target [, name [, args [, kwargs]]]]]),由該類實例化得到的對象,表示一個子進程中的任務(尚未啟動) 強調: 1. 需要使用關鍵字的方式來指定參數 2. args指定的為傳給target函數的位置參數,是一個元組形式,必須有逗號
參數:
- group參數未使用,值始終為None
- target表示調用對象,即子進程要執行的任務
- args表示調用對象的位置參數元組,args=(1,2,‘egon‘,)
- kwargs表示調用對象的字典,kwargs={‘name‘:‘egon‘,‘age‘:18}
- name為子進程的名稱
Process類的方法
p.start(): # 啟動進程,並調用該子進程中的p.run() --> 和直接調用run方法是不同的,因為它會初始化部分其他參數。 p.run(): # 進程啟動時運行的方法,正是它去調用target指定的函數,我們自定義類的類中一定要實現該方法 p.terminate(): # 強制終止進程p,不會進行任何清理操作,如果p創建了子進程,該子進程就成了僵屍進程,使用該方法需要特別小心這種情況。如果p還保存了一個鎖那麽也將不會被釋放,進而導致死鎖 p.is_alive(): # 如果p仍然運行,返回True p.join([timeout]): # 主線程等待p終止(強調:是主線程處於等的狀態,而p是處於運行的狀態)。timeout是可選的超時時間,需要強調的是,p.join只能join住start開啟的進程,而不能join住run開啟的進程
Process的其他屬性
p.daemon: # 默認值為False,如果設為True,代表p為後臺運行的守護進程,當p的父進程終止時,p也隨之終止,並且設定為True後,p不能創建自己的新進程,必須在p.start()之前設置 p.name: # 進程的名稱 p.pid: # 進程的pid p.exitcode: # 進程在運行時為None、如果為–N,表示被信號N結束(了解即可) p.authkey: # 進程的身份驗證鍵,默認是由os.urandom()隨機生成的32字符的字符串。這個鍵的用途是為涉及網絡連接的底層進程間通信提供安全性,這類連接只有在具有相同的身份驗證鍵時才能成功
特別強調:設置 p.daemon=True 是會隨著主進程執行完畢而被回收,不管子進程是否完成任務。
基本使用
使用Process創建進程的類有兩種方法:
1、通過實例化Process類完成進程的創建
2、繼承Process類,定制自己需要的功能後實例化創建進程類
# --------------------------- 方法1 --------------------------- import random import time from multiprocessing import Process def hello(name): print(‘Welcome to my Home‘) time.sleep(random.randint(1,3)) print(‘Bye Bye‘) p = Process(target=hello,args=(‘daxin‘,)) # 創建子進程p p.start() # 啟動子進程 print(‘主進程結束‘) # --------------------------- 方法2 --------------------------- import random import time from multiprocessing import Process class MyProcess(Process): def __init__(self,name): super(MyProcess, self).__init__() # 必須繼承父類的構造函數 self.name = name def run(self): # 必須叫run方法,因為start,就是執行的run方法。 print(‘Welcome to {0} Home‘.format(self.name)) time.sleep(random.randint(1,3)) print(‘Bye Bye‘) p = MyProcess(‘daxin‘) p.start() print(‘主進程結束‘)
利用多進程完成修改socket server
上一節我們利用socket完成了socket server的編寫,這裏我們使用multiprocessing對server端進行改寫,完成並發接受請求的功能。
Socket Server端 Socket client端如果服務端接受上萬個請求,那麽豈不是要創建1萬個進程去分別對應?這樣是不行的,那麽我們可以使用進程池的概念來解決這個問題,進程池的問題,在後續小節中詳細說明
進程同步鎖
進程之間數據不共享,但是共享同一套文件系統,所以訪問同一個文件,或同一個打印終端,是沒有問題的,競爭帶來的結果就是錯亂,如何控制,就是加鎖處理。
爭搶資源造成的順序問題鎖的目的就是:當程序1在使用的時候,申請鎖,並且鎖住共享資源,待使用完畢後,釋放鎖資源,其他程序獲取鎖後,重復這個過程。
Multiprocessing模塊提供了Lock對象用來完成進程同步鎖的功能
from multiprocessing import Lock lock = Lock() # 對象沒有參數 # 通過使用lock對象的acquire/release方法來進行 鎖/釋放 的需求。
利用進程同步鎖模擬搶票軟件的需求:
- 創建票文件,內容為json,設置余票數量
- 並發100個進程搶票
- 利用random + time 模塊模擬網絡延遲
import random import time import json from multiprocessing import Process,Lock def gettickles(filename,str,lock): lock.acquire() # 對要修改的部分加鎖 with open(filename,encoding=‘utf-8‘) as f: dic = json.loads(f.read()) if dic[‘count‘] > 0 : dic[‘count‘] -= 1 time.sleep(random.random()) with open(filename,‘w‘,encoding=‘utf-8‘) as f: f.write(json.dumps(dic)) print(‘\033[33m{0}搶票成功\033[0m‘.format(str)) else: print(‘\033[35m{0}搶票失敗\033[0m‘.format(str)) lock.release() # 修改完畢後解鎖 if __name__ == ‘__main__‘: lock = Lock() # 創建一個鎖文件 p_l = [] for i in range(1000): p = Process(target=gettickles,args=(‘a.txt‘,‘用戶%s‘ % i,lock)) p_l.append(p) p.start()
加鎖可以保證多個進程修改同一塊數據時,同一時間只能有一個任務可以進行修改,即串行的修改,沒錯,速度是慢了,但犧牲了速度卻保證了數據安全。
進程池
在利用Python進行系統管理的時候,特別是同時操作多個文件目錄,或者遠程控制多臺主機,並行操作可以節約大量的時間。多進程是實現並發的手段之一,需要註意的問題是:
- 很明顯需要並發執行的任務通常要遠大於核數
- 一個操作系統不可能無限開啟進程,通常有幾個核就開幾個進程
- 進程開啟過多,效率反而會下降(開啟進程是需要占用系統資源的,而且開啟多余核數目的進程也無法做到並行)
例如當被操作對象數目不大時,可以直接利用multiprocessing中的Process動態成生多個進程,十幾個還好,但如果是上百個,上千個。。。手動的去限制進程數量卻又太過繁瑣,此時可以發揮進程池的功效。
我們就可以通過維護一個進程池來控制進程數目,比如httpd的進程模式,規定最小進程數和最大進程數...
ps:對於遠程過程調用的高級應用程序而言,應該使用進程池,Pool可以提供指定數量的進程,供用戶調用,當有新的請求提交到pool中時,如果池還沒有滿,那麽就會創建一個新的進程用來執行該請求;但如果池中的進程數已經達到規定最大值,那麽該請求就會等待,直到池中有進程結束,就重用進程池中的進程。
創建進程池的類:如果指定numprocess為3,則進程池會從無到有創建三個進程,然後自始至終使用這三個進程去執行所有任務,不會開啟其他進程
from multiprocessing import Pool pool = Pool(processes=None, initializer=None, initargs=())
參數:
- processes:進程池的最大進程數量
- initiallizer:初始化完畢後要執行的函數
- initargs:要傳遞給函數的參數
常用方法
p.apply(func [, args [, kwargs]]) # 調用進程池中的一個進程執行函數func,args/kwargs為傳遞的參數,註意apply是阻塞式的,既串行執行。 p.apply_async(func [, args [, kwargs]]) # 功能同apply,區別是非阻塞的,既異步執行。 ———> 常用 p.close() # 關閉進程池,防止進一步操作。如果所有操作持續掛起,它們將在工作進程終止前完成 P.join() # 等待所有工作進程退出。此方法只能在close()或teminate()之後調用
註意:
apply_async 會返回AsyncResul對象,這個AsyncResul對象有有一下方法:
View Code利用進程池改寫socket server:
import os import socket import multiprocessing server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) server.bind((‘127.0.0.1‘,8100)) server.listen(5) def talk(conn): print(‘我的進程號是: %s‘ % os.getpid() ) while True: msg = conn.recv(1024) if not msg:break data = msg.decode(‘utf-8‘) msg = data.upper() conn.send(msg.encode(‘utf-8‘)) if __name__ == ‘__main__‘: pool = multiprocessing.Pool(1) while True: conn,addr = server.accept() print(addr) pool.apply_async(talk,args=(conn,)) pool.close() pool.join()
這裏指定了進程池的數量為1,那麽並發兩個連接的話,第二個會hold住,只有第一個斷開後,才會連接,註意:進程的Pid號,還是相同的。
回調函數
需要回調函數的場景:進程池中任何一個任務一旦處理完了,就立即告知主進程:我好了額,你可以處理我的結果了。主進程則調用一個函數去處理該結果,該函數即回調函數。我們可以把耗時間(阻塞)的任務放到進程池中,然後指定回調函數(主進程負責執行),這樣主進程在執行回調函數時就省去了I/O的過程,直接拿到的是任務的結果。
apply_async(self, func, args=(), kwds={}, callback=None) # func的結果會交給指定的callback函數處理
一個爬蟲的小例子:
from multiprocessing import Pool import requests import os def geturl(url): print(‘我的進程號為: %s‘ % os.getpid()) print(‘我處理的url為: %s ‘ % url ) response = requests.get(url) # 請求網頁 return response.text # 返回網頁源碼 def urlparser(htmlcode): print(‘我的進程號是: %s ‘ % os.getpid()) datalength = len(htmlcode) # 計算源碼的長度 print(‘解析到的html大小為: %s‘ % datalength) if __name__ == ‘__main__‘: pool = Pool() url = [ ‘http://www.baidu.com‘, ‘http://www.sina.com‘, ‘http://www.qq.com‘, ‘http://www.163.com‘ ] res_l = [] for i in url: res = pool.apply_async(geturl,args=(i,),callback=urlparser) # res 是 geturl執行的結果,因為已經交給urlparser處理了,所以這裏不用拿 res_l.append(res) pool.close() pool.join() for res in res_l: print(res.get()) # 這裏拿到的就是網頁的源碼
進程間通訊
進程彼此之間互相隔離,要實現進程間通信(IPC),multiprocessing模塊提供的兩種形式:隊列和管道,這兩種方式都是使用消息傳遞的。但是還有一種基於共享數據的方式,現在已經不推薦使用,建議使用隊列的方式進行進程間通訊。
展望未來,基於消息傳遞的並發編程是大勢所趨,即便是使用線程,推薦做法也是將程序設計為大量獨立的線程集合,通過消息隊列交換數據。這樣極大地減少了對使用鎖定和其他同步手段的需求,還可以擴展到分布式系統中。
隊列
底層就是以管道和鎖定的方式實現。
創建隊列的類:
Queue([maxsize]):創建共享的進程隊列,Queue是多進程安全的隊列,可以使用Queue實現多進程之間的數據傳遞。 # 參數 maxsize: 隊列能承載的最大數量,省略的話則不限制隊列大小
基本使用:
from multiprocessing import Queue q = Queue(3) q.put(‘a‘) # 數據存入Queue print(q.get()) # 從Queue中取出數據
註意:隊列(Queue)是FIFO模式,既先進先出。
隊列的方法
q.put() 用於插入數據到隊列中。
q.put(obj, block=True, timeout=None) # 參數: # blocked,timeout:如果blocked為True(默認值),並且timeout為正值,該方法會阻塞timeout指定的時間,直到該隊列有剩余的空間。如果超時,會拋出Queue.Full異常。如果blocked為False,但該Queue已滿,會立即拋出Queue.Full異常。
PS:q.put_nowait() 等同於 q.put(block=False)
q.get() 用於從隊列中獲取數據。
q.get(block=True,timeout=None) # 參數: # blocked和timeout。如果blocked為True(默認值),並且timeout為正值,那麽在等待時間內沒有取到任何元素,會拋出Queue.Empty異常。如果blocked為False,有兩種情況存在,如果Queue有一個值可用,則立即返回該值,否則,如果隊列為空,則立即拋出Queue.Empty異常.
PS:q.get_nowait() 等同於 q.get(block=False)
其他的方法(不是特別準確,可以忘記)生產者消費者模型
在並發編程中使用生產者和消費者模式能夠解決絕大多數並發問題。該模式通過平衡生產線程和消費線程的工作能力來提高程序的整體處理數據的速度。
為什麽要使用生產者和消費者模式
在線程世界裏,生產者就是生產數據的線程,消費者就是消費數據的線程。在多線程開發當中,如果生產者處理速度很快,而消費者處理速度很慢,那麽生產者就必須等待消費者處理完,才能繼續生產數據。同樣的道理,如果消費者的處理能力大於生產者,那麽消費者就必須等待生產者。為了解決這個問題於是引入了生產者和消費者模式。
什麽是生產者消費者模式
生產者消費者模式是通過一個容器來解決生產者和消費者的強耦合問題。生產者和消費者彼此之間不直接通訊,而通過阻塞隊列來進行通訊,所以生產者生產完數據之後不用等待消費者處理,直接扔給阻塞隊列,消費者不找生產者要數據,而是直接從阻塞隊列裏取,阻塞隊列就相當於一個緩沖區,平衡了生產者和消費者的處理能力。
基於隊列實現生產者消費者模型:
- 生產者只負責生產蛋糕,生產完畢的蛋糕放在隊列中
- 消費者只負責消費蛋糕,每次從隊列中拿取蛋糕
上面的例子很完美,但是生產者生產完畢,消費者也消費完畢了,那麽我們的主程序就應該退出了,可是並沒有,因為消費者還在等待從隊列中獲取(q.get),這裏我們考慮可以發送一個做完/吃完的信號,抓取到信號後退出即可。
- 在隊列中放固定的值來做信號
- 利用JoinableQueue對象 + daemon屬性 來對消費者進程進行回收
其中:
- 利用JoinableQueue對象的join,task_done方法,完成確認/通知的目的。
- 如果生產者生產完畢,消費者必然也會給生產者確認消費完畢,那麽只要等待生產者執行完畢後進行就可以退出主進程了。
- 主進程退出但是消費者進程還未回收,那麽就可以設置消費者daemon屬性為true,跟隨主進程被回收即可。
共享數據
進程間數據是獨立的,可以借助於隊列或管道實現通信,二者都是基於消息傳遞的,雖然進程間數據獨立,但也可以通過Manager實現數據共享,事實上Manager的功能遠不止於此。
Manager() # 沒有參數 # 使用Manager對象創建共享數據類型
利用Manager創建數據,完成進程共享
import os from multiprocessing import Manager,Process def worker(d,l): d[os.getpid()]=os.getpid() # 對共享數據進行修改 l.append(os.getpid()) if __name__ == ‘__main__‘: m = Manager() d = m.dict() # 創建共享字典 l = m.list() # 創建共享列表 p_l = [] for i in range(10): p= Process(target=worker,args=(d,l)) p_l.append(p) p.start() for p in p_l: p.join() print(d) print(l)
Threading模塊
Python 標準庫提供了 thread 和 threading 兩個模塊來對多線程進行支持。其中, thread 模塊以低級、原始的方式來處理和控制線程,而 threading 模塊通過對 thread 進行二次封裝,提供了更方便的 api 來處理線程。
PS:multiprocessing完全模仿了threading模塊的接口,二者在使用層面,有很大的相似性,所以很多用法都是相同的,所以可能看起來會比較眼熟。
Thread類和使用
Thread 是threading模塊中最重要的類之一,可以使用它來創建線程。
有兩種方式來創建線程:
- 通過繼承Thread類,重寫它的run方法;
- 創建一個threading.Thread對象,在它的初始化函數(__init__)中將可調用對象作為參數傳入;
# -----------------------實例化對象-------------------------- import threading def work(name): print(‘hello,{0}‘.format(name)) if __name__ == ‘__main__‘: t = threading.Thread(target=work,args=(‘daxin‘,)) t.start() print(‘主進程‘) # -----------------------自己創建類-------------------------- import threading class Work(threading.Thread): def __init__(self,name): super(Work, self).__init__() self.name = name def run(self): print(‘hello,{0}‘.format(self.name)) if __name__ == ‘__main__‘: t = Work(name=‘daxin‘) t.start() print(‘主進程‘)
PS:執行的時候,我們可以看到會先打印"hello,daxin",然後才會打印"主進程",所以這也同時說明了,創建線程比創建進程消耗資源少的多,線程會被很快的創建出來並執行。如果我們在target執行的函數和主函數中,同時打印os.getpid,你會發現,進程號是相同的,這也說明了這裏開啟的是自線程。
Python學習筆記 - day13 - 進程與線程