原文地址:blogof33.com/post/8/
前言
最近做專案,Raspberry Pi (ARM Cortex-A53 四核,1GB記憶體)上面連了兩個超聲波,需要一直測距。樹莓派作為機器人主控控制機器人運動,此外樹莓派上面還同時執行了攝像頭和web伺服器(用來傳輸視訊流),並且後臺常駐有Tensorflow影像識別模型,按照要求隔一段時間會進行影像識別。由於樹莓派的配置不高,每次執行Tensorflow影像識別四個核都會跑滿,所以為了使得超聲波不影響Tensorflow的識別並且防止CPU長期佔用率過高燒壞擴充板,需要降低超聲波CPU佔用率。
Python GIL
因為有兩個超聲波同時測距,因為控制程式用python寫的,所以我們最開始想到的是使用 Python thread 庫中的 start_new_thread 方法建立子程式:
thread.start_new_thread(checkdist,(GPIO_R1,var1,))
thread.start_new_thread(checkdist,(GPIO_R2,var2,))
複製程式碼
checkdist為超聲波程式,具體可見之前的文章-》樹莓派3B 超聲波測距感測器Python GPIO/WPI/BCM三種方式 。
然後程式執行的時候機器人運動起來很卡,發出的命令有延遲。一看CPU佔用率,如下圖,原來是程式全部擠在一個核上面,導致卡頓。
為什麼整個程式全部擠在一個核上面執行?一查資料,發現是因為CPython直譯器存在GIL。
GIL是什麼
首先需要明確的一點是 **GIL(Global Interpreter Lock,全域性直譯器鎖)**並不是Python的特性,它是在實現Python直譯器(CPython,使用C語言實現)時所引入的一個概念。直譯器有很多種,同樣一段程式碼可以通過CPython,PyPy,JPython等不同的Python執行環境來執行。像其中的JPython就沒有GIL。然而因為CPython是大部分環境下預設的Python執行環境。所以在很多人的概念裡CPython就是Python,也就想當然的把GIL
歸結為Python語言的缺陷。所以這裡要先明確一點:GIL並不是Python的特性,Python完全可以不依賴於GIL。
CPython直譯器中所有C程式碼在執行Python時必須保持這個鎖。Python之父Guido van Rossum當初加這個鎖是因為那個年代多核還不常見,並且這個鎖使用起來足夠簡單,保證了同一時刻只有一個執行緒對共享資源進行存取(這裡提一句,執行緒切換時,CPython可以進行協同式多工處理或者搶佔式多工處理,具體說明見 這篇文章),但是這樣也使得python無法實現多核多執行緒,隨著多核時代的來臨,GIL暴露出它先天的不足,不僅僅使程式不能多核多執行緒並行執行,並且在多核上面執行會對多執行緒的效率造成影響。
那麼,python發展到今天,為什麼不能移除GIL,用更好的實現代替呢?
曾經有人做過實驗,移除了GIL用更小粒度的鎖,發現在單執行緒上面效能降低非常嚴重,多執行緒程式也只有線上程數達到一定數量時才會有效能上的改進。所以移除GIL換更小粒度的鎖目前看來還是不值得的,Python社群目前最為重點的研究之一就是GIL,移除GIL任重道遠。
那麼,我們如何繞開GIL的限制呢?我進行了幾種嘗試。
繞開GIL
multiprocessing
要實現程式同時執行在多核上面,如果僅僅使用python,一般來說都是使用 multiprocessing 編寫多程式程式:
from multiprocessing import Process
p1=Process(target=checkdist,args=(GPIO_R1,var1,))
P2=Process(target=checkdist,args=(GPIO_R2,var2,))
P1.start()
P2.start()
P1.join()
P2.join()
複製程式碼
CPU佔用情況:
發現程式超聲波部分執行在兩個核上面了,父程式執行在另外一個核上面,所以機器人運動起來不卡了!但是,兩個核都跑滿了,雖然解決了延遲問題,但是佔用率更糟糕了。出現這樣的問題,是不是程式太重了?網上找了一圈,很多人都說超聲波死迴圈確實會把核佔滿。
那麼,沒有解決方法了嗎?這時候我想到C語言,能不能用C語言繞過GIL的限制呢?
與C語言共舞
“Python 有時候是一把瑞士軍刀”。官方的直譯器CPython是用C語言實現的,所以Python具有與C/C++整合的能力。如果用C語言來執行超聲波距離檢測,用python來呼叫,能否實現多核多執行緒?
python有一個 ctypes 庫,能夠實現python呼叫C語言函式的要求。閱讀了官方文件以後,我們首先編寫C語言的函式,如下所示,關於超聲波函式的具體說明請見上一篇文章 樹莓派3B 超聲波測距感測器Python GPIO/WPI/BCM三種方式。
//checkdist.c檔案
//這裡我的樹莓派擴充板只能使用bcm編碼方式
#include<stdio.h>
#include <bcm2835.h>
#include<termio.h>
#include<sys/time.h>
#include<stdlib.h>
void checkdist(int GPIO_R,int *var,int *signal,int GPIO_S,void(*t_stop)(int)){
struct timeval tv1;
struct timeval tv2;
long start, stop;
double dis;
if(!bcm2835_init()){
printf("setup bcm2835 failed !");
return;
}
bcm2835_gpio_fsel(GPIO_S, BCM2835_GPIO_FSEL_OUTP);
bcm2835_gpio_fsel(GPIO_R, BCM2835_GPIO_FSEL_INPT);
while(1){
if(*signal==1)
continue;
else
*signal=1;
printf("GPIO:%d
",GPIO_R);
bcm2835_gpio_write(GPIO_S,HIGH);
bcm2835_delayMicroseconds(15);//延時15us
bcm2835_gpio_write(GPIO_S,LOW);
while(!bcm2835_gpio_lev(GPIO_R));
gettimeofday(&tv1, NULL);
while(bcm2835_gpio_lev(GPIO_R));
gettimeofday(&tv2, NULL);
start = tv1.tv_sec * 1000000 + tv1.tv_usec; //微秒級的時間
stop = tv2.tv_sec * 1000000 + tv2.tv_usec;
dis = ((double)(stop - start) * 34000 / 2)/1000000; //求出距離
if(dis<5){
*var=1;
t_stop(1);
}
else
*var=0;
printf("dist:%lfcm
",dis);
*signal=0;
bcm2835_delay(100);//延時15ms
}
}
複製程式碼
然後使用以下命令生成目標檔案:
gcc checkdist.c -shared -fPIC -o libcheck.so
這裡說明一下,選項 -shared -fPIC
意思是生成與位置無關的程式碼,則產生的程式碼中,沒有絕對地址,全部使用相對地址,故而程式碼可以被載入器載入到記憶體的任意位置,都可以正確的執行。這正是共享庫所要求的,共享庫被載入時,在記憶體的位置不是固定的。防止變數之類地址錯誤。
然後使用以下的python程式碼實現C語言執行緒函式:
#checkdist函式原型為:
#void checkdist(int GPIO_R,int *var,int *signal,int GPIO_S,void(*t_stop)(int));
#t_stop為python函式,函式原型為:def t_stop(t_time)
lib=cdll.LoadLibrary("/home/pi/Raspbarry_Tensorflow_Car/Servo/MotorHAT/libcheck.so")#載入DLL
stop_func=CFUNCTYPE(None,c_int)
#CFUNCTYPE的第一個引數是函式的返回值,void則為NULL,函式的其他引數緊隨其後
func=stop_func(t_stop)#回撥函式,func為函式指標型別,指向python中的t_stop函式
signal=c_int(0)#c語言中的int型別
var1=c_int(0)
var2=c_int(0)
#建立兩個子執行緒,執行緒函式為C語言函式checkdist
thread.start_new_thread(lib.checkdist,(GPIO_R1,byref(var1),byref(signal),GPIO_S,func,))
thread.start_new_thread(lib.checkdist,(GPIO_R2,byref(var2),byref(signal),GPIO_S,func,))
複製程式碼
下面來說明一下上面程式碼的一些細節。
lib=cdll.LoadLibrary("/home/pi/Raspbarry_Tensorflow_Car/Servo/MotorHAT/libcheck.so")
這一行程式碼載入C語言目標檔案 libcheck.so 。
ctypes 庫提供了三個容易載入動態連線庫的物件:cdll、windll和oledll。通過訪問這三個物件的屬性,就可以呼叫動態連線庫的函式了。其中cdll主要用來載入C語言呼叫方式(cdecl),windll主要用來載入WIN32呼叫方式(stdcall),而oledll使用WIN32呼叫方式(stdcall)且返回值是Windows裡返回的HRESULT值。在C語言裡面,引數是採用入棧的方式來傳遞,順序是從右往左,cdll和後面兩種的區別在於清除棧時,cdll是使用呼叫者清除棧的方式,所以實現可變引數的函式只能使用該呼叫約定;而windll和oledll是使用被呼叫者清除,被呼叫的函式在返回前清理傳送引數的棧,函式引數個數固定。下圖可以很好的體現出來:
這裡採用cdll的方式,防止不必要的錯誤。
然後是呼叫回撥函式:
stop_func=CFUNCTYPE(None,c_int)
#CFUNCTYPE的第一個引數是函式的返回值,void則為NULL,函式的其他引數緊隨其後
func=stop_func(t_stop)#回撥函式,func為函式指標型別,指向python中的t_stop函式
複製程式碼
這兩行的目的就是將python中的 t_stop
函式傳進後面的C函式,使得C語言函式裡面能夠呼叫 t_stop
函式。
然後後面一行的 signal=c_int(0)
相當於C語言中的 int singel=0;
後面幾行同理。
最後便是:
#建立兩個子執行緒,執行緒函式為C語言函式checkdist
thread.start_new_thread(lib.checkdist,(GPIO_R1,byref(var1),byref(signal),GPIO_S,func,))
thread.start_new_thread(lib.checkdist,(GPIO_R2,byref(var2),byref(signal),GPIO_S,func,))
複製程式碼
建立執行緒,引數的函式是C語言函式 checkdist,byref(var1)
相當於 &var1
,傳 var1 的地址進去,其他的類似。
然後我們執行一下看看結果怎麼樣:
Nice!我們可以看出來,結果超出了預料,效果比想象中的要好很多!不僅實現了多核多執行緒,而且CPU佔用率降低了很多。雙核佔用率基本上在2%~30%之間波動,比之前的好太多。
結語
這次的經歷收穫頗豐,想不到簡單的一個超聲波避障,會遇到這麼多問題。其實文章寫到這裡意猶未盡,還有很多方面沒有探討過,比如GIL對多執行緒的效率造成影響的實驗,為什麼會出現 multiprocessing 和C語言作為執行緒函式之間這麼大的佔用率差距,等等。另外還有一個 Tensorflow 在樹莓派這種效能低下的機器上面的優化問題,我也一直想寫,遲遲未能動筆。先就這樣吧,文章還有很多不足,如果發現有疏漏的地方,請各位讀者不吝賜教。
與君共勉。