陳碩 (giantchen_AT_gmail)
http://blog.csdn.net/Solstice http://weibo.com/giantchen
陳碩關于 C++ 工程實踐的系列文章: 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 署名-非商業性使用-禁止演繹 3.0 Unported 許可協議(cc by-nc-nd)”進行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/
前一篇文章談了值語義,這篇文章談一談與之密切相關的數據抽象(data abstraction)。
文章結構:
- 什么是數據抽象?它與面向對象有何區別?
- 數據抽象所需的語言設施
- 數據抽象的例子
什么是數據抽象
數據抽象(data abstraction)是與面向對象(object-oriented)并列的一種編程范式(programming paradigm)。說“數據抽象”或許顯得陌生,它的另外一個名字“抽象數據類型/abstract data type/ADT”想必如雷貫耳。
“支持數據抽象”一直是C++語言的設計目標,Bjarne Stroustrup 在他的《The C++ Programming Language》第二版(1991年出版)中寫道[2nd]:
The C++ programming language is designed to
- be a better C
- support data abstraction
- support object-oriented programming
這本書第三版(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++ 的早期文獻,其中有一篇 Bjarne Stroustrup 在 1984 年寫的 《Data Abstraction in C++》 http://www.softwarepreservation.org/projects/c_plus_plus/cfront/release_e/doc/DataAbstraction.pdf 。在這個頁面還能找到 Bjarne 寫的關于 C++ 操作符重載和復數運算的文章,作為數據抽象的詳解與范例??梢?C++ 早期是以數據抽象為賣點的,支持數據抽象是C++相對于C的一大優勢。
作為語言的設計者,Bjarne 把數據抽象作為C++的四個子語言之一。這個觀點不是普遍接受的,比如作為語言的使用者,Scott Meyers 在《Effective C++ 第三版》中把 C++ 分為四個子語言:C、Object-Oriented C++、Template C++、STL。在 Scott Meyers 的分類法中,就沒有出現數據抽象,而是歸入了 object-oriented C++。
那么到底什么是數據抽象?
簡單的說,數據抽象是用來描述數據結構的。數據抽象就是 ADT。一個 ADT 主要表現為它支持的一些操作,比方說 stack.push、stack.pop,這些操作應該具有明確的時間和空間復雜度。另外,一個 ADT 可以隱藏其實現細節,比方說 stack 既可以用動態數組實現,又可以用鏈表實現。
按照這個定義,數據抽象和基于對象(object-based)很像,那么它們的區別在哪里?語義不同。ADT 通常是值語義,而 object-based 是對象語言。(這兩種語義的定義見前文《C++ 工程實踐(8):值語義》)。ADT class 是可以拷貝的,拷貝之后的 instance 與原 instance 脫離關系。
比方說 stack a; a.push(10); stack b = a; b.pop(); 這時候 a 里仍然有元素 10。
C++ 標準庫中的數據抽象
C++ 標準庫里 complex<> 、pair<>、vector<>、list<>、map<>、set<>、string、stack、queue 都是數據抽象的例子。vector 是動態數組,它的主要操作有 push_back()、size()、begin()、end() 等等,這些操作不僅含義清晰,而且計算復雜度都是常數。類似的,list 是鏈表,map 是有序關聯數組,set 是有序集合、stack 是 FILO 棧、queue是 FIFO 隊列。“動態數組”、“鏈表”、“有序集合”、“關聯數組”、“棧”、“隊列”都是定義明確(操作、復雜度)的抽象數據類型。
數據抽象與面向對象的區別
本文把 data abstraction、object-based、object-oriented 視為三個編程范式。這種細致的分類或許有助于理解區分它們之間的差別。
庸俗地講,面向對象(object-oriented)有三大特征:封裝、繼承、多態。而基于對象(object-based)則只有封裝,沒有繼承和多態,即只有具體類,沒有抽象接口。它們兩個都是對象語義。
面向對象真正核心的思想是消息傳遞(messaging),“封裝繼承多態”只是表象。這一點孟巖 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/ 都有精彩的論述,陳碩不再贅言。
數據抽象與它們兩個的界限在于“語義”,數據抽象不是對象語義,而是值語義。比方說 muduo 里的 TcpConnection 和 Buffer 都是具體類,但前者是基于對象的(object-based),而后者是數據抽象。
類似的,muduo::Date、muduo::Timestamp 都是數據抽象。盡管這兩個 classes 簡單到只有一個 int/long 數據成員,但是它們各自定義了一套操作(operation),并隱藏了內部數據,從而讓它從 data aggregation 變成了 data abstraction。
數據抽象是針對“數據”的,這意味著 ADT class 應該可以拷貝,只要把數據復制一份就行了。如果一個 class 代表了其他資源(文件、員工、打印機、賬號),那么它就是 object-based 或 object-oriented,而不是數據抽象。
ADT class 可以作為 Object-based/object-oriented class 的成員,但反過來不成立,因為這樣一來 ADS class 的拷貝就失去意義了。
數據抽象所需的語言設施
不是每個語言都支持數據抽象,下面簡要列出“數據抽象”所需的語言設施。
支持數據聚合
數據聚合 data aggregation,或者 value aggregates。即定義 C-style struct,把有關數據放到同一個 struct 里。FORTRAN77沒有這個能力,FORTRAN77 無法實現 ADT。這種數據聚合 struct 是 ADT 的基礎,struct List、struct HashTable 等能把鏈表和哈希表結構的數據放到一起,而不是用幾個零散的變量來表示它。
全局函數與重載
例如我定義了 complex,那么我可以同時定義 complex sin(const complex& x); 和 complex exp(const complex& x); 等等全局函數來實現復數的三角函數和指數運算。sin 和 exp 不是 complex 的成員,而是全局函數 double sin(double) 和 double exp(double) 的重載。這樣能讓 double a = sin(b); 和 complex a = sin(b); 具有相同的代碼形式,而不必寫成 complex a = b.sin();。
C 語言可以定義全局函數,但是不能與已有的函數重名,也就沒有重載。Java 沒有全局函數,而且 Math class 是封閉的,并不能往其中添加 sin(Complex)。
成員函數與 private 數據
數據也可以聲明為 private,防止外界意外修改。不是每個 ADT 都適合把數據聲明為 private,例如 complex、point、pair<> 這樣的 ADT 使用 public data 更加合理。
要能夠在 struct 里定義操作,而不是只能用全局函數來操作 struct。比方說 vector 有 push_back() 操作,push_back 是 vector 的一部分,它必須直接修改 vector 的 private data members,因此無法定義為全局函數。
這兩點其實就是定義 class,現在的語言都能直接支持,C 語言除外。
拷貝控制(copy control)
copy control 是拷貝 stack a; stack b = a; 和賦值 stack b; b = a; 的合稱。
當拷貝一個 ADT 時會發生什么?比方說拷貝一個 stack,是不是應該把它的每個元素按值拷貝到新 stack?
如果語言支持顯示控制對象的生命期(比方說C++的確定性析構),而 ADT 用到了動態分配的內存,那么 copy control 更為重要,不然如何防止訪問已經失效的對象?
由于 C++ class 是值語義,copy control 是實現深拷貝的必要手段。而且 ADT 用到的資源只涉及動態分配的內存,所以深拷貝是可行的。相反,object-based 編程風格中的 class 往往代表某樣真實的事物(Employee、Account、File 等等),深拷貝無意義。
C 語言沒有 copy control,也沒有辦法防止拷貝,一切要靠程序員自己小心在意。FILE* 可以隨意拷貝,但是只要關閉其中一個 copy,其他 copies 也都失效了,跟空懸指針一般。整個 C 語言對待資源(malloc 得到的內存,open() 打開的文件,socket() 打開的連接)都是這樣,用整數或指針來代表(即“句柄”)。而整數和指針類型的“句柄”是可以隨意拷貝的,很容易就造成重復釋放、遺漏釋放、使用已經釋放的資源等等常見錯誤。這方面 C++ 是一個顯著的進步,boost::noncopyable 是 boost 里最值得推廣的庫。
操作符重載
如果要寫動態數組,我們希望能像使用內置數組一樣使用它,比如支持下標操作。C++可以重載 operator[] 來做到這一點。
如果要寫復數,我們系統能像使用內置的 double 一樣使用它,比如支持加減乘除。C++ 可以重載 operator+ 等操作符來做到這一點。
如果要寫日期時間,我們希望它能直接用大于小于號來比較先后,用 == 來判斷是否相等。C++ 可以重載 operator< 等操作符來做到這一點。
這要求語言能重載成員與全局操作符。操作符重載是 C++ 與生俱來的特性,1984 年的 CFront E 就支持操作符重載,并且提供了一個 complex class,這個 class 與目前標準庫的 complex<> 在使用上無區別。
如果沒有操作符重載,那么用戶定義的ADT與內置類型用起來就不一樣(想想有的語言要區分 == 和 equals,代碼寫起來實在很累贅)。Java 里有 BigInteger,但是 BigInteger 用起來和普通 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;
}
當然,操作符重載容易被濫用,因為這樣顯得很酷。我認為只在 ADT 表示一個“數值”的時候才適合重載加減乘除,其他情況下用具名函數為好,因此 muduo::Timestamp 只重載了關系操作符,沒有重載加減操作符。另外一個理由見《C++ 工程實踐(3):采用有利于版本管理的代碼格式》。
效率無損
“抽象”不代表低效。在 C++ 中,提高抽象的層次并不會降低效率。不然的話,人們寧可在低層次上編程,而不愿使用更便利的抽象,數據抽象也就失去了市場。后面我們將看到一個具體的例子。
模板與泛型
如果我寫了一個 int vector,那么我不想為 doule 和 string 再實現一遍同樣的代碼。我應該把 vector 寫成 template,然后用不同的類型來具現化它,從而得到 vector<int>、vector<double>、vector<complex>、vector<string> 等等具體類型。
不是每個 ADT 都需要這種泛型能力,一個 Date class 就沒必要讓用戶指定該用哪種類型的整數,int32_t 足夠了。
根據上面的要求,不是每個面向對象語言都能原生支持數據抽象,也說明數據抽象不是面向對象的子集。
數據抽象的例子
下面我們看看數值模擬 N-body 問題的兩個程序,前一個用 C 語言,后一個是 C++ 的。這個例子來自編程語言的性能對比網站 http://shootout.alioth.debian.org/gp4/benchmark.php?test=nbody&lang=all。
兩個程序使用了相同的算法。
C 語言版,完整代碼見 https://gist.github.com/1158889#file_nbody.c,下面是代碼骨干。planet 保存與行星位置、速度、質量,位置和速度各有三個分量,程序模擬幾大行星在三維空間中受引力支配的運動。
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() 函數實現的數值積分,它根據各個星球之間的距離和引力,算出加速度,再修正速度,然后更新星球的位置。這個 naive 算法的復雜度是 O(N^2)。
C++ 數據抽象版,完整代碼見 https://gist.github.com/1158889#file_nbody.cc,下面是代碼骨架。
首先定義 Vector3 這個抽象,代表三維向量,它既可以是位置,有可以是速度。本處略去了 Vector3 的操作符重載,Vector3 支持常見的向量加減乘除運算。
然后定義 Planet 這個抽象,代表一個行星,它有兩個 Vector3 成員:位置和速度。
需要說明的是,按照語義,Vector3 是數據抽象,而 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() 代碼簡短得多,而且更容易驗證其正確性。(想想如果把 C 語言版的 advance() 中的 vx、vy、vz、dx、dy、dz 寫錯位了,這種錯誤較難發現。)
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 語言一樣快??纯?memory layout 就會明白:
C struct 的成員是連續存儲的,struct 數組也是連續的。

C++ 盡管定義了了 Vector3 這個抽象,它的內存布局并沒有改變,Planet 的布局和 C planet 一模一樣,Planet[] 的布局也和 C 數組一樣。
另一方面,C++ 的 inline 函數在這里也起了巨大作用,我們可以放心地調用 Vector3::operator+=() 等操作符,編譯器會生成和 C 一樣高效的代碼。
不是每個編程語言都能做到在提升抽象的時候不影響性能,來看看 Java 的內存布局。
如果我們用 class Vector3、class Planet、Planet[] 的方式寫一個 Java 版的 N-body 程序,內存布局將會是:

這樣大大降低了 memory locality,有興趣的讀者可以對比 Java 和 C++ 的實現效率。
注:這里的 N-body 算法只為比較語言之間的性能與編程的便利性,真正科研中用到的 N-body 算法會使用更高級和底層的優化,復雜度是O(N log N),在大規模模擬時其運行速度也比本 naive 算法快得多。
更多的例子
- Date 與 Timestamp,這兩個 class 的“數據”都是整數,各定義了一套操作,用于表達日期與時間這兩個概念。
- BigInteger,它本身就是一個“數”。如果用 C++ 實現 BigInteger,那么階乘函數寫出來十分自然,下面第二個函數是 Java 語言的版本。
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;
}
高精度運算庫 gmp 有一套高質量的 C++ 封裝 http://gmplib.org/manual/C_002b_002b-Interface-General.html#C_002b_002b-Interface-General
小結
數據抽象是C++的重要抽象手段,適合封裝“數據”,它的語義簡單,容易使用。數據抽象能簡化代碼書寫,減少偶然錯誤。
陳碩 (giantchen_AT_gmail)
http://blog.csdn.net/Solstice http://weibo.com/giantchen
陳碩關于 C++ 工程實踐的系列文章: 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 署名-非商業性使用-禁止演繹 3.0 Unported 許可協議(cc by-nc-nd)”進行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/
本文是前一篇《C++ 工程實踐(7):iostream 的用途與局限》的后續,在這篇文章的“iostream 與標準庫其他組件的交互”一節,我簡單地提到iostream的對象和C++標準庫中的其他對象(主要是容器和string)具有不同的語義,主要體現在iostream不能拷貝或賦值。今天全面談一談我對這個問題的理解。
本文的“對象”定義較為寬泛,a region of memory that has a type,在這個定義下,int、double、bool 變量都是對象。
什么是值語義
值語義(value sematics)指的是對象的拷貝與原對象無關,就像拷貝 int 一樣。C++ 的內置類型(bool/int/double/char)都是值語義,標準庫里的 complex<> 、pair<>、vector<>、map<>、string 等等類型也都是值語意,拷貝之后就與原對象脫離關系。Java 語言的 primitive types 也是值語義。
與值語義對應的是“對象語義/object sematics”,或者叫做引用語義(reference sematics),由于“引用”一詞在 C++ 里有特殊含義,所以我在本文中使用“對象語義”這個術語。對象語義指的是面向對象意義下的對象,對象拷貝是禁止的。例如 muduo 里的 Thread 是對象語義,拷貝 Thread 是無意義的,也是被禁止的:因為 Thread 代表線程,拷貝一個 Thread 對象并不能讓系統增加一個一模一樣的線程。
同樣的道理,拷貝一個 Employee 對象是沒有意義的,一個雇員不會變成兩個雇員,他也不會領兩份薪水??截?TcpConnection 對象也沒有意義,系統里邊只有一個 TCP 連接,拷貝 TcpConnection 對象不會讓我們擁有兩個連接。Printer 也是不能拷貝的,系統只連接了一個打印機,拷貝 Printer 并不能憑空增加打印機。凡此總總,面向對象意義下的“對象”是 non-copyable。
Java 里邊的 class 對象都是對象語義/引用語義。ArrayList<Integer> a = new ArrayList<Integer>(); ArrayList<Integer> b = a; 那么 a 和 b 指向的是同一個ArrayList 對象,修改 a 同時也會影響 b。
值語義與 immutable 無關。Java 有 value object 一說,按(PoEAA 486)的定義,它實際上是 immutable object,例如 String、Integer、BigInteger、joda.time.DateTime 等等(因為 Java 沒有辦法實現真正的值語義 class,只好用 immutable object 來模擬)。盡管 immutable object 有其自身的用處,但不是本文的主題。muduo 中的 Date、Timestamp 也都是 immutable 的。
C++中的值語義對象也可以是 mutable,比如 complex<>、pair<>、vector<>、map<>、string 都是可以修改的。muduo 的 InetAddress 和 Buffer 都具有值語義,它們都是可以修改的。
值語義的對象不一定是 POD,例如 string 就不是 POD,但它是值語義的。
值語義的對象不一定小,例如 vector<int> 的元素可多可少,但它始終是值語義的。當然,很多值語義的對象都是小的,例如complex<>、muduo::Date、muduo::Timestamp。
值語義與生命期
值語義的一個巨大好處是生命期管理很簡單,就跟 int 一樣——你不需要操心 int 的生命期。值語義的對象要么是 stack object,或者直接作為其他 object 的成員,因此我們不用擔心它的生命期(一個函數使用自己stack上的對象,一個成員函數使用自己的數據成員對象)。相反,對象語義的 object 由于不能拷貝,我們只能通過指針或引用來使用它。
一旦使用指針和引用來操作對象,那么就要擔心所指的對象是否已被釋放,這一度是 C++ 程序 bug 的一大來源。此外,由于 C++ 只能通過指針或引用來獲得多態性,那么在C++里從事基于繼承和多態的面向對象編程有其本質的困難——資源管理。
考慮一個簡單的對象建模——家長與子女:a Parent has a Child, a Child knows his/her Parent。在 Java 里邊很好寫,不用擔心內存泄漏,也不用擔心空懸指針:
public class Parent
{
private Child myChild;
}
public class Child
{
private Parent myParent;
}
只要正確初始化 myChild 和 myParent,那么 Java 程序員就不用擔心出現訪問錯誤。一個 handle 是否有效,只需要判斷其是否 non null。
在 C++ 里邊就要為資源管理費一番腦筋:Parent 和 Child 都代表的是真人,肯定是不能拷貝的,因此具有對象語義。Parent 是直接持有 Child 嗎?抑或 Parent 和 Child 通過指針互指?Child 的生命期由 Parent 控制嗎?如果還有 ParentClub 和 School 兩個 class,分別代表家長俱樂部和學校:ParentClub has many Parent(s),School has many Child(ren),那么如何保證它們始終持有有效的 Parent 對象和 Child 對象?何時才能安全地釋放 Parent 和 Child ?
直接但是易錯的寫法:
class Child;
class Parent : boost::noncopyable
{
private:
Child* myChild;
};
class Child : boost::noncopyable
{
private:
Parent* myParent;
};
如果直接使用指針作為成員,那么如何確保指針的有效性?如何防止出現空懸指針?Child 和 Parent 由誰負責釋放?在釋放某個 Parent 對象的時候,如何確保程序中沒有指向它的指針?在釋放某個 Child 對象的時候,如何確保程序中沒有指向它的指針?
這一系列問題一度是C++面向對象編程頭疼的問題,不過現在有了 smart pointer,我們可以借助 smart pointer 把對象語義轉換為值語義,從而輕松解決對象生命期:讓 Parent 持有 Child 的 smart pointer,同時讓 Child 持有 Parent 的 smart pointer,這樣始終引用對方的時候就不用擔心出現空懸指針。當然,其中一個 smart pointer 應該是 weak reference,否則會出現循環引用,導致內存泄漏。到底哪一個是 weak reference,則取決于具體應用場景。
如果 Parent 擁有 Child,Child 的生命期由其 Parent 控制,Child 的生命期小于 Parent,那么代碼就比較簡單:
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;
};
在上面這個設計中,Child 的指針不能泄露給外界,否則仍然有可能出現空懸指針。
如果 Parent 與 Child 的生命期相互獨立,就要麻煩一些:
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();
}
上面這個 shared_ptr+weak_ptr 的做法似乎有點小題大做。
考慮一個稍微復雜一點的對象模型:a Child has parents: mom and dad; a Parent has one or more Child(ren); a Parent knows his/her spouser. 這個對象模型用 Java 表述一點都不復雜,垃圾收集會幫我們搞定對象生命期。
public class Parent
{
private Parent mySpouser;
private ArrayList<Child> myChildren;
}
public class Child
{
private Parent myMom;
private Parent myDad;
}
如果用 C++ 來實現,如何才能避免出現空懸指針,同時避免出現內存泄漏呢?借助 shared_ptr 把裸指針轉換為值語義,我們就不用擔心這兩個問題了:
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++ 做面向對象編程將會困難重重。
值語義與標準庫
C++ 要求凡是能放入標準容器的類型必須具有值語義。準確地說:type 必須是 SGIAssignable concept 的 model。但是,由 于C++ 編譯器會為 class 默認提供 copy constructor 和 assignment operator,因此除非明確禁止,否則 class 總是可以作為標準庫的元素類型——盡管程序可以編譯通過,但是隱藏了資源管理方面的 bug。
因此,在寫一個 class 的時候,先讓它繼承 boost::noncopyable,幾乎總是正確的。
在現代 C++ 中,一般不需要自己編寫 copy constructor 或 assignment operator,因為只要每個數據成員都具有值語義的話,編譯器自動生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 為成員來持有其他對象,那么就能自動啟用或禁用 copying&assigning。例外:編寫 HashMap 這類底層庫時還是需要自己實現 copy control。
值語義與C++語言
C++ 的 class 本質上是值語義的,這才會出現 object slicing 這種語言獨有的問題,也才會需要程序員注意 pass-by-value 和 pass-by-const-reference 的取舍。在其他面向對象編程語言中,這都不需要費腦筋。
值語義是C++語言的三大約束之一,C++ 的設計初衷是讓用戶定義的類型(class)能像內置類型(int)一樣工作,具有同等的地位。為此C++做了以下設計(妥協):
- class 的 layout 與 C struct 一樣,沒有額外的開銷。定義一個“只包含一個 int 成員的 class ”的對象開銷和定義一個 int 一樣。
- 甚至 class data member 都默認是 uninitialized,因為函數局部的 int 是 uninitialized。
- class 可以在 stack 上創建,也可以在 heap 上創建。因為 int 可以是 stack variable。
- class 的數組就是一個個 class 對象挨著,沒有額外的 indirection。因為 int 數組就是這樣。
- 編譯器會為 class 默認生成 copy constructor 和 assignment operator。其他語言沒有 copy constructor 一說,也不允許重載 assignment operator。C++ 的對象默認是可以拷貝的,這是一個尷尬的特性。
- 當 class type 傳入函數時,默認是 make a copy (除非參數聲明為 reference)。因為把 int 傳入函數時是 make a copy。
- 當函數返回一個 class type 時,只能通過 make a copy(C++ 不得不定義 RVO 來解決性能問題)。因為函數返回 int 時是 make a copy。
- 以 class type 為成員時,數據成員是嵌入的。例如 pair<complex<double>, size_t> 的 layout 就是 complex<double> 挨著 size_t。
這些設計帶來了性能上的好處,原因是 memory locality。比方說我們在 C++ 里定義 complex<double> class,array of complex<double>, vector<complex<double> >,它們的 layout 分別是:(re 和 im 分別是復數的實部和虛部。)

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

Java 里邊每個 object 都有 header,至少有兩個 word 的開銷。對比 Java 和 C++,可見 C++ 的對象模型要緊湊得多。
待續
下一篇文章我會談與值語義緊密相關的數據抽象(data abstraction),解釋為什么它是與面向對象并列的一種編程范式,為什么支持面向對象的編程語言不一定支持數據抽象。C++在最初的時候是以 data abstraction 為賣點,不過隨著時間的流逝,現在似乎很多人只知 Object-Oriented,不知 data abstraction 了。C++ 的強大之處在于“抽象”不以性能損失為代價,下一篇文章我們將看到具體例子。
陳碩 (giantchen_AT_gmail)
http://blog.csdn.net/Solstice http://weibo.com/giantchen
陳碩關于 C++ 工程實踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx
陳碩博客文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
本作品采用“Creative Commons 署名-非商業性使用-禁止演繹 3.0 Unported 許可協議(cc by-nc-nd)”進行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/
本文主要考慮 x86 Linux 平臺,不考慮跨平臺的可移植性,也不考慮國際化(i18n),但是要考慮 32-bit 和 64-bit 的兼容性。本文以 stdio 指代 C 語言的 scanf/printf 系列格式化輸入輸出函數。本文注意區分“編程初學者”和“C++初學者”,二者含義不同。
摘要:C++ iostream 的主要作用是讓初學者有一個方便的命令行輸入輸出試驗環境,在真實的項目中很少用到 iostream,因此不必把精力花在深究 iostream 的格式化與 manipulator。iostream 的設計初衷是提供一個可擴展的類型安全的 IO 機制,但是后來莫名其妙地加入了 locale 和 facet 等累贅。其整個設計復雜不堪,多重+虛擬繼承的結構也很巴洛克,性能方面幾無亮點。iostream 在實際項目中的用處非常有限,為此投入過多學習精力實在不值。
stdio 格式化輸入輸出的缺點
1. 對編程初學者不友好
看看下面這段簡單的輸入輸出代碼。
#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。
- 輸入的參數不統一。對于 i、s、f、d 等變量,在傳入 scanf() 的時候要取地址(&),而對于 name,則不用取地址。
讀者可以試一試如何用幾句話向剛開始學編程的初學者解釋上面兩條背后原因(涉及到傳遞函數不定參數時的類型轉換,函數調用棧的內存布局,指針的意義,字符數組退化為字符指針等等),如果一開始解釋不清,只好告訴學生“這是規定”。
- 緩沖區溢出的危險。上面的例子在讀入 name 的時候沒有指定大小,這是用 C 語言編程的安全漏洞的主要來源。應該在一開始就強調正確的做法,避免養成錯誤的習慣。正確而安全的做法如 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);
}
這個動態構造格式化字符串的做法恐怕更難向初學者解釋。
2. 安全性(security)
C 語言的安全性問題近十幾年來引起了廣泛的注意,C99 增加了 snprintf() 等能夠指定輸出緩沖區大小的函數,輸出方面的安全性問題已經得到解決;輸入方面似乎沒有太大進展,還要靠程序員自己動手。
考慮一個簡單的編程任務:從文件或標準輸入讀入一行字符串,行的長度不確定。我發現沒有哪個 C 語言標準庫函數能完成這個任務,除非 roll your own。
首先,gets() 是錯誤的,因為不能指定緩沖區的長度。
其次,fgets() 也有問題。它能指定緩沖區的長度,所以是安全的。但是程序必須預設一個長度的最大值,這不滿足題目要求“行的長度不確定”。另外,程序無法判斷 fgets() 到底讀了多少個字節。為什么?考慮一個文件的內容是 9 個字節的字符串 "Chen\000Shuo",注意中間出現了 '\0' 字符,如果用 fgets() 來讀取,客戶端如何知道 "\000Shuo" 也是輸入的一部分?畢竟 strlen() 只返回 4,而且整個字符串里沒有 '\n' 字符。
最后,可以用 glibc 定義的 getline(3) 函數來讀取不定長的“行”。這個函數能正確處理各種情況,不過它返回的是 malloc() 分配的內存,要求調用端自己 free()。
3. 類型安全(type-safe)
如果 printf() 的整數參數類型是 int、long 等標準類型, 那么 printf() 的格式化字符串很容易寫。但是如果參數類型是 typedef 的類型呢?
如果你想在程序中用 printf 來打印日志,你能一眼看出下面這些類型該用 "%d" "%ld" "%lld" 中的哪一個來輸出?你的選擇是否同時兼容 32-bit 和 64-bit 平臺?
- clock_t。這是 clock(3) 的返回類型
- dev_t。這是 mknod(3) 的參數類型
- in_addr_t、in_port_t。這是 struct sockaddr_in 的成員類型
- nfds_t。這是 poll(2) 的參數類型
- off_t。這是 lseek(2) 的參數類型,麻煩的是,這個類型與宏定義 _FILE_OFFSET_BITS 有關。
- pid_t、uid_t、gid_t。這是 getpid(2) getuid(2) getgid(2) 的返回類型
- ptrdiff_t。printf() 專門定義了 "t" 前綴來支持這一類型(即使用 "%td" 來打印)。
- size_t、ssize_t。這兩個類型到處都在用。printf() 為此專門定義了 "z" 前綴來支持這兩個類型(即使用 "%zu" 或 "%zd" 來打印)。
- socklen_t。這是 bind(2) 和 connect(2) 的參數類型
- time_t。這是 time(2) 的返回類型,也是 gettimeofday(2) 和 clock_gettime(2) 的輸出結構體的成員類型
如果在 C 程序里要正確打印以上類型的整數,恐怕要費一番腦筋?!禩he Linux Programming Interface》的作者建議(3.6.2節)先統一轉換為 long 類型再用 "%ld" 來打??;對于某些類型仍然需要特殊處理,比如 off_t 的類型可能是 long long。
還有,int64_t 在 32-bit 和 64-bit 平臺上是不同的類型,為此,如果程序要打印 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++ 編碼規范也提到了 64-bit 兼容性: http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#64-bit_Portability
這些問題在 C++ 里都不存在,在這方面 iostream 是個進步。
C stdio 在類型安全方面原本還有一個缺點,即格式化字符串與參數類型不匹配會造成難以發現的 bug,不過現在的編譯器已經能夠檢測很多這種錯誤:
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. 不可擴展?
C stdio 的另外一個缺點是無法支持自定義的類型,比如我寫了一個 Date class,我無法像打印 int 那樣用 printf 來直接打印 Date 對象。
struct Date
{
int year, month, day;
};
Date date;
printf("%D", &date); // WRONG
Glibc 放寬了這個限制,允許用戶調用 register_printf_function(3) 注冊自己的類型,當然,前提是與現有的格式字符不沖突(這其實大大限制了這個功能的用處,現實中也幾乎沒有人真的去用它)。http://www.gnu.org/s/hello/manual/libc/Printf-Extension-Example.html http://en.wikipedia.org/wiki/Printf#Custom_format_placeholders
5. 性能
C stdio 的性能方面有兩個弱點。
- 使用一種 little language (現在流行叫 DSL)來配置格式。固然有利于緊湊性和靈活性,但損失了一點點效率。每次打印一個整數都要先解析 "%d" 字符串,大多數情況下不是問題,某些場合需要自己寫整數到字符串的轉換。
- C locale 的負擔。locale 指的是不同語種對“什么是空白”、“什么是字母”,“什么是小數點”有不同的定義(德語里邊小數點是逗號,不是句點)。C 語言的 printf()、scanf()、isspace()、isalpha()、ispunct()、strtod() 等等函數都和 locale 有關,而且可以在運行時動態更改。就算是程序只使用默認的 "C" locale,任然要為這個靈活性付出代價。
iostream 的設計初衷
iostream 的設計初衷包括克服 C stdio 的缺點,提供一個高效的可擴展的類型安全的 IO 機制。“可擴展”有兩層意思,一是可以擴展到用戶自定義類型,而是通過繼承 iostream 來定義自己的 stream,本文把前一種稱為“類型可擴展”后一種稱為“功能可擴展”。
“類型可擴展”和“類型安全”都是通過函數重載來實現的。
iostream 對初學者很友好,用 iostream 重寫與前面同樣功能的代碼:
#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 版本容易解釋得多,而且沒有安全性(security)方面的問題。
我們自己的類型也可以融入 iostream,使用起來與 built-in 類型沒有區別。這主要得力于 C++ 可以定義 non-member functions/operators。
#include <ostream> // 是不是太重量級了?
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 憑借這兩點(類型安全和類型可擴展),基本克服了 stdio 在使用上的不便與不安全。如果 iostream 止步于此,那它將是一個非常便利的庫,可惜它前進了另外一步。
iostream 與標準庫其他組件的交互
不同于標準庫其他 class 的“值語意”,iostream 是“對象語意”,即 iostream 是 non-copyable。這是正確的,因為如果 fstream 代表一個文件的話,拷貝一個 fstream 對象意味著什么呢?表示打開了兩個文件嗎?如果銷毀一個 fstream 對象,它會關閉文件句柄,那么另一個 fstream copy 對象會因此受影響嗎?
C++ 同時支持“數據抽象”和“面向對象編程”,其實主要就是“值語意”與“對象語意”的區別,我發現不是每個人都清楚這一點,這里多說幾句。標準庫里的 complex<> 、pair<>、vector<>、 string 等等都是值語意,拷貝之后就與原對象脫離關系,就跟拷貝一個 int 一樣。而我們自己寫的 Employee class、TcpConnection class 通常是對象語意,拷貝一個 Employee 對象是沒有意義的,一個雇員不會變成兩個雇員,他也不會領兩份薪水??截?TcpConnection 對象也沒有意義,系統里邊只有一個 TCP 連接,拷貝 TcpConnection 對象不會讓我們擁有兩個連接。因此如果在 C++ 里做面向對象編程,寫的 class 通常應該禁用 copy constructor 和 assignment operator,比如可以繼承 boost::noncopyable。對象語意的類型不能直接作為標準容器庫的成員。另一方面,如果要寫一個圖形程序,其中用到三維空間的向量,那么我們可以寫 Vector3D class,它應該是值語意的,允許拷貝,并且可以用作標準容器庫的成員,例如 vector<Vector3D> 表示一條三維的折線。
C stdio 的另外一個缺點是 FILE* 可以隨意拷貝,但是只要關閉其中一個 copy,其他 copies 也都失效了,跟空懸指針一般。這其實不光是 C stdio 的缺點,整個 C 語言對待資源(malloc 得到的內存,open() 打開的文件,socket() 打開的連接)都是這樣,用整數或指針來代表(即“句柄”)。而整數和指針類型的“句柄”是可以隨意拷貝的,很容易就造成重復釋放、遺漏釋放、使用已經釋放的資源等等常見錯誤。這是因為 C 語言錯誤地讓“對象語言”的東西變成了值語意。
iostream 禁止拷貝,利用對象的生命期來明確管理資源(如文件),很自然地就避免了 C 語言易犯的錯誤。這就是 RAII,一種重要且獨特的 C++ 編程手法。
std::string
iostream 可以與 string 配合得很好。但是有一個問題:誰依賴誰?
std::string 的 operator << 和 operator >> 是如何聲明的?"string" 頭文件在聲明這兩個 operators 的時候要不要 include "iostream" ?
iostream 和 string 都可以單獨 include 來使用,顯然 iostream 頭文件里不會定義 string 的 << 和 >> 操作。但是,如果"string"要include "iostream",豈不是讓 string 的用戶被迫也用了 iostream?編譯 iostream 頭文件可是相當的慢啊(因為 iostream 是 template,其實現代碼都放到了頭文件中)。
標準庫的解決辦法是定義 iosfwd 頭文件,其中包含 istream 和 ostream 等的前向聲明 (forward declarations),這樣 "string" 頭文件在定義輸入輸出操作符時就可以不必包含 "iostream",只需要包含簡短得多的 "iosfwd"。我們自己寫程序也可借此學習如何支持可選的功能。
值得注意的是,istream::getline() 成員函數的參數類型是 char*,因為 "istream" 沒有包含 "string",而我們常用的 std::getline() 函數是個 non-member function,定義在 "string" 里邊。
std::complex
標準庫的復數類 complex 的情況比較復雜。使用 complex 會自動包含 sstream,后者會包含 istream 和 ostream,這是個不小的負擔。問題是,為什么?
它的 operator >> 操作比 string 復雜得多,如何應對格式不正確的情況?輸入字符串不會遇到格式不正確,但是輸入一個復數可能遇到各種問題,比如數字的格式不對等。我懷疑有誰會真的在產品項目里用 operator >> 來讀入字符方式表示的復數,這樣的代碼的健壯性如何保證。基于同樣的理由,我認為產品代碼中應該避免用 istream 來讀取帶格式的內容,后面也不再談 istream 的缺點,它已經被秒殺。
它的 operator << 也很奇怪,它不是直接使用參數 ostream& os 對象來輸出,而是先構造 ostringstream,輸出到該 string stream,再把結果字符串輸出到 ostream。簡化后的代碼如下:
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 會用到動態分配內存,也就是說,每輸出一個 complex 對象就會分配釋放一次內存,效率堪憂。
根據以上分析,我認為 iostream 和 complex 配合得不好,但是它們耦合得更緊密(與 string/iostream 相比),這可能是個不得已的技術限制吧(complex 是 template,其 operator<< 必須在頭文件中定義,而這個定義又用到了 ostringstream,不得已包含了 iostream 的實現)。
如果程序要對 complex 做 IO,從效率和健壯性方面考慮,建議不要使用 iostream。
iostream 在使用方面的缺點
在簡單使用 iostream 的時候,它確實比 stdio 方便,但是深入一點就會發現,二者可說各擅勝場。下面談一談 iostream 在使用方面的缺點。
1. 格式化輸出很繁瑣
iostream 采用 manipulator 來格式化,如果我想按照 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,會簡短得多,因為 printf 采用了一種表達能力較強的小語言來描述輸出格式。
--- 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:
使用小語言來描述格式還帶來另外一個好處:外部可配置。
2. 外部可配置性
比方說,我想用一個外部的配置文件來定義日期的格式。C stdio 很好辦,把格式字符串 "%d-%02d-%02d" 保存到配置里就行。但是 iostream 呢?它的格式是寫死在代碼里的,靈活性大打折扣。
再舉一個例子,程序的 message 的多語言化。
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;
對于 stdio,要讓這段程序支持中文的話,把代碼中的"My name is %1$s, I am %2$d years old.\n",
替換為 "我叫%1$s,今年%2$d歲。\n" 即可。也可以把這段提示語做成資源文件,在運行時讀入。而對于 iostream,恐怕沒有這么方便,因為代碼是支離破碎的。
C stdio 的格式化字符串體現了重要的“數據就是代碼”的思想,這種“數據”與“代碼”之間的相互轉換是程序靈活性的根源,遠比 OO 更為靈活。
3. stream 的狀態
如果我想用 16 進制方式輸出一個整數 x,那么可以用 hex 操控符,但是這會改變 ostream 的狀態。比如說
int x = 8888;
cout << hex << showbase << x << endl; // forgot to reset state
cout << 123 << endl;
這這段代碼會把 123 也按照 16 進制方式輸出,這恐怕不是我們想要的。
再舉一個例子,setprecision() 也會造成持續影響:
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
可見代碼中的 setprecision() 影響了后續輸出的精度。注意 setw() 不會造成影響,它只對下一個輸出有效。
這說明,如果使用 manipulator 來控制格式,需要時刻小心防止影響了后續代碼。而使用 C stdio 就沒有這個問題,它是“上下文無關的”。
4. 知識的通用性
在 C 語言之外,有其他很多語言也支持 printf() 風格的格式化,例如 Java、Perl、Ruby 等等 (http://en.wikipedia.org/wiki/Printf#Programming_languages_with_printf)。學會 printf() 的格式化方法,這個知識還可以用到其他語言中。但是 C++ iostream 只此一家別無分店,反正都是格式化輸出,stdio 的投資回報率更高。
基于這點考慮,我認為不必深究 iostream 的格式化方法,只需要用好它最基本的類型安全輸出即可。在真的需要格式化的場合,可以考慮 snprintf() 打印到棧上緩沖,再用 ostream 輸出。
5. 線程安全與原子性
iostream 的另外一個問題是線程安全性。stdio 的函數是線程安全的,而且 C 語言還提供了 flockfile(3)/funlockfile(3) 之類的函數來明確控制 FILE* 的加鎖與解鎖。
iostream 在線程安全方面沒有保證,就算單個 operator<< 是線程安全的,也不能保證原子性。因為 cout << a << b; 是兩次函數調用,相當于 cout.operator<<(a).operator<<(b)。兩次調用中間可能會被打斷進行上下文切換,造成輸出內容不連續,插入了其他線程打印的字符。
而 fprintf(stdout, "%s %d", a, b); 是一次函數調用,而且是線程安全的,打印的內容不會受其他線程影響。
因此,iostream 并不適合在多線程程序中做 logging。
iostream 的局限
根據以上分析,我們可以歸納 iostream 的局限:
- 輸入方面,istream 不適合輸入帶格式的數據,因為“糾錯”能力不強,進一步的分析請見孟巖寫的《契約思想的一個反面案例》,孟巖說“復雜的設計必然帶來復雜的使用規則,而面對復雜的使用規則,用戶是可以投票的,那就是你做你的,我不用!”可謂鞭辟入里。如果要用 istream,我推薦的做法是用 getline() 讀入一行數據,然后用正則表達式來判斷內容正誤,并做分組,然后用 strtod/strtol 之類的函數做類型轉換。這樣似乎更容易寫出健壯的程序。
- 輸出方面,ostream 的格式化輸出非常繁瑣,而且寫死在代碼里,不如 stdio 的小語言那么靈活通用。建議只用作簡單的無格式輸出。
- log 方面,由于 ostream 沒有辦法在多線程程序中保證一行輸出的完整性,建議不要直接用它來寫 log。如果是簡單的單線程程序,輸出數據量較少的情況下可以酌情使用。當然,產品代碼應該用成熟的 logging 庫,而不要用其它東西來湊合。
- in-memory 格式化方面,由于 ostringstream 會動態分配內存,它不適合性能要求較高的場合。
- 文件 IO 方面,如果用作文本文件的輸入或輸出,(i|o)fstream 有上述的缺點;如果用作二進制數據輸入輸出,那么自己簡單封裝一個 File class 似乎更好用,也不必為用不到的功能付出代價(后文還有具體例子)。ifstream 的一個用處是在程序啟動時讀入簡單的文本配置文件。如果配置文件是其他文本格式(XML 或 JSON),那么用相應的庫來讀,也用不到 ifstream。
- 性能方面,iostream 沒有兌現“高效性”諾言。iostream 在某些場合比 stdio 快,在某些場合比 stdio 慢,對于性能要求較高的場合,我們應該自己實現字符串轉換(見后文的代碼與測試)。iostream 性能方面的一個注腳:在線 ACM/ICPC 判題網站上,如果一個簡單的題目發生超時錯誤,那么把其中 iostream 的輸入輸出換成 stdio,有時就能過關。
既然有這么多局限,iostream 在實際項目中的應用就大為受限了,在這上面投入太多的精力實在不值得。說實話,我沒有見過哪個 C++ 產品代碼使用 iostream 來作為輸入輸出設施。 http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#Streams
iostream 在設計方面的缺點
iostream 的設計有相當多的 WTFs,stackoverflow 有人吐槽說“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 。
面向對象的設計
iostream 是個面向對象的 IO 類庫,本節簡單介紹它的繼承體系。
對 iostream 略有了解的人會知道它用了多重繼承和虛擬繼承,簡單地畫個類圖如下,是典型的菱形繼承:

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

這個繼承體系糅合了面向對象與泛型編程,但可惜它兩方面都不討好。
再進一步加深了解,發現還有一個平行的 streambuf 繼承體系,fstream 和 stringstream 的不同之處主要就在于它們使用了不同的 streambuf 具體類型。

再把這兩個繼承體系畫到一幅圖里:

注意到 basic_ios 持有了 streambuf 的指針;而 fstreams 和 stringstreams 則分別包含 filebuf 和 stringbuf 的對象??瓷先ビ悬c像 Bridge 模式。
看了這樣巴洛克的設計,有沒有人還打算在自己的項目中想通過繼承 iostream 來實現自己的 stream,以實現功能擴展么?
面向對象方面的設計缺陷
本節我們分析一下 iostream 的設計違反了哪些 OO 準則。
我們知道,面向對象中的 public 繼承需要滿足 Liskov 替換原則。(見《Effective C++ 第3版》條款32:確保你的 public 繼承模塑出 is-a 關系?!禖++ 編程規范》條款 37:public 繼承意味可替換性。繼承非為復用,乃為被復用。)
在程序里需要用到 ostream 的地方(例如 operator<< ),我傳入 ofstream 或 ostringstream 都應該能按預期工作,這就是 OO 繼承強調的“可替換性”,派生類的對象可以替換基類對象,從而被 operator<< 復用。
iostream 的繼承體系多次違反了 Liskov 原則,這些地方繼承的目的是為了復用基類的代碼,下圖中我把違規的繼承關系用紅線標出。

在現有的繼承體系中,合理的有:
- ifstream is-a istream
- istringstream is-a istream
- ofstream is-a ostream
- ostringstream is-a ostream
- fstream is-a iostream
- stringstream is-a iostream
我認為不怎么合理的有:
- ios 繼承 ios_base,有沒有哪種情況下程序代碼期待 ios_base 對象,但是客戶可以傳入一個 ios 對象替代之?如果沒有,這里用 public 繼承是不是違反 OO 原則?
- istream 繼承 ios,有沒有哪種情況下程序代碼期待 ios 對象,但是客戶可以傳入一個 istream 對象替代之?如果沒有,這里用 public 繼承是不是違反 OO 原則?
- ostream 繼承 ios,有沒有哪種情況下程序代碼期待 ios 對象,但是客戶可以傳入一個 ostream 對象替代之?如果沒有,這里用 public 繼承是不是違反 OO 原則?
- iostream 多重繼承 istream 和 ostream。為什么 iostream 要同時繼承兩個 non-interface class?這是接口繼承還是實現繼承?是不是可以用組合(composition)來替代?(見《Effective C++ 第3版》條款38:通過組合模塑出 has-a 或“以某物實現”?!禖++ 編程規范》條款 34:盡可能以組合代替繼承。)
用組合替換繼承之后的體系:

注意到在新的設計中,只有真正的 is-a 關系采用了 public 繼承,其他均以組合來代替,組合關系以紅線表示。新的設計沒有用的虛擬繼承或多重繼承。
其中 iostream 的新實現值得一提,代碼結構如下:
class istream;
class ostream;
class iostream
{
public:
istream& get_istream();
ostream& get_ostream();
virtual ~iostream();
};
這樣一來,在需要 iostream 對象表現得像 istream 的地方,調用 get_istream() 函數返回一個 istream 的引用;在需要 iostream 對象表現得像 ostream 的地方,調用 get_ostream() 函數返回一個 ostream 的引用。功能不受影響,而且代碼更清晰。(雖然我非常懷疑 iostream 的真正價值,一個東西既可讀又可寫,說明是個 sophisticated IO 對象,為什么還用這么厚的 OO 封裝?)
陽春的 locale
iostream 的故事還不止這些,它還包含一套陽春的 locale/facet 實現,這套實踐中沒人用的東西進一步增加了 iostream 的復雜度,而且不可避免地影響其性能。Nathan Myers 正是始作俑者 http://www.cantrip.org/locale.html 。
ostream 自身定義的針對整數和浮點數的 operator<< 成員函數的函數體是:
bool failed =
use_facet<num_put>(getloc()).put(
ostreambuf_iterator(*this), *this, fill(), val).failed();
它會轉而調用 num_put::put(),后者會調用 num_put::do_put(),而 do_put() 是個虛函數,沒辦法 inline。iostream 在性能方面的不足恐怕部分來自于此。這個虛函數白白浪費了把 template 的實現放到頭文件應得的好處,編譯和運行速度都快不起來。
我沒有深入挖掘其中的細節,感興趣的同學可以移步觀看 facet 的繼承體系:http://gcc.gnu.org/onlinedocs/libstdc++/libstdc++-html-USERS-4.4/a00431.html
據此分析,我不認為以 iostream 為基礎的上層程序庫(比方說那些克服 iostream 格式化方面的缺點的庫)有多大的實用價值。
臆造抽象
孟巖評價 “ iostream 最大的缺點是臆造抽象”,我非常贊同他老人家的觀點。
這個評價同樣適用于 Java 那一套疊床架屋的 InputStream/OutputStream/Reader/Writer 繼承體系,.NET 也搞了這么一套繁文縟節。
乍看之下,用 input stream 表示一個可以“讀”的數據流,用 output stream 表示一個可以“寫”的數據流,屏蔽底層細節,面向接口編程,“符合面向對象原則”,似乎是一件美妙的事情。但是,真實的世界要殘酷得多。
IO 是個極度復雜的東西,就拿最常見的 memory stream、file stream、socket stream 來說,它們之間的差異極大:
- 是單向 IO 還是雙向 IO。只讀或者只寫?還是既可讀又可寫?
- 順序訪問還是隨機訪問。可不可以 seek?可不可以退回 n 字節?
- 文本數據還是二進制數據。格式有誤怎么辦?如何編寫健壯的處理輸入的代碼?
- 有無緩沖。write 500 字節是否能保證完全寫入?有沒有可能只寫入了 300 字節?余下 200 字節怎么辦?
- 是否阻塞。會不會返回 EWOULDBLOCK 錯誤?
- 有哪些出錯的情況。這是最難的,memory stream 幾乎不可能出錯,file stream 和 socket stream 的出錯情況完全不同。socket stream 可能遇到對方斷開連接,file stream 可能遇到超出磁盤配額。
根據以上列舉的初步分析,我不認為有辦法設計一個公共的基類把各方面的情況都考慮周全。各種 IO 設施之間共性太小,差異太大,例外太多。如果硬要用面向對象來建模,基類要么太瘦(只放共性,這個基類包含的 interface functions 沒多大用),要么太肥(把各種 IO 設施的特性都包含進來,這個基類包含的 interface functions 很多,但是不是每一個都能調用)。
C 語言對此的解決辦法是用一個 int 表示 IO 對象(file 或 PIPE 或 socket),然后配以 read()/write()/lseek()/fcntl() 等一系列全局函數,程序員自己搭配組合。這個做法我認為比面向對象的方案要簡潔高效。
iostream 在性能方面沒有比 stdio 高多少,在健壯性方面多半不如 stdio,在靈活性方面受制于本身的復雜設計而難以讓使用者自行擴展。目前看起來只適合一些簡單的要求不高的應用,但是又不得不為它的復雜設計付出運行時代價,總之其定位有點不上不下。
在實際的項目中,我們可以提煉出一些簡單高效的 strip-down 版本,在獲得便利性的同時避免付出不必要的代價。
一個 300 行的 memory buffer output stream
我認為以 operator<< 來輸出數據非常適合 logging,因此寫了一個簡單的 LogStream。代碼不到 300行,完全獨立于 iostream。
這個 LogStream 做到了類型安全和類型可擴展。它不支持定制格式化、不支持 locale/facet、沒有繼承、buffer 也沒有繼承與虛函數、沒有動態分配內存、buffer 大小固定。簡單地說,適合 logging 以及簡單的字符串轉換。
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 本身不是線程安全的,它不適合做全局對象。正確的使用方式是每條 log 消息構造一個 LogStream,用完就扔。LogStream 的成本極低,這么做不會有什么性能損失。
目前這個 logging 庫還在開發之中,只完成了 LogStream 這一部分。將來可能改用動態分配的 buffer,這樣方便在線程之間傳遞數據。
整數到字符串的高效轉換
muduo::LogStream 的整數轉換是自己寫的,用的是 Matthew Wilson 的算法,見 http://blog.csdn.net/solstice/article/details/5139302 。這個算法比 stdio 和 iostream 都要快。
浮點數到字符串的高效轉換
目前 muduo::LogStream 的浮點數格式化采用的是 snprintf() 所以從性能上與 stdio 持平,比 ostream 快一些。
浮點數到字符串的轉換是個復雜的話題,這個領域 20 年以來沒有什么進展(目前的實現大都基于 David M. Gay 在 1990 年的工作《Correctly Rounded Binary-Decimal and Decimal-Binary Conversions》,代碼 http://netlib.org/fp/),直到 2010 年才有突破。
Florian Loitsch 發明了新的更快的算法 Grisu3,他的論文《Printing floating-point numbers quickly and accurately with integers》發表在 PLDI 2010,代碼見 Google V8 引擎,還有這里 http://code.google.com/p/double-conversion/ 。有興趣的同學可以閱讀這篇博客 http://www.serpentine.com/blog/2011/06/29/here-be-dragons-advances-in-problems-you-didnt-even-know-you-had/ 。
將來 muduo::LogStream 可能會改用 Grisu3 算法實現浮點數轉換。
性能對比
由于 muduo::LogStream 拋掉了很多負擔,可以預見它的性能好于 ostringstream 和 stdio。我做了一個簡單的性能測試,結果如下。

從上表看出,ostreamstream 有時候比 snprintf 快,有時候比它慢,muduo::LogStream 比它們兩個都快得多(double 類型除外)。
泛型編程
其他程序庫如何使用 LogStream 作為輸出呢?辦法很簡單,用模板。
前面我們定義了 Date class 針對 std::ostream 的 operator<<,只要稍作修改就能同時適用于 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;
}
現實的 C++ 程序如何做文件 IO
舉兩個例子, Kyoto Cabinet 和 Google leveldb。
Google leveldb
Google leveldb 是一個高效的持久化 key-value db。
它定義了三個精簡的 interface:
接口函數如下
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 明確區分 input 和 output,進一步它又把 input 分為 sequential 和 random access,然后提煉出了三個簡單的接口,每個接口只有屈指可數的幾個函數。這幾個接口在各個平臺下的實現也非常簡單明了(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),一看就懂。
注意這三個接口使用了虛函數,我認為這是正當的,因為一次 IO 往往伴隨著 context switch,虛函數的開銷比起 context switch 來可以忽略不計。相反,iostream 每次 operator<<() 就調用虛函數,我認為不太明智。
Kyoto Cabinet
Kyoto Cabinet 也是一個 key-value db,是前幾年流行的 Tokyo Cabinet 的升級版。它采用了與 leveldb 不同的文件抽象。
KC 定義了一個 File class,同時包含了讀寫操作,這是個 fat interface。http://fallabs.com/kyotocabinet/api/classkyotocabinet_1_1File.html
在具體實現方面,它沒有使用虛函數,而是采用 #ifdef 來區分不同的平臺(見 http://code.google.com/p/read-taobao-code/source/browse/trunk/tair/src/storage/kdb/kyotocabinet/kcfile.cc),等于把兩份獨立的代碼寫到了同一個文件里邊。
相比之下,Google leveldb 的做法更高明一些。
小結
在 C++ 項目里邊自己寫個 File class,把項目用到的文件 IO 功能簡單封裝一下(以 RAII 手法封裝 FILE* 或者 file descriptor 都可以,視情況而定),通常就能滿足需要。記得把拷貝構造和賦值操作符禁用,在析構函數里釋放資源,避免泄露內部的 handle,這樣就能自動避免很多 C 語言文件操作的常見錯誤。
如果要用 stream 方式做 logging,可以拋開繁重的 iostream 自己寫一個簡單的 LogStream,重載幾個 operator<<,用起來一樣方便;而且可以用 stack buffer,輕松做到線程安全。
陳碩 (giantchen AT gmail)
blog.csdn.net/Solstice
前幾天我在新浪微博上出了兩道有關 TCP 的思考題,引發了一場討論 http://weibo.com/1701018393/eCuxDrta0Nn 。
第一道初級題目是:
有一臺機器,它有一個 IP,上面運行了一個 TCP 服務程序,程序只偵聽一個端口,問:從理論上講(只考慮 TCP/IP 這一層面,不考慮IPv6)這個服務程序可以支持多少并發 TCP 連接?答 65536 上下的直接刷掉。
具體來說,這個問題等價于:有一個 TCP 服務程序的地址是 1.2.3.4:8765,問它從理論上能接受多少個并發連接?
第二道進階題目是:
一臺被測機器 A,功能同上,同一交換機上還接有一臺機器 B,如果允許 B 的程序直接收發以太網 frame,問:讓 A 承擔 10 萬個并發 TCP 連接需要用多少 B 的資源?100萬個呢?
從討論的結果看,很多人做出了第一道題,而第二道題幾乎無人問津。
這里先不公布答案(第一題答案見文末),讓我們繼續思考一個本質的問題:一個 TCP 連接要占用多少系統資源。
在現在的 Linux 操作系統上,如果用 socket()/connect() 或 accept() 來創建 TCP 連接,那么每個連接至少要占用一個文件描述符(file descriptor)。為什么說“至少”?因為文件描述符可以復制,比如 dup();也可以被繼承,比如 fork();這樣可能出現系統里邊同一個 TCP 連接有多個文件描述符與之對應。據此,很多人給出的第一題答案是:并發連接數受限于系統能同時打開的文件數目的最大值。這個答案在實踐中是正確的,卻不符合原題意。
如果拋開操作系統層面,只考慮 TCP/IP 層面,建立一個 TCP 連接有哪些開銷?理論上最小的開銷是多少?考慮兩個場景:
1. 假設有一個 TCP 服務程序,向這個程序成功發起連接需要做哪些事情?換句話說,如何才能讓這個 TCP 服務程序認為有客戶連接到了它(讓它的 accept() 調用正常返回)?
2. 假設有一個 TCP 客戶端程序,讓這個程序成功建立到服務器的連接需要做哪些事情?換句話說,如何才能讓這個 TCP 客戶端程序認為它自己已經連接到服務器了(讓它的 connect() 調用正常返回)?
以上這兩個問題問的不是如何編程,如何調用 Sockets API,而是問如何讓操作系統的 TCP/IP 協議棧認為任務已經成功完成,連接已經成功建立。
學過 TCP/IP 協議,理解三路握手的同學明白,TCP 連接是虛擬的連接,不是電路連接,維持 TCP 連接理論上不占用網絡資源(會占用兩頭程序的系統資源)。只要連接的雙方認為 TCP 連接存在,并且可以互相發送 IP packet,那么 TCP 連接就一直存在。
對于問題 1,向一個 TCP 服務程序發起一個連接,客戶端(為明白起見,以下稱為 faketcp 客戶端)只需要做三件事情(三路握手):
1a. 向 TCP 服務程序發一個 IP packet,包含 SYN 的 TCP segment
1b. 等待對方返回一個包含 SYN 和 ACK 的 TCP segment
1c. 向對方發送一個包含 ACK 的 segment
在做完這三件事情之后,TCP 服務器程序會認為連接已建立。而做這三件事情并不占用客戶端的資源(?),如果faketcp 客戶端程序可以繞開操作系統的 TCP/IP 協議棧,自己直接發送并接收 IP packet 或 Ethernet frame 的話。換句話說,faketcp 客戶端可以一直重復做這三件事件,每次用一個不同的 IP:PORT,在服務端創建不計其數的 TCP 連接,而 faketcp 客戶端自己毫發無損。很快我們將看到如何用程序來實現這一點。
對于問題 2,為了讓一個 TCP 客戶端程序認為連接已建立,faketcp 服務端只需要做兩件事情:
2a. 等待客戶端發來的 SYN TCP segment
2b. 發送一個包含 SYN 和 ACK 的 TCP segment
2c. 忽視對方發來的包含 ACK 的 segment
在做完這兩件事情(收一個 SYN、發一個 SYN+ACK)之后,TCP 客戶端程序會認為連接已建立。而做這三件事情并不占用 faketcp 服務端的資源(?)換句話說,faketcp 服務端可以一直重復做這兩件事件,接受不計其數的 TCP 連接,而 faketcp 服務端自己毫發無損。很快我們將看到如何用程序來實現這一點。
基于對以上兩個問題的分析,說明單獨談論“TCP 并發連接數”是沒有意義的,因為連接數基本上是要多少有多少。更有意義的性能指標或許是:“每秒鐘收發多少條消息”、“每秒鐘收發多少字節的數據”、“支持多少個活動的并發客戶”等等。
faketcp 的程序實現
代碼見: https://github.com/chenshuo/recipes/tree/master/faketcp 可以直接用 make 編譯
為了驗證我上面的說法,我寫了幾個小程序來實現 faketcp,這幾個程序可以發起或接受不計其數的 TCP 并發連接,并且不消耗操作系統資源,連動態內存分配都不會用到。
我家里有一臺運行 Ubuntu Linux 10.04 的 PC 機,hostname 是 atom,所有的試驗都在這上面進行。
家里試驗環境的網絡配置是:

陳碩在《談一談網絡編程學習經驗》中曾提到“可以用 TUN/TAP 設備在用戶態實現一個能與本機點對點通信的 TCP/IP 協議?!?,這次的試驗正好可以用上這個辦法。
試驗的網絡配置是:

具體做法是:在 atom 上通過打開 /dev/net/tun 設備來創建一個 tun0 虛擬網卡,然后把這個網卡的地址設為 192.168.0.1/24,這樣 faketcp 程序就扮演了 192.168.0.0/24 這個網段上的所有機器。atom 發給 192.168.0.2~192.168.0.254 的 IP packet 都會發給 faketcp 程序,faketcp 程序可以模擬其中任何一個 IP 給 atom 發 IP packet。
程序分成幾步來實現。
第一步:實現 icmp echo 協議,這樣就能 ping 通 faketcp 了。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/icmpecho.cc
其中響應 icmp echo request 的函數在 https://github.com/chenshuo/recipes/blob/master/faketcp/faketcp.cc#L57 這個函數在后面的程序中也會用到。
運行方法,打開 3 個命令行窗口:
1. 在第 1 個窗口運行 sudo ./icmpecho ,程序顯示
allocted tunnel interface tun0
2. 在第 2 個窗口運行
$ sudo ifconfig tun0 192.168.0.1/24
$ sudo tcpdump -i tun0
3. 在第 3 個窗口運行
$ ping 192.168.0.2
$ ping 192.168.0.3
$ ping 192.168.0.234
發現每個 192.168.0.X 的 IP 都能 ping 通。
第二步:實現拒絕 TCP 連接的功能,即在收到 SYN TCP segment 的時候發送 RST segment。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/rejectall.cc
運行方法,打開 3 個命令行窗口,頭兩個窗口的操作與前面相同,運行的 faketcp 程序是 ./rejectall
3. 在第 3 個窗口運行
$ nc 192.168.0.2 2000
$ nc 192.168.0.2 3333
$ nc 192.168.0.7 5555
發現向其中任意一個 IP 發起的 TCP 連接都被拒接了。
第三步:實現接受 TCP 連接的功能,即在收到SYN TCP segment 的時候發回 SYN+ACK。這個程序同時處理了連接斷開的情況,即在收到 FIN segment 的時候發回 FIN+ACK。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/acceptall.cc
運行方法,打開 3 個命令行窗口,步驟與前面相同,運行的 faketcp 程序是 ./acceptall。這次會發現 nc 能和 192.168.0.X 中的每一個 IP 每一個 PORT 都能連通。還可以在第 4 個窗口中運行 netstat –tpn ,以確認連接確實建立起來了。如果在 nc 中輸入數據,數據會堆積在操作系統中,表現為 netstat 顯示的發送隊列(Send-Q)的長度增加。
第四步:在第三步接受 TCP 連接的基礎上,實現接收數據,即在收到包含 payload 數據 的 TCP segment 時發回 ACK。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/discardall.cc
運行方法,打開 3 個命令行窗口,步驟與前面相同,運行的 faketcp 程序是 ./acceptall。這次會發現 nc 能和 192.168.0.X 中的每一個 IP 每一個 PORT 都能連通,數據也能發出去。還可以在第 4 個窗口中運行 netstat –tpn ,以確認連接確實建立起來了,并且發送隊列的長度為 0。
這一步已經解決了前面的問題 2,扮演任意 TCP 服務端。
第五步:解決前面的問題 1,扮演客戶端向 atom 發起任意多的連接。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/connectmany.cc
這一步的運行方法與前面不同,打開 4 個命令行窗口。
1. 在第 1 個窗口運行 sudo ./connectmany 192.168.0.1 2007 1000 ,表示將向 192.168.0.1:2007 發起 1000 個并發連接。
程序顯示
allocted tunnel interface tun0
press enter key to start connecting 192.168.0.1:2007
2. 在第 2 個窗口運行
$ sudo ifconfig tun0 192.168.0.1/24
$ sudo tcpdump -i tun0
3. 在第 3 個窗口運行一個能接收并發 TCP 連接的服務程序,可以是 httpd,也可以是 muduo 的 echo 或 discard 示例,程序應 listen 2007 端口。
4. 回到第 1 個窗口中敲回車,然后在第 4 個窗口中用 netstat -tpn 來觀察并發連接。
有興趣的話,還可以繼續擴展,做更多的有關 TCP 的試驗,以進一步加深理解,驗證操作系統 TCP/IP 協議棧面對不同輸入的行為。甚至可以按我在《談一談網絡編程學習經驗》中提議的那樣,實現完整的 TCP 狀態機,做出一個簡單的 mini tcp stack。
第一道題的答案:
在只考慮 IPv4 的情況下,并發數的理論上限是 2**48??紤]某些 IP 段被保留了,這個上界可適當縮小,但數量級不變。實際的限制是操作系統全局文件描述符的數量,以及內存大小。
一個 TCP 連接有兩個 end points,每個 end point 是 {ip, port},題目說其中一個 end point 已經固定,那么留下一個 end point 的自由度,即 2 ** 48。客戶端 IP 的上限是 2**32 個,每個客戶端IP發起連接的上限是 2**16,乘到一起得理論上限。
即便客戶端使用 NAT,也不影響這個理論上限。(為什么?)
在真實的 Linux 系統中,可以通過調整內核參數來支持上百萬并發連接,具體做法見:
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
(.完.)
陳碩 (giantchen AT gmail)
blog.csdn.net/Solstice
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文以一個 Sudoku Solver 為例,回顧了并發網絡服務程序的多種設計方案,并介紹了使用 muduo 網絡庫編寫多線程服務器的兩種最常用手法。以往的例子展現了 Muduo 在編寫單線程并發網絡服務程序方面的能力與便捷性,今天我們看一看它在多線程方面的表現。
本文代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/
Sudoku Solver
假設有這么一個網絡編程任務:寫一個求解數獨的程序 (Sudoku Solver),并把它做成一個網絡服務。
Sudoku Solver 是我喜愛的網絡編程例子,它曾經出現在《分布式系統部署、監控與進程管理的幾重境界》、《Muduo 設計與實現之一:Buffer 類的設計》、《〈多線程服務器的適用場合〉例釋與答疑》等文中,它也可以看成是 echo 服務的一個變種(《談一談網絡編程學習經驗》把 echo 列為三大 TCP 網絡編程案例之一)。
寫這么一個程序在網絡編程方面的難度不高,跟寫 echo 服務差不多(從網絡連接讀入一個 Sudoku 題目,算出答案,再發回給客戶),挑戰在于怎樣做才能發揮現在多核硬件的能力?在談這個問題之前,讓我們先寫一個基本的單線程版。
協議
一個簡單的以 \r\n 分隔的文本行協議,使用 TCP 長連接,客戶端在不需要服務時主動斷開連接。
請求:[id:]〈81digits〉\r\n
響應:[id:]〈81digits〉\r\n 或者 [id:]NoSolution\r\n
其中[id:]表示可選的 id,用于區分先后的請求,以支持 Parallel Pipelining,響應中會回顯請求中的 id。Parallel Pipelining 的意義見賴勇浩的《以小見大——那些基于 protobuf 的五花八門的 RPC(2) 》,或者見我寫的《分布式系統的工程化開發方法》第 54 頁關于 out-of-order RPC 的介紹。
〈81digits〉是 Sudoku 的棋盤,9x9 個數字,未知數字以 0 表示。
如果 Sudoku 有解,那么響應是填滿數字的棋盤;如果無解,則返回 NoSolution。
例子1:
請求:000000010400000000020000000000050407008000300001090000300400200050100000000806000\r\n
響應:693784512487512936125963874932651487568247391741398625319475268856129743274836159\r\n
例子2:
請求:a:000000010400000000020000000000050407008000300001090000300400200050100000000806000\r\n
響應:a:693784512487512936125963874932651487568247391741398625319475268856129743274836159\r\n
例子3:
請求:b:000000010400000000020000000000050407008000300001090000300400200050100000000806005\r\n
響應:b:NoSolution\r\n
基于這個文本協議,我們可以用 telnet 模擬客戶端來測試 sudoku solver,不需要單獨編寫 sudoku client。SudokuSolver 的默認端口號是 9981,因為它有 9x9=81 個格子。
基本實現
Sudoku 的求解算法見《談談數獨(Sudoku)》一文,這不是本文的重點。假設我們已經有一個函數能求解 Sudoku,它的原型如下
string solveSudoku(const string& puzzle);
函數的輸入是上文的"〈81digits〉",輸出是"〈81digits〉"或"NoSolution"。這個函數是個 pure function,同時也是線程安全的。
有了這個函數,我們以《Muduo 網絡編程示例之零:前言》中的 EchoServer 為藍本,稍作修改就能得到 SudokuServer。這里只列出最關鍵的 onMessage() 函數,完整的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_basic.cc 。onMessage() 的主要功能是處理協議格式,并調用 solveSudoku() 求解問題。
// 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 是一個并發服務器,可以同時服務多個客戶連接。但是它是單線程的,無法發揮多核硬件的能力。
Sudoku 是一個計算密集型的任務(見《Muduo 設計與實現之一:Buffer 類的設計》中關于其性能的分析),其瓶頸在 CPU。為了讓這個單線程 server_basic 程序充分利用 CPU 資源,一個簡單的辦法是在同一臺機器上部署多個 server_basic 進程,讓每個進程占用不同的端口,比如在一臺 8 核機器上部署 8 個 server_basic 進程,分別占用 9981、9982、……、9988 端口。這樣做其實是把難題推給了客戶端,因為客戶端(s)要自己做負載均衡。再想得遠一點,在 8 個 server_basic 前面部署一個 load balancer?似乎小題大做了。
能不能在一個端口上提供服務,并且又能發揮多核處理器的計算能力呢?當然可以,辦法不止一種。
常見的并發網絡服務程序設計方案
W. Richard Stevens 的 UNP2e 第 27 章 Client-Server Design Alternatives 介紹了十來種當時(90 年代末)流行的編寫并發網絡程序的方案。UNP3e 第 30 章,內容未變,還是這幾種。以下簡稱 UNP CSDA 方案。UNP 這本書主要講解阻塞式網絡編程,在非阻塞方面著墨不多,僅有一章。正確使用 non-blocking IO 需要考慮的問題很多,不適宜直接調用 Sockets API,而需要一個功能完善的網絡庫支撐。
隨著 2000 年前后第一次互聯網浪潮的興起,業界對高并發 http 服務器的強烈需求大大推動了這一領域的研究,目前高性能 httpd 普遍采用的是單線程 reactor 方式。另外一個說法是 IBM Lotus 使用 TCP 長連接協議,而把 Lotus 服務端移植到 Linux 的過程中 IBM 的工程師們大大提高了 Linux 內核在處理并發連接方面的可伸縮性,因為一個公司可能有上萬人同時上線,連接到同一臺跑著 Lotus server 的 Linux 服務器。
可伸縮網絡編程這個領域其實近十年來沒什么新東西,POSA2 已經作了相當全面的總結,另外以下幾篇文章也值得參考。
http://bulk.fefe.de/scalable-networking.pdf
http://www.kegel.com/c10k.html
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
下表是陳碩總結的 10 種常見方案。其中“多連接互通”指的是如果開發 chat 服務,多個客戶連接之間是否能方便地交換數據(chat 也是《談一談網絡編程學習經驗》中舉的三大 TCP 網絡編程案例之一)。對于 echo/http/sudoku 這類“連接相互獨立”的服務程序,這個功能無足輕重,但是對于 chat 類服務至關重要。“順序性”指的是在 http/sudoku 這類請求-響應服務中,如果客戶連接順序發送多個請求,那么計算得到的多個響應是否按相同的順序發還給客戶(這里指的是在自然條件下,不含刻意同步)。
UNP CSDA 方案歸入 0~5。5 也是目前用得很多的單線程 reactor 方案,muduo 對此提供了很好的支持。6 和 7 其實不是實用的方案,只是作為過渡品。8 和 9 是本文重點介紹的方案,其實這兩個方案已經在《多線程服務器的常用編程模型》一文中提到過,只不過當時我還沒有寫 muduo,無法用具體的代碼示例來說明。
在對比各方案之前,我們先看看基本的 micro benchmark 數據(前三項由 lmbench 測得):
- fork()+exit(): 160us
- pthread_create()+pthread_join(): 12us
- context switch : 1.5us
- sudoku resolve: 100us (根據題目難度不同,浮動范圍 20~200us)
方案 0:這其實不是并發服務器,而是 iterative 服務器,因為它一次只能服務一個客戶。代碼見 UNP figure 1.9,UNP 以此為對比其他方案的基準點。這個方案不適合長連接,到是很適合 daytime 這種 write-only 服務。
方案 1:這是傳統的 Unix 并發網絡編程方案,UNP 稱之為 child-per-client 或 fork()-per-client,另外也俗稱 process-per-connection。這種方案適合并發連接數不大的情況。至今仍有一些網絡服務程序用這種方式實現,比如 PostgreSQL 和 Perforce 的服務端。這種方案適合“計算響應的工作量遠大于 fork() 的開銷”這種情況,比如數據庫服務器。這種方案適合長連接,但不太適合短連接,因為 fork() 開銷大于求解 sudoku 的用時。
方案 2:這是傳統的 Java 網絡編程方案 thread-per-connection,在 Java 1.4 引入 NIO 之前,Java 網絡服務程序多采用這種方案。它的初始化開銷比方案 1 要小很多。這種方案的伸縮性受到線程數的限制,一兩百個還行,幾千個的話對操作系統的 scheduler 恐怕是個不小的負擔。
方案 3:這是針對方案 1 的優化,UNP 詳細分析了幾種變化,包括對 accept 驚群問題的考慮。
方案 4:這是對方案 2 的優化,UNP 詳細分析了它的幾種變化。
以上幾種方案都是阻塞式網絡編程,程序(thread-of-control)通常阻塞在 read() 上,等待數據到達。但是 TCP 是個全雙工協議,同時支持 read() 和 write() 操作,當一個線程/進程阻塞在 read() 上,但程序又想給這個 TCP 連接發數據,那該怎么辦?比如說 echo client,既要從 stdin 讀,又要從網絡讀,當程序正在阻塞地讀網絡的時候,如何處理鍵盤輸入?又比如 proxy,既要把連接 a 收到的數據發給連接 b,又要把從連接 b 收到的數據發給連接 a,那么到底讀哪個?(proxy 是《談一談網絡編程學習經驗》中舉的三大 TCP 網絡編程案例之一。)
一種方法是用兩個線程/進程,一個負責讀,一個負責寫。UNP 也在實現 echo client 時介紹了這種方案。另外見 Python Pinhole 的代碼:http://code.activestate.com/recipes/114642/
另一種方法是使用 IO multiplexing,也就是 select/poll/epoll/kqueue 這一系列的“多路選擇器”,讓一個 thread-of-control 能處理多個連接。“IO 復用”其實復用的不是 IO 連接,而是復用線程。使用 select/poll 幾乎肯定要配合 non-blocking IO,而使用 non-blocking IO 肯定要使用應用層 buffer,原因見《Muduo 設計與實現之一:Buffer 類的設計》。這就不是一件輕松的事兒了,如果每個程序都去搞一套自己的 IO multiplexing 機制(本質是 event-driven 事件驅動),這是一種很大的浪費。感謝 Doug Schmidt 為我們總結出了 Reactor 模式,讓 event-driven 網絡編程有章可循。繼而出現了一些通用的 reactor 框架/庫,比如 libevent、muduo、Netty、twisted、POE 等等,有了這些庫,我想基本不用去編寫阻塞式的網絡程序了(特殊情況除外,比如 proxy 流量限制)。
單線程 reactor 的程序結構是(圖片取自 Doug Lea 的演講):

方案 5:基本的單線程 reactor 方案,即前面的 server_basic.cc 程序。本文以它作為對比其他方案的基準點。這種方案的優點是由網絡庫搞定數據收發,程序只關心業務邏輯;缺點在前面已經談了:適合 IO 密集的應用,不太適合 CPU 密集的應用,因為較難發揮多核的威力。
方案 6:這是一個過渡方案,收到 Sudoku 請求之后,不在 reactor 線程計算,而是創建一個新線程去計算,以充分利用多核 CPU。這是非常初級的多線程應用,因為它為每個請求(而不是每個連接)創建了一個新線程。這個開銷可以用線程池來避免,即方案 8。這個方案還有一個特點是 out-of-order,即同時創建多個線程去計算同一個連接上收到的多個請求,那么算出結果的次序是不確定的,可能第 2 個 Sudoku 比較簡單,比第 1 個先算出結果。這也是為什么我們在一開始設計協議的時候使用了 id,以便客戶端區分 response 對應的是哪個 request。
方案 7:為了讓返回結果的順序確定,我們可以為每個連接創建一個計算線程,每個連接上的請求固定發給同一個線程去算,先到先得。這也是一個過渡方案,因為并發連接數受限于線程數目,這個方案或許還不如直接使用阻塞 IO 的 thread-per-connection 方案2。方案 7 與方案 6 的另外一個區別是一個 client 的最大 CPU 占用率,在方案 6 中,一個 connection 上發來的一長串突發請求(burst requests) 可以占滿全部 8 個 core;而在方案 7 中,由于每個連接上的請求固定由同一個線程處理,那么它最多占用 12.5% 的 CPU 資源。這兩種方案各有優劣,取決于應用場景的需要,到底是公平性重要還是突發性能重要。這個區別在方案 8 和方案 9 中同樣存在,需要根據應用來取舍。
方案 8:為了彌補方案 6 中為每個請求創建線程的缺陷,我們使用固定大小線程池,程序結構如下圖。全部的 IO 工作都在一個 reactor 線程完成,而計算任務交給 thread pool。如果計算任務彼此獨立,而且 IO 的壓力不大,那么這種方案是非常適用的。Sudoku Solver 正好符合。代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc 后文給出了它與方案 9 的區別。

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

代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_multiloop.cc
server_multiloop.cc 與 server_basic.cc 的區別很小,關鍵只有一行代碼: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 相比變化不大,只是把原來 onMessage() 中涉及計算和發回響應的部分抽出來做成一個函數,然后交給 ThreadPool 去計算。記住方案 8 有 out-of-order 的可能,客戶端要根據 id 來匹配響應。
$ 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_;
};
完整代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc
方案 10:把方案 8 和方案 9 混合,既使用多個 reactors 來處理 IO,又使用線程池來處理計算。這種方案適合既有突發 IO (利用多線程處理多個連接上的 IO),又有突發計算的應用(利用線程池把一個連接上的計算任務分配給多個線程去做)。

這種其實方案看起來復雜,其實寫起來很簡單,只要把方案 8 的代碼加一行 server_.setThreadNum(numThreads); 就行,這里就不舉例了。
結語
我在《多線程服務器的常用編程模型》一文中說
總結起來,我推薦的多線程服務端編程模式為:event loop per thread + thread pool。
- event loop 用作 non-blocking IO 和定時器。
- thread pool 用來做計算,具體可以是任務隊列或消費者-生產者隊列。
當時(2010年2月)我還說“以這種方式寫服務器程序,需要一個優質的基于 Reactor 模式的網絡庫來支撐,我只用過in-house的產品,無從比較并推薦市面上常見的 C++ 網絡庫,抱歉。”
現在有了 muduo 網絡庫,我終于能夠用具體的代碼示例把思想完整地表達出來。
談一談網絡編程學習經驗
陳碩
giantchen@gmail.com
blog.csdn.net/Solstice
2011-06-08
PDF 版下載:https://github.com/downloads/chenshuo/documents/LearningNetworkProgramming.pdf
本文談一談我在學習網絡編程方面的一些個人經驗。“網絡編程”這個術語的范圍很廣,本文指用Sockets API開發基于TCP/IP的網絡應用程序,具體定義見“網絡編程的各種任務角色”一節。
受限于本人的經歷和經驗,這篇文章的適應范圍是:
· x86-64 Linux服務端網絡編程,直接或間接使用 Sockets API
· 公司內網。不一定是局域網,但總體位于公司防火墻之內,環境可控
本文可能不適合:
· PC客戶端網絡編程,程序運行在客戶的PC上,環境多變且不可控
· Windows網絡編程
· 面向公網的服務程序
· 高性能網絡服務器
本文分兩個部分:
1. 網絡編程的一些胡思亂想,談談我對這一領域的認識
2. 幾本必看的書,基本上還是W. Richard Stevents那幾本
另外,本文沒有特別說明時均暗指TCP協議,“連接”是“TCP連接”,“服務端”是“TCP服務端”。
網絡編程的一些胡思亂想
以下胡亂列出我對網絡編程的一些想法,前后無關聯。
網絡編程是什么?
網絡編程是什么?是熟練使用Sockets API嗎?說實話,在實際項目里我只用過兩次Sockets API,其他時候都是使用封裝好的網絡庫。
第一次是2005年在學校做一個羽毛球賽場計分系統:我用C# 編寫運行在PC機上的軟件,負責比分的顯示;再用C# 寫了運行在PDA上的計分界面,記分員拿著PDA記錄比分;這兩部分程序通過 TCP協議相互通信。這其實是個簡單的分布式系統,體育館有不止一片場地,每個場地都有一名拿PDA的記分員,每個場地都有兩臺顯示比分的PC機(顯示器是42吋平板電視,放在場地的對角,這樣兩邊看臺的觀眾都能看到比分)。這兩臺PC機功能不完全一樣,一臺只負責顯示當前比分,另一臺還要負責與PDA通信,并更新數據庫里的比分信息。此外,還有一臺PC機負責周期性地從數據庫讀出全部7片場地的比分,顯示在體育館墻上的大屏幕上。這臺PC上還運行著一個程序,負責生成比分數據的靜態頁面,通過FTP上傳發布到某門戶網站的體育頻道。系統中還有一個錄入賽程(參賽隊,運動員,出場順序等)數據庫的程序,運行在數據庫服務器上。算下來整個系統有十來個程序,運行在二十多臺設備(PC和PDA)上,還要考慮可靠性。將來有機會把這個小系統仔細講一講,挺有意思的。
這是我第一次寫實際項目中的網絡程序,當時寫下來的感覺是像寫命令行與用戶交互的程序:程序在命令行輸出一句提示語,等待客戶輸入一句話,然后處理客戶輸入,再輸出下一句提示語,如此循環。只不過這里的“客戶”不是人,而是另一個程序。在建立好TCP連接之后,雙方的程序都是read/write循環(為求簡單,我用的是blocking讀寫),直到有一方斷開連接。
第二次是2010年編寫muduo網絡庫,我再次拿起了Sockets API,寫了一個基于Reactor模式的C++ 網絡庫。寫這個庫的目的之一就是想讓日常的網絡編程從Sockets API的瑣碎細節中解脫出來,讓程序員專注于業務邏輯,把時間用在刀刃上。Muduo 網絡庫的示例代碼包含了幾十個網絡程序,這些示例程序都沒有直接使用Sockets API。
在此之外,無論是實習還是工作,雖然我寫的程序都會通過TCP協議與其他程序打交道,但我沒有直接使用過Sockets API。對于TCP網絡編程,我認為核心是處理“三個半事件”,見《Muduo 網絡編程示例之零:前言》中的“TCP 網絡編程本質論”。程序員的主要工作是在事件處理函數中實現業務邏輯,而不是和Sockets API較勁。
這里還是沒有說清楚“網絡編程”是什么,請繼續閱讀后文“網絡編程的各種任務角色”。
學習網絡編程有用嗎?
以上說的是比較底層的網絡編程,程序代碼直接面對從TCP或UDP收到的數據以及構造數據包發出去。在實際工作中,另一種常見 的情況是通過各種 client library 來與服務端打交道,或者在現成的框架中填空來實現server,或者采用更上層的通信方式。比如用libmemcached與memcached打交道,使用libpq來與PostgreSQL 打交道,編寫Servlet來響應http請求,使用某種RPC與其他進程通信,等等。這些情況都會發生網絡通信,但不一定算作“網絡編程”。如果你的工作是前面列舉的這些,學習TCP/IP網絡編程還有用嗎?
我認為還是有必要學一學,至少在troubleshooting 的時候有用。無論如何,這些library或framework都會調用底層的Sockets API來實現網絡功能。當你的程序遇到一個線上問題,如果你熟悉Sockets API,那么從strace不難發現程序卡在哪里,盡管可能你沒有直接調用這些Sockets API。另外,熟悉TCP/IP協議、會用tcpdump也大大有助于分析解決線上網絡服務問題。
在什么平臺上學習網絡編程?
對于服務端網絡編程,我建議在Linux上學習。
如果在10年前,這個問題的答案或許是FreeBSD,因為FreeBSD根正苗紅,在2000年那一次互聯網浪潮中扮演了重要角色,是很多公司首選的免費服務器操作系統。2000年那會兒Linux還遠未成熟,連epoll都還沒有實現。(FreeBSD在2001年發布4.1版,加入了kqueue,從此C10k不是問題。)
10年后的今天,事情起了變化,Linux成為了市場份額最大的服務器操作系統(http://en.wikipedia.org/wiki/Usage_share_of_operating_systems)。在Linux這種大眾系統上學網絡編程,遇到什么問題會比較容易解決。因為用的人多,你遇到的問題別人多半也遇到過;同樣因為用的人多,如果真的有什么內核bug,很快就會得到修復,至少有work around的辦法。如果用別的系統,可能一個問題發到論壇上半個月都不會有人理。從內核源碼的風格看,FreeBSD更干凈整潔,注釋到位,但是無奈它的市場份額遠不如Linux,學習Linux是更好的技術投資。
可移植性重要嗎?
寫網絡程序要不要考慮移植性?這取決于項目需要,如果貴公司做的程序要賣給其他公司,而對方可能使用Windows、Linux、FreeBSD、Solaris、AIX、HP-UX等等操作系統,這時候考慮移植性。如果編寫公司內部的服務器上用的網絡程序,那么大可只關注一個平臺,比如Linux。因為編寫和維護可移植的網絡程序的代價相當高,平臺間的差異可能遠比想象中大,即便是POSIX系統之間也有不小的差異(比如Linux沒有SO_NOSIGPIPE選項),錯誤的返回碼也大不一樣。
我就不打算把muduo往Windows或其他操作系統移植。如果需要編寫可移植的網絡程序,我寧愿用libevent或者Java Netty這樣現成的庫,把臟活累活留給別人。
網絡編程的各種任務角色
計算機網絡是個 big topic,涉及很多人物和角色,既有開發人員,也有運維人員。比方說:公司內部兩臺機器之間 ping 不通,通常由網絡運維人員解決,看看是布線有問題還是路由器設置不對;兩臺機器能ping通,但是程序連不上,經檢查是本機防火墻設置有問題,通常由系統管理員解決;兩臺機器能連上,但是丟包很嚴重,發現是網卡或者交換機的網口故障,由硬件維修人員解決;兩臺機器的程序能連上,但是偶爾發過去的請求得不到響應,通常是程序bug,應該由開發人員解決。
本文主要關心開發人員這一角色。下面簡單列出一些我能想到的跟網絡打交道的編程任務,其中前三項是面向網絡本身,后面幾項是在計算機網絡之上構建信息系統。
1. 開發網絡設備,編寫防火墻、交換機、路由器的固件 firmware
2. 開發或移植網卡的驅動
3. 移植或維護TCP/IP協議棧(特別是在嵌入式系統上)
4. 開發或維護標準的網絡協議程序,HTTP、FTP、DNS、SMTP、POP3、NFS
5. 開發標準網絡協議的“附加品”,比如HAProxy、squid、varnish等web load balancer
6. 開發標準或非標準網絡服務的客戶端庫,比如ZooKeeper客戶端庫,memcached客戶端庫
7. 開發與公司業務直接相關的網絡服務程序,比如即時聊天軟件的后臺服務器,網游服務器,金融交易系統,互聯網企業用的分布式海量存儲,微博發帖的內部廣播通知,等等
8. 客戶端程序中涉及網絡的部分,比如郵件客戶端中與 POP3、SMTP通信的部分,以及網游的客戶端程序中與服務器通信的部分
本文所指的“網絡編程”專指第7項,即在TCP/IP協議之上開發業務軟件。
面向業務的網絡編程的特點
跟開發通用的網絡程序不同,開發面向公司業務的專用網絡程序有其特點:
· 業務邏輯比較復雜,而且時常變化
如果寫一個HTTP服務器,在大致實現HTTP /1.1標準之后,程序的主體功能一般不會有太大的變化,程序員會把時間放在性能調優和bug修復上。而開發針對公司業務的專用程序時,功能說明書(spec)很可能不如HTTP/1.1標準那么細致明確。更重要的是,程序是快速演化的。以即時聊天工具的后臺服務器為例,可能第一版只支持在線聊天;幾個月之后發布第二版,支持離線消息;又過了幾個月,第三版支持隱身聊天;隨后,第四版支持上傳頭像;如此等等。這要求程序員能快速響應新的業務需求,公司才能保持競爭力。
· 不一定需要遵循公認的通信協議標準
比方說網游服務器就沒什么協議標準,反正客戶端和服務端都是本公司開發,如果發現目前的協議設計有問題,兩邊一起改了就是了。
· 程序結構沒有定論
對于高并發大吞吐的標準網絡服務,一般采用單線程事件驅動的方式開發,比如HAProxy、lighttpd等都是這個模式。但是對于專用的業務系統,其業務邏輯比較復雜,占用較多的CPU資源,這種單線程事件驅動方式不見得能發揮現在多核處理器的優勢。這留給程序員比較大的自由發揮空間,做好了橫掃千軍,做爛了一敗涂地。
· 性能評判的標準不同
如果開發httpd這樣的通用服務,必然會和開源的Nginx、lighttpd等高性能服務器比較,程序員要投入相當的精力去優化程序,才能在市場上占有一席之地。而面向業務的專用網絡程序不一定有開源的實現以供對比性能,程序員通常更加注重功能的穩定性與開發的便捷性。性能只要一代比一代強即可。
· 網絡編程起到支撐作用,但不處于主導地位
程序員的主要工作是實現業務邏輯,而不只是實現網絡通信協議。這要求程序員深入理解業務。程序的性能瓶頸不一定在網絡上,瓶頸有可能是CPU、Disk IO、數據庫等等,這時優化網絡方面的代碼并不能提高整體性能。只有對所在的領域有深入的了解,明白各種因素的權衡(trade-off),才能做出一些有針對性的優化。
幾個術語
互聯網上的很多口水戰是由對同一術語的不同理解引起的,比我寫的《多線程服務器的適用場合》就曾經人被說是“掛羊頭賣狗肉”,因為這篇文章中舉的 master例子“根本就算不上是個網絡服務器。因為它的瓶頸根本就跟網絡無關。”
· 網絡服務器
“網絡服務器”這個術語確實含義模糊,到底指硬件還是軟件?到底是服務于網絡本身的機器(交換機、路由器、防火墻、NAT),還是利用網絡為其他人或程序提供服務的機器(打印服務器、文件服務器、郵件服務器)。每個人根據自己熟悉的領域,可能會有不同的解讀。比方說或許有人認為只有支持高并發高吞吐的才算是網絡服務器。
為了避免無謂的爭執,我只用“網絡服務程序”或者“網絡應用程序”這種含義明確的術語。“開發網絡服務程序”通常不會造成誤解。
· 客戶端?服務端?
在TCP網絡編程里邊,客戶端和服務端很容易區分,主動發起連接的是客戶端,被動接受連接的是服務端。當然,這個“客戶端”本身也可能是個后臺服務程序,HTTP Proxy對HTTP Server來說就是個客戶端。
· 客戶端編程?服務端編程?
但是“服務端編程”和“客戶端編程”就不那么好區分。比如 Web crawler,它會主動發起大量連接,扮演的是HTTP客戶端的角色,但似乎應該歸入“服務端編程”。又比如寫一個 HTTP proxy,它既會扮演服務端——被動接受 web browser 發起的連接,也會扮演客戶端——主動向 HTTP server 發起連接,它究竟算服務端還是客戶端?我猜大多數人會把它歸入服務端編程。
那么究竟如何定義“服務端編程”?
服務端編程需要處理大量并發連接?也許是,也許不是。比如云風在一篇介紹網游服務器的博客http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html中就談到,網游中用到的“連接服務器”需要處理大量連接,而“邏輯服務器”只有一個外部連接。那么開發這種網游“邏輯服務器”算服務端編程還是客戶端編程呢?
我認為,“服務端網絡編程”指的是編寫沒有用戶界面的長期運行的網絡程序,程序默默地運行在一臺服務器上,通過網絡與其他程序打交道,而不必和人打交道。與之對應的是客戶端網絡程序,要么是短時間運行,比如wget;要么是有用戶界面(無論是字符界面還是圖形界面)。本文主要談服務端網絡編程。
7x24重要嗎?內存碎片可怕嗎?
一談到服務端網絡編程,有人立刻會提出7x24運行的要求。對于某些網絡設備而言,這是合理的需求,比如交換機、路由器。對于開發商業系統,我認為要求程序7x24運行通常是系統設計上考慮不周。具體見《分布式系統的工程化開發方法》第20頁起。重要的不是7x24,而是在程序不必做到7x24的情況下也能達到足夠高的可用性。一個考慮周到的系統應該允許每個進程都能隨時重啟,這樣才能在廉價的服務器硬件上做到高可用性。
既然不要求7x24,那么也不必害怕內存碎片,理由如下:
· 64-bit系統的地址空間足夠大,不會出現沒有足夠的連續空間這種情況。
· 現在的內存分配器(malloc及其第三方實現)今非昔比,除了memcached這種純以內存為賣點的程序需要自己設計分配器之外,其他網絡程序大可使用系統自帶的malloc或者某個第三方實現。
· Linux Kernel也大量用到了動態內存分配。既然操作系統內核都不怕動態分配內存造成碎片,應用程序為什么要害怕?
· 內存碎片如何度量?有沒有什么工具能為當前進程的內存碎片狀況評個分?如果不能比較兩種方案的內存碎片程度,談何優化?
有人為了避免內存碎片,不使用STL容器,也不敢new/delete,這算是premature optimization還是因噎廢食呢?
協議設計是網絡編程的核心
對于專用的業務系統,協議設計是核心任務,決定了系統的開發難度與可靠性,但是這個領域還沒有形成大家公認的設計流程。
系統中哪個程序發起連接,哪個程序接受連接?如果寫標準的網絡服務,那么這不是問題,按RFC來就行了。自己設計業務系統,有沒有章法可循?以網游為例,到底是連接服務器主動連接邏輯服務器,還是邏輯服務器主動連接“連接服務器”?似乎沒有定論,兩種做法都行。一般可以按照“依賴->被依賴”的關系來設計發起連接的方向。
比新建連接難的是關閉連接。在傳統的網絡服務中(特別是短連接服務),不少是服務端主動關閉連接,比如daytime、HTTP/1.0。也有少部分是客戶端主動關閉連接,通常是些長連接服務,比如 echo、chargen等。我們自己的業務系統該如何設計連接關閉協議呢?
服務端主動關閉連接的缺點之一是會多占用服務器資源。服務端主動關閉連接之后會進入TIME_WAIT狀態,在一段時間之內hold住一些內核資源。如果并發訪問量很高,這會影響服務端的處理能力。這似乎暗示我們應該把協議設計為客戶端主動關閉,讓TIME_WAIT狀態分散到多臺客戶機器上,化整為零。
這又有另外的問題:客戶端賴著不走怎么辦?會不會造成拒絕服務攻擊?或許有一個二者結合的方案:客戶端在收到響應之后就應該主動關閉,這樣把 TIME_WAIT 留在客戶端。服務端有一個定時器,如果客戶端若干秒鐘之內沒有主動斷開,就踢掉它。這樣善意的客戶端會把TIME_WAIT留給自己,buggy的客戶端會把 TIME_WAIT留給服務端?;蛘吒纱嗍褂瞄L連接協議,這樣避免頻繁創建銷毀連接。
比連接的建立與斷開更重要的是設計消息協議。消息格式很好辦,XML、JSON、Protobuf都是很好的選擇;難的是消息內容。一個消息應該包含哪些內容?多個程序相互通信如何避免race condition(見《分布式系統的工程化開發方法》p.16的例子)?系統的全局狀態該如何躍遷?可惜這方面可供參考的例子不多,也沒有太多通用的指導原則,我知道的只有30年前提出的end-to-end principle和happens-before relationship。只能從實踐中慢慢積累了。
網絡編程的三個層次
侯捷先生在《漫談程序員與編程》中講到 STL 運用的三個檔次:“會用STL,是一種檔次。對STL原理有所了解,又是一個檔次。追蹤過STL源碼,又是一個檔次。第三種檔次的人用起 STL 來,虎虎生風之勢絕非第一檔次的人能夠望其項背。”
我認為網絡編程也可以分為三個層次:
1. 讀過教程和文檔
2. 熟悉本系統TCP/IP協議棧的脾氣
3. 自己寫過一個簡單的TCP/IP stack
第一個層次是基本要求,讀過《Unix網絡編程》這樣的編程教材,讀過《TCP/IP詳解》基本理解TCP/IP協議,讀過本系統的manpage。這個層次可以編寫一些基本的網絡程序,完成常見的任務。但網絡編程不是照貓畫虎這么簡單,若是按照manpage的功能描述就能編寫產品級的網絡程序,那人生就太幸福了。
第二個層次,熟悉本系統的TCP/IP協議棧參數設置與優化是開發高性能網絡程序的必備條件。摸透協議棧的脾氣還能解決工作中遇到的比較復雜的網絡問題。拿Linux的TCP/IP協議棧來說:
· 有可能出現自連接(見《學之者生,用之者死——ACE歷史與簡評》舉的三個硬傷),程序應該有所準備。
· Linux的內核會有bug,比如某種TCP擁塞控制算法曾經出現TCP window clamping(窗口箝位)bug,導致吞吐量暴跌,可以選用其他擁塞控制算法來繞開(work around)這個問題。
這些陰暗角落在manpage里沒有描述,要通過其他渠道了解。
編寫可靠的網絡程序的關鍵是熟悉各種場景下的error code(文件描述符用完了如何?本地ephemeral port暫時用完,不能發起新連接怎么辦?服務端新建并發連接太快,backlog用完了,客戶端connect會返回什么錯誤?),有的在manpage里有描述,有的要通過實踐或閱讀源碼獲得。
第三個層次,通過自己寫一個簡單的TCP/IP協議棧,能大大加深對TCP/IP的理解,更能明白TCP為什么要這么設計,有哪些因素制約,每一步操作的代價是什么,寫起網絡程序來更是成竹在胸。
其實實現TCP/IP只需要操作系統提供三個接口函數:一個函數,兩個回調函數。分別是:send_packet()、on_receive_packet()、on_timer()。多年前有一篇文章《使用libnet與libpcap構造TCP/IP協議軟件》介紹了在用戶態實現TCP/IP的方法。lwIP也是很好的借鑒對象。
如果有時間,我打算自己寫一個Mini/Tiny/Toy/Trivial/Yet-Another TCP/IP。我準備換一個思路,用TUN/TAP設備在用戶態實現一個能與本機點對點通信的TCP/IP協議棧,這樣那三個接口函數就表現為我最熟悉的文件讀寫。在用戶態實現的好處是便于調試,協議棧做成靜態庫,與應用程序鏈接到一起(庫的接口不必是標準的Sockets API)。做完這一版,還可以繼續發揮,用FTDI的USB-SPI接口芯片連接ENC28J60適配器,做一個真正獨立于操作系統的TCP/IP stack。如果只實現最基本的IP、ICMP Echo、TCP的話,代碼應能控制在3000行以內;也可以實現UDP,如果應用程序需要用到DNS的話。
最主要的三個例子
我認為TCP網絡編程有三個例子最值得學習研究,分別是echo、chat、proxy,都是長連接協議。
Echo的作用:熟悉服務端被動接受新連接、收發數據、被動處理連接斷開。每個連接是獨立服務的,連接之間沒有關聯。在消息內容方面Echo有一些變種:比如做成一問一答的方式,收到的請求和發送響應的內容不一樣,這時候要考慮打包與拆包格式的設計,進一步還可以寫簡單的HTTP服務。
Chat的作用:連接之間的數據有交流,從a收到的數據要發給b。這樣對連接管理提出的更高的要求:如何用一個程序同時處理多個連接?fork() per connection似乎是不行的。如何防止串話?b有可能隨時斷開連接,而新建立的連接c可能恰好復用了b的文件描述符,那么a會不會錯誤地把消息發給c?
Proxy的作用:連接的管理更加復雜:既要被動接受連接,也要主動發起連接,既要主動關閉連接,也要被動關閉連接。還要考慮兩邊速度不匹配,見《Muduo 網絡編程示例之十:socks4a 代理服務器》。
這三個例子功能簡單,突出了TCP網絡編程中的重點問題,挨著做一遍基本就能達到層次一的要求。
TCP的可靠性有多高?
TCP是“面向連接的、可靠的、字節流傳輸協議”,這里的“可靠”究竟是什么意思?《Effective TCP/IP Programming》第9條說:Realize That TCP Is a Reliable Protocol, Not an Infallible Protocol,那么TCP在哪種情況下會出錯?這里說的“出錯”指的是收到的數據與發送的數據不一致,而不是數據不可達。
我在《一種自動反射消息類型的 Google Protobuf 網絡傳輸方案》中設計了帶check sum的消息格式,很多人表示不理解,認為是多余的。IP header里邊有check sum,TCP header也有check sum,鏈路層以太網還有CRC32校驗,那么為什么還需要在應用層做校驗?什么情況下TCP傳送的數據會出錯?
IP header和TCP header的check sum是一種非常弱的16-bit check sum算法,把數據當成反碼表示的16-bit integers,再加到一起。這種checksum算法能檢出一些簡單的錯誤,而對某些錯誤無能為力,由于是簡單的加法,遇到“和”不變的情況就無法檢查出錯誤(比如交換兩個16-bit整數,加法滿足交換律,結果不變)。以太網的CRC32只能保證同一個網段上的通信不會出錯(兩臺機器的網線插到同一個交換機上,這時候以太網的CRC是有用的)。但是,如果兩臺機器之間經過了多級路由器呢?

上圖中Client向Server發了一個TCP segment,這個segment先被封裝成一個IP packet,再被封裝成ethernet frame,發送到路由器(圖中消息a)。Router收到ethernet frame (b),轉發到另一個網段(c),最后Server收到d,通知應用程序。Ethernet CRC能保證a和b相同,c和d相同;TCP header check sum的強度不足以保證收發payload的內容一樣。另外,如果把Router換成NAT,那么NAT自己會構造c(替換掉源地址),這時候a和d的payload不能用tcp header checksum校驗。
路由器可能出現硬件故障,比方說它的內存故障(或偶然錯誤)導致收發IP報文出現多bit的反轉或雙字節交換,這個反轉如果發生在payload區,那么無法用鏈路層、網絡層、傳輸層的check sum查出來,只能通過應用層的check sum來檢測。這個現象在開發的時候不會遇到,因為開發用的幾臺機器很可能都連到同一個交換機,ethernet CRC能防止錯誤。開發和測試的時候數據量不大,錯誤很難發生。之后大規模部署到生產環境,網絡環境復雜,這時候出個錯就讓人措手不及。有一篇論文《When the CRC and TCP checksum disagree》分析了這個問題。另外《The Limitations of the Ethernet CRC and TCP/IP checksums for error detection》(http://noahdavids.org/self_published/CRC_and_checksum.html)也值得一讀。
這個情況真的會發生嗎?會的,Amazon S3 在2008年7月就遇到過,單bit反轉導致了一次嚴重線上事故,所以他們吸取教訓加了 check sum。見http://status.aws.amazon.com/s3-20080720.html
另外一個例證:下載大文件的時候一般都會附上MD5,這除了有安全方面的考慮(防止篡改),也說明應用層應該自己設法校驗數據的正確性。這是end-to-end principle的一個例證。
三本必看的書
談到Unix編程和網絡編程,W. Richard Stevens 是個繞不開的人物,他生前寫了6本書,APUE、兩卷UNP、三卷TCP/IP。有四本與網絡編程直接相關。UNP第二卷其實跟網絡編程關系不大,是APUE在多線程和進程間通信(IPC)方面的補充。很多人把TCP/IP一二三卷作為整體推薦,其實這三本書用處不同,應該區別對待。
這里談到的幾本書都沒有超出孟巖在《TCP/IP 網絡編程之四書五經》中的推薦,說明網絡編程這一領域已經相對成熟穩定。
· 《TCP/IP Illustrated, Vol. 1: The Protocols》中文名《TCP/IP 詳解》,以下簡稱 TCPv1。
TCPv1 是一本奇書。
這本書迄今至少被三百多篇學術論文引用過http://portal.acm.org/citation.cfm?id=161724。一本學術專著被論文引用算不上出奇,難得的是一本寫給程序員看的技術書能被學術論文引用幾百次,我不知道還有哪本技術書能做到這一點。
TCPv1 堪稱 TCP/IP領域的圣經。作者 W. Richard Stevens 不是 TCP/IP 協議的發明人,他從使用者(程序員)的角度,以 tcpdump 為工具,對 TCP 協議抽絲剝繭娓娓道來(第17~24章),讓人嘆服。恐怕 TCP 協議的設計者也難以講解得如此出色,至少不會像他這么耐心細致地畫幾百幅收發 package 的時序圖。
TCP作為一個可靠的傳輸層協議,其核心有三點:
1. Positive acknowledgement with retransmission
2. Flow control using sliding window(包括Nagle 算法等)
3. Congestion control(包括slow start、congestion avoidance、fast retransmit等)
第一點已經足以滿足“可靠性”要求(為什么?);第二點是為了提高吞吐量,充分利用鏈路層帶寬;第三點是防止過載造成丟包。換言之,第二點是避免發得太慢,第三點是避免發得太快,二者相互制約。從反饋控制的角度看,TCP像是一個自適應的節流閥,根據管道的擁堵情況自動調整閥門的流量。
TCP的 flow control 有一個問題,每個TCP connection是彼此獨立的,保存有自己的狀態變量;一個程序如果同時開啟多個連接,或者操作系統中運行多個網絡程序,這些連接似乎不知道他人的存在,缺少對網卡帶寬的統籌安排。(或許現代的操作系統已經解決了這個問題?)
TCPv1 唯一的不足是它出版太早了,1993 年至今網絡技術發展了幾代。鏈路層方面,當年主流的 10Mbit 網卡和集線器早已經被淘汰;100Mbit 以太網也沒什么企業在用了,交換機(switch)也已經全面取代了集線器(hub);服務器機房以 1Gbit 網絡為主,有些場合甚至用上了 10Gbit 以太網。另外,無線網的普及也讓TCP flow control面臨新挑戰;原來設計TCP的時候,人們認為丟包通常是擁塞造成的,這時應該放慢發送速度,減輕擁塞;而在無線網中,丟包可能是信號太弱造成的,這時反而應該快速重試,以保證性能。網絡層方面變化不大,IPv6 雷聲大雨點小。傳輸層方面,由于鏈路層帶寬大增,TCP window scale option 被普遍使用,另外 TCP timestamps option 和 TCP selective ack option 也很常用。由于這些因素,在現在的 Linux 機器上運行 tcpdump 觀察 TCP 協議,程序輸出會與原書有些不同。
一個好消息:TCPv1將于今年10月(2011年)推出第二版,Amazon 的預定頁面是:http://www.amazon.com/gp/product/0321336313,讓我們拭目以待。
· 《Unix Network Programming, Vol. 1: Networking API》第二版或第三版(這兩版的副標題稍有不同,第三版去掉了 XTI),以下統稱 UNP,如果需要會以 UNP2e、UNP3e 細分。
UNP是Sockets API的權威指南,但是網絡編程遠不是使用那十幾個Sockets API那么簡單,作者 W. Richard Stevens深刻地認識到這一點,他在UNP2e的前言中寫到: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.
搞網絡編程,一定要熟悉TCP/IP協議及其外在表現(比如打開和關閉Nagle算法對收發包的影響),不然出點意料之外的情況就摸不著頭腦了。我不知道為什么UNP3e在前言中去掉了這段至關重要的話。
另外值得一提的是,UNP中文版翻譯得相當好,譯者楊繼張先生是真懂網絡編程的。
UNP很詳細,面面俱到,UDP、TCP、IPv4、IPv6都講到了。要說有什么缺點的話,就是太詳細了,重點不夠突出。我十分贊同孟巖說的
“(孟巖)我主張,在具備基礎之后,學習任何新東西,都要抓住主線,突出重點。對于關鍵理論的學習,要集中精力,速戰速決。而旁枝末節和非本質性的知識內容,完全可以留給實踐去零敲碎打。
“原因是這樣的,任何一個高級的知識內容,其中都只有一小部分是有思想創新、有重大影響的,而其它很多東西都是瑣碎的、非本質的。因此,集中學習時必須把握住真正重要那部分,把其它東西留給實踐。對于重點知識,只有集中學習其理論,才能確保體系性、連貫性、正確性,而對于那些旁枝末節,只有邊干邊學能夠讓你了解它們的真實價值是大是小,才能讓你留下更生動的印象。如果你把精力用錯了地方,比如用集中大塊的時間來學習那些本來只需要查查手冊就可以明白的小技巧,而對于真正重要的、思想性東西放在平時零敲碎打,那么肯定是事倍功半,甚至適得其反。
“因此我對于市面上絕大部分開發類圖書都不滿——它們基本上都是面向知識體系本身的,而不是面向讀者的??偸前严嚓P的所有知識細節都放在一堆,然后一堆一堆攢起來變成一本書。反映在內容上,就是毫無重點地平鋪直敘,不分輕重地陳述細節,往往在第三章以前就用無聊的細節謀殺了讀者的熱情。為什么當年侯捷先生的《深入淺出MFC》和 Scott Meyers 的 Effective C++ 能夠成為經典?就在于這兩本書抓住了各自領域中的主干,提綱挈領,綱舉目張,一下子打通讀者的任督二脈。可惜這樣的書太少,就算是已故 Richard Stevens 和當今 Jeffrey Richter 的書,也只是在體系性和深入性上高人一頭,并不是面向讀者的書。”
什么是旁枝末節呢?拿以太網來說,CRC32如何計算就是“旁枝末節”。網絡程序員要明白check sum的作用,知道為什么需要check sum,至于具體怎么算CRC就不需要程序員操心。這部分通常是由網卡硬件完成的,在發包的時候由硬件填充CRC,在收包的時候網卡自動丟棄CRC不合格的包。如果代碼里邊確實要用到CRC計算,調用通用的zlib就行,也不用自己實現。
UNP就像給了你一堆做菜的原料(各種Sockets 函數的用法),常用和不常用的都給了(Out-of-Band Data、Signal-Driven IO 等等),要靠讀者自己設法取舍組合,做出一盤大菜來。在第一遍讀的時候,我建議只讀那些基本且重要的章節;另外那些次要的內容可略作了解,即便跳過不讀也無妨。UNP是一本操作性很強的書,讀這本這本書一定要上機練習。
另外,UNP舉的兩個例子(菜譜)太簡單,daytime和echo一個是短連接協議,一個是長連接無格式協議,不足以覆蓋基本的網絡開發場景(比如 TCP封包與拆包、多連接之間交換數據)。我估計 W. Richard Stevens 原打算在 UNP第三卷中講解一些實際的例子,只可惜他英年早逝,我等無福閱讀。
UNP是一本偏重Unix傳統的書,這本書寫作的時候服務端還不需要處理成千上萬的連接,也沒有現在那么多網絡攻擊。書中重點介紹的以accept()+fork()來處理并發連接的方式在現在看來已經有點吃力,這本書的代碼也沒有特別防范惡意攻擊。如果工作涉及這些方面,需要再進一步學習專門的知識(C10k問題,安全編程)。
TCPv1和UNP應該先看哪本?我不知道。我自己是先看的TCPv1,花了大約半學期時間,然后再讀UNP2e和APUE。
· 《Effective TCP/IP Programming》
第三本書我猶豫了很久,不知道該推薦哪本,還有哪本書能與 W. Richard Stevens 的這兩本比肩嗎?W. Richard Stevens 為技術書籍的寫作樹立了難以逾越的標桿,他是一位偉大的技術作家。沒能看到他寫完 UNP 第三卷實在是人生的遺憾。
《Effective TCP/IP Programming》這本書屬于專家經驗總結類,初看時覺得收獲很大,工作一段時間再看也能有新的發現。比如第6 條“TCP是一個字節流協議”,看過這一條就不會去研究所謂的“TCP粘包問題”。我手頭這本電力社2001年的中文版翻譯尚可,但是很狗血的是把參考文獻去掉了,正文中引用的文章資料根本查不到名字。人郵2011年重新翻譯出版的版本有參考文獻。
其他值得一看的書
以下兩本都不易讀,需要相當的基礎。
· 《TCP/IP Illustrated, Vol. 2: The Implementation》以下簡稱 TCPv2
1200頁的大部頭,詳細講解了4.4BSD的完整TCP/IP協議棧,注釋了15,000行C源碼。這本書啃下來不容易,如果時間不充裕,我認為沒必要啃完,應用層的網絡程序員選其中與工作相關的部分來閱讀即可。
這本書第一作者是Gary Wright,從敘述風格和內容組織上是典型的“面向知識體系本身”,先講mbuf,再從鏈路層一路往上、以太網、IP網絡層、ICMP、IP多播、IGMP、IP路由、多播路由、Sockets系統調用、ARP等等。到了正文內容3/4的地方才開始講TCP。面面俱到、主次不明。
對于主要使用TCP的程序員,我認為TCPv2一大半內容可以跳過不看,比如路由表、IGMP等等(開發網絡設備的人可能更關心這些內容)。在工作中大可以把IP視為host-to-host的協議,把“IP packet如何送達對方機器”的細節視為黑盒子,這不會影響對TCP的理解和運用,因為網絡協議是分層的。這樣精簡下來,需要看的只有三四百頁,四五千行代碼,大大減輕了負擔。
這本書直接呈現高質量的工業級操作系統源碼,讀起來有難度,讀懂它甚至要有“不求甚解的能力”。其一,代碼只能看,不能上機運行,也不能改動試驗。其二,與操作系統其他部分緊密關聯。比如TCP/IP stack下接網卡驅動、軟中斷;上承inode轉發來的系統調用操作;中間還要與平級的進程文件描述符管理子系統打交道;如果要把每一部分都弄清楚,把持不住就迷失主題了。其三,一些歷史包袱讓代碼變復雜晦澀。比如BSD在80年代初需要在只有4M內存的VAX上實現TCP/IP,內存方面捉襟見肘,這才發明了mbuf結構,代碼也增加了不少偶發復雜度(buffer不連續的處理)。
讀這套TCP/IP書切忌膠柱鼓瑟,這套書以4.4BSD為底,其描述的行為(特別是與timer相關的行為)與現在的Linux TCP/IP有不小的出入,用書本上的知識直接套用到生產環境的Linux系統可能會造成不小的誤解和困擾。(TCPv3不重要,可以成套買來收藏,不讀亦可。)
· 《Pattern-Oriented Software Architecture Volume 2: Patterns for Concurrent and Networked Objects》以下簡稱POSA2
這本書總結了開發并發網絡服務程序的模式,是對UNP很好的補充。UNP中的代碼往往把業務邏輯和Sockets API調用混在一起,代碼固然短小精悍,但是這種編碼風格恐怕不適合開發大型的網絡程序。POSA2強調模塊化,網絡通信交給library/framework去做,程序員寫代碼只關注業務邏輯,這是非常重要的思想。閱讀這本書對于深入理解常用的event-driven網絡庫(libevent、Java Netty、Java Mina、Perl POE、Python Twisted等等)也很有幫助,因為這些庫都是依照這本書的思想編寫的。
POSA2的代碼是示意性的,思想很好,細節不佳。其C++ 代碼沒有充分考慮資源的自動化管理(RAII),如果直接按照書中介紹的方式去實現網絡庫,那么會給使用者造成不小的負擔與陷阱。換言之,照他說的做,而不是照他做的學。
不值一看的書
Douglas Comer 教授名氣很大,著作等身,但是他寫的網絡方面的書不值一讀,味同嚼蠟。網絡編程與 TCP/IP 方面,有W. Richard Stevens 的書扛鼎;計算機網絡原理方面,有Kurose的“自頂向下”和Peterson的“系統”打旗,沒其他人什么事兒。順便一提,Tanenbaum的操作系統教材是最好的之一(嗯,之二,因為他寫了兩本:“現代”和“設計與實現”),不過他的計算機網絡和體系結構教材的地位比不上他的操作系統書的地位。體系結構方面,Patterson 和 Hennessy二人合作的兩本書是最好的,近年來嶄露頭角的《深入理解計算機系統》也非常好;當然,側重點不同。
(完)
Muduo 網絡編程示例之十:socks4a 代理服務器
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
這是《Muduo 網絡編程示例》系列的第十篇文章,本系列暫告一段落。
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文介紹用 muduo 實現一個簡單的 socks4a 代理服務器,代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/socks4a/ 。
TCP 中繼器
在實現 socks4a proxy 之前,我們先寫一個功能更簡單的網絡程序—— TCP 中繼器 (TCP relay),或者叫做窮人的 tcpdump (poor man's tcpdump)。
一般情況下,客戶端程序直接連接服務端,如下圖。有時候,我們想在 client 和 server 之間放一個中繼器 (relay),把 client 與 server 之間的通信內容記錄下來。這時用 tcpdump 是最方便省事的,但是 tcpdump 需要 root 權限,萬一沒有 root 密碼呢?窮人有窮人的辦法,自己寫一個 relay,讓 client 連接 relay,再讓 relay 連接 server,如下圖中的 T 型結構,relay 扮演了類似 proxy 的角色。

TcpRelay 是我們自己寫的,可以動動手腳。除了記錄通信內容,還可以制造延時,或者故意翻轉 1 bit 數據以模擬 router 硬件故障。
TcpRelay 的功能(業務邏輯)看上去很簡單,無非是把連接 C 上收到的數據發給連接 S,同時把連接 S 上收到的數據發給連接 C。但仔細考慮起來,細節其實不那么簡單:
- 建立連接。為了真實模擬 client,TcpRelay 在 accept 連接 C 之后才向 server 發起連接 S,那么在 S 建立起來之前,從 C 收到數據怎么辦?要不要暫存起來?
- 并發連接的管理。上圖中只畫出了一個 client,實際上 TcpRelay 可以服務多個 clients,左右兩邊這些并發連接如何管理,如何防止串話(cross talk)?
- 連接斷開。Client 和 Server 都可能主動斷開連接。當 Client 主動斷開連接 C 時,TcpRelay 應該立刻斷開 S。當 Server 主動斷開連接 S 時,TcpRelay 應立刻斷開 C。這樣才能比較精確地模擬 Client 和 Server 的行為。在關閉連接的剎那,又有新的 client 連接進來,復用了剛剛 close 的 fd 號碼,會不會造成串話? 萬一 Client 和 Server 幾乎同時主動斷開連接,TcpRelay 如何應對?
- 速度不匹配。如果連接 C 的帶寬是 100KB/s,而連接 S 的帶寬是 10MB/s,不巧 Server 是個 chargen 服務,會全速發送數據,那么會不會撐爆 TcpRelay 的 buffer?如何限速?特別是在使用 non-blocking IO 和 level-trigger polling 的時候如何限制讀取數據的速度?
在看 muduo 的實現之前,請讀者思考:如果用 Sockets API 來實現 TcpRelay,如何解決以上這些問題。
TcpRelay 的實現很簡單,只有幾十行代碼 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 。這個實現解決了前三個問題,第四個留給將來吧。
Socks4a 代理服務器
Socks4a 的功能與 TcpRelay 非常相似,也是把連接 C 上收到的數據發給連接 S,同時把連接 S 上收到的數據發給連接 C。它與 TcpRelay 的區別在于,TcpRelay 固定連到某個 server 地址,而 socks4a 允許 client 指定要連哪個 server。在 accept 連接 C 之后,Socks4a server 會讀幾個字節,以了解 server 的地址,再發起連接 S。
Socks4a 的協議非常簡單,請參考維基百科 http://en.wikipedia.org/wiki/SOCKS#SOCKS_4a 。
muduo 的 socks4a 代理服務器的實現在 http://code.google.com/p/muduo/source/browse/trunk/examples/socks4a/socks4a.cc,它也使用了 Tunnel class。與 TcpRelay 相比,只多了解析 server 地址這一步驟。
muduo 這個 socks4a 是個標準的網絡服務,可以供 Web 瀏覽器使用(我正是這么測試它的)。
n:1 與 1:n 連接轉發
云風在《寫了一個 proxy 用途你懂的》中寫了一個 TCP 隧道 tunnel,程序由三部分組成:n:1 連接轉發服務,1:n 連接轉發服務,socks 代理服務。
我仿照他的思路,用 muduo 實現了這三個程序。不同的是,我沒有做數據混淆,所以不能用來翻傳說中的墻。
有興趣的讀者可以把這三個程序級聯起來試一試。
Muduo 編程示例系列告一段落
《Muduo 網絡編程示例》從今年2月初開始寫,到今天正好是四個月,我寫了十一篇博客,基本按計劃完成了任務。這個系列暫告一段落。
這個系列基本涵蓋了 muduo 為編寫單線程服務端和客戶端 TCP 網絡程序提供的功能,muduo 的能力不止于此:
- 多線程,muduo::net::TcpServer 內置了一個簡單但適應性很強的線程模型。目前博客上的例子涉及的業務邏輯很簡單,沒有復雜的運算,瓶頸通常在 IO 上,多線程的優勢發揮不出來。
- 高級應用。比方說用 muduo::net::Channel 配合 signalfd 來處理信號;其他非阻塞網絡客戶端庫(例如 ZooKeeper 的 C 客戶端,PostgreSQL 的客戶端 libpq)與 muduo EventLoop 的集成。
以上兩點在以后的文章里會提及,不會明珠暗藏。
Muduo 在 2010 年 8 月底發布 0.1.0 版,隨著這個編程示例系列文章的發表,迄今已發布了 14 次小升級,下載地址: http://code.google.com/p/muduo/downloads/list
接下來的計劃
接下來,我還會寫一系列博客,目前想到的有:
- 談一談我的網絡編程學習經驗。文章已經完成大半,端午節之后可以發布。
- muduo 設計與實現系列,介紹如何一步步實現一個非阻塞網絡庫。代碼已經準備得差不多了,在 https://github.com/chenshuo/recipes/tree/master/reactor
- 用 muduo 實現一些稍微復雜一些的網絡程序,比如小規模的分布式系統。計劃有:利用 Paxos 算法實現一個高可用的 in-memory key value 存儲,在此基礎上實現 naming service,然后實現我以前多次提到的簡單機群管理系統等等。目前 muduo 的示例程序都是簡單獨立的網絡程序,下半年我想多寫一寫由多個程序組成的系統,具體談一談分布式系統細節設計。
另外,我會逐步把已有的博客文章整理成 PDF 合集,方便下載保存,地址是: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
Muduo 網絡編程示例之九:簡單的消息廣播服務
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
這是《Muduo 網絡編程示例》系列的第九篇文章,講用 muduo 實現一個簡單的 pub/sub 服務。
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文介紹用 muduo 實現一個簡單的 topic-based 消息廣播服務,這其實是“聊天室”的一個簡單擴展,不過聊天的不是人,而是分布式系統中的程序。
本文的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/hub
在分布式系統中,除了常用的 end-to-end 通信,還有一對多的廣播通信。一提到“廣播”,或許會讓人聯想到 IP 多播或 IP 組播,這不是本文的主題。本文將要談的是基于 TCP 協議的應用層廣播。示意圖如下:

上圖中圓角矩形代表程序,"Hub"是一個服務程序,不是網絡集線器,它起到類似集線器的作用,故而得名。Publisher 和 Subscriper 通過 TCP 協議與 Hub 程序通信。Publisher 把消息發到某個 topic 上,Subscribers 訂閱該 topic,然后就能收到消息。即 publisher 借助 hub 把消息廣播給了多個 subscribers。這種 pub/sub 結構的好處在于可以增加多個 Subscriber 而不用修改 Publisher,一定程度上實現了“解耦”(也可以看成分布式的 observer pattern)。 由于走的是 TCP 協議,廣播是基本可靠的,這里的“可靠”指的是“比 UDP 可靠”,不是“完全可靠”。(思考:如何避免 Hub 成為 single point of failure?)
為了避免串擾(cross-talk),每個 topic 在同一時間只應該有一個 publisher,hub 不提供 compare-and-swap 操作。
(“可靠廣播、原子廣播”在分布式系統中有重大意義,是以 replicated state machine 方式實現可靠的分布式服務的基礎,“可靠廣播”涉及 consensus 算法,超出了本文的范圍。)
應用層廣播在分布式系統中用處很大,這里略舉幾例:
1. 體育比分轉播。有 8 片比賽場地正在進行羽毛球比賽,每個場地的計分程序把當前比分發送到各自的 topic 上(第 1 號場地發送到 court1,第 2 號發送到 court2,以此類推)。需要用到比分的程序(賽場的大屏幕顯示,網上比分轉播等等)自己訂閱感興趣的 topic ,就能及時收到最新比分數據。由于本文實現的不是 100% 可靠廣播,那么消息應該是 snapshot,而不是 incremental。(換句話說,消息的內容是“現在是幾比幾”,而不是“剛才誰得分”。)
2. 負載監控。每臺機器上運行一個監控程序,周期性地把本機當前負載(CPU、網絡、磁盤、溫度)publish 到以 hostname 命名的 topic 上,這樣需要用到這些數據的程序只要在 hub 訂閱相應的 topic 就能獲得數據,無需與多臺機器直接打交道。(為了可靠起見,監控程序發送的消息里邊應該包含時間戳,這樣能防止 stale 數據,甚至一定程度上起到心跳的作用。)沿著這個思路,分布式系統中的服務程序也可以把自己的當前負載發布到 hub 上,供 load balancer 和 monitor 取用。
協議
為了簡單起見,muduo 的 hub 示例采用以 '\r\n' 分界的文本協議,這樣用 telnet 就能測試 hub。協議只有三個命令:
- sub <topic>\r\n
- 該命令表示訂閱 <topic>,以后該 topic 有任何跟新都會發給這個 tcp 連接。在 sub 的時候,hub 會把該 <topic> 上最近的消息發給此 subscriber。
- unsub <topic>\r\n
- pub <topic>\r\n<content>\r\n
- 往 <topic> 發送消息,內容為 <content>。所有訂閱了此 <topic> 的 subscribers 會收到同樣的消息“pub <topic>\r\n<content>\r\n”
代碼
muduo 示例中的 hub 分為幾個部分:
一個程序可以既是 publisher 又是 subscriber,而且 pubsub 庫只用一個 tcp 連接(這樣 failover 比較簡便)。
使用范例:
- 開啟 4 個命令行窗口
- 在第一個窗口運行 $ hub 9999
- 在第二個窗口運行 $ sub 127.0.0.1:9999 mytopic
- 在第三個窗口運行 $ sub 127.0.0.1:9999 mytopic court
- 在第四個窗口運行 $ pub 127.0.0.1:9999 mytopic "Hello world." ,這時第二三號窗口都會打印 “mytopic: Hello world.”,表明收到了 mytopic 這個主題上的消息。
- 在第四個窗口運行 $ pub 127.0.0.1:9999 court "13:11" ,這時第三號窗口會打印 “court: 13:11”,表明收到了 court 這個主題上的消息。第二號窗口沒有訂閱此消息,故無輸出。
借助這個簡單的 pub/sub 機制,還可以做很多有意思的事情。比如把分布式系統中的程序的一部分 end-to-end 通信改為通過 pub/sub 來做(例如,原來是 A 向 B 發一個 SOAP request,B 通過同一個 tcp 連接發回 response (分析二者的通信只能通過查看 log 或用 tcpdump 截獲);現在是 A 往 topic_a_to_b 上發布 request,B 在 topic_b_to_a 上發 response),這樣多掛一個 monitoring subscriber 就能輕易地查看通信雙方的溝通情況,很容易做狀態監控與 trouble shooting。
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
陳碩關于 C++ 工程實踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx
陳碩博客文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
本作品采用“Creative Commons 署名-非商業性使用-禁止演繹 3.0 Unported 許可協議(cc by-nc-nd)”進行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/
摘要:本文討論了在編寫單元測試時 mock 系統調用(以及其他第三方庫)的幾種做法。
本文只考慮 Linux x86/amd64 平臺。
陳碩在《分布式程序的自動化回歸測試》 http://blog.csdn.net/Solstice/archive/2011/04/25/6359748.aspx 一文中曾經談到單元測試在分布式程序開發中的優缺點(好吧,主要是缺點)。但是,在某些情況下,單元測試是很有必要的,在測試 failure 場景的時候尤顯重要,比如:
- 在開發存儲系統時,模擬 read(2)/write(2) 返回 EIO 錯誤(有可能是磁盤寫滿了,有可能是磁盤出壞道讀不出數據)。
- 在開發網絡庫的時候,模擬 write(2) 返回 EPIPE 錯誤(對方意外斷開連接)。
- 在開發網絡庫的時候,模擬自連接 (self-connection),網絡庫應該用 getsockname(2) 和 getpeername(2) 判斷是否是自連接,然后斷開之。
- 在開發網絡庫的時候,模擬本地 ephemeral port 用完,connect(2) 返回 EAGAIN 臨時錯誤。
- 讓 gethostbyname(2) 返回我們預設的值,防止單元測試給公司的 DNS server 帶來太大壓力。
這些 test case 恐怕很難用前文提到的 test harness 來測試,該單元測試上場了?,F在的問題是,如何 mock 這些系統函數?或者換句話說,如何把對系統函數的依賴注入到被測程序中?
系統函數的依賴注入
在Michael Feathers 的《修改代碼的藝術 / Working Effectively with Legacy Code》一書第 4.3.2 節中,作者介紹了鏈接期接縫(link seam),正好可以解決我們的問題。另外,在 Stack Overflow 的一個帖子里也總結了幾種做法:http://stackoverflow.com/questions/2924440/advice-on-mocking-system-calls
如果程序(庫)在編寫的時候就考慮了可測試性,那么用不到上面的 hack 手段,我們可以從設計上解決依賴注入的問題。這里提供兩個思路。
其一,采用傳統的面向對象的手法,借助運行期的遲綁定實現注入與替換。自己寫一個 System interface,把程序里用到的 open、close、read、write、connect、bind、listen、accept、gethostname、getpeername、getsockname 等等函數統統用虛函數封裝一層。然后在代碼里不要直接調用 open(),而是調用 System::instance().open()。
這樣代碼主動把控制權交給了 System interface,我們可以在這里動動手腳。在寫單元測試的時候,把這個 singleton instance 替換為我們的 mock object,這樣就能模擬各種 error code。
其二,采用編譯期或鏈接期的遲綁定。注意到在第一種做法中,運行期多態是不必要的,因為程序從生到死只會用到一個 implementation object。為此付出虛函數調用的代價似乎有些不值。(其實,跟系統調用比起來,虛函數這點開銷可忽略不計。)
我們可以寫一個 system namespace 頭文件,在其中聲明 read() 和 write() 等普通函數,然后在 .cc 文件里轉發給對應系統的系統函數 ::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);
}
此處的代碼來自 muduo 網絡庫
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
有了這么一層間接性,就可以在編寫單元測試的時候動動手腳,鏈接我們的 stub 實現,以達到替換實現的目的:
// MockSocketsOps.cc
int sockets::connect(int sockfd, const struct sockaddr_in& addr)
{
errno = EAGAIN;
return -1;
}
C++ 一個程序只能有一個 main() 入口,所以要先把程序做成 library,再用單元測試代碼鏈接這個 library。假設有一個 mynetcat 程序,為了編寫 C++ 單元測試,我們把它拆成兩部分,library 和 main(),源文件分別是 mynetcat.cc 和 main.cc。
在編譯普通程序的時候:
g++ main.cc mynetcat.cc SocketsOps.cc -o mynetcat
在編譯單元測試時這么寫:
g++ test.cc mynetcat.cc MockSocketsOps.cc -o test
以上是最簡單的例子,在實際開發中可以讓 stub 功能更強大一些,比如根據不同的 test case 返回不同的錯誤。這么做無需用到虛函數,代碼寫起來也比較簡潔,只用前綴 sockets:: 即可。例如應用程序的代碼里寫 sockets::connect(fd, addr)。
muduo 目前還沒有單元測試,只是預留了這些 stubs。
namespace 的好處在于它不是封閉的,我們可以隨時打開往里添加新的函數,而不用改動原來的頭文件(該文件的控制權可能不在我們手里)。這也是以 non-member non-friend 函數為接口的優點。
以上兩種做法還有一個好處,即只 mock 我們關心的部分代碼。如果程序用到了 SQLite 或 Berkeley DB 這些會訪問本地文件系統的第三方庫,那么我們的 System interface 或 system namespace 不會攔截這些第三方庫的 open(2)、close(2)、read(2)、write(2) 等系統調用。
鏈接期墊片 (link seams)
如果程序在一開始編碼的時候沒有考慮單元測試,那么又該如何注入 mock 系統調用呢?上面第二種做法已經給出了答案,那就是使用 link seam (鏈接期墊片)。
比方說要 mock connect(2) 函數,那么我們自己在單元測試程序里實現一個 connect() 函數,在鏈接的時候,會優先采用我們自己定義的函數。(這對動態鏈接是成立的,如果是靜態鏈接,會報 multiple definition 錯誤。好在絕大多數情況下 libc 是動態鏈接的。)
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);
}
}
如果程序真的要調用 connect(2) 怎么辦?在我們自己的 mock connect(2) 里不能再調用 connect() 了,否則會出現無限遞歸。為了防止這種情況,我們用 dlsym(RTDL_NEXT, "connect") 獲得 connect(2) 系統函數的真實地址,然后通過函數指針 connect_func 來調用它。
例子:ZooKeeper 的 C client library
ZooKeeper 的 C client library 正是采用了 link seams 來編寫單元測試,代碼見:
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 的帖子里還提到一個做法,可以方便地替換動態庫里的函數,即使用 ld 的 --wrap 參數,
文檔里說得很清楚,這里不再贅述。
--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++ 庫
link seam 同樣適用于第三方 C++ 庫
比方說公司某個基礎庫團隊提供了了 File class,但是這個 class 沒有使用虛函數,我們無法通過 sub-classing 的辦法來實現 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 的程序編寫單元測試,那么我們可以自己定義其成員函數的實現,這樣可以注入任何我們想要的結果。
// MockFile.cc
int File::readn(void* data, int len)
{
return -1;
}
(這個做法對動態庫是可行的,靜態庫會報錯。我們要么讓對方提供專供單元測試的動態庫,要么拿過源碼來自己編譯一個。)
Java 也有類似的做法,在 class path 里替換我們自己的 stub jar 文件,以實現 link seam。不過 Java 有動態代理,很少用得著 link seam 來實現依賴注入。
Muduo 網絡編程示例之八:Timing wheel 踢掉空閑連接
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
這是《Muduo 網絡編程示例》系列的第八篇文章,原計劃講文件傳輸,這里插入一點計劃之外的內容。
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文介紹如何使用 timing wheel 來踢掉空閑的連接,一個連接如果若干秒沒有收到數據,就認為是空閑連接。
本文的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/idleconnection
在嚴肅的網絡程序中,應用層的心跳協議是必不可少的。應該用心跳消息來判斷對方進程是否能正常工作,“踢掉空閑連接”只是一時權宜之計。我這里想順便講講 shared_ptr 和 weak_ptr 的用法。
如果一個連接連續幾秒鐘(后文以 8s 為例)內沒有收到數據,就把它斷開,為此有兩種簡單粗暴的做法:
- 每個連接保存“最后收到數據的時間 lastReceiveTime”,然后用一個定時器,每秒鐘遍歷一遍所有連接,斷開那些 (now - connection.lastReceiveTime) > 8s 的 connection。這種做法全局只有一個 repeated timer,不過每次 timeout 都要檢查全部連接,如果連接數目比較大(幾千上萬),這一步可能會比較費時。
- 每個連接設置一個 one-shot timer,超時定為 8s,在超時的時候就斷開本連接。當然,每次收到數據要去更新 timer。這種做法需要很多個 one-shot timer,會頻繁地更新 timers。如果連接數目比較大,可能對 reactor 的 timer queue 造成壓力。
使用 timing wheel 能避免上述兩種做法的缺點。timing wheel 可以翻譯為“時間輪盤”或“刻度盤”,本文保留英文。
連接超時不需要精確定時,只要大致 8 秒鐘超時斷開就行,多一秒少一秒關系不大。處理連接超時可以用一個簡單的數據結構:8 個桶組成的循環隊列。第一個桶放下一秒將要超時的連接,第二個放下 2 秒將要超時的連接。每個連接一收到數據就把自己放到第 8 個桶,然后在每秒鐘的 callback 里把第一個桶里的連接斷開,把這個空桶挪到隊尾。這樣大致可以做到 8 秒鐘沒有數據就超時斷開連接。更重要的是,每次不用檢查全部的 connection,只要檢查第一個桶里的 connections,相當于把任務分散了。
Timing wheel 原理
《Hashed and hierarchical timing wheels: efficient data structures for implementing a timer facility》這篇論文詳細比較了實現定時器的各種數據結構,并提出了層次化的 timing wheel 與 hash timing wheel 等新結構。針對本文要解決的問題的特點,我們不需要實現一個通用的定時器,只用實現 simple timing wheel 即可。
Simple timing wheel 的基本結構是一個循環隊列,還有一個指向隊尾的指針 (tail),這個指針每秒鐘移動一格,就像鐘表上的時針,timing wheel 由此得名。
以下是某一時刻 timing wheel 的狀態,格子里的數字是倒計時(與通常的 timing wheel 相反),表示這個格子(桶子)中的連接的剩余壽命。

一秒鐘以后,tail 指針移動一格,原來四點鐘方向的格子被清空,其中的連接已被斷開。

連接超時被踢掉的過程
假設在某個時刻,conn 1 到達,把它放到當前格子中,它的剩余壽命是 7 秒。此后 conn 1 上沒有收到數據。

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

又過了幾秒鐘,tail 指向 conn 1 之前的那個格子,conn 1 即將被斷開。

下一秒,tail 重新指向 conn 1 原來所在的格子,清空其中的數據,斷開 conn 1 連接。

連接刷新
如果在斷開 conn 1 之前收到數據,就把它移到當前的格子里。

收到數據,conn 1 的壽命延長為 7 秒。

時間繼續前進,conn 1 壽命遞減,不過它已經比第一種情況長壽了。

多個連接
timing wheel 中的每個格子是個 hash set,可以容納不止一個連接。
比如一開始,conn 1 到達。

隨后,conn 2 到達,這時候 tail 還沒有移動,兩個連接位于同一個格子中,具有相同的剩余壽命。(下圖中畫成鏈表,代碼中是哈希表。)

幾秒鐘之后,conn 1 收到數據,而 conn 2 一直沒有收到數據,那么 conn 1 被移到當前的格子中。這時 conn 1 的壽命比 conn 2 長。

代碼實現與改進
我們用以前多次出現的 EchoServer 來說明具體如何實現 timing wheel。代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/idleconnection
在具體實現中,格子里放的不是連接,而是一個特制的 Entry struct,每個 Entry 包含 TcpConnection 的 weak_ptr。Entry 的析構函數會判斷連接是否還存在(用 weak_ptr),如果還存在則斷開連接。
數據結構:
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;
在實現中,為了簡單起見,我們不會真的把一個連接從一個格子移到另一個格子,而是采用引用計數的辦法,用 shared_ptr 來管理 Entry。如果從連接收到數據,就把對應的 EntryPtr 放到這個格子里,這樣它的引用計數就遞增了。當 Entry 的引用計數遞減到零,說明它沒有在任何一個格子里出現,那么連接超時,Entry 的析構函數會斷開連接。
Timing wheel 用 boost::circular_buffer 實現,其中每個 Bucket 元素是個 hash set of EntryPtr。
在構造函數中,注冊每秒鐘的回調(EventLoop::runEvery() 注冊 EchoServer::onTimer() ),然后把 timing wheel 設為適當的大小。
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() 的實現只有一行:往隊尾添加一個空的 Bucket,這樣 circular_buffer 會自動彈出隊首的 Bucket,并析構之。在析構 Bucket 的時候,會依次析構其中的 EntryPtr 對象,這樣 Entry 的引用計數就不用我們去操心,C++ 的值語意會幫我們搞定一切。
void EchoServer::onTimer()
{
connectionBuckets_.push_back(Bucket());
}
在連接建立時,創建一個 Entry 對象,把它放到 timing wheel 的隊尾。另外,我們還需要把 Entry 的弱引用保存到 TcpConnection 的 context 里,因為在收到數據的時候還要用到 Entry。(思考題:如果 TcpConnection::setContext 保存的是強引用 EntryPtr,會出現什么情況?)
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();
}
}
在收到消息時,從 TcpConnection 的 context 中取出 Entry 的弱引用,把它提升為強引用 EntryPtr,然后放到當前的 timing wheel 隊尾。(思考題,為什么要把 Entry 作為 TcpConnection 的 context 保存,如果這里再創建一個新的 Entry 會有什么后果?)
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);
}
}
然后呢?沒有然后了,程序已經完成了我們想要的功能。(完整的代碼會打印 circular_buffer 變化的情況,運行一下即可理解。)
希望本文有助于您理解 shared_ptr 和 weak_ptr。
改進
在現在的實現中,每次收到消息都會往隊尾添加 EntryPtr (當然,hash set 會幫我們去重。)一個簡單的改進措施是,在 TcpConnection 里保存“最后一次往隊尾添加引用時的 tail 位置”,然后先檢查 tail 是否變化,若無變化則不重復添加 EntryPtr。這樣或許能提高效率。
以上改進留作練習。