[0]:概述
今天打算來介紹一下“生産者/消費者模式”,這玩意兒在很多開發領域都能派上用場。由于該模式很重要,打算分幾個文章來介紹。今天這個文章先來掃盲一把。如果你對這個模式已經比較了解,請跳過本掃盲帖,直接看下一個文章(關于該模式的具體應用)。
看到這裡,可能有同學心中犯嘀咕了:在四人幫(GOF)的23種模式裡面似乎沒聽說過這種嘛!其實GOF那經典的23種模式主要是基于OO的(從書名《Design Patterns: Elements of Reusable Object-Oriented Software》就可以看出來)。而Pattern實際上即可以是OO的Pattern,也可以是非OO的Pattern的。
★簡介
言歸正傳!在實際的軟體開發過程中,經常會碰到如下場景:某個子產品負責産生資料,這些資料由另一個子產品來負責處理(此處的子產品是廣義的,可以是類、函數、線程、程序等)。産生資料的子產品,就形象地稱為生産者;而處理資料的子產品,就稱為消費者。
單單抽象出生産者和消費者,還夠不上是生産者/消費者模式。該模式還需要有一個緩沖區處于生産者和消費者之間,作為一個中介。生産者把資料放入緩沖區,而消費者從緩沖區取出資料。大概的結構如下圖。

為了不至于太抽象,我們舉一個寄信的例子(雖說這年頭寄信已經不時興,但這個例子還是比較貼切的)。假設你要寄一封平信,大緻過程如下:
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方式。
順便補充幾個注意事項,大夥兒留意一下:
1、對stdio進行讀寫操作是以阻塞方式進行。比如管道中沒有資料,消費者程序的讀操作就會一直停在哪兒,直到管道中重新有資料。
2、由于stdio内部帶有自己的緩沖區(這緩沖區和管道緩沖區是兩碼事),有時會導緻一些不太爽的現象(比如生産者程序輸出了資料,但消費者程序沒有立即讀到)。具體的細節,大夥兒可以看"這裡"。
◇SOCKET(TCP方式)
基于TCP方式的SOCKET通訊是又一個類似于隊列的IPC方式。它同樣保證了資料的順序到達;同樣有緩沖的機制。而且這玩意兒也是跨平台和跨語言的,和剛才介紹的shell管道符方式類似。
SOCKET相比shell管道符的方式,有啥優點捏?主要有如下幾個優點:
1、SOCKET方式可以跨機器(便于實作分布式)。這是主要優點。
2、SOCKET方式便于将來擴充成為多對一或者一對多。這也是主要優點。
3、SOCKET可以設定阻塞和非阻塞方法,用起來比較靈活。這是次要優點。
4、SOCKET支援雙向通訊,有利于消費者回報資訊。
當然有利就有弊。相對于上述shell管道的方式,使用SOCKET在程式設計上會更複雜一些。好在前人已經做了大量的工作,搞出很多SOCKET通訊庫和架構給大夥兒用(比如C++的ACE庫、Python的Twisted)。借助于這些第三方的庫和架構,SOCKET方式用起來還是比較爽的。由于具體的網絡通訊庫該怎麼用不是本系列的重點,此處就不細說了。
雖然TCP在很多方面比UDP可靠,但鑒于跨機器通訊先天的不可預料性(比如網線可能被某傻X給拔錯了,網絡的忙閑波動可能很大),在程式設計上我們還是要多留一手。具體該如何做捏?可以在生産者程序和消費者程序内部各自再引入基于線程的"生産者/消費者模式"。這話聽着像繞密碼,為了便于了解,畫張圖給大夥兒瞅一瞅。
這麼做的關鍵點在于把代碼分為兩部分:生産線程和消費線程屬于和業務邏輯相關的代碼(和通訊邏輯無關);發送線程和接收線程屬于通訊相關的代碼(和業務邏輯無關)。
這樣的好處是很明顯的,具體如下:
1、能夠應對暫時性的網絡故障。并且在網絡故障解除後,能夠繼續工作。
2、網絡故障的應對處理方式(比如斷開後的嘗試重連),隻影響發送和接收線程,不會影響生産線程和消費線程(業務邏輯部分)。
3、具體的SOCKET方式(阻塞和非阻塞)隻影響發送和接收線程,不影響生産線程和消費線程(業務邏輯部分)。
4、不依賴TCP自身的發送緩沖區和接收緩沖區。(預設的TCP緩沖區的大小可能無法滿足實際要求)
5、業務邏輯的變化(比如業務需求變更)不影響發送線程和接收線程。
針對上述的最後一條,再多啰嗦幾句。如果整個業務系統中有多個程序是采用上述的模式,那或許可以重構一把:在業務邏輯代碼和通訊邏輯代碼之間切一刀,把業務邏輯無關的部分封裝成一個通訊中間件(說中間件顯得比較牛X :-)。如果大夥兒對這玩意兒有興趣,以後專門開個文章聊。
[3]:環形緩沖區
前一個文章提及了隊列緩沖區可能存在的性能問題及解決方法:環形緩沖區。今天就專門來描述一下這個話題。
為了防止有人給咱扣上“過度設計”的大帽子,事先聲明一下:隻有當存儲空間的配置設定/釋放非常頻繁并且确實産生了明顯的影響,你才應該考慮環形緩沖區的使用。否則的話,還是老老實實用最基本、最簡單的隊列緩沖區吧。還有一點需要說明一下:本文所提及的“存儲空間”,不僅包括記憶體,還可能包括諸如硬碟之類的存儲媒體。
★環形緩沖區 vs 隊列緩沖區
◇外部接口相似
在介紹環形緩沖區之前,咱們先來回顧一下普通的隊列。普通的隊列有一個寫入端和一個讀出端。隊列為空的時候,讀出端無法讀取資料;當隊列滿(達到最大尺寸)時,寫入端無法寫入資料。
對于使用者來講,環形緩沖區和隊列緩沖區是一樣的。它也有一個寫入端(用于push)和一個讀出端(用于pop),也有緩沖區“滿”和“空”的狀态。是以,從隊列緩沖區切換到環形緩沖區,對于使用者來說能比較平滑地過渡。
◇内部結構迥異
雖然兩者的對外接口差不多,但是内部結構和運作機制有很大差别。隊列的内部結構此處就不多啰嗦了。重點介紹一下環形緩沖區的内部結構。
大夥兒可以把環形緩沖區的讀出端(以下簡稱R)和寫入端(以下簡稱W)想象成是兩個人在體育場跑道上追逐(R追W)。當R追上W的時候,就是緩沖區為空;當W追上R的時候(W比R多跑一圈),就是緩沖區滿。
為了形象起見,去找來一張圖并略作修改,如下:
從上圖可以看出,環形緩沖區所有的push和pop操作都是在一個固定的存儲空間内進行。而隊列緩沖區在push的時候,可能會配置設定存儲空間用于存儲新元素;在pop時,可能會釋放廢棄元素的存儲空間。是以環形方式相比隊列方式,少掉了對于緩沖區元素所用存儲空間的配置設定、釋放。這是環形緩沖區的一個主要優勢。
★環形緩沖區的實作
如果你手頭已經有現成的環形緩沖區可供使用,并且你對環形緩沖區的内部實作不感興趣,可以跳過這段。
◇數組方式 vs 連結清單方式
環形緩沖區的内部實作,即可基于數組(此處的數組,泛指連續存儲空間)實作,也可基于連結清單實作。
數組在實體存儲上是一維的連續線性結構,可以在初始化時,把存儲空間一次性配置設定好,這是數組方式的優點。但是要使用數組來模拟環,你必須在邏輯上把數組的頭和尾相連。在順序周遊數組時,對尾部元素(最後一個元素)要作一下特殊處理。通路尾部元素的下一個元素時,要重新回到頭部元素(第0個元素)。如下圖所示:
使用連結清單的方式,正好和數組相反:連結清單省去了頭尾相連的特殊處理。但是連結清單在初始化的時候比較繁瑣,而且在有些場合(比如後面提到的跨程序的IPC)不太友善使用。
◇讀寫操作
環形緩沖區要維護兩個索引,分别對應寫入端(W)和讀取端(R)。寫入(push)的時候,先確定環沒滿,然後把資料複制到W所對應的元素,最後W指向下一個元素;讀取(pop)的時候,先確定環沒空,然後傳回R對應的元素,最後R指向下一個元素。
◇判斷“空”和“滿”
上述的操作并不複雜,不過有一個小小的麻煩:空環和滿環的時候,R和W都指向同一個位置!這樣就無法判斷到底是“空”還是“滿”。大體上有兩種方法可以解決該問題。
辦法1:始終保持一個元素不用
當空環的時候,R和W重疊。當W比R跑得快,追到距離R還有一個元素間隔的時候,就認為環已經滿。當環内元素占用的存儲空間較大的時候,這種辦法顯得很土(浪費空間)。
辦法2:維護額外變量
如果不喜歡上述辦法,還可以采用額外的變量來解決。比如可以用一個整數記錄目前環中已經儲存的元素個數(該整數>=0)。當R和W重疊的時候,通過該變量就可以知道是“空”還是“滿”。
◇元素的存儲
由于環形緩沖區本身就是要降低存儲空間配置設定的開銷,是以緩沖區中元素的類型要選好。盡量存儲值類型的資料,而不要存儲指針(引用)類型的資料。因為指針類型的資料又會引起存儲空間(比如堆記憶體)的配置設定和釋放,使得環形緩沖區的效果打折扣。
★應用場合
剛才介紹了環形緩沖區内部的實作機制。按照前一個文章的慣例,我們來介紹一下線上程和程序方式下的使用。
如果你所使用的程式設計語言和開發庫中帶有現成的、成熟的環形緩沖區,強烈建議使用現成的庫,不要重新制造輪子;确實找不到現成的,才考慮自己實作。如果你純粹是業餘時間練練手,那另當别論。
◇用于并發線程
和線程中的隊列緩沖區類似,線程中的環形緩沖區也要考慮線程安全的問題。除非你使用的環形緩沖區的庫已經幫你實作了線程安全,否則你還是得自己動手搞定。線程方式下的環形緩沖區用得比較多,相關的網上資料也多,下面就大緻介紹幾個。
對于C++的程式員,強烈推薦使用boost提供的circular_buffer模闆,該模闆最開始是在boost 1.35版本中引入的。鑒于boost在C++社群中的地位,大夥兒應該可以放心使用該模闆。
對于C程式員,可以去看看開源項目circbuf,不過該項目是GPL協定的,不太爽;而且活躍度不太高;而且隻有一個開發人員。大夥兒慎用!建議隻拿它當參考。
對于C#程式員,可以參考CodeProject上的一個示例。
◇用于并發程序
程序間的環形緩沖區,似乎少有現成的庫可用。大夥兒隻好自己動手、豐衣足食了。
适用于程序間環形緩沖的IPC類型,常見的有共享記憶體和檔案。在這兩種方式上進行環形緩沖,通常都采用數組的方式實作。程式事先配置設定好一個固定長度的存儲空間,然後具體的讀寫操作、判斷“空”和“滿”、元素存儲等細節就可參照前面所說的來進行。
共享記憶體方式的性能很好,适用于資料流量很大的場景。但是有些語言(比如Java)對于共享記憶體不支援。是以,該方式在多語言協同開發的系統中,會有一定的局限性。
而檔案方式在程式設計語言方面支援很好,幾乎所有程式設計語言都支援操作檔案。但它可能會受限于磁盤讀寫(Disk I/O)的性能。是以檔案方式不太适合于快速資料傳輸;但是對于某些“資料單元”很大的場合,檔案方式是值得考慮的。
對于程序間的環形緩沖區,同樣要考慮好程序間的同步、互斥等問題,限于篇幅,此處就不細說了。
下一個文章,咱們來聊一下雙緩沖區的使用。