線程CPU使用率到底該如何計算?

來源:公衆號【魚鷹談單片機】

作者:魚鷹Osprey

ID   :emOsprey

這篇筆記有如下內容:

1、爲什麼需要計算各個線程的CPU使用率?

2、該如何計算線程CPU使用率?

3、FreeRTOS線程計算的弊端?如何打破 FreeRTOS 線程計算方式的時間限制?

4、關鍵代碼介紹。

上次介紹瞭如何計算整個系統的CPU使用率:

單片機裏面的CPU使用率是什麼鬼?

實操RT-Thread系統CPU利用率功能添加

但是卻沒有介紹該如何計算每個線程(任務)的CPU使用率。

爲什麼要計算線程CPU使用率?

首先要問的是,爲什麼要計算線程的CPU使用率,有啥用?

我們知道系統的CPU使用率關注的是整個系統的使用情況,使用率越低,表示越能更及時的響應外部情況,整個系統的性能也會越好。

但這是從系統整體考量的,並不能反映單個線程的執行情況。

比如雖然整體的CPU使用率是30%,但是有一個線程佔據了25%的使用率,一個線程使用率是5%,那麼你肯定會想,爲啥這個線程需要佔用這麼高的CPU使用率,是不是代碼寫的有問題,是不是代碼可以優化一下?

當系統運行時,如果你能實時觀察各個線程的CPU使用率,那麼你就能知道平時這個線程的CPU使用情況是怎樣的,爲什麼後來又高那麼多,那麼你就可以由此分析出這個線程可能出現了問題,也就可以針對性的進行檢查了。

這點對於合作開發的項目更是明顯,很多時候因爲有些線程的代碼不是自己寫的,所以根本不知道代碼執行情況,一旦系統出現問題,那麼可能就是互相甩鍋了。

而當計算了線程的CPU使用率,一旦發現某個線程執行異常,那麼就能交給負責的人去查看了。

所以說,使用操作系統的項目是非常有必要計算各個線程(任務)的CPU使用率的。

就好比你的電腦,風扇嗡嗡響(CPU高負荷運行),如果只有一個系統CPU使用率,發現高達90%,但是你卻根本不知道爲什麼這麼高,所以只能重啓。

而一旦有了進程CPU使用率,查看一下哪個進程CPU使用率高,把對應的進程關閉就行了,根本不需要重啓電腦。

        

如何計算線程CPU使用率?

那麼現在就來看看該如何計算各個線程的CPU使用率。

從前面的筆記,我們其實也可以猜測該如何計算,無非就是獲取每個線程的執行時間罷了。

比如,1秒時間內,空閒任務執行700毫秒,任務1執行200毫秒,任務2執行100毫秒,那麼各個任務的CPU使用率分別是 70%、20%、10%。

以前計算系統的CPU使用率的時候,採用了軟件方法計算空閒任務的運行時間,這必然是不夠準確的,所以最好的方式是採用硬件計時。

因爲魚鷹採用STM32F103進行測試,所以使用DWT外設進行精確計時,不過麻煩的是,在KEIL 軟件仿真情況下,DWT外設是無法工作的,所以如果要測試的話,必須使用硬件仿真的方式,不過如果真要KEIL軟件仿真的話,也不是沒有辦法,就是使用硬件定時器,這個按下不表。

畢竟,DWT外設的功能在這裏說白了也就是個定時器而已。

既然要獲取線程的執行時間,關鍵一點就是,我們要知道操作系統什麼時候會切換到某一個線程運行,什麼時候又會從這個線程切出,到另一個線程執行呢?

這個關鍵還是在系統內置的鉤子函數。上次的筆記魚鷹介紹過空閒鉤子函數,今天介紹另一個鉤子,任務切換鉤子函數。

這個鉤子函數的特點就是,每當系統需要切換到下一個任務時,就會先執行這個函數。這個函數一般有兩個參數,當前任務即將切換的任務

只要設置任務切換的鉤子函數,並且有時間戳,那麼計算一個任務的執行時間也就不那麼困難了。

比如,操作系統在時刻12345 ms 切換到空閒任務執行,突然一個任務就緒,開始準備執行,所以在時刻12445切換到那個就緒任務執行,那麼空閒任務的執行時間我們也就可以準確計算出來了。

12445 – 12345 = 100 ms

也就是說,這一次空閒任務執行了 100 毫秒。

如果我們要計算單位時間(比如1秒內)空閒任務的執行時間,我們只要在每次運行到空閒任務時累計時間即可。

比如1秒內,空閒任務執行了 5 次,分別是 10、200、100、200、50,累計時間爲

10 + 200 + 100 + 200 + 50 = 560毫秒

由此,可計算空閒任務的CPU使用率爲 56%,從而可計算出系統的CPU使用率是44%。

是的,通過線程的CPU使用率方法,我們其實也可以計算整個系統的CPU使用率。而且這種計算方式比前面所說的計算方法更準確,更科學。

前面採用時間戳進行計算,但是時間戳是會溢出的,那個時候,你的時間計算還是準確的嗎?

FreeRTOS線程計算限制?

現在魚鷹就來說說第三個問題,FreeRTOS線程計算的弊端?如何打破 FreeRTOS 線程計算方式的時間限制?

從網上查找FreeRTOS任務CPU計算相關的資料,可以得到以下信息:

1、需要開一個定時器,這個定時器中斷頻率是操作系統時鐘的十幾倍(爲了保證計算精度)。

2、一個64 位的變量在定時器自加更新,一旦變量溢出,時間計算就會出現問題。

(相關細節可查看安富萊教程)

第一個問題會導致系統性能下降(中斷頻率太高,一般是微秒級別的),而第二個問題導致在一段時間內(小時級別)線程CPU使用率計算準確,超出時間後,計算會有問題,所以教程中不建議在正式版本加入此功能。

第一個問題其實很好解決,就是使用硬件定時器,不再由CPU去更新時間,這樣不會佔用CPU時間,第二個問題其實也非常好解決,就是通過《延時功能進化論(合集)》的方式解決溢出問題,這裏不再展開說其中的奧妙。

任務切換鉤子函數的實現

總之,魚鷹接下來的實現方式解決了以上兩個痛點,即使無限執行下去,也不會影響到計算精度問題,唯一對系統產生的一點影響,只有在任務切換時消耗的一點計算時間(微秒級別)。

那麼先上任務切換鉤子函數關鍵實現代碼(RT-Thread):

void thread_stats_scheduler_hook(struct rt_thread *from, struct rt_thread *to)
{
    static uint32_t schedule_last_time;
    
    uint32_t time;
    
    time = get_curr_time();
    
    from->user_data   += (time - schedule_last_time);
    schedule_last_time = time;
}

如何將這個函數註冊到操作系統中被系統調用呢?

通過這個函數即可:

那麼現在來分析這個鉤子函數實現:

一個靜態變量,用於記錄切換時的時間戳。

每次任務開始切換時,更新這個時間戳,同時累積時間,這個時間保存在當前任務的user_data裏面。

難理解?看下圖就清楚了。

假設系統調度是從任務1切換到任務2,即from爲任務1,to爲任務2,此時獲取的時間戳爲 T1

上一次的時間戳我們已經通過靜態變量保留了,這裏爲T0,那麼T1-T0就是from任務即任務1在本次運行的時間,只要下次運行任務1時繼續不斷的累積這個時間,那麼就可以得到任務1的總運行時間。

任務2同理。

當然我們不可能一直累積下去,不然肯定會溢出,所以隔一段時間就需要清零,這個時間其實就是線程CPU計算的週期

    這裏還有一個函數沒有說,就是 get_curr_time(),在這裏使用DWT,爲了可以重新實現該函數,魚鷹使用了弱屬性 weak(關於這個看參考:《困惑多年,爲什麼 printf 可以重定向? 》)。

__weak
uint32_t get_curr_time() 
{
    return DWT->CYCCNT; // don't use the function rt_tick_get()
}

這裏可以看到有個註釋,不要使用 rt_tick_get 函數,爲啥?

精度太低,有些任務本來執行了的,但是因爲執行時間小於操作系統的時鐘(比如1毫秒),那麼就無法累積時間了,那麼即使這個任務運行再多,時間累積也爲 0,這肯定是我們不希望看到的。

然後再說一個點,爲了簡化代碼(鉤子函數代碼只有短短几行),魚鷹這樣的實現是有兩個問題的。

1、首次運行計算有誤,因爲靜態變量應該在運行任務之前就初始化的(不應該初始化爲 0),而鉤子函數是在任務運行之後才調用的,所以從開機以來的時間被累加到第一個運行任務中了,這肯定是有問題的,不過後面隨着系統的運行,靜態變量被持續更新,就不會再出現這個問題了。

2、爲了減少修改,魚鷹把線程的use_data當成一個變量使用了,實際上這個變量的功能應該是存儲線程私有變量地址的,但是因爲魚鷹懶得修改太多代碼,所以直接拿來用了。正因爲如此,所以魚鷹添加線程CPU計算時,只要修改很少的代碼就可以了。

線程CPU計算

目前我們已經能夠通過鉤子函數獲取各個線程的CPU執行時間,現在就看該如何計算了。

爲了計算各個線程的CPU使用率,我們需要確定計算週期,這裏我們可以設置1秒計算一次。

其次,我們需要確定在哪個任務執行計算。

原理上來說,可以是系統中的任何一個任務,但是爲了減少對系統的干擾,可以將計算工作放到優先級比較低的任務中進行,比如空閒任務。

現在,看看函數是如何實現的:

// can call the function 1 s (max 60s when stm32f1xx because of dwt)
void thread_cal_usage(thread_run_info_def *run_info)
{
    static uint32_t total_time_last; 
    
    uint32_t time, total_time;
    struct rt_list_node *node;
    struct rt_list_node *list;
    struct rt_thread *thread;
    uint32_t i;
 
    rt_enter_critical(); // 關閉系統調度,防止在計算過程中更新線程時間,影響計算
  
  time = get_curr_time(); // 獲取當前時間戳
    total_time             = time - total_time_last;   // 計算運行總時間
    total_time_last = time; // 更新時間
    
    list = &(rt_object_get_information(RT_Object_Class_Thread)->object_list); // 獲取線程列表指針
  // 搜索類別
    for(i = 0, node = list->next; (node != list) && i < THREAD_NBR_MAX; node = node->next, i++){
        thread            = rt_list_entry(node, struct rt_thread, list); // 獲取線程地址
        run_info[i].name  = thread->name;     // 保存線程名
        run_info[i].time  = thread->user_data;  // 保存線程執行時間
        thread->user_data = 0;          // 清除線程執行時間
    }
        
    rt_exit_critical();// 開啓系統調度
    
  // 計算各個線程的 CPU 使用率
    total_time /= 100; 
    if(total_time > 0){
        for(uint32_t j = i, i = 0; i < j; i++)
        {
            run_info[i].usage = run_info[i].time / total_time;
        }
    }
}


註釋已經很詳盡了,所以不多做討論。主要說以下幾點:

1、爲什麼需要關閉調度器,可以使用關中斷嗎?

關調度器是爲了防止在獲取各個線程執行時間時,因爲系統調度而導致執行時間被更新,從而導致計算有誤,所以需要關閉調度器。

那麼爲什麼不使用關中斷的方式呢?沒有必要。一旦關中斷,那麼中斷就無法響應了,所以在可以關調度器的情況下滿足要求,就不應該關中斷。

2、爲什麼分兩步計算,爲什麼不將最終的計算放在第一個循環中執行呢?

節省時間,爲了儘量減少關調度器的時間,能省一點是一點。畢竟只要能獲取到關鍵信息,啥時候計算都一樣。

3、因爲線程CPU計算週期是自動計算的,所以,計算週期其實就是該函數的調用週期,即2秒調用一次,那麼線程CPU計算週期就是2秒,但是需要注意的是,調用週期必須小於定時器的溢出時間,即當你使用 DWT 時,調用週期應該在 60 秒以下(72 M 系統時鐘),否則計算是有問題的。

現在我們已經算是完成了線程CPU計算問題,但爲了使用方便,我們需要把它打印出來,或者把這些信息字符串化:

void thread_stats_print(void)
{
    thread_run_info_def run_info[THREAD_NBR_MAX] = {0};
    thread_run_info_def *p_info;


    thread_cal_usage(run_info);
    
    rt_kprintf("thread\t\t\ttime\t usage\n");
    for(uint32_t i = 0; i < THREAD_NBR_MAX; i++)
    {
        p_info = &run_info[i]; 
        if(p_info->name != NULL)
        {
            if(p_info->usage > 0)  // CPU 使用率大於 1 %
            {
                    rt_kprintf("%-16s\t%u\t%2u%%\n",  p_info->name, 
                                                     (uint32_t)p_info->time, 
                                                     (uint32_t)p_info->usage);
            }
            else  
            {
                    rt_kprintf("%-16s\t%u\t<1%%\n", p_info->name, 
                                                   (uint32_t)p_info->time,  
                                                   (uint32_t)p_info->usage);
            }
        }
        else
        {
            break;
        }
    }
}

這裏將線程名、線程執行時間、線程使用率都打印出來了,但是需要注意的是,這裏的time 時間單位是定時器的單位,而不是微秒、毫秒,比如如果使用 DWT,那麼單位就是 1/72 微秒,即如果 time 值爲 1000,那麼換算到微秒,應該是 1000/72 秒,當然了,你也可以在打印的同時就把時間換算一下,這個自由發揮就好。

最後,魚鷹將代碼提交到了 RT-Thread 官方工程裏面,這是魚鷹第一次使用 Git 提交開源項目(操作不熟練,還是上網搜的教程),也不知道最後合併了沒有。

如果對完整代碼感興趣的,也可以在後臺回覆關鍵字領取。

推薦閱讀:

終極串口接收方式,極致效率

爲什麼說你一定要掌握 KEIL 調試方法?

延時功能進化論(合集)

指針,很難嗎?| 解析指針的過程與意義(一)

如何寫一個健壯且高效的串口接收程序?

KIEL 調試那些事兒之窗口展示——變量(二)

打了多年的單片機調試斷點到底應該怎麼設置?| 顛覆認知

-THE END-


如果對你有幫助,記得轉發分享哦

微信公衆號「魚鷹談單片機

每週一更單片機知識

長按後前往圖中包含的公衆號關注

魚鷹,一個被嵌入式耽誤的暢銷書作家

個人微信「EmbeddedOsprey

長按後打開對方的名片關注

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章