緩衝區系列(1)

 ★簡介

    在實際的軟件開發過程中,經常會碰到如下場景:某個模塊負責產生數據,這些數據由另一個模塊來負責處理(此處的模塊是廣義的,可以是類、函數、線程、進程等)。產生數據的模塊,就形象地稱爲生產者;而處理數據的模塊,就稱爲消費者。

    單單抽象出生產者和消費者,還夠不上是生產者/消費者模式。該模式還需要有一個緩衝區處於生產者和消費者之間,作爲一箇中介。生產者把數據放入緩衝區,而消費者從緩衝區取出數據。大概的結構如下圖。


    爲了不至於太抽象,我們舉一個寄信的例子(雖說這年頭寄信已經不時興,但這個例子還是比較貼切的)。假設你要寄一封平信,大致過程如下:

    1、你把信寫好——相當於生產者製造數據

    2、你把信放入郵筒——相當於生產者把數據放入緩衝區

    3、郵遞員把信從郵筒取出——相當於消費者把數據取出緩衝區

    4、郵遞員把信拿去郵局做相應的處理——相當於消費者處理數據

    ★優點

    可能有同學會問了:這個緩衝區有什麼用捏?爲什麼不讓生產者直接調用消費者的某個函數,直接把數據傳遞過去?搞出這麼一個緩衝區作甚?

    其實這裏面是大有講究的,大概有如下一些好處。

    ◇解耦

    假設生產者和消費者分別是兩個類。如果讓生產者直接調用消費者的某個方法,那麼生產者對於消費者就會產生依賴(也就是耦合)。將來如果消費者的代碼發生變化,可能會影響到生產者。而如果兩者都依賴於某個緩衝區,兩者之間不直接依賴,耦合也就相應降低了。

    接着上述的例子,如果不使用郵筒(也就是緩衝區),你必須得把信直接交給郵遞員。有同學會說,直接給郵遞員不是挺簡單的嘛?其實不簡單,你必須得認識誰是郵遞員,才能把信給他(光憑身上穿的制服,萬一有人假冒,就慘了)。這就產生和你和郵遞員之間的依賴(相當於生產者和消費者的強耦合)。萬一哪天郵遞員換人了,你還要重新認識一下(相當於消費者變化導致修改生產者代碼)。而郵筒相對來說比較固定,你依賴它的成本就比較低(相當於和緩衝區之間的弱耦合)。

    ◇支持併發(concurrency)

    生產者直接調用消費者的某個方法,還有另一個弊端。由於函數調用是同步的(或者叫阻塞的),在消費者的方法沒有返回之前,生產者只好一直等在那邊。萬一消費者處理數據很慢,生產者就會白白糟蹋大好時光。

    使用了生產者/消費者模式之後,生產者和消費者可以是兩個獨立的併發主體(常見併發類型有進程和線程兩種,後面的帖子會講兩種併發類型下的應用)。生產者把製造出來的數據往緩衝區一丟,就可以再去生產下一個數據。基本上不用依賴消費者的處理速度。

    其實當初這個模式,主要就是用來處理併發問題的。

    從寄信的例子來看。如果沒有郵筒,你得拿着信傻站在路口等郵遞員過來收(相當於生產者阻塞);又或者郵遞員得挨家挨戶問,誰要寄信(相當於消費者輪詢)。不管是哪種方法,都挺土的。

    ◇支持忙閒不均

    緩衝區還有另一個好處。如果製造數據的速度時快時慢,緩衝區的好處就體現出來了。當數據製造快的時候,消費者來不及處理,未處理的數據可以暫時存在緩衝區中。等生產者的製造速度慢下來,消費者再慢慢處理掉。

    爲了充分複用,我們再拿寄信的例子來說事。假設郵遞員一次只能帶走1000封信。萬一某次碰上情人節(也可能是聖誕節)送賀卡,需要寄出去的信超過1000封,這時候郵筒這個緩衝區就派上用場了。郵遞員把來不及帶走的信暫存在郵筒中,等下次過來時再拿走。

 費了這麼多口水,希望原先不太瞭解生產者/消費者模式的同學能夠明白它是怎麼一回事。然後在下一個帖子中,我們來說說如何確定數據單元。

    另外,爲了方便閱讀,把本系列帖子的目錄整理如下:

    1、如何確定數據單元

    2、隊列緩衝區

    3、隊列緩衝區

    4、雙緩衝區

    5、......

    [1]:如何確定數據單元?

    既然前一個帖子已經搞過掃盲了,那接下來應該開始聊一些具體的編程技術問題了。不過在進入具體的技術細節之前,咱們先要搞明白一個問題:如何確定數據單元?只有把數據單元分析清楚,後面的技術設計纔好搞。

    ★啥是數據單元

    何謂數據單元捏?簡單地說,每次生產者放到緩衝區的,就是一個數據單元;每次消費者從緩衝區取出的,也是一個數據單元。對於前一個帖子中寄信的例子,我們可以把每一封單獨的信件看成是一個數據單元。

    不過光這麼介紹,太過於簡單,無助於大夥兒分析出這玩意兒。所以,後面咱們來看一下數據單元需要具備哪些特性。搞明白這些特性之後,就容易從複雜的業務邏輯中分析出適合做數據單元的東西了。

    ★數據單元的特性

    分析數據單元,需要考慮如下幾個方面的特性:

    ◇關聯到業務對象

    首先,數據單元必須關聯到某種業務對象。在考慮該問題的時候,你必須深刻理解當前這個生產者/消費者模式所對應的業務邏輯,才能夠作出合適的判斷。

    由於“寄信”這個業務邏輯比較簡單,所以大夥兒很容易就可以判斷出數據單元是啥。但現實生活中,往往沒這麼樂觀。大多數業務邏輯都比較複雜,當中包含的業務對象是層次繁多、類型各異。在這種情況下,就不易作出決策了。

    這一步很重要,如果選錯了業務對象,會導致後續程序設計和編碼實現的複雜度大爲上升,增加了開發和維護成本。

    ◇完整性

    所謂完整性,就是在傳輸過程中,要保證該數據單元的完整。要麼整個數據單元被傳遞到消費者,要麼完全沒有傳遞到消費者。不允許出現部分傳遞的情形。

    對於寄信來說,你不能把半封信放入郵筒;同樣的,郵遞員從郵筒中拿信,也不能只拿出信的一部分。

    ◇獨立性

    所謂獨立性,就是各個數據單元之間沒有互相依賴,某個數據單元傳輸失敗不應該影響已經完成傳輸的單元;也不應該影響尚未傳輸的單元。

    爲啥會出現傳輸失敗捏?假如生產者的生產速度在一段時間內一直超過消費者的處理速度,那就會導致緩衝區不斷增長並達到上限,之後的數據單元就會被丟棄。如果數據單元相互獨立,等到生產者的速度降下來之後,後續的數據單元繼續處理,不會受到牽連;反之,如果數據單元之間有某種耦合,導致被丟棄的數據單元會影響到後續其它單元的處理,那就會使程序邏輯變得非常複雜。

    對於寄信來說,某封信弄丟了,不會影響後續信件的送達;當然更不會影響已經送達的信件。

    ◇顆粒度

    前面提到,數據單元需要關聯到某種業務對象。那麼數據單元和業務對象是否要一一對應捏?很多場合確實是一一對應的。

    不過,有時出於性能等因素的考慮,也可能會把N個業務對象打包成一個數據單元。那麼,這個N該如何取值就是顆粒度的考慮了。顆粒度的大小是有講究的。太大的顆粒度可能會造成某種浪費;太小的顆粒度可能會造成性能問題。顆粒度的權衡要基於多方面的因素,以及一些經驗值的考量。

    還是拿寄信的例子。如果顆粒度過小(比如設定爲1),那郵遞員每次只取出1封信。如果信件多了,那就得來回跑好多趟,浪費了時間。

    如果顆粒度太大(比如設定爲100),那寄信的人得等到湊滿100封信纔拿去放入郵筒。假如平時很少寫信,就得等上很久,也不太爽。

    可能有同學會問:生產者和消費者的顆粒度能否設置成不同大小(比如對於寄信人設置成1,對於郵遞員設置成100)。當然,理論上可以這麼幹,但是在某些情況下會增加程序邏輯和代碼實現的複雜度。後面討論具體技術細節時,或許會聊到這個問題。

    好,數據單元的話題就說到這。希望通過本帖子,大夥兒能夠搞明白數據單元到底是怎麼一回事。下一個帖子,咱們來聊一下“基於隊列的緩衝區”,技術上如何實現。

[2]:隊列緩衝區

    經過前面兩個帖子的鋪墊,今天終於開始聊一些具體的編程技術了。由於不同的緩衝區類型、不同的併發場景對於具體的技術實現有較大的影響。爲了深入淺出、便於大夥兒理解,咱們先來介紹最傳統、最常見的方式。也就是單個生產者對應單個消費者,當中用隊列(FIFO)作緩衝。

    關於併發的場景,在之前的帖子“進程還線程?是一個問題!”中,已經專門論述了進程和線程各自的優缺點,兩者皆不可偏廢。所以,後面對各種緩衝區類型的介紹都會同時提及進程方式和線程方式。

    ★線程方式

    先來說一下併發線程中使用隊列的例子,以及相關的優缺點。

    ◇內存分配的性能

    在線程方式下,生產者和消費者各自是一個線程。生產者把數據寫入隊列頭(以下簡稱push),消費者從隊列尾部讀出數據(以下簡稱pop)。當隊列爲空,消費者就稍息(稍事休息);當隊列滿(達到最大長度),生產者就稍息。整個流程並不複雜。

    那麼,上述過程會有什麼問題捏?一個主要的問題是關於內存分配的性能開銷。對於常見的隊列實現:在每次push時,可能涉及到堆內存的分配;在每次pop時,可能涉及堆內存的釋放。假如生產者和消費者都很勤快,頻繁地push、pop,那內存分配的開銷就很可觀了。對於內存分配的開銷,用Java的同學可以參見前幾天的帖子“Java性能優化[1]”;對於用C/C++的同學,想必對OS底層機制會更清楚,應該知道分配堆內存(new或malloc)會有加鎖的開銷和用戶態/核心態切換的開銷。

    那該怎麼辦捏?請聽下文分解,關於“生產者/消費者模式[3]:環形緩衝區”。

    ◇同步和互斥的性能

    另外,由於兩個線程共用一個隊列,自然就會涉及到線程間諸如同步啊、互斥啊、死鎖啊等等勞心費神的事情。好在"操作系統"這門課程對此有詳細介紹,學過的同學應該還有點印象吧?對於沒學過這門課的同學,也不必難過,網上相關的介紹挺多的(比如"這裏"),大夥自己去瞅一瞅。關於這方面的細節,咱今天就不多囉嗦了。

    這會兒要細談的是,同步和互斥的性能開銷。在很多場合中,諸如信號量、互斥量等玩意兒的使用也是有不小的開銷的(某些情況下,也可能導致用戶態/核心態切換)。如果像剛纔所說,生產者和消費者都很勤快,那這些開銷也不容小覷啊。

    這又該咋辦捏?請聽下文的下文分解,關於“生產者/消費者模式[4]:雙緩衝區”。

    ◇適用於隊列的場合

    剛纔盡批判了隊列的缺點,難道隊列方式就一無是處?非也。由於隊列是很常見的數據結構,大部分編程語言都內置了隊列的支持(具體介紹見"這裏"),有些語言甚至提供了線程安全的隊列(比如JDK 1.5引入的ArrayBlockingQueue)。因此,開發人員可以撿現成,避免了重新發明輪子。

    所以,假如你的數據流量不是很大,採用隊列緩衝區的好處還是很明顯的:邏輯清晰、代碼簡單、維護方便。比較符合KISS原則。

    ★進程方式

    說完了線程的方式,再來介紹基於進程的併發。

    跨進程的生產者/消費者模式,非常依賴於具體的進程間通訊(IPC)方式。而IPC的種類名目繁多,不便於挨個列舉(畢竟口水有限)。因此咱們挑選幾種跨平臺、且編程語言支持較多的IPC方式來說事兒。

    ◇匿名管道

    感覺管道是最像隊列的IPC類型。生產者進程在管道的寫端放入數據;消費者進程在管道的讀端取出數據。整個的效果和線程中使用隊列非常類似,區別在於使用管道就無需操心線程安全、內存分配等瑣事(操作系統暗中都幫你搞定了)。

    管道又分命名管道和匿名管道兩種,今天主要聊匿名管道。因爲命名管道在不同的操作系統下差異較大(比如Win32和POSIX,在命名管道的API接口和功能實現上都有較大差異;有些平臺不支持命名管道,比如Windows CE)。除了操作系統的問題,對於有些編程語言(比如Java)來說,命名管道是無法使用的。所以我一般不推薦使用這玩意兒。

    其實匿名管道在不同平臺上的API接口,也是有差異的(比如Win32的CreatePipe和POSIX的pipe,用法就很不一樣)。但是我們可以僅使用標準輸入和標準輸出(以下簡稱stdio)來進行數據的流入流出。然後利用shell的管道符把生產者進程和消費者進程關聯起來(沒聽說過這種手法的同學,可以看"這裏")。實際上,很多操作系統(尤其是POSIX風格的)自帶的命令都充分利用了這個特性來實現數據的傳輸(比如more、grep等)。

    這麼幹有幾個好處:

    1、基本上所有操作系統都支持在shell方式下使用管道符。因此很容易實現跨平臺。

    2、大部分編程語言都能夠操作stdio,因此跨編程語言也就容易實現。

    3、剛纔已經提到,管道方式省卻了線程安全方面的瑣事。有利於降低開發、調試成本。

    當然,這種方式也有自身的缺點:

    1、生產者進程和消費者進程必須得在同一臺主機上,無法跨機器通訊。這個缺點比較明顯。

    2、在一對一的情況下,這種方式挺合用。但如果要擴展到一對多或者多對一,那就有點棘手了。所以這種方式的擴展性要打個折扣。假如今後要考慮類似的擴展,這個缺點就比較明顯。

    3、由於管道是shell創建的,對於兩邊的進程不可見(程序看到的只是stdio)。在某些情況下,導致程序不便於對管道進行操縱(比如調整管道緩衝區尺寸)。這個缺點不太明顯。

    4、最後,這種方式只能單向傳數據。好在大多數情況下,消費者進程不需要傳數據給生產者進程。萬一你確實需要信息反饋(從消費者到生產者),那就費勁了。可能得考慮換種IPC方式。


#include <stdio.h>
#include 
<stdlib.h>

typedef 
int elemType;
/************************************************************************/
/*                    以下是關於隊列鏈接存儲操作的6種算法               */
/************************************************************************/
struct sNode{
    elemType data;            
/* 值域 */
    
struct sNode *next;        /* 鏈接指針 */
};
struct queueLK{
    
struct sNode *front;    /* 隊首指針 */
    
struct sNode *rear;        /* 隊尾指針 */
};

/* 1.初始化鏈隊 */
void initQueue(struct queueLK *hq)
{
    hq
->front = hq->rear = NULL;        /* 把隊首和隊尾指針置空 */
    
return;
}

/* 2.向鏈隊中插入一個元素x */
void enQueue(struct queueLK *hq, elemType x)
{
    
/* 得到一個由newP指針所指向的新結點 */
    
struct sNode *newP;
    newP 
= malloc(sizeof(struct sNode));
    
if(newP == NULL){
        printf(
"內存空間分配失敗! ");
        exit(
1);
    }
    
/* 把x的值賦給新結點的值域,把新結點的指針域置空 */
    newP
->data = x;
    newP
->next = NULL;
    
/* 若鏈隊爲空,則新結點即是隊首結點又是隊尾結點 */
    
if(hq->rear == NULL){
        hq
->front = hq->rear = newP;
    }
else{    /* 若鏈隊非空,則依次修改隊尾結點的指針域和隊尾指針,使之指向新的隊尾結點 */
        hq
->rear = hq->rear->next = newP;        /* 注意賦值順序哦 */
    }
    
return;
}

/* 3.從隊列中刪除一個元素 */
elemType outQueue(
struct queueLK *hq)
{
    
struct sNode *p;
    elemType temp;
    
/* 若鏈隊爲空則停止運行 */
    
if(hq->front == NULL){
        printf(
"隊列爲空,無法刪除! ");
        exit(
1);
    }
    temp 
= hq->front->data;        /* 暫存隊尾元素以便返回 */
    p 
= hq->front;                /* 暫存隊尾指針以便回收隊尾結點 */
    hq
->front = p->next;        /* 使隊首指針指向下一個結點 */
    
/* 若刪除後鏈隊爲空,則需同時使隊尾指針爲空 */
    
if(hq->front == NULL){
        hq
->rear = NULL;
    }
    free(p);        
/* 回收原隊首結點 */
    
return temp;    /* 返回被刪除的隊首元素值 */
}

/* 4.讀取隊首元素 */
elemType peekQueue(
struct queueLK *hq)
{
    
/* 若鏈隊爲空則停止運行 */
    
if(hq->front == NULL){
        printf(
"隊列爲空,無法刪除! ");
        exit(
1);
    }
    
return hq->front->data;        /* 返回隊首元素 */
}

/* 5.檢查鏈隊是否爲空,若爲空則返回1, 否則返回0 */
int emptyQueue(struct queueLK *hq)
{
    
/* 判斷隊首或隊尾任一個指針是否爲空即可 */
    
if(hq->front == NULL){
        
return 1;
    }
else{
        
return 0;
    }
}

/* 6.清除鏈隊中的所有元素 */
void clearQueue(struct queueLK *hq)
{
    
struct sNode *= hq->front;        /* 隊首指針賦給p */
    
/* 依次刪除隊列中的每一個結點,最後使隊首指針爲空 */
    
while(p != NULL){
        hq
->front = hq->front->next;
        free(p);
        p 
= hq->front;
    }    
/* 循環結束後隊首指針已經爲空 */
    hq
->rear = NULL;        /* 置隊尾指針爲空 */
    
return;
}

/************************************************************************/

int main(int argc, char* argv[])
{
    
struct queueLK q;
    
int a[8= {385179301522};
    
int i;
    initQueue(
&q);
    
for(i = 0; i < 8; i++){
        enQueue(
&q, a[i]);
    }
    printf(
"%d ", outQueue(&q));    printf("%d  ", outQueue(&q));
    enQueue(
&q, 68);
    printf(
"%d ", peekQueue(&q));    printf("%d  ", outQueue(&q));
    
while(!emptyQueue(&q)){
        printf(
"%d ", outQueue(&q));
    }
    printf(
" ");
    clearQueue(
&q);
    system(
"pause");
}



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