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

原文地址:blogof33.com/post/8/html

前言

最近作項目,Raspberry Pi (ARM Cortex-A53 四核,1GB內存)上面連了兩個超聲波,須要一直測距。樹莓派做爲機器人主控控制機器人運動,此外樹莓派上面還同時運行了攝像頭和web服務器(用來傳輸視頻流),而且後臺常駐有Tensorflow圖像識別模型,按照要求隔一段時間會進行圖像識別。因爲樹莓派的配置不高,每次運行Tensorflow圖像識別四個核都會跑滿,因此爲了使得超聲波不影響Tensorflow的識別而且防止CPU長期佔用率太高燒壞拓展板,須要下降超聲波CPU佔用率。python

Python GIL

由於有兩個超聲波同時測距,由於控制程序用python寫的,因此咱們最開始想到的是使用 Python thread 庫中的 start_new_thread 方法建立子進程:web

thread.start_new_thread(checkdist,(GPIO_R1,var1,))
thread.start_new_thread(checkdist,(GPIO_R2,var2,))
複製代碼

checkdist爲超聲波程序,具體可見以前的文章-》樹莓派3B 超聲波測距傳感器Python GPIO/WPI/BCM三種方式bash

而後程序運行的時候機器人運動起來很卡,發出的命令有延遲。一看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暴露出它先天的不足,不只僅使程序不能多核多線程並行運行,而且在多核上面運行會對多線程的效率形成影響。post

那麼,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 在樹莓派這種性能低下的機器上面的優化問題,我也一直想寫,遲遲未能動筆。先就這樣吧,文章還有不少不足,若是發現有疏漏的地方,請各位讀者不吝賜教。

與君共勉。

相關文章
相關標籤/搜索