與Python多核多執行緒併發,混編有關的一次歷程

ACool發表於2018-06-21

原文地址: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佔用率,如下圖,原來是程式全部擠在一個核上面,導致卡頓。

6tpgz.png

為什麼整個程式全部擠在一個核上面執行?一查資料,發現是因為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佔用情況:

6tM5Q.png

發現程式超聲波部分執行在兩個核上面了,父程式執行在另外一個核上面,所以機器人運動起來不卡了!但是,兩個核都跑滿了,雖然解決了延遲問題,但是佔用率更糟糕了。出現這樣的問題,是不是程式太重了?網上找了一圈,很多人都說超聲波死迴圈確實會把核佔滿。

那麼,沒有解決方法了嗎?這時候我想到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\n",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\n",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是使用被呼叫者清除,被呼叫的函式在返回前清理傳送引數的棧,函式引數個數固定。下圖可以很好的體現出來:

8KPEO.png

這裡採用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 的地址進去,其他的類似。

然後我們執行一下看看結果怎麼樣:

6t4ah.png

Nice!我們可以看出來,結果超出了預料,效果比想象中的要好很多!不僅實現了多核多執行緒,而且CPU佔用率降低了很多。雙核佔用率基本上在2%~30%之間波動,比之前的好太多。

結語

這次的經歷收穫頗豐,想不到簡單的一個超聲波避障,會遇到這麼多問題。其實文章寫到這裡意猶未盡,還有很多方面沒有探討過,比如GIL對多執行緒的效率造成影響的實驗,為什麼會出現 multiprocessing 和C語言作為執行緒函式之間這麼大的佔用率差距,等等。另外還有一個 Tensorflow 在樹莓派這種效能低下的機器上面的優化問題,我也一直想寫,遲遲未能動筆。先就這樣吧,文章還有很多不足,如果發現有疏漏的地方,請各位讀者不吝賜教。

與君共勉。

相關文章