陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
今天收到一位网友来信:
在 simple 中的 daytime 示例中,服务端主动关闭时调用的是如下函数序列,这不是只是关闭了连接上的写操作吗,怎么是关闭了整个连接?
1: void DaytimeServer::onConnection(const muduo::net::TcpConnectionPtr& conn)
2: {
3: if (conn->connected())
4: {
5: conn->send(Timestamp::now().toFormattedString() + "\n");
6: conn->shutdown();
7: }
8: }
9:
10: void TcpConnection::shutdown()
11: {
12: if (state_ == kConnected)
13: {
14: setState(kDisconnecting);
15: loop_->runInLoop(boost::bind(&TcpConnection::shutdownInLoop, this));
16: }
17: }
18:
19: void TcpConnection::shutdownInLoop()
20: {
21: loop_->assertInLoopThread();
22: if (!channel_->isWriting())
23: {
24: // we are not writing
25: socket_->shutdownWrite();
26: }
27: }
28:
29: void Socket::shutdownWrite()
30: {
31: sockets::shutdownWrite(sockfd_);
32: }
33:
34: void sockets::shutdownWrite(int sockfd)
35: {
36: if (::shutdown(sockfd, SHUT_WR) < 0)
37: {
38: LOG_SYSERR << "sockets::shutdownWrite";
39: }
40: }
陈硕答复如下:
Muduo TcpConnection 没有提供 close,而只提供 shutdown ,这么做是为了收发数据的完整性。
TCP 是一个全双工协议,同一个文件描述符既可读又可写, shutdownWrite() 关闭了“写”方向的连接,保留了“读”方向,这称为 TCP half-close。如果直接 close(socket_fd),那么 socket_fd 就不能读或写了。
用 shutdown 而不用 close 的效果是,如果对方已经发送了数据,这些数据还“在路上”,那么 muduo 不会漏收这些数据。换句话说,muduo 在 TCP 这一层面解决了“当你打算关闭网络连接的时候,如何得知对方有没有发了一些数据而你还没有收到?”这一问题。当然,这个问题也可以在上面的协议层解决,双方商量好不再互发数据,就可以直接断开连接。
等于说 muduo 把“主动关闭连接”这件事情分成两步来做,如果要主动关闭连接,它会先关本地“写”端,等对方关闭之后,再关本地“读”端。练习:阅读代码,回答“如果被动关闭连接,muduo 的行为如何?” 提示:muduo 在 read() 返回 0 的时候会回调 connection callback,这样客户代码就知道对方断开连接了。
Muduo 这种关闭连接的方式对对方也有要求,那就是对方 read() 到 0 字节之后会主动关闭连接(无论 shutdownWrite() 还是 close()),一般的网络程序都会这样,不是什么问题。当然,这么做有一个潜在的安全漏洞,万一对方故意不不关,那么 muduo 的连接就一直半开着,消耗系统资源。
完整的流程是:我们发完了数据,于是 shutdownWrite,发送 TCP FIN 分节,对方会读到 0 字节,然后对方通常会关闭连接,这样 muduo 会读到 0 字节,然后 muduo 关闭连接。(思考题:在 shutdown() 之后,muduo 回调 connection callback 的时间间隔大约是一个 round-trip time,为什么?)
另外,如果有必要,对方可以在 read() 返回 0 之后继续发送数据,这是直接利用了 half-close TCP 连接。muduo 会收到这些数据,通过 message callback 通知客户代码。
那么 muduo 什么时候真正 close socket 呢?在 TcpConnection 对象析构的时候。TcpConnection 持有一个 Socket 对象,Socket 是一个 RAII handler,它的析构函数会 close(sockfd_)。这样,如果发生 TcpConnection 对象泄漏,那么我们从 /proc/pid/fd/ 就能找到没有关闭的文件描述符,便于查错。
muduo 在 read() 返回 0 的时候会回调 connection callback,然后把 TcpConnection 的引用计数减一,如果 TcpConnection 的引用计数降到零,它就会析构了。
参考:
《TCP/IP 详解》第一卷第 18.5 节,TCP Half-Close。
《UNIX 网络编程》第一卷第三版第 6.6 节, shutdown() 函数。
摘要: 陈硕 (giantchen_AT_gmail) Blog.csdn.net/Solstice 这是《Muduo 网络编程示例》系列的第四篇文章。 Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx Python Twisted 是一款非常好的网络库,它也采用 Reactor 作为网络编程的基本模型,所以从使用上与 m...
阅读全文
陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
本文只考虑 Linux x86 平台,服务端开发(不考虑 Windows 的跨 DLL 内存分配释放问题)。本文假定读者知道 ::operator new() 和 ::operator delete() 是干什么的,与通常用的 new/delete 表达式有和区别和联系,这方面的知识可参考侯捷先生的文章《池内春秋》[1],或者这篇文章。
C++ 的内存管理是个老生常谈的话题,我在《当析构函数遇到多线程》第 7 节“插曲:系统地避免各种指针错误”中简单回顾了一些常见的问题以及在现代 C++ 中的解决办法。基本上,按现代 C++ 的手法(RAII)来管理内存,你很难遇到什么内存方面的错误。“没有错误”是基本要求,不代表“足够好”。我们常常会设法优化性能,如果 profiling 表明 hot spot 在内存分配和释放上,重载全局的 ::operator new() 和 ::operator delete() 似乎是一个一劳永逸好办法(以下简写为“重载 ::operator new()”),本文试图说明这个办法往往行不通。
内存管理的基本要求
如果只考虑分配和释放,内存管理基本要求是“不重不漏”:既不重复 delete,也不漏掉 delete。也就说我们常说的 new/delete 要配对,“配对”不仅是个数相等,还隐含了 new 和 delete 的调用本身要匹配,不要“东家借的东西西家还”。例如:
- 用系统默认的 malloc() 分配的内存要交给系统默认的 free() 去释放;
- 用系统默认的 new 表达式创建的对象要交给系统默认的 delete 表达式去析构并释放;
- 用系统默认的 new[] 表达式创建的对象要交给系统默认的 delete[] 表达式去析构并释放;
- 用系统默认的 ::operator new() 分配的的内存要交给系统默认的 ::operator delete() 去释放;
- 用 placement new 创建的对象要用 placement delete (为了表述方便,姑且这么说吧)去析构(其实就是直接调用析构函数);
- 从某个内存池 A 分配的内存要还给这个内存池。
- 如果定制 new/delete,那么要按规矩来。见 Effective C++ 相关条款。
做到以上这些不难,是每个 C++ 开发人员的基本功。不过,如果你想重载全局的 ::operator new(),事情就麻烦了。
重载 ::operator new() 的理由
Effective C++ 第三版第 50 条列举了定制 new/delete 的几点理由:
- 检测代码中的内存错误
- 优化性能
- 获得内存使用的统计数据
这些都是正当的需求,文末我们将会看到,不重载 ::operator new() 也能达到同样的目的。
::operator new() 的两种重载方式
1. 不改变其签名,无缝直接替换系统原有的版本,例如:
#include <new>
void* operator new(size_t size);
void operator delete(void* p);
用这种方式的重载,使用方不需要包含任何特殊的头文件,也就是说不需要看见这两个函数声明。“性能优化”通常用这种方式。
2. 增加新的参数,调用时也提供这些额外的参数,例如:
void* operator new(size_t size, const char* file, int line); // 其返回的指针必须能被普通的 ::operator delete(void*) 释放
void operator delete(void* p, const char* file, int line); // 这个函数只在析构函数抛异常的情况下才会被调用
然后用的时候是
Foo* p = new (__FILE, __LINE__) Foo; // 这样能跟踪是哪个文件哪一行代码分配的内存
我们也可以用宏替换 new 来节省打字。用这第二种方式重载,使用方需要看到这两个函数声明,也就是说要主动包含你提供的头文件。“检测内存错误”和“统计内存使用情况”通常会用这种方式重载。当然,这不是绝对的。
在学习 C++ 的阶段,每个人都可以写个一两百行的程序来验证教科书上的说法,重载 ::operator new() 在这样的玩具程序里边不会造成什么麻烦。
不过,我认为在现实的产品开发中,重载 ::operator new() 乃是下策,我们有更简单安全的办法来到达以上目标。
现实的开发环境
作为 C++ 应用程序的开发人员,在编写稍具规模的程序时,我们通常会用到一些 library。我们可以根据 library 的提供方把它们大致分为这么几大类:
- C 语言的标准库,也包括 Linux 编程环境提供的 Posix 系列函数。
- 第三方的 C 语言库,例如 OpenSSL。
- C++ 语言的标准库,主要是 STL。(我想没有人在产品中使用 IOStream 吧?)
- 第三方的通用 C++ 库,例如 Boost.Regex,或者某款 XML 库。
- 公司其他团队的人开发的内部基础 C++ 库,比如网络通信和日志等基础设施。
- 本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换模块。
在使用这些 library 的时候,不可避免地要在各个 library 之间交换数据。比方说 library A 的输出作为 library B 的输入,而 library A 的输出本身常常会用到动态分配的内存(比如 std::vector<double>)。
如果所有的 C++ library 都用同一套内存分配器(就是系统默认的 new/delete ),那么内存的释放就很方便,直接交给 delete 去释放就行。如果不是这样,那就得时时刻刻记住“这一块内存是属于哪个分配器,是系统默认的还是我们定制的,释放的时候不要还错了地方”。
(由于 C 语言不像 C++ 一样提过了那么多的定制性,C library 通常都会默认直接用 malloc/free 来分配和释放内存,不存在上面提到的“内存还错地方”问题。或者有的考虑更全面的 C library 会让你注册两个函数,用于它内部分配和释放内存,这就就能完全掌控该 library 的内存使用。这种依赖注入的方式在 C++ 里变得花哨而无用,见陈硕写的《C++ 标准库中的allocator是多余的》。)
但是,如果重载了 ::operator new(),事情恐怕就没有这么简单了。
重载 ::operator new() 的困境
首先,重载 ::operator new() 不会给 C 语言的库带来任何麻烦,当然,重载它得到的三点好处也无法让 C 语言的库享受到。
以下仅考虑 C++ library 和 C++ 主程序。
规则 1:绝对不能在 library 里重载 ::operator new()
如果你是某个 library 的作者,你的 library 要提供给别人使用,那么你无权重载全局 ::operator new(size_t) (注意这是上面提到的第一种重载方式),因为这非常具有侵略性:任何用到你的 library 的程序都被迫使用了你重载的 ::operator new(),而别人很可能不愿意这么做。另外,如果有两个 library 都试图重载 ::operator new(size_t),那么它们会打架,我估计会发生 duplicated symbol link error。干脆,作为 library 的编写者,大家都不要重载 ::operator new(size_t) 好了。
那么第二种重载方式呢?首先,::operator new(size_t size, const char* file, int line) 这种方式得到的 void* 指针必须同时能被 ::operator delete(void*) 和 ::operator delete(void* p, const char* file, int line) 这两个函数释放。这时候你需要决定,你的 ::operator new(size_t size, const char* file, int line) 返回的指针是不是兼容系统默认的 ::operator delete(void*)。
- 如果不兼容(也就是说不能用系统默认的 ::operator delete(void*) 来释放内存),那么你得重载 ::operator delete(void*),让它的行为与你的 operator new(size_t size, const char* file, int line) 匹配。一旦你决定重载 ::operator delete(void*),那么你必须重载 ::operator new(size_t),这就回到了情况 1:你无权重载全局 ::operator new(size_t)。
- 如果选择兼容系统默认的 ::operator delete(void*),那么你在 operator new(size_t size, const char* file, int line) 里能做的事情非常有限,比方说你不能额外动态分配内存来做 house keeping 或保存统计数据(无论显示还是隐式),因为系统默认的 ::operator delete(void*) 不会释放你额外分配的内存。(这里隐式分配内存指的是往 std::map<> 这样的容器里添加元素。)
看到这里估计很多人已经晕了,但这还没完。
其次,在 library 里重载 operator new(size_t size, const char* file, int line) 还涉及到你的重载要不要暴露给 library 的使用者(其他 library 或主程序)。这里“暴露”有两层意思:1) 包含你的头文件的代码会不会用你重载的 ::operator new(),2) 重载之后的 ::operator new() 分配的内存能不能在你的 library 之外被安全地释放。如果不行,那么你是不是要暴露某个接口函数来让使用者安全地释放内存?或者返回 shared_ptr ,利用其“捕获”deleter 的特性?听上去好像挺复杂?这里就不一一展开讨论了,总之,作为 library 的作者,绝对不要动“重载 operator new()”的念头。
事实 2:在主程序里重载 ::operator new() 作用不大
这不是一条规则,而是我试图说明这么做没有多大意义。
如果用第一种方式重载全局 ::operator new(size_t),会影响本程序用到的所有 C++ library,这么做或许不会有什么问题,不过我建议你使用下一节介绍的更简单的“替代办法”。
如果用第二种方式重载 ::operator new(size_t size, const char* file, int line),那么你的行为是否惠及本程序用到的其他 C++ library 呢?比方说你要不要统计 C++ library 中的内存使用情况?如果某个 library 会返回它自己用 new 分配的内存和对象,让你用完之后自己释放,那么是否打算对错误释放内存做检查?
C++ library 从代码组织上有两种形式:1) 以头文件方式提供(如以 STL 和 Boost 为代表的模板库);2) 以头文件+二进制库文件方式提供(大多数非模板库以此方式发布)。
对于纯以头文件方式实现的 library,那么你可以在你的程序的每个 .cpp 文件的第一行包含重载 ::operator new 的头文件,这样程序里用到的其他 C++ library 也会转而使用你的 ::operator new 来分配内存。当然这是一种相当有侵略性的做法,如果运气好,编译和运行都没问题;如果运气差一点,可能会遇到编译错误,这其实还不算坏事;运气更差一点,编译没有错误,运行的时候时不时出现非法访问,导致 segment fault;或者在某些情况下你定制的分配策略与 library 有冲突,内存数据损坏,出现莫名其妙的行为。
对于以库文件方式实现的 library,这么做并不能让其受惠,因为 library 的源文件已经编译成了二进制代码,它不会调用你新重载的 ::operator new(想想看,已经编译的二进制代码怎么可能提供额外的 new (__FILE__, __LINE__) 参数呢?)更麻烦的是,如果某些头文件有 inline function,还会引起诡异的“串扰”。即 library 有的部分用了你的分配器,有的部分用了系统默认的分配器,然后在释放内存的时候没有给对地方,造成分配器的数据结构被破坏。
总之,第二种重载方式看似功能更丰富,但其实与程序里使用的其他 C++ library 很难无缝配合。
综上,对于现实生活中的 C++ 项目,重载 ::operator new() 几乎没有用武之地,因为很难处理好与程序所用的 C++ library 的关系,毕竟大多数 library 在设计的时候没有考虑到你会重载 ::operator new() 并强塞给它。
如果确实需要定制内存分配,该如何办?
替代办法
很简单,替换 malloc。如果需要,直接从 malloc 层面入手,通过 LD_PRELOAD 来加载一个 .so,其中有 malloc/free 的替代实现(drop-in replacement),这样能同时为 C 和 C++ 代码服务,而且避免 C++ 重载 ::operator new() 的阴暗角落。
对于“检测内存错误”这一用法,我们可以用 valgrind 或者 dmalloc 或者 efence 来达到相同的目的,专业的除错工具比自己山寨一个内存检查器要靠谱。
对于“统计内存使用数据”,替换 malloc 同样能得到足够的信息,因为我们可以用 backtrace() 函数来获得调用栈,这比 new (__FILE__, __LINE__) 的信息更丰富。比方说你通过分析 (__FILE__, __LINE__) 发现 std::string 大量分配释放内存,有超出预期的开销,但是你却不知道代码里哪一部分在反复创建和销毁 std::string 对象,因为 (__FILE__, __LINE__) 只能告诉你最内层的调用函数。用 backtrace() 能找到真正的发起调用者。
对于“性能优化”这一用法,我认为这目前的多线程开发中,自己实现一个能打败系统默认的 malloc 的内存分配器是不现实的。一个通用的内存分配器本来就有相当的难度,为多线程程序实现一个安全和高效的通用(全局)内存分配器超出了一般开发人员的能力。不如使用现有的针对多核多线程优化的 malloc,例如 Google tcmalloc 和 Intel TBB 2.2 里的内存分配器。好在这些 allocator 都不是侵入式的,也无须重载 ::operator new()。
为单独的 class 重载 operator new() 有问题吗?
与全局 ::operator new() 不同,per-class operator new() 和 operator delete () 的影响面要小得多,它只影响本 class 及其派生类。似乎重载 member operator new() 是可行的。我对此持反对态度。
如果一个 class Node 需要重载 member operator new(),说明它用到了特殊的内存分配策略,常见的情况是使用了内存池或对象池。我宁愿把这一事实明显地摆出来,而不是改变 new Node 的默认行为。具体地说,是用 factory 来创建对象,比如 static Node* Node::createNode() 或者 static shared_ptr<Node> Node::createNode();。
这可以归结为最小惊讶原则:如果我在代码里读到 Node* p = new Node,我会认为它在 heap 上分配了内存,如果 Node class 重载了 member operator new(),那么我要事先仔细阅读 node.h 才能发现其实这行代码使用了私有的内存池。为什么不写得明确一点呢?写成 Node* p = Node::createNode(),那么我能猜到 Node::createNode() 肯定做了什么与 new Node 不一样的事情,免得将来大吃一惊。
The Zen of Python 说 explicit is better than implicit,我深信不疑。
总结:重载 ::operator new() 或许在某些临时的场合能应个急,但是不应该作为一种策略来使用。如果需要,我们可以从 malloc 层面入手,彻底而全面地替换内存分配器。
参考文献:
[1] 侯捷,《池內春秋—— Memory Pool 的設計哲學與無痛運用》,《程序员》2002 年第 9 期。
匿名 namespace (anonymous namespace 或称 unnamed namespace) 是 C++ 的一项非常有用的功能,其主要目的是让该 namespace 中的成员(变量或函数)具有独一无二的全局名称,避免名字碰撞 (name collisions)。一般在编写 .cpp 文件时,如果需要写一些小的 helper 函数,我们常常会放到匿名 namespace 里。muduo 0.1.7 中的 muduo/base/Date.cc 和 muduo/base/Thread.cc 等处就用到了匿名 namespace。
我最近在工作中遇到并重新思考了这一问题,发现匿名 namespace 并不是多多益善。
C 语言的 static 关键字的两种用法
C 语言的 static 关键字有两种用途:
1. 用于函数内部修饰变量,即函数内的静态变量。这种变量的生存期长于该函数,使得函数具有一定的“状态”。使用静态变量的函数一般是不可重入的,也不是线程安全的。
2. 用在文件级别(函数体之外),修饰变量或函数,表示该变量或函数只在本文件可见,其他文件看不到也访问不到该变量或函数。专业的说法叫“具有 internal linkage”(简言之:不暴露给别的 translation unit)。
C 语言的这两种用法很明确,一般也不容易混淆。
C++ 语言的 static 关键字的四种用法
由于 C++ 引入了 class,在保持与 C 语言兼容的同时,static 关键字又有了两种新用法:
3. 用于修饰 class 的数据成员,即所谓“静态成员”。这种数据成员的生存期大于 class 的对象(实体 instance)。静态数据成员是每个 class 有一份,普通数据成员是每个 instance 有一份,因此也分别叫做 class variable 和 instance variable。
4. 用于修饰 class 的成员函数,即所谓“静态成员函数”。这种成员函数只能访问 class variable 和其他静态程序函数,不能访问 instance variable 或 instance method。
当然,这几种用法可以相互组合,比如 C++ 的成员函数(无论 static 还是 instance)都可以有其局部的静态变量(上面的用法 1)。对于 class template 和 function template,其中的 static 对象的真正个数跟 template instantiation (模板具现化)有关,相信学过 C++ 模板的人不会陌生。
可见在 C++ 里 static 被 overload 了多次。匿名 namespace 的引入是为了减轻 static 的负担,它替换了 static 的第 2 种用途。也就是说,在 C++ 里不必使用文件级的 static 关键字,我们可以用匿名 namespace 达到相同的效果。(其实严格地说,linkage 或许稍有不同,这里不展开讨论了。)
匿名 namespace 的不利之处
在工程实践中,匿名 namespace 有两大不利之处:
- 其中的函数难以设断点,如果你像我一样使用的是 gdb 这样的文本模式 debugger。
- 使用某些版本的 g++ 时,同一个文件每次编译出来的二进制文件会变化,这让某些 build tool 失灵。
考虑下面这段简短的代码 (anon.cc):
1: namespace
2: {
3: void foo()
4: {
5: }
6: }
7:
8: int main()
9: {
10: foo();
11: }
对于问题 1:
gdb 的<tab>键自动补全功能能帮我们设定断点,不是什么大问题。前提是你知道那个"(anonymous namespace)::foo()"正是你想要的函数。
$ gdb ./a.out
GNU gdb (GDB) 7.0.1-debian
(gdb) b '<tab>
(anonymous namespace) __data_start _end
(anonymous namespace)::foo() __do_global_ctors_aux _fini
_DYNAMIC __do_global_dtors_aux _init
_GLOBAL_OFFSET_TABLE_ __dso_handle _start
_IO_stdin_used __gxx_personality_v0 anon.cc
__CTOR_END__ __gxx_personality_v0@plt call_gmon_start
__CTOR_LIST__ __init_array_end completed.6341
__DTOR_END__ __init_array_start data_start
__DTOR_LIST__ __libc_csu_fini dtor_idx.6343
__FRAME_END__ __libc_csu_init foo
__JCR_END__ __libc_start_main frame_dummy
__JCR_LIST__ __libc_start_main@plt int
__bss_start _edata main
(gdb) b '(<tab>
anonymous namespace) anonymous namespace)::foo()
(gdb) b '(anonymous namespace)::foo()'
Breakpoint 1 at 0x400588: file anon.cc, line 4.
麻烦的是,如果两个文件 anon.cc 和 anonlib.cc 都定义了匿名空间中的 foo() 函数(这不会冲突),那么 gdb 无法区分这两个函数,你只能给其中一个设断点。或者你使用 文件名:行号 的方式来分别设断点。(从技术上,匿名 namespace 中的函数是 weak text,链接的时候如果发生符号重名,linker 不会报错。)
从根本上解决的办法是使用普通具名 namespace,如果怕重名,可以把源文件名(必要时加上路径)作为 namespace 名字的一部分。
对于问题 2:
把它编译两次,分别生成 a.out 和 b.out:
$ g++ -g -o a.out anon.cc
$ g++ -g -o b.out anon.cc
$ md5sum a.out b.out
0f7a9cc15af7ab1e57af17ba16afcd70 a.out
8f22fc2bbfc27beb922aefa97d174e3b b.out
$ g++ --version
g++ (GCC) 4.2.4 (Ubuntu 4.2.4-1ubuntu4)
$ diff -u <(nm a.out) <(nm b.out)
--- /dev/fd/63 2011-02-15 22:27:58.960754999 +0800
+++ /dev/fd/62 2011-02-15 22:27:58.960754999 +0800
@@ -2,7 +2,7 @@
0000000000600940 d _GLOBAL_OFFSET_TABLE_
0000000000400634 R _IO_stdin_used
w _Jv_RegisterClasses
-0000000000400538 t _ZN36_GLOBAL__N_anon.cc_00000000_E2CEEB513fooEv
+0000000000400538 t _ZN36_GLOBAL__N_anon.cc_00000000_CB51498D3fooEv
0000000000600748 d __CTOR_END__
0000000000600740 d __CTOR_LIST__
0000000000600758 d __DTOR_END__
由上可见,g++ 4.2.4 会随机地给匿名 namespace 生成一个惟一的名字(foo() 函数的 mangled name 中的 E2CEEB51 和 CB51498D 是随机的),以保证名字不冲突。也就是说,同样的源文件,两次编译得到的二进制文件内容不相同,这有时候会造成问题。比如说拿到一个会发生 core dump 的二进制可执行文件,无法确定它是由哪个 revision 的代码编译出来的。毕竟编译结果不可复现,具有一定的随机性。
这可以用 gcc 的 -frandom-seed 参数解决,具体见文档。
这个现象在 gcc 4.2.4 中存在(之前的版本估计类似),在 gcc 4.4.5 中不存在。
替代办法
如果前面的“不利之处”给你带来困扰,解决办法也很简单,就是使用普通具名 namespace。当然,要起一个好的名字,比如 boost 里就常常用 boost::detail 来放那些“不应该暴露给客户,但又不得不放到头文件里”的函数或 class。
总而言之,匿名 namespace 没什么大问题,使用它也不是什么过错。万一它碍事了,可以用普通具名 namespace 替代之。
这是一套紧凑的 PPT,基本上每一张幻灯片都可以单独写一篇博客,但是我没有那么多时间一一展开论述,只能把结论和主要论据列了出来。
![Slide1 Slide1](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide1_1.png)
![Slide2 Slide2](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide2.png)
![Slide3 Slide3](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide3.png)
![Slide4 Slide4](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide4.png)
![Slide5 Slide5](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide5.png)
![Slide6 Slide6](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide6.png)
![Slide7 Slide7](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide7.png)
![Slide8 Slide8](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide8.png)
![Slide9 Slide9](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide9.png)
![Slide10 Slide10](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide10.png)
![Slide11 Slide11](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide11.png)
![Slide12 Slide12](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide12.png)
![Slide13 Slide13](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide13.png)
![Slide14 Slide14](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide14.png)
![Slide15 Slide15](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide15.png)
![Slide16 Slide16](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide16.png)
![Slide17 Slide17](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide17.png)
![Slide18 Slide18](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide18.png)
![Slide19 Slide19](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide19.png)
![Slide20 Slide20](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide20.png)
![Slide21 Slide21](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide21.png)
![Slide22 Slide22](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide22.png)
![Slide23 Slide23](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide23.png)
![Slide24 Slide24](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide24.png)
![Slide25 Slide25](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide25.png)
![Slide26 Slide26](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide26.png)
![Slide27 Slide27](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide27.png)
![Slide28 Slide28](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide28.png)
![Slide29 Slide29](http://www.cppblog.com/images/cppblog_com/Solstice/Windows-Live-Writer/b0499b5a8cb6_F704/Slide29.png)
摘要: 陈硕 (giantchen_AT_gmail) Blog.csdn.net/Solstice 这是《Muduo 网络编程示例》系列的第三篇文章。 Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx 程序中的时间 程序中对时间的处理是个大问题,我打算单独写一篇文章来全面地讨论这个问题。文章暂定名《〈程...
阅读全文
陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
这是《Muduo 网络编程示例》系列的第二篇文章。
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文讲介绍一个与 Boost.Asio 的示例代码中的聊天服务器功能类似的网络服务程序,包括客户端与服务端的 muduo 实现。这个例子的主要目的是介绍如何处理分包,并初步涉及 Muduo 的多线程功能。Muduo 的下载地址: http://muduo.googlecode.com/files/muduo-0.1.7-alpha.tar.gz ,SHA1 873567e43b3c2cae592101ea809b30ba730f2ee6,本文的完整代码可在线阅读
http://code.google.com/p/muduo/source/browse/trunk/examples/asio/chat/ 。
TCP 分包
前面一篇《五个简单 TCP 协议》中处理的协议没有涉及分包,在 TCP 这种字节流协议上做应用层分包是网络编程的基本需求。分包指的是在发生一个消息(message)或一帧(frame)数据时,通过一定的处理,让接收方能从字节流中识别并截取(还原)出一个个消息。“粘包问题”是个伪问题。
对于短连接的 TCP 服务,分包不是一个问题,只要发送方主动关闭连接,就表示一条消息发送完毕,接收方 read() 返回 0,从而知道消息的结尾。例如前一篇文章里的 daytime 和 time 协议。
对于长连接的 TCP 服务,分包有四种方法:
- 消息长度固定,比如 muduo 的 roundtrip 示例就采用了固定的 16 字节消息;
- 使用特殊的字符或字符串作为消息的边界,例如 HTTP 协议的 headers 以 "\r\n" 为字段的分隔符;
- 在每条消息的头部加一个长度字段,这恐怕是最常见的做法,本文的聊天协议也采用这一办法;
- 利用消息本身的格式来分包,例如 XML 格式的消息中 <root>...</root> 的配对,或者 JSON 格式中的 { ... } 的配对。解析这种消息格式通常会用到状态机。
在后文的代码讲解中还会仔细讨论用长度字段分包的常见陷阱。
聊天服务
本文实现的聊天服务非常简单,由服务端程序和客户端程序组成,协议如下:
- 服务端程序中某个端口侦听 (listen) 新的连接;
- 客户端向服务端发起连接;
- 连接建立之后,客户端随时准备接收服务端的消息并在屏幕上显示出来;
- 客户端接受键盘输入,以回车为界,把消息发送给服务端;
- 服务端接收到消息之后,依次发送给每个连接到它的客户端;原来发送消息的客户端进程也会收到这条消息;
- 一个服务端进程可以同时服务多个客户端进程,当有消息到达服务端后,每个客户端进程都会收到同一条消息,服务端广播发送消息的顺序是任意的,不一定哪个客户端会先收到这条消息。
- (可选)如果消息 A 先于消息 B 到达服务端,那么每个客户端都会先收到 A 再收到 B。
这实际上是一个简单的基于 TCP 的应用层广播协议,由服务端负责把消息发送给每个连接到它的客户端。参与“聊天”的既可以是人,也可以是程序。在以后的文章中,我将介绍一个稍微复杂的一点的例子 hub,它有“聊天室”的功能,客户端可以注册特定的 topic(s),并往某个 topic 发送消息,这样代码更有意思。
消息格式
本聊天服务的消息格式非常简单,“消息”本身是一个字符串,每条消息的有一个 4 字节的头部,以网络序存放字符串的长度。消息之间没有间隙,字符串也不一定以 '\0' 结尾。比方说有两条消息 "hello" 和 "chenshuo",那么打包后的字节流是:
0x00, 0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o', 0x00, 0x00, 0x00, 0x08, 'c', 'h', 'e', 'n', 's', 'h', 'u', 'o'
共 21 字节。
打包的代码
这段代码把 const string& message 打包为 muduo::net::Buffer,并通过 conn 发送。
1: void send(muduo::net::TcpConnection* conn, const string& message)
2: {
3: muduo::net::Buffer buf;
4: buf.append(message.data(), message.size());
5: int32_t len = muduo::net::sockets::hostToNetwork32(static_cast<int32_t>(message.size()));
6: buf.prepend(&len, sizeof len);
7: conn->send(&buf);
8: }
muduo::Buffer 有一个很好的功能,它在头部预留了 8 个字节的空间,这样第 6 行的 prepend() 操作就不需要移动已有的数据,效率较高。
分包的代码
解析数据往往比生成数据复杂,分包打包也不例外。
1: void onMessage(const muduo::net::TcpConnectionPtr& conn,
2: muduo::net::Buffer* buf,
3: muduo::Timestamp receiveTime)
4: {
5: while (buf->readableBytes() >= kHeaderLen)
6: {
7: const void* data = buf->peek();
8: int32_t tmp = *static_cast<const int32_t*>(data);
9: int32_t len = muduo::net::sockets::networkToHost32(tmp);
10: if (len > 65536 || len < 0)
11: {
12: LOG_ERROR << "Invalid length " << len;
13: conn->shutdown();
14: }
15: else if (buf->readableBytes() >= len + kHeaderLen)
16: {
17: buf->retrieve(kHeaderLen);
18: muduo::string message(buf->peek(), len);
19: buf->retrieve(len);
20: messageCallback_(conn, message, receiveTime); // 收到完整的消息,通知用户
21: }
22: else
23: {
24: break;
25: }
26: }
27: }
上面这段代码第 7 行用了 while 循环来反复读取数据,直到 Buffer 中的数据不够一条完整的消息。请读者思考,如果换成 if (buf->readableBytes() >= kHeaderLen) 会有什么后果。
以前面提到的两条消息的字节流为例:
0x00, 0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o', 0x00, 0x00, 0x00, 0x08, 'c', 'h', 'e', 'n', 's', 'h', 'u', 'o'
假设数据最终都全部到达,onMessage() 至少要能正确处理以下各种数据到达的次序,每种情况下 messageCallback_ 都应该被调用两次:
- 每次收到一个字节的数据,onMessage() 被调用 21 次;
- 数据分两次到达,第一次收到 2 个字节,不足消息的长度字段;
- 数据分两次到达,第一次收到 4 个字节,刚好够长度字段,但是没有 body;
- 数据分两次到达,第一次收到 8 个字节,长度完整,但 body 不完整;
- 数据分两次到达,第一次收到 9 个字节,长度完整,body 也完整;
- 数据分两次到达,第一次收到 10 个字节,第一条消息的长度完整、body 也完整,第二条消息长度不完整;
- 请自行移动分割点,验证各种情况;
- 数据一次就全部到达,这时必须用 while 循环来读出两条消息,否则消息会堆积。
请读者验证 onMessage() 是否做到了以上几点。这个例子充分说明了 non-blocking read 必须和 input buffer 一起使用。
编解码器 LengthHeaderCodec
有人评论 Muduo 的接收缓冲区不能设置回调函数的触发条件,确实如此。每当 socket 可读,Muduo 的 TcpConnection 会读取数据并存入 Input Buffer,然后回调用户的函数。不过,一个简单的间接层就能解决问题,让用户代码只关心“消息到达”而不是“数据到达”,如本例中的 LengthHeaderCodec 所展示的那一样。
1: #ifndef MUDUO_EXAMPLES_ASIO_CHAT_CODEC_H
2: #define MUDUO_EXAMPLES_ASIO_CHAT_CODEC_H
3:
4: #include <muduo/base/Logging.h>
5: #include <muduo/net/Buffer.h>
6: #include <muduo/net/SocketsOps.h>
7: #include <muduo/net/TcpConnection.h>
8:
9: #include <boost/function.hpp>
10: #include <boost/noncopyable.hpp>
11:
12: using muduo::Logger;
13:
14: class LengthHeaderCodec : boost::noncopyable
15: {
16: public:
17: typedef boost::function<void (const muduo::net::TcpConnectionPtr&,
18: const muduo::string& message,
19: muduo::Timestamp)> StringMessageCallback;
20:
21: explicit LengthHeaderCodec(const StringMessageCallback& cb)
22: : messageCallback_(cb)
23: {
24: }
25:
26: void onMessage(const muduo::net::TcpConnectionPtr& conn,
27: muduo::net::Buffer* buf,
28: muduo::Timestamp receiveTime)
29: { 同上 }
30:
31: void send(muduo::net::TcpConnection* conn, const muduo::string& message)
32: { 同上 }
33:
34: private:
35: StringMessageCallback messageCallback_;
36: const static size_t kHeaderLen = sizeof(int32_t);
37: };
38:
39: #endif // MUDUO_EXAMPLES_ASIO_CHAT_CODEC_H
这段代码把以 Buffer* 为参数的 MessageCallback 转换成了以 const string& 为参数的 StringMessageCallback,让用户代码不必关心分包操作。客户端和服务端都能从中受益。
服务端的实现
聊天服务器的服务端代码小于 100 行,不到 asio 的一半。
请先阅读第 68 行起的数据成员的定义。除了经常见到的 EventLoop 和 TcpServer,ChatServer 还定义了 codec_ 和 std::set<TcpConnectionPtr> connections_ 作为成员,connections_ 是目前已建立的客户连接,在收到消息之后,服务器会遍历整个容器,把消息广播给其中每一个 TCP 连接。
首先,在构造函数里注册回调:
1: #include "codec.h"
2:
3: #include <muduo/base/Logging.h>
4: #include <muduo/base/Mutex.h>
5: #include <muduo/net/EventLoop.h>
6: #include <muduo/net/SocketsOps.h>
7: #include <muduo/net/TcpServer.h>
8:
9: #include <boost/bind.hpp>
10:
11: #include <set>
12: #include <stdio.h>
13:
14: using namespace muduo;
15: using namespace muduo::net;
16:
17: class ChatServer : boost::noncopyable
18: {
19: public:
20: ChatServer(EventLoop* loop,
21: const InetAddress& listenAddr)
22: : loop_(loop),
23: server_(loop, listenAddr, "ChatServer"),
24: codec_(boost::bind(&ChatServer::onStringMessage, this, _1, _2, _3))
25: {
26: server_.setConnectionCallback(
27: boost::bind(&ChatServer::onConnection, this, _1));
28: server_.setMessageCallback(
29: boost::bind(&LengthHeaderCodec::onMessage, &codec_, _1, _2, _3));
30: }
31:
32: void start()
33: {
34: server_.start();
35: }
36:
这里有几点值得注意,在以往的代码里是直接把本 class 的 onMessage() 注册给 server_;这里我们把 LengthHeaderCodec::onMessage() 注册给 server_,然后向 codec_ 注册了 ChatServer::onStringMessage(),等于说让 codec_ 负责解析消息,然后把完整的消息回调给 ChatServer。这正是我前面提到的“一个简单的间接层”,在不增加 Muduo 库的复杂度的前提下,提供了足够的灵活性让我们在用户代码里完成需要的工作。
另外,server_.start() 绝对不能在构造函数里调用,这么做将来会有线程安全的问题,见我在《当析构函数遇到多线程 ── C++ 中线程安全的对象回调》一文中的论述。
以下是处理连接的建立和断开的代码,注意它把新建的连接加入到 connections_ 容器中,把已断开的连接从容器中删除。这么做是为了避免内存和资源泄漏,TcpConnectionPtr 是 boost::shared_ptr<TcpConnection>,是 muduo 里唯一一个默认采用 shared_ptr 来管理生命期的对象。以后我们会谈到这么做的原因。
37: private:
38: void onConnection(const TcpConnectionPtr& conn)
39: {
40: LOG_INFO << conn->localAddress().toHostPort() << " -> "
41: << conn->peerAddress().toHostPort() << " is "
42: << (conn->connected() ? "UP" : "DOWN");
43:
44: MutexLockGuard lock(mutex_);
45: if (conn->connected())
46: {
47: connections_.insert(conn);
48: }
49: else
50: {
51: connections_.erase(conn);
52: }
53: }
54:
以下是服务端处理消息的代码,它遍历整个 connections_ 容器,把消息打包发送给各个客户连接。
55: void onStringMessage(const TcpConnectionPtr&,
56: const string& message,
57: Timestamp)
58: {
59: MutexLockGuard lock(mutex_);
60: for (ConnectionList::iterator it = connections_.begin();
61: it != connections_.end();
62: ++it)
63: {
64: codec_.send(get_pointer(*it), message);
65: }
66: }
67:
数据成员:
68: typedef std::set<TcpConnectionPtr> ConnectionList;
69: EventLoop* loop_;
70: TcpServer server_;
71: LengthHeaderCodec codec_;
72: MutexLock mutex_;
73: ConnectionList connections_;
74: };
75:
main() 函数里边是例行公事的代码:
76: int main(int argc, char* argv[])
77: {
78: LOG_INFO << "pid = " << getpid();
79: if (argc > 1)
80: {
81: EventLoop loop;
82: uint16_t port = static_cast<uint16_t>(atoi(argv[1]));
83: InetAddress serverAddr(port);
84: ChatServer server(&loop, serverAddr);
85: server.start();
86: loop.loop();
87: }
88: else
89: {
90: printf("Usage: %s port\n", argv[0]);
91: }
92: }
如果你读过 asio 的对应代码,会不会觉得 Reactor 往往比 Proactor 容易使用?
客户端的实现
我有时觉得服务端的程序常常比客户端的更容易写,聊天服务器再次验证了我的看法。客户端的复杂性来自于它要读取键盘输入,而 EventLoop 是独占线程的,所以我用了两个线程,main() 函数所在的线程负责读键盘,另外用一个 EventLoopThread 来处理网络 IO。我暂时没有把标准输入输出融入 Reactor 的想法,因为服务器程序的 stdin 和 stdout 往往是重定向了的。
来看代码,首先,在构造函数里注册回调,并使用了跟前面一样的 LengthHeaderCodec 作为中间层,负责打包分包。
1: #include "codec.h"
2:
3: #include <muduo/base/Logging.h>
4: #include <muduo/base/Mutex.h>
5: #include <muduo/net/EventLoopThread.h>
6: #include <muduo/net/TcpClient.h>
7:
8: #include <boost/bind.hpp>
9: #include <boost/noncopyable.hpp>
10:
11: #include <iostream>
12: #include <stdio.h>
13:
14: using namespace muduo;
15: using namespace muduo::net;
16:
17: class ChatClient : boost::noncopyable
18: {
19: public:
20: ChatClient(EventLoop* loop, const InetAddress& listenAddr)
21: : loop_(loop),
22: client_(loop, listenAddr, "ChatClient"),
23: codec_(boost::bind(&ChatClient::onStringMessage, this, _1, _2, _3))
24: {
25: client_.setConnectionCallback(
26: boost::bind(&ChatClient::onConnection, this, _1));
27: client_.setMessageCallback(
28: boost::bind(&LengthHeaderCodec::onMessage, &codec_, _1, _2, _3));
29: client_.enableRetry();
30: }
31:
32: void connect()
33: {
34: client_.connect();
35: }
36:
disconnect() 目前为空,客户端的连接由操作系统在进程终止时关闭。
37: void disconnect()
38: {
39: // client_.disconnect();
40: }
41:
write() 会由 main 线程调用,所以要加锁,这个锁不是为了保护 TcpConnection,而是保护 shared_ptr。
42: void write(const string& message)
43: {
44: MutexLockGuard lock(mutex_);
45: if (connection_)
46: {
47: codec_.send(get_pointer(connection_), message);
48: }
49: }
50:
onConnection() 会由 EventLoop 线程调用,所以要加锁以保护 shared_ptr。
51: private:
52: void onConnection(const TcpConnectionPtr& conn)
53: {
54: LOG_INFO << conn->localAddress().toHostPort() << " -> "
55: << conn->peerAddress().toHostPort() << " is "
56: << (conn->connected() ? "UP" : "DOWN");
57:
58: MutexLockGuard lock(mutex_);
59: if (conn->connected())
60: {
61: connection_ = conn;
62: }
63: else
64: {
65: connection_.reset();
66: }
67: }
68:
把收到的消息打印到屏幕,这个函数由 EventLoop 线程调用,但是不用加锁,因为 printf() 是线程安全的。
注意这里不能用 cout,它不是线程安全的。
69: void onStringMessage(const TcpConnectionPtr&,
70: const string& message,
71: Timestamp)
72: {
73: printf("<<< %s\n", message.c_str());
74: }
75:
数据成员:
76: EventLoop* loop_;
77: TcpClient client_;
78: LengthHeaderCodec codec_;
79: MutexLock mutex_;
80: TcpConnectionPtr connection_;
81: };
82:
main() 函数里除了例行公事,还要启动 EventLoop 线程和读取键盘输入。
83: int main(int argc, char* argv[])
84: {
85: LOG_INFO << "pid = " << getpid();
86: if (argc > 2)
87: {
88: EventLoopThread loopThread;
89: uint16_t port = static_cast<uint16_t>(atoi(argv[2]));
90: InetAddress serverAddr(argv[1], port);
91:
92: ChatClient client(loopThread.startLoop(), serverAddr); // 注册到 EventLoopThread 的 EventLoop 上。
93: client.connect();
94: std::string line;
95: while (std::getline(std::cin, line))
96: {
97: string message(line.c_str()); // 这里似乎多此一举,可直接发送 line。这里是
98: client.write(message);
99: }
100: client.disconnect();
101: }
102: else
103: {
104: printf("Usage: %s host_ip port\n", argv[0]);
105: }
106: }
107:
简单测试
开三个命令行窗口,在第一个运行
$ ./asio_chat_server 3000
第二个运行
$ ./asio_chat_client 127.0.0.1 3000
第三个运行同样的命令
$ ./asio_chat_client 127.0.0.1 3000
这样就有两个客户端进程参与聊天。在第二个窗口里输入一些字符并回车,字符会出现在本窗口和第三个窗口中。
下一篇文章我会介绍 Muduo 中的定时器,并实现 Boost.Asio 教程中的 timer2~5 示例,以及带流量统计功能的 discard 和 echo 服务器(来自 Java Netty)。流量等于单位时间内发送或接受的字节数,这要用到定时器功能。
(待续)
摘要: 这是《Muduo 网络编程示例》系列的第一篇文章。本文将介绍五个简单 TCP 网络服务协议的 muduo 实现,包括 echo、discard、chargen、daytime、time,以及 time 协议的客户端。以上五个协议使用不同的端口,可以放到同一个进程中实现,且不必使用多线程。
阅读全文
陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
我将会写一系列文章,介绍用 muduo 网络库完成常见的 TCP 网络编程任务。目前计划如下:
- UNP 中的简单协议,包括 echo、daytime、time、discard 等。
- Boost.Asio 中的示例,包括 timer2~6、chat 等。
- Java Netty 中的示例,包括 discard、echo、uptime 等,其中的 discard 和 echo 带流量统计功能。
- Python twisted 中的示例,包括 finger01~07
- 云风的串并转换连接服务器 multiplexer,包括单线程和多线程两个版本。
- 用于测试两台机器的往返延迟的 roundtrip
- 用于测试两台机器的带宽的 pingpong
- 文件传输
- 一个基于 TCP 的应用层广播 hub
- socks4a 代理服务器,包括简单的 TCP 中继(relay)。
- 一个 Sudoku 服务器的演变,从单线程到多线程,从阻塞到 event-based。
- 一个提供短址服务的 httpd 服务器
其中前面 7 个已经放到了 muduo 代码的 examples 目录中,下载地址是: http://muduo.googlecode.com/files/muduo-0.1.5-alpha.tar.gz
这些例子都比较简单,逻辑不复杂,代码也很短,适合摘取关键部分放到博客上。其中一些有一定的代表性与针对性,比如“如何传输完整的文件”估计是网络编程的初学者经常遇到的问题。请注意,muduo 是设计来开发内网的网络程序,它没有做任何安全方面的加强措施,如果用在公网上可能会受到攻击,在后面的例子中我会谈到这一点。
本系列文章适用于 Linux 2.6.x (x > 25),主要测试发行版为 Ubuntu 10.04 LTS 和 Debian 6.0 Squeeze,64-bit x86 硬件。
TCP 网络编程本质论
我认为,TCP 网络编程最本质的是处理三个半事件:
- 连接的建立,包括服务端接受 (accept) 新连接和客户端成功发起 (connect) 连接。
- 连接的断开,包括主动断开 (close 或 shutdown) 和被动断开 (read 返回 0)。
- 消息到达,文件描述符可读。这是最为重要的一个事件,对它的处理方式决定了网络编程的风格(阻塞还是非阻塞,如何处理分包,应用层的缓冲如何设计等等)。
- 消息发送完毕,这算半个。对于低流量的服务,可以不必关心这个事件;另外,这里“发送完毕”是指将数据写入操作系统的缓冲区,将由 TCP 协议栈负责数据的发送与重传,不代表对方已经收到数据。
这其中有很多难点,也有很多细节需要注意,比方说:
- 如果要主动关闭连接,如何保证对方已经收到全部数据?如果应用层有缓冲(这在非阻塞网络编程中是必须的,见下文),那么如何保证先发送完缓冲区中的数据,然后再断开连接。直接调用 close(2) 恐怕是不行的。
- 如果主动发起连接,但是对方主动拒绝,如何定期 (带 back-off) 重试?
- 非阻塞网络编程该用边沿触发(edge trigger)还是电平触发(level trigger)?(这两个中文术语有其他译法,我选择了一个电子工程师熟悉的说法。)如果是电平触发,那么什么时候关注 EPOLLOUT 事件?会不会造成 busy-loop?如果是边沿触发,如何防止漏读造成的饥饿?epoll 一定比 poll 快吗?
- 在非阻塞网络编程中,为什么要使用应用层缓冲区?假如一次读到的数据不够一个完整的数据包,那么这些已经读到的数据是不是应该先暂存在某个地方,等剩余的数据收到之后再一并处理?见 lighttpd 关于 \r\n\r\n 分包的 bug。假如数据是一个字节一个字节地到达,间隔 10ms,每个字节触发一次文件描述符可读 (readable) 事件,程序是否还能正常工作?lighttpd 在这个问题上出过安全漏洞。
- 在非阻塞网络编程中,如何设计并使用缓冲区?一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面,我们系统减少内存占用。如果有 10k 个连接,每个连接一建立就分配 64k 的读缓冲的话,将占用 640M 内存,而大多数时候这些缓冲区的使用率很低。muduo 用 readv 结合栈上空间巧妙地解决了这个问题。
- 如果使用发送缓冲区,万一接收方处理缓慢,数据会不会一直堆积在发送方,造成内存暴涨?如何做应用层的流量控制?
- 如何设计并实现定时器?并使之与网络 IO 共用一个线程,以避免锁。
这些问题在 muduo 的代码中可以找到答案。
Muduo 简介
我编写 Muduo 网络库的目的之一就是简化日常的 TCP 网络编程,让程序员能把精力集中在业务逻辑的实现上,而不要天天和 Sockets API 较劲。借用 Brooks 的话说,我希望 Muduo 能减少网络编程中的偶发复杂性 (accidental complexity)。
Muduo 只支持 Linux 2.6.x 下的并发非阻塞 TCP 网络编程,它的安装方法见陈硕的 blog 文章。
Muduo 的使用非常简单,不需要从指定的类派生,也不用覆写虚函数,只需要注册几个回调函数去处理前面提到的三个半事件就行了。
以经典的 echo 回显服务为例:
1. 定义 EchoServer class,不需要派生自任何基类:
1 #ifndef MUDUO_EXAMPLES_SIMPLE_ECHO_ECHO_H
2 #define MUDUO_EXAMPLES_SIMPLE_ECHO_ECHO_H
3
4 #include <muduo/net/TcpServer.h>
5
6 // RFC 862
7 class EchoServer
8 {
9 public:
10 EchoServer(muduo::net::EventLoop* loop,
11 const muduo::net::InetAddress& listenAddr);
12
13 void start();
14
15 private:
16 void onConnection(const muduo::net::TcpConnectionPtr& conn);
17
18 void onMessage(const muduo::net::TcpConnectionPtr& conn,
19 muduo::net::Buffer* buf,
20 muduo::Timestamp time);
21
22 muduo::net::EventLoop* loop_;
23 muduo::net::TcpServer server_;
24 };
25
26 #endif // MUDUO_EXAMPLES_SIMPLE_ECHO_ECHO_H
27
在构造函数里注册回调函数:
1 EchoServer::EchoServer(EventLoop* loop,
2 const InetAddress& listenAddr)
3 : loop_(loop),
4 server_(loop, listenAddr, "EchoServer")
5 {
6 server_.setConnectionCallback(
7 boost::bind(&EchoServer::onConnection, this, _1));
8 server_.setMessageCallback(
9 boost::bind(&EchoServer::onMessage, this, _1, _2, _3));
10 }
11
12 void EchoServer::start()
13 {
14 server_.start();
15 }
16
17
2. 实现 EchoServer::onConnection() 和 EchoServer::onMessage():
1 void EchoServer::onConnection(const TcpConnectionPtr& conn)
2 {
3 LOG_INFO << "EchoServer - " << conn->peerAddress().toHostPort() << " -> "
4 << conn->localAddress().toHostPort() << " is "
5 << (conn->connected() ? "UP" : "DOWN");
6 }
7
8 void EchoServer::onMessage(const TcpConnectionPtr& conn,
9 Buffer* buf,
10 Timestamp time)
11 {
12 string msg(buf->retrieveAsString());
13 LOG_INFO << conn->name() << " echo " << msg.size() << " bytes at " << time.toString();
14 conn->send(msg);
15 }
16
3. 在 main() 里用 EventLoop 让整个程序跑起来:
1 #include "echo.h"
2
3 #include <muduo/base/Logging.h>
4 #include <muduo/net/EventLoop.h>
5
6 using namespace muduo;
7 using namespace muduo::net;
8
9 int main()
10 {
11 LOG_INFO << "pid = " << getpid();
12 EventLoop loop;
13 InetAddress listenAddr(2007);
14 EchoServer server(&loop, listenAddr);
15 server.start();
16 loop.loop();
17 }
18
完整的代码见 muduo/examples/simple/echo。
这个几十行的小程序实现了一个并发的 echo 服务程序,可以同时处理多个连接。
对这个程序的详细分析见下一篇博客《Muduo 网络编程示例之一:五个简单 TCP 协议》
(待续)
前面我们比较了 muduo 和 libevent2 的吞吐量,得到的结论是 muduo 比 libevent2 快 18%。有人会说,libevent2 并不是为高吞吐的应用场景而设计的,这样的比较不公平,胜之不武。为了公平起见,这回我们用 libevent2 自带的性能测试程序(击鼓传花)来对比 muduo 和 libevent2 在高并发情况下的 IO 事件处理效率。
测试对象
测试环境
测试用的软硬件环境与《muduo 与 boost asio 吞吐量对比》和《muduo 与 libevent2 吞吐量对比》相同,另外我还在自己的笔记本上运行了测试,结果也附在后面。
测试内容
测试的场景是:有 1000 个人围成一圈,玩击鼓传花的游戏,一开始第 1 个人手里有花,他把花传给右手边的人,那个人再继续把花传给右手边的人,当花转手 100 次之后游戏停止,记录从开始到结束的时间。
用程序表达是,有 1000 个网络连接 (socketpairs 或 pipes),数据在这些连接中顺次传递,一开始往第 1 个连接里写 1 个字节,然后从这个连接的另一头读出这 1 个字节,再写入第 2 个连接,然后读出来继续写到第 3 个连接,直到一共写了 100 次之后程序停止,记录所用的时间。
以上是只有一个活动连接的场景,我们实际测试的是 100 个或 1000 个活动连接(即 100 朵花或 1000 朵花,均匀分散在人群手中),而连接总数(即并发数)从 100 到 100,000 (十万)。注意每个连接是两个文件描述符,为了运行测试,需要调高每个进程能打开的文件数,比如设为 256000。
libevent2 的测试代码位于 test/bench.c,我修复了 2.0.6-rc 版里的一个小 bug,修正后的代码见 http://github.com/chenshuo/recipes/blob/master/pingpong/libevent/bench.c
muduo 的测试代码位于 examples/pingpong/bench.cc,见 http://gist.github.com/564985#file_pingpong_bench.cc
测试结果与讨论
第一轮,分别用 100 个活动连接和 1000 个活动连接,无超时,读写 100 次,测试一次游戏的总时间(包含初始化)和事件处理的时间(不包含注册 event watcher)随连接数(并发数)变化的情况。具体解释见 libev 的性能测试文档 http://libev.schmorp.de/bench.html ,不同之处在于我们不比较 timer event 的性能,只比较 IO event 的性能。对每个并发数,程序循环 25 次,刨去第一次的热身数据,后 24 次算平均值。测试用的脚本在 http://github.com/chenshuo/recipes/blob/master/pingpong/libevent/run_bench.sh 。这个脚本是 libev 的作者 Marc Lehmann 写的,我略作改用,用于测试 muduo 和 libevent2。
第一轮的结果,请先只看红线和绿线。红线是 libevent2 用的时间,绿线是 muduo 用的时间。数字越小越好。注意这个图的横坐标是对数的,每一个数量级的取值点为 1, 2, 3, 4, 5, 6, 7.5, 10。
从红绿线对比可以看出:
1. libevent2 在初始化 event watcher 上面比 muduo 快 20% (左边的两个图)
2. 在事件处理方面(右边的两个图):a) 在 100 个活动连接的情况下,libevent2 和 muduo 分段领先。当总连接数(并发数)小于 1000 时,二者性能差不多;当总连接数大于 30000 时,muduo 略占优;当总连接数大于 1000 小于 30000 时,libevent2 明显领先。b) 在 1000 个活动连接的情况下,当并发数小于 10000 时,libevent2 和 muduo 得分接近;当并发数大于 10000 时,muduo 明显占优。
这里我们有两个问题:1. 为什么 muduo 花在初始化上的时间比较多? 2. 为什么在一些情况下它比 libevent2 慢很多。
我仔细分析了其中的原因,并参考了 libev 的作者 Marc Lehmann 的观点 ( http://lists.schmorp.de/pipermail/libev/2010q2/001041.html ),结论是:在第一轮初始化时,libevent2 和 muduo 都是用 epoll_ctl(fd, EPOLL_CTL_ADD, …) 来添加 fd event watcher。不同之处在于,在后面 24 轮中,muduo 使用了 epoll_ctl(fd, EPOLL_CTL_MOD, …) 来更新已有的 event watcher;然而 libevent2 继续调用 epoll_ctl(fd, EPOLL_CTL_ADD, …) 来重复添加 fd,并忽略返回的错误码 EEXIST (File exists)。在这种重复添加的情况下,EPOLL_CTL_ADD 将会快速地返回错误,而 EPOLL_CTL_MOD 会做更多的工作,花的时间也更长。于是 libevent2 捡了个便宜。
为了验证这个结论,我改动了 muduo,让它每次都用 EPOLL_CTL_ADD 方式初始化和更新 event watcher,并忽略返回的错误。
第二轮测试结果见上图的蓝线,可见改动之后的 muduo 的初始化性能比 libevent2 更好,事件处理的耗时也有所降低(我推测是 kernel 内部的原因)。
这个改动只是为了验证想法,我并没有把它放到 muduo 最终的代码中去,这或许可以留作日后优化的余地。(具体的改动是 muduo/net/poller/EPollPoller.cc 第 115 行和 144 行,读者可自行验证。)
同样的测试在双核笔记本电脑上运行了一次,结果如下:(我的笔记本的 CPU 主频是 2.4GHz,高于台式机的 1.86GHz,所以用时较少。)
结论:在事件处理效率方面,muduo 与 libevent2 总体比较接近,各擅胜场。在并发量特别大的情况下(大于 10k),muduo 略微占优。
关于 muduo 的更多介绍请见《发布一个基于 Reactor 模式的 C++ 网络库》。muduo 的项目网站是 http://code.google.com/p/muduo ,上面有个 class diagram 可供参考。