陈硕 (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 数组也是连续的。
![value3 value3](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/7e77817fefec_10F5D/value3_fad1c84e-0822-4b4f-98d3-697a350cefb5.png)
C++ 尽管定义了了 Vector3 这个抽象,它的内存布局并没有改变,Planet 的布局和 C planet 一模一样,Planet[] 的布局也和 C 数组一样。
另一方面,C++ 的 inline 函数在这里也起了巨大作用,我们可以放心地调用 Vector3::operator+=() 等操作符,编译器会生成和 C 一样高效的代码。
不是每个编程语言都能做到在提升抽象的时候不影响性能,来看看 Java 的内存布局。
如果我们用 class Vector3、class Planet、Planet[] 的方式写一个 Java 版的 N-body 程序,内存布局将会是:
![value4 value4](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/7e77817fefec_10F5D/value4_7d78f5de-99d2-47e0-a369-df3ce927f945.png)
这样大大降低了 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 分别是复数的实部和虚部。)
![value1 value1](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b9672fd801cc_111AB/value1_fecaf9b5-9453-4cb0-970c-9918c7d9a727.png)
而如果我们在 Java 里干同样的事情,layout 大不一样,memory locality 也差很多:
![value2 value2](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b9672fd801cc_111AB/value2_05b05637-f7b3-4247-b60b-8d9ecb8e4359.png)
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 程序里要正确打印以上类型的整数,恐怕要费一番脑筋。《The 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 略有了解的人会知道它用了多重继承和虚拟继承,简单地画个类图如下,是典型的菱形继承:
![simple simple](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/C-7-iostream-_12C30/simple_4a5f955b-621e-4fb5-9e5d-981cd19db363.png)
如果加深一点了解,会发现 iostream 现在是模板化的,同时支持窄字符和宽字符。下图是现在的继承体系,同时画出了 fstreams 和 stringstreams。图中方框的第二行是模板的具现化类型,也就是我们代码里常用的具体类型(通过 typedef 定义)。
![ios ios](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/C-7-iostream-_12C30/ios_cb1b83e7-3b9a-43b4-bd3b-5f8ff253825c.png)
这个继承体系糅合了面向对象与泛型编程,但可惜它两方面都不讨好。
再进一步加深了解,发现还有一个平行的 streambuf 继承体系,fstream 和 stringstream 的不同之处主要就在于它们使用了不同的 streambuf 具体类型。
![buf buf](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/C-7-iostream-_12C30/buf_446f5d14-7442-43e1-93cd-aaff74b094a8.png)
再把这两个继承体系画到一幅图里:
![full full](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/C-7-iostream-_12C30/full_16da0b3e-bd24-4acd-96a6-d8fb0b89e136.png)
注意到 basic_ios 持有了 streambuf 的指针;而 fstreams 和 stringstreams 则分别包含 filebuf 和 stringbuf 的对象。看上去有点像 Bridge 模式。
看了这样巴洛克的设计,有没有人还打算在自己的项目中想通过继承 iostream 来实现自己的 stream,以实现功能扩展么?
面向对象方面的设计缺陷
本节我们分析一下 iostream 的设计违反了哪些 OO 准则。
我们知道,面向对象中的 public 继承需要满足 Liskov 替换原则。(见《Effective C++ 第3版》条款32:确保你的 public 继承模塑出 is-a 关系。《C++ 编程规范》条款 37:public 继承意味可替换性。继承非为复用,乃为被复用。)
在程序里需要用到 ostream 的地方(例如 operator<< ),我传入 ofstream 或 ostringstream 都应该能按预期工作,这就是 OO 继承强调的“可替换性”,派生类的对象可以替换基类对象,从而被 operator<< 复用。
iostream 的继承体系多次违反了 Liskov 原则,这些地方继承的目的是为了复用基类的代码,下图中我把违规的继承关系用红线标出。
![correct correct](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/C-7-iostream-_12C30/correct_c06a208e-fd53-486f-b2ee-40d58b2eb931.png)
在现有的继承体系中,合理的有:
- 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 或“以某物实现”。《C++ 编程规范》条款 34:尽可能以组合代替继承。)
用组合替换继承之后的体系:
![myown myown](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/C-7-iostream-_12C30/myown_ec92e699-1a0c-4cfb-b5a9-5a577bfe41da.png)
注意到在新的设计中,只有真正的 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。我做了一个简单的性能测试,结果如下。
![benchmark benchmark](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/C-7-iostream-_12C30/benchmark_0ea04df4-6061-40ba-953e-e468e087645c.png)
从上表看出,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,所有的试验都在这上面进行。
家里试验环境的网络配置是:
![net net](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/-TCP-_7DB4/net_8a152e90-398e-4b5a-82b1-b9719209e096.png)
陈硕在《谈一谈网络编程学习经验》中曾提到“可以用 TUN/TAP 设备在用户态实现一个能与本机点对点通信的 TCP/IP 协议栈”,这次的试验正好可以用上这个办法。
试验的网络配置是:
![tun tun](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/-TCP-_7DB4/tun_2d64cc6a-acfb-4dd5-80cb-b5e2dce5444b.png)
具体做法是:在 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 这类请求-响应服务中,如果客户连接顺序发送多个请求,那么计算得到的多个响应是否按相同的顺序发还给客户(这里指的是在自然条件下,不含刻意同步)。![reactor_comparison reactor_comparison](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/3a541217cdb6_121C3/reactor_comparison_14c3857a-1b9e-40cd-ad3c-3b92c518e0d9.png)
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 的演讲):
![reactor_basic reactor_basic](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/3a541217cdb6_121C3/reactor_basic_55bfac4c-901d-430f-8f5a-9f7b7201a1a2.png)
方案 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 的区别。
![reactor_threadpool reactor_threadpool](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/3a541217cdb6_121C3/reactor_threadpool_d9d919ff-6e4c-495d-beec-3cc15a9067cc.png)
如果 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 的默认线程模型。
![reactor_multiple reactor_multiple](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/3a541217cdb6_121C3/reactor_multiple_8ca778e1-717c-42db-8e63-11ddcf1c55ae.png)
代码见: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),又有突发计算的应用(利用线程池把一个连接上的计算任务分配给多个线程去做)。
![reactor_hybrid reactor_hybrid](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/3a541217cdb6_121C3/reactor_hybrid_86ee4e66-a8a7-4689-a131-05d96fe4feb7.png)
这种其实方案看起来复杂,其实写起来很简单,只要把方案 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留给服务端。或者干脆使用长连接协议,这样避免频繁创建销毁连接。
比连接的建立与断开更重要的是设计消息协议。消息格式很好办,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是有用的)。但是,如果两台机器之间经过了多级路由器呢?
![router router](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/958c781e7d58_E312/router_3.png)
上图中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都讲到了。要说有什么缺点的话,就是太详细了,重点不够突出。我十分赞同孟岩说的
“(孟岩)我主张,在具备基础之后,学习任何新东西,都要抓住主线,突出重点。对于关键理论的学习,要集中精力,速战速决。而旁枝末节和非本质性的知识内容,完全可以留给实践去零敲碎打。
“原因是这样的,任何一个高级的知识内容,其中都只有一小部分是有思想创新、有重大影响的,而其它很多东西都是琐碎的、非本质的。因此,集中学习时必须把握住真正重要那部分,把其它东西留给实践。对于重点知识,只有集中学习其理论,才能确保体系性、连贯性、正确性,而对于那些旁枝末节,只有边干边学能够让你了解它们的真实价值是大是小,才能让你留下更生动的印象。如果你把精力用错了地方,比如用集中大块的时间来学习那些本来只需要查查手册就可以明白的小技巧,而对于真正重要的、思想性东西放在平时零敲碎打,那么肯定是事倍功半,甚至适得其反。
“因此我对于市面上绝大部分开发类图书都不满——它们基本上都是面向知识体系本身的,而不是面向读者的。总是把相关的所有知识细节都放在一堆,然后一堆一堆攒起来变成一本书。反映在内容上,就是毫无重点地平铺直叙,不分轻重地陈述细节,往往在第三章以前就用无聊的细节谋杀了读者的热情。为什么当年侯捷先生的《深入浅出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 的角色。
![relay relay](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/4b8b6e4110d7_12319/relay_725a0bef-f65a-4b76-beb9-96b4c964ec64.png)
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 hub](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/d59aa822e826_12D5E/hub_030fc751-30fe-4b70-a7ef-e1b2cd874029.png)
上图中圆角矩形代表程序,"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 来测试,该单元测试上场了。现在的问题是,如何 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 相反),表示这个格子(桶子)中的连接的剩余寿命。
![wheel1 wheel1](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel1_26649bd2-811d-4c55-ab01-a7d1d69cf4d2.png)
一秒钟以后,tail 指针移动一格,原来四点钟方向的格子被清空,其中的连接已被断开。
![wheel2 wheel2](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel2_237b5de9-94e0-4d02-8af6-6c11ade2abf1.png)
连接超时被踢掉的过程
假设在某个时刻,conn 1 到达,把它放到当前格子中,它的剩余寿命是 7 秒。此后 conn 1 上没有收到数据。
![wheel3 wheel3](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel3_beea0fd8-1268-49e6-9c21-ad59795144ae.png)
1 秒钟之后,tail 指向下一个格子,conn 1 的剩余寿命是 6 秒。
![wheel4 wheel4](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel4_ad067035-8462-42c5-b33c-6d7b40faf5ec.png)
又过了几秒钟,tail 指向 conn 1 之前的那个格子,conn 1 即将被断开。
![wheel5 wheel5](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel5_7d0e810c-87f1-4e49-ba0b-535d39aab3c8.png)
下一秒,tail 重新指向 conn 1 原来所在的格子,清空其中的数据,断开 conn 1 连接。
![wheel6 wheel6](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel6_54f35ddd-452d-4ff8-aeed-fdaa2aa35287.png)
连接刷新
如果在断开 conn 1 之前收到数据,就把它移到当前的格子里。
![wheel4 wheel4](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel4_da2f6a05-b4c1-4e23-bfb0-705f474ce47a.png)
收到数据,conn 1 的寿命延长为 7 秒。
![wheel7 wheel7](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel7_999067a1-cb64-47df-b2c3-113b84ac30ed.png)
时间继续前进,conn 1 寿命递减,不过它已经比第一种情况长寿了。
![wheel8 wheel8](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel8_fa1932b2-56b0-4528-a57f-4625ae38542d.png)
多个连接
timing wheel 中的每个格子是个 hash set,可以容纳不止一个连接。
比如一开始,conn 1 到达。
![wheel3 wheel3](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel3_31111b7b-9e9d-4ea0-83fe-832f9b091e69.png)
随后,conn 2 到达,这时候 tail 还没有移动,两个连接位于同一个格子中,具有相同的剩余寿命。(下图中画成链表,代码中是哈希表。)
![wheel9 wheel9](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel9_05feeadf-8733-4577-94be-27462fd5762b.png)
几秒钟之后,conn 1 收到数据,而 conn 2 一直没有收到数据,那么 conn 1 被移到当前的格子中。这时 conn 1 的寿命比 conn 2 长。
![wheel10 wheel10](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/Muduo-_7386/wheel10_8a944ded-d9f6-48a6-9917-45579d672e14.png)
代码实现与改进
我们用以前多次出现的 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。这样或许能提高效率。
以上改进留作练习。