• <ins id="pjuwb"></ins>
    <blockquote id="pjuwb"><pre id="pjuwb"></pre></blockquote>
    <noscript id="pjuwb"></noscript>
          <sup id="pjuwb"><pre id="pjuwb"></pre></sup>
            <dd id="pjuwb"></dd>
            <abbr id="pjuwb"></abbr>

            陳碩的Blog

            C++ 工程實(shí)踐(9):數(shù)據(jù)抽象

            陳碩 (giantchen_AT_gmail)
            http://blog.csdn.net/Solstice  http://weibo.com/giantchen
            陳碩關(guān)于 C++ 工程實(shí)踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx
            排版正常的版本: http://www.cnblogs.com/Solstice/category/287661.html
            陳碩博客文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
            本作品采用“Creative Commons 署名-非商業(yè)性使用-禁止演繹 3.0 Unported 許可協(xié)議(cc by-nc-nd)”進(jìn)行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/

            前一篇文章談了值語(yǔ)義,這篇文章談一談與之密切相關(guān)的數(shù)據(jù)抽象(data abstraction)。

            文章結(jié)構(gòu):

            1. 什么是數(shù)據(jù)抽象?它與面向?qū)ο笥泻螀^(qū)別?
            2. 數(shù)據(jù)抽象所需的語(yǔ)言設(shè)施
            3. 數(shù)據(jù)抽象的例子

            什么是數(shù)據(jù)抽象

            數(shù)據(jù)抽象(data abstraction)是與面向?qū)ο?object-oriented)并列的一種編程范式(programming paradigm)。說(shuō)“數(shù)據(jù)抽象”或許顯得陌生,它的另外一個(gè)名字“抽象數(shù)據(jù)類(lèi)型/abstract data type/ADT”想必如雷貫耳。

            “支持?jǐn)?shù)據(jù)抽象”一直是C++語(yǔ)言的設(shè)計(jì)目標(biāo),Bjarne Stroustrup 在他的《The C++ Programming Language》第二版(1991年出版)中寫(xiě)道[2nd]:

            The C++ programming language is designed to

            • be a better C
            • support data abstraction
            • support object-oriented programming

            這本書(shū)第三版(1997年出版)[3rd] 增加了一條:

            C++ is a general-purpose programming language with a bias towards systems programming that

            • is a better C,
            • supports data abstraction,
            • supports object-oriented programming, and
            • supports generic programming.

            http://www.softwarepreservation.org/projects/c_plus_plus/index.html#cfront 可以找到 C++ 的早期文獻(xiàn),其中有一篇 Bjarne Stroustrup 在 1984 年寫(xiě)的 《Data Abstraction in C++》 http://www.softwarepreservation.org/projects/c_plus_plus/cfront/release_e/doc/DataAbstraction.pdf 。在這個(gè)頁(yè)面還能找到 Bjarne 寫(xiě)的關(guān)于 C++ 操作符重載和復(fù)數(shù)運(yùn)算的文章,作為數(shù)據(jù)抽象的詳解與范例。可見(jiàn) C++ 早期是以數(shù)據(jù)抽象為賣(mài)點(diǎn)的,支持?jǐn)?shù)據(jù)抽象是C++相對(duì)于C的一大優(yōu)勢(shì)。

            作為語(yǔ)言的設(shè)計(jì)者,Bjarne 把數(shù)據(jù)抽象作為C++的四個(gè)子語(yǔ)言之一。這個(gè)觀點(diǎn)不是普遍接受的,比如作為語(yǔ)言的使用者,Scott Meyers 在《Effective C++ 第三版》中把 C++ 分為四個(gè)子語(yǔ)言:C、Object-Oriented C++、Template C++、STL。在 Scott Meyers 的分類(lèi)法中,就沒(méi)有出現(xiàn)數(shù)據(jù)抽象,而是歸入了 object-oriented C++。

             

            那么到底什么是數(shù)據(jù)抽象?

            簡(jiǎn)單的說(shuō),數(shù)據(jù)抽象是用來(lái)描述數(shù)據(jù)結(jié)構(gòu)的。數(shù)據(jù)抽象就是 ADT。一個(gè) ADT 主要表現(xiàn)為它支持的一些操作,比方說(shuō) stack.push、stack.pop,這些操作應(yīng)該具有明確的時(shí)間和空間復(fù)雜度。另外,一個(gè) ADT 可以隱藏其實(shí)現(xiàn)細(xì)節(jié),比方說(shuō) stack 既可以用動(dòng)態(tài)數(shù)組實(shí)現(xiàn),又可以用鏈表實(shí)現(xiàn)。

            按照這個(gè)定義,數(shù)據(jù)抽象和基于對(duì)象(object-based)很像,那么它們的區(qū)別在哪里?語(yǔ)義不同。ADT 通常是值語(yǔ)義,而 object-based 是對(duì)象語(yǔ)言。(這兩種語(yǔ)義的定義見(jiàn)前文《C++ 工程實(shí)踐(8):值語(yǔ)義》)。ADT class 是可以拷貝的,拷貝之后的 instance 與原 instance 脫離關(guān)系。

            比方說(shuō) stack a; a.push(10); stack b = a; b.pop(); 這時(shí)候 a 里仍然有元素 10。

             

            C++ 標(biāo)準(zhǔn)庫(kù)中的數(shù)據(jù)抽象

            C++ 標(biāo)準(zhǔn)庫(kù)里  complex<> 、pair<>、vector<>、list<>、map<>、set<>、string、stack、queue 都是數(shù)據(jù)抽象的例子。vector 是動(dòng)態(tài)數(shù)組,它的主要操作有 push_back()、size()、begin()、end() 等等,這些操作不僅含義清晰,而且計(jì)算復(fù)雜度都是常數(shù)。類(lèi)似的,list 是鏈表,map 是有序關(guān)聯(lián)數(shù)組,set 是有序集合、stack 是 FILO 棧、queue是 FIFO 隊(duì)列。“動(dòng)態(tài)數(shù)組”、“鏈表”、“有序集合”、“關(guān)聯(lián)數(shù)組”、“棧”、“隊(duì)列”都是定義明確(操作、復(fù)雜度)的抽象數(shù)據(jù)類(lèi)型。

             

            數(shù)據(jù)抽象與面向?qū)ο蟮膮^(qū)別

            本文把 data abstraction、object-based、object-oriented 視為三個(gè)編程范式。這種細(xì)致的分類(lèi)或許有助于理解區(qū)分它們之間的差別。

            庸俗地講,面向?qū)ο?object-oriented)有三大特征:封裝、繼承、多態(tài)。而基于對(duì)象(object-based)則只有封裝,沒(méi)有繼承和多態(tài),即只有具體類(lèi),沒(méi)有抽象接口。它們兩個(gè)都是對(duì)象語(yǔ)義。

            面向?qū)ο笳嬲诵牡乃枷胧窍鬟f(messaging),“封裝繼承多態(tài)”只是表象。這一點(diǎn)孟巖 http://blog.csdn.net/myan/article/details/5928531 和王益 http://cxwangyi.wordpress.com/2011/06/19/%E6%9D%82%E8%B0%88%E7%8E%B0%E4%BB%A3%E9%AB%98%E7%BA%A7%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80/ 都有精彩的論述,陳碩不再贅言。

            數(shù)據(jù)抽象與它們兩個(gè)的界限在于“語(yǔ)義”,數(shù)據(jù)抽象不是對(duì)象語(yǔ)義,而是值語(yǔ)義。比方說(shuō) muduo 里的 TcpConnection 和 Buffer 都是具體類(lèi),但前者是基于對(duì)象的(object-based),而后者是數(shù)據(jù)抽象。

            類(lèi)似的,muduo::Date、muduo::Timestamp 都是數(shù)據(jù)抽象。盡管這兩個(gè) classes 簡(jiǎn)單到只有一個(gè) int/long 數(shù)據(jù)成員,但是它們各自定義了一套操作(operation),并隱藏了內(nèi)部數(shù)據(jù),從而讓它從 data aggregation 變成了 data abstraction。

            數(shù)據(jù)抽象是針對(duì)“數(shù)據(jù)”的,這意味著 ADT class 應(yīng)該可以拷貝,只要把數(shù)據(jù)復(fù)制一份就行了。如果一個(gè) class 代表了其他資源(文件、員工、打印機(jī)、賬號(hào)),那么它就是 object-based 或 object-oriented,而不是數(shù)據(jù)抽象。

            ADT class 可以作為 Object-based/object-oriented class 的成員,但反過(guò)來(lái)不成立,因?yàn)檫@樣一來(lái) ADS class 的拷貝就失去意義了。

             

            數(shù)據(jù)抽象所需的語(yǔ)言設(shè)施

            不是每個(gè)語(yǔ)言都支持?jǐn)?shù)據(jù)抽象,下面簡(jiǎn)要列出“數(shù)據(jù)抽象”所需的語(yǔ)言設(shè)施。

            支持?jǐn)?shù)據(jù)聚合

            數(shù)據(jù)聚合 data aggregation,或者 value aggregates。即定義 C-style struct,把有關(guān)數(shù)據(jù)放到同一個(gè) struct 里。FORTRAN77沒(méi)有這個(gè)能力,F(xiàn)ORTRAN77 無(wú)法實(shí)現(xiàn) ADT。這種數(shù)據(jù)聚合 struct 是 ADT 的基礎(chǔ),struct List、struct HashTable 等能把鏈表和哈希表結(jié)構(gòu)的數(shù)據(jù)放到一起,而不是用幾個(gè)零散的變量來(lái)表示它。

            全局函數(shù)與重載

            例如我定義了 complex,那么我可以同時(shí)定義 complex sin(const complex& x); 和 complex exp(const complex& x); 等等全局函數(shù)來(lái)實(shí)現(xiàn)復(fù)數(shù)的三角函數(shù)和指數(shù)運(yùn)算。sin 和 exp 不是 complex 的成員,而是全局函數(shù) double sin(double) 和 double exp(double) 的重載。這樣能讓 double a = sin(b); 和 complex a = sin(b); 具有相同的代碼形式,而不必寫(xiě)成 complex a = b.sin();。

            C 語(yǔ)言可以定義全局函數(shù),但是不能與已有的函數(shù)重名,也就沒(méi)有重載。Java 沒(méi)有全局函數(shù),而且 Math class 是封閉的,并不能往其中添加 sin(Complex)。

            成員函數(shù)與 private 數(shù)據(jù)

            數(shù)據(jù)也可以聲明為 private,防止外界意外修改。不是每個(gè) ADT 都適合把數(shù)據(jù)聲明為 private,例如 complex、point、pair<> 這樣的 ADT 使用 public data 更加合理。

            要能夠在 struct 里定義操作,而不是只能用全局函數(shù)來(lái)操作 struct。比方說(shuō) vector 有 push_back() 操作,push_back 是 vector 的一部分,它必須直接修改 vector 的 private data members,因此無(wú)法定義為全局函數(shù)。

            這兩點(diǎn)其實(shí)就是定義 class,現(xiàn)在的語(yǔ)言都能直接支持,C 語(yǔ)言除外。

            拷貝控制(copy control)

            copy control 是拷貝 stack a; stack b = a; 和賦值 stack b; b = a; 的合稱(chēng)。

            當(dāng)拷貝一個(gè) ADT 時(shí)會(huì)發(fā)生什么?比方說(shuō)拷貝一個(gè) stack,是不是應(yīng)該把它的每個(gè)元素按值拷貝到新 stack?

            如果語(yǔ)言支持顯示控制對(duì)象的生命期(比方說(shuō)C++的確定性析構(gòu)),而 ADT 用到了動(dòng)態(tài)分配的內(nèi)存,那么 copy control 更為重要,不然如何防止訪問(wèn)已經(jīng)失效的對(duì)象?

            由于 C++ class 是值語(yǔ)義,copy control 是實(shí)現(xiàn)深拷貝的必要手段。而且 ADT 用到的資源只涉及動(dòng)態(tài)分配的內(nèi)存,所以深拷貝是可行的。相反,object-based 編程風(fēng)格中的 class 往往代表某樣真實(shí)的事物(Employee、Account、File 等等),深拷貝無(wú)意義。

            C 語(yǔ)言沒(méi)有 copy control,也沒(méi)有辦法防止拷貝,一切要靠程序員自己小心在意。FILE* 可以隨意拷貝,但是只要關(guān)閉其中一個(gè) copy,其他 copies 也都失效了,跟空懸指針一般。整個(gè) C 語(yǔ)言對(duì)待資源(malloc 得到的內(nèi)存,open() 打開(kāi)的文件,socket() 打開(kāi)的連接)都是這樣,用整數(shù)或指針來(lái)代表(即“句柄”)。而整數(shù)和指針類(lèi)型的“句柄”是可以隨意拷貝的,很容易就造成重復(fù)釋放、遺漏釋放、使用已經(jīng)釋放的資源等等常見(jiàn)錯(cuò)誤。這方面 C++ 是一個(gè)顯著的進(jìn)步,boost::noncopyable 是 boost 里最值得推廣的庫(kù)。

            操作符重載

            如果要寫(xiě)動(dòng)態(tài)數(shù)組,我們希望能像使用內(nèi)置數(shù)組一樣使用它,比如支持下標(biāo)操作。C++可以重載 operator[] 來(lái)做到這一點(diǎn)。

            如果要寫(xiě)復(fù)數(shù),我們系統(tǒng)能像使用內(nèi)置的 double 一樣使用它,比如支持加減乘除。C++ 可以重載 operator+ 等操作符來(lái)做到這一點(diǎn)。

            如果要寫(xiě)日期時(shí)間,我們希望它能直接用大于小于號(hào)來(lái)比較先后,用 == 來(lái)判斷是否相等。C++ 可以重載 operator< 等操作符來(lái)做到這一點(diǎn)。

            這要求語(yǔ)言能重載成員與全局操作符。操作符重載是 C++ 與生俱來(lái)的特性,1984 年的 CFront E 就支持操作符重載,并且提供了一個(gè) complex class,這個(gè) class 與目前標(biāo)準(zhǔn)庫(kù)的 complex<> 在使用上無(wú)區(qū)別。

            如果沒(méi)有操作符重載,那么用戶定義的ADT與內(nèi)置類(lèi)型用起來(lái)就不一樣(想想有的語(yǔ)言要區(qū)分 == 和 equals,代碼寫(xiě)起來(lái)實(shí)在很累贅)。Java 里有 BigInteger,但是 BigInteger 用起來(lái)和普通 int/long 大不相同:

                public static BigInteger mean(BigInteger x, BigInteger y) {
                    BigInteger two = BigInteger.valueOf(2);
                    return x.add(y).divide(two);
                }
            
                public static long mean(long x, long y) {
                    return (x + y) / 2;
                }

            當(dāng)然,操作符重載容易被濫用,因?yàn)檫@樣顯得很酷。我認(rèn)為只在 ADT 表示一個(gè)“數(shù)值”的時(shí)候才適合重載加減乘除,其他情況下用具名函數(shù)為好,因此 muduo::Timestamp 只重載了關(guān)系操作符,沒(méi)有重載加減操作符。另外一個(gè)理由見(jiàn)《C++ 工程實(shí)踐(3):采用有利于版本管理的代碼格式》。

            效率無(wú)損

            “抽象”不代表低效。在 C++ 中,提高抽象的層次并不會(huì)降低效率。不然的話,人們寧可在低層次上編程,而不愿使用更便利的抽象,數(shù)據(jù)抽象也就失去了市場(chǎng)。后面我們將看到一個(gè)具體的例子。

            模板與泛型

            如果我寫(xiě)了一個(gè) int vector,那么我不想為 doule 和 string 再實(shí)現(xiàn)一遍同樣的代碼。我應(yīng)該把 vector 寫(xiě)成 template,然后用不同的類(lèi)型來(lái)具現(xiàn)化它,從而得到 vector<int>、vector<double>、vector<complex>、vector<string> 等等具體類(lèi)型。

            不是每個(gè) ADT 都需要這種泛型能力,一個(gè) Date class 就沒(méi)必要讓用戶指定該用哪種類(lèi)型的整數(shù),int32_t 足夠了。

             

            根據(jù)上面的要求,不是每個(gè)面向?qū)ο笳Z(yǔ)言都能原生支持?jǐn)?shù)據(jù)抽象,也說(shuō)明數(shù)據(jù)抽象不是面向?qū)ο蟮淖蛹?/p>

            數(shù)據(jù)抽象的例子

            下面我們看看數(shù)值模擬 N-body 問(wèn)題的兩個(gè)程序,前一個(gè)用 C 語(yǔ)言,后一個(gè)是 C++ 的。這個(gè)例子來(lái)自編程語(yǔ)言的性能對(duì)比網(wǎng)站 http://shootout.alioth.debian.org/gp4/benchmark.php?test=nbody&lang=all

            兩個(gè)程序使用了相同的算法。

            C 語(yǔ)言版,完整代碼見(jiàn) https://gist.github.com/1158889#file_nbody.c,下面是代碼骨干。planet 保存與行星位置、速度、質(zhì)量,位置和速度各有三個(gè)分量,程序模擬幾大行星在三維空間中受引力支配的運(yùn)動(dòng)。

            struct planet
            {
              double x, y, z;
              double vx, vy, vz;
              double mass;
            };
            
            void advance(int nbodies, struct planet *bodies, double dt)
            {
              for (int i = 0; i < nbodies; i++)
              {
                struct planet *p1 = &(bodies[i]);
                for (int j = i + 1; j < nbodies; j++)
                {
                  struct planet *p2 = &(bodies[j]);
                  double dx = p1->x - p2->x;
                  double dy = p1->y - p2->y;
                  double dz = p1->z - p2->z;
                  double distance_squared = dx * dx + dy * dy + dz * dz;
                  double distance = sqrt(distance_squared);
                  double mag = dt / (distance * distance_squared);
                  p1->vx -= dx * p2->mass * mag;
                  p1->vy -= dy * p2->mass * mag;
                  p1->vz -= dz * p2->mass * mag;
                  p2->vx += dx * p1->mass * mag;
                  p2->vy += dy * p1->mass * mag;
                  p2->vz += dz * p1->mass * mag;
                }
              }
              for (int i = 0; i < nbodies; i++)
              {
                struct planet * p = &(bodies[i]);
                p->x += dt * p->vx;
                p->y += dt * p->vy;
                p->z += dt * p->vz;
              }
            }
            

            其中最核心的算法是 advance() 函數(shù)實(shí)現(xiàn)的數(shù)值積分,它根據(jù)各個(gè)星球之間的距離和引力,算出加速度,再修正速度,然后更新星球的位置。這個(gè) naive 算法的復(fù)雜度是 O(N^2)。

            C++ 數(shù)據(jù)抽象版,完整代碼見(jiàn) https://gist.github.com/1158889#file_nbody.cc,下面是代碼骨架。

            首先定義 Vector3 這個(gè)抽象,代表三維向量,它既可以是位置,有可以是速度。本處略去了 Vector3 的操作符重載,Vector3 支持常見(jiàn)的向量加減乘除運(yùn)算。

            然后定義 Planet 這個(gè)抽象,代表一個(gè)行星,它有兩個(gè) Vector3 成員:位置和速度。

            需要說(shuō)明的是,按照語(yǔ)義,Vector3 是數(shù)據(jù)抽象,而 Planet 是 object-based.

            struct Vector3
            {
              Vector3(double x, double y, double z)
                : x(x), y(y), z(z)
              {
              }
            
              double x;
              double y;
              double z;
            };
            
            struct Planet
            {
              Planet(const Vector3& position, const Vector3& velocity, double mass)
                : position(position), velocity(velocity), mass(mass)
              {
              }
            
              Vector3 position;
              Vector3 velocity;
              const double mass;
            };
            

            相同功能的 advance() 代碼簡(jiǎn)短得多,而且更容易驗(yàn)證其正確性。(想想如果把 C 語(yǔ)言版的 advance() 中的 vx、vy、vz、dx、dy、dz 寫(xiě)錯(cuò)位了,這種錯(cuò)誤較難發(fā)現(xiàn)。)

            void advance(int nbodies, Planet* bodies, double delta_time)
            {
              for (Planet* p1 = bodies; p1 != bodies + nbodies; ++p1)
              {
                for (Planet* p2 = p1 + 1; p2 != bodies + nbodies; ++p2)
                {
                  Vector3 difference = p1->position - p2->position;
                  double distance_squared = magnitude_squared(difference);
                  double distance = std::sqrt(distance_squared);
                  double magnitude = delta_time / (distance * distance_squared);
                  p1->velocity -= difference * p2->mass * magnitude;
                  p2->velocity += difference * p1->mass * magnitude;
                }
              }
              for (Planet* p = bodies; p != bodies + nbodies; ++p)
              {
                p->position += delta_time * p->velocity;
              }
            }
            

            性能上,盡管 C++ 使用了更高層的抽象 Vector3,但它的性能和 C 語(yǔ)言一樣快。看看 memory layout 就會(huì)明白:

            C struct 的成員是連續(xù)存儲(chǔ)的,struct 數(shù)組也是連續(xù)的。

            value3

            C++ 盡管定義了了 Vector3 這個(gè)抽象,它的內(nèi)存布局并沒(méi)有改變,Planet 的布局和 C planet 一模一樣,Planet[] 的布局也和 C 數(shù)組一樣。

            另一方面,C++ 的 inline 函數(shù)在這里也起了巨大作用,我們可以放心地調(diào)用 Vector3::operator+=() 等操作符,編譯器會(huì)生成和 C 一樣高效的代碼。

            不是每個(gè)編程語(yǔ)言都能做到在提升抽象的時(shí)候不影響性能,來(lái)看看 Java 的內(nèi)存布局。

            如果我們用 class Vector3、class Planet、Planet[] 的方式寫(xiě)一個(gè) Java 版的 N-body 程序,內(nèi)存布局將會(huì)是:

            value4

            這樣大大降低了 memory locality,有興趣的讀者可以對(duì)比 Java 和 C++ 的實(shí)現(xiàn)效率。

            注:這里的 N-body 算法只為比較語(yǔ)言之間的性能與編程的便利性,真正科研中用到的 N-body 算法會(huì)使用更高級(jí)和底層的優(yōu)化,復(fù)雜度是O(N log N),在大規(guī)模模擬時(shí)其運(yùn)行速度也比本 naive 算法快得多。

            更多的例子

            • Date 與 Timestamp,這兩個(gè) class 的“數(shù)據(jù)”都是整數(shù),各定義了一套操作,用于表達(dá)日期與時(shí)間這兩個(gè)概念。
            • BigInteger,它本身就是一個(gè)“數(shù)”。如果用 C++ 實(shí)現(xiàn) BigInteger,那么階乘函數(shù)寫(xiě)出來(lái)十分自然,下面第二個(gè)函數(shù)是 Java 語(yǔ)言的版本。
            BigInteger factorial(int n)
            {
                BigInteger result(1);
                for (int i = 1; i <= n; ++i) {
                    result *= i;
                }
                return result;
            }
            
            public static BigInteger factorial(int n) {
                BigInteger result = BigInteger.ONE;
                for (int i = 1; i <= n; ++i) {
                    result = result.multiply(BigInteger.valueOf(i));
                }
                return result;
            }

            高精度運(yùn)算庫(kù) gmp 有一套高質(zhì)量的 C++ 封裝 http://gmplib.org/manual/C_002b_002b-Interface-General.html#C_002b_002b-Interface-General

            • 圖形學(xué)中的三維齊次坐標(biāo) Vector4 和對(duì)應(yīng)的 4x4 變換矩陣 Matrix4,例如 http://www.ogre3d.org/docs/api/html/classOgre_1_1Matrix4.html
            • 金融領(lǐng)域中經(jīng)常成對(duì)出現(xiàn)的“買(mǎi)入價(jià)/賣(mài)出價(jià)”,可以封裝為 BidOffer struct,這個(gè) struct 的成員可以有 mid() “中間價(jià)”,spread() “買(mǎi)賣(mài)差價(jià)”,加減操作符,等等。

            小結(jié)

            數(shù)據(jù)抽象是C++的重要抽象手段,適合封裝“數(shù)據(jù)”,它的語(yǔ)義簡(jiǎn)單,容易使用。數(shù)據(jù)抽象能簡(jiǎn)化代碼書(shū)寫(xiě),減少偶然錯(cuò)誤。

            posted @ 2011-08-22 00:19 陳碩 閱讀(2526) | 評(píng)論 (0)編輯 收藏

            C++ 工程實(shí)踐(8):值語(yǔ)義

            陳碩 (giantchen_AT_gmail)
            http://blog.csdn.net/Solstice  http://weibo.com/giantchen
            陳碩關(guān)于 C++ 工程實(shí)踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx
            排版正常的版本: http://www.cnblogs.com/Solstice/category/287661.html
            陳碩博客文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
            本作品采用“Creative Commons 署名-非商業(yè)性使用-禁止演繹 3.0 Unported 許可協(xié)議(cc by-nc-nd)”進(jìn)行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/

            本文是前一篇《C++ 工程實(shí)踐(7):iostream 的用途與局限》的后續(xù),在這篇文章的“iostream 與標(biāo)準(zhǔn)庫(kù)其他組件的交互”一節(jié),我簡(jiǎn)單地提到iostream的對(duì)象和C++標(biāo)準(zhǔn)庫(kù)中的其他對(duì)象(主要是容器和string)具有不同的語(yǔ)義,主要體現(xiàn)在iostream不能拷貝或賦值。今天全面談一談我對(duì)這個(gè)問(wèn)題的理解。

            本文的“對(duì)象”定義較為寬泛,a region of memory that has a type,在這個(gè)定義下,int、double、bool 變量都是對(duì)象。

            什么是值語(yǔ)義

            值語(yǔ)義(value sematics)指的是對(duì)象的拷貝與原對(duì)象無(wú)關(guān),就像拷貝 int 一樣。C++ 的內(nèi)置類(lèi)型(bool/int/double/char)都是值語(yǔ)義,標(biāo)準(zhǔn)庫(kù)里的 complex<> 、pair<>、vector<>、map<>、string 等等類(lèi)型也都是值語(yǔ)意,拷貝之后就與原對(duì)象脫離關(guān)系。Java 語(yǔ)言的 primitive types 也是值語(yǔ)義。

            與值語(yǔ)義對(duì)應(yīng)的是“對(duì)象語(yǔ)義/object sematics”,或者叫做引用語(yǔ)義(reference sematics),由于“引用”一詞在 C++ 里有特殊含義,所以我在本文中使用“對(duì)象語(yǔ)義”這個(gè)術(shù)語(yǔ)。對(duì)象語(yǔ)義指的是面向?qū)ο笠饬x下的對(duì)象,對(duì)象拷貝是禁止的。例如 muduo 里的 Thread 是對(duì)象語(yǔ)義,拷貝 Thread 是無(wú)意義的,也是被禁止的:因?yàn)?Thread 代表線程,拷貝一個(gè) Thread 對(duì)象并不能讓系統(tǒng)增加一個(gè)一模一樣的線程。

            同樣的道理,拷貝一個(gè) Employee 對(duì)象是沒(méi)有意義的,一個(gè)雇員不會(huì)變成兩個(gè)雇員,他也不會(huì)領(lǐng)兩份薪水。拷貝 TcpConnection 對(duì)象也沒(méi)有意義,系統(tǒng)里邊只有一個(gè) TCP 連接,拷貝 TcpConnection  對(duì)象不會(huì)讓我們擁有兩個(gè)連接。Printer 也是不能拷貝的,系統(tǒng)只連接了一個(gè)打印機(jī),拷貝 Printer 并不能憑空增加打印機(jī)。凡此總總,面向?qū)ο笠饬x下的“對(duì)象”是 non-copyable。

            Java 里邊的 class 對(duì)象都是對(duì)象語(yǔ)義/引用語(yǔ)義。ArrayList<Integer> a = new ArrayList<Integer>(); ArrayList<Integer> b = a; 那么 a 和 b 指向的是同一個(gè)ArrayList 對(duì)象,修改 a 同時(shí)也會(huì)影響 b。

            值語(yǔ)義與 immutable 無(wú)關(guān)。Java 有 value object 一說(shuō),按(PoEAA 486)的定義,它實(shí)際上是 immutable object,例如 String、Integer、BigInteger、joda.time.DateTime 等等(因?yàn)?Java 沒(méi)有辦法實(shí)現(xiàn)真正的值語(yǔ)義 class,只好用 immutable object 來(lái)模擬)。盡管 immutable object 有其自身的用處,但不是本文的主題。muduo 中的 Date、Timestamp 也都是 immutable 的。

            C++中的值語(yǔ)義對(duì)象也可以是 mutable,比如 complex<>、pair<>、vector<>、map<>、string 都是可以修改的。muduo 的 InetAddress 和 Buffer 都具有值語(yǔ)義,它們都是可以修改的。

            值語(yǔ)義的對(duì)象不一定是 POD,例如 string 就不是 POD,但它是值語(yǔ)義的。

            值語(yǔ)義的對(duì)象不一定小,例如 vector<int> 的元素可多可少,但它始終是值語(yǔ)義的。當(dāng)然,很多值語(yǔ)義的對(duì)象都是小的,例如complex<>、muduo::Date、muduo::Timestamp。

            值語(yǔ)義與生命期

            值語(yǔ)義的一個(gè)巨大好處是生命期管理很簡(jiǎn)單,就跟 int 一樣——你不需要操心 int 的生命期。值語(yǔ)義的對(duì)象要么是 stack object,或者直接作為其他 object 的成員,因此我們不用擔(dān)心它的生命期(一個(gè)函數(shù)使用自己stack上的對(duì)象,一個(gè)成員函數(shù)使用自己的數(shù)據(jù)成員對(duì)象)。相反,對(duì)象語(yǔ)義的 object 由于不能拷貝,我們只能通過(guò)指針或引用來(lái)使用它。

            一旦使用指針和引用來(lái)操作對(duì)象,那么就要擔(dān)心所指的對(duì)象是否已被釋放,這一度是 C++ 程序 bug 的一大來(lái)源。此外,由于 C++ 只能通過(guò)指針或引用來(lái)獲得多態(tài)性,那么在C++里從事基于繼承和多態(tài)的面向?qū)ο缶幊逃衅浔举|(zhì)的困難——資源管理。

            考慮一個(gè)簡(jiǎn)單的對(duì)象建模——家長(zhǎng)與子女:a Parent has a Child, a Child knows his/her Parent。在 Java 里邊很好寫(xiě),不用擔(dān)心內(nèi)存泄漏,也不用擔(dān)心空懸指針:

            public class Parent
            {
                private Child myChild;
            }
                
            public class Child
            {
                private Parent myParent;
            }

            只要正確初始化 myChild 和 myParent,那么 Java 程序員就不用擔(dān)心出現(xiàn)訪問(wèn)錯(cuò)誤。一個(gè) handle 是否有效,只需要判斷其是否 non null。

            在 C++ 里邊就要為資源管理費(fèi)一番腦筋:Parent 和 Child 都代表的是真人,肯定是不能拷貝的,因此具有對(duì)象語(yǔ)義。Parent 是直接持有 Child 嗎?抑或 Parent 和 Child 通過(guò)指針互指?Child 的生命期由 Parent 控制嗎?如果還有 ParentClub 和 School 兩個(gè) class,分別代表家長(zhǎng)俱樂(lè)部和學(xué)校:ParentClub has many Parent(s),School has many Child(ren),那么如何保證它們始終持有有效的 Parent 對(duì)象和 Child 對(duì)象?何時(shí)才能安全地釋放 Parent 和 Child ?

            直接但是易錯(cuò)的寫(xiě)法:

            class Child;
            
            class Parent : boost::noncopyable
            {
             private:
              Child* myChild;
            };
            
            class Child : boost::noncopyable
            {
             private:
              Parent* myParent;
            };
            

            如果直接使用指針作為成員,那么如何確保指針的有效性?如何防止出現(xiàn)空懸指針?Child 和 Parent 由誰(shuí)負(fù)責(zé)釋放?在釋放某個(gè) Parent 對(duì)象的時(shí)候,如何確保程序中沒(méi)有指向它的指針?在釋放某個(gè) Child 對(duì)象的時(shí)候,如何確保程序中沒(méi)有指向它的指針?

            這一系列問(wèn)題一度是C++面向?qū)ο缶幊填^疼的問(wèn)題,不過(guò)現(xiàn)在有了 smart pointer,我們可以借助 smart pointer 把對(duì)象語(yǔ)義轉(zhuǎn)換為值語(yǔ)義,從而輕松解決對(duì)象生命期:讓 Parent 持有 Child 的 smart pointer,同時(shí)讓 Child 持有 Parent 的 smart pointer,這樣始終引用對(duì)方的時(shí)候就不用擔(dān)心出現(xiàn)空懸指針。當(dāng)然,其中一個(gè) smart pointer 應(yīng)該是 weak reference,否則會(huì)出現(xiàn)循環(huán)引用,導(dǎo)致內(nèi)存泄漏。到底哪一個(gè)是 weak reference,則取決于具體應(yīng)用場(chǎng)景。

            如果 Parent 擁有 Child,Child 的生命期由其 Parent 控制,Child 的生命期小于 Parent,那么代碼就比較簡(jiǎn)單:

            class Parent;
            class Child : boost::noncopyable
            {
             public:
              explicit Child(Parent* myParent_)
                : myParent(myParent_)
              {
              }
            
             private:
              Parent* myParent;
            };
            
            class Parent : boost::noncopyable
            {
             public:
              Parent()
                : myChild(new Child(this))
              {
              }
            
             private:
              boost::scoped_ptr<Child> myChild;
            };

            在上面這個(gè)設(shè)計(jì)中,Child 的指針不能泄露給外界,否則仍然有可能出現(xiàn)空懸指針。

            如果 Parent 與 Child 的生命期相互獨(dú)立,就要麻煩一些:

            class Parent;
            typedef boost::shared_ptr<Parent> ParentPtr;
            
            class Child : boost::noncopyable
            {
             public:
              explicit Child(const ParentPtr& myParent_)
                : myParent(myParent_)
              {
              }
            
             private:
              boost::weak_ptr<Parent> myParent;
            };
            typedef boost::shared_ptr<Child> ChildPtr;
            
            
            class Parent : public boost::enable_shared_from_this<Parent>,
                           private boost::noncopyable
            {
             public:
              Parent()
              {
              }
            
              void addChild()
              {
                myChild.reset(new Child(shared_from_this()));
              }
            
             private:
              ChildPtr myChild;
            };
            
            int main()
            {
              ParentPtr p(new Parent);
              p->addChild();
            }
            

            上面這個(gè) shared_ptr+weak_ptr 的做法似乎有點(diǎn)小題大做。

            考慮一個(gè)稍微復(fù)雜一點(diǎn)的對(duì)象模型:a Child has parents: mom and dad; a Parent has one or more Child(ren); a Parent knows his/her spouser. 這個(gè)對(duì)象模型用 Java 表述一點(diǎn)都不復(fù)雜,垃圾收集會(huì)幫我們搞定對(duì)象生命期。

            public class Parent
            {
                private Parent mySpouser;
                private ArrayList<Child> myChildren;
            }
            
            public class Child
            {
                private Parent myMom;
                private Parent myDad;
            }

            如果用 C++ 來(lái)實(shí)現(xiàn),如何才能避免出現(xiàn)空懸指針,同時(shí)避免出現(xiàn)內(nèi)存泄漏呢?借助 shared_ptr 把裸指針轉(zhuǎn)換為值語(yǔ)義,我們就不用擔(dān)心這兩個(gè)問(wèn)題了:

            class Parent;
            typedef boost::shared_ptr<Parent> ParentPtr;
            
            class Child : boost::noncopyable
            {
             public:
              explicit Child(const ParentPtr& myMom_,
                             const ParentPtr& myDad_)
                : myMom(myMom_),
                  myDad(myDad_)
              {
              }
            
             private:
              boost::weak_ptr<Parent> myMom;
              boost::weak_ptr<Parent> myDad;
            };
            typedef boost::shared_ptr<Child> ChildPtr;
            
            class Parent : boost::noncopyable
            {
             public:
              Parent()
              {
              }
            
              void setSpouser(const ParentPtr& spouser)
              {
                mySpouser = spouser;
              }
            
              void addChild(const ChildPtr& child)
              {
                myChildren.push_back(child);
              }
            
             private:
              boost::weak_ptr<Parent> mySpouser;
              std::vector<ChildPtr> myChildren;
            };
            
            int main()
            {
              ParentPtr mom(new Parent);
              ParentPtr dad(new Parent);
              mom->setSpouser(dad);
              dad->setSpouser(mom);
              {
                ChildPtr child(new Child(mom, dad));
                mom->addChild(child);
                dad->addChild(child);
              }
              {
                ChildPtr child(new Child(mom, dad));
                mom->addChild(child);
                dad->addChild(child);
              }
            }
            

            如果不使用 smart pointer,用 C++ 做面向?qū)ο缶幊虒?huì)困難重重。

            值語(yǔ)義與標(biāo)準(zhǔn)庫(kù)

            C++ 要求凡是能放入標(biāo)準(zhǔn)容器的類(lèi)型必須具有值語(yǔ)義。準(zhǔn)確地說(shuō):type 必須是 SGIAssignable concept 的 model。但是,由 于C++ 編譯器會(huì)為 class 默認(rèn)提供 copy constructor 和 assignment operator,因此除非明確禁止,否則 class 總是可以作為標(biāo)準(zhǔn)庫(kù)的元素類(lèi)型——盡管程序可以編譯通過(guò),但是隱藏了資源管理方面的 bug。

            因此,在寫(xiě)一個(gè) class 的時(shí)候,先讓它繼承 boost::noncopyable,幾乎總是正確的。

            在現(xiàn)代 C++ 中,一般不需要自己編寫(xiě) copy constructor 或 assignment operator,因?yàn)橹灰總€(gè)數(shù)據(jù)成員都具有值語(yǔ)義的話,編譯器自動(dòng)生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 為成員來(lái)持有其他對(duì)象,那么就能自動(dòng)啟用或禁用 copying&assigning。例外:編寫(xiě) HashMap 這類(lèi)底層庫(kù)時(shí)還是需要自己實(shí)現(xiàn) copy control。

            值語(yǔ)義與C++語(yǔ)言

            C++ 的 class 本質(zhì)上是值語(yǔ)義的,這才會(huì)出現(xiàn) object slicing 這種語(yǔ)言獨(dú)有的問(wèn)題,也才會(huì)需要程序員注意 pass-by-value 和 pass-by-const-reference 的取舍。在其他面向?qū)ο缶幊陶Z(yǔ)言中,這都不需要費(fèi)腦筋。

            值語(yǔ)義是C++語(yǔ)言的三大約束之一,C++ 的設(shè)計(jì)初衷是讓用戶定義的類(lèi)型(class)能像內(nèi)置類(lèi)型(int)一樣工作,具有同等的地位。為此C++做了以下設(shè)計(jì)(妥協(xié)):

            • class 的 layout 與 C struct 一樣,沒(méi)有額外的開(kāi)銷(xiāo)。定義一個(gè)“只包含一個(gè) int 成員的 class ”的對(duì)象開(kāi)銷(xiāo)和定義一個(gè) int 一樣。
            • 甚至 class data member 都默認(rèn)是 uninitialized,因?yàn)楹瘮?shù)局部的 int 是 uninitialized。
            • class 可以在 stack 上創(chuàng)建,也可以在 heap 上創(chuàng)建。因?yàn)?int 可以是 stack variable。
            • class 的數(shù)組就是一個(gè)個(gè) class 對(duì)象挨著,沒(méi)有額外的 indirection。因?yàn)?int 數(shù)組就是這樣。
            • 編譯器會(huì)為 class 默認(rèn)生成 copy constructor 和 assignment operator。其他語(yǔ)言沒(méi)有 copy constructor 一說(shuō),也不允許重載 assignment operator。C++ 的對(duì)象默認(rèn)是可以拷貝的,這是一個(gè)尷尬的特性。
            • 當(dāng) class type 傳入函數(shù)時(shí),默認(rèn)是 make a copy (除非參數(shù)聲明為 reference)。因?yàn)榘?int 傳入函數(shù)時(shí)是 make a copy。
            • 當(dāng)函數(shù)返回一個(gè) class type 時(shí),只能通過(guò) make a copy(C++ 不得不定義 RVO 來(lái)解決性能問(wèn)題)。因?yàn)楹瘮?shù)返回 int 時(shí)是 make a copy。
            • 以 class type 為成員時(shí),數(shù)據(jù)成員是嵌入的。例如 pair<complex<double>, size_t> 的 layout 就是 complex<double> 挨著 size_t。

            這些設(shè)計(jì)帶來(lái)了性能上的好處,原因是 memory locality。比方說(shuō)我們?cè)?C++ 里定義 complex<double> class,array of complex<double>, vector<complex<double> >,它們的 layout 分別是:(re 和 im 分別是復(fù)數(shù)的實(shí)部和虛部。)

            value1

            而如果我們?cè)?Java 里干同樣的事情,layout 大不一樣,memory locality 也差很多:

            value2

            Java 里邊每個(gè) object 都有 header,至少有兩個(gè) word 的開(kāi)銷(xiāo)。對(duì)比 Java 和 C++,可見(jiàn) C++ 的對(duì)象模型要緊湊得多。

            待續(xù)

            下一篇文章我會(huì)談與值語(yǔ)義緊密相關(guān)的數(shù)據(jù)抽象(data abstraction),解釋為什么它是與面向?qū)ο蟛⒘械囊环N編程范式,為什么支持面向?qū)ο蟮木幊陶Z(yǔ)言不一定支持?jǐn)?shù)據(jù)抽象。C++在最初的時(shí)候是以 data abstraction 為賣(mài)點(diǎn),不過(guò)隨著時(shí)間的流逝,現(xiàn)在似乎很多人只知 Object-Oriented,不知 data abstraction 了。C++ 的強(qiáng)大之處在于“抽象”不以性能損失為代價(jià),下一篇文章我們將看到具體例子。

            posted @ 2011-08-16 21:13 陳碩 閱讀(2807) | 評(píng)論 (4)編輯 收藏

            C++ 工程實(shí)踐(7):iostream 的用途與局限

            陳碩 (giantchen_AT_gmail)

            http://blog.csdn.net/Solstice  http://weibo.com/giantchen

            陳碩關(guān)于 C++ 工程實(shí)踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx

            陳碩博客文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx

            本作品采用“Creative Commons 署名-非商業(yè)性使用-禁止演繹 3.0 Unported 許可協(xié)議(cc by-nc-nd)”進(jìn)行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/

            本文主要考慮 x86 Linux 平臺(tái),不考慮跨平臺(tái)的可移植性,也不考慮國(guó)際化(i18n),但是要考慮 32-bit 和 64-bit 的兼容性。本文以 stdio 指代 C 語(yǔ)言的 scanf/printf 系列格式化輸入輸出函數(shù)。本文注意區(qū)分“編程初學(xué)者”和“C++初學(xué)者”,二者含義不同。

            摘要:C++ iostream 的主要作用是讓初學(xué)者有一個(gè)方便的命令行輸入輸出試驗(yàn)環(huán)境,在真實(shí)的項(xiàng)目中很少用到 iostream,因此不必把精力花在深究 iostream 的格式化與 manipulator。iostream 的設(shè)計(jì)初衷是提供一個(gè)可擴(kuò)展的類(lèi)型安全的 IO 機(jī)制,但是后來(lái)莫名其妙地加入了 locale 和 facet 等累贅。其整個(gè)設(shè)計(jì)復(fù)雜不堪,多重+虛擬繼承的結(jié)構(gòu)也很巴洛克,性能方面幾無(wú)亮點(diǎn)。iostream 在實(shí)際項(xiàng)目中的用處非常有限,為此投入過(guò)多學(xué)習(xí)精力實(shí)在不值。

            stdio 格式化輸入輸出的缺點(diǎn)

            1. 對(duì)編程初學(xué)者不友好

            看看下面這段簡(jiǎn)單的輸入輸出代碼。

            #include <stdio.h>
            
            int main()
            {
              int i;
              short s;
              float f;
              double d;
              char name[80];
            
              scanf("%d %hd %f %lf %s", &i, &s, &f, &d, name);
              printf("%d %d %f %f %s", i, s, f, d, name);
            }
            

            注意到其中

            • 輸入和輸出用的格式字符串不一樣。輸入 short 要用 %hd,輸出用 %d;輸入 double 要用 %lf,輸出用 %f。
            • 輸入的參數(shù)不統(tǒng)一。對(duì)于 i、s、f、d 等變量,在傳入 scanf() 的時(shí)候要取地址(&),而對(duì)于 name,則不用取地址。

            讀者可以試一試如何用幾句話向剛開(kāi)始學(xué)編程的初學(xué)者解釋上面兩條背后原因(涉及到傳遞函數(shù)不定參數(shù)時(shí)的類(lèi)型轉(zhuǎn)換,函數(shù)調(diào)用棧的內(nèi)存布局,指針的意義,字符數(shù)組退化為字符指針等等),如果一開(kāi)始解釋不清,只好告訴學(xué)生“這是規(guī)定”。

            • 緩沖區(qū)溢出的危險(xiǎn)。上面的例子在讀入 name 的時(shí)候沒(méi)有指定大小,這是用 C 語(yǔ)言編程的安全漏洞的主要來(lái)源。應(yīng)該在一開(kāi)始就強(qiáng)調(diào)正確的做法,避免養(yǎng)成錯(cuò)誤的習(xí)慣。正確而安全的做法如 Bjarne Stroustrup 在《Learning Standard C++ as a New Language》所示:
            #include <stdio.h>
            
            int main()
            {
              const int max = 80;
              char name[max];
            
              char fmt[10];
              sprintf(fmt, "%%%ds", max - 1);
              scanf(fmt, name);
              printf("%s\n", name);
            }

            這個(gè)動(dòng)態(tài)構(gòu)造格式化字符串的做法恐怕更難向初學(xué)者解釋。

            2. 安全性(security)

            C 語(yǔ)言的安全性問(wèn)題近十幾年來(lái)引起了廣泛的注意,C99 增加了 snprintf() 等能夠指定輸出緩沖區(qū)大小的函數(shù),輸出方面的安全性問(wèn)題已經(jīng)得到解決;輸入方面似乎沒(méi)有太大進(jìn)展,還要靠程序員自己動(dòng)手。

            考慮一個(gè)簡(jiǎn)單的編程任務(wù):從文件或標(biāo)準(zhǔn)輸入讀入一行字符串,行的長(zhǎng)度不確定。我發(fā)現(xiàn)沒(méi)有哪個(gè) C 語(yǔ)言標(biāo)準(zhǔn)庫(kù)函數(shù)能完成這個(gè)任務(wù),除非 roll your own。

            首先,gets() 是錯(cuò)誤的,因?yàn)椴荒苤付ň彌_區(qū)的長(zhǎng)度。

            其次,fgets() 也有問(wèn)題。它能指定緩沖區(qū)的長(zhǎng)度,所以是安全的。但是程序必須預(yù)設(shè)一個(gè)長(zhǎng)度的最大值,這不滿足題目要求“行的長(zhǎng)度不確定”。另外,程序無(wú)法判斷 fgets() 到底讀了多少個(gè)字節(jié)。為什么?考慮一個(gè)文件的內(nèi)容是 9 個(gè)字節(jié)的字符串 "Chen\000Shuo",注意中間出現(xiàn)了 '\0' 字符,如果用 fgets() 來(lái)讀取,客戶端如何知道 "\000Shuo" 也是輸入的一部分?畢竟 strlen() 只返回 4,而且整個(gè)字符串里沒(méi)有 '\n' 字符。

            最后,可以用 glibc 定義的 getline(3) 函數(shù)來(lái)讀取不定長(zhǎng)的“行”。這個(gè)函數(shù)能正確處理各種情況,不過(guò)它返回的是 malloc() 分配的內(nèi)存,要求調(diào)用端自己 free()。

            3. 類(lèi)型安全(type-safe)

            如果 printf() 的整數(shù)參數(shù)類(lèi)型是 int、long 等標(biāo)準(zhǔn)類(lèi)型, 那么 printf() 的格式化字符串很容易寫(xiě)。但是如果參數(shù)類(lèi)型是 typedef 的類(lèi)型呢?

            如果你想在程序中用 printf 來(lái)打印日志,你能一眼看出下面這些類(lèi)型該用 "%d" "%ld" "%lld" 中的哪一個(gè)來(lái)輸出?你的選擇是否同時(shí)兼容 32-bit 和 64-bit 平臺(tái)?

            • clock_t。這是 clock(3) 的返回類(lèi)型
            • dev_t。這是 mknod(3) 的參數(shù)類(lèi)型
            • in_addr_t、in_port_t。這是 struct sockaddr_in 的成員類(lèi)型
            • nfds_t。這是 poll(2) 的參數(shù)類(lèi)型
            • off_t。這是 lseek(2) 的參數(shù)類(lèi)型,麻煩的是,這個(gè)類(lèi)型與宏定義 _FILE_OFFSET_BITS 有關(guān)。
            • pid_t、uid_t、gid_t。這是 getpid(2) getuid(2) getgid(2) 的返回類(lèi)型
            • ptrdiff_t。printf() 專(zhuān)門(mén)定義了 "t" 前綴來(lái)支持這一類(lèi)型(即使用 "%td" 來(lái)打印)。
            • size_t、ssize_t。這兩個(gè)類(lèi)型到處都在用。printf() 為此專(zhuān)門(mén)定義了 "z" 前綴來(lái)支持這兩個(gè)類(lèi)型(即使用 "%zu" 或 "%zd" 來(lái)打印)。
            • socklen_t。這是 bind(2) 和 connect(2) 的參數(shù)類(lèi)型
            • time_t。這是 time(2) 的返回類(lèi)型,也是 gettimeofday(2) 和 clock_gettime(2) 的輸出結(jié)構(gòu)體的成員類(lèi)型

            如果在 C 程序里要正確打印以上類(lèi)型的整數(shù),恐怕要費(fèi)一番腦筋。《The Linux Programming Interface》的作者建議(3.6.2節(jié))先統(tǒng)一轉(zhuǎn)換為 long 類(lèi)型再用 "%ld" 來(lái)打印;對(duì)于某些類(lèi)型仍然需要特殊處理,比如 off_t 的類(lèi)型可能是 long long。

            還有,int64_t 在 32-bit 和 64-bit 平臺(tái)上是不同的類(lèi)型,為此,如果程序要打印 int64_t 變量,需要包含 <inttypes.h> 頭文件,并且使用 PRId64 宏:

            #include <stdio.h>
            #define __STDC_FORMAT_MACROS
            #include <inttypes.h>
            
            int main()
            {
              int64_t x = 100;
              printf("%" PRId64 "\n", x);
              printf("%06" PRId64 "\n", x);
            }
            

            muduo 的 Timestamp 使用了 PRId64 http://code.google.com/p/muduo/source/browse/trunk/muduo/base/Timestamp.cc#25

            Google C++ 編碼規(guī)范也提到了 64-bit 兼容性: http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#64-bit_Portability

            這些問(wèn)題在 C++ 里都不存在,在這方面 iostream 是個(gè)進(jìn)步。

            C stdio 在類(lèi)型安全方面原本還有一個(gè)缺點(diǎn),即格式化字符串與參數(shù)類(lèi)型不匹配會(huì)造成難以發(fā)現(xiàn)的 bug,不過(guò)現(xiàn)在的編譯器已經(jīng)能夠檢測(cè)很多這種錯(cuò)誤:

            int main()
            {
              double d = 100.0;
              // warning: format '%d' expects type 'int', but argument 2 has type 'double'
              printf("%d\n", d);
            
              short s;
              // warning: format '%d' expects type 'int*', but argument 2 has type 'short int*'
              scanf("%d", &s);
            
              size_t sz = 1;
              // no warning
              printf("%zd\n", sz);
            }
            

            4. 不可擴(kuò)展?

            C stdio 的另外一個(gè)缺點(diǎn)是無(wú)法支持自定義的類(lèi)型,比如我寫(xiě)了一個(gè) Date class,我無(wú)法像打印 int 那樣用 printf 來(lái)直接打印 Date 對(duì)象。

            struct Date
            {
              int year, month, day;
            };
            
            Date date;
            printf("%D", &date);  // WRONG
            

            Glibc 放寬了這個(gè)限制,允許用戶調(diào)用 register_printf_function(3) 注冊(cè)自己的類(lèi)型,當(dāng)然,前提是與現(xiàn)有的格式字符不沖突(這其實(shí)大大限制了這個(gè)功能的用處,現(xiàn)實(shí)中也幾乎沒(méi)有人真的去用它)。http://www.gnu.org/s/hello/manual/libc/Printf-Extension-Example.html  http://en.wikipedia.org/wiki/Printf#Custom_format_placeholders

            5. 性能

            C stdio 的性能方面有兩個(gè)弱點(diǎn)。

            1. 使用一種 little language (現(xiàn)在流行叫 DSL)來(lái)配置格式。固然有利于緊湊性和靈活性,但損失了一點(diǎn)點(diǎn)效率。每次打印一個(gè)整數(shù)都要先解析 "%d" 字符串,大多數(shù)情況下不是問(wèn)題,某些場(chǎng)合需要自己寫(xiě)整數(shù)到字符串的轉(zhuǎn)換。
            2. C locale 的負(fù)擔(dān)。locale 指的是不同語(yǔ)種對(duì)“什么是空白”、“什么是字母”,“什么是小數(shù)點(diǎn)”有不同的定義(德語(yǔ)里邊小數(shù)點(diǎn)是逗號(hào),不是句點(diǎn))。C 語(yǔ)言的 printf()、scanf()、isspace()、isalpha()、ispunct()、strtod() 等等函數(shù)都和 locale 有關(guān),而且可以在運(yùn)行時(shí)動(dòng)態(tài)更改。就算是程序只使用默認(rèn)的 "C" locale,任然要為這個(gè)靈活性付出代價(jià)。

            iostream 的設(shè)計(jì)初衷

            iostream 的設(shè)計(jì)初衷包括克服 C stdio 的缺點(diǎn),提供一個(gè)高效的可擴(kuò)展的類(lèi)型安全的 IO 機(jī)制。“可擴(kuò)展”有兩層意思,一是可以擴(kuò)展到用戶自定義類(lèi)型,而是通過(guò)繼承 iostream 來(lái)定義自己的 stream,本文把前一種稱(chēng)為“類(lèi)型可擴(kuò)展”后一種稱(chēng)為“功能可擴(kuò)展”。

            “類(lèi)型可擴(kuò)展”和“類(lèi)型安全”都是通過(guò)函數(shù)重載來(lái)實(shí)現(xiàn)的。

            iostream 對(duì)初學(xué)者很友好,用 iostream 重寫(xiě)與前面同樣功能的代碼:

            #include <iostream>
            #include <string>
            using namespace std;
            
            int main()
            {
              int i;
              short s;
              float f;
              double d;
              string name;
            
              cin >> i >> s >> f >> d >> name;
              cout << i << " " << s << " " << f << " " << d << " " << name << endl;
            }
            

            這段代碼恐怕比 scanf/printf 版本容易解釋得多,而且沒(méi)有安全性(security)方面的問(wèn)題。

            我們自己的類(lèi)型也可以融入 iostream,使用起來(lái)與 built-in 類(lèi)型沒(méi)有區(qū)別。這主要得力于 C++ 可以定義 non-member functions/operators。

            #include <ostream>  // 是不是太重量級(jí)了?
            
            class Date
            {
             public:
              Date(int year, int month, int day)
                : year_(year), month_(month), day_(day)
              {
              }
            
              void writeTo(std::ostream& os) const
              {
                os << year_ << '-' << month_ << '-' << day_;
              }
            
             private:
              int year_, month_, day_;
            };
            
            std::ostream& operator<<(std::ostream& os, const Date& date)
            {
              date.writeTo(os);
              return os;
            }
            
            int main()
            {
              Date date(2011, 4, 3);
              std::cout << date << std::endl;
              // 輸出 2011-4-3
            }
            

            iostream 憑借這兩點(diǎn)(類(lèi)型安全和類(lèi)型可擴(kuò)展),基本克服了 stdio 在使用上的不便與不安全。如果 iostream 止步于此,那它將是一個(gè)非常便利的庫(kù),可惜它前進(jìn)了另外一步。

            iostream 與標(biāo)準(zhǔn)庫(kù)其他組件的交互

            不同于標(biāo)準(zhǔn)庫(kù)其他 class 的“值語(yǔ)意”,iostream 是“對(duì)象語(yǔ)意”,即 iostream 是 non-copyable。這是正確的,因?yàn)槿绻?fstream 代表一個(gè)文件的話,拷貝一個(gè) fstream 對(duì)象意味著什么呢?表示打開(kāi)了兩個(gè)文件嗎?如果銷(xiāo)毀一個(gè) fstream 對(duì)象,它會(huì)關(guān)閉文件句柄,那么另一個(gè) fstream copy 對(duì)象會(huì)因此受影響嗎?

            C++ 同時(shí)支持“數(shù)據(jù)抽象”和“面向?qū)ο缶幊?#8221;,其實(shí)主要就是“值語(yǔ)意”與“對(duì)象語(yǔ)意”的區(qū)別,我發(fā)現(xiàn)不是每個(gè)人都清楚這一點(diǎn),這里多說(shuō)幾句。標(biāo)準(zhǔn)庫(kù)里的 complex<> 、pair<>、vector<>、 string 等等都是值語(yǔ)意,拷貝之后就與原對(duì)象脫離關(guān)系,就跟拷貝一個(gè) int 一樣。而我們自己寫(xiě)的 Employee class、TcpConnection class 通常是對(duì)象語(yǔ)意,拷貝一個(gè) Employee 對(duì)象是沒(méi)有意義的,一個(gè)雇員不會(huì)變成兩個(gè)雇員,他也不會(huì)領(lǐng)兩份薪水。拷貝 TcpConnection 對(duì)象也沒(méi)有意義,系統(tǒng)里邊只有一個(gè) TCP 連接,拷貝 TcpConnection  對(duì)象不會(huì)讓我們擁有兩個(gè)連接。因此如果在 C++ 里做面向?qū)ο缶幊蹋瑢?xiě)的 class 通常應(yīng)該禁用 copy constructor 和 assignment operator,比如可以繼承 boost::noncopyable。對(duì)象語(yǔ)意的類(lèi)型不能直接作為標(biāo)準(zhǔn)容器庫(kù)的成員。另一方面,如果要寫(xiě)一個(gè)圖形程序,其中用到三維空間的向量,那么我們可以寫(xiě) Vector3D class,它應(yīng)該是值語(yǔ)意的,允許拷貝,并且可以用作標(biāo)準(zhǔn)容器庫(kù)的成員,例如 vector<Vector3D> 表示一條三維的折線。

            C stdio 的另外一個(gè)缺點(diǎn)是 FILE* 可以隨意拷貝,但是只要關(guān)閉其中一個(gè) copy,其他 copies 也都失效了,跟空懸指針一般。這其實(shí)不光是 C stdio 的缺點(diǎn),整個(gè) C 語(yǔ)言對(duì)待資源(malloc 得到的內(nèi)存,open() 打開(kāi)的文件,socket() 打開(kāi)的連接)都是這樣,用整數(shù)或指針來(lái)代表(即“句柄”)。而整數(shù)和指針類(lèi)型的“句柄”是可以隨意拷貝的,很容易就造成重復(fù)釋放、遺漏釋放、使用已經(jīng)釋放的資源等等常見(jiàn)錯(cuò)誤。這是因?yàn)?C 語(yǔ)言錯(cuò)誤地讓“對(duì)象語(yǔ)言”的東西變成了值語(yǔ)意。

            iostream 禁止拷貝,利用對(duì)象的生命期來(lái)明確管理資源(如文件),很自然地就避免了 C 語(yǔ)言易犯的錯(cuò)誤。這就是 RAII,一種重要且獨(dú)特的 C++ 編程手法。

            std::string

            iostream 可以與 string 配合得很好。但是有一個(gè)問(wèn)題:誰(shuí)依賴(lài)誰(shuí)?

            std::string 的 operator << 和 operator >> 是如何聲明的?"string" 頭文件在聲明這兩個(gè) operators 的時(shí)候要不要 include "iostream" ?

            iostream 和 string 都可以單獨(dú) include 來(lái)使用,顯然 iostream 頭文件里不會(huì)定義 string 的 << 和 >> 操作。但是,如果"string"要include "iostream",豈不是讓 string 的用戶被迫也用了 iostream?編譯 iostream 頭文件可是相當(dāng)?shù)穆。ㄒ驗(yàn)?iostream 是 template,其實(shí)現(xiàn)代碼都放到了頭文件中)。

            標(biāo)準(zhǔn)庫(kù)的解決辦法是定義 iosfwd 頭文件,其中包含 istream 和 ostream 等的前向聲明 (forward declarations),這樣 "string" 頭文件在定義輸入輸出操作符時(shí)就可以不必包含 "iostream",只需要包含簡(jiǎn)短得多的 "iosfwd"。我們自己寫(xiě)程序也可借此學(xué)習(xí)如何支持可選的功能。

            值得注意的是,istream::getline() 成員函數(shù)的參數(shù)類(lèi)型是 char*,因?yàn)?"istream" 沒(méi)有包含 "string",而我們常用的 std::getline() 函數(shù)是個(gè) non-member function,定義在 "string" 里邊。

            std::complex

            標(biāo)準(zhǔn)庫(kù)的復(fù)數(shù)類(lèi) complex 的情況比較復(fù)雜。使用 complex 會(huì)自動(dòng)包含 sstream,后者會(huì)包含 istream 和 ostream,這是個(gè)不小的負(fù)擔(dān)。問(wèn)題是,為什么?

            它的 operator >> 操作比 string 復(fù)雜得多,如何應(yīng)對(duì)格式不正確的情況?輸入字符串不會(huì)遇到格式不正確,但是輸入一個(gè)復(fù)數(shù)可能遇到各種問(wèn)題,比如數(shù)字的格式不對(duì)等。我懷疑有誰(shuí)會(huì)真的在產(chǎn)品項(xiàng)目里用 operator >> 來(lái)讀入字符方式表示的復(fù)數(shù),這樣的代碼的健壯性如何保證。基于同樣的理由,我認(rèn)為產(chǎn)品代碼中應(yīng)該避免用 istream 來(lái)讀取帶格式的內(nèi)容,后面也不再談 istream 的缺點(diǎn),它已經(jīng)被秒殺。

            它的 operator << 也很奇怪,它不是直接使用參數(shù) ostream& os 對(duì)象來(lái)輸出,而是先構(gòu)造 ostringstream,輸出到該 string stream,再把結(jié)果字符串輸出到 ostream。簡(jiǎn)化后的代碼如下:

            template<typename T>
            std::ostream& operator<<(std::ostream& os, const std::complex<T>& x)
            {
              std::ostringstream s;
              s << '(' << x.real() << ',' << x.imag() << ')';
              return os << s.str();
            }
            

            注意到 ostringstream 會(huì)用到動(dòng)態(tài)分配內(nèi)存,也就是說(shuō),每輸出一個(gè) complex 對(duì)象就會(huì)分配釋放一次內(nèi)存,效率堪憂。

            根據(jù)以上分析,我認(rèn)為 iostream 和 complex 配合得不好,但是它們耦合得更緊密(與 string/iostream 相比),這可能是個(gè)不得已的技術(shù)限制吧(complex 是 template,其 operator<< 必須在頭文件中定義,而這個(gè)定義又用到了 ostringstream,不得已包含了 iostream 的實(shí)現(xiàn))。

            如果程序要對(duì) complex 做 IO,從效率和健壯性方面考慮,建議不要使用 iostream。

            iostream 在使用方面的缺點(diǎn)

            在簡(jiǎn)單使用 iostream 的時(shí)候,它確實(shí)比 stdio 方便,但是深入一點(diǎn)就會(huì)發(fā)現(xiàn),二者可說(shuō)各擅勝場(chǎng)。下面談一談 iostream 在使用方面的缺點(diǎn)。

            1. 格式化輸出很繁瑣

            iostream 采用 manipulator 來(lái)格式化,如果我想按照 2010-04-03 的格式輸出前面定義的 Date class,那么代碼要改成:

            --- 02-02.cc    2011-07-16 16:40:05.000000000 +0800
            +++ 04-01.cc    2011-07-16 17:10:27.000000000 +0800
            @@ -1,4 +1,5 @@
             #include <iostream>
            +#include <iomanip>
            
             class Date
             {
            @@ -10,7 +11,9 @@
            
               void writeTo(std::ostream& os) const
               {
            -    os << year_ << '-' << month_ << '-' << day_;
            +    os << year_ << '-'
            +       << std::setw(2) << std::setfill('0') << month_ << '-'
            +       << std::setw(2) << std::setfill('0') << day_;
               }
            
              private:
            

            假如用 stdio,會(huì)簡(jiǎn)短得多,因?yàn)?printf 采用了一種表達(dá)能力較強(qiáng)的小語(yǔ)言來(lái)描述輸出格式。

            --- 04-01.cc    2011-07-16 17:03:22.000000000 +0800
            +++ 04-02.cc    2011-07-16 17:04:21.000000000 +0800
            @@ -1,5 +1,5 @@
             #include <iostream>
            -#include <iomanip>
            +#include <stdio.h>
            
             class Date
             {
            @@ -11,9 +11,9 @@
            
               void writeTo(std::ostream& os) const
               {
            -    os << year_ << '-' << month_ << '-' << day_;
            +    char buf[32];
            +    snprintf(buf, sizeof buf, "%d-%02d-%02d", year_, month_, day_);
            +    os << buf;
               }
            
              private:
            

            使用小語(yǔ)言來(lái)描述格式還帶來(lái)另外一個(gè)好處:外部可配置。

            2. 外部可配置性

            比方說(shuō),我想用一個(gè)外部的配置文件來(lái)定義日期的格式。C stdio 很好辦,把格式字符串 "%d-%02d-%02d" 保存到配置里就行。但是 iostream 呢?它的格式是寫(xiě)死在代碼里的,靈活性大打折扣。

            再舉一個(gè)例子,程序的 message 的多語(yǔ)言化。

              const char* name = "Shuo Chen";
              int age = 29;
              printf("My name is %1$s, I am %2$d years old.\n", name, age);
              cout << "My name is " << name << ", I am " << age << " years old." << endl;
            
            對(duì)于 stdio,要讓這段程序支持中文的話,把代碼中的"My name is %1$s, I am %2$d years old.\n",

            替換為 "我叫%1$s,今年%2$d歲。\n" 即可。也可以把這段提示語(yǔ)做成資源文件,在運(yùn)行時(shí)讀入。而對(duì)于 iostream,恐怕沒(méi)有這么方便,因?yàn)榇a是支離破碎的。

            C stdio 的格式化字符串體現(xiàn)了重要的“數(shù)據(jù)就是代碼”的思想,這種“數(shù)據(jù)”與“代碼”之間的相互轉(zhuǎn)換是程序靈活性的根源,遠(yuǎn)比 OO 更為靈活。

            3. stream 的狀態(tài)

            如果我想用 16 進(jìn)制方式輸出一個(gè)整數(shù) x,那么可以用 hex 操控符,但是這會(huì)改變 ostream 的狀態(tài)。比如說(shuō)

              int x = 8888;
              cout << hex << showbase << x << endl;  // forgot to reset state
              cout << 123 << endl;
            

            這這段代碼會(huì)把 123 也按照 16 進(jìn)制方式輸出,這恐怕不是我們想要的。

            再舉一個(gè)例子,setprecision() 也會(huì)造成持續(xù)影響:

              double d = 123.45;
              printf("%8.3f\n", d);
              cout << d << endl;
              cout << setw(8) << fixed << setprecision(3) << d << endl;
              cout << d << endl;
            

            輸出是:

            $ ./a.out
             123.450
            123.45    # default cout format
             123.450  # our format
            123.450   # side effects
            

            可見(jiàn)代碼中的 setprecision() 影響了后續(xù)輸出的精度。注意 setw() 不會(huì)造成影響,它只對(duì)下一個(gè)輸出有效。

            這說(shuō)明,如果使用 manipulator 來(lái)控制格式,需要時(shí)刻小心防止影響了后續(xù)代碼。而使用 C stdio 就沒(méi)有這個(gè)問(wèn)題,它是“上下文無(wú)關(guān)的”。

            4. 知識(shí)的通用性

            在 C 語(yǔ)言之外,有其他很多語(yǔ)言也支持 printf() 風(fēng)格的格式化,例如 Java、Perl、Ruby 等等 (http://en.wikipedia.org/wiki/Printf#Programming_languages_with_printf)。學(xué)會(huì) printf() 的格式化方法,這個(gè)知識(shí)還可以用到其他語(yǔ)言中。但是 C++ iostream 只此一家別無(wú)分店,反正都是格式化輸出,stdio 的投資回報(bào)率更高。

            基于這點(diǎn)考慮,我認(rèn)為不必深究 iostream 的格式化方法,只需要用好它最基本的類(lèi)型安全輸出即可。在真的需要格式化的場(chǎng)合,可以考慮 snprintf() 打印到棧上緩沖,再用 ostream 輸出。

            5. 線程安全與原子性

            iostream 的另外一個(gè)問(wèn)題是線程安全性。stdio 的函數(shù)是線程安全的,而且 C 語(yǔ)言還提供了 flockfile(3)/funlockfile(3) 之類(lèi)的函數(shù)來(lái)明確控制 FILE* 的加鎖與解鎖。

            iostream 在線程安全方面沒(méi)有保證,就算單個(gè) operator<< 是線程安全的,也不能保證原子性。因?yàn)?cout << a << b; 是兩次函數(shù)調(diào)用,相當(dāng)于 cout.operator<<(a).operator<<(b)。兩次調(diào)用中間可能會(huì)被打斷進(jìn)行上下文切換,造成輸出內(nèi)容不連續(xù),插入了其他線程打印的字符。

            而 fprintf(stdout, "%s %d", a, b); 是一次函數(shù)調(diào)用,而且是線程安全的,打印的內(nèi)容不會(huì)受其他線程影響。

            因此,iostream 并不適合在多線程程序中做 logging。

            iostream 的局限

            根據(jù)以上分析,我們可以歸納 iostream 的局限:

            • 輸入方面,istream 不適合輸入帶格式的數(shù)據(jù),因?yàn)?#8220;糾錯(cuò)”能力不強(qiáng),進(jìn)一步的分析請(qǐng)見(jiàn)孟巖寫(xiě)的《契約思想的一個(gè)反面案例》,孟巖說(shuō)“復(fù)雜的設(shè)計(jì)必然帶來(lái)復(fù)雜的使用規(guī)則,而面對(duì)復(fù)雜的使用規(guī)則,用戶是可以投票的,那就是你做你的,我不用!”可謂鞭辟入里。如果要用 istream,我推薦的做法是用 getline() 讀入一行數(shù)據(jù),然后用正則表達(dá)式來(lái)判斷內(nèi)容正誤,并做分組,然后用 strtod/strtol 之類(lèi)的函數(shù)做類(lèi)型轉(zhuǎn)換。這樣似乎更容易寫(xiě)出健壯的程序。
            • 輸出方面,ostream 的格式化輸出非常繁瑣,而且寫(xiě)死在代碼里,不如 stdio 的小語(yǔ)言那么靈活通用。建議只用作簡(jiǎn)單的無(wú)格式輸出。
            • log 方面,由于 ostream 沒(méi)有辦法在多線程程序中保證一行輸出的完整性,建議不要直接用它來(lái)寫(xiě) log。如果是簡(jiǎn)單的單線程程序,輸出數(shù)據(jù)量較少的情況下可以酌情使用。當(dāng)然,產(chǎn)品代碼應(yīng)該用成熟的 logging 庫(kù),而不要用其它東西來(lái)湊合。
            • in-memory 格式化方面,由于 ostringstream 會(huì)動(dòng)態(tài)分配內(nèi)存,它不適合性能要求較高的場(chǎng)合。
            • 文件 IO 方面,如果用作文本文件的輸入輸出,(i|o)fstream 有上述的缺點(diǎn);如果用作二進(jìn)制數(shù)據(jù)輸入輸出,那么自己簡(jiǎn)單封裝一個(gè) File class 似乎更好用,也不必為用不到的功能付出代價(jià)(后文還有具體例子)。ifstream 的一個(gè)用處是在程序啟動(dòng)時(shí)讀入簡(jiǎn)單的文本配置文件。如果配置文件是其他文本格式(XML 或 JSON),那么用相應(yīng)的庫(kù)來(lái)讀,也用不到 ifstream。
            • 性能方面,iostream 沒(méi)有兌現(xiàn)“高效性”諾言。iostream 在某些場(chǎng)合比 stdio 快,在某些場(chǎng)合比 stdio 慢,對(duì)于性能要求較高的場(chǎng)合,我們應(yīng)該自己實(shí)現(xiàn)字符串轉(zhuǎn)換(見(jiàn)后文的代碼與測(cè)試)。iostream 性能方面的一個(gè)注腳:在線 ACM/ICPC 判題網(wǎng)站上,如果一個(gè)簡(jiǎn)單的題目發(fā)生超時(shí)錯(cuò)誤,那么把其中 iostream 的輸入輸出換成 stdio,有時(shí)就能過(guò)關(guān)。

            既然有這么多局限,iostream 在實(shí)際項(xiàng)目中的應(yīng)用就大為受限了,在這上面投入太多的精力實(shí)在不值得。說(shuō)實(shí)話,我沒(méi)有見(jiàn)過(guò)哪個(gè) C++ 產(chǎn)品代碼使用 iostream 來(lái)作為輸入輸出設(shè)施。 http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#Streams 

            iostream 在設(shè)計(jì)方面的缺點(diǎn)

            iostream 的設(shè)計(jì)有相當(dāng)多的 WTFs,stackoverflow 有人吐槽說(shuō)“If you had to judge by today's software engineering standards, would C++'s IOStreams still be considered well-designed?” http://stackoverflow.com/questions/2753060/who-architected-designed-cs-iostreams-and-would-it-still-be-considered-well

            面向?qū)ο蟮脑O(shè)計(jì)

            iostream 是個(gè)面向?qū)ο蟮?IO 類(lèi)庫(kù),本節(jié)簡(jiǎn)單介紹它的繼承體系。

            對(duì) iostream 略有了解的人會(huì)知道它用了多重繼承和虛擬繼承,簡(jiǎn)單地畫(huà)個(gè)類(lèi)圖如下,是典型的菱形繼承:

            simple

            如果加深一點(diǎn)了解,會(huì)發(fā)現(xiàn) iostream 現(xiàn)在是模板化的,同時(shí)支持窄字符和寬字符。下圖是現(xiàn)在的繼承體系,同時(shí)畫(huà)出了 fstreams 和 stringstreams。圖中方框的第二行是模板的具現(xiàn)化類(lèi)型,也就是我們代碼里常用的具體類(lèi)型(通過(guò) typedef 定義)。

             

            ios

            這個(gè)繼承體系糅合了面向?qū)ο笈c泛型編程,但可惜它兩方面都不討好。

            再進(jìn)一步加深了解,發(fā)現(xiàn)還有一個(gè)平行的 streambuf 繼承體系,fstream 和 stringstream 的不同之處主要就在于它們使用了不同的 streambuf 具體類(lèi)型。

             

            buf

            再把這兩個(gè)繼承體系畫(huà)到一幅圖里:

            full

            注意到 basic_ios 持有了 streambuf 的指針;而 fstreams 和 stringstreams 則分別包含 filebuf 和 stringbuf 的對(duì)象。看上去有點(diǎn)像 Bridge 模式。

             

            看了這樣巴洛克的設(shè)計(jì),有沒(méi)有人還打算在自己的項(xiàng)目中想通過(guò)繼承 iostream 來(lái)實(shí)現(xiàn)自己的 stream,以實(shí)現(xiàn)功能擴(kuò)展么?

            面向?qū)ο蠓矫娴脑O(shè)計(jì)缺陷

            本節(jié)我們分析一下 iostream 的設(shè)計(jì)違反了哪些 OO 準(zhǔn)則。

            我們知道,面向?qū)ο笾械?public 繼承需要滿足 Liskov 替換原則。(見(jiàn)《Effective C++ 第3版》條款32:確保你的 public 繼承模塑出 is-a 關(guān)系。《C++ 編程規(guī)范》條款 37:public 繼承意味可替換性。繼承非為復(fù)用,乃為被復(fù)用。)

            在程序里需要用到 ostream 的地方(例如 operator<< ),我傳入 ofstream 或 ostringstream 都應(yīng)該能按預(yù)期工作,這就是 OO 繼承強(qiáng)調(diào)的“可替換性”,派生類(lèi)的對(duì)象可以替換基類(lèi)對(duì)象,從而被 operator<< 復(fù)用。

            iostream 的繼承體系多次違反了 Liskov 原則,這些地方繼承的目的是為了復(fù)用基類(lèi)的代碼,下圖中我把違規(guī)的繼承關(guān)系用紅線標(biāo)出。

            correct

            在現(xiàn)有的繼承體系中,合理的有:

            • ifstream is-a istream
            • istringstream is-a istream
            • ofstream is-a ostream
            • ostringstream is-a ostream
            • fstream is-a iostream
            • stringstream is-a iostream

            我認(rèn)為不怎么合理的有:

            • ios 繼承 ios_base,有沒(méi)有哪種情況下程序代碼期待 ios_base 對(duì)象,但是客戶可以傳入一個(gè) ios 對(duì)象替代之?如果沒(méi)有,這里用 public 繼承是不是違反 OO 原則?
            • istream 繼承 ios,有沒(méi)有哪種情況下程序代碼期待 ios 對(duì)象,但是客戶可以傳入一個(gè) istream 對(duì)象替代之?如果沒(méi)有,這里用 public 繼承是不是違反 OO 原則?
            • ostream 繼承 ios,有沒(méi)有哪種情況下程序代碼期待 ios 對(duì)象,但是客戶可以傳入一個(gè) ostream 對(duì)象替代之?如果沒(méi)有,這里用 public 繼承是不是違反 OO 原則?
            • iostream 多重繼承 istream 和 ostream。為什么 iostream 要同時(shí)繼承兩個(gè) non-interface class?這是接口繼承還是實(shí)現(xiàn)繼承?是不是可以用組合(composition)來(lái)替代?(見(jiàn)《Effective C++ 第3版》條款38:通過(guò)組合模塑出 has-a 或“以某物實(shí)現(xiàn)”。《C++ 編程規(guī)范》條款 34:盡可能以組合代替繼承。)

            用組合替換繼承之后的體系:

            myown

            注意到在新的設(shè)計(jì)中,只有真正的 is-a 關(guān)系采用了 public 繼承,其他均以組合來(lái)代替,組合關(guān)系以紅線表示。新的設(shè)計(jì)沒(méi)有用的虛擬繼承或多重繼承。

            其中 iostream 的新實(shí)現(xiàn)值得一提,代碼結(jié)構(gòu)如下:

            class istream;
            class ostream;
            
            class iostream
            {
             public:
              istream& get_istream();
              ostream& get_ostream();
              virtual ~iostream();
            };
            

            這樣一來(lái),在需要 iostream 對(duì)象表現(xiàn)得像 istream 的地方,調(diào)用 get_istream() 函數(shù)返回一個(gè) istream 的引用;在需要 iostream 對(duì)象表現(xiàn)得像 ostream 的地方,調(diào)用 get_ostream() 函數(shù)返回一個(gè) ostream 的引用。功能不受影響,而且代碼更清晰。(雖然我非常懷疑 iostream 的真正價(jià)值,一個(gè)東西既可讀又可寫(xiě),說(shuō)明是個(gè) sophisticated IO 對(duì)象,為什么還用這么厚的 OO 封裝?)

            陽(yáng)春的 locale

            iostream 的故事還不止這些,它還包含一套陽(yáng)春的 locale/facet 實(shí)現(xiàn),這套實(shí)踐中沒(méi)人用的東西進(jìn)一步增加了 iostream 的復(fù)雜度,而且不可避免地影響其性能。Nathan Myers 正是始作俑者 http://www.cantrip.org/locale.html

            ostream 自身定義的針對(duì)整數(shù)和浮點(diǎn)數(shù)的 operator<< 成員函數(shù)的函數(shù)體是:

            bool failed =
              use_facet<num_put>(getloc()).put(
                ostreambuf_iterator(*this), *this, fill(), val).failed();
            

            它會(huì)轉(zhuǎn)而調(diào)用 num_put::put(),后者會(huì)調(diào)用 num_put::do_put(),而 do_put() 是個(gè)虛函數(shù),沒(méi)辦法 inline。iostream 在性能方面的不足恐怕部分來(lái)自于此。這個(gè)虛函數(shù)白白浪費(fèi)了把 template 的實(shí)現(xiàn)放到頭文件應(yīng)得的好處,編譯和運(yùn)行速度都快不起來(lái)。

            我沒(méi)有深入挖掘其中的細(xì)節(jié),感興趣的同學(xué)可以移步觀看 facet 的繼承體系:http://gcc.gnu.org/onlinedocs/libstdc++/libstdc++-html-USERS-4.4/a00431.html

            據(jù)此分析,我不認(rèn)為以 iostream 為基礎(chǔ)的上層程序庫(kù)(比方說(shuō)那些克服 iostream 格式化方面的缺點(diǎn)的庫(kù))有多大的實(shí)用價(jià)值。

            臆造抽象

            孟巖評(píng)價(jià) “ iostream 最大的缺點(diǎn)是臆造抽象”,我非常贊同他老人家的觀點(diǎn)。

            這個(gè)評(píng)價(jià)同樣適用于 Java 那一套疊床架屋的 InputStream/OutputStream/Reader/Writer 繼承體系,.NET 也搞了這么一套繁文縟節(jié)。

            乍看之下,用 input stream 表示一個(gè)可以“讀”的數(shù)據(jù)流,用 output stream 表示一個(gè)可以“寫(xiě)”的數(shù)據(jù)流,屏蔽底層細(xì)節(jié),面向接口編程,“符合面向?qū)ο笤瓌t”,似乎是一件美妙的事情。但是,真實(shí)的世界要?dú)埧岬枚唷?/p>

            IO 是個(gè)極度復(fù)雜的東西,就拿最常見(jiàn)的 memory stream、file stream、socket stream 來(lái)說(shuō),它們之間的差異極大:

            • 是單向 IO 還是雙向 IO。只讀或者只寫(xiě)?還是既可讀又可寫(xiě)?
            • 順序訪問(wèn)還是隨機(jī)訪問(wèn)。可不可以 seek?可不可以退回 n 字節(jié)?
            • 文本數(shù)據(jù)還是二進(jìn)制數(shù)據(jù)。格式有誤怎么辦?如何編寫(xiě)健壯的處理輸入的代碼?
            • 有無(wú)緩沖。write 500 字節(jié)是否能保證完全寫(xiě)入?有沒(méi)有可能只寫(xiě)入了 300 字節(jié)?余下 200 字節(jié)怎么辦?
            • 是否阻塞。會(huì)不會(huì)返回 EWOULDBLOCK 錯(cuò)誤?
            • 有哪些出錯(cuò)的情況。這是最難的,memory stream 幾乎不可能出錯(cuò),file stream 和 socket stream 的出錯(cuò)情況完全不同。socket stream 可能遇到對(duì)方斷開(kāi)連接,file stream 可能遇到超出磁盤(pán)配額。

            根據(jù)以上列舉的初步分析,我不認(rèn)為有辦法設(shè)計(jì)一個(gè)公共的基類(lèi)把各方面的情況都考慮周全。各種 IO 設(shè)施之間共性太小,差異太大,例外太多。如果硬要用面向?qū)ο髞?lái)建模,基類(lèi)要么太瘦(只放共性,這個(gè)基類(lèi)包含的 interface functions 沒(méi)多大用),要么太肥(把各種 IO 設(shè)施的特性都包含進(jìn)來(lái),這個(gè)基類(lèi)包含的 interface functions 很多,但是不是每一個(gè)都能調(diào)用)。

            C 語(yǔ)言對(duì)此的解決辦法是用一個(gè) int 表示 IO 對(duì)象(file 或 PIPE 或 socket),然后配以 read()/write()/lseek()/fcntl() 等一系列全局函數(shù),程序員自己搭配組合。這個(gè)做法我認(rèn)為比面向?qū)ο蟮姆桨敢?jiǎn)潔高效。

            iostream 在性能方面沒(méi)有比 stdio 高多少,在健壯性方面多半不如 stdio,在靈活性方面受制于本身的復(fù)雜設(shè)計(jì)而難以讓使用者自行擴(kuò)展。目前看起來(lái)只適合一些簡(jiǎn)單的要求不高的應(yīng)用,但是又不得不為它的復(fù)雜設(shè)計(jì)付出運(yùn)行時(shí)代價(jià),總之其定位有點(diǎn)不上不下。

            在實(shí)際的項(xiàng)目中,我們可以提煉出一些簡(jiǎn)單高效的 strip-down 版本,在獲得便利性的同時(shí)避免付出不必要的代價(jià)。

            一個(gè) 300 行的 memory buffer output stream

            我認(rèn)為以 operator<< 來(lái)輸出數(shù)據(jù)非常適合 logging,因此寫(xiě)了一個(gè)簡(jiǎn)單的 LogStream。代碼不到 300行,完全獨(dú)立于 iostream。

            這個(gè) LogStream 做到了類(lèi)型安全和類(lèi)型可擴(kuò)展。它不支持定制格式化、不支持 locale/facet、沒(méi)有繼承、buffer 也沒(méi)有繼承與虛函數(shù)、沒(méi)有動(dòng)態(tài)分配內(nèi)存、buffer 大小固定。簡(jiǎn)單地說(shuō),適合 logging 以及簡(jiǎn)單的字符串轉(zhuǎn)換。

            LogStream 的接口定義是

            class LogStream : boost::noncopyable
            {
              typedef LogStream self;
             public:
              typedef detail::FixedBuffer Buffer;
              LogStream();
            
              self& operator<<(bool);
            
              self& operator<<(short);
              self& operator<<(unsigned short);
              self& operator<<(int);
              self& operator<<(unsigned int);
              self& operator<<(long);
              self& operator<<(unsigned long);
              self& operator<<(long long);
              self& operator<<(unsigned long long);
            
              self& operator<<(const void*);
            
              self& operator<<(float);
              self& operator<<(double);
              // self& operator<<(long double);
            
              self& operator<<(char);
              // self& operator<<(signed char);
              // self& operator<<(unsigned char);
            
              self& operator<<(const char*);
              self& operator<<(const string&);
            
              const Buffer& buffer() const { return buffer_; }
              void resetBuffer() { buffer_.reset(); }
            
             private:
              Buffer buffer_;
            };
            

            LogStream 本身不是線程安全的,它不適合做全局對(duì)象。正確的使用方式是每條 log 消息構(gòu)造一個(gè) LogStream,用完就扔。LogStream 的成本極低,這么做不會(huì)有什么性能損失。

            目前這個(gè) logging 庫(kù)還在開(kāi)發(fā)之中,只完成了 LogStream 這一部分。將來(lái)可能改用動(dòng)態(tài)分配的 buffer,這樣方便在線程之間傳遞數(shù)據(jù)。

            整數(shù)到字符串的高效轉(zhuǎn)換

            muduo::LogStream 的整數(shù)轉(zhuǎn)換是自己寫(xiě)的,用的是 Matthew Wilson 的算法,見(jiàn) http://blog.csdn.net/solstice/article/details/5139302 。這個(gè)算法比 stdio 和 iostream 都要快。

            浮點(diǎn)數(shù)到字符串的高效轉(zhuǎn)換

            目前 muduo::LogStream 的浮點(diǎn)數(shù)格式化采用的是 snprintf() 所以從性能上與 stdio 持平,比 ostream 快一些。

            浮點(diǎn)數(shù)到字符串的轉(zhuǎn)換是個(gè)復(fù)雜的話題,這個(gè)領(lǐng)域 20 年以來(lái)沒(méi)有什么進(jìn)展(目前的實(shí)現(xiàn)大都基于 David M. Gay 在 1990 年的工作《Correctly Rounded Binary-Decimal and Decimal-Binary Conversions》,代碼 http://netlib.org/fp/),直到 2010 年才有突破。

            Florian Loitsch 發(fā)明了新的更快的算法 Grisu3,他的論文《Printing floating-point numbers quickly and accurately with integers》發(fā)表在 PLDI 2010,代碼見(jiàn) Google V8 引擎,還有這里 http://code.google.com/p/double-conversion/ 。有興趣的同學(xué)可以閱讀這篇博客 http://www.serpentine.com/blog/2011/06/29/here-be-dragons-advances-in-problems-you-didnt-even-know-you-had/

            將來(lái) muduo::LogStream 可能會(huì)改用 Grisu3 算法實(shí)現(xiàn)浮點(diǎn)數(shù)轉(zhuǎn)換。

            性能對(duì)比

            由于 muduo::LogStream 拋掉了很多負(fù)擔(dān),可以預(yù)見(jiàn)它的性能好于 ostringstream 和 stdio。我做了一個(gè)簡(jiǎn)單的性能測(cè)試,結(jié)果如下。

            benchmark

            從上表看出,ostreamstream 有時(shí)候比 snprintf 快,有時(shí)候比它慢,muduo::LogStream 比它們兩個(gè)都快得多(double 類(lèi)型除外)。

            泛型編程

            其他程序庫(kù)如何使用 LogStream 作為輸出呢?辦法很簡(jiǎn)單,用模板。

            前面我們定義了 Date class 針對(duì) std::ostream 的 operator<<,只要稍作修改就能同時(shí)適用于 std::ostream 和 LogStream。而且 Date 的頭文件不再需要 include <ostream>,降低了耦合。

             class Date
             {
              public:
               Date(int year, int month, int day)
                 : year_(year), month_(month), day_(day)
               {
               }
            
            -  void writeTo(std::ostream& os) const
            +  template<typename OStream>
            +  void writeTo(OStream& os) const
               {
                 char buf[32];
                 snprintf(buf, sizeof buf, "%d-%02d-%02d", year_, month_, day_);
                 os << buf;
               }
            
              private:
               int year_, month_, day_;
             };
            
            -std::ostream& operator<<(std::ostream& os, const Date& date)
            +template<typename OStream>
            +OStream& operator<<(OStream& os, const Date& date)
             {
               date.writeTo(os);
               return os;
             }
            

            現(xiàn)實(shí)的 C++ 程序如何做文件 IO

            舉兩個(gè)例子, Kyoto CabinetGoogle leveldb

            Google leveldb

            Google leveldb 是一個(gè)高效的持久化 key-value db。

            它定義了三個(gè)精簡(jiǎn)的 interface:

            接口函數(shù)如下

            struct Slice {
              const char* data_;
              size_t size_;
            };
            
            // A file abstraction for reading sequentially through a file
            class SequentialFile {
             public:
              SequentialFile() { }
              virtual ~SequentialFile();
            
              virtual Status Read(size_t n, Slice* result, char* scratch) = 0;
              virtual Status Skip(uint64_t n) = 0;
            };
            
            // A file abstraction for randomly reading the contents of a file.
            class RandomAccessFile {
             public:
              RandomAccessFile() { }
              virtual ~RandomAccessFile();
            
              virtual Status Read(uint64_t offset, size_t n, Slice* result,
                                  char* scratch) const = 0;
            };
            
            // A file abstraction for sequential writing.  The implementation
            // must provide buffering since callers may append small fragments
            // at a time to the file.
            class WritableFile {
             public:
              WritableFile() { }
              virtual ~WritableFile();
            
              virtual Status Append(const Slice& data) = 0;
              virtual Status Close() = 0;
              virtual Status Flush() = 0;
              virtual Status Sync() = 0;
            };
            

            leveldb 明確區(qū)分 input 和 output,進(jìn)一步它又把 input 分為 sequential 和 random access,然后提煉出了三個(gè)簡(jiǎn)單的接口,每個(gè)接口只有屈指可數(shù)的幾個(gè)函數(shù)。這幾個(gè)接口在各個(gè)平臺(tái)下的實(shí)現(xiàn)也非常簡(jiǎn)單明了(http://code.google.com/p/leveldb/source/browse/trunk/util/env_posix.cc#35  http://code.google.com/p/leveldb/source/browse/trunk/util/env_chromium.cc#176),一看就懂。

            注意這三個(gè)接口使用了虛函數(shù),我認(rèn)為這是正當(dāng)?shù)模驗(yàn)橐淮?IO 往往伴隨著 context switch,虛函數(shù)的開(kāi)銷(xiāo)比起 context switch 來(lái)可以忽略不計(jì)。相反,iostream 每次 operator<<() 就調(diào)用虛函數(shù),我認(rèn)為不太明智。

            Kyoto Cabinet

            Kyoto Cabinet 也是一個(gè) key-value db,是前幾年流行的 Tokyo Cabinet 的升級(jí)版。它采用了與 leveldb 不同的文件抽象。

            KC 定義了一個(gè) File class,同時(shí)包含了讀寫(xiě)操作,這是個(gè) fat interface。http://fallabs.com/kyotocabinet/api/classkyotocabinet_1_1File.html

            在具體實(shí)現(xiàn)方面,它沒(méi)有使用虛函數(shù),而是采用 #ifdef 來(lái)區(qū)分不同的平臺(tái)(見(jiàn) http://code.google.com/p/read-taobao-code/source/browse/trunk/tair/src/storage/kdb/kyotocabinet/kcfile.cc),等于把兩份獨(dú)立的代碼寫(xiě)到了同一個(gè)文件里邊。

            相比之下,Google leveldb 的做法更高明一些。

            小結(jié)

            在 C++ 項(xiàng)目里邊自己寫(xiě)個(gè) File class,把項(xiàng)目用到的文件 IO 功能簡(jiǎn)單封裝一下(以 RAII 手法封裝 FILE* 或者 file descriptor 都可以,視情況而定),通常就能滿足需要。記得把拷貝構(gòu)造和賦值操作符禁用,在析構(gòu)函數(shù)里釋放資源,避免泄露內(nèi)部的 handle,這樣就能自動(dòng)避免很多 C 語(yǔ)言文件操作的常見(jiàn)錯(cuò)誤。

            如果要用 stream 方式做 logging,可以拋開(kāi)繁重的 iostream 自己寫(xiě)一個(gè)簡(jiǎn)單的 LogStream,重載幾個(gè) operator<<,用起來(lái)一樣方便;而且可以用 stack buffer,輕松做到線程安全。

            posted @ 2011-07-17 15:06 陳碩 閱讀(12543) | 評(píng)論 (10)編輯 收藏

            關(guān)于 TCP 并發(fā)連接的幾個(gè)思考題與試驗(yàn)

            陳碩 (giantchen AT gmail)

            blog.csdn.net/Solstice

            前幾天我在新浪微博上出了兩道有關(guān) TCP 的思考題,引發(fā)了一場(chǎng)討論 http://weibo.com/1701018393/eCuxDrta0Nn

            第一道初級(jí)題目是:

            有一臺(tái)機(jī)器,它有一個(gè) IP,上面運(yùn)行了一個(gè) TCP 服務(wù)程序,程序只偵聽(tīng)一個(gè)端口,問(wèn):從理論上講(只考慮 TCP/IP 這一層面,不考慮IPv6)這個(gè)服務(wù)程序可以支持多少并發(fā) TCP 連接?答 65536 上下的直接刷掉。

            具體來(lái)說(shuō),這個(gè)問(wèn)題等價(jià)于:有一個(gè) TCP 服務(wù)程序的地址是 1.2.3.4:8765,問(wèn)它從理論上能接受多少個(gè)并發(fā)連接?

            第二道進(jìn)階題目是:

            一臺(tái)被測(cè)機(jī)器 A,功能同上,同一交換機(jī)上還接有一臺(tái)機(jī)器 B,如果允許 B 的程序直接收發(fā)以太網(wǎng) frame,問(wèn):讓 A 承擔(dān) 10 萬(wàn)個(gè)并發(fā) TCP 連接需要用多少 B 的資源?100萬(wàn)個(gè)呢?

            從討論的結(jié)果看,很多人做出了第一道題,而第二道題幾乎無(wú)人問(wèn)津。

             

            這里先不公布答案(第一題答案見(jiàn)文末),讓我們繼續(xù)思考一個(gè)本質(zhì)的問(wèn)題:一個(gè) TCP 連接要占用多少系統(tǒng)資源。

            在現(xiàn)在的 Linux 操作系統(tǒng)上,如果用 socket()/connect() 或 accept() 來(lái)創(chuàng)建 TCP 連接,那么每個(gè)連接至少要占用一個(gè)文件描述符(file descriptor)。為什么說(shuō)“至少”?因?yàn)槲募枋龇梢詮?fù)制,比如 dup();也可以被繼承,比如 fork();這樣可能出現(xiàn)系統(tǒng)里邊同一個(gè) TCP 連接有多個(gè)文件描述符與之對(duì)應(yīng)。據(jù)此,很多人給出的第一題答案是:并發(fā)連接數(shù)受限于系統(tǒng)能同時(shí)打開(kāi)的文件數(shù)目的最大值。這個(gè)答案在實(shí)踐中是正確的,卻不符合原題意。

             

            如果拋開(kāi)操作系統(tǒng)層面,只考慮 TCP/IP 層面,建立一個(gè) TCP 連接有哪些開(kāi)銷(xiāo)?理論上最小的開(kāi)銷(xiāo)是多少?考慮兩個(gè)場(chǎng)景:

            1. 假設(shè)有一個(gè) TCP 服務(wù)程序,向這個(gè)程序成功發(fā)起連接需要做哪些事情?換句話說(shuō),如何才能讓這個(gè) TCP 服務(wù)程序認(rèn)為有客戶連接到了它(讓它的 accept() 調(diào)用正常返回)?

            2. 假設(shè)有一個(gè) TCP 客戶端程序,讓這個(gè)程序成功建立到服務(wù)器的連接需要做哪些事情?換句話說(shuō),如何才能讓這個(gè) TCP 客戶端程序認(rèn)為它自己已經(jīng)連接到服務(wù)器了(讓它的 connect() 調(diào)用正常返回)?

            以上這兩個(gè)問(wèn)題問(wèn)的不是如何編程,如何調(diào)用 Sockets API,而是問(wèn)如何讓操作系統(tǒng)的 TCP/IP 協(xié)議棧認(rèn)為任務(wù)已經(jīng)成功完成,連接已經(jīng)成功建立。

             

            學(xué)過(guò) TCP/IP 協(xié)議,理解三路握手的同學(xué)明白,TCP 連接是虛擬的連接,不是電路連接,維持 TCP 連接理論上不占用網(wǎng)絡(luò)資源(會(huì)占用兩頭程序的系統(tǒng)資源)。只要連接的雙方認(rèn)為 TCP 連接存在,并且可以互相發(fā)送 IP packet,那么 TCP 連接就一直存在。

            對(duì)于問(wèn)題 1,向一個(gè) TCP 服務(wù)程序發(fā)起一個(gè)連接,客戶端(為明白起見(jiàn),以下稱(chēng)為 faketcp 客戶端)只需要做三件事情(三路握手):

            1a. 向 TCP 服務(wù)程序發(fā)一個(gè) IP packet,包含 SYN 的 TCP segment

            1b. 等待對(duì)方返回一個(gè)包含 SYN 和 ACK 的 TCP segment

            1c. 向?qū)Ψ桨l(fā)送一個(gè)包含 ACK 的 segment

            在做完這三件事情之后,TCP 服務(wù)器程序會(huì)認(rèn)為連接已建立。而做這三件事情并不占用客戶端的資源(?),如果faketcp 客戶端程序可以繞開(kāi)操作系統(tǒng)的 TCP/IP 協(xié)議棧,自己直接發(fā)送并接收 IP packet 或 Ethernet frame 的話。換句話說(shuō),faketcp 客戶端可以一直重復(fù)做這三件事件,每次用一個(gè)不同的 IP:PORT,在服務(wù)端創(chuàng)建不計(jì)其數(shù)的 TCP 連接,而 faketcp 客戶端自己毫發(fā)無(wú)損。很快我們將看到如何用程序來(lái)實(shí)現(xiàn)這一點(diǎn)。

            對(duì)于問(wèn)題 2,為了讓一個(gè) TCP 客戶端程序認(rèn)為連接已建立,faketcp 服務(wù)端只需要做兩件事情:

            2a. 等待客戶端發(fā)來(lái)的 SYN TCP segment

            2b. 發(fā)送一個(gè)包含 SYN 和 ACK 的 TCP segment

            2c. 忽視對(duì)方發(fā)來(lái)的包含 ACK 的 segment

            在做完這兩件事情(收一個(gè) SYN、發(fā)一個(gè) SYN+ACK)之后,TCP 客戶端程序會(huì)認(rèn)為連接已建立。而做這三件事情并不占用 faketcp 服務(wù)端的資源(?)換句話說(shuō),faketcp 服務(wù)端可以一直重復(fù)做這兩件事件,接受不計(jì)其數(shù)的 TCP 連接,而 faketcp 服務(wù)端自己毫發(fā)無(wú)損。很快我們將看到如何用程序來(lái)實(shí)現(xiàn)這一點(diǎn)。

             

            基于對(duì)以上兩個(gè)問(wèn)題的分析,說(shuō)明單獨(dú)談?wù)摗癟CP 并發(fā)連接數(shù)”是沒(méi)有意義的,因?yàn)檫B接數(shù)基本上是要多少有多少。更有意義的性能指標(biāo)或許是:“每秒鐘收發(fā)多少條消息”、“每秒鐘收發(fā)多少字節(jié)的數(shù)據(jù)”、“支持多少個(gè)活動(dòng)的并發(fā)客戶”等等。

            faketcp 的程序?qū)崿F(xiàn)

            代碼見(jiàn): https://github.com/chenshuo/recipes/tree/master/faketcp 可以直接用 make 編譯

            為了驗(yàn)證我上面的說(shuō)法,我寫(xiě)了幾個(gè)小程序來(lái)實(shí)現(xiàn) faketcp,這幾個(gè)程序可以發(fā)起或接受不計(jì)其數(shù)的 TCP 并發(fā)連接,并且不消耗操作系統(tǒng)資源,連動(dòng)態(tài)內(nèi)存分配都不會(huì)用到。

            我家里有一臺(tái)運(yùn)行 Ubuntu Linux 10.04 的 PC 機(jī),hostname 是 atom,所有的試驗(yàn)都在這上面進(jìn)行。

            家里試驗(yàn)環(huán)境的網(wǎng)絡(luò)配置是:

            net

            陳碩在《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》中曾提到“可以用 TUN/TAP 設(shè)備在用戶態(tài)實(shí)現(xiàn)一個(gè)能與本機(jī)點(diǎn)對(duì)點(diǎn)通信的 TCP/IP 協(xié)議棧”,這次的試驗(yàn)正好可以用上這個(gè)辦法。

            試驗(yàn)的網(wǎng)絡(luò)配置是:

            tun

            具體做法是:在 atom 上通過(guò)打開(kāi) /dev/net/tun 設(shè)備來(lái)創(chuàng)建一個(gè) tun0 虛擬網(wǎng)卡,然后把這個(gè)網(wǎng)卡的地址設(shè)為 192.168.0.1/24,這樣 faketcp 程序就扮演了 192.168.0.0/24 這個(gè)網(wǎng)段上的所有機(jī)器。atom 發(fā)給 192.168.0.2~192.168.0.254 的 IP packet 都會(huì)發(fā)給 faketcp 程序,faketcp 程序可以模擬其中任何一個(gè) IP 給 atom 發(fā) IP packet。

            程序分成幾步來(lái)實(shí)現(xiàn)。

            第一步:實(shí)現(xiàn) icmp echo 協(xié)議,這樣就能 ping 通 faketcp 了。

            代碼見(jiàn) https://github.com/chenshuo/recipes/blob/master/faketcp/icmpecho.cc

            其中響應(yīng) icmp echo request 的函數(shù)在 https://github.com/chenshuo/recipes/blob/master/faketcp/faketcp.cc#L57 這個(gè)函數(shù)在后面的程序中也會(huì)用到。

            運(yùn)行方法,打開(kāi) 3 個(gè)命令行窗口:

            1. 在第 1 個(gè)窗口運(yùn)行 sudo ./icmpecho ,程序顯示

            allocted tunnel interface tun0

            2. 在第 2 個(gè)窗口運(yùn)行

            $ sudo ifconfig tun0 192.168.0.1/24

            $ sudo tcpdump -i tun0

            3. 在第 3 個(gè)窗口運(yùn)行

            $ ping 192.168.0.2

            $ ping 192.168.0.3

            $ ping 192.168.0.234

            發(fā)現(xiàn)每個(gè) 192.168.0.X 的 IP 都能 ping 通。

             

            第二步:實(shí)現(xiàn)拒絕 TCP 連接的功能,即在收到 SYN TCP segment 的時(shí)候發(fā)送 RST segment。

            代碼見(jiàn) https://github.com/chenshuo/recipes/blob/master/faketcp/rejectall.cc

            運(yùn)行方法,打開(kāi) 3 個(gè)命令行窗口,頭兩個(gè)窗口的操作與前面相同,運(yùn)行的 faketcp 程序是 ./rejectall

            3. 在第 3 個(gè)窗口運(yùn)行

            $ nc 192.168.0.2 2000

            $ nc 192.168.0.2 3333

            $ nc 192.168.0.7 5555

            發(fā)現(xiàn)向其中任意一個(gè) IP 發(fā)起的 TCP 連接都被拒接了。

             

            第三步:實(shí)現(xiàn)接受 TCP 連接的功能,即在收到SYN TCP segment 的時(shí)候發(fā)回 SYN+ACK。這個(gè)程序同時(shí)處理了連接斷開(kāi)的情況,即在收到 FIN segment 的時(shí)候發(fā)回 FIN+ACK。

            代碼見(jiàn) https://github.com/chenshuo/recipes/blob/master/faketcp/acceptall.cc

            運(yùn)行方法,打開(kāi) 3 個(gè)命令行窗口,步驟與前面相同,運(yùn)行的 faketcp 程序是 ./acceptall。這次會(huì)發(fā)現(xiàn) nc 能和 192.168.0.X 中的每一個(gè) IP 每一個(gè) PORT 都能連通。還可以在第 4 個(gè)窗口中運(yùn)行 netstat –tpn ,以確認(rèn)連接確實(shí)建立起來(lái)了。如果在 nc 中輸入數(shù)據(jù),數(shù)據(jù)會(huì)堆積在操作系統(tǒng)中,表現(xiàn)為 netstat 顯示的發(fā)送隊(duì)列(Send-Q)的長(zhǎng)度增加。

             

            第四步:在第三步接受 TCP 連接的基礎(chǔ)上,實(shí)現(xiàn)接收數(shù)據(jù),即在收到包含 payload 數(shù)據(jù) 的 TCP segment 時(shí)發(fā)回 ACK。

            代碼見(jiàn) https://github.com/chenshuo/recipes/blob/master/faketcp/discardall.cc

            運(yùn)行方法,打開(kāi) 3 個(gè)命令行窗口,步驟與前面相同,運(yùn)行的 faketcp 程序是 ./acceptall。這次會(huì)發(fā)現(xiàn) nc 能和 192.168.0.X 中的每一個(gè) IP 每一個(gè) PORT 都能連通,數(shù)據(jù)也能發(fā)出去。還可以在第 4 個(gè)窗口中運(yùn)行 netstat –tpn ,以確認(rèn)連接確實(shí)建立起來(lái)了,并且發(fā)送隊(duì)列的長(zhǎng)度為 0。

            這一步已經(jīng)解決了前面的問(wèn)題 2,扮演任意 TCP 服務(wù)端。

             

            第五步:解決前面的問(wèn)題 1,扮演客戶端向 atom 發(fā)起任意多的連接。

            代碼見(jiàn) https://github.com/chenshuo/recipes/blob/master/faketcp/connectmany.cc

            這一步的運(yùn)行方法與前面不同,打開(kāi) 4 個(gè)命令行窗口。

            1. 在第 1 個(gè)窗口運(yùn)行 sudo ./connectmany 192.168.0.1 2007 1000 ,表示將向 192.168.0.1:2007 發(fā)起 1000 個(gè)并發(fā)連接。

            程序顯示

            allocted tunnel interface tun0
            press enter key to start connecting 192.168.0.1:2007

             

            2. 在第 2 個(gè)窗口運(yùn)行

            $ sudo ifconfig tun0 192.168.0.1/24

            $ sudo tcpdump -i tun0

            3. 在第 3 個(gè)窗口運(yùn)行一個(gè)能接收并發(fā) TCP 連接的服務(wù)程序,可以是 httpd,也可以是 muduo 的 echo 或 discard 示例,程序應(yīng) listen 2007 端口。

            4. 回到第 1 個(gè)窗口中敲回車(chē),然后在第 4 個(gè)窗口中用 netstat -tpn 來(lái)觀察并發(fā)連接。

             

            有興趣的話,還可以繼續(xù)擴(kuò)展,做更多的有關(guān) TCP 的試驗(yàn),以進(jìn)一步加深理解,驗(yàn)證操作系統(tǒng) TCP/IP 協(xié)議棧面對(duì)不同輸入的行為。甚至可以按我在《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》中提議的那樣,實(shí)現(xiàn)完整的 TCP 狀態(tài)機(jī),做出一個(gè)簡(jiǎn)單的 mini tcp stack。

             

            第一道題的答案:

            在只考慮 IPv4 的情況下,并發(fā)數(shù)的理論上限是 2**48。考慮某些 IP 段被保留了,這個(gè)上界可適當(dāng)縮小,但數(shù)量級(jí)不變。實(shí)際的限制是操作系統(tǒng)全局文件描述符的數(shù)量,以及內(nèi)存大小。

            一個(gè) TCP 連接有兩個(gè) end points,每個(gè) end point 是 {ip, port},題目說(shuō)其中一個(gè) end point 已經(jīng)固定,那么留下一個(gè) end point 的自由度,即 2 ** 48。客戶端 IP 的上限是 2**32 個(gè),每個(gè)客戶端IP發(fā)起連接的上限是 2**16,乘到一起得理論上限。

            即便客戶端使用 NAT,也不影響這個(gè)理論上限。(為什么?)

             

            在真實(shí)的 Linux 系統(tǒng)中,可以通過(guò)調(diào)整內(nèi)核參數(shù)來(lái)支持上百萬(wàn)并發(fā)連接,具體做法見(jiàn):

            http://urbanairship.com/blog/2010/09/29/linux-kernel-tuning-for-c500k/

            http://www.metabrew.com/article/a-million-user-comet-application-with-mochiweb-part-3

             

            (.完.)

            posted @ 2011-07-01 12:50 陳碩 閱讀(6753) | 評(píng)論 (7)編輯 收藏

            Muduo 多線程模型:一個(gè) Sudoku 服務(wù)器演變

            陳碩 (giantchen AT gmail)

            blog.csdn.net/Solstice

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

            本文以一個(gè) Sudoku Solver 為例,回顧了并發(fā)網(wǎng)絡(luò)服務(wù)程序的多種設(shè)計(jì)方案,并介紹了使用 muduo 網(wǎng)絡(luò)庫(kù)編寫(xiě)多線程服務(wù)器的兩種最常用手法。以往的例子展現(xiàn)了 Muduo 在編寫(xiě)單線程并發(fā)網(wǎng)絡(luò)服務(wù)程序方面的能力與便捷性,今天我們看一看它在多線程方面的表現(xiàn)。

            本文代碼見(jiàn):http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/

            Sudoku Solver

            假設(shè)有這么一個(gè)網(wǎng)絡(luò)編程任務(wù):寫(xiě)一個(gè)求解數(shù)獨(dú)的程序 (Sudoku Solver),并把它做成一個(gè)網(wǎng)絡(luò)服務(wù)。

            Sudoku Solver 是我喜愛(ài)的網(wǎng)絡(luò)編程例子,它曾經(jīng)出現(xiàn)在《分布式系統(tǒng)部署、監(jiān)控與進(jìn)程管理的幾重境界》、《Muduo 設(shè)計(jì)與實(shí)現(xiàn)之一:Buffer 類(lèi)的設(shè)計(jì)》、《〈多線程服務(wù)器的適用場(chǎng)合〉例釋與答疑》等文中,它也可以看成是 echo 服務(wù)的一個(gè)變種(《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》把 echo 列為三大 TCP 網(wǎng)絡(luò)編程案例之一)。

            寫(xiě)這么一個(gè)程序在網(wǎng)絡(luò)編程方面的難度不高,跟寫(xiě) echo 服務(wù)差不多(從網(wǎng)絡(luò)連接讀入一個(gè) Sudoku 題目,算出答案,再發(fā)回給客戶),挑戰(zhàn)在于怎樣做才能發(fā)揮現(xiàn)在多核硬件的能力?在談這個(gè)問(wèn)題之前,讓我們先寫(xiě)一個(gè)基本的單線程版。

            協(xié)議

            一個(gè)簡(jiǎn)單的以 \r\n 分隔的文本行協(xié)議,使用 TCP 長(zhǎng)連接,客戶端在不需要服務(wù)時(shí)主動(dòng)斷開(kāi)連接。

            請(qǐng)求:[id:]〈81digits〉\r\n

            響應(yīng):[id:]〈81digits〉\r\n 或者 [id:]NoSolution\r\n

            其中[id:]表示可選的 id,用于區(qū)分先后的請(qǐng)求,以支持 Parallel Pipelining,響應(yīng)中會(huì)回顯請(qǐng)求中的 id。Parallel Pipelining 的意義見(jiàn)賴(lài)勇浩的《以小見(jiàn)大——那些基于 protobuf 的五花八門(mén)的 RPC(2) 》,或者見(jiàn)我寫(xiě)的《分布式系統(tǒng)的工程化開(kāi)發(fā)方法》第 54 頁(yè)關(guān)于 out-of-order RPC 的介紹。

            〈81digits〉是 Sudoku 的棋盤(pán),9x9 個(gè)數(shù)字,未知數(shù)字以 0 表示。

            如果 Sudoku 有解,那么響應(yīng)是填滿數(shù)字的棋盤(pán);如果無(wú)解,則返回 NoSolution。

            例子1:

            請(qǐng)求:000000010400000000020000000000050407008000300001090000300400200050100000000806000\r\n

            響應(yīng):693784512487512936125963874932651487568247391741398625319475268856129743274836159\r\n

            例子2:

            請(qǐng)求:a:000000010400000000020000000000050407008000300001090000300400200050100000000806000\r\n

            響應(yīng):a:693784512487512936125963874932651487568247391741398625319475268856129743274836159\r\n

            例子3:

            請(qǐng)求:b:000000010400000000020000000000050407008000300001090000300400200050100000000806005\r\n

            響應(yīng):b:NoSolution\r\n

            基于這個(gè)文本協(xié)議,我們可以用 telnet 模擬客戶端來(lái)測(cè)試 sudoku solver,不需要單獨(dú)編寫(xiě) sudoku client。SudokuSolver 的默認(rèn)端口號(hào)是 9981,因?yàn)樗?9x9=81 個(gè)格子。

            基本實(shí)現(xiàn)

            Sudoku 的求解算法見(jiàn)《談?wù)剶?shù)獨(dú)(Sudoku)》一文,這不是本文的重點(diǎn)。假設(shè)我們已經(jīng)有一個(gè)函數(shù)能求解 Sudoku,它的原型如下

            string solveSudoku(const string& puzzle);

            函數(shù)的輸入是上文的"〈81digits〉",輸出是"〈81digits〉"或"NoSolution"。這個(gè)函數(shù)是個(gè) pure function,同時(shí)也是線程安全的。

            有了這個(gè)函數(shù),我們以《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中的 EchoServer 為藍(lán)本,稍作修改就能得到 SudokuServer。這里只列出最關(guān)鍵的 onMessage() 函數(shù),完整的代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_basic.cc 。onMessage() 的主要功能是處理協(xié)議格式,并調(diào)用 solveSudoku() 求解問(wèn)題。

             // muduo/examples/sudoku/server_basic.cc
            
              const int kCells = 81;
            
              void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp)
              {
                LOG_DEBUG << conn->name();
                size_t len = buf->readableBytes();
                while (len >= kCells + 2)
                {
                  const char* crlf = buf->findCRLF();
                  if (crlf)
                  {
                    string request(buf->peek(), crlf);
                    string id;
                    buf->retrieveUntil(crlf + 2);
                    string::iterator colon = find(request.begin(), request.end(), ':');
                    if (colon != request.end())
                    {
                      id.assign(request.begin(), colon);
                      request.erase(request.begin(), colon+1);
                    }
                    if (request.size() == implicit_cast<size_t>(kCells))
                    {
                      string result = solveSudoku(request);
                      if (id.empty())
                      {
                        conn->send(result+"\r\n");
                      }
                      else
                      {
                        conn->send(id+":"+result+"\r\n");
                      }
                    }
                    else
                    {
                      conn->send("Bad Request!\r\n");
                      conn->shutdown();
                    }
                  }
                  else
                  {
                    break;
                  }
                }
              }
            

            server_basic.cc 是一個(gè)并發(fā)服務(wù)器,可以同時(shí)服務(wù)多個(gè)客戶連接。但是它是單線程的,無(wú)法發(fā)揮多核硬件的能力。

            Sudoku 是一個(gè)計(jì)算密集型的任務(wù)(見(jiàn)《Muduo 設(shè)計(jì)與實(shí)現(xiàn)之一:Buffer 類(lèi)的設(shè)計(jì)》中關(guān)于其性能的分析),其瓶頸在 CPU。為了讓這個(gè)單線程 server_basic 程序充分利用 CPU 資源,一個(gè)簡(jiǎn)單的辦法是在同一臺(tái)機(jī)器上部署多個(gè) server_basic 進(jìn)程,讓每個(gè)進(jìn)程占用不同的端口,比如在一臺(tái) 8 核機(jī)器上部署 8 個(gè) server_basic 進(jìn)程,分別占用 9981、9982、……、9988 端口。這樣做其實(shí)是把難題推給了客戶端,因?yàn)榭蛻舳?s)要自己做負(fù)載均衡。再想得遠(yuǎn)一點(diǎn),在 8 個(gè) server_basic 前面部署一個(gè) load balancer?似乎小題大做了。

            能不能在一個(gè)端口上提供服務(wù),并且又能發(fā)揮多核處理器的計(jì)算能力呢?當(dāng)然可以,辦法不止一種。

            常見(jiàn)的并發(fā)網(wǎng)絡(luò)服務(wù)程序設(shè)計(jì)方案

            W. Richard Stevens 的 UNP2e 第 27 章 Client-Server Design Alternatives 介紹了十來(lái)種當(dāng)時(shí)(90 年代末)流行的編寫(xiě)并發(fā)網(wǎng)絡(luò)程序的方案。UNP3e 第 30 章,內(nèi)容未變,還是這幾種。以下簡(jiǎn)稱(chēng) UNP CSDA 方案。UNP 這本書(shū)主要講解阻塞式網(wǎng)絡(luò)編程,在非阻塞方面著墨不多,僅有一章。正確使用 non-blocking IO 需要考慮的問(wèn)題很多,不適宜直接調(diào)用 Sockets API,而需要一個(gè)功能完善的網(wǎng)絡(luò)庫(kù)支撐。

            隨著 2000 年前后第一次互聯(lián)網(wǎng)浪潮的興起,業(yè)界對(duì)高并發(fā) http 服務(wù)器的強(qiáng)烈需求大大推動(dòng)了這一領(lǐng)域的研究,目前高性能 httpd 普遍采用的是單線程 reactor 方式。另外一個(gè)說(shuō)法是 IBM Lotus 使用 TCP 長(zhǎng)連接協(xié)議,而把 Lotus 服務(wù)端移植到 Linux 的過(guò)程中 IBM 的工程師們大大提高了 Linux 內(nèi)核在處理并發(fā)連接方面的可伸縮性,因?yàn)橐粋€(gè)公司可能有上萬(wàn)人同時(shí)上線,連接到同一臺(tái)跑著 Lotus server 的 Linux 服務(wù)器。

            可伸縮網(wǎng)絡(luò)編程這個(gè)領(lǐng)域其實(shí)近十年來(lái)沒(méi)什么新東西,POSA2 已經(jīng)作了相當(dāng)全面的總結(jié),另外以下幾篇文章也值得參考。

            http://bulk.fefe.de/scalable-networking.pdf

            http://www.kegel.com/c10k.html

            http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf

            下表是陳碩總結(jié)的 10 種常見(jiàn)方案。其中“多連接互通”指的是如果開(kāi)發(fā) chat 服務(wù),多個(gè)客戶連接之間是否能方便地交換數(shù)據(jù)(chat 也是《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》中舉的三大 TCP 網(wǎng)絡(luò)編程案例之一)。對(duì)于 echo/http/sudoku 這類(lèi)“連接相互獨(dú)立”的服務(wù)程序,這個(gè)功能無(wú)足輕重,但是對(duì)于 chat 類(lèi)服務(wù)至關(guān)重要。“順序性”指的是在 http/sudoku 這類(lèi)請(qǐng)求-響應(yīng)服務(wù)中,如果客戶連接順序發(fā)送多個(gè)請(qǐng)求,那么計(jì)算得到的多個(gè)響應(yīng)是否按相同的順序發(fā)還給客戶(這里指的是在自然條件下,不含刻意同步)。reactor_comparison

            UNP CSDA 方案歸入 0~5。5 也是目前用得很多的單線程 reactor 方案,muduo 對(duì)此提供了很好的支持。6 和 7 其實(shí)不是實(shí)用的方案,只是作為過(guò)渡品。8 和 9 是本文重點(diǎn)介紹的方案,其實(shí)這兩個(gè)方案已經(jīng)在《多線程服務(wù)器的常用編程模型》一文中提到過(guò),只不過(guò)當(dāng)時(shí)我還沒(méi)有寫(xiě) muduo,無(wú)法用具體的代碼示例來(lái)說(shuō)明。

            在對(duì)比各方案之前,我們先看看基本的 micro benchmark 數(shù)據(jù)(前三項(xiàng)由 lmbench 測(cè)得):

            • fork()+exit(): 160us
            • pthread_create()+pthread_join(): 12us
            • context switch : 1.5us
            • sudoku resolve: 100us (根據(jù)題目難度不同,浮動(dòng)范圍 20~200us)

            方案 0:這其實(shí)不是并發(fā)服務(wù)器,而是 iterative 服務(wù)器,因?yàn)樗淮沃荒芊?wù)一個(gè)客戶。代碼見(jiàn) UNP figure 1.9,UNP 以此為對(duì)比其他方案的基準(zhǔn)點(diǎn)。這個(gè)方案不適合長(zhǎng)連接,到是很適合 daytime 這種 write-only 服務(wù)。

            方案 1:這是傳統(tǒng)的 Unix 并發(fā)網(wǎng)絡(luò)編程方案,UNP 稱(chēng)之為 child-per-client 或 fork()-per-client,另外也俗稱(chēng) process-per-connection。這種方案適合并發(fā)連接數(shù)不大的情況。至今仍有一些網(wǎng)絡(luò)服務(wù)程序用這種方式實(shí)現(xiàn),比如 PostgreSQL 和 Perforce 的服務(wù)端。這種方案適合“計(jì)算響應(yīng)的工作量遠(yuǎn)大于 fork() 的開(kāi)銷(xiāo)”這種情況,比如數(shù)據(jù)庫(kù)服務(wù)器。這種方案適合長(zhǎng)連接,但不太適合短連接,因?yàn)?fork() 開(kāi)銷(xiāo)大于求解 sudoku 的用時(shí)。

            方案 2:這是傳統(tǒng)的 Java 網(wǎng)絡(luò)編程方案 thread-per-connection,在 Java 1.4 引入 NIO 之前,Java 網(wǎng)絡(luò)服務(wù)程序多采用這種方案。它的初始化開(kāi)銷(xiāo)比方案 1 要小很多。這種方案的伸縮性受到線程數(shù)的限制,一兩百個(gè)還行,幾千個(gè)的話對(duì)操作系統(tǒng)的 scheduler 恐怕是個(gè)不小的負(fù)擔(dān)。

            方案 3:這是針對(duì)方案 1 的優(yōu)化,UNP 詳細(xì)分析了幾種變化,包括對(duì) accept 驚群?jiǎn)栴}的考慮。

            方案 4:這是對(duì)方案 2 的優(yōu)化,UNP 詳細(xì)分析了它的幾種變化。

            以上幾種方案都是阻塞式網(wǎng)絡(luò)編程,程序(thread-of-control)通常阻塞在 read() 上,等待數(shù)據(jù)到達(dá)。但是 TCP 是個(gè)全雙工協(xié)議,同時(shí)支持 read() 和 write() 操作,當(dāng)一個(gè)線程/進(jìn)程阻塞在 read() 上,但程序又想給這個(gè) TCP 連接發(fā)數(shù)據(jù),那該怎么辦?比如說(shuō) echo client,既要從 stdin 讀,又要從網(wǎng)絡(luò)讀,當(dāng)程序正在阻塞地讀網(wǎng)絡(luò)的時(shí)候,如何處理鍵盤(pán)輸入?又比如 proxy,既要把連接 a 收到的數(shù)據(jù)發(fā)給連接 b,又要把從連接 b 收到的數(shù)據(jù)發(fā)給連接 a,那么到底讀哪個(gè)?(proxy 是《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》中舉的三大 TCP 網(wǎng)絡(luò)編程案例之一。)

            一種方法是用兩個(gè)線程/進(jìn)程,一個(gè)負(fù)責(zé)讀,一個(gè)負(fù)責(zé)寫(xiě)。UNP 也在實(shí)現(xiàn) echo client 時(shí)介紹了這種方案。另外見(jiàn) Python Pinhole 的代碼:http://code.activestate.com/recipes/114642/

            另一種方法是使用 IO multiplexing,也就是 select/poll/epoll/kqueue 這一系列的“多路選擇器”,讓一個(gè) thread-of-control 能處理多個(gè)連接。“IO 復(fù)用”其實(shí)復(fù)用的不是 IO 連接,而是復(fù)用線程。使用 select/poll 幾乎肯定要配合 non-blocking IO,而使用 non-blocking IO 肯定要使用應(yīng)用層 buffer,原因見(jiàn)《Muduo 設(shè)計(jì)與實(shí)現(xiàn)之一:Buffer 類(lèi)的設(shè)計(jì)》。這就不是一件輕松的事兒了,如果每個(gè)程序都去搞一套自己的 IO multiplexing 機(jī)制(本質(zhì)是 event-driven 事件驅(qū)動(dòng)),這是一種很大的浪費(fèi)。感謝 Doug Schmidt 為我們總結(jié)出了 Reactor 模式,讓 event-driven 網(wǎng)絡(luò)編程有章可循。繼而出現(xiàn)了一些通用的 reactor 框架/庫(kù),比如 libevent、muduo、Netty、twisted、POE 等等,有了這些庫(kù),我想基本不用去編寫(xiě)阻塞式的網(wǎng)絡(luò)程序了(特殊情況除外,比如 proxy 流量限制)。

            單線程 reactor 的程序結(jié)構(gòu)是(圖片取自 Doug Lea 的演講):

            reactor_basic

            方案 5:基本的單線程 reactor 方案,即前面的 server_basic.cc 程序。本文以它作為對(duì)比其他方案的基準(zhǔn)點(diǎn)。這種方案的優(yōu)點(diǎn)是由網(wǎng)絡(luò)庫(kù)搞定數(shù)據(jù)收發(fā),程序只關(guān)心業(yè)務(wù)邏輯;缺點(diǎn)在前面已經(jīng)談了:適合 IO 密集的應(yīng)用,不太適合 CPU 密集的應(yīng)用,因?yàn)檩^難發(fā)揮多核的威力。

            方案 6:這是一個(gè)過(guò)渡方案,收到 Sudoku 請(qǐng)求之后,不在 reactor 線程計(jì)算,而是創(chuàng)建一個(gè)新線程去計(jì)算,以充分利用多核 CPU。這是非常初級(jí)的多線程應(yīng)用,因?yàn)樗鼮槊總€(gè)請(qǐng)求(而不是每個(gè)連接)創(chuàng)建了一個(gè)新線程。這個(gè)開(kāi)銷(xiāo)可以用線程池來(lái)避免,即方案 8。這個(gè)方案還有一個(gè)特點(diǎn)是 out-of-order,即同時(shí)創(chuàng)建多個(gè)線程去計(jì)算同一個(gè)連接上收到的多個(gè)請(qǐng)求,那么算出結(jié)果的次序是不確定的,可能第 2 個(gè) Sudoku 比較簡(jiǎn)單,比第 1 個(gè)先算出結(jié)果。這也是為什么我們?cè)谝婚_(kāi)始設(shè)計(jì)協(xié)議的時(shí)候使用了 id,以便客戶端區(qū)分 response 對(duì)應(yīng)的是哪個(gè) request。

            方案 7:為了讓返回結(jié)果的順序確定,我們可以為每個(gè)連接創(chuàng)建一個(gè)計(jì)算線程,每個(gè)連接上的請(qǐng)求固定發(fā)給同一個(gè)線程去算,先到先得。這也是一個(gè)過(guò)渡方案,因?yàn)椴l(fā)連接數(shù)受限于線程數(shù)目,這個(gè)方案或許還不如直接使用阻塞 IO 的 thread-per-connection 方案2。方案 7 與方案 6 的另外一個(gè)區(qū)別是一個(gè) client 的最大 CPU 占用率,在方案 6 中,一個(gè) connection 上發(fā)來(lái)的一長(zhǎng)串突發(fā)請(qǐng)求(burst requests) 可以占滿全部 8 個(gè) core;而在方案 7 中,由于每個(gè)連接上的請(qǐng)求固定由同一個(gè)線程處理,那么它最多占用 12.5% 的 CPU 資源。這兩種方案各有優(yōu)劣,取決于應(yīng)用場(chǎng)景的需要,到底是公平性重要還是突發(fā)性能重要。這個(gè)區(qū)別在方案 8 和方案 9 中同樣存在,需要根據(jù)應(yīng)用來(lái)取舍。

            方案 8:為了彌補(bǔ)方案 6 中為每個(gè)請(qǐng)求創(chuàng)建線程的缺陷,我們使用固定大小線程池,程序結(jié)構(gòu)如下圖。全部的 IO 工作都在一個(gè) reactor 線程完成,而計(jì)算任務(wù)交給 thread pool。如果計(jì)算任務(wù)彼此獨(dú)立,而且 IO 的壓力不大,那么這種方案是非常適用的。Sudoku Solver 正好符合。代碼見(jiàn):http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc 后文給出了它與方案 9 的區(qū)別。

            reactor_threadpool

            如果 IO 的壓力比較大,一個(gè) reactor 忙不過(guò)來(lái),可以試試 multiple reactors 的方案 9。

            方案 9:這是 muduo 內(nèi)置的多線程方案,也是 Netty 內(nèi)置的多線程方案。這種方案的特點(diǎn)是 one loop per thread,有一個(gè) main reactor 負(fù)責(zé) accept 連接,然后把連接掛在某個(gè) sub reactor 中(muduo 采用 round-robin 的方式來(lái)選擇 sub reactor),這樣該連接的所有操作都在那個(gè) sub reactor 所處的線程中完成。多個(gè)連接可能被分派到多個(gè)線程中,以充分利用 CPU。Muduo 采用的是固定大小的 reactor pool,池子的大小通常根據(jù) CPU 核數(shù)確定,也就是說(shuō)線程數(shù)是固定的,這樣程序的總體處理能力不會(huì)隨連接數(shù)增加而下降。另外,由于一個(gè)連接完全由一個(gè)線程管理,那么請(qǐng)求的順序性有保證,突發(fā)請(qǐng)求也不會(huì)占滿全部 8 個(gè)核(如果需要優(yōu)化突發(fā)請(qǐng)求,可以考慮方案 10)。這種方案把 IO 分派給多個(gè)線程,防止出現(xiàn)一個(gè) reactor 的處理能力飽和。與方案 8 的線程池相比,方案 9 減少了進(jìn)出 thread pool 的兩次上下文切換。我認(rèn)為這是一個(gè)適應(yīng)性很強(qiáng)的多線程 IO 模型,因此把它作為 muduo 的默認(rèn)線程模型。

            reactor_multiple

            代碼見(jiàn):http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_multiloop.cc

            server_multiloop.cc 與 server_basic.cc 的區(qū)別很小,關(guān)鍵只有一行代碼:server_.setThreadNum(numThreads);

            $ diff server_basic.cc server_multiloop.cc -up
            --- server_basic.cc     2011-06-15 13:40:59.000000000 +0800
            +++ server_multiloop.cc 2011-06-15 13:39:53.000000000 +0800
            @@ -21,19 +21,22 @@ using namespace muduo::net;
             class SudokuServer
             {
              public:
            -  SudokuServer(EventLoop* loop, const InetAddress& listenAddr)
            +  SudokuServer(EventLoop* loop, const InetAddress& listenAddr, int numThreads)
                 : loop_(loop),
                   server_(loop, listenAddr, "SudokuServer"),
            +      numThreads_(numThreads),
                   startTime_(Timestamp::now())
               {
                 server_.setConnectionCallback(
                     boost::bind(&SudokuServer::onConnection, this, _1));
                 server_.setMessageCallback(
                     boost::bind(&SudokuServer::onMessage, this, _1, _2, _3));
            +    server_.setThreadNum(numThreads);
               }
            
            

            方案 8 使用 thread pool 的代碼與使用多 reactors 的方案 9 相比變化不大,只是把原來(lái) onMessage() 中涉及計(jì)算和發(fā)回響應(yīng)的部分抽出來(lái)做成一個(gè)函數(shù),然后交給 ThreadPool 去計(jì)算。記住方案 8 有 out-of-order 的可能,客戶端要根據(jù) id 來(lái)匹配響應(yīng)。

            $ diff server_multiloop.cc server_threadpool.cc -up
            --- server_multiloop.cc 2011-06-15 13:39:53.000000000 +0800
            +++ server_threadpool.cc        2011-06-15 14:07:52.000000000 +0800
            @@ -31,12 +32,12 @@ class SudokuServer
                     boost::bind(&SudokuServer::onConnection, this, _1));
                 server_.setMessageCallback(
                     boost::bind(&SudokuServer::onMessage, this, _1, _2, _3));
            -    server_.setThreadNum(numThreads);
               }
            
               void start()
               {
                 LOG_INFO << "starting " << numThreads_ << " threads.";
            +    threadPool_.start(numThreads_);
                 server_.start();
               }
            
            @@ -68,15 +69,7 @@ class SudokuServer
                     }
                     if (request.size() == implicit_cast<size_t>(kCells))
                     {
            -          string result = solveSudoku(request);
            -          if (id.empty())
            -          {
            -            conn->send(result+"\r\n");
            -          }
            -          else
            -          {
            -            conn->send(id+":"+result+"\r\n");
            -          }
            +          threadPool_.run(boost::bind(solve, conn, request, id));
                     }
                     else
                     {
            @@ -91,8 +84,23 @@ class SudokuServer
                 }
               }
            
            +  static void solve(const TcpConnectionPtr& conn, const string& request, const string& id)
            +  {
            +    LOG_DEBUG << conn->name();
            +    string result = solveSudoku(request);
            +    if (id.empty())
            +    {
            +      conn->send(result+"\r\n");
            +    }
            +    else
            +    {
            +      conn->send(id+":"+result+"\r\n");
            +    }
            +  }
            +
               EventLoop* loop_;
               TcpServer server_;
            +  ThreadPool threadPool_;
               int numThreads_;
               Timestamp startTime_;
             };
            

            完整代碼見(jiàn):http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc

            方案 10:把方案 8 和方案 9 混合,既使用多個(gè) reactors 來(lái)處理 IO,又使用線程池來(lái)處理計(jì)算。這種方案適合既有突發(fā) IO (利用多線程處理多個(gè)連接上的 IO),又有突發(fā)計(jì)算的應(yīng)用(利用線程池把一個(gè)連接上的計(jì)算任務(wù)分配給多個(gè)線程去做)。

            reactor_hybrid

            這種其實(shí)方案看起來(lái)復(fù)雜,其實(shí)寫(xiě)起來(lái)很簡(jiǎn)單,只要把方案 8 的代碼加一行 server_.setThreadNum(numThreads); 就行,這里就不舉例了。

            結(jié)語(yǔ)

            我在《多線程服務(wù)器的常用編程模型》一文中說(shuō)

            總結(jié)起來(lái),我推薦的多線程服務(wù)端編程模式為:event loop per thread + thread pool。

            • event loop 用作 non-blocking IO 和定時(shí)器。
            • thread pool 用來(lái)做計(jì)算,具體可以是任務(wù)隊(duì)列或消費(fèi)者-生產(chǎn)者隊(duì)列。

            當(dāng)時(shí)(2010年2月)我還說(shuō)“以這種方式寫(xiě)服務(wù)器程序,需要一個(gè)優(yōu)質(zhì)的基于 Reactor 模式的網(wǎng)絡(luò)庫(kù)來(lái)支撐,我只用過(guò)in-house的產(chǎn)品,無(wú)從比較并推薦市面上常見(jiàn)的 C++ 網(wǎng)絡(luò)庫(kù),抱歉。

            現(xiàn)在有了 muduo 網(wǎng)絡(luò)庫(kù),我終于能夠用具體的代碼示例把思想完整地表達(dá)出來(lái)。

            posted @ 2011-06-16 12:58 陳碩 閱讀(5935) | 評(píng)論 (2)編輯 收藏

            談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)(06-08更新)

            談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)

            陳碩

            giantchen@gmail.com

            blog.csdn.net/Solstice

            2011-06-08

            PDF 版下載:https://github.com/downloads/chenshuo/documents/LearningNetworkProgramming.pdf

            本文談一談我在學(xué)習(xí)網(wǎng)絡(luò)編程方面的一些個(gè)人經(jīng)驗(yàn)。“網(wǎng)絡(luò)編程”這個(gè)術(shù)語(yǔ)的范圍很廣,本文指用Sockets API開(kāi)發(fā)基于TCP/IP的網(wǎng)絡(luò)應(yīng)用程序,具體定義見(jiàn)“網(wǎng)絡(luò)編程的各種任務(wù)角色”一節(jié)。

            受限于本人的經(jīng)歷和經(jīng)驗(yàn),這篇文章的適應(yīng)范圍是:

            · x86-64 Linux服務(wù)端網(wǎng)絡(luò)編程,直接或間接使用 Sockets API

            · 公司內(nèi)網(wǎng)。不一定是局域網(wǎng),但總體位于公司防火墻之內(nèi),環(huán)境可控

            本文可能不適合:

            · PC客戶端網(wǎng)絡(luò)編程,程序運(yùn)行在客戶的PC上,環(huán)境多變且不可控

            · Windows網(wǎng)絡(luò)編程

            · 面向公網(wǎng)的服務(wù)程序

            · 高性能網(wǎng)絡(luò)服務(wù)器

            本文分兩個(gè)部分:

            1. 網(wǎng)絡(luò)編程的一些胡思亂想,談?wù)勎覍?duì)這一領(lǐng)域的認(rèn)識(shí)

            2. 幾本必看的書(shū),基本上還是W. Richard Stevents那幾本

            另外,本文沒(méi)有特別說(shuō)明時(shí)均暗指TCP協(xié)議,“連接”是“TCP連接”,“服務(wù)端”是“TCP服務(wù)端”。

            網(wǎng)絡(luò)編程的一些胡思亂想

            以下胡亂列出我對(duì)網(wǎng)絡(luò)編程的一些想法,前后無(wú)關(guān)聯(lián)。

            網(wǎng)絡(luò)編程是什么?

            網(wǎng)絡(luò)編程是什么?是熟練使用Sockets API嗎?說(shuō)實(shí)話,在實(shí)際項(xiàng)目里我只用過(guò)兩次Sockets API,其他時(shí)候都是使用封裝好的網(wǎng)絡(luò)庫(kù)。

            第一次是2005年在學(xué)校做一個(gè)羽毛球賽場(chǎng)計(jì)分系統(tǒng):我用C# 編寫(xiě)運(yùn)行在PC機(jī)上的軟件,負(fù)責(zé)比分的顯示;再用C# 寫(xiě)了運(yùn)行在PDA上的計(jì)分界面,記分員拿著PDA記錄比分;這兩部分程序通過(guò) TCP協(xié)議相互通信。這其實(shí)是個(gè)簡(jiǎn)單的分布式系統(tǒng),體育館有不止一片場(chǎng)地,每個(gè)場(chǎng)地都有一名拿PDA的記分員,每個(gè)場(chǎng)地都有兩臺(tái)顯示比分的PC機(jī)(顯示器是42吋平板電視,放在場(chǎng)地的對(duì)角,這樣兩邊看臺(tái)的觀眾都能看到比分)。這兩臺(tái)PC機(jī)功能不完全一樣,一臺(tái)只負(fù)責(zé)顯示當(dāng)前比分,另一臺(tái)還要負(fù)責(zé)與PDA通信,并更新數(shù)據(jù)庫(kù)里的比分信息。此外,還有一臺(tái)PC機(jī)負(fù)責(zé)周期性地從數(shù)據(jù)庫(kù)讀出全部7片場(chǎng)地的比分,顯示在體育館墻上的大屏幕上。這臺(tái)PC上還運(yùn)行著一個(gè)程序,負(fù)責(zé)生成比分?jǐn)?shù)據(jù)的靜態(tài)頁(yè)面,通過(guò)FTP上傳發(fā)布到某門(mén)戶網(wǎng)站的體育頻道。系統(tǒng)中還有一個(gè)錄入賽程(參賽隊(duì),運(yùn)動(dòng)員,出場(chǎng)順序等)數(shù)據(jù)庫(kù)的程序,運(yùn)行在數(shù)據(jù)庫(kù)服務(wù)器上。算下來(lái)整個(gè)系統(tǒng)有十來(lái)個(gè)程序,運(yùn)行在二十多臺(tái)設(shè)備(PC和PDA)上,還要考慮可靠性。將來(lái)有機(jī)會(huì)把這個(gè)小系統(tǒng)仔細(xì)講一講,挺有意思的。

            這是我第一次寫(xiě)實(shí)際項(xiàng)目中的網(wǎng)絡(luò)程序,當(dāng)時(shí)寫(xiě)下來(lái)的感覺(jué)是像寫(xiě)命令行與用戶交互的程序:程序在命令行輸出一句提示語(yǔ),等待客戶輸入一句話,然后處理客戶輸入,再輸出下一句提示語(yǔ),如此循環(huán)。只不過(guò)這里的“客戶”不是人,而是另一個(gè)程序。在建立好TCP連接之后,雙方的程序都是read/write循環(huán)(為求簡(jiǎn)單,我用的是blocking讀寫(xiě)),直到有一方斷開(kāi)連接。

            第二次是2010年編寫(xiě)muduo網(wǎng)絡(luò)庫(kù),我再次拿起了Sockets API,寫(xiě)了一個(gè)基于Reactor模式的C++ 網(wǎng)絡(luò)庫(kù)。寫(xiě)這個(gè)庫(kù)的目的之一就是想讓日常的網(wǎng)絡(luò)編程從Sockets API的瑣碎細(xì)節(jié)中解脫出來(lái),讓程序員專(zhuān)注于業(yè)務(wù)邏輯,把時(shí)間用在刀刃上。Muduo 網(wǎng)絡(luò)庫(kù)的示例代碼包含了幾十個(gè)網(wǎng)絡(luò)程序,這些示例程序都沒(méi)有直接使用Sockets API。

            在此之外,無(wú)論是實(shí)習(xí)還是工作,雖然我寫(xiě)的程序都會(huì)通過(guò)TCP協(xié)議與其他程序打交道,但我沒(méi)有直接使用過(guò)Sockets API。對(duì)于TCP網(wǎng)絡(luò)編程,我認(rèn)為核心是處理“三個(gè)半事件”,見(jiàn)《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中的“TCP 網(wǎng)絡(luò)編程本質(zhì)論”。程序員的主要工作是在事件處理函數(shù)中實(shí)現(xiàn)業(yè)務(wù)邏輯,而不是和Sockets API較勁。

            這里還是沒(méi)有說(shuō)清楚“網(wǎng)絡(luò)編程”是什么,請(qǐng)繼續(xù)閱讀后文“網(wǎng)絡(luò)編程的各種任務(wù)角色”。

            學(xué)習(xí)網(wǎng)絡(luò)編程有用嗎?

            以上說(shuō)的是比較底層的網(wǎng)絡(luò)編程,程序代碼直接面對(duì)從TCP或UDP收到的數(shù)據(jù)以及構(gòu)造數(shù)據(jù)包發(fā)出去。在實(shí)際工作中,另一種常見(jiàn) 的情況是通過(guò)各種 client library 來(lái)與服務(wù)端打交道,或者在現(xiàn)成的框架中填空來(lái)實(shí)現(xiàn)server,或者采用更上層的通信方式。比如用libmemcached與memcached打交道,使用libpq來(lái)與PostgreSQL 打交道,編寫(xiě)Servlet來(lái)響應(yīng)http請(qǐng)求,使用某種RPC與其他進(jìn)程通信,等等。這些情況都會(huì)發(fā)生網(wǎng)絡(luò)通信,但不一定算作“網(wǎng)絡(luò)編程”。如果你的工作是前面列舉的這些,學(xué)習(xí)TCP/IP網(wǎng)絡(luò)編程還有用嗎?

            我認(rèn)為還是有必要學(xué)一學(xué),至少在troubleshooting 的時(shí)候有用。無(wú)論如何,這些library或framework都會(huì)調(diào)用底層的Sockets API來(lái)實(shí)現(xiàn)網(wǎng)絡(luò)功能。當(dāng)你的程序遇到一個(gè)線上問(wèn)題,如果你熟悉Sockets API,那么從strace不難發(fā)現(xiàn)程序卡在哪里,盡管可能你沒(méi)有直接調(diào)用這些Sockets API。另外,熟悉TCP/IP協(xié)議、會(huì)用tcpdump也大大有助于分析解決線上網(wǎng)絡(luò)服務(wù)問(wèn)題。

            在什么平臺(tái)上學(xué)習(xí)網(wǎng)絡(luò)編程?

            對(duì)于服務(wù)端網(wǎng)絡(luò)編程,我建議在Linux上學(xué)習(xí)。

            如果在10年前,這個(gè)問(wèn)題的答案或許是FreeBSD,因?yàn)镕reeBSD根正苗紅,在2000年那一次互聯(lián)網(wǎng)浪潮中扮演了重要角色,是很多公司首選的免費(fèi)服務(wù)器操作系統(tǒng)。2000年那會(huì)兒Linux還遠(yuǎn)未成熟,連epoll都還沒(méi)有實(shí)現(xiàn)。(FreeBSD在2001年發(fā)布4.1版,加入了kqueue,從此C10k不是問(wèn)題。)

            10年后的今天,事情起了變化,Linux成為了市場(chǎng)份額最大的服務(wù)器操作系統(tǒng)(http://en.wikipedia.org/wiki/Usage_share_of_operating_systems)。在Linux這種大眾系統(tǒng)上學(xué)網(wǎng)絡(luò)編程,遇到什么問(wèn)題會(huì)比較容易解決。因?yàn)橛玫娜硕啵阌龅降膯?wèn)題別人多半也遇到過(guò);同樣因?yàn)橛玫娜硕啵绻娴挠惺裁磧?nèi)核bug,很快就會(huì)得到修復(fù),至少有work around的辦法。如果用別的系統(tǒng),可能一個(gè)問(wèn)題發(fā)到論壇上半個(gè)月都不會(huì)有人理。從內(nèi)核源碼的風(fēng)格看,F(xiàn)reeBSD更干凈整潔,注釋到位,但是無(wú)奈它的市場(chǎng)份額遠(yuǎn)不如Linux,學(xué)習(xí)Linux是更好的技術(shù)投資。

            可移植性重要嗎?

            寫(xiě)網(wǎng)絡(luò)程序要不要考慮移植性?這取決于項(xiàng)目需要,如果貴公司做的程序要賣(mài)給其他公司,而對(duì)方可能使用Windows、Linux、FreeBSD、Solaris、AIX、HP-UX等等操作系統(tǒng),這時(shí)候考慮移植性。如果編寫(xiě)公司內(nèi)部的服務(wù)器上用的網(wǎng)絡(luò)程序,那么大可只關(guān)注一個(gè)平臺(tái),比如Linux。因?yàn)榫帉?xiě)和維護(hù)可移植的網(wǎng)絡(luò)程序的代價(jià)相當(dāng)高,平臺(tái)間的差異可能遠(yuǎn)比想象中大,即便是POSIX系統(tǒng)之間也有不小的差異(比如Linux沒(méi)有SO_NOSIGPIPE選項(xiàng)),錯(cuò)誤的返回碼也大不一樣。

            我就不打算把muduo往Windows或其他操作系統(tǒng)移植。如果需要編寫(xiě)可移植的網(wǎng)絡(luò)程序,我寧愿用libevent或者Java Netty這樣現(xiàn)成的庫(kù),把臟活累活留給別人。

            網(wǎng)絡(luò)編程的各種任務(wù)角色

            計(jì)算機(jī)網(wǎng)絡(luò)是個(gè) big topic,涉及很多人物和角色,既有開(kāi)發(fā)人員,也有運(yùn)維人員。比方說(shuō):公司內(nèi)部?jī)膳_(tái)機(jī)器之間 ping 不通,通常由網(wǎng)絡(luò)運(yùn)維人員解決,看看是布線有問(wèn)題還是路由器設(shè)置不對(duì);兩臺(tái)機(jī)器能ping通,但是程序連不上,經(jīng)檢查是本機(jī)防火墻設(shè)置有問(wèn)題,通常由系統(tǒng)管理員解決;兩臺(tái)機(jī)器能連上,但是丟包很?chē)?yán)重,發(fā)現(xiàn)是網(wǎng)卡或者交換機(jī)的網(wǎng)口故障,由硬件維修人員解決;兩臺(tái)機(jī)器的程序能連上,但是偶爾發(fā)過(guò)去的請(qǐng)求得不到響應(yīng),通常是程序bug,應(yīng)該由開(kāi)發(fā)人員解決。

            本文主要關(guān)心開(kāi)發(fā)人員這一角色。下面簡(jiǎn)單列出一些我能想到的跟網(wǎng)絡(luò)打交道的編程任務(wù),其中前三項(xiàng)是面向網(wǎng)絡(luò)本身,后面幾項(xiàng)是在計(jì)算機(jī)網(wǎng)絡(luò)之上構(gòu)建信息系統(tǒng)。

            1. 開(kāi)發(fā)網(wǎng)絡(luò)設(shè)備,編寫(xiě)防火墻、交換機(jī)、路由器的固件 firmware

            2. 開(kāi)發(fā)或移植網(wǎng)卡的驅(qū)動(dòng)

            3. 移植或維護(hù)TCP/IP協(xié)議棧(特別是在嵌入式系統(tǒng)上)

            4. 開(kāi)發(fā)或維護(hù)標(biāo)準(zhǔn)的網(wǎng)絡(luò)協(xié)議程序,HTTP、FTP、DNS、SMTP、POP3、NFS

            5. 開(kāi)發(fā)標(biāo)準(zhǔn)網(wǎng)絡(luò)協(xié)議的“附加品”,比如HAProxy、squid、varnish等web load balancer

            6. 開(kāi)發(fā)標(biāo)準(zhǔn)或非標(biāo)準(zhǔn)網(wǎng)絡(luò)服務(wù)的客戶端庫(kù),比如ZooKeeper客戶端庫(kù),memcached客戶端庫(kù)

            7. 開(kāi)發(fā)與公司業(yè)務(wù)直接相關(guān)的網(wǎng)絡(luò)服務(wù)程序,比如即時(shí)聊天軟件的后臺(tái)服務(wù)器,網(wǎng)游服務(wù)器,金融交易系統(tǒng),互聯(lián)網(wǎng)企業(yè)用的分布式海量存儲(chǔ),微博發(fā)帖的內(nèi)部廣播通知,等等

            8. 客戶端程序中涉及網(wǎng)絡(luò)的部分,比如郵件客戶端中與 POP3、SMTP通信的部分,以及網(wǎng)游的客戶端程序中與服務(wù)器通信的部分

            本文所指的“網(wǎng)絡(luò)編程”專(zhuān)指第7項(xiàng),即在TCP/IP協(xié)議之上開(kāi)發(fā)業(yè)務(wù)軟件。

            面向業(yè)務(wù)的網(wǎng)絡(luò)編程的特點(diǎn)

            跟開(kāi)發(fā)通用的網(wǎng)絡(luò)程序不同,開(kāi)發(fā)面向公司業(yè)務(wù)的專(zhuān)用網(wǎng)絡(luò)程序有其特點(diǎn):

            · 業(yè)務(wù)邏輯比較復(fù)雜,而且時(shí)常變化

            如果寫(xiě)一個(gè)HTTP服務(wù)器,在大致實(shí)現(xiàn)HTTP /1.1標(biāo)準(zhǔn)之后,程序的主體功能一般不會(huì)有太大的變化,程序員會(huì)把時(shí)間放在性能調(diào)優(yōu)和bug修復(fù)上。而開(kāi)發(fā)針對(duì)公司業(yè)務(wù)的專(zhuān)用程序時(shí),功能說(shuō)明書(shū)(spec)很可能不如HTTP/1.1標(biāo)準(zhǔn)那么細(xì)致明確。更重要的是,程序是快速演化的。以即時(shí)聊天工具的后臺(tái)服務(wù)器為例,可能第一版只支持在線聊天;幾個(gè)月之后發(fā)布第二版,支持離線消息;又過(guò)了幾個(gè)月,第三版支持隱身聊天;隨后,第四版支持上傳頭像;如此等等。這要求程序員能快速響應(yīng)新的業(yè)務(wù)需求,公司才能保持競(jìng)爭(zhēng)力。

            · 不一定需要遵循公認(rèn)的通信協(xié)議標(biāo)準(zhǔn)

            比方說(shuō)網(wǎng)游服務(wù)器就沒(méi)什么協(xié)議標(biāo)準(zhǔn),反正客戶端和服務(wù)端都是本公司開(kāi)發(fā),如果發(fā)現(xiàn)目前的協(xié)議設(shè)計(jì)有問(wèn)題,兩邊一起改了就是了。

            · 程序結(jié)構(gòu)沒(méi)有定論

            對(duì)于高并發(fā)大吞吐的標(biāo)準(zhǔn)網(wǎng)絡(luò)服務(wù),一般采用單線程事件驅(qū)動(dòng)的方式開(kāi)發(fā),比如HAProxy、lighttpd等都是這個(gè)模式。但是對(duì)于專(zhuān)用的業(yè)務(wù)系統(tǒng),其業(yè)務(wù)邏輯比較復(fù)雜,占用較多的CPU資源,這種單線程事件驅(qū)動(dòng)方式不見(jiàn)得能發(fā)揮現(xiàn)在多核處理器的優(yōu)勢(shì)。這留給程序員比較大的自由發(fā)揮空間,做好了橫掃千軍,做爛了一敗涂地。

            · 性能評(píng)判的標(biāo)準(zhǔn)不同

            如果開(kāi)發(fā)httpd這樣的通用服務(wù),必然會(huì)和開(kāi)源的Nginx、lighttpd等高性能服務(wù)器比較,程序員要投入相當(dāng)?shù)木θ?yōu)化程序,才能在市場(chǎng)上占有一席之地。而面向業(yè)務(wù)的專(zhuān)用網(wǎng)絡(luò)程序不一定有開(kāi)源的實(shí)現(xiàn)以供對(duì)比性能,程序員通常更加注重功能的穩(wěn)定性與開(kāi)發(fā)的便捷性。性能只要一代比一代強(qiáng)即可。

            · 網(wǎng)絡(luò)編程起到支撐作用,但不處于主導(dǎo)地位

            程序員的主要工作是實(shí)現(xiàn)業(yè)務(wù)邏輯,而不只是實(shí)現(xiàn)網(wǎng)絡(luò)通信協(xié)議。這要求程序員深入理解業(yè)務(wù)。程序的性能瓶頸不一定在網(wǎng)絡(luò)上,瓶頸有可能是CPU、Disk IO、數(shù)據(jù)庫(kù)等等,這時(shí)優(yōu)化網(wǎng)絡(luò)方面的代碼并不能提高整體性能。只有對(duì)所在的領(lǐng)域有深入的了解,明白各種因素的權(quán)衡(trade-off),才能做出一些有針對(duì)性的優(yōu)化。

            幾個(gè)術(shù)語(yǔ)

            互聯(lián)網(wǎng)上的很多口水戰(zhàn)是由對(duì)同一術(shù)語(yǔ)的不同理解引起的,比我寫(xiě)的《多線程服務(wù)器的適用場(chǎng)合》就曾經(jīng)人被說(shuō)是“掛羊頭賣(mài)狗肉”,因?yàn)檫@篇文章中舉的 master例子“根本就算不上是個(gè)網(wǎng)絡(luò)服務(wù)器。因?yàn)樗钠款i根本就跟網(wǎng)絡(luò)無(wú)關(guān)。”

            · 網(wǎng)絡(luò)服務(wù)器

            “網(wǎng)絡(luò)服務(wù)器”這個(gè)術(shù)語(yǔ)確實(shí)含義模糊,到底指硬件還是軟件?到底是服務(wù)于網(wǎng)絡(luò)本身的機(jī)器(交換機(jī)、路由器、防火墻、NAT),還是利用網(wǎng)絡(luò)為其他人或程序提供服務(wù)的機(jī)器(打印服務(wù)器、文件服務(wù)器、郵件服務(wù)器)。每個(gè)人根據(jù)自己熟悉的領(lǐng)域,可能會(huì)有不同的解讀。比方說(shuō)或許有人認(rèn)為只有支持高并發(fā)高吞吐的才算是網(wǎng)絡(luò)服務(wù)器。

            為了避免無(wú)謂的爭(zhēng)執(zhí),我只用“網(wǎng)絡(luò)服務(wù)程序”或者“網(wǎng)絡(luò)應(yīng)用程序”這種含義明確的術(shù)語(yǔ)。“開(kāi)發(fā)網(wǎng)絡(luò)服務(wù)程序”通常不會(huì)造成誤解。

            · 客戶端?服務(wù)端?

            在TCP網(wǎng)絡(luò)編程里邊,客戶端和服務(wù)端很容易區(qū)分,主動(dòng)發(fā)起連接的是客戶端,被動(dòng)接受連接的是服務(wù)端。當(dāng)然,這個(gè)“客戶端”本身也可能是個(gè)后臺(tái)服務(wù)程序,HTTP Proxy對(duì)HTTP Server來(lái)說(shuō)就是個(gè)客戶端。

            · 客戶端編程?服務(wù)端編程?

            但是“服務(wù)端編程”和“客戶端編程”就不那么好區(qū)分。比如 Web crawler,它會(huì)主動(dòng)發(fā)起大量連接,扮演的是HTTP客戶端的角色,但似乎應(yīng)該歸入“服務(wù)端編程”。又比如寫(xiě)一個(gè) HTTP proxy,它既會(huì)扮演服務(wù)端——被動(dòng)接受 web browser 發(fā)起的連接,也會(huì)扮演客戶端——主動(dòng)向 HTTP server 發(fā)起連接,它究竟算服務(wù)端還是客戶端?我猜大多數(shù)人會(huì)把它歸入服務(wù)端編程。

            那么究竟如何定義“服務(wù)端編程”?

            服務(wù)端編程需要處理大量并發(fā)連接?也許是,也許不是。比如云風(fēng)在一篇介紹網(wǎng)游服務(wù)器的博客http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html中就談到,網(wǎng)游中用到的“連接服務(wù)器”需要處理大量連接,而“邏輯服務(wù)器”只有一個(gè)外部連接。那么開(kāi)發(fā)這種網(wǎng)游“邏輯服務(wù)器”算服務(wù)端編程還是客戶端編程呢?

            我認(rèn)為,“服務(wù)端網(wǎng)絡(luò)編程”指的是編寫(xiě)沒(méi)有用戶界面的長(zhǎng)期運(yùn)行的網(wǎng)絡(luò)程序,程序默默地運(yùn)行在一臺(tái)服務(wù)器上,通過(guò)網(wǎng)絡(luò)與其他程序打交道,而不必和人打交道。與之對(duì)應(yīng)的是客戶端網(wǎng)絡(luò)程序,要么是短時(shí)間運(yùn)行,比如wget;要么是有用戶界面(無(wú)論是字符界面還是圖形界面)。本文主要談服務(wù)端網(wǎng)絡(luò)編程。

            7x24重要嗎??jī)?nèi)存碎片可怕嗎?

            一談到服務(wù)端網(wǎng)絡(luò)編程,有人立刻會(huì)提出7x24運(yùn)行的要求。對(duì)于某些網(wǎng)絡(luò)設(shè)備而言,這是合理的需求,比如交換機(jī)、路由器。對(duì)于開(kāi)發(fā)商業(yè)系統(tǒng),我認(rèn)為要求程序7x24運(yùn)行通常是系統(tǒng)設(shè)計(jì)上考慮不周。具體見(jiàn)《分布式系統(tǒng)的工程化開(kāi)發(fā)方法》第20頁(yè)起。重要的不是7x24,而是在程序不必做到7x24的情況下也能達(dá)到足夠高的可用性。一個(gè)考慮周到的系統(tǒng)應(yīng)該允許每個(gè)進(jìn)程都能隨時(shí)重啟,這樣才能在廉價(jià)的服務(wù)器硬件上做到高可用性。

            既然不要求7x24,那么也不必害怕內(nèi)存碎片,理由如下:

            · 64-bit系統(tǒng)的地址空間足夠大,不會(huì)出現(xiàn)沒(méi)有足夠的連續(xù)空間這種情況。

            · 現(xiàn)在的內(nèi)存分配器(malloc及其第三方實(shí)現(xiàn))今非昔比,除了memcached這種純以?xún)?nèi)存為賣(mài)點(diǎn)的程序需要自己設(shè)計(jì)分配器之外,其他網(wǎng)絡(luò)程序大可使用系統(tǒng)自帶的malloc或者某個(gè)第三方實(shí)現(xiàn)。

            · Linux Kernel也大量用到了動(dòng)態(tài)內(nèi)存分配。既然操作系統(tǒng)內(nèi)核都不怕動(dòng)態(tài)分配內(nèi)存造成碎片,應(yīng)用程序?yàn)槭裁匆ε拢?

            · 內(nèi)存碎片如何度量?有沒(méi)有什么工具能為當(dāng)前進(jìn)程的內(nèi)存碎片狀況評(píng)個(gè)分?如果不能比較兩種方案的內(nèi)存碎片程度,談何優(yōu)化?

            有人為了避免內(nèi)存碎片,不使用STL容器,也不敢new/delete,這算是premature optimization還是因噎廢食呢?

            協(xié)議設(shè)計(jì)是網(wǎng)絡(luò)編程的核心

            對(duì)于專(zhuān)用的業(yè)務(wù)系統(tǒng),協(xié)議設(shè)計(jì)是核心任務(wù),決定了系統(tǒng)的開(kāi)發(fā)難度與可靠性,但是這個(gè)領(lǐng)域還沒(méi)有形成大家公認(rèn)的設(shè)計(jì)流程。

            系統(tǒng)中哪個(gè)程序發(fā)起連接,哪個(gè)程序接受連接?如果寫(xiě)標(biāo)準(zhǔn)的網(wǎng)絡(luò)服務(wù),那么這不是問(wèn)題,按RFC來(lái)就行了。自己設(shè)計(jì)業(yè)務(wù)系統(tǒng),有沒(méi)有章法可循?以網(wǎng)游為例,到底是連接服務(wù)器主動(dòng)連接邏輯服務(wù)器,還是邏輯服務(wù)器主動(dòng)連接“連接服務(wù)器”?似乎沒(méi)有定論,兩種做法都行。一般可以按照“依賴(lài)->被依賴(lài)”的關(guān)系來(lái)設(shè)計(jì)發(fā)起連接的方向。

            比新建連接難的是關(guān)閉連接。在傳統(tǒng)的網(wǎng)絡(luò)服務(wù)中(特別是短連接服務(wù)),不少是服務(wù)端主動(dòng)關(guān)閉連接,比如daytime、HTTP/1.0。也有少部分是客戶端主動(dòng)關(guān)閉連接,通常是些長(zhǎng)連接服務(wù),比如 echo、chargen等。我們自己的業(yè)務(wù)系統(tǒng)該如何設(shè)計(jì)連接關(guān)閉協(xié)議呢?

            服務(wù)端主動(dòng)關(guān)閉連接的缺點(diǎn)之一是會(huì)多占用服務(wù)器資源。服務(wù)端主動(dòng)關(guān)閉連接之后會(huì)進(jìn)入TIME_WAIT狀態(tài),在一段時(shí)間之內(nèi)hold住一些內(nèi)核資源。如果并發(fā)訪問(wèn)量很高,這會(huì)影響服務(wù)端的處理能力。這似乎暗示我們應(yīng)該把協(xié)議設(shè)計(jì)為客戶端主動(dòng)關(guān)閉,讓TIME_WAIT狀態(tài)分散到多臺(tái)客戶機(jī)器上,化整為零。

            這又有另外的問(wèn)題:客戶端賴(lài)著不走怎么辦?會(huì)不會(huì)造成拒絕服務(wù)攻擊?或許有一個(gè)二者結(jié)合的方案:客戶端在收到響應(yīng)之后就應(yīng)該主動(dòng)關(guān)閉,這樣把 TIME_WAIT 留在客戶端。服務(wù)端有一個(gè)定時(shí)器,如果客戶端若干秒鐘之內(nèi)沒(méi)有主動(dòng)斷開(kāi),就踢掉它。這樣善意的客戶端會(huì)把TIME_WAIT留給自己,buggy的客戶端會(huì)把 TIME_WAIT留給服務(wù)端。或者干脆使用長(zhǎng)連接協(xié)議,這樣避免頻繁創(chuàng)建銷(xiāo)毀連接。

            比連接的建立與斷開(kāi)更重要的是設(shè)計(jì)消息協(xié)議。消息格式很好辦,XML、JSON、Protobuf都是很好的選擇;難的是消息內(nèi)容。一個(gè)消息應(yīng)該包含哪些內(nèi)容?多個(gè)程序相互通信如何避免race condition(見(jiàn)《分布式系統(tǒng)的工程化開(kāi)發(fā)方法》p.16的例子)?系統(tǒng)的全局狀態(tài)該如何躍遷?可惜這方面可供參考的例子不多,也沒(méi)有太多通用的指導(dǎo)原則,我知道的只有30年前提出的end-to-end principle和happens-before relationship。只能從實(shí)踐中慢慢積累了。

            網(wǎng)絡(luò)編程的三個(gè)層次

            侯捷先生在《漫談程序員與編程》中講到 STL 運(yùn)用的三個(gè)檔次:“會(huì)用STL,是一種檔次。對(duì)STL原理有所了解,又是一個(gè)檔次。追蹤過(guò)STL源碼,又是一個(gè)檔次。第三種檔次的人用起 STL 來(lái),虎虎生風(fēng)之勢(shì)絕非第一檔次的人能夠望其項(xiàng)背。”

            我認(rèn)為網(wǎng)絡(luò)編程也可以分為三個(gè)層次:

            1. 讀過(guò)教程和文檔

            2. 熟悉本系統(tǒng)TCP/IP協(xié)議棧的脾氣

            3. 自己寫(xiě)過(guò)一個(gè)簡(jiǎn)單的TCP/IP stack

            第一個(gè)層次是基本要求,讀過(guò)《Unix網(wǎng)絡(luò)編程》這樣的編程教材,讀過(guò)《TCP/IP詳解》基本理解TCP/IP協(xié)議,讀過(guò)本系統(tǒng)的manpage。這個(gè)層次可以編寫(xiě)一些基本的網(wǎng)絡(luò)程序,完成常見(jiàn)的任務(wù)。但網(wǎng)絡(luò)編程不是照貓畫(huà)虎這么簡(jiǎn)單,若是按照manpage的功能描述就能編寫(xiě)產(chǎn)品級(jí)的網(wǎng)絡(luò)程序,那人生就太幸福了。

            第二個(gè)層次,熟悉本系統(tǒng)的TCP/IP協(xié)議棧參數(shù)設(shè)置與優(yōu)化是開(kāi)發(fā)高性能網(wǎng)絡(luò)程序的必備條件。摸透協(xié)議棧的脾氣還能解決工作中遇到的比較復(fù)雜的網(wǎng)絡(luò)問(wèn)題。拿Linux的TCP/IP協(xié)議棧來(lái)說(shuō):

            · 有可能出現(xiàn)自連接(見(jiàn)《學(xué)之者生,用之者死——ACE歷史與簡(jiǎn)評(píng)》舉的三個(gè)硬傷),程序應(yīng)該有所準(zhǔn)備。

            · Linux的內(nèi)核會(huì)有bug,比如某種TCP擁塞控制算法曾經(jīng)出現(xiàn)TCP window clamping(窗口箝位)bug,導(dǎo)致吞吐量暴跌,可以選用其他擁塞控制算法來(lái)繞開(kāi)(work around)這個(gè)問(wèn)題。

            這些陰暗角落在manpage里沒(méi)有描述,要通過(guò)其他渠道了解。

            編寫(xiě)可靠的網(wǎng)絡(luò)程序的關(guān)鍵是熟悉各種場(chǎng)景下的error code(文件描述符用完了如何?本地ephemeral port暫時(shí)用完,不能發(fā)起新連接怎么辦?服務(wù)端新建并發(fā)連接太快,backlog用完了,客戶端connect會(huì)返回什么錯(cuò)誤?),有的在manpage里有描述,有的要通過(guò)實(shí)踐或閱讀源碼獲得。

            第三個(gè)層次,通過(guò)自己寫(xiě)一個(gè)簡(jiǎn)單的TCP/IP協(xié)議棧,能大大加深對(duì)TCP/IP的理解,更能明白TCP為什么要這么設(shè)計(jì),有哪些因素制約,每一步操作的代價(jià)是什么,寫(xiě)起網(wǎng)絡(luò)程序來(lái)更是成竹在胸。

            其實(shí)實(shí)現(xiàn)TCP/IP只需要操作系統(tǒng)提供三個(gè)接口函數(shù):一個(gè)函數(shù),兩個(gè)回調(diào)函數(shù)。分別是:send_packet()、on_receive_packet()、on_timer()。多年前有一篇文章《使用libnet與libpcap構(gòu)造TCP/IP協(xié)議軟件》介紹了在用戶態(tài)實(shí)現(xiàn)TCP/IP的方法。lwIP也是很好的借鑒對(duì)象。

            如果有時(shí)間,我打算自己寫(xiě)一個(gè)Mini/Tiny/Toy/Trivial/Yet-Another TCP/IP。我準(zhǔn)備換一個(gè)思路,用TUN/TAP設(shè)備在用戶態(tài)實(shí)現(xiàn)一個(gè)能與本機(jī)點(diǎn)對(duì)點(diǎn)通信的TCP/IP協(xié)議棧,這樣那三個(gè)接口函數(shù)就表現(xiàn)為我最熟悉的文件讀寫(xiě)。在用戶態(tài)實(shí)現(xiàn)的好處是便于調(diào)試,協(xié)議棧做成靜態(tài)庫(kù),與應(yīng)用程序鏈接到一起(庫(kù)的接口不必是標(biāo)準(zhǔn)的Sockets API)。做完這一版,還可以繼續(xù)發(fā)揮,用FTDI的USB-SPI接口芯片連接ENC28J60適配器,做一個(gè)真正獨(dú)立于操作系統(tǒng)的TCP/IP stack。如果只實(shí)現(xiàn)最基本的IP、ICMP Echo、TCP的話,代碼應(yīng)能控制在3000行以?xún)?nèi);也可以實(shí)現(xiàn)UDP,如果應(yīng)用程序需要用到DNS的話。

            最主要的三個(gè)例子

            我認(rèn)為T(mén)CP網(wǎng)絡(luò)編程有三個(gè)例子最值得學(xué)習(xí)研究,分別是echo、chat、proxy,都是長(zhǎng)連接協(xié)議。

            Echo的作用:熟悉服務(wù)端被動(dòng)接受新連接、收發(fā)數(shù)據(jù)、被動(dòng)處理連接斷開(kāi)。每個(gè)連接是獨(dú)立服務(wù)的,連接之間沒(méi)有關(guān)聯(lián)。在消息內(nèi)容方面Echo有一些變種:比如做成一問(wèn)一答的方式,收到的請(qǐng)求和發(fā)送響應(yīng)的內(nèi)容不一樣,這時(shí)候要考慮打包與拆包格式的設(shè)計(jì),進(jìn)一步還可以寫(xiě)簡(jiǎn)單的HTTP服務(wù)。

            Chat的作用:連接之間的數(shù)據(jù)有交流,從a收到的數(shù)據(jù)要發(fā)給b。這樣對(duì)連接管理提出的更高的要求:如何用一個(gè)程序同時(shí)處理多個(gè)連接?fork() per connection似乎是不行的。如何防止串話?b有可能隨時(shí)斷開(kāi)連接,而新建立的連接c可能恰好復(fù)用了b的文件描述符,那么a會(huì)不會(huì)錯(cuò)誤地把消息發(fā)給c?

            Proxy的作用:連接的管理更加復(fù)雜:既要被動(dòng)接受連接,也要主動(dòng)發(fā)起連接,既要主動(dòng)關(guān)閉連接,也要被動(dòng)關(guān)閉連接。還要考慮兩邊速度不匹配,見(jiàn)《Muduo 網(wǎng)絡(luò)編程示例之十:socks4a 代理服務(wù)器》。

            這三個(gè)例子功能簡(jiǎn)單,突出了TCP網(wǎng)絡(luò)編程中的重點(diǎn)問(wèn)題,挨著做一遍基本就能達(dá)到層次一的要求。

            TCP的可靠性有多高?

            TCP是“面向連接的、可靠的、字節(jié)流傳輸協(xié)議”,這里的“可靠”究竟是什么意思?《Effective TCP/IP Programming》第9條說(shuō):Realize That TCP Is a Reliable Protocol, Not an Infallible Protocol,那么TCP在哪種情況下會(huì)出錯(cuò)?這里說(shuō)的“出錯(cuò)”指的是收到的數(shù)據(jù)與發(fā)送的數(shù)據(jù)不一致,而不是數(shù)據(jù)不可達(dá)。

            我在《一種自動(dòng)反射消息類(lèi)型的 Google Protobuf 網(wǎng)絡(luò)傳輸方案》中設(shè)計(jì)了帶check sum的消息格式,很多人表示不理解,認(rèn)為是多余的。IP header里邊有check sum,TCP header也有check sum,鏈路層以太網(wǎng)還有CRC32校驗(yàn),那么為什么還需要在應(yīng)用層做校驗(yàn)?什么情況下TCP傳送的數(shù)據(jù)會(huì)出錯(cuò)?

            IP header和TCP header的check sum是一種非常弱的16-bit check sum算法,把數(shù)據(jù)當(dāng)成反碼表示的16-bit integers,再加到一起。這種checksum算法能檢出一些簡(jiǎn)單的錯(cuò)誤,而對(duì)某些錯(cuò)誤無(wú)能為力,由于是簡(jiǎn)單的加法,遇到“和”不變的情況就無(wú)法檢查出錯(cuò)誤(比如交換兩個(gè)16-bit整數(shù),加法滿足交換律,結(jié)果不變)。以太網(wǎng)的CRC32只能保證同一個(gè)網(wǎng)段上的通信不會(huì)出錯(cuò)(兩臺(tái)機(jī)器的網(wǎng)線插到同一個(gè)交換機(jī)上,這時(shí)候以太網(wǎng)的CRC是有用的)。但是,如果兩臺(tái)機(jī)器之間經(jīng)過(guò)了多級(jí)路由器呢?

            router

            上圖中Client向Server發(fā)了一個(gè)TCP segment,這個(gè)segment先被封裝成一個(gè)IP packet,再被封裝成ethernet frame,發(fā)送到路由器(圖中消息a)。Router收到ethernet frame (b),轉(zhuǎn)發(fā)到另一個(gè)網(wǎng)段(c),最后Server收到d,通知應(yīng)用程序。Ethernet CRC能保證a和b相同,c和d相同;TCP header check sum的強(qiáng)度不足以保證收發(fā)payload的內(nèi)容一樣。另外,如果把Router換成NAT,那么NAT自己會(huì)構(gòu)造c(替換掉源地址),這時(shí)候a和d的payload不能用tcp header checksum校驗(yàn)。

            路由器可能出現(xiàn)硬件故障,比方說(shuō)它的內(nèi)存故障(或偶然錯(cuò)誤)導(dǎo)致收發(fā)IP報(bào)文出現(xiàn)多bit的反轉(zhuǎn)或雙字節(jié)交換,這個(gè)反轉(zhuǎn)如果發(fā)生在payload區(qū),那么無(wú)法用鏈路層、網(wǎng)絡(luò)層、傳輸層的check sum查出來(lái),只能通過(guò)應(yīng)用層的check sum來(lái)檢測(cè)。這個(gè)現(xiàn)象在開(kāi)發(fā)的時(shí)候不會(huì)遇到,因?yàn)殚_(kāi)發(fā)用的幾臺(tái)機(jī)器很可能都連到同一個(gè)交換機(jī),ethernet CRC能防止錯(cuò)誤。開(kāi)發(fā)和測(cè)試的時(shí)候數(shù)據(jù)量不大,錯(cuò)誤很難發(fā)生。之后大規(guī)模部署到生產(chǎn)環(huán)境,網(wǎng)絡(luò)環(huán)境復(fù)雜,這時(shí)候出個(gè)錯(cuò)就讓人措手不及。有一篇論文《When the CRC and TCP checksum disagree》分析了這個(gè)問(wèn)題。另外《The Limitations of the Ethernet CRC and TCP/IP checksums for error detection》(http://noahdavids.org/self_published/CRC_and_checksum.html)也值得一讀。

            這個(gè)情況真的會(huì)發(fā)生嗎?會(huì)的,Amazon S3 在2008年7月就遇到過(guò),單bit反轉(zhuǎn)導(dǎo)致了一次嚴(yán)重線上事故,所以他們吸取教訓(xùn)加了 check sum。見(jiàn)http://status.aws.amazon.com/s3-20080720.html

            另外一個(gè)例證:下載大文件的時(shí)候一般都會(huì)附上MD5,這除了有安全方面的考慮(防止篡改),也說(shuō)明應(yīng)用層應(yīng)該自己設(shè)法校驗(yàn)數(shù)據(jù)的正確性。這是end-to-end principle的一個(gè)例證。

            三本必看的書(shū)

            談到Unix編程和網(wǎng)絡(luò)編程,W. Richard Stevens 是個(gè)繞不開(kāi)的人物,他生前寫(xiě)了6本書(shū),APUE、兩卷UNP、三卷TCP/IP。有四本與網(wǎng)絡(luò)編程直接相關(guān)。UNP第二卷其實(shí)跟網(wǎng)絡(luò)編程關(guān)系不大,是APUE在多線程和進(jìn)程間通信(IPC)方面的補(bǔ)充。很多人把TCP/IP一二三卷作為整體推薦,其實(shí)這三本書(shū)用處不同,應(yīng)該區(qū)別對(duì)待。

            這里談到的幾本書(shū)都沒(méi)有超出孟巖在《TCP/IP 網(wǎng)絡(luò)編程之四書(shū)五經(jīng)》中的推薦,說(shuō)明網(wǎng)絡(luò)編程這一領(lǐng)域已經(jīng)相對(duì)成熟穩(wěn)定。

            · 《TCP/IP Illustrated, Vol. 1: The Protocols》中文名《TCP/IP 詳解》,以下簡(jiǎn)稱(chēng) TCPv1。

            TCPv1 是一本奇書(shū)。

            這本書(shū)迄今至少被三百多篇學(xué)術(shù)論文引用過(guò)http://portal.acm.org/citation.cfm?id=161724。一本學(xué)術(shù)專(zhuān)著被論文引用算不上出奇,難得的是一本寫(xiě)給程序員看的技術(shù)書(shū)能被學(xué)術(shù)論文引用幾百次,我不知道還有哪本技術(shù)書(shū)能做到這一點(diǎn)。

            TCPv1 堪稱(chēng) TCP/IP領(lǐng)域的圣經(jīng)。作者 W. Richard Stevens 不是 TCP/IP 協(xié)議的發(fā)明人,他從使用者(程序員)的角度,以 tcpdump 為工具,對(duì) TCP 協(xié)議抽絲剝繭娓娓道來(lái)(第17~24章),讓人嘆服。恐怕 TCP 協(xié)議的設(shè)計(jì)者也難以講解得如此出色,至少不會(huì)像他這么耐心細(xì)致地畫(huà)幾百幅收發(fā) package 的時(shí)序圖。

            TCP作為一個(gè)可靠的傳輸層協(xié)議,其核心有三點(diǎn):

            1. Positive acknowledgement with retransmission

            2. Flow control using sliding window(包括Nagle 算法等)

            3. Congestion control(包括slow start、congestion avoidance、fast retransmit等)

            第一點(diǎn)已經(jīng)足以滿足“可靠性”要求(為什么?);第二點(diǎn)是為了提高吞吐量,充分利用鏈路層帶寬;第三點(diǎn)是防止過(guò)載造成丟包。換言之,第二點(diǎn)是避免發(fā)得太慢,第三點(diǎn)是避免發(fā)得太快,二者相互制約。從反饋控制的角度看,TCP像是一個(gè)自適應(yīng)的節(jié)流閥,根據(jù)管道的擁堵情況自動(dòng)調(diào)整閥門(mén)的流量。

            TCP的 flow control 有一個(gè)問(wèn)題,每個(gè)TCP connection是彼此獨(dú)立的,保存有自己的狀態(tài)變量;一個(gè)程序如果同時(shí)開(kāi)啟多個(gè)連接,或者操作系統(tǒng)中運(yùn)行多個(gè)網(wǎng)絡(luò)程序,這些連接似乎不知道他人的存在,缺少對(duì)網(wǎng)卡帶寬的統(tǒng)籌安排。(或許現(xiàn)代的操作系統(tǒng)已經(jīng)解決了這個(gè)問(wèn)題?)

            TCPv1 唯一的不足是它出版太早了,1993 年至今網(wǎng)絡(luò)技術(shù)發(fā)展了幾代。鏈路層方面,當(dāng)年主流的 10Mbit 網(wǎng)卡和集線器早已經(jīng)被淘汰;100Mbit 以太網(wǎng)也沒(méi)什么企業(yè)在用了,交換機(jī)(switch)也已經(jīng)全面取代了集線器(hub);服務(wù)器機(jī)房以 1Gbit 網(wǎng)絡(luò)為主,有些場(chǎng)合甚至用上了 10Gbit 以太網(wǎng)。另外,無(wú)線網(wǎng)的普及也讓TCP flow control面臨新挑戰(zhàn);原來(lái)設(shè)計(jì)TCP的時(shí)候,人們認(rèn)為丟包通常是擁塞造成的,這時(shí)應(yīng)該放慢發(fā)送速度,減輕擁塞;而在無(wú)線網(wǎng)中,丟包可能是信號(hào)太弱造成的,這時(shí)反而應(yīng)該快速重試,以保證性能。網(wǎng)絡(luò)層方面變化不大,IPv6 雷聲大雨點(diǎn)小。傳輸層方面,由于鏈路層帶寬大增,TCP window scale option 被普遍使用,另外 TCP timestamps option 和 TCP selective ack option 也很常用。由于這些因素,在現(xiàn)在的 Linux 機(jī)器上運(yùn)行 tcpdump 觀察 TCP 協(xié)議,程序輸出會(huì)與原書(shū)有些不同。

            一個(gè)好消息:TCPv1將于今年10月(2011年)推出第二版,Amazon 的預(yù)定頁(yè)面是:http://www.amazon.com/gp/product/0321336313,讓我們拭目以待。

            · 《Unix Network Programming, Vol. 1: Networking API》第二版或第三版(這兩版的副標(biāo)題稍有不同,第三版去掉了 XTI),以下統(tǒng)稱(chēng) UNP,如果需要會(huì)以 UNP2e、UNP3e 細(xì)分。

            UNP是Sockets API的權(quán)威指南,但是網(wǎng)絡(luò)編程遠(yuǎn)不是使用那十幾個(gè)Sockets API那么簡(jiǎn)單,作者 W. Richard Stevens深刻地認(rèn)識(shí)到這一點(diǎn),他在UNP2e的前言中寫(xiě)到:http://www.kohala.com/start/preface.unpv12e.html

            I have found when teaching network programming that about 80% of all network programming problems have nothing to do with network programming, per se. That is, the problems are not with the API functions such as accept and select, but the problems arise from a lack of understanding of the underlying network protocols. For example, I have found that once a student understands TCP's three-way handshake and four-packet connection termination, many network programming problems are immediately understood.

            搞網(wǎng)絡(luò)編程,一定要熟悉TCP/IP協(xié)議及其外在表現(xiàn)(比如打開(kāi)和關(guān)閉Nagle算法對(duì)收發(fā)包的影響),不然出點(diǎn)意料之外的情況就摸不著頭腦了。我不知道為什么UNP3e在前言中去掉了這段至關(guān)重要的話。

            另外值得一提的是,UNP中文版翻譯得相當(dāng)好,譯者楊繼張先生是真懂網(wǎng)絡(luò)編程的。

            UNP很詳細(xì),面面俱到,UDP、TCP、IPv4、IPv6都講到了。要說(shuō)有什么缺點(diǎn)的話,就是太詳細(xì)了,重點(diǎn)不夠突出。我十分贊同孟巖說(shuō)的

            “(孟巖)我主張,在具備基礎(chǔ)之后,學(xué)習(xí)任何新東西,都要抓住主線,突出重點(diǎn)。對(duì)于關(guān)鍵理論的學(xué)習(xí),要集中精力,速戰(zhàn)速?zèng)Q。而旁枝末節(jié)和非本質(zhì)性的知識(shí)內(nèi)容,完全可以留給實(shí)踐去零敲碎打。

            “原因是這樣的,任何一個(gè)高級(jí)的知識(shí)內(nèi)容,其中都只有一小部分是有思想創(chuàng)新、有重大影響的,而其它很多東西都是瑣碎的、非本質(zhì)的。因此,集中學(xué)習(xí)時(shí)必須把握住真正重要那部分,把其它東西留給實(shí)踐。對(duì)于重點(diǎn)知識(shí),只有集中學(xué)習(xí)其理論,才能確保體系性、連貫性、正確性,而對(duì)于那些旁枝末節(jié),只有邊干邊學(xué)能夠讓你了解它們的真實(shí)價(jià)值是大是小,才能讓你留下更生動(dòng)的印象。如果你把精力用錯(cuò)了地方,比如用集中大塊的時(shí)間來(lái)學(xué)習(xí)那些本來(lái)只需要查查手冊(cè)就可以明白的小技巧,而對(duì)于真正重要的、思想性東西放在平時(shí)零敲碎打,那么肯定是事倍功半,甚至適得其反。

            “因此我對(duì)于市面上絕大部分開(kāi)發(fā)類(lèi)圖書(shū)都不滿——它們基本上都是面向知識(shí)體系本身的,而不是面向讀者的。總是把相關(guān)的所有知識(shí)細(xì)節(jié)都放在一堆,然后一堆一堆攢起來(lái)變成一本書(shū)。反映在內(nèi)容上,就是毫無(wú)重點(diǎn)地平鋪直敘,不分輕重地陳述細(xì)節(jié),往往在第三章以前就用無(wú)聊的細(xì)節(jié)謀殺了讀者的熱情。為什么當(dāng)年侯捷先生的《深入淺出MFC》和 Scott Meyers 的 Effective C++ 能夠成為經(jīng)典?就在于這兩本書(shū)抓住了各自領(lǐng)域中的主干,提綱挈領(lǐng),綱舉目張,一下子打通讀者的任督二脈。可惜這樣的書(shū)太少,就算是已故 Richard Stevens 和當(dāng)今 Jeffrey Richter 的書(shū),也只是在體系性和深入性上高人一頭,并不是面向讀者的書(shū)。”

            什么是旁枝末節(jié)呢?拿以太網(wǎng)來(lái)說(shuō),CRC32如何計(jì)算就是“旁枝末節(jié)”。網(wǎng)絡(luò)程序員要明白check sum的作用,知道為什么需要check sum,至于具體怎么算CRC就不需要程序員操心。這部分通常是由網(wǎng)卡硬件完成的,在發(fā)包的時(shí)候由硬件填充CRC,在收包的時(shí)候網(wǎng)卡自動(dòng)丟棄CRC不合格的包。如果代碼里邊確實(shí)要用到CRC計(jì)算,調(diào)用通用的zlib就行,也不用自己實(shí)現(xiàn)。

            UNP就像給了你一堆做菜的原料(各種Sockets 函數(shù)的用法),常用和不常用的都給了(Out-of-Band Data、Signal-Driven IO 等等),要靠讀者自己設(shè)法取舍組合,做出一盤(pán)大菜來(lái)。在第一遍讀的時(shí)候,我建議只讀那些基本且重要的章節(jié);另外那些次要的內(nèi)容可略作了解,即便跳過(guò)不讀也無(wú)妨。UNP是一本操作性很強(qiáng)的書(shū),讀這本這本書(shū)一定要上機(jī)練習(xí)。

            另外,UNP舉的兩個(gè)例子(菜譜)太簡(jiǎn)單,daytime和echo一個(gè)是短連接協(xié)議,一個(gè)是長(zhǎng)連接無(wú)格式協(xié)議,不足以覆蓋基本的網(wǎng)絡(luò)開(kāi)發(fā)場(chǎng)景(比如 TCP封包與拆包、多連接之間交換數(shù)據(jù))。我估計(jì) W. Richard Stevens 原打算在 UNP第三卷中講解一些實(shí)際的例子,只可惜他英年早逝,我等無(wú)福閱讀。

            UNP是一本偏重Unix傳統(tǒng)的書(shū),這本書(shū)寫(xiě)作的時(shí)候服務(wù)端還不需要處理成千上萬(wàn)的連接,也沒(méi)有現(xiàn)在那么多網(wǎng)絡(luò)攻擊。書(shū)中重點(diǎn)介紹的以accept()+fork()來(lái)處理并發(fā)連接的方式在現(xiàn)在看來(lái)已經(jīng)有點(diǎn)吃力,這本書(shū)的代碼也沒(méi)有特別防范惡意攻擊。如果工作涉及這些方面,需要再進(jìn)一步學(xué)習(xí)專(zhuān)門(mén)的知識(shí)(C10k問(wèn)題,安全編程)。

            TCPv1和UNP應(yīng)該先看哪本?我不知道。我自己是先看的TCPv1,花了大約半學(xué)期時(shí)間,然后再讀UNP2e和APUE。

            · 《Effective TCP/IP Programming

            第三本書(shū)我猶豫了很久,不知道該推薦哪本,還有哪本書(shū)能與 W. Richard Stevens 的這兩本比肩嗎?W. Richard Stevens 為技術(shù)書(shū)籍的寫(xiě)作樹(shù)立了難以逾越的標(biāo)桿,他是一位偉大的技術(shù)作家。沒(méi)能看到他寫(xiě)完 UNP 第三卷實(shí)在是人生的遺憾。

            Effective TCP/IP Programming》這本書(shū)屬于專(zhuān)家經(jīng)驗(yàn)總結(jié)類(lèi),初看時(shí)覺(jué)得收獲很大,工作一段時(shí)間再看也能有新的發(fā)現(xiàn)。比如第6 條“TCP是一個(gè)字節(jié)流協(xié)議”,看過(guò)這一條就不會(huì)去研究所謂的“TCP粘包問(wèn)題”。我手頭這本電力社2001年的中文版翻譯尚可,但是很狗血的是把參考文獻(xiàn)去掉了,正文中引用的文章資料根本查不到名字。人郵2011年重新翻譯出版的版本有參考文獻(xiàn)。

            其他值得一看的書(shū)

            以下兩本都不易讀,需要相當(dāng)?shù)幕A(chǔ)。

            · 《TCP/IP Illustrated, Vol. 2: The Implementation》以下簡(jiǎn)稱(chēng) TCPv2

            1200頁(yè)的大部頭,詳細(xì)講解了4.4BSD的完整TCP/IP協(xié)議棧,注釋了15,000行C源碼。這本書(shū)啃下來(lái)不容易,如果時(shí)間不充裕,我認(rèn)為沒(méi)必要啃完,應(yīng)用層的網(wǎng)絡(luò)程序員選其中與工作相關(guān)的部分來(lái)閱讀即可。

            這本書(shū)第一作者是Gary Wright,從敘述風(fēng)格和內(nèi)容組織上是典型的“面向知識(shí)體系本身”,先講mbuf,再?gòu)逆溌穼右宦吠稀⒁蕴W(wǎng)、IP網(wǎng)絡(luò)層、ICMP、IP多播、IGMP、IP路由、多播路由、Sockets系統(tǒng)調(diào)用、ARP等等。到了正文內(nèi)容3/4的地方才開(kāi)始講TCP。面面俱到、主次不明。

            對(duì)于主要使用TCP的程序員,我認(rèn)為T(mén)CPv2一大半內(nèi)容可以跳過(guò)不看,比如路由表、IGMP等等(開(kāi)發(fā)網(wǎng)絡(luò)設(shè)備的人可能更關(guān)心這些內(nèi)容)。在工作中大可以把IP視為host-to-host的協(xié)議,把“IP packet如何送達(dá)對(duì)方機(jī)器”的細(xì)節(jié)視為黑盒子,這不會(huì)影響對(duì)TCP的理解和運(yùn)用,因?yàn)榫W(wǎng)絡(luò)協(xié)議是分層的。這樣精簡(jiǎn)下來(lái),需要看的只有三四百頁(yè),四五千行代碼,大大減輕了負(fù)擔(dān)。

            這本書(shū)直接呈現(xiàn)高質(zhì)量的工業(yè)級(jí)操作系統(tǒng)源碼,讀起來(lái)有難度,讀懂它甚至要有“不求甚解的能力”。其一,代碼只能看,不能上機(jī)運(yùn)行,也不能改動(dòng)試驗(yàn)。其二,與操作系統(tǒng)其他部分緊密關(guān)聯(lián)。比如TCP/IP stack下接網(wǎng)卡驅(qū)動(dòng)、軟中斷;上承inode轉(zhuǎn)發(fā)來(lái)的系統(tǒng)調(diào)用操作;中間還要與平級(jí)的進(jìn)程文件描述符管理子系統(tǒng)打交道;如果要把每一部分都弄清楚,把持不住就迷失主題了。其三,一些歷史包袱讓代碼變復(fù)雜晦澀。比如BSD在80年代初需要在只有4M內(nèi)存的VAX上實(shí)現(xiàn)TCP/IP,內(nèi)存方面捉襟見(jiàn)肘,這才發(fā)明了mbuf結(jié)構(gòu),代碼也增加了不少偶發(fā)復(fù)雜度(buffer不連續(xù)的處理)。

            讀這套TCP/IP書(shū)切忌膠柱鼓瑟,這套書(shū)以4.4BSD為底,其描述的行為(特別是與timer相關(guān)的行為)與現(xiàn)在的Linux TCP/IP有不小的出入,用書(shū)本上的知識(shí)直接套用到生產(chǎn)環(huán)境的Linux系統(tǒng)可能會(huì)造成不小的誤解和困擾。(TCPv3不重要,可以成套買(mǎi)來(lái)收藏,不讀亦可。)

            · 《Pattern-Oriented Software Architecture Volume 2: Patterns for Concurrent and Networked Objects》以下簡(jiǎn)稱(chēng)POSA2

            這本書(shū)總結(jié)了開(kāi)發(fā)并發(fā)網(wǎng)絡(luò)服務(wù)程序的模式,是對(duì)UNP很好的補(bǔ)充。UNP中的代碼往往把業(yè)務(wù)邏輯和Sockets API調(diào)用混在一起,代碼固然短小精悍,但是這種編碼風(fēng)格恐怕不適合開(kāi)發(fā)大型的網(wǎng)絡(luò)程序。POSA2強(qiáng)調(diào)模塊化,網(wǎng)絡(luò)通信交給library/framework去做,程序員寫(xiě)代碼只關(guān)注業(yè)務(wù)邏輯,這是非常重要的思想。閱讀這本書(shū)對(duì)于深入理解常用的event-driven網(wǎng)絡(luò)庫(kù)(libevent、Java Netty、Java Mina、Perl POE、Python Twisted等等)也很有幫助,因?yàn)檫@些庫(kù)都是依照這本書(shū)的思想編寫(xiě)的。

            POSA2的代碼是示意性的,思想很好,細(xì)節(jié)不佳。其C++ 代碼沒(méi)有充分考慮資源的自動(dòng)化管理(RAII),如果直接按照書(shū)中介紹的方式去實(shí)現(xiàn)網(wǎng)絡(luò)庫(kù),那么會(huì)給使用者造成不小的負(fù)擔(dān)與陷阱。換言之,照他說(shuō)的做,而不是照他做的學(xué)。

            不值一看的書(shū)

            Douglas Comer 教授名氣很大,著作等身,但是他寫(xiě)的網(wǎng)絡(luò)方面的書(shū)不值一讀,味同嚼蠟。網(wǎng)絡(luò)編程與 TCP/IP 方面,有W. Richard Stevens 的書(shū)扛鼎;計(jì)算機(jī)網(wǎng)絡(luò)原理方面,有Kurose的“自頂向下”和Peterson的“系統(tǒng)”打旗,沒(méi)其他人什么事兒。順便一提,Tanenbaum的操作系統(tǒng)教材是最好的之一(嗯,之二,因?yàn)樗麑?xiě)了兩本:“現(xiàn)代”和“設(shè)計(jì)與實(shí)現(xiàn)”),不過(guò)他的計(jì)算機(jī)網(wǎng)絡(luò)和體系結(jié)構(gòu)教材的地位比不上他的操作系統(tǒng)書(shū)的地位。體系結(jié)構(gòu)方面,Patterson 和 Hennessy二人合作的兩本書(shū)是最好的,近年來(lái)嶄露頭角的《深入理解計(jì)算機(jī)系統(tǒng)》也非常好;當(dāng)然,側(cè)重點(diǎn)不同。

            (完)

            posted @ 2011-06-06 08:44 陳碩 閱讀(60226) | 評(píng)論 (14)編輯 收藏

            Muduo 網(wǎng)絡(luò)編程示例之十:socks4a 代理服務(wù)器

            Muduo 網(wǎng)絡(luò)編程示例之十:socks4a 代理服務(wù)器

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第十篇文章,本系列暫告一段落。

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

            本文介紹用 muduo 實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 socks4a 代理服務(wù)器,代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/socks4a/

            TCP 中繼器

            在實(shí)現(xiàn) socks4a proxy 之前,我們先寫(xiě)一個(gè)功能更簡(jiǎn)單的網(wǎng)絡(luò)程序—— TCP 中繼器 (TCP relay),或者叫做窮人的 tcpdump (poor man's tcpdump)。

            一般情況下,客戶端程序直接連接服務(wù)端,如下圖。有時(shí)候,我們想在 client 和 server 之間放一個(gè)中繼器 (relay),把 client 與 server 之間的通信內(nèi)容記錄下來(lái)。這時(shí)用 tcpdump 是最方便省事的,但是 tcpdump 需要 root 權(quán)限,萬(wàn)一沒(méi)有 root 密碼呢?窮人有窮人的辦法,自己寫(xiě)一個(gè) relay,讓 client 連接 relay,再讓 relay 連接 server,如下圖中的 T 型結(jié)構(gòu),relay 扮演了類(lèi)似 proxy 的角色。

            relay

            TcpRelay 是我們自己寫(xiě)的,可以動(dòng)動(dòng)手腳。除了記錄通信內(nèi)容,還可以制造延時(shí),或者故意翻轉(zhuǎn) 1 bit 數(shù)據(jù)以模擬 router 硬件故障。

            TcpRelay 的功能(業(yè)務(wù)邏輯)看上去很簡(jiǎn)單,無(wú)非是把連接 C 上收到的數(shù)據(jù)發(fā)給連接 S,同時(shí)把連接 S 上收到的數(shù)據(jù)發(fā)給連接 C。但仔細(xì)考慮起來(lái),細(xì)節(jié)其實(shí)不那么簡(jiǎn)單:

            • 建立連接。為了真實(shí)模擬 client,TcpRelay 在 accept 連接 C 之后才向 server 發(fā)起連接 S,那么在 S 建立起來(lái)之前,從 C 收到數(shù)據(jù)怎么辦?要不要暫存起來(lái)?
            • 并發(fā)連接的管理。上圖中只畫(huà)出了一個(gè) client,實(shí)際上 TcpRelay 可以服務(wù)多個(gè) clients,左右兩邊這些并發(fā)連接如何管理,如何防止串話(cross talk)?
            • 連接斷開(kāi)。Client 和 Server 都可能主動(dòng)斷開(kāi)連接。當(dāng) Client 主動(dòng)斷開(kāi)連接 C 時(shí),TcpRelay 應(yīng)該立刻斷開(kāi) S。當(dāng) Server 主動(dòng)斷開(kāi)連接 S 時(shí),TcpRelay 應(yīng)立刻斷開(kāi) C。這樣才能比較精確地模擬 Client 和 Server 的行為。在關(guān)閉連接的剎那,又有新的 client 連接進(jìn)來(lái),復(fù)用了剛剛 close 的 fd 號(hào)碼,會(huì)不會(huì)造成串話? 萬(wàn)一 Client 和 Server 幾乎同時(shí)主動(dòng)斷開(kāi)連接,TcpRelay 如何應(yīng)對(duì)?
            • 速度不匹配。如果連接 C 的帶寬是 100KB/s,而連接 S 的帶寬是 10MB/s,不巧 Server 是個(gè) chargen 服務(wù),會(huì)全速發(fā)送數(shù)據(jù),那么會(huì)不會(huì)撐爆 TcpRelay 的 buffer?如何限速?特別是在使用 non-blocking IO 和 level-trigger polling 的時(shí)候如何限制讀取數(shù)據(jù)的速度?

            在看 muduo 的實(shí)現(xiàn)之前,請(qǐng)讀者思考:如果用 Sockets API 來(lái)實(shí)現(xiàn) TcpRelay,如何解決以上這些問(wèn)題。

            TcpRelay 的實(shí)現(xiàn)很簡(jiǎn)單,只有幾十行代碼 http://code.google.com/p/muduo/source/browse/trunk/examples/socks4a/tcprelay.cc,主要邏輯都在 Tunnel class 里

            http://code.google.com/p/muduo/source/browse/trunk/examples/socks4a/tunnel.h 。這個(gè)實(shí)現(xiàn)解決了前三個(gè)問(wèn)題,第四個(gè)留給將來(lái)吧。

            Socks4a 代理服務(wù)器

            Socks4a 的功能與 TcpRelay 非常相似,也是把連接 C 上收到的數(shù)據(jù)發(fā)給連接 S,同時(shí)把連接 S 上收到的數(shù)據(jù)發(fā)給連接 C。它與 TcpRelay 的區(qū)別在于,TcpRelay 固定連到某個(gè) server 地址,而 socks4a 允許 client 指定要連哪個(gè) server。在 accept 連接 C 之后,Socks4a server 會(huì)讀幾個(gè)字節(jié),以了解 server 的地址,再發(fā)起連接 S。

            Socks4a 的協(xié)議非常簡(jiǎn)單,請(qǐng)參考維基百科 http://en.wikipedia.org/wiki/SOCKS#SOCKS_4a

            muduo 的 socks4a 代理服務(wù)器的實(shí)現(xiàn)在 http://code.google.com/p/muduo/source/browse/trunk/examples/socks4a/socks4a.cc,它也使用了 Tunnel class。與 TcpRelay 相比,只多了解析 server 地址這一步驟。

            muduo 這個(gè) socks4a 是個(gè)標(biāo)準(zhǔn)的網(wǎng)絡(luò)服務(wù),可以供 Web 瀏覽器使用(我正是這么測(cè)試它的)。

            n:1 與 1:n 連接轉(zhuǎn)發(fā)

            云風(fēng)在《寫(xiě)了一個(gè) proxy 用途你懂的》中寫(xiě)了一個(gè) TCP 隧道 tunnel,程序由三部分組成:n:1 連接轉(zhuǎn)發(fā)服務(wù),1:n 連接轉(zhuǎn)發(fā)服務(wù),socks 代理服務(wù)。

            我仿照他的思路,用 muduo 實(shí)現(xiàn)了這三個(gè)程序。不同的是,我沒(méi)有做數(shù)據(jù)混淆,所以不能用來(lái)翻傳說(shuō)中的墻。

            有興趣的讀者可以把這三個(gè)程序級(jí)聯(lián)起來(lái)試一試。

            Muduo 編程示例系列告一段落

            Muduo 網(wǎng)絡(luò)編程示例》從今年2月初開(kāi)始寫(xiě),到今天正好是四個(gè)月,我寫(xiě)了十一篇博客,基本按計(jì)劃完成了任務(wù)。這個(gè)系列暫告一段落。

            這個(gè)系列基本涵蓋了 muduo 為編寫(xiě)單線程服務(wù)端和客戶端 TCP 網(wǎng)絡(luò)程序提供的功能,muduo 的能力不止于此:

            • 多線程,muduo::net::TcpServer 內(nèi)置了一個(gè)簡(jiǎn)單但適應(yīng)性很強(qiáng)的線程模型。目前博客上的例子涉及的業(yè)務(wù)邏輯很簡(jiǎn)單,沒(méi)有復(fù)雜的運(yùn)算,瓶頸通常在 IO 上,多線程的優(yōu)勢(shì)發(fā)揮不出來(lái)。
            • 高級(jí)應(yīng)用。比方說(shuō)用 muduo::net::Channel 配合 signalfd 來(lái)處理信號(hào);其他非阻塞網(wǎng)絡(luò)客戶端庫(kù)(例如 ZooKeeper 的 C 客戶端,PostgreSQL 的客戶端 libpq)與 muduo EventLoop 的集成。

            以上兩點(diǎn)在以后的文章里會(huì)提及,不會(huì)明珠暗藏。

            Muduo 在 2010 年 8 月底發(fā)布 0.1.0 版,隨著這個(gè)編程示例系列文章的發(fā)表,迄今已發(fā)布了 14 次小升級(jí),下載地址: http://code.google.com/p/muduo/downloads/list

            接下來(lái)的計(jì)劃

            接下來(lái),我還會(huì)寫(xiě)一系列博客,目前想到的有:

            1. 談一談我的網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)。文章已經(jīng)完成大半,端午節(jié)之后可以發(fā)布。
            2. muduo 設(shè)計(jì)與實(shí)現(xiàn)系列,介紹如何一步步實(shí)現(xiàn)一個(gè)非阻塞網(wǎng)絡(luò)庫(kù)。代碼已經(jīng)準(zhǔn)備得差不多了,在 https://github.com/chenshuo/recipes/tree/master/reactor
            3. 用 muduo 實(shí)現(xiàn)一些稍微復(fù)雜一些的網(wǎng)絡(luò)程序,比如小規(guī)模的分布式系統(tǒng)。計(jì)劃有:利用 Paxos 算法實(shí)現(xiàn)一個(gè)高可用的 in-memory key value 存儲(chǔ),在此基礎(chǔ)上實(shí)現(xiàn) naming service,然后實(shí)現(xiàn)我以前多次提到的簡(jiǎn)單機(jī)群管理系統(tǒng)等等。目前 muduo 的示例程序都是簡(jiǎn)單獨(dú)立的網(wǎng)絡(luò)程序,下半年我想多寫(xiě)一寫(xiě)由多個(gè)程序組成的系統(tǒng),具體談一談分布式系統(tǒng)細(xì)節(jié)設(shè)計(jì)。

            另外,我會(huì)逐步把已有的博客文章整理成 PDF 合集,方便下載保存,地址是: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx

            posted @ 2011-06-02 23:02 陳碩 閱讀(2936) | 評(píng)論 (0)編輯 收藏

            Muduo 網(wǎng)絡(luò)編程示例之九:簡(jiǎn)單的消息廣播服務(wù)

            Muduo 網(wǎng)絡(luò)編程示例之九:簡(jiǎn)單的消息廣播服務(wù)

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第九篇文章,講用 muduo 實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 pub/sub 服務(wù)

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

            本文介紹用 muduo 實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 topic-based 消息廣播服務(wù),這其實(shí)是“聊天室”的一個(gè)簡(jiǎn)單擴(kuò)展,不過(guò)聊天的不是人,而是分布式系統(tǒng)中的程序。

            本文的代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/hub

             

            在分布式系統(tǒng)中,除了常用的 end-to-end 通信,還有一對(duì)多的廣播通信。一提到“廣播”,或許會(huì)讓人聯(lián)想到 IP 多播或 IP 組播,這不是本文的主題。本文將要談的是基于 TCP 協(xié)議的應(yīng)用層廣播。示意圖如下:

            hub

            上圖中圓角矩形代表程序,"Hub"是一個(gè)服務(wù)程序,不是網(wǎng)絡(luò)集線器,它起到類(lèi)似集線器的作用,故而得名。Publisher 和 Subscriper 通過(guò) TCP 協(xié)議與 Hub 程序通信。Publisher 把消息發(fā)到某個(gè) topic 上,Subscribers 訂閱該 topic,然后就能收到消息。即 publisher 借助 hub 把消息廣播給了多個(gè) subscribers。這種 pub/sub 結(jié)構(gòu)的好處在于可以增加多個(gè) Subscriber 而不用修改 Publisher,一定程度上實(shí)現(xiàn)了“解耦”(也可以看成分布式的 observer pattern)。 由于走的是 TCP 協(xié)議,廣播是基本可靠的,這里的“可靠”指的是“比 UDP 可靠”,不是“完全可靠”。(思考:如何避免 Hub 成為 single point of failure?)

            為了避免串?dāng)_(cross-talk),每個(gè) topic 在同一時(shí)間只應(yīng)該有一個(gè) publisher,hub 不提供 compare-and-swap 操作。

            (“可靠廣播、原子廣播”在分布式系統(tǒng)中有重大意義,是以 replicated state machine 方式實(shí)現(xiàn)可靠的分布式服務(wù)的基礎(chǔ),“可靠廣播”涉及 consensus 算法,超出了本文的范圍。)

             

            應(yīng)用層廣播在分布式系統(tǒng)中用處很大,這里略舉幾例:

            1. 體育比分轉(zhuǎn)播。有 8 片比賽場(chǎng)地正在進(jìn)行羽毛球比賽,每個(gè)場(chǎng)地的計(jì)分程序把當(dāng)前比分發(fā)送到各自的 topic 上(第 1 號(hào)場(chǎng)地發(fā)送到 court1,第 2 號(hào)發(fā)送到 court2,以此類(lèi)推)。需要用到比分的程序(賽場(chǎng)的大屏幕顯示,網(wǎng)上比分轉(zhuǎn)播等等)自己訂閱感興趣的 topic ,就能及時(shí)收到最新比分?jǐn)?shù)據(jù)。由于本文實(shí)現(xiàn)的不是 100% 可靠廣播,那么消息應(yīng)該是 snapshot,而不是 incremental。(換句話說(shuō),消息的內(nèi)容是“現(xiàn)在是幾比幾”,而不是“剛才誰(shuí)得分”。)

            2. 負(fù)載監(jiān)控。每臺(tái)機(jī)器上運(yùn)行一個(gè)監(jiān)控程序,周期性地把本機(jī)當(dāng)前負(fù)載(CPU、網(wǎng)絡(luò)、磁盤(pán)、溫度)publish 到以 hostname 命名的 topic 上,這樣需要用到這些數(shù)據(jù)的程序只要在 hub 訂閱相應(yīng)的 topic 就能獲得數(shù)據(jù),無(wú)需與多臺(tái)機(jī)器直接打交道。(為了可靠起見(jiàn),監(jiān)控程序發(fā)送的消息里邊應(yīng)該包含時(shí)間戳,這樣能防止 stale 數(shù)據(jù),甚至一定程度上起到心跳的作用。)沿著這個(gè)思路,分布式系統(tǒng)中的服務(wù)程序也可以把自己的當(dāng)前負(fù)載發(fā)布到 hub 上,供 load balancer 和 monitor 取用。

            協(xié)議

            為了簡(jiǎn)單起見(jiàn),muduo 的 hub 示例采用以 '\r\n' 分界的文本協(xié)議,這樣用 telnet 就能測(cè)試 hub。協(xié)議只有三個(gè)命令:

            • sub <topic>\r\n
              • 該命令表示訂閱 <topic>,以后該 topic 有任何跟新都會(huì)發(fā)給這個(gè) tcp 連接。在 sub 的時(shí)候,hub 會(huì)把該 <topic> 上最近的消息發(fā)給此 subscriber。
            • unsub <topic>\r\n
              • 該命令表示退訂 <topic>
            • pub <topic>\r\n<content>\r\n
              • 往 <topic> 發(fā)送消息,內(nèi)容為 <content>。所有訂閱了此 <topic> 的 subscribers 會(huì)收到同樣的消息“pub <topic>\r\n<content>\r\n”

            代碼

            muduo 示例中的 hub 分為幾個(gè)部分:

            一個(gè)程序可以既是 publisher 又是 subscriber,而且 pubsub 庫(kù)只用一個(gè) tcp 連接(這樣 failover 比較簡(jiǎn)便)。

            使用范例:

            1. 開(kāi)啟 4 個(gè)命令行窗口
            2. 在第一個(gè)窗口運(yùn)行 $ hub 9999
            3. 在第二個(gè)窗口運(yùn)行 $ sub 127.0.0.1:9999 mytopic
            4. 在第三個(gè)窗口運(yùn)行 $ sub 127.0.0.1:9999 mytopic court
            5. 在第四個(gè)窗口運(yùn)行 $ pub 127.0.0.1:9999 mytopic "Hello world."  ,這時(shí)第二三號(hào)窗口都會(huì)打印 “mytopic: Hello world.”,表明收到了 mytopic 這個(gè)主題上的消息。
            6. 在第四個(gè)窗口運(yùn)行 $ pub 127.0.0.1:9999 court "13:11"  ,這時(shí)第三號(hào)窗口會(huì)打印 “court: 13:11”,表明收到了 court 這個(gè)主題上的消息。第二號(hào)窗口沒(méi)有訂閱此消息,故無(wú)輸出。

            借助這個(gè)簡(jiǎn)單的 pub/sub 機(jī)制,還可以做很多有意思的事情。比如把分布式系統(tǒng)中的程序的一部分 end-to-end 通信改為通過(guò) pub/sub 來(lái)做(例如,原來(lái)是 A 向 B 發(fā)一個(gè) SOAP request,B 通過(guò)同一個(gè) tcp 連接發(fā)回 response (分析二者的通信只能通過(guò)查看 log 或用 tcpdump 截獲);現(xiàn)在是 A 往 topic_a_to_b 上發(fā)布 request,B 在 topic_b_to_a 上發(fā) response),這樣多掛一個(gè) monitoring subscriber 就能輕易地查看通信雙方的溝通情況,很容易做狀態(tài)監(jiān)控與 trouble shooting。

            posted @ 2011-05-25 23:21 陳碩 閱讀(2398) | 評(píng)論 (2)編輯 收藏

            C++ 工程實(shí)踐(6):?jiǎn)卧獪y(cè)試如何 mock 系統(tǒng)調(diào)用

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice

            陳碩關(guān)于 C++ 工程實(shí)踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx

            陳碩博客文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx

            本作品采用“Creative Commons 署名-非商業(yè)性使用-禁止演繹 3.0 Unported 許可協(xié)議(cc by-nc-nd)”進(jìn)行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/

            摘要:本文討論了在編寫(xiě)單元測(cè)試時(shí) mock 系統(tǒng)調(diào)用(以及其他第三方庫(kù))的幾種做法。

            本文只考慮 Linux x86/amd64 平臺(tái)。

            陳碩在《分布式程序的自動(dòng)化回歸測(cè)試》 http://blog.csdn.net/Solstice/archive/2011/04/25/6359748.aspx 一文中曾經(jīng)談到單元測(cè)試在分布式程序開(kāi)發(fā)中的優(yōu)缺點(diǎn)(好吧,主要是缺點(diǎn))。但是,在某些情況下,單元測(cè)試是很有必要的,在測(cè)試 failure 場(chǎng)景的時(shí)候尤顯重要,比如:

            • 在開(kāi)發(fā)存儲(chǔ)系統(tǒng)時(shí),模擬 read(2)/write(2) 返回 EIO 錯(cuò)誤(有可能是磁盤(pán)寫(xiě)滿了,有可能是磁盤(pán)出壞道讀不出數(shù)據(jù))。
            • 在開(kāi)發(fā)網(wǎng)絡(luò)庫(kù)的時(shí)候,模擬 write(2) 返回 EPIPE 錯(cuò)誤(對(duì)方意外斷開(kāi)連接)。
            • 在開(kāi)發(fā)網(wǎng)絡(luò)庫(kù)的時(shí)候,模擬自連接 (self-connection),網(wǎng)絡(luò)庫(kù)應(yīng)該用 getsockname(2) 和 getpeername(2) 判斷是否是自連接,然后斷開(kāi)之。
            • 在開(kāi)發(fā)網(wǎng)絡(luò)庫(kù)的時(shí)候,模擬本地 ephemeral port 用完,connect(2) 返回 EAGAIN 臨時(shí)錯(cuò)誤。
            • 讓 gethostbyname(2) 返回我們預(yù)設(shè)的值,防止單元測(cè)試給公司的 DNS server 帶來(lái)太大壓力。

            這些 test case 恐怕很難用前文提到的 test harness 來(lái)測(cè)試,該單元測(cè)試上場(chǎng)了。現(xiàn)在的問(wèn)題是,如何 mock 這些系統(tǒng)函數(shù)?或者換句話說(shuō),如何把對(duì)系統(tǒng)函數(shù)的依賴(lài)注入到被測(cè)程序中?

            系統(tǒng)函數(shù)的依賴(lài)注入

            在Michael Feathers 的《修改代碼的藝術(shù) / Working Effectively with Legacy Code》一書(shū)第 4.3.2 節(jié)中,作者介紹了鏈接期接縫(link seam),正好可以解決我們的問(wèn)題。另外,在 Stack Overflow 的一個(gè)帖子里也總結(jié)了幾種做法:http://stackoverflow.com/questions/2924440/advice-on-mocking-system-calls

            如果程序(庫(kù))在編寫(xiě)的時(shí)候就考慮了可測(cè)試性,那么用不到上面的 hack 手段,我們可以從設(shè)計(jì)上解決依賴(lài)注入的問(wèn)題。這里提供兩個(gè)思路。

            其一,采用傳統(tǒng)的面向?qū)ο蟮氖址ǎ柚\(yùn)行期的遲綁定實(shí)現(xiàn)注入與替換。自己寫(xiě)一個(gè) System interface,把程序里用到的 open、close、read、write、connect、bind、listen、accept、gethostname、getpeername、getsockname 等等函數(shù)統(tǒng)統(tǒng)用虛函數(shù)封裝一層。然后在代碼里不要直接調(diào)用 open(),而是調(diào)用 System::instance().open()。

            這樣代碼主動(dòng)把控制權(quán)交給了 System interface,我們可以在這里動(dòng)動(dòng)手腳。在寫(xiě)單元測(cè)試的時(shí)候,把這個(gè) singleton instance 替換為我們的 mock object,這樣就能模擬各種 error code。

            其二,采用編譯期或鏈接期的遲綁定。注意到在第一種做法中,運(yùn)行期多態(tài)是不必要的,因?yàn)槌绦驈纳剿乐粫?huì)用到一個(gè) implementation object。為此付出虛函數(shù)調(diào)用的代價(jià)似乎有些不值。(其實(shí),跟系統(tǒng)調(diào)用比起來(lái),虛函數(shù)這點(diǎn)開(kāi)銷(xiāo)可忽略不計(jì)。)

            我們可以寫(xiě)一個(gè) system namespace 頭文件,在其中聲明 read() 和 write() 等普通函數(shù),然后在 .cc 文件里轉(zhuǎn)發(fā)給對(duì)應(yīng)系統(tǒng)的系統(tǒng)函數(shù) ::read() 和 ::write() 等。

            // SocketsOps.h
            namespace sockets
            {
            int connect(int sockfd, const struct sockaddr_in& addr);
            }
            // SocketsOps.cc
            int sockets::connect(int sockfd, const struct sockaddr_in& addr)
            {
            return ::connect(sockfd, sockaddr_cast(&addr), sizeof addr);
            }
            
            此處的代碼來(lái)自 muduo 網(wǎng)絡(luò)庫(kù)

            http://code.google.com/p/muduo/source/browse/trunk/muduo/net/SocketsOps.h
            http://code.google.com/p/muduo/source/browse/trunk/muduo/net/SocketsOps.cc

            有了這么一層間接性,就可以在編寫(xiě)單元測(cè)試的時(shí)候動(dòng)動(dòng)手腳,鏈接我們的 stub 實(shí)現(xiàn),以達(dá)到替換實(shí)現(xiàn)的目的:

            // MockSocketsOps.cc
            int sockets::connect(int sockfd, const struct sockaddr_in& addr)
            {
            errno = EAGAIN;
            return -1;
            }

            C++ 一個(gè)程序只能有一個(gè) main() 入口,所以要先把程序做成 library,再用單元測(cè)試代碼鏈接這個(gè) library。假設(shè)有一個(gè) mynetcat 程序,為了編寫(xiě) C++ 單元測(cè)試,我們把它拆成兩部分,library 和 main(),源文件分別是 mynetcat.cc 和 main.cc。

            在編譯普通程序的時(shí)候:

            g++ main.cc mynetcat.cc SocketsOps.cc -o mynetcat
            

            在編譯單元測(cè)試時(shí)這么寫(xiě):

            g++ test.cc mynetcat.cc MockSocketsOps.cc -o test

            以上是最簡(jiǎn)單的例子,在實(shí)際開(kāi)發(fā)中可以讓 stub 功能更強(qiáng)大一些,比如根據(jù)不同的 test case 返回不同的錯(cuò)誤。這么做無(wú)需用到虛函數(shù),代碼寫(xiě)起來(lái)也比較簡(jiǎn)潔,只用前綴 sockets:: 即可。例如應(yīng)用程序的代碼里寫(xiě) sockets::connect(fd, addr)。

            muduo 目前還沒(méi)有單元測(cè)試,只是預(yù)留了這些 stubs。

            namespace 的好處在于它不是封閉的,我們可以隨時(shí)打開(kāi)往里添加新的函數(shù),而不用改動(dòng)原來(lái)的頭文件(該文件的控制權(quán)可能不在我們手里)。這也是以 non-member non-friend 函數(shù)為接口的優(yōu)點(diǎn)。


            以上兩種做法還有一個(gè)好處,即只 mock 我們關(guān)心的部分代碼。如果程序用到了 SQLite 或 Berkeley DB 這些會(huì)訪問(wèn)本地文件系統(tǒng)的第三方庫(kù),那么我們的 System interface 或 system namespace 不會(huì)攔截這些第三方庫(kù)的 open(2)、close(2)、read(2)、write(2) 等系統(tǒng)調(diào)用。

            鏈接期墊片 (link seams)

            如果程序在一開(kāi)始編碼的時(shí)候沒(méi)有考慮單元測(cè)試,那么又該如何注入 mock 系統(tǒng)調(diào)用呢?上面第二種做法已經(jīng)給出了答案,那就是使用 link seam (鏈接期墊片)。

            比方說(shuō)要 mock connect(2) 函數(shù),那么我們自己在單元測(cè)試程序里實(shí)現(xiàn)一個(gè) connect() 函數(shù),在鏈接的時(shí)候,會(huì)優(yōu)先采用我們自己定義的函數(shù)。(這對(duì)動(dòng)態(tài)鏈接是成立的,如果是靜態(tài)鏈接,會(huì)報(bào)  multiple definition 錯(cuò)誤。好在絕大多數(shù)情況下 libc 是動(dòng)態(tài)鏈接的。)

            typedef int (*connect_func_t)(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
            connect_func_t connect_func = dlsym(RTDL_NEXT, "connect");
            bool mock_connect;
            int mock_connect_errno;
            // mock connect
            extern "C" int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
            {
            if (mock_connect) {
            errno = mock_connect_errno;
            return errno == 0 ? 0 : -1;
            } else {
            return connect_func(sockfd, addr, addrlen);
            }
            }
            


            如果程序真的要調(diào)用 connect(2) 怎么辦?在我們自己的 mock connect(2) 里不能再調(diào)用 connect() 了,否則會(huì)出現(xiàn)無(wú)限遞歸。為了防止這種情況,我們用 dlsym(RTDL_NEXT, "connect") 獲得 connect(2) 系統(tǒng)函數(shù)的真實(shí)地址,然后通過(guò)函數(shù)指針 connect_func 來(lái)調(diào)用它。

            例子:ZooKeeper 的 C client library

            ZooKeeper 的 C client library 正是采用了 link seams 來(lái)編寫(xiě)單元測(cè)試,代碼見(jiàn):

            http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.h
            http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.cc

            其他手法

            Stack Overflow 的帖子里還提到一個(gè)做法,可以方便地替換動(dòng)態(tài)庫(kù)里的函數(shù),即使用 ld 的 --wrap 參數(shù),
            文檔里說(shuō)得很清楚,這里不再贅述。

                   --wrap=symbol
            Use a wrapper function for symbol.  Any undefined reference to
            symbol will be resolved to "__wrap_symbol".  Any undefined
            reference to "__real_symbol" will be resolved to symbol.
            This can be used to provide a wrapper for a system function.  The
            wrapper function should be called "__wrap_symbol".  If it wishes to
            call the system function, it should call "__real_symbol".
            Here is a trivial example:
            void *
            __wrap_malloc (size_t c)
            {
            printf ("malloc called with %zu\n", c);
            return __real_malloc (c);
            }
            If you link other code with this file using --wrap malloc, then all
            calls to "malloc" will call the function "__wrap_malloc" instead.
            The call to "__real_malloc" in "__wrap_malloc" will call the real
            "malloc" function.
            You may wish to provide a "__real_malloc" function as well, so that
            links without the --wrap option will succeed.  If you do this, you
            should not put the definition of "__real_malloc" in the same file
            as "__wrap_malloc"; if you do, the assembler may resolve the call
            before the linker has a chance to wrap it to "malloc".
            

            第三方 C++ 庫(kù)

            link seam 同樣適用于第三方 C++ 庫(kù)

            比方說(shuō)公司某個(gè)基礎(chǔ)庫(kù)團(tuán)隊(duì)提供了了 File class,但是這個(gè) class 沒(méi)有使用虛函數(shù),我們無(wú)法通過(guò) sub-classing 的辦法來(lái)實(shí)現(xiàn) mock object。

            class File : boost::noncopyable
            {
            public:
            File(const char* filename);
            ~File();
            int readn(void* data, int len);
            int writen(const void* data, int len);
            size_t getSize() const;
            private:
            };

            如果需要為用到 File class 的程序編寫(xiě)單元測(cè)試,那么我們可以自己定義其成員函數(shù)的實(shí)現(xiàn),這樣可以注入任何我們想要的結(jié)果。

            // MockFile.cc
            int File::readn(void* data, int len)
            {
            return -1;
            }
            

            (這個(gè)做法對(duì)動(dòng)態(tài)庫(kù)是可行的,靜態(tài)庫(kù)會(huì)報(bào)錯(cuò)。我們要么讓對(duì)方提供專(zhuān)供單元測(cè)試的動(dòng)態(tài)庫(kù),要么拿過(guò)源碼來(lái)自己編譯一個(gè)。)


            Java 也有類(lèi)似的做法,在 class path 里替換我們自己的 stub jar 文件,以實(shí)現(xiàn) link seam。不過(guò) Java 有動(dòng)態(tài)代理,很少用得著 link seam 來(lái)實(shí)現(xiàn)依賴(lài)注入。

            posted @ 2011-05-16 00:18 陳碩 閱讀(3576) | 評(píng)論 (4)編輯 收藏

            Muduo 網(wǎng)絡(luò)編程示例之八:用 Timing wheel 踢掉空閑連接

            Muduo 網(wǎng)絡(luò)編程示例之八:Timing wheel 踢掉空閑連接

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第八篇文章,原計(jì)劃講文件傳輸,這里插入一點(diǎn)計(jì)劃之外的內(nèi)容。

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

            本文介紹如何使用 timing wheel 來(lái)踢掉空閑的連接,一個(gè)連接如果若干秒沒(méi)有收到數(shù)據(jù),就認(rèn)為是空閑連接。

            本文的代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/idleconnection

             

            在嚴(yán)肅的網(wǎng)絡(luò)程序中,應(yīng)用層的心跳協(xié)議是必不可少的。應(yīng)該用心跳消息來(lái)判斷對(duì)方進(jìn)程是否能正常工作,“踢掉空閑連接”只是一時(shí)權(quán)宜之計(jì)。我這里想順便講講 shared_ptr 和 weak_ptr 的用法。

            如果一個(gè)連接連續(xù)幾秒鐘(后文以 8s 為例)內(nèi)沒(méi)有收到數(shù)據(jù),就把它斷開(kāi),為此有兩種簡(jiǎn)單粗暴的做法:

            • 每個(gè)連接保存“最后收到數(shù)據(jù)的時(shí)間 lastReceiveTime”,然后用一個(gè)定時(shí)器,每秒鐘遍歷一遍所有連接,斷開(kāi)那些 (now - connection.lastReceiveTime) > 8s 的 connection。這種做法全局只有一個(gè) repeated timer,不過(guò)每次 timeout 都要檢查全部連接,如果連接數(shù)目比較大(幾千上萬(wàn)),這一步可能會(huì)比較費(fèi)時(shí)。
            • 每個(gè)連接設(shè)置一個(gè) one-shot timer,超時(shí)定為 8s,在超時(shí)的時(shí)候就斷開(kāi)本連接。當(dāng)然,每次收到數(shù)據(jù)要去更新 timer。這種做法需要很多個(gè) one-shot timer,會(huì)頻繁地更新 timers。如果連接數(shù)目比較大,可能對(duì) reactor 的 timer queue 造成壓力。

            使用 timing wheel 能避免上述兩種做法的缺點(diǎn)。timing wheel 可以翻譯為“時(shí)間輪盤(pán)”或“刻度盤(pán)”,本文保留英文。

            連接超時(shí)不需要精確定時(shí),只要大致 8 秒鐘超時(shí)斷開(kāi)就行,多一秒少一秒關(guān)系不大。處理連接超時(shí)可以用一個(gè)簡(jiǎn)單的數(shù)據(jù)結(jié)構(gòu):8 個(gè)桶組成的循環(huán)隊(duì)列。第一個(gè)桶放下一秒將要超時(shí)的連接,第二個(gè)放下 2 秒將要超時(shí)的連接。每個(gè)連接一收到數(shù)據(jù)就把自己放到第 8 個(gè)桶,然后在每秒鐘的 callback 里把第一個(gè)桶里的連接斷開(kāi),把這個(gè)空桶挪到隊(duì)尾。這樣大致可以做到 8 秒鐘沒(méi)有數(shù)據(jù)就超時(shí)斷開(kāi)連接。更重要的是,每次不用檢查全部的 connection,只要檢查第一個(gè)桶里的 connections,相當(dāng)于把任務(wù)分散了。

            Timing wheel 原理

            《Hashed and hierarchical timing wheels: efficient data structures for implementing a timer facility》這篇論文詳細(xì)比較了實(shí)現(xiàn)定時(shí)器的各種數(shù)據(jù)結(jié)構(gòu),并提出了層次化的 timing wheel 與 hash timing wheel 等新結(jié)構(gòu)。針對(duì)本文要解決的問(wèn)題的特點(diǎn),我們不需要實(shí)現(xiàn)一個(gè)通用的定時(shí)器,只用實(shí)現(xiàn) simple timing wheel 即可。

            Simple timing wheel 的基本結(jié)構(gòu)是一個(gè)循環(huán)隊(duì)列,還有一個(gè)指向隊(duì)尾的指針 (tail),這個(gè)指針每秒鐘移動(dòng)一格,就像鐘表上的時(shí)針,timing wheel 由此得名。

            以下是某一時(shí)刻 timing wheel 的狀態(tài),格子里的數(shù)字是倒計(jì)時(shí)(與通常的 timing wheel 相反),表示這個(gè)格子(桶子)中的連接的剩余壽命。

            wheel1

            一秒鐘以后,tail 指針移動(dòng)一格,原來(lái)四點(diǎn)鐘方向的格子被清空,其中的連接已被斷開(kāi)。

            wheel2

            連接超時(shí)被踢掉的過(guò)程

            假設(shè)在某個(gè)時(shí)刻,conn 1 到達(dá),把它放到當(dāng)前格子中,它的剩余壽命是 7 秒。此后 conn 1 上沒(méi)有收到數(shù)據(jù)。

            wheel3

            1 秒鐘之后,tail 指向下一個(gè)格子,conn 1 的剩余壽命是 6 秒。

            wheel4

            又過(guò)了幾秒鐘,tail 指向 conn 1 之前的那個(gè)格子,conn 1 即將被斷開(kāi)。

            wheel5

            下一秒,tail 重新指向 conn 1 原來(lái)所在的格子,清空其中的數(shù)據(jù),斷開(kāi) conn 1 連接。

            wheel6

            連接刷新

            如果在斷開(kāi) conn 1 之前收到數(shù)據(jù),就把它移到當(dāng)前的格子里。

            wheel4

            收到數(shù)據(jù),conn 1 的壽命延長(zhǎng)為 7 秒。

            wheel7

            時(shí)間繼續(xù)前進(jìn),conn 1 壽命遞減,不過(guò)它已經(jīng)比第一種情況長(zhǎng)壽了。

            wheel8

            多個(gè)連接

            timing wheel 中的每個(gè)格子是個(gè) hash set,可以容納不止一個(gè)連接。

            比如一開(kāi)始,conn 1 到達(dá)。

            wheel3

            隨后,conn 2 到達(dá),這時(shí)候 tail 還沒(méi)有移動(dòng),兩個(gè)連接位于同一個(gè)格子中,具有相同的剩余壽命。(下圖中畫(huà)成鏈表,代碼中是哈希表。)

            wheel9

            幾秒鐘之后,conn 1 收到數(shù)據(jù),而 conn 2 一直沒(méi)有收到數(shù)據(jù),那么 conn 1 被移到當(dāng)前的格子中。這時(shí) conn 1 的壽命比 conn 2 長(zhǎng)。

            wheel10

            代碼實(shí)現(xiàn)與改進(jìn)

            我們用以前多次出現(xiàn)的 EchoServer 來(lái)說(shuō)明具體如何實(shí)現(xiàn) timing wheel。代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/idleconnection

            在具體實(shí)現(xiàn)中,格子里放的不是連接,而是一個(gè)特制的 Entry struct,每個(gè) Entry 包含 TcpConnection 的 weak_ptr。Entry 的析構(gòu)函數(shù)會(huì)判斷連接是否還存在(用 weak_ptr),如果還存在則斷開(kāi)連接。

            數(shù)據(jù)結(jié)構(gòu):

              typedef boost::weak_ptr<muduo::net::TcpConnection> WeakTcpConnectionPtr;
            struct Entry : public muduo::copyable
            {
            Entry(const WeakTcpConnectionPtr& weakConn)
            : weakConn_(weakConn)
            {
            }
            ~Entry()
            {
            muduo::net::TcpConnectionPtr conn = weakConn_.lock();
            if (conn)
            {
            conn->shutdown();
            }
            }
            WeakTcpConnectionPtr weakConn_;
            };
            typedef boost::shared_ptr<Entry> EntryPtr;
            typedef boost::weak_ptr<Entry> WeakEntryPtr;
            typedef boost::unordered_set<EntryPtr> Bucket;
            typedef boost::circular_buffer<Bucket> WeakConnectionList;
            

            在實(shí)現(xiàn)中,為了簡(jiǎn)單起見(jiàn),我們不會(huì)真的把一個(gè)連接從一個(gè)格子移到另一個(gè)格子,而是采用引用計(jì)數(shù)的辦法,用 shared_ptr 來(lái)管理 Entry。如果從連接收到數(shù)據(jù),就把對(duì)應(yīng)的 EntryPtr 放到這個(gè)格子里,這樣它的引用計(jì)數(shù)就遞增了。當(dāng) Entry 的引用計(jì)數(shù)遞減到零,說(shuō)明它沒(méi)有在任何一個(gè)格子里出現(xiàn),那么連接超時(shí),Entry 的析構(gòu)函數(shù)會(huì)斷開(kāi)連接。

            Timing wheel 用 boost::circular_buffer 實(shí)現(xiàn),其中每個(gè) Bucket 元素是個(gè) hash set of EntryPtr。

             

            在構(gòu)造函數(shù)中,注冊(cè)每秒鐘的回調(diào)(EventLoop::runEvery() 注冊(cè) EchoServer::onTimer() ),然后把 timing wheel 設(shè)為適當(dāng)?shù)拇笮 ?/p>

            EchoServer::EchoServer(EventLoop* loop,
            const InetAddress& listenAddr,
            int idleSeconds)
            : loop_(loop),
            server_(loop, listenAddr, "EchoServer"),
            connectionBuckets_(idleSeconds)
            {
            server_.setConnectionCallback(
            boost::bind(&EchoServer::onConnection, this, _1));
            server_.setMessageCallback(
            boost::bind(&EchoServer::onMessage, this, _1, _2, _3));
            loop->runEvery(1.0, boost::bind(&EchoServer::onTimer, this));
            connectionBuckets_.resize(idleSeconds);
            }

            其中 EchoServer::onTimer() 的實(shí)現(xiàn)只有一行:往隊(duì)尾添加一個(gè)空的 Bucket,這樣 circular_buffer 會(huì)自動(dòng)彈出隊(duì)首的 Bucket,并析構(gòu)之。在析構(gòu) Bucket 的時(shí)候,會(huì)依次析構(gòu)其中的 EntryPtr 對(duì)象,這樣 Entry 的引用計(jì)數(shù)就不用我們?nèi)ゲ傩模珻++ 的值語(yǔ)意會(huì)幫我們搞定一切。

            void EchoServer::onTimer()
            {
            connectionBuckets_.push_back(Bucket());
            }

            在連接建立時(shí),創(chuàng)建一個(gè) Entry 對(duì)象,把它放到 timing wheel 的隊(duì)尾。另外,我們還需要把 Entry 的弱引用保存到 TcpConnection 的 context 里,因?yàn)樵谑盏綌?shù)據(jù)的時(shí)候還要用到 Entry。(思考題:如果 TcpConnection::setContext 保存的是強(qiáng)引用 EntryPtr,會(huì)出現(xiàn)什么情況?)

            void EchoServer::onConnection(const TcpConnectionPtr& conn)
            {
            LOG_INFO << "EchoServer - " << conn->peerAddress().toHostPort() << " -> "
            << conn->localAddress().toHostPort() << " is "
            << (conn->connected() ? "UP" : "DOWN");
            if (conn->connected())
            {
            EntryPtr entry(new Entry(conn));
            connectionBuckets_.back().insert(entry);
            WeakEntryPtr weakEntry(entry);
            conn->setContext(weakEntry);
            }
            else
            {
            assert(!conn->getContext().empty());
            WeakEntryPtr weakEntry(boost::any_cast<WeakEntryPtr>(conn->getContext()));
            LOG_DEBUG << "Entry use_count = " << weakEntry.use_count();
            }
            }

            在收到消息時(shí),從 TcpConnection 的 context 中取出 Entry 的弱引用,把它提升為強(qiáng)引用 EntryPtr,然后放到當(dāng)前的 timing wheel 隊(duì)尾。(思考題,為什么要把 Entry 作為 TcpConnection 的 context 保存,如果這里再創(chuàng)建一個(gè)新的 Entry 會(huì)有什么后果?)

            void EchoServer::onMessage(const TcpConnectionPtr& conn,
            Buffer* buf,
            Timestamp time)
            {
            string msg(buf->retrieveAsString());
            LOG_INFO << conn->name() << " echo " << msg.size() << " bytes at " << time.toString();
            conn->send(msg);
            assert(!conn->getContext().empty());
            WeakEntryPtr weakEntry(boost::any_cast<WeakEntryPtr>(conn->getContext()));
            EntryPtr entry(weakEntry.lock());
            if (entry)
            {
            connectionBuckets_.back().insert(entry);
            }
            }
            

            然后呢?沒(méi)有然后了,程序已經(jīng)完成了我們想要的功能。(完整的代碼會(huì)打印 circular_buffer 變化的情況,運(yùn)行一下即可理解。)

            希望本文有助于您理解 shared_ptr 和 weak_ptr。

            改進(jìn)

            在現(xiàn)在的實(shí)現(xiàn)中,每次收到消息都會(huì)往隊(duì)尾添加 EntryPtr (當(dāng)然,hash set 會(huì)幫我們?nèi)ブ亍#┮粋€(gè)簡(jiǎn)單的改進(jìn)措施是,在 TcpConnection 里保存“最后一次往隊(duì)尾添加引用時(shí)的 tail 位置”,然后先檢查 tail 是否變化,若無(wú)變化則不重復(fù)添加 EntryPtr。這樣或許能提高效率。

            以上改進(jìn)留作練習(xí)。

            posted @ 2011-05-04 21:19 陳碩 閱讀(4074) | 評(píng)論 (5)編輯 收藏

            僅列出標(biāo)題
            共6頁(yè): 1 2 3 4 5 6 
            <2010年4月>
            28293031123
            45678910
            11121314151617
            18192021222324
            2526272829301
            2345678

            導(dǎo)航

            統(tǒng)計(jì)

            常用鏈接

            隨筆分類(lèi)

            隨筆檔案

            相冊(cè)

            搜索

            最新評(píng)論

            閱讀排行榜

            評(píng)論排行榜

            无码AV波多野结衣久久| 色综合久久夜色精品国产| 久久综合亚洲色一区二区三区| 色婷婷久久综合中文久久蜜桃av | 伊人色综合九久久天天蜜桃| 国产情侣久久久久aⅴ免费| 无码乱码观看精品久久| 国产无套内射久久久国产| 99久久久精品| 久久成人影院精品777| 午夜精品久久久久久中宇| 99久久精品国产一区二区蜜芽| 久久ZYZ资源站无码中文动漫| 久久亚洲AV成人无码国产| 欧美激情精品久久久久久久| 亚洲国产日韩欧美综合久久| 久久国产欧美日韩精品| 国内精品伊人久久久影院| 久久se精品一区精品二区国产 | 国产精品一久久香蕉产线看| 2021国产精品午夜久久| 亚洲国产成人久久一区WWW| 久久精品国产亚洲av影院| 一本色道久久88精品综合 | 熟妇人妻久久中文字幕| 2021最新久久久视精品爱| 亚洲伊人久久综合中文成人网| 观看 国产综合久久久久鬼色 欧美 亚洲 一区二区 | 精品久久久久久无码国产| 国产成人精品久久二区二区| 久久精品国产亚洲av麻豆蜜芽| 新狼窝色AV性久久久久久| 久久香综合精品久久伊人| 99久久综合国产精品免费| 国产欧美久久久精品影院| 久久久久久久久波多野高潮| 影音先锋女人AV鲁色资源网久久| 伊人久久精品无码av一区| 久久久无码人妻精品无码| A级毛片无码久久精品免费| 91精品国产综合久久香蕉|