char buf[100];
Receive(buf, 100);
執行緒同步 10:輪詢
在日常程式設計中,許多的程式呼叫都會暫停並等待,但如果我們想要更多的使用彈性, 例如可以中途做些別的事情、或者想要中途可以取消不等了、或者想要顯示工作的進度等等的話, 我們還可以怎麼辦呢? 那麼本次就來介紹一個非常簡單也非常常見的設計方法:輪詢。
許多日常我們常用的程式呼叫都會需要卡住等待一會兒, 直到我們呼叫的工作確實完成了才會返回呼叫處並繼續往下執行, 比如說讀取檔案、或者寫入檔案的相關呼叫等通常就屬於這個範圍。 一般通常情況下我們執行這些呼叫所消耗的時間並不會有感的太久, 以至於通常我們並不會特別注意到這事,通常就直接等待工作結束再繼續往下走就好了。 但是有的時候這個工作消耗的時間有點多,以至於我們可能很難直接忽略它的存在。 比方說如果你的程式經常是被用來處理那些動輒好幾個 GB 的影片檔案, 即便不做任何運算處理,即便只是單純的把檔案內容讀出來然後複製到另一個檔案去, 可能都得要花上以分鐘來計算的時間,我想你一定不會願意程式要一直卡在那裡不動! 不過像檔案讀寫這樣的操作其實困擾還不是最大的,也就是會多花上一些時間,總是還會完成並返回; 但是有些其它的操作可能就不是這麼好應付了。 比方說從網路收取資料的呼叫預設也是會卡住並等待收足了資料才返回, 可是實際上很有可能你永遠也收不到資料! 比方說應該要傳送資料的遠端電腦上的那個程式當掉了,或者那臺電腦當機了, 或者停電斷訊了,或者你這邊的網路線被人不小心給踢掉了,太多正常且合理的原因能導致你收不到資料。 於是你的這個想要接收網路資料的呼叫就很有機會能夠一直被卡住到天荒地老!
除了上面舉例的兩種呼叫之外,現實上還有許多其它的呼叫都有可能會發生類似的事情。 你無法確定一個呼叫究竟需要花費多少時間來完成,甚至可能無法確定它是不是有可能永遠不能被完成。 當然我們可以建立另外一條執行緒來做這個等待的工作,然後讓原本的執行緒可以繼續去做其它的事情, 而這也是本系列第一篇就介紹過的多緒設計的常見用途之一。 但是如果僅僅只是多緒的使用其實並不足以良好的解決上面提到的那些問題, 畢竟改成換另外一條執行緒卡住到天荒地老不能結束、不能銷毀,顯然也不是一個應當正常的程式設計。 此外在實際的程式設計中可能並不僅僅只滿足於讓其它的執行緒去等待, 然後讓原本的執行緒可以繼續響應其它的工作而已。 比方我們可能會想要知道那個被岔出去獨立執行緒執行的工作現在進展到什麼程度了(也就是俗稱進度條), 或者使用者可能做一做就改變心態,想要終止並放棄掉這個工作等等。 這些需求並不是簡單的把工作丟到另外一條執行緒去背景執行就完事了, 因為我們還想要更多的操縱能力,例如可以查詢進度,例如可以「正常的」中斷和結束進行中的工作等等。
非阻塞模式
對於上面描述的這些問題,一個簡單的解決手段就是使用非阻塞式的模式來呼叫這些功能。
如果詳細查閱使用說明文件,可能會發現那些一般需要消耗一定時間來進行的功能,
大多都存在一種稱為 Non-Blocking Mode 的模式可以切換使用,
例如最常見的讀寫檔案、與網路收送等。
比方 POSIX 相容作業系統在使用 open() 開檔時可以加上 O_NONBLOCK 旗標來將檔案切換為非阻塞模式,
或者在呼叫 socket() 建立網路通信端的時候也可以加上 SOCK_NONBLOCK 旗標來切換使用非阻塞式的通信端,
或者在檔案描述符開啟的狀態下可以呼叫 fcntl() 來切換阻塞或非阻塞模式等。
(可是 C/C++ 的標準函式庫似乎並不支援 non-blocking 模式。)
除了這些常見的系統呼叫可以切換是否阻塞的模式之外,
還有許多硬體設備和通訊相關的程式庫很可能都是非阻塞式的,特別是那些非大廠所開發的功能,
因為以製做軟體程式庫或驅動程式的角度來說,非阻塞式的功能是更加簡單且容易實現的!
在非阻塞式(non-blocking)模式之下,相關的功能呼叫不會進入等待,而是會立刻返回。
比方以網路接收資料為例,當我呼叫函式想要接收 100 個位元組(bytes)的資料,
那麼在非阻塞模式下呼叫 recv() 時,函式就不會去等待新的資料的接收,
而是會將先前已經收下來並存放在暫存緩衝區內的資料返回給我,然後就結束並返回。
因此這裡要注意這點,在非阻塞模式下,雖然我要求 100 bytes 的資料,
但實際取到的可能只有一部份(當然函式會告訴你總共給了你多少資料,詳請參閱個別函式說明文件),
或者可能暫時取不到任何資料(此時函式可能會告訴說給了你 0 bytes 的資料、或者傳回某種錯誤碼比如 EAGAIN 等,詳請參閱各函式之說明)。
不同於在預設的阻塞模式下一般都會等待直到所有你要求的資料都填寫完畢了才會返回,
當使用非阻塞模式時,你就會需要自己處理資料的分批接收和組裝處理等工作。
那麼就拿上面這個想要接收 100 bytes 資料的例子來說明, 在一般通常預設的阻塞式(blocking mode)模式下是這麼呼叫使用的:
而若是改用非阻塞式(non-blocking mode)模式來做同樣的事情, 典型的呼叫使用方式則會像是這樣:
char buf[100]
int remain = 100;
while( remain > 0 )
{
int rsize = Receive(buf + 100 - remain, remain);
remain -= rsize;
}
|
Tip
|
為了簡化不必要的複雜度,
上面的兩段程式碼示例呼叫的不是正規的網路接收函式,
而是一個虛構的 Receive() 函式,
並且完全忽略掉可能發生錯誤時的處理。
而這個虛構的 Receive() 函式會傳回實際填回給我們的資料大小,
並且在當下沒有任何資料可以填回給我們的時候會傳回零。
|
從這個範例可以明顯看到使用非阻塞式呼叫的方式稍微更加複雜一點, 因為這讓我們必須要自己負擔起組合拼接資料的工作, 然後不斷的多次嘗試接收片段資料並組合直到得到我們需要的結果。 雖然在處理上面相比於會直接幫我們做好這些事的阻塞式模式來說更加麻煩瑣碎些, 但好處就是這給我們提供了能夠製做出更加複雜功能的空間。 比方我們可以將上面的範例再稍加包裝一下, 就能變成一個可以讓呼叫者隨時查詢當前工作完成狀態,並且隨時可以終止這項工作的軟體模組:
class DummyReceiver
{
private:
char *buf;
int total;
int filled;
int remain;
// 為了簡化範例程式,這個執行緒類別也是虛構的!
Thread thrd;
// 用來給外部控制終止執行緒的旗標,
// 想必本系列的讀者對它應該已經很熟悉了!
bool go_term;
private:
void ThreadFunc()
{
// 這裡是真正執行下載工作的地方,
// 使用與前面範例程式碼同樣的非阻塞式工作模式。
while( !go_term && remain )
{
int rsize = Receive(buf + filled, remain);
filled += rsize;
remain -= rsize;
}
}
public:
void StartWork(char *buf, int size)
{
// 使用者呼叫這個函式來啟動在背景下載資料的工作
this->buf = buf;
this->total = size;
this->filled = 0;
this->remain = size;
go_term = false;
thrd.Create(ThreadFunc, this);
}
int StopWork()
{
// 使用者呼叫這個程式來結束工作。
// 如果工作還沒有完成,那麼函式就會中斷並結束工作。
go_term = true;
thrd.Join();
return filled;
}
void QueryStatus(int &total, int &filled)
{
// 使用者可以隨時呼叫這個函式,
// 得到當前已完成的資料大小、和總共的目標資料大小。
// 可以用來判斷工作是否完成,以及用來顯示完成的進度等資訊。
total = this->total;
filled = this->filled;
}
};
|
Tip
|
上面的範例程式並不符合嚴謹的 C++ 語法(為了簡化敘述版面的緣故), 因此讀者應將其看待為虛擬碼! |
輪詢
其實在前面介紹非阻塞式模式的時候,就已經先悄悄的導入了「輪詢」(Polling)模式。 什麼是輪詢呢?就是輪番的詢問。 像前面的範例通過使用一個迴圈來不斷查詢當前的狀態、收取已經完成的片段資料等,這其實就是輪詢。 我們不知道資料什麼時候會進來?沒關係,我們就不厭其煩的一直來問! 東西沒有就算了,我就空手回去就是了;若有的話就拿一點走,不完整也無所謂,反正有一點是一點。 這就是輪詢的核心精髓。
相比於其它那些更加「正規」的工作模式可能大多採用收到包裹送達的通知後才動身前來索取, 輪詢法就顯得笨拙得多! 輪詢就像是一個看不見的愣頭青, 他既不知道能從哪裡接到通知再來要資料比較省事,也看不見任何當前的局勢狀態, 於是只好不厭其煩的不斷過來問你「東西到了沒?」 你若回答:「沒有」,他甚至來不及等到你說下句話就已經掉頭回去了; 你若回答:「有,不過還沒齊全,你要不要稍等一下等會那誰又送來……」, 你話還沒說完,他卻只回答說:「無所謂,你現在有的這些就先給我,剩下的等我下次來再說!」 然後就看著他一會兒就來、一會兒就來,來來回回跑來好幾次,雖然可能大多時候都是空手而回。
不過上面的輪詢範例那樣的使用方式在實際上是比較少見的。 上面範例裡的輪詢所採用的是「忙碌等待」(Busy Waiting)模式,不斷不斷的不帶休息的詢問。 其運作原理特性與曾經介紹過的自旋鎖是一樣的, 優點是當資料抵達的時候可以在第一時間最快速的響應; 但缺點就是在當資料需要花費一定的時間才能夠抵達備便的時候, 它的無意義反覆循環就會消耗掉可觀的 CPU 運算量,並且排擠其它執行緒的執行, 而大部份的資料收送、檔案讀寫、與事件蒐集等應用場景大多屬於後者。 因此我們在使用輪詢法的時候,通常都會在迴圈裡面加上一個小段時間的等待, 如下面這樣:
char buf[100]
int remain = 100;
while( remain > 0 )
{
int rsize = Receive(buf + 100 - remain, remain);
remain -= rsize;
const int a_little_time = 100;
Sleep(a_little_time);
}
這樣簡單的加入一個小段時間的等待,就能將被佔用的 CPU 給解放出來。 如果你在自己執行這兩種程式碼的時候有順便開啟電腦的資源管理器觀察的話, 就會發現在前面 busy wait 模式下的輪詢運作時,CPU (單核)的使用率應該是滿的。 而加入小段睡眠等待後的執行,CPU 佔用率立刻就掉下來到幾乎像沒事一樣。 因為在通常一般的情況下,資料或事件都是需要一段合理的時間才能收到的, 如果現在詢問的結果是沒有東西的話,那麼大概率在短時間內的下次詢問也是不會收到東西的。 因此不如把執行緒休眠一下讓電腦不要這麼無意義的忙碌, 同時也是把 CPU 的執行資源讓給其它可能正在排隊等待排程的執行緒。
不過上面那樣的做法還不夠完美,因為每次的輪詢不論結果如何都會進行睡眠等待。 這時候如果剛好遇到一個事情密集發生的高峰期, 比如說現在每秒鐘會收到一個包裹而你卻在每次輪詢的時候都等待 10 秒鐘, 那麼可想而知你接收處理的速度會趕不上東西送來的速度,造成東西的不斷累積愈疊愈多, 輕則使後面抵達的資料需要等待更加漫長的時間才會被你接收並處理, 重則可能導致系統內的緩衝區塞滿而導致丟失資料等情形。 因此通常我在設計中使用輪詢法的時候,會在當下有接收到東西的情況下不進行睡眠, 因為或者可能剛好遇到資料湧入的高發期,或者可能是我一次沒能收完全部的資料走, 總之這時立刻接著進行下一次的資料處理,遇到還有資料在等我的機率是比較高的。 於是這時候我就不會進行休眠,而會一小段時間接續著不斷工作 (反正在東西瞬間大量湧入的時候本來也就應該忙起來), 只在本次的輪旋處理階段中沒有發生實質的資料讀寫時,才會進行上述的小段休眠。 經過這樣的處理修正後的輪詢法就能一定程度兼顧 CPU 的佔用、事件發生時的反應速度、 以及在事件密集發生的高峰期時的資料消化能力。 若將上面的輪詢範例經過這一段所描述的修正處理,則會改寫為如下:
char buf[100]
int remain = 100;
while( remain > 0 )
{
int rsize = Receive(buf + 100 - remain, remain);
remain -= rsize;
if(!rsize)
{
const int a_little_time = 100;
Sleep(a_little_time);
}
}
小睡時間長短
上面已經解釋完了輪詢法的運作邏輯與基本使用結構, 然而在實際應用中,可能真正讓人苦惱猶豫的東西在於前面一筆帶過的那個「小睡時間」的長短。
如同前面所述,這個休眠的時間是為了讓 CPU 不至於過度且無意義的忙碌, 並且釋放執行資源給其它執行緒運作的機會而存在。 但是這個時間應該設定多少合適呢? 時間設定的長了,那確實執行資源佔用率低了,CPU 也不忙了,但是對於事件的反應速度也慢了! 比如假設我一秒鐘輪詢一次,那麼如果剛剛好不巧, 資料正好就在我才剛剛來問完有沒有東西並且前腳才剛空手回去的時候,就在這時候資料進來了, 那麼在此情況下我就要等待整整一秒鐘之後的下一次詢問時才能得知資料的到來,並且完成讀取以及相應的處理; 也就是說在極限最差的情況下,我對任何東西進來之後到實際能夠產生反應的時間差就是一秒鐘。 相對的,如果這個休眠的時間設的短了,那麼對事情發生到產生反應的的平均延遲確實是縮短了, 可是輪詢的頻率也增加,無效的詢問數量也上漲,系統資源忙碌佔用的問題也跟著上升了。
因此在輪詢法的實際應用中,也許最為玄妙的部份就在於對這個小憩時間長短的拿捏, 一般需要一些經驗和預判來決定這個數值。 一般來說一個廣泛合適的數值大約落在 10、20 毫秒起到 100 毫秒內的這個範圍; 如果預期事件發生的頻率較低且即時性要求不高的話,設定 100 毫秒以上的小憩間隔也是可以的; 而 1000 毫秒(也就是一秒鐘)以上的時間則在絕大部份的執行緒應用之下都算是相當相當長的休眠了!
不過如果你的程式並不是那種負載量很大的程式、或者會啟動大量執行緒或行程的程式, 有時候其實也不用給自己那麼大的壓力,因為 1 毫秒的時間屬實相當恰當且好用! 這是因為作業系統的計時器反應本身就需要一段最基本的時間, 於是即便你要求休眠個 1 毫秒,或者有些系統還能讓你設定休眠 1 微秒, 但是真正休眠的時間其實都達不到要求的這個短時間,通常睡眠到醒來至少都會經過數毫秒以上 (至於具體這個最短的時間片多長,就要看各作業系統的設定配置了)。 因此如果你自己寫程式做實驗的話就會發現,使用不休眠的忙碌輪詢會讓 CPU 佔用率飆到 100%, 但是但凡你給輪詢迴圈休息個 1 毫秒或 1 微秒,CPU 的佔用率馬上就會掉下來到與一般待機狀態沒什麼差別; 如果你更進一步統計並列印每次休眠到再次醒過來的時間的話, 甚至能發現只要你設定的休眠時間少於 10 毫秒、或 5 毫秒以內的話,效果基本就是一樣的了。
然而請注意上一段的經驗結論所適用的前提, 也就是在一般通用的家用桌面系統的預設條件,搭配您的程式本身並不是高負荷類型程式的情況下。 至於如果你的程式,或你程式的運作環境並不尋常一般的話, 那麼上述休眠 1 毫秒就足夠任何情況通用的結論可能就不適合你了! 比方說如果你的程式運作在可能經過特別裁剪和配置的作業系統(大部份可能是嵌入式系統), 那麼也許你的執行緒就會真的一直高頻率的被喚起。 或者是如果你的程式會建立數量相當龐大的執行緒,比如幾十上百條執行緒, 那麼每個執行緒都休眠 1 微秒顯然並不能夠有效降低 CPU 的佔用率, 因為排程器可能光是在你的這些執行緒之間切來切去就已經夠忙了! 因此對於這些應用需求,就不能夠無腦的休眠 1 毫秒、1 微秒搞定一切, 還是需要實際估算評估一個合適恰當的輪詢休眠時間長短。
無論如何,輪詢法因為缺乏事件通知機制(或者說可能就是因為缺乏所以才需要使用輪詢), 因此最大的罩門就是或者事件響應延遲較大、或者系統資源佔用高、或者二者皆有。 其中的響應延遲在某些時候可能會產生一定程度的困擾。 例如在我曾經經手過的一個系統上就是使用輪詢法來偵測某特定事件的發生並擷取其資料進行處理。 然而這個資料不是處理完就沒事了, 而是還有下游的軟體模組需要使用同樣的方式來輪詢並取得這個模組所處理過的資料, 然後那個模組也同樣會被它的下游模組給輪詢……。 就這樣經過幾個模組之間的互相傳遞之後,事件響應延遲就被放大了。 比方說如果每個輪詢的休眠時間是 20 毫秒的話,那麼若經過像流水線一樣的 5 個模組分別處理過這個資料, 則在最壞的情況下,一個事件發生時就要等到 100 毫秒之後我們的系統才能夠產生反應。 (當然這個問題後來使用更加先進的處理方法解決掉了!)
測試實驗
至此想必讀者已經對於輪詢法的無論原理還是使用調試等都擁有了一定程度的了解, 接下來就讓我們來嘗試一個真正能夠被編譯執行的程式, 看看輪詢法的不同休眠設定對事件響應的速度、和系統資源的佔用都有什麼樣的影響。
這個測試程式會建立一個工廠執行緒, 它負責每過 500 毫秒就產生一個訊息,並將訊息存入自己的訊息貯列中供後續其它執行緒取走, 而其它執行緒當然也就使用本篇所介紹的輪詢法來不斷詢問並索取訊息。 其中在工廠執行緒產生訊息的時候,也會在訊息裡面記錄訊息產生的時間戳, 這樣後面索取的人就可以藉此計算從訊息產生到真正被收走並處理的時間經過了多久。 第二條執行緒就負責以輪詢的方式從工廠執行緒的貯列詢問和索取訊息, 一旦取到了訊息,就將訊息也放到自己的訊息貯列裡供後面其他人能取走, 這是為了模擬出前面所描述的那種訊息經過一個串一個的模組層層轉交的效果。 然後第三條執行緒以輪詢方式向第二條執行緒的貯列索取訊息、第四條執行緒又向第三條執行緒的貯列索取……, 這個範例程式總共建立了 100 條索取資料的執行緒。 也就是說一個訊息要經過 100 次的輪詢索取之後才會被交到最後一個人手中, 藉此可以計算訊息從被產生直到傳遞到最後一人手裡所經過的時間延遲。
在這測試程式裡面,各執行緒不斷輪詢索取訊息的核心部份程式碼如下:
while(!data->go_term)
{
// 嘗試向設定的訊息來源索取新訊息
Message msg;
bool avail = PopMessage(data->src, msg);
if(avail)
{
// 取得新的訊息以後,將訊息做點該做的處理,
// 然後再將訊息推入自己的貯列,
// 讓下一個接手的人可以取走。
msg.count++;
PushMessage(data, msg);
}
// 階段工作結束,
// 判斷是否需要稍微休息一小段時間再繼續下一輪。
if( !avail && data->period )
SleepMs(data->period);
}
為化簡輪詢的工作,其中用來將訊息推送入貯列、以及嘗試從貯列中索要訊息的部份
被包裝為 PushMessage() 和 PopMessage() 函式,如下:
void PushMessage(Deliverer *data, const Message &msg)
{
// 將訊息存入訊息貯列
pthread_mutex_lock(&data->msg_mutex);
data->msg_list.push_back(msg);
pthread_mutex_unlock(&data->msg_mutex);
}
bool PopMessage(Deliverer *data, Message &msg)
{
// 嘗試從訊息貯列中取出一筆訊息
pthread_mutex_lock(&data->msg_mutex);
bool have_msg = !data->msg_list.empty();
if(have_msg)
{
msg = data->msg_list.front();
data->msg_list.pop_front();
}
pthread_mutex_unlock(&data->msg_mutex);
return have_msg;
}
上面測試程式僅節錄核心重要的部份,而完整程式碼可以 從這裡下載, 而測試和觀察的結果如下:
(測試程式在我的電腦上執行,我的電腦擁有一個四核心的 CPU, 其中 CPU 佔用率的部份是以目視電腦資源管理器的顯示畫面來估計的。)
| 輪詢休眠(ms) | 平均延遲(ms) | CPU 佔用率觀察 |
|---|---|---|
500 |
23000 |
0% |
200 |
10000 |
0% |
100 |
5000 |
似乎有所微幅上升 |
50 |
2500 |
似乎有所微幅上升 |
20 |
1000 |
似乎有所微幅上升 |
10 |
500 |
小於 10% |
5 |
270 |
約 10% |
2 |
110 |
約 10% |
1 |
60 |
約 14% ~ 20% |
0 |
7300 |
100% |
從測試結果來看, 輪詢休眠在 100 毫秒以下就能肉眼觀察出來 CPU 開始稍微忙碌起來,而在休眠 10 毫秒以下時開始有感忙碌; 至於對訊息的響應延遲則是隨著休眠的時間逐漸縮短而基本上等比例下降, 當休眠時間僅為 1 毫秒的時候,平均總處理延遲來到了 60 毫秒; 然而當不休眠而採用忙碌輪詢時,不只 CPU 的佔用率飆升至滿,對訊息響應的延遲也不減反增, 達到與大約 100 多毫秒休眠的條件相同的等級,這是因為執行緒的輪旋空耗而導致互相排擠執行機會的緣故。 不過上面的呈現是基於我在 4 核心 CPU 的環境下開闢了 100 條執行緒的結果, 而如果將執行緒縮減到只有 2、3 條,則只要休眠時間有個至少 1 毫秒,只要不是忙碌等待, CPU 的佔用率看起來和平常待機時並沒有什麼變化; 而對於訊息的平均響應部份,當輪詢休眠小於 5 毫秒的時候,結果基本上都已經相同了。
結論
本篇介紹了現實中相當常見也相當實用的處理事件和資料讀寫收送的輪詢法, 以及同時也順帶介紹了欲使用輪詢法所需要依賴的非阻塞式呼叫, 想必讀者對於輪詢法和非阻塞函式的原理、使用結構、和調試方面等都具有了相當程度的理解。 輪詢法最大的問題在於處理事情的反應速度上面可能並不能夠足夠即時, 特別是在層層套疊輪詢的情況下這個缺點會被進一步放大, 而如果要改善反應速度不夠即時的問題則可能又會導致計算資源的負擔問題。 最終決定一個折衷合適的休眠時間就成為使用輪詢法的調試重點所在。
然而雖然輪詢法存在一些難以兼顧的缺點, 並且在現實的大多數多緒應用中可能會更加傾向喜歡使用基於事件通知的更加高效的進階手段, 然而輪詢法依然存在一定的價值,並且被大量使用。 最主要的原因是輪詢法相比於其它的進階方法更加簡單易學易用, 再加上在一般非重負載用途的程式應用中它的缺點也並不是那麼明顯, 於是乎就造成了輪詢法實際上相當廣泛常見於產業介程式碼的原因其一。 另一項原因則是有的時候其實是不得不使用輪詢法。 比方說當我們的產品裡使用了某個上游廠商的硬體設備, 則很有可能廠商提供的驅動程式也根本就沒有支援事件通知機制, 導致我們不得不使用輪詢來探測和索取事件訊息資料。 其原因無它,因為支援事件通知的驅動程式可能比較複雜, 而若是小廠商的話很可能其實也無力開發那樣的驅動程式, 於是簡單容易實做的非阻塞呼叫搭配輪詢法就這麼跟著上工了。 或者還有的時候是我們需要從比方說像是 GPIO 的通道與週邊設備通信, 這時候可能連像樣的硬體中斷都沒法用上(或者有的時候可能是我們沒有精力去實做那樣的驅動), 於是輪詢大法就又被搬出來了!
下一篇:「執行緒同步 11:睡眠與喚醒」