陈硕的Blog

Muduo 网络编程示例之七:“串并转换”连接服务器及其自动化测试

Muduo 网络编程示例之七:连接服务器及其自动化测试

陈硕 (giantchen_AT_gmail)

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

这是《Muduo 网络编程示例》系列的第七篇文章。

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

本文介绍如何使用 test harness 来测试一个具有内部逻辑的网络服务程序。

本文的代码见 http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer

下载地址:http://muduo.googlecode.com/files/muduo-0.2.0-alpha.tar.gz SHA1 checksum: 75a09a82f96b583004876e95105c679e64c95715

 

云风在他的博客中提到了网游连接服务器的功能需求(搜“练手项目”),我用 C++ 初步实现了这些需求,并为之编写了配套的自动化 test harness,作为 muduo 网络库的示例。

注意:本文呈现的代码仅仅实现了基本的功能需求,没有考虑安全性,也没有特别优化性能,不适合用作真正的放在公网上运行的网游连接服务器。

功能需求

这个连接服务器把多个客户连接汇聚为一个内部 TCP 连接,起到“数据串并转换”的作用,让 backend 的逻辑服务器专心处理业务,而无需顾及多连接的并发性。以下是系统的框图:

multiplexer

这个连接服务器的作用与数字电路中的数据选择器 (multiplexer) 类似,所以我把它命名为 multiplexer。(其实 IO-Multiplexing 也是取的这个意思,让一个 thread-of-control 能有选择地处理多个 IO 文件描述符。)

mux

(上图取自 wikipedia,是 public domain 版权)

实现

Multiplexer 的功能需求不复杂,无非是在 backend connection 和 client connections 之间倒腾数据。具体来说,主要是处理四种事件:

由上可见,multiplexer 的功能与 proxy 颇为类似。multiplexer_simple.cc 是一个线程版的实现,借助 muduo 的 io-multiplexing 特性,可以方便地处理多个并发连接。

在实现的时候有两点值得注意:

  • TcpConnection 的 id 如何存放?当从 backend 收到数据,如何根据 id 找到对应的 client connection?当从 client connection 收到数据,如何得知其 id ?

第一个问题比较好解决,用 std::map〈int, TcpConnectionPtr〉 clientConns_; 保存从 id 到 client connection 的映射就行。

第二个问题固然可以用类似的办法解决,但是我想借此介绍一下 muduo::net::TcpConnection 的 context 功能。每个 TcpConnection 都有一个 boost::any 成员,可由客户代码自由支配(get/set),代码如下。这个 boost::any 是 TcpConnection 的 context,可以用于保存与 connection 绑定的任意数据(比方说 connection id、connection 的最后数据到达时间、connection 所代表的用户的名字等等)。这样客户代码不必继承 TcpConnection 就能 attach 自己的状态,而且也用不着 TcpConnectionFactory 了(如果允许继承,那么必然要向 TcpServer 注入此 factory)。

class TcpConnection : public boost::enable_shared_from_this<TcpConnection>,
                      boost::noncopyable
{
 public:

  void setContext(const boost::any& context)
  { context_ = context; }

  boost::any& getContext()
  { return context_; }

  const boost::any& getContext() const
  { return context_; }

  // ...

 private:
  // ...
  boost::any context_;
};

typedef boost::shared_ptr<TcpConnection> TcpConnectionPtr;

对于 Multiplexer,在 onClientConnection() 里调用 conn->setContext(id),把 id 存到 TcpConnection 对象中。onClientMessage() 从 TcpConnection 对象中取得 id,连同数据一起发送给 backend,完整实现如下:

  void onClientMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp)
  {
    if (!conn->getContext().empty())
    {
      int id = boost::any_cast<int>(conn->getContext());
      sendBackendBuffer(id, buf);
    }
    else
    {
      buf->retrieveAll();
    }
  }
  • TcpConnection 的生命期如何管理?由于 Client Connection 是动态创建并销毁,其生与灭完全由客户决定,如何保证 backend 想向它发送数据的时候,这个 TcpConnection 对象还活着?解决思路是用 reference counting,当然,不用自己写,用 boost::shared_ptr 即可。TcpConnection 是 muduo 中唯一默认采用 shared_ptr 来管理生命期的对象,盖由其动态生命期的本质决定。更多内容请参考陈硕《当析构函数遇到多线程──C++ 中线程安全的对象回调

multiplexer 是二进制协议,如何测试呢?

自动化测试

Multiplexer 是 muduo 网络编程示例中第一个具有 non-trivial 业务逻辑的网络程序,根据陈硕《分布式程序的自动化回归测试》一文的思想,我为它编写了 test harness。代码见 http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer

这个 Test harness 采用 Java 编写,用的是 Netty 库。这个 test harness 要扮演 clients 和 backend,也就是既要主动发起连接,也要被动接受连接。结构如下:

harness

Test harness 会把各种 event 汇聚到一个 blocking queue 里边,方便编写 test case。Test case 则操纵 test harness,发起连接、发送数据、检查收到的数据,例如以下是其中一个 test case

http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer/testcase/TestOneClientSend.java

这里的几个 test cases 都以用 java 直接写的,如果有必要,也可以采用 Groovy 来编写,这样可以在不重启 test harness 的情况下随时修改添加 test cases。具体做法见陈硕《“过家家”版的移动离线计费系统实现》。

将来的改进

有了这个自动化的 test harness,我们可以比较方便且安全地修改(甚至重新设计)multiplexer。例如
  • 增加“backend 发送指令断开 client connection”的功能。有了自动化测试,这个新功能可以被单独测试(指开发者测试),而不需要真正的 backend 参与进来。
  • 将 Multiplexer 改用多线程重写。有了自动化回归测试,我们不用担心破坏原有的功能,可以放心大胆地重写。而且由于 test harness 是从外部测试,不是单元测试,重写 multiplexer 的时候不用动 test cases,这样保证了测试的稳定性。另外,这个 test harness 稍作改进还可以进行 stress testing,既可用于验证多线程 multiplexer 的正确性,亦可对比其相对单线程版的效率提升。

posted @ 2011-05-02 19:47 陈硕 阅读(2385) | 评论 (0)编辑 收藏

Muduo 网络编程示例之六:限制服务器的最大并发连接数

Muduo 网络编程示例之六:限制服务器的最大并发连接数

陈硕 (giantchen_AT_gmail)

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

这是《Muduo 网络编程示例》系列的第六篇文章。

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

 

本文已以大家都熟悉的 EchoServer 介绍如何限制服务器的并发连接数。

本文的代码见 http://code.google.com/p/muduo/source/browse/trunk/examples/maxconnection/

《Muduo 网络编程示例 系列》计划中的第六篇文章原本是“用于测试两台机器的带宽的 pingpong 程序”,pingpong 协议的程序已经在《muduo 与 boost asio 吞吐量对比》和《muduo 与 libevent2 吞吐量对比》两篇文章中介绍过了,所以我改为写另外一个有点意思的主题。

这篇文章中的“并发连接数”是指一个 server program 能同时支持的客户端连接数,连接系由客户端主动发起,服务端被动接受(accept)连接。(如果要限制应用程序主动发起的连接,则问题要简单得多,毕竟主动权和决定权都在程序本身。)

为什么要限制并发连接数?

一方面,我们不希望服务程序超载,另一方面,更因为 file descriptor 是稀缺资源,如果出现 file descriptor 耗尽,很棘手(跟 “malloc 失败/new() 抛出 std::bad_alloc”差不多同样棘手)。

我在《分布式系统的工程化开发方法》一文中曾谈到 libev 作者建议的一种应对“accept()ing 时 file descriptor 耗尽”的办法。

 

幻灯片35

幻灯片36

Muduo 的 acceptor 正是这么实现的,但是,这个做法在多线程下不能保证正确,会有 race condition。(思考题:是什么 race condition?)

其实有另外一种比较简单的办法:file descriptor 是 hard limit,我们可以自己设一个稍低一点的 soft limit,如果超过 soft limit 就主动关闭新连接,这样就避免触及“file descriptor 耗尽”这种边界条件。比方说当前进程的 max file descriptor 是 1024,那么我们可以在连接数达到 1000 的时候进入“拒绝新连接”状态,这样留给我们足够的腾挪空间。

 

Muduo 中限制并发连接数


Muduo 中限制并发连接数的做法简单得出奇。以在《Muduo 网络编程示例之零:前言》中出场过的 EchoServer 为例,只需要为它增加一个 int 成员,表示当前的活动连接数。(如果是多线程程序,应该用 muduo::AtomicInt32。)

class EchoServer
{
 public:
  EchoServer(muduo::net::EventLoop* loop,
             const muduo::net::InetAddress& listenAddr,
             int maxConnections);

  void start();

 private:
  void onConnection(const muduo::net::TcpConnectionPtr& conn);

  void onMessage(const muduo::net::TcpConnectionPtr& conn,
                 muduo::net::Buffer* buf,
                 muduo::Timestamp time);

  muduo::net::EventLoop* loop_;
  muduo::net::TcpServer server_;
  int numConnected_; // should be atomic_int
  const int kMaxConnections;
};

然后,在 EchoServer::onConnection() 中判断当前活动连接数,如果超过最大允许数,则踢掉连接。

void EchoServer::onConnection(const TcpConnectionPtr& conn)
{
  LOG_INFO << "EchoServer - " << conn->peerAddress().toHostPort() << " -> "
    << conn->localAddress().toHostPort() << " is "
    << (conn->connected() ? "UP" : "DOWN");

  if (conn->connected())
  {
    ++numConnected_;
    if (numConnected_ > kMaxConnections)
    {
      conn->shutdown();
    }
  }
  else
  {
    --numConnected_;
  }
  LOG_INFO << "numConnected = " << numConnected_;
}

这种做法可以积极地防止耗尽 file descriptor。

另外,如果是有业务逻辑的服务,可以在 shutdown() 之前发送一个简单的响应,表明本服务程序的负载能力已经饱和,提示客户端尝试下一个可用的 server(当然,下一个可用的 server 地址不一定要在这个响应里给出,客户端可以自己去 name service 查询),这样方便客户端快速 failover。

 

后文将介绍如何处理空闲连接的超时:如果一个连接长时间(若干秒)没有输入数据,则踢掉此连接。办法有很多种,我用 Time Wheel 解决。

posted @ 2011-04-27 00:03 陈硕 阅读(4812) | 评论 (9)编辑 收藏

分布式程序的自动化回归测试

陈硕 (giantchen_AT_gmail)

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

 

陈硕关于分布式系统的系列文章:http://blog.csdn.net/Solstice/category/802325.aspx

 

本作品采用“Creative Commons 署名-非商业性使用-禁止演绎 3.0 Unported 许可协议(cc by-nc-nd)”进行许可。
http://creativecommons.org/licenses/by-nc-nd/3.0/

 

本文所谈的“测试”全部指的是“开发者测试/developer testing”,由程序员自己来做,不是由 QA 团队进行的系统测试。这两种测试各有各的用途,不能相互替代。

我在《朴实的C++设计》一文中谈到“为了确保正确性,我们另外用Java写了一个测试夹具(test harness)来测试我们这个C++程序。这个测试夹具模拟了所有与我们这个C++程序打交道的其他程序,能够测试各种正常或异常的情况。基本上任何代码改动和bug修复都在这个夹具中有体现。如果要新加一个功能,会有对应的测试用例来验证其行为。如果发现了一个bug,先往夹具里加一个或几个能复现bug的测试用例,然后修复代码,让测试通过。我们积累了几百个测试用例,这些用例表示了我们对程序行为的预期,是一份可以运行的文档。每次代码改动提交之前,我们都会执行一遍测试,以防低级错误发生。

今天把 test harness 这个做法仔细说一说。

自动化测试的必要性

我想自动化测试的必要性无需赘言,自动化测试是 absolutely good stuff。

基本上,要是没有自动化的测试,我是不敢改产品代码的(“改”包括添加新功能和重构)。自动化测试的作用是把程序已经实现的 features 以 test case 的形式固化下来,将来任何代码改动如果破坏了现有的功能需求就会触发测试 failure。好比 DNA 双链的互补关系,这种互补结构对保持生物遗传的稳定有重要作用。类似的,自动化测试与被测程序的互补结构对保持系统的功能稳定有重要作用。

单元测试的能与不能

一提到自动化测试,我猜很多人想到的是单元测试(unit testing)。单元测试确实有很大的用处,对于解决某一类型的问题很有帮助。粗略地说,单元测试主要用于测试一个函数、一个 class 或者相关的几个 classes。

最典型的是测试纯函数,比如计算个人所得税的函数,输出是“起征点、扣除五险一金之后的应纳税所得额、税率表”,输出是应该缴的个税。又比如我在《〈程序中的日期与时间〉第一章 日期计算》中用单元测试来验证 Julian day number 算法的正确性。再比如我在《“过家家”版的移动离线计费系统实现》和《模拟银行窗口排队叫号系统的运作》中用单元测试来检查程序运行的结果是否符合预期。(最后这个或许不是严格意义上的单元测试,更像是验收测试。)

为了能用单元测试,主代码有时候需要做一些改动。这对 Java 通常不构成问题(反正都编译成 jar 文件,在运行的时候指定 entry point)。对于 C++,一个程序只能有一个 main() 入口点,要采用单元测试的话,需要把功能代码(被测对象)做成一个 library,然后让单元测试代码(包含 main() 函数)link 到这个 library 上;当然,为了正常启动程序,我们还需要写一个普通的 main(),并 link 到这个 library 上。

单元测试的缺点

根据我的个人经验,我发现单元测试有以下缺点。

  • 阻碍大型重构

单元测试是白盒测试,测试代码直接调用被测代码,测试代码与被测代码紧耦合。从理论上说,“测试”应该只关心被测代码实现的功能,不用管它是如何实现的(包括它提供什么样的函数调用接口)。比方说,以前面的个税计算器函数为例,作为使用者,我们只关心它算的结果是否正确。但是,如果要写单元测试,测试代码必须调用被测代码,那么测试代码必须要知道个税计算器的 package、class、method name、parameter list、return type 等等信息,还要知道如何构造这个 class。以上任何一点改动都会造成测试失败(编译就不通过)。

在添加新功能的时候,我们常会重构已有的代码,在保持原有功能的情况下让代码的“形状”更适合实现新的需求。一旦修改原有的代码,单元测试就可能编译不过:比如给成员函数或构造函数添加一个参数,或者把成员函数从一个 class 移到另一个 class。对于 Java,这个问题还比较好解决,因为 IDE 的重构功能很强,能自动找到 references,并修改之。

对于 C++,这个问题更为严重,因为一改功能代码的接口,单元测试就编译不过了,而 C++ 通常没有自动重构工具(语法太复杂,语意太微妙)可以帮我们,都得手动来。要么每改动一点功能代码就修复单元测试,让编译通过;要么留着单元测试编译不通过,先把功能代码改成我们想要的样子,再来统一修复单元测试。

这两种做法都有困难,前者,C++ 编译缓慢,如果每改动一点就修复单元测试,一天下来也前进不了几步,很多时间浪费在等待编译上;后者,问题更严重,单元测试与被测代码的互补性是保证程序功能稳定的关键,如果大幅修改功能代码的同时又大幅修改了单元测试,那么如何保证前后的单元测试的效果(测试点)不变?如果单元测试自身的代码发生了改动,如何保证它测试结果的有效性?会不会某个手误让功能代码和单元测试犯了相同的错误,负负得正,测试还是绿的,但是实际功能已经亮了红灯?难道我们要为单元测试写单元测试吗?

有时候,我们需要重新设计并重写某个程序(有可能换用另一种语言)。这时候旧代码中的单元测试完全作废了(代码结构发生巨大改变,甚至连编程语言都换了),其中包含的宝贵的业务知识也付之东流,岂不可惜?

  • 为了方便测试而施行依赖注入,破坏代码的整体性

为了让代码具有“可测试性”,我们常会使用依赖注入技术,这么做的好处据说是“解耦”(其实,有人一句话道破真相但凡你在某个地方切断联系,那么你必然会在另一个地方重新产生联系),坏处就是割裂了代码的逻辑:单看一块代码不知道它是干嘛的,它依赖的对象不知道在哪儿创建的,如果一个 interface 有多个实现,不到运行的时候不知道用的是哪个实现。(动态绑定的初衷就是如此,想来读过“以面向对象思想实现”的代码的人都明白我在说什么。)

以《Muduo 网络编程示例之二:Boost.Asio 的聊天服务器》中出现的聊天服务器 ChatServer 为例,ChatServer 直接使用了 muduo::net::TcpServer 和 muduo::net::TcpConnection 来处理网络连接并收发数据,这个设计简单直接。如果要为 ChatServer 写单元测试,那么首先它肯定不能在构造函数里初始化 TcpServer 了。

稍微复杂一点的测试要用 mock object。ChatServer 用 TcpServer 和 TcpConenction 来收发消息,为了能单元测试,我们要为 TcpServer 和 TcpConnection 提供 mock 实现,原本一个具体类 TcpServer 就变成了一个 interface TcpServer 加两个实现 TcpServerImpl 和 TcpServerMock,同理 TcpConnection 也一化为三。ChatServer 本身的代码也变得复杂,我们要设法把 TcpServer 和 TcpConnection 注入到其中,ChatServer 不能自己初始化 TcpServer 对象。

这恐怕是在 C++ 中使用单元测试的主要困难之一。Java 有动态代理,还可以用 cglib 来操作字节码以实现注入。而 C++ 比较原始,只能自己手工实现 interface 和 implementations。这样原本紧凑的以 concrete class 构成的代码结构因为单元测试的需要而变得松散(所谓“面向接口编程”嘛),而这么做的目的仅仅是为了满足“源码级的可测试性”,是不是有一点因小失大呢?(这里且暂时忽略虚函数和普通函数在性能上的些微差别。)对于不同的 test case,可能还需要不同的 mock 对象,比如 TcpServerMock 和 TcpServerFailureMock,这又增加了编码的工作量。

此外,如果程序中用到的涉及 IO 的第三方库没有以 interface 方式暴露接口,而是直接提供的 concrete class (这是对的,因为C++中应该《避免使用虚函数作为库的接口》),这也让编写单元变得困难,因为总不能自己挨个 wrapper 一遍吧?难道用 link-time 的注入技术?

  • 某些 failure 场景难以测试,而考察这些场景对编写稳定的分布式系统有重要作用。比方说:网络连不上、数据库超时、系统资源不足。
  • 对多线程程序无能为力。如果一个程序的功能涉及多个线程合作,那么就比较难用单元测试来验证其正确性。
  • 如果程序涉及比较多的交互(指和其他程序交互,不是指图形用户界面),用单元测试来构造测试场景比较麻烦,每个场景要写一堆无趣的代码。而这正是分布式系统最需要测试的地方。

总的来说,单元测试是一个值得掌握的技术,用在适当的地方确实能提高生产力。同时,在分布式系统中,我们还需要其他的自动化测试手段。

分布式系统测试的要点

在分布式系统中,class 与 function 级别的单元测试对整个系统的帮助不大,当然,这种单元测试对单个程序的质量有帮助;但是,一堆砖头垒在一起是变不成大楼的。

分布式系统测试的要点是测试进程间的交互:一个进程收到客户请求,该如何处理,然后转发给其他进程;收到响应之后,又修改并应答客户。测试这些多进程协作的场景才算测到了点子上。

假设一个分布式系统由四五种进程组成,每个程序有各自的开发人员。对于整个系统,我们可以用脚本来模拟客户,自动化地测试系统的整体运作情况,这种测试通常由 QA 团队来执行,也可以作为系统的冒烟测试。

对于其中每个程序的开发人员,上述测试方法对日常的开发帮助不大,因为测试要能通过必须整个系统都正常运转才行,在开发阶段,这一点不是时时刻刻都能满足(有可能你用到的新功能对方还没有实现,这反过来影响了你的进度)。另一方面,如果出现测试失败,开发人员不能立刻知道这是自己的程序出错,有可能是环境原因造成的错误,这通常要去读程序日志才能判定。还有,作为开发者测试,我们希望它无副作用,每天反复多次运行也不会增加整个环境的负担,以整个 QA 系统为测试平台不可避免要留下一些垃圾数据,而清理这些数据又会花一些宝贵的工作时间。(你得判断数据是自己的测试生成的还是别人的测试留下的,不能误删了别人的测试数据。)

作为开发人员,我们需要一种单独针对自己编写的那个程序的自动化测试方案,一方面提高日常开发的效率,另一方面作为自己那个程序的功能验证测试集(即回归测试/regression tests)。

 

分布式系统的抽象观点

 

一台机器两根线

形象地来看,一个分布式系统就是一堆机器,每台机器的屁股上拖着两根线:电源线网线(不考虑 SAN 等存储设备),电源线插到电源插座上,网线插到交换机上。

 

harness_net_power

这个模型实际上说明,一台机器的表现出来的行为完全由它接出来的两根线展现,今天不谈电源线,只谈网线。(“在乎服务器的功耗”在我看来就是公司利润率很低的标志,要从电费上抠成本。)

如果网络是普通的千兆以太网,那么吞吐量不大于 125MB/s。这个吞吐量比起现在的 CPU 运算速度和内存带宽简直小得可怜。这里我想提的是,对于不特别在意 latency 的应用,只要能让千兆以太网的吞吐量饱和或接近饱和,用什么编程语言其实无所谓。Java 做网络服务端开发也是很好的选择(不是指 web 开发,而是做一些基础的分布式组件,例如 ZooKeeper 和 Hadoop 之类)。尽管可能 C++ 只用了 15% 的 CPU,而 Java 用了 30% 的 CPU,Java 还占用更多的内存,但是千兆网卡带宽都已经跑满,那些省下在资源也只能浪费了;对于外界(从网线上看过来)而言,两种语言的效果是一样的,而通常 Java 的开发效率更高。(Java 是比 C++ 慢一些,但是透过千兆网络不一定还能看得出这个区别来。)

进程间通过 TCP 相互连接

陈硕在《多线程服务器的常用编程模型》第 5 节“进程间通信”中提倡仅使用 TCP 作为进程间通信的手段,今天这个观点将再次得到验证。

以下是 Hadoop 的分布式文件系统 HDFS 的架构简图。

harness_system

HDFS 有四个角色参与其中,NameNode(保存元数据)、DataNode(存储节点,多个)、Secondary NameNode(定期写 check point)、Client(客户,系统的使用者)。这些进程运行在多台机器上,之间通过 TCP 协议互联。程序的行为完全由它在 TCP 连接上的表现决定(TCP 就好比前面提到的“网线”)。

在这个系统中,一个程序其实不知道与自己打交道的到底是什么。比如,对于 DataNode,它其实不在乎自己连接的是真的 NameNode 还是某个调皮的小孩用 Telnet 模拟的 NameNode,它只管接受命令并执行。对于 NameNode,它其实也不知道 DataNode 是不是真的把用户数据存到磁盘上去了,它只需要根据 DataNode 的反馈更新自己的元数据就行。这已经为我们指明了方向。

一种自动化的回归测试方案

假如我是 NameNode 的开发者,为了能自动化测试 NameNode,我可以为它写一个 test harness (这是一个独立的进程),这个 test harness 仿冒(mock)了与被测进程打交道的全部程序。如下图所示,是不是有点像“缸中之脑”?

harness_namenode

对于 DataNode 的开发者,他们也可以写一个专门的 test harness,模拟 Client 和 NameNode。

harness_datanode

Test harness 的优点

  • 完全从外部观察被测程序,对被测程序没有侵入性,代码该怎么写就怎么写,不需要为测试留路。
  • 能测试真实环境下的表现,程序不是单独为测试编译的版本,而是将来真实运行的版本。数据也是从网络上读取,发送到网络上。
  • 允许被测程序做大的重构,以优化内部代码结构,只要其表现出来的行为不变,测试就不会失败。(在重构期间不用修改 test case。)
  • 能比较方便地测试 failure 场景。比如,若要测试 DataNode 出错时 NameNode 的反应,只要让 test harness 模拟的那个 mock DataNode 返回我们想要的出错信息。要测试 NameNode 在某个 DataNode 失效之后的反应,只要让 test harness 断开对应的网络连接即可。要测量某请求超时的反应,只要让 Test harness 不返回结果即可。这对构建可靠的分布式系统尤为重要。
  • 帮助开发人员从使用者的角度理解程序,程序的哪些行为在外部是看得到的,哪些行为是看不到的。
  • 有了一套比较完整的 test cases 之后,甚至可以换种语言重写被测程序(假设为了提高内存利用率,换用 C++ 来重新实现 NameNode),测试用例依旧可用。这时 test harness 起到知识传承的作用。
  • 发现 bug 之后,往 test harness 里添加能复现 bug 的 test case,修复 bug 之后,test case 继续留在 harness 中,反正出现回归(regression)。

实现要点

  • Test harness 的要点在于隔断被测程序与其他程序的联系,它冒充了全部其他程序。这样被测程序就像被放到测试台上观察一样,让我们只关注它一个。
  • Test harness 要能发起或接受多个 TCP 连接,可能需要用某个现成的 NIO 网络库,如果不想写成多线程程序的话。
  • Test harness 可以与被测程序运行在同一台机器,也可以运行在两台机器上。在运行被测程序的时候,可能要用一个特殊的启动脚本把它依赖的 host:port 指向 test harness。
  • Test harness 只需要表现得跟它要 mock 的程序一样,不需要真的去实现复杂的逻辑。比如 mock DataNode 只需要对 NameNode 返回“Yes sir, 数据已存好”,而不需要真的把数据存到硬盘上。若要 mock 比较复杂的逻辑,可以用“记录+回放”的方式,把预设的响应放到 test case 里回放(replay)给被测程序。
  • 因为通信走 TCP 协议,test harness 不一定要和被测程序用相同的语言,只要符合协议就行。试想如果用共享内存实现 IPC,这是不可能的。陈硕在《在 muduo 中实现 protobuf 编解码器与消息分发器》中提到利用 protobuf 的跨语言特性,我们可以采用 Java 为 C++ 服务程序编写 test harness。其他跨语言的协议格式也行,比如 XML 或 Json。
  • Test harness 运行起来之后,等待被测程序的连接,或者主动连接被测程序,或者兼而有之,取决于所用的通信方式。
  • 一切就绪之后,Test harness 依次执行 test cases。一个 NameNode test case 的典型过程是:test harness 模仿 client 向被测 NameNode 发送一个请求(eg. 创建文件),NameNode 可能会联络 mock DataNode,test harness 模仿 DataNode 应有的响应,NameNode 收到 mock DataNode 的反馈之后发送响应给 client,这时 test harness 检查响应是否符合预期。
  • Test harness 中的 test cases 以配置文件(每个 test case 有一个或多个文本配置文件,每个 test case 占一个目录)方式指定。test harness 和 test cases 连同程序代码一起用 version controlling 工具管理起来。这样能复现以外任何一个版本的应有行为。
  • 对于比较复杂的 test case,可以用嵌入式脚本语言来描述场景。如果 test harness 是 Java 写的,那么可以嵌入 Groovy,就像陈硕在《“过家家”版的移动离线计费系统实现》中用 Groovy 实现计费逻辑一样。Groovy 调用 test harness 模拟多个程序分别发送多份数据并验证结果,groovy 本身就是程序代码,可以有逻辑判断甚至循环。这种动静结合的做法在不增加 test harness 复杂度的情况下提供了相当高的灵活性。
  • Test harness 可以有一个命令行界面,程序员输入“run 10”就选择执行第 10 号 test case。

几个实例

Test harness 这种测试方法适合测试有状态的、与多个进程通信的分布式程序,除了 Hadoop 中的 NameNode 与 DataNode,我还能想到几个例子。

1. chat 聊天服务器

聊天服务器会与多个客户端打交道,我们可以用 test harness 模拟 5 个客户端,模拟用户上下线,发送消息等情况,自动检测聊天服务器的工作情况。

2. 连接服务器、登录服务器、逻辑服务器

这是云风在他的 blog 中提到的三种网游服务器(http://blog.codingnow.com/2007/02/user_authenticate.htmlhttp://blog.codingnow.com/2006/04/iocp_kqueue_epoll.htmlhttp://blog.codingnow.com/2010/11/go_prime.html),我这里借用来举例子。

如果要为连接服务器写 test harness,那么需要模拟客户(发起连接)、登录服务器(验证客户资料)、逻辑服务器(收发网游数据),有了这样的 test harness,可以方便地测试连接服务器的正确性,也可以方便地模拟其他各个服务器断开连接的情况,看看连接服务器是否应对自如。

同样的思路,可以为登录服务器写 test harness。(我估计不用为逻辑服务器再写了,因为肯定已经有自动测试了。)

3. 多 master 之间的二段提交

这是分布式容错的一个经典做法。用 test harness 能把 primary master  和 secondary masters 单独拎出来测试。在测试 primary master 的时候,test harness 扮演 name service 和 secondary masters。在测试 secondary master 的时候,test harness 扮演 name service、primary master、其他 secondary masters。可以比较容易地测试各种 failure 情况。如果不这么做,而直接部署多个 masters 来测试,恐怕很难做到自动化测试。

4. paxos 的实现

Paxos 协议的实现肯定离不了单元测试,因为涉及多个角色中比较复杂的状态变迁。同时,如果我要写 paxos 实现,那么 test harness 也是少不了的,它能自动测试 paxos 节点在真实网络环境下的表现,并且轻易模拟各种 failure 场景。

局限性

如果被测程序有 TCP 之外的 IO,或者其 TCP 协议不易模拟(比如通过 TCP 连接数据库),那么这种测试方案会受到干扰。

对于数据库,如果被测程序只是简单的从数据库 select 一些配置信息,那么或许可以在 test harness 里内嵌一个 in-memory H2 DB engine,然后让被测程序从这里读取数据。当然,前提是被测程序的 DB driver 能连上 H2 (或许不是大问题,H2 支持 JDBC 和 部分 ODBC)。如果被测程序有比较复杂的 SQL 代码,那么 H2 表现的行为不一定和生产环境的数据库一致,这时候恐怕还是要部署测试数据库(有可能为每个开发人员部署一个小的测试数据库,以免相互干扰)。

如果被测程序有其他 IO (写 log 不算),比如 DataNode 会访问文件系统,那么 test harness 没有能把 DataNode 完整地包裹起来,有些 failure cases 不是那么容易测试。这是或许可以把 DataNode 指向 tmpfs,这样能比较容易测试磁盘满的情况。当然,这样也有局限性,因为 tmpfs 没有真实磁盘那么大,也不能模拟磁盘读写错误。我不是分布式存储方面的专家,这些问题留给分布式文件系统的实现者去考虑吧。(测试 paxos 节点似乎也可以用 tmpfs 来模拟 persist storage,由 test case 填充所需的初始数据。)

其他用处

Test harness 除了实现 features 的回归测试,它还有别的用处。

  • 加速开发,提高生产力。

前面提到,如果有个新功能(增加一种新的 request type)需要改动两个程序,有可能造成相互等待:客户程序 A 说要先等服务程序 B 实现对应的功能响应,这样 A 才能发送新的请求,不然每次请求就会被拒绝,无法测试;服务程序 B 说要先等 A 能够发送新的请求,这样自己才能开始编码与测试,不然都不知道请求长什么样子,也触发不了新写的代码。(当然,这是我虚构的例子。)

如果 A 和 B 都有各自的 test harness,事情就好办了,双方大致商量一个协议格式,然后分头编码。程序 A 的作者在自己的 harness 里边添加一个 test case,模拟他认为 B 应有的响应,这个响应可以 hard code 某种最常见的响应,不必真的实现所需的判断逻辑(毕竟这是程序 B 的作者该干的事情),然后程序 A 的作者就可以编码并测试自己的程序了。同理,程序 B 的作者也不用等 A 拿出一个半成品来发送新请求,他往自己的 harness 添加一个 test case,模拟他认为 A 应该发送的请求,然后就可以编码并测试自己的新功能。双方齐头并进,减少扯皮。等功能实现得差不多了,两个程序互相连一连,如果发现协议有不一致,检查一下 harness 中的新 test cases(这代表了 A/B 程序对对方的预期),看看那边改动比较方便,很快就能解决问题。

  • 压力测试。

Test harness 稍作改进还可以变功能测试为压力测试,供程序员 profiling 用。比如反复不间断发送请求,向被测程序加压。不过,如果被测程序是 C++ 写的,而 test harness 是 Java 写的,有可能出现 test harness 占 100% CPU,而被测程序还跑得优哉游哉的情况。这时候可以单独用 C++ 写一个负载生成器。

小结

以单独的进程作为 test harness 对于开发分布式程序相当有帮助,它能达到单元测试的自动化程度和细致程度,又避免了单元测试对功能代码结构的侵入与依赖。

posted @ 2011-04-25 00:27 陈硕 阅读(953) | 评论 (0)编辑 收藏

Muduo 网络编程示例之五: 测量两台机器的网络延迟

Muduo 网络编程示例之五: 测量两台机器的网络延迟

陈硕 (giantchen_AT_gmail)

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

这是《Muduo 网络编程示例》系列的第五篇文章。

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

 

本文介绍一个简单的网络程序 roundtrip,用于测量两台机器之间的网络延迟,即“往返时间 / round trip time / RTT”。这篇文章主要考察定长 TCP 消息的分包,TCP_NODELAY 的作用。

本文的代码见 http://code.google.com/p/muduo/source/browse/trunk/examples/roundtrip/roundtrip.cc

测量 RTT 的办法很简单:

  • host A 发一条消息给 host B,其中包含 host A 发送消息的本地时间
  • host B 收到之后立刻把消息 echo 回 host A
  • host A 收到消息之后,用当前时间减去消息中的时间就得到了 RTT。

NTP 协议的工作原理与之类似,不过,除了测量 RTT,NTP 还需要知道两台机器之间的时间差 (clock offset),这样才能校准时间。

roundtrip_ntp

以上是 NTP 协议收发消息的协议,RTT = (T4-T1) – (T3-T2),时间差 = ((T4+T1)-(T2+T3))/2。NTP 的要求是往返路径上的单程延迟要尽量相等,这样才能减少系统误差。偶然误差由单程延迟的不确定性决定。

在我设计的 roundtrip 示例程序中,协议有所简化:

roundtrip_simple

简化之后的协议少取一次时间,因为 server 收到消息之后立刻发送回 client,耗时很少(若干微秒),基本不影响最终结果。

我设计的消息格式是 16 字节定长消息:

roundtrip_msg

T1 和 T2 都是 muduo::Timestamp,一个 int64_t,表示从 Epoch 到现在的微秒数。

为了让消息的单程往返时间接近,server 和 client 发送的消息都是 16 bytes,这样做到对称。

由于是定长消息,可以不必使用 codec,在 message callback 中直接用

while (buffer->readableBytes() >= frameLen) { ... } 就能 decode。

请读者思考,如果把 while 换成 if 会有什么后果?

 

client 程序以 200ms 为间隔发送消息,在收到消息之后打印 RTT 和 clock offset。一次运作实例如下:

roundtrip_example

这个例子中,client 和 server 的时钟不是完全对准的,server 的时间快了 850 us,用 roundtrip 程序能测量出这个时间差。有了这个时间差就能校正分布式系统中测量得到的消息延迟。

比方说以上图为例,server 在它本地 1.235000 时刻发送了一条消息,client 在它本地 1.234300 收到这条消息,直接计算的话延迟是 –700us。这个结果肯定是错的,因为 server 和 client 不在一个时钟域(这是数字电路中的概念),它们的时间直接相减无意义。如果我们已经测量得到 server 比 client 快 850us,那么做用这个数据一次校正: -700+850 = 150us,这个结果就比较符合实际了。当然,在实际应用中,clock offset 要经过一个低通滤波才能使用,不然偶然性太大。

请读者思考,为什么不能直接以 RTT/2 作为两天机器之间收发消息的单程延迟?

这个程序在局域网中使用没有问题,如果在广域网上使用,而且 RTT 大于 200ms,那么受 Nagle 算法影响,测量结果是错误的(具体分析留作练习,这能测试对 Nagle 的理解),这时候我们需要设置 TCP_NODELAY 参数,让程序在广域网上也能正常工作。

posted @ 2011-04-20 09:26 陈硕 阅读(2989) | 评论 (7)编辑 收藏

Muduo 设计与实现之一:Buffer 类的设计

陈硕 (giantchen_AT_gmail)

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

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

 

本文介绍 Muduo 中输入输出缓冲区的设计与实现。

本文中 buffer 指一般的应用层缓冲区、缓冲技术,Buffer 特指 muduo::net::Buffer class。

本文前两节的内容已事先发表在 muduo 英文博客 http://muduo.chenshuo.com/2011/04/essentials-of-non-blocking-tcp-network.html

Muduo 的 IO 模型

UNPv1 第 6.2 节总结了 Unix/Linux 上的五种 IO 模型:阻塞(blocking)、非阻塞(non-blocking)、IO 复用(IO multiplexing)、信号驱动(signal-driven)、异步(asynchronous)。这些都是单线程下的 IO 模型。

C10k 问题的页面介绍了五种 IO 策略,把线程也纳入考量。(现在 C10k 已经不是什么问题,C100k 也不是大问题,C1000k 才算得上挑战)。

在这个多核时代,线程是不可避免的。那么服务端网络编程该如何选择线程模型呢?我赞同 libev 作者的观点:one loop per thread is usually a good model。之前我也不止一次表述过这个观点,见《多线程服务器的常用编程模型》《多线程服务器的适用场合》。

如果采用 one loop per thread 的模型,多线程服务端编程的问题就简化为如何设计一个高效且易于使用的 event loop,然后每个线程 run 一个 event loop 就行了(当然、同步和互斥是不可或缺的)。在“高效”这方面已经有了很多成熟的范例(libev、libevent、memcached、varnish、lighttpd、nginx),在“易于使用”方面我希望 muduo 能有所作为。(muduo 可算是用现代 C++ 实现了 Reactor 模式,比起原始的 Reactor 来说要好用得多。)

event loop 是 non-blocking 网络编程的核心,在现实生活中,non-blocking 几乎总是和 IO-multiplexing 一起使用,原因有两点:

  • 没有人真的会用轮询 (busy-pooling) 来检查某个 non-blocking IO 操作是否完成,这样太浪费 CPU cycles。
  • IO-multiplex 一般不能和 blocking IO 用在一起,因为 blocking IO 中 read()/write()/accept()/connect() 都有可能阻塞当前线程,这样线程就没办法处理其他 socket 上的 IO 事件了。见 UNPv1 第 16.6 节“nonblocking accept”的例子。

所以,当我提到 non-blocking 的时候,实际上指的是 non-blocking + IO-muleiplexing,单用其中任何一个是不现实的。另外,本文所有的“连接”均指 TCP 连接,socket 和 connection 在文中可互换使用。

当然,non-blocking 编程比 blocking 难得多,见陈硕在《Muduo 网络编程示例之零:前言》中“TCP 网络编程本质论”一节列举的难点。基于 event loop 的网络编程跟直接用 C/C++ 编写单线程 Windows 程序颇为相像:程序不能阻塞,否则窗口就失去响应了;在 event handler 中,程序要尽快交出控制权,返回窗口的事件循环。

为什么 non-blocking 网络编程中应用层 buffer 是必须的?

Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系统调用上,这样可以最大限度地复用 thread-of-control,让一个线程能服务于多个 socket 连接。IO 线程只能阻塞在 IO-multiplexing 函数上,如 select()/poll()/epoll_wait()。这样一来,应用层的缓冲是必须的,每个 TCP socket 都要有 stateful 的 input buffer 和 output buffer。

TcpConnection 必须要有 output buffer

考虑一个常见场景:程序想通过 TCP 连接发送 100k 字节的数据,但是在 write() 调用中,操作系统只接受了 80k 字节(受 TCP advertised window 的控制,细节见 TCPv1),你肯定不想在原地等待,因为不知道会等多久(取决于对方什么时候接受数据,然后滑动 TCP 窗口)。程序应该尽快交出控制权,返回 event loop。在这种情况下,剩余的 20k 字节数据怎么办?

对于应用程序而言,它只管生成数据,它不应该关心到底数据是一次性发送还是分成几次发送,这些应该由网络库来操心,程序只要调用 TcpConnection::send() 就行了,网络库会负责到底。网络库应该接管这剩余的 20k 字节数据,把它保存在该 TCP connection 的 output buffer 里,然后注册 POLLOUT 事件,一旦 socket 变得可写就立刻发送数据。当然,这第二次 write() 也不一定能完全写入 20k 字节,如果还有剩余,网络库应该继续关注 POLLOUT 事件;如果写完了 20k 字节,网络库应该停止关注 POLLOUT,以免造成 busy loop。(Muduo EventLoop 采用的是 epoll level trigger,这么做的具体原因我以后再说。)

如果程序又写入了 50k 字节,而这时候 output buffer 里还有待发送的 20k 数据,那么网络库不应该直接调用 write(),而应该把这 50k 数据 append 在那 20k 数据之后,等 socket 变得可写的时候再一并写入。

如果 output buffer 里还有待发送的数据,而程序又想关闭连接(对程序而言,调用 TcpConnection::send() 之后他就认为数据迟早会发出去),那么这时候网络库不能立刻关闭连接,而要等数据发送完毕,见我在《为什么 muduo 的 shutdown() 没有直接关闭 TCP 连接?》一文中的讲解。

综上,要让程序在 write 操作上不阻塞,网络库必须要给每个 tcp connection 配置 output buffer。

TcpConnection 必须要有 input buffer

TCP 是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等等情况。一个常见的场景是,发送方 send 了两条 10k 字节的消息(共 20k),接收方收到数据的情况可能是:

  • 一次性收到 20k 数据
  • 分两次收到,第一次 5k,第二次 15k
  • 分两次收到,第一次 15k,第二次 5k
  • 分两次收到,第一次 10k,第二次 10k
  • 分三次收到,第一次 6k,第二次 8k,第三次 6k
  • 其他任何可能

网络库在处理“socket 可读”事件的时候,必须一次性把 socket 里的数据读完(从操作系统 buffer 搬到应用层 buffer),否则会反复触发 POLLIN 事件,造成 busy-loop。(Again, Muduo EventLoop 采用的是 epoll level trigger,这么做的具体原因我以后再说。)

那么网络库必然要应对“数据不完整”的情况,收到的数据先放到 input buffer 里,等构成一条完整的消息再通知程序的业务逻辑。这通常是 codec 的职责,见陈硕《Muduo 网络编程示例之二:Boost.Asio 的聊天服务器》一文中的“TCP 分包”的论述与代码。

所以,在 tcp 网络编程中,网络库必须要给每个 tcp connection 配置 input buffer。

 

所有 muduo 中的 IO 都是带缓冲的 IO (buffered IO),你不会自己去 read() 或 write() 某个 socket,只会操作 TcpConnection 的 input buffer 和 output buffer。更确切的说,是在 onMessage() 回调里读取 input buffer;调用 TcpConnection::send() 来间接操作 output buffer,一般不会直接操作 output buffer。

btw, muduo 的 onMessage() 的原型如下,它既可以是 free function,也可以是 member function,反正 muduo TcpConnection 只认 boost::function<>。

void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime);

对于网络程序来说,一个简单的验收测试是:输入数据每次收到一个字节(200 字节的输入数据会分 200 次收到,每次间隔 10 ms),程序的功能不受影响。对于 Muduo 程序,通常可以用 codec 来分离“消息接收”与“消息处理”,见陈硕《在 muduo 中实现 protobuf 编解码器与消息分发器》一文中对“编解码器 codec”的介绍。

如果某个网络库只提供相当于 char buf[8192] 的缓冲,或者根本不提供缓冲区,而仅仅通知程序“某 socket 可读/某 socket 可写”,要程序自己操心 IO buffering,这样的网络库用起来就很不方便了。(我有所指,你懂得。)

 

Buffer 的要求

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

Muduo Buffer 的设计考虑了常见的网络编程需求,我试图在易用性和性能之间找一个平衡点,目前这个平衡点更偏向于易用性。

Muduo Buffer 的设计要点:

  • 对外表现为一块连续的内存(char*, len),以方便客户代码的编写。
  • 其 size() 可以自动增长,以适应不同大小的消息。它不是一个 fixed size array (即 char buf[8192])。
  • 内部以 vector of char 来保存数据,并提供相应的访问函数。

Buffer 其实像是一个 queue,从末尾写入数据,从头部读出数据。

谁会用 Buffer?谁写谁读?根据前文分析,TcpConnection 会有两个 Buffer 成员,input buffer 与 output buffer。

  • input buffer,TcpConnection 会从 socket 读取数据,然后写入 input buffer(其实这一步是用 Buffer::readFd() 完成的);客户代码从 input buffer 读取数据。
  • output buffer,客户代码会把数据写入 output buffer(其实这一步是用 TcpConnection::send() 完成的);TcpConnection 从 output buffer 读取数据并写入 socket。

其实,input 和 output 是针对客户代码而言,客户代码从 input 读,往 output 写。TcpConnection 的读写正好相反。

以下是 muduo::net::Buffer 的类图。请注意,为了后面画图方便,这个类图跟实际代码略有出入,但不影响我要表达的观点。

bc

这里不介绍每个成员函数的作用,留给《Muduo 网络编程示例》系列。下文会仔细介绍 readIndex 和 writeIndex 的作用。

Buffer::readFd()

我在《Muduo 网络编程示例之零:前言》中写道

  • 在非阻塞网络编程中,如何设计并使用缓冲区?一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面,我们系统减少内存占用。如果有 10k 个连接,每个连接一建立就分配 64k 的读缓冲的话,将占用 640M 内存,而大多数时候这些缓冲区的使用率很低。muduo 用 readv 结合栈上空间巧妙地解决了这个问题。

具体做法是,在栈上准备一个 65536 字节的 stackbuf,然后利用 readv() 来读取数据,iovec 有两块,第一块指向 muduo Buffer 中的 writable 字节,另一块指向栈上的 stackbuf。这样如果读入的数据不多,那么全部都读到 Buffer 中去了;如果长度超过 Buffer 的 writable 字节数,就会读到栈上的 stackbuf 里,然后程序再把 stackbuf 里的数据 append 到 Buffer 中。

代码见 http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.cc#36

这么做利用了临时栈上空间,避免开巨大 Buffer 造成的内存浪费,也避免反复调用 read() 的系统开销(通常一次 readv() 系统调用就能读完全部数据)。

这算是一个小小的创新吧。

线程安全?

muduo::net::Buffer 不是线程安全的,这么做是有意的,原因如下:

  • 对于 input buffer,onMessage() 回调始终发生在该 TcpConnection 所属的那个 IO 线程,应用程序应该在 onMessage() 完成对 input buffer 的操作,并且不要把 input buffer 暴露给其他线程。这样所有对 input buffer 的操作都在同一个线程,Buffer class 不必是线程安全的。
  • 对于 output buffer,应用程序不会直接操作它,而是调用 TcpConnection::send() 来发送数据,后者是线程安全的。

如果 TcpConnection::send() 调用发生在该 TcpConnection 所属的那个 IO 线程,那么它会转而调用 TcpConnection::sendInLoop(),sendInLoop() 会在当前线程(也就是 IO 线程)操作 output buffer;如果 TcpConnection::send() 调用发生在别的线程,它不会在当前线程调用 sendInLoop() ,而是通过 EventLoop::runInLoop() 把 sendInLoop() 函数调用转移到 IO 线程(听上去颇为神奇?),这样 sendInLoop() 还是会在 IO 线程操作 output buffer,不会有线程安全问题。当然,跨线程的函数转移调用涉及函数参数的跨线程传递,一种简单的做法是把数据拷一份,绝对安全(不明白的同学请阅读代码)。

另一种更为高效做法是用 swap()。这就是为什么 TcpConnection::send() 的某个重载以 Buffer* 为参数,而不是 const Buffer&,这样可以避免拷贝,而用 Buffer::swap() 实现高效的线程间数据转移。(最后这点,仅为设想,暂未实现。目前仍然以数据拷贝方式在线程间传递,略微有些性能损失。)

 

Muduo Buffer 的数据结构

Buffer 的内部是一个 vector of char,它是一块连续的内存。此外,Buffer 有两个 data members,指向该 vector 中的元素。这两个 indices 的类型是 int,不是 char*,目的是应对迭代器失效。muduo Buffer 的设计参考了 Netty 的 ChannelBuffer 和 libevent 1.4.x 的 evbuffer。不过,其 prependable 可算是一点“微创新”。

Muduo Buffer 的数据结构如下:

图 1buffer0

两个 indices 把 vector 的内容分为三块:prependable、readable、writable,各块的大小是(公式一):

prependable = readIndex

readable = writeIndex - readIndex

writable = size() - writeIndex

(prependable 的作用留到后面讨论。)

readIndex 和 writeIndex 满足以下不变式(invariant):

0 ≤ readIndex ≤ writeIndex ≤ data.size()

Muduo Buffer 里有两个常数 kCheapPrepend 和 kInitialSize,定义了 prependable 的初始大小和 writable 的初始大小。(readable 的初始大小为 0。)在初始化之后,Buffer 的数据结构如下:括号里的数字是该变量或常量的值。

图 2buffer1

根据以上(公式一)可算出各块的大小,刚刚初始化的 Buffer 里没有 payload 数据,所以 readable == 0。

Muduo Buffer 的操作

1. 基本的 read-write cycle

Buffer 初始化后的情况见图 1,如果有人向 Buffer 写入了 200 字节,那么其布局是:

图 3buffer2

图 3 中 writeIndex 向后移动了 200 字节,readIndex 保持不变,readable 和 writable 的值也有变化。

 

如果有人从 Buffer read() & retrieve() (下称“读入”)了 50 字节,结果见图 4。与上图相比,readIndex 向后移动 50 字节,writeIndex 保持不变,readable 和 writable 的值也有变化(这句话往后从略)。

图 4buffer3

 

然后又写入了 200 字节,writeIndex 向后移动了 200 字节,readIndex 保持不变,见图 5

图 5buffer4

 

接下来,一次性读入 350 字节,请注意,由于全部数据读完了,readIndex 和 writeIndex 返回原位以备新一轮使用,见图 6,这和图 2 是一样的。

图 6buffer5

 

以上过程可以看作是发送方发送了两条消息,长度分别为 50 字节和 350 字节,接收方分两次收到数据,每次 200 字节,然后进行分包,再分两次回调客户代码。

 

自动增长

Muduo Buffer 不是固定长度的,它可以自动增长,这是使用 vector 的直接好处。

假设当前的状态如图 7 所示。(这和前面图 5 是一样的。)

图 7buffer4

 

客户代码一次性写入 1000 字节,而当前可写的字节数只有 624,那么 buffer 会自动增长以容纳全部数据,得到的结果是图 8。注意 readIndex 返回到了前面,以保持 prependable 等于 kCheapPrependable。由于 vector 重新分配了内存,原来指向它元素的指针会失效,这就是为什么 readIndex 和 writeIndex 是整数下标而不是指针。

图 8buffer6

 

然后读入 350 字节,readIndex 前移,见图 9

图 9buffer7

 

最后,读完剩下的 1000 字节,readIndex 和 writeIndex 返回 kCheapPrependable,见图 10。

图 10buffer8

注意 buffer 并没有缩小大小,下次写入 1350 字节就不会重新分配内存了。换句话说,Muduo Buffer 的 size() 是自适应的,它一开始的初始值是 1k,如果程序里边经常收发 10k 的数据,那么用几次之后它的 size() 会自动增长到 10k,然后就保持不变。这样一方面避免浪费内存(有的程序可能只需要 4k 的缓冲),另一方面避免反复分配内存。当然,客户代码可以手动 shrink() buffer size()。

size() 与 capacity()

使用 vector 的另一个好处是它的 capcity() 机制减少了内存分配的次数。比方说程序反复写入 1 字节,muduo Buffer 不会每次都分配内存,vector 的 capacity() 以指数方式增长,让 push_back() 的平均复杂度是常数。比方说经过第一次增长,size() 刚好满足写入的需求,如图 11。但这个时候 vector 的 capacity() 已经大于 size(),在接下来写入 capacity()-size() 字节的数据时,都不会重新分配内存,见图 12

图 11buffer6

图 12buffer9

 

细心的读者可能会发现用 capacity() 也不是完美的,它有优化的余地。具体来说,vector::resize() 会初始化(memset/bzero)内存,而我们不需要它初始化,因为反正立刻就要填入数据。比如,在图 12 的基础上写入 200 字节,由于 capacity() 足够大,不会重新分配内存,这是好事;但是 vector::resize() 会先把那 200 字节设为 0 (图 13),然后 muduo buffer 再填入数据(图 14)。这么做稍微有点浪费,不过我不打算优化它,除非它确实造成了性能瓶颈。(精通 STL 的读者可能会说用 vector::append() 以避免浪费,但是 writeIndex 和 size() 不一定是对齐的,会有别的麻烦。)

 

图 13buffer9a

图 14buffer9b

google protobuf 中有一个 STLStringResizeUninitialized 函数,干的就是这个事情。

内部腾挪

有时候,经过若干次读写,readIndex 移到了比较靠后的位置,留下了巨大的 prependable 空间,见图 14

图 14buffer10

 

这时候,如果我们想写入 300 字节,而 writable 只有 200 字节,怎么办?muduo Buffer 在这种情况下不会重新分配内存,而是先把已有的数据移到前面去,腾出 writable 空间,见图 15

图 15buffer11

 

然后,就可以写入 300 字节了,见图 16

图 16buffer12

这么做的原因是,如果重新分配内存,反正也是要把数据拷到新分配的内存区域,代价只会更大。

prepend

前面说 muduo Buffer 有个小小的创新(或许不是创新,我记得在哪儿看到过类似的做法,忘了出处),即提供 prependable 空间,让程序能以很低的代价在数据前面添加几个字节。

比方说,程序以固定的4个字节表示消息的长度(即《Muduo 网络编程示例之二:Boost.Asio 的聊天服务器》中的 LengthHeaderCodec),我要序列化一个消息,但是不知道它有多长,那么我可以一直 append() 直到序列化完成(图 17,写入了 200 字节),然后再在序列化数据的前面添加消息的长度(图 18,把 200 这个数 prepend 到首部)。

 

图 17buffer13

 

图 18buffer14

通过预留 kCheapPrependable 空间,可以简化客户代码,一个简单的空间换时间思路。

其他设计方案

这里简单谈谈其他可能的应用层 buffer 设计方案。

不用 vector<char>?

如果有 STL 洁癖,那么可以自己管理内存,以 4 个指针为 buffer 的成员,数据结构见图 19。

图 19alternative

说实话我不觉得这种方案比 vector 好。代码变复杂,性能也未见得有 noticeable 的改观。

如果放弃“连续性”要求,可以用 circular buffer,这样可以减少一点内存拷贝(没有“内部腾挪”)。

Zero copy ?

如果对性能有极高的要求,受不了 copy() 与 resize(),那么可以考虑实现分段连续的 zero copy buffer 再配合 gather scatter IO,数据结构如图 20,这是 libevent 2.0.x 的设计方案。TCPv2介绍的 BSD TCP/IP 实现中的 mbuf 也是类似的方案,Linux 的 sk_buff 估计也差不多。细节有出入,但基本思路都是不要求数据在内存中连续,而是用链表把数据块链接到一起。

图 20evbuf0

当然,高性能的代价是代码变得晦涩难读,buffer 不再是连续的,parse 消息会稍微麻烦。如果你的程序只处理 protobuf Message,这不是问题,因为 protobuf 有 ZeroCopyInputStream 接口,只要实现这个接口,parsing 的事情就交给 protobuf Message 去操心了。

性能是不是问题?看跟谁比

看到这里,有的读者可能会嘀咕,muduo Buffer 有那么多可以优化的地方,其性能会不会太低?对此,我的回应是“可以优化,不一定值得优化。”

Muduo 的设计目标是用于开发公司内部的分布式程序。换句话说,它是用来写专用的 Sudoku server 或者游戏服务器,不是用来写通用的 httpd 或 ftpd 或 www proxy。前者通常有业务逻辑,后者更强调高并发与高吞吐。

以 Sudoku 为例,假设求解一个 Sudoku 问题需要 0.2ms,服务器有 8 个核,那么理想情况下每秒最多能求解 40,000 个问题。每次 Sudoku 请求的数据大小低于 100 字节(一个 9x9 的数独只要 81 字节,加上 header 也可以控制在 100 bytes 以下),就是说 100 x 40000 = 4 MB per second 的吞吐量就足以让服务器的 CPU 饱和。在这种情况下,去优化 Buffer 的内存拷贝次数似乎没有意义。

再举一个例子,目前最常用的千兆以太网的裸吞吐量是 125MB/s,扣除以太网 header、IP header、TCP header之后,应用层的吞吐率大约在 115 MB/s 上下。而现在服务器上最常用的 DDR2/DDR3 内存的带宽至少是 4GB/s,比千兆以太网高 40 倍以上。就是说,对于几 k 或几十 k 大小的数据,在内存里边拷几次根本不是问题,因为受以太网延迟和带宽的限制,跟这个程序通信的其他机器上的程序不会觉察到性能差异。

最后举一个例子,如果你实现的服务程序要跟数据库打交道,那么瓶颈常常在 DB 上,优化服务程序本身不见得能提高性能(从 DB 读一次数据往往就抵消了你做的全部 low-level 优化),这时不如把精力投入在 DB 调优上。

专用服务程序与通用服务程序的另外一点区别是 benchmark 的对象不同。如果你打算写一个 httpd,自然有人会拿来和目前最好的 nginx 对比,立马就能比出性能高低。然而,如果你写一个实现公司内部业务的服务程序(比如分布式存储或者搜索或者微博或者短网址),由于市面上没有同等功能的开源实现,你不需要在优化上投入全部精力,只要一版做得比一版好就行。先正确实现所需的功能,投入生产应用,然后再根据真实的负载情况来做优化,这恐怕比在编码阶段就盲目调优要更 effective 一些。

Muduo 的设计目标之一是吞吐量能让千兆以太网饱和,也就是每秒收发 120 兆字节的数据。这个很容易就达到,不用任何特别的努力。

如果确实在内存带宽方面遇到问题,说明你做的应用实在太 critical,或许应该考虑放到 Linux kernel 里边去,而不是在用户态尝试各种优化。毕竟只有把程序做到 kernel 里才能真正实现 zero copy,否则,核心态和用户态之间始终是有一次内存拷贝的。如果放到 kernel 里还不能满足需求,那么要么自己写新的 kernel,或者直接用 FPGA 或 ASIC 操作 network adapter 来实现你的高性能服务器。

(待续)

posted @ 2011-04-17 12:24 陈硕 阅读(9484) | 评论 (28)编辑 收藏

在 muduo 中实现 protobuf 编解码器与消息分发器

陈硕 (giantchen_AT_gmail)

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

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

本文是《一种自动反射消息类型的 Google Protobuf 网络传输方案》的延续,介绍如何将前文介绍的打包方案与 muduo::net::Buffer 结合,实现了 protobuf codec 和 dispatcher。

Muduo 的下载地址: http://muduo.googlecode.com/files/muduo-0.1.9-alpha.tar.gz ,SHA1 dc0bb5f7becdfc0277fb35f6dfaafee8209213bc ,本文的完整代码可在线阅读 http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/

考虑到不是每个人都安装了 Google Protobuf,muduo 中的 protobuf 相关示例默认是不 build 的,如果你的机器上安装了 protobuf 2.3.0 或 2.4.0a,那么可以用 ./build.sh protobuf_all 来构建 protobuf 相关的 examples。

 

在介绍 codec 和 dispatcher 之前,先讲讲前文的一个未决问题。

为什么 Protobuf 的默认序列化格式没有包含消息的长度与类型?

Protobuf 是经过深思熟虑的消息打包方案,它的默认序列化格式没有包含消息的长度与类型,自然有其道理。哪些情况下不需要在 protobuf 序列化得到的字节流中包含消息的长度和(或)类型?我能想到的答案有:

  • 如果把消息写入文件,一个文件存一个消息,那么序列化结果中不需要包含长度和类型,因为从文件名和文件长度中可以得知消息的类型与长度。
  • 如果把消息写入文件,一个文件存多个消息,那么序列化结果中不需要包含类型,因为文件名就代表了消息的类型。
  • 如果把消息存入数据库(或者 NoSQL),以 VARBINARY 字段保存,那么序列化结果中不需要包含长度和类型,因为从字段名和字段长度中可以得知消息的类型与长度。
  • 如果把消息以 UDP 方式发生给对方,而且对方一个 UDP port 只接收一种消息类型,那么序列化结果中不需要包含长度和类型,因为从 port 和 UDP packet 长度中可以得知消息的类型与长度。
  • 如果把消息以 TCP 短连接方式发给对方,而且对方一个 TCP port 只接收一种消息类型,那么序列化结果中不需要包含长度和类型,因为从 port 和 TCP 字节流长度中可以得知消息的类型与长度。
  • 如果把消息以 TCP 长连接方式发给对方,但是对方一个 TCP port 只接收一种消息类型,那么序列化结果中不需要包含类型,因为 port 代表了消息的类型。
  • 如果采用 RPC 方式通信,那么只需要告诉对方 method name,对方自然能推断出 Request 和 Response 的消息类型,这些可以由 protoc 生成的 RPC stubs 自动搞定。

对于最后一点,比方说 sudoku.proto 定义为:

service SudokuService {
  rpc Solve (SudokuRequest) returns (SudokuResponse);
}

那么 RPC method Sudoku.Solve 对应的请求和响应分别是 SudokuRequest 和 SudokuResponse。在发送 RPC 请求的时候,不需要包含 SudokuRequest 的类型,只需要发送 method name Sudoku.Solve,对方自知道应该按照 SudokuRequest 来解析(parse)请求。这个例子来自我的半成品项目 evproto,见 http://blog.csdn.net/Solstice/archive/2010/04/17/5497699.aspx

对于上述这些情况,如果 protobuf 无条件地把长度和类型放到序列化的字节串中,只会浪费网络带宽和存储。可见 protobuf 默认不发送长度和类型是正确的决定。Protobuf 为消息格式的设计树立了典范,哪些该自己搞定,哪些留给外部系统去解决,这些都考虑得很清楚。

只有在使用 TCP 长连接,且在一个连接上传递不止一种消息的情况下(比方同时发 Heartbeat 和 Request/Response),才需要我前文提到的那种打包方案。(为什么要在一个连接上同时发 Heartbeat 和业务消息?请见陈硕《分布式系统的工程化开发方法》 p.51 心跳协议的设计。)这时候我们需要一个分发器 dispatcher,把不同类型的消息分给各个消息处理函数,这正是本文的主题之一。

以下均只考虑 TCP 长连接这一应用场景。

先谈谈编解码器。

什么是编解码器 codec?

Codec 是 encoder 和 decoder 的缩写,这是一个到软硬件都在使用的术语,这里我借指“把网络数据和业务消息之间互相转换”的代码。

在最简单的网络编程中,没有消息 message 只有字节流数据,这时候是用不到 codec 的。比如我们前面讲过的 echo server,它只需要把收到的数据原封不动地发送回去,它不必关心消息的边界(也没有“消息”的概念),收多少就发多少,这种情况下它干脆直接使用 muduo::net::Buffer,取到数据再交给 TcpConnection 发送回去,见下图。

codec_echo

non-trivial 的网络服务程序通常会以消息为单位来通信,每条消息有明确的长度与界限。程序每次收到一个完整的消息的时候才开始处理,发送的时候也是把一个完整的消息交给网络库。比如我们前面讲过的 asio chat 服务,它的一条聊天记录就是一条消息,我们设计了一个简单的消息格式,即在聊天记录前面加上 4 字节的 length header,LengthHeaderCodec 代码及解说见《Muduo 网络编程示例之二:Boost.Asio 的聊天服务器》一文。

codec 的基本功能之一是做 TCP 分包:确定每条消息的长度,为消息划分界限。在 non-blocking 网络编程中,codec 几乎是必不可少的。如果只收到了半条消息,那么不会触发消息回调,数据会停留在 Buffer 里(数据已经读到 Buffer 中了),等待收到一个完整的消息再通知处理函数。既然这个任务太常见,我们干脆做一个 utility class,避免服务端和客户端程序都要自己处理分包,这就有了 LengthHeaderCodec。这个 codec 的使用有点奇怪,不需要继承,它也没有基类,只要把它当成普通 data member 来用,把 TcpConnection 的数据喂给它,然后向它注册 onXXXMessage() 回调,代码见 asio chat 示例。muduo 里的 codec 都是这样的风格,通过 boost::function 粘合到一起。

codec 是一层间接性,它位于 TcpConnection 和 ChatServer 之间,拦截处理收到的数据,在收到完整的消息之后再调用 CharServer 对应的处理函数,注意 CharServer::onStringMessage() 的参数是 std::string,不再是 muduo::net::Buffer,也就是说 LengthHeaderCodec 把 Buffer 解码成了 string。另外,在发送消息的时候,ChatServer 通过 LengthHeaderCodec::send() 来发送 string,LengthHeaderCodec 负责把它编码成 Buffer。这正是“编解码器”名字的由来。

codec_chat

Protobuf codec 与此非常类似,只不过消息类型从 std::string 变成了 protobuf::Message。对于只接收处理 Query 消息的 QueryServer 来说,用 ProtobufCodec 非常方便,收到 protobuf::Message 之后 down cast 成 Query 来用就行。如果要接收处理不止一种消息,ProtobufCodec 恐怕还不能单独完成工作,请继续阅读下文。

codec_protobuf

 

实现 ProtobufCodec

Protobuf 的打包方案我已经在《一种自动反射消息类型的 Google Protobuf 网络传输方案》中讲过,并以 string 为载体演示了 encode 和 decode 操作。在 muduo 里,我们有专门的 Buffer class,编码更轻松。

编码算法很直截了当,按照前文定义的消息格式一路打包下来,最后更新一下首部的长度即可。

解码算法有几个要点:

  • protobuf::Message 是 new 出来的对象,它的生命期如何管理?muduo 采用 shared_ptr<Message> 来自动管理对象生命期,这与其他地方的做法是一致的。
  • 出错如何处理?比方说长度超出范围、check sum 不正确、message type name 不能识别、message parse 出错等等。ProtobufCodec 定义了 ErrorCallback,用户代码可以注册这个回调。如果不注册,默认的处理是断开连接,让客户重连重试。codec 的单元测试里模拟了各种出错情况。
  • 如何处理一次收到半条消息、一条消息、一条半消息、两条消息等等情况?这是每个 non-blocking 网络程序中的 codec 都要面对的问题。

ProtobufCodec 在实际使用中有明显的不足:它只负责把 muduo::net::Buffer 转换为具体类型的 protobuf::Message,应用程序拿到 Message 之后还有再根据其具体类型做一次分发。我们可以考虑做一个简单通用的分发器 dispatcher,以简化客户代码。

此外,目前 ProtobufCodec 的实现非常初级,它没有充分利用 ZeroCopyInputStream 和 ZeroCopyOutputStream,而是把收到的数据作为 byte array 交给 protobuf Message 去解析,这给性能优化留下了空间。protobuf Message 不要求数据连续(像 vector 那样),只要求数据分段连续(像 deque 那样),这给 buffer 管理带来性能上的好处(避免重新分配内存,减少内存碎片),当然也使得代码变复杂。muduo::net::Buffer 非常简单,它内部是 vector<char>,我目前不想让 protobuf 影响 muduo 本身的设计,毕竟 muduo 是个通用的网络库,不是为实现 protobuf RPC 而特制的。

消息分发器 dispatcher 有什么用?

前面提到,在使用 TCP 长连接,且在一个连接上传递不止一种 protobuf 消息的情况下,客户代码需要对收到的消息按类型做分发。比方说,收到 Logon 消息就交给 QueryServer::onLogon() 去处理,收到 Query 消息就交给 QueryServer::onQuery() 去处理。这个消息分派机制可以做得稍微有点通用性,让所有 muduo+protobuf 程序收益,而且不增加复杂性。

换句话说,又是一层间接性,ProtobufCodec 拦截了 TcpConnection 的数据,把它转换为 Message,ProtobufDispatcher 拦截了 ProtobufCodec 的 callback,按消息具体类型把它分派给多个 callbacks。

codec_dispatcher

ProtobufCodec 与 ProtobufDispatcher 的综合运用

我写了两个示例代码,client 和 server,把 ProtobufCodec 和 ProtobufDispatcher 串联起来使用。server 响应 Query 消息,发生回 Answer 消息,如果收到未知消息类型,则断开连接。client 可以选择发送 Query 或 Empty 消息,由命令行控制。这样可以测试 unknown message callback。

为节省篇幅,这里就不列出代码了,请移步阅读

http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/client.cc 

http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/server.cc

在构造函数中,通过注册回调函数把四方 (TcpConnection、codec、dispatcher、QueryServer) 结合起来。

ProtobufDispatcher 的两种实现

要完成消息分发,那么就是对消息做 type-switch,这似乎是一个 bad smell,但是 protobuf Message 的 Descriptor 没有留下定制点(比如暴露一个 boost::any 成员),我们只好硬来了。

先定义

typedef boost::function<void (Message*)> ProtobufMessageCallback;

注意,本节出现的不是 muduo dispatcher 真实的代码,仅为示意,突出重点,便于画图。

ProtobufDispatcherLite 的结构非常简单,它有一个 map<Descriptor*, ProtobufMessageCallback> 成员,客户代码可以以 Descriptor* 为 key 注册回调(recall: 每个具体消息类型都有一个全局的 Descriptor 对象,其地址是不变的,可以用来当 key)。在收到 protobuf Message 之后,在 map 中找到对应的 ProtobufMessageCallback,然后调用之。如果找不到,就调用 defaultCallback。

codec_dispatcher_lite

当然,它的设计也有小小的缺陷,那就是 ProtobufMessageCallback 限制了客户代码只能接受基类 Message,客户代码需要自己做向下转型,比如:

codec_query_server1

 

如果我希望 QueryServer 这么设计:不想每个消息处理函数自己做 down casting,而是交给 dispatcher 去处理,客户代码拿到的就已经是想要的具体类型。如下:

codec_query_server2

那么该该如何实现 ProtobufDispatcher 呢?它如何与多个未知的消息类型合作?做 down cast 需要知道目标类型,难道我们要用一长串模板类型参数吗?

有一个办法,把多态与模板结合,利用 templated derived class 来提供类型上的灵活性。设计如下。

codec_dispatcher_class

ProtobufDispatcher 有一个模板成员函数,可以接受注册任意消息类型 T 的回调,然后它创建一个模板化的派生类 CallbackT<T>,这样消息的类新信息就保存在了 CallbackT<T> 中,做 down casting 就简单了。

 

比方说,我们有两个具体消息类型 Query 和 Answer。

codec_query

然后我们这样注册回调:

dispatcher_.registerMessageCallback<muduo::Query>(
    boost::bind(&QueryServer::onQuery, this, _1, _2, _3));
dispatcher_.registerMessageCallback<muduo::Answer>(
    boost::bind(&QueryServer::onAnswer, this, _1, _2, _3));

这样会具现化 (instantiation) 出两个 CallbackT 实体,如下:

codec_query_callback

以上设计参考了 shared_ptr 的 deleter,Scott Meyers 也谈到过

ProtobufCodec 和 ProtobufDispatcher 有何意义?

ProtobufCodec 和 ProtobufDispatcher 把每个直接收发 protobuf Message 的网络程序都会用到的功能提炼出来做成了公用的 utility,这样以后新写 protobuf 网络程序就不必为打包分包和消息分发劳神了。它俩以库的形式存在,是两个可以拿来就当 data member 用的 class,它们没有基类,也没有用到虚函数或者别的什么面向对象特征,不侵入 muduo::net 或者你的代码。

 

下一篇文章讲《分布式程序的自动回归测试》会介绍利用 protobuf 的跨语言特性,采用 Java 为 C++ 服务程序编写 test harness。

posted @ 2011-04-13 07:47 陈硕 阅读(4317) | 评论 (1)编辑 收藏

一种自动反射消息类型的 Google Protobuf 网络传输方案

陈硕 (giantchen_AT_gmail)

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

 

这篇文章要解决的问题是:在接收到 protobuf 数据之后,如何自动创建具体的 Protobuf Message 对象,再做的反序列化。“自动”的意思是:当程序中新增一个 protobuf Message 类型时,这部分代码不需要修改,不需要自己去注册消息类型。其实,Google Protobuf 本身具有很强的反射(reflection)功能,可以根据 type name 创建具体类型的 Message 对象,我们直接利用即可。

本文假定读者了解 Google Protocol Buffers 是什么,这不是一篇 protobuf 入门教程。

本文以 C++ 语言举例,其他语言估计有类似的解法,欢迎补充。

本文的示例代码在: https://github.com/chenshuo/recipes/tree/master/protobuf

网络编程中使用 protobuf 的两个问题

Google Protocol Buffers (Protobuf) 是一款非常优秀的库,它定义了一种紧凑的可扩展二进制消息格式,特别适合网络数据传输。它为多种语言提供 binding,大大方便了分布式程序的开发,让系统不再局限于用某一种语言来编写。

在网络编程中使用 protobuf 需要解决两个问题:

  • 长度,protobuf 打包的数据没有自带长度信息或终结符,需要由应用程序自己在发生和接收的时候做正确的切分;
  • 类型,protobuf 打包的数据没有自带类型信息,需要由发送方把类型信息传给给接收方,接收方创建具体的 Protobuf Message 对象,再做的反序列化。

第一个很好解决,通常的做法是在每个消息前面加个固定长度的 length header,例如我在 《Muduo 网络编程示例之二: Boost.Asio 的聊天服务器》 中实现的 LengthHeaderCodec,代码见 http://code.google.com/p/muduo/source/browse/trunk/examples/asio/chat/codec.h

第二个问题其实也很好解决,Protobuf 对此有内建的支持。但是奇怪的是,从网上简单搜索的情况看,我发现了很多山寨的做法。

 

山寨做法

以下均为在 protobuf data 之前加上 header,header 中包含 int length 和类型信息。类型信息的山寨做法主要有两种:

  • 在 header 中放 int typeId,接收方用 switch-case 来选择对应的消息类型和处理函数;
  • 在 header 中放 string typeName,接收方用 look-up table 来选择对应的消息类型和处理函数。

这两种做法都有问题。

第一种做法要求保持 typeId 的唯一性,它和 protobuf message type 一一对应。如果 protobuf message 的使用范围不广,比如接收方和发送方都是自己维护的程序,那么 typeId 的唯一性不难保证,用版本管理工具即可。如果 protobuf message 的使用范围很大,比如全公司都在用,而且不同部门开发的分布式程序可能相互通信,那么就需要一个公司内部的全局机构来分配 typeId,每次增加新 message type 都要去注册一下,比较麻烦。

第二种做法稍好一点。typeName 的唯一性比较好办,因为可以加上 package name(也就是用 message 的 fully qualified type name),各个部门事先分好 namespace,不会冲突与重复。但是每次新增消息类型的时候都要去手工修改 look-up table 的初始化代码,比较麻烦。

其实,不需要自己重新发明轮子,protobuf 本身已经自带了解决方案。

 

根据 type name 反射自动创建 Message 对象

Google Protobuf 本身具有很强的反射(reflection)功能,可以根据 type name 创建具体类型的 Message 对象。但是奇怪的是,其官方教程里没有明确提及这个用法,我估计还有很多人不知道这个用法,所以觉得值得写这篇 blog 谈一谈。

 

以下是陈硕绘制的 Protobuf  class diagram,点击查看原图

protobuf_classdiagram

我估计大家通常关心和使用的是图的左半部分:MessageLite、Message、Generated Message Types (Person, AddressBook) 等,而较少注意到图的右半部分:Descriptor, DescriptorPool, MessageFactory。

上图中,其关键作用的是 Descriptor class,每个具体 Message Type 对应一个 Descriptor 对象。尽管我们没有直接调用它的函数,但是Descriptor在“根据 type name 创建具体类型的 Message 对象”中扮演了重要的角色,起了桥梁作用。上图的红色箭头描述了根据 type name 创建具体 Message 对象的过程,后文会详细介绍。

原理简述

Protobuf Message class 采用了 prototype pattern,Message class 定义了 New() 虚函数,用以返回本对象的一份新实例,类型与本对象的真实类型相同。也就是说,拿到 Message* 指针,不用知道它的具体类型,就能创建和它类型一样的具体 Message Type 的对象。

每个具体 Message Type 都有一个 default instance,可以通过 ConcreteMessage::default_instance() 获得,也可以通过 MessageFactory::GetPrototype(const Descriptor*) 来获得。所以,现在问题转变为 1. 如何拿到 MessageFactory;2. 如何拿到 Descriptor*。

当然,ConcreteMessage::descriptor() 返回了我们想要的 Descriptor*,但是,在不知道 ConcreteMessage 的时候,如何调用它的静态成员函数呢?这似乎是个鸡与蛋的问题。

我们的英雄是 DescriptorPool,它可以根据 type name 查到 Descriptor*,只要找到合适的 DescriptorPool,再调用 DescriptorPool::FindMessageTypeByName(const string& type_name) 即可。眼前一亮?

在最终解决问题之前,先简单测试一下,看看我上面说的对不对。

简单测试

本文用于举例的 proto 文件:query.proto,见 https://github.com/chenshuo/recipes/blob/master/protobuf/query.proto

package muduo;
message Query {
required int64 id = 1;
required string questioner = 2;
repeated string question = 3;
}
message Answer {
required int64 id = 1;
required string questioner = 2;
required string answerer = 3;
repeated string solution = 4;
}
message Empty {
optional int32 id = 1;
}
其中的 Query.questioner 和 Answer.answerer 是我在前一篇文章这提到的《分布式系统中的进程标识》。

以下代码验证 ConcreteMessage::default_instance()、ConcreteMessage::descriptor()、 MessageFactory::GetPrototype()、DescriptorPool::FindMessageTypeByName() 之间的不变式 (invariant):

https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L15

  typedef muduo::Query T;
std::string type_name = T::descriptor()->full_name();
cout << type_name << endl;
const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(type_name);
assert(descriptor == T::descriptor());
cout << "FindMessageTypeByName() = " << descriptor << endl;
cout << "T::descriptor()         = " << T::descriptor() << endl;
cout << endl;
const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
assert(prototype == &T::default_instance());
cout << "GetPrototype()        = " << prototype << endl;
cout << "T::default_instance() = " << &T::default_instance() << endl;
cout << endl;
T* new_obj = dynamic_cast<T*>(prototype->New());
assert(new_obj != NULL);
assert(new_obj != prototype);
assert(typeid(*new_obj) == typeid(T::default_instance()));
cout << "prototype->New() = " << new_obj << endl;
cout << endl;
delete new_obj;

根据 type name 自动创建 Message 的关键代码

好了,万事具备,开始行动:

  1. 用 DescriptorPool::generated_pool() 找到一个 DescriptorPool 对象,它包含了程序编译的时候所链接的全部 protobuf Message types
  2. 用 DescriptorPool::FindMessageTypeByName() 根据 type name 查找 Descriptor。
  3. 再用 MessageFactory::generated_factory() 找到 MessageFactory 对象,它能创建程序编译的时候所链接的全部 protobuf Message types。
  4. 然后,用 MessageFactory::GetPrototype() 找到具体 Message Type 的 default instance。
  5. 最后,用 prototype->New() 创建对象。

示例代码见 https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L69

Message* createMessage(const std::string& typeName)
{
Message* message = NULL;
const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(typeName);
if (descriptor)
{
const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
if (prototype)
{
message = prototype->New();
}
}
return message;
}

调用方式:https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L49

  Message* newQuery = createMessage("muduo.Query");
assert(newQuery != NULL);
assert(typeid(*newQuery) == typeid(muduo::Query::default_instance()));
cout << "createMessage(\"muduo.Query\") = " << newQuery << endl;

古之人不余欺也 :-)

注意,createMessage() 返回的是动态创建的对象的指针,调用方有责任释放它,不然就会内存泄露。在 muduo 里,我用 shared_ptr<Message> 来自动管理 Message 对象的生命期。

线程安全性

Google 的文档说,我们用到的那几个 MessageFactory 和 DescriptorPool 都是线程安全的,Message::New() 也是线程安全的。并且它们都是 const member function。

 

关键问题解决了,那么剩下工作就是设计一种包含长度和消息类型的 protobuf 传输格式

Protobuf 传输格式

陈硕设计了一个简单的格式,包含 protobuf data 和它对应的长度与类型信息,消息的末尾还有一个 check sum。格式如下图,图中方块的宽度是 32-bit。

protobuf_wireformat1

用 C struct 伪代码描述:

 struct ProtobufTransportFormat __attribute__ ((__packed__))
{
int32_t  len;
int32_t  nameLen;
char     typeName[nameLen];
char     protobufData[len-nameLen-8];
int32_t  checkSum; // adler32 of nameLen, typeName and protobufData
};
注意,这个格式不要求 32-bit 对齐,我们的 decoder 会自动处理非对齐的消息。

例子

用这个格式打包一个 muduo.Query 对象的结果是:

protobuf_wireexample

设计决策

以下是我在设计这个传输格式时的考虑:

  • signed int。消息中的长度字段只使用了 signed 32-bit int,而没有使用 unsigned int,这是为了移植性,因为 Java 语言没有 unsigned 类型。另外 Protobuf 一般用于打包小于 1M 的数据,unsigned int 也没用。
  • check sum。虽然 TCP 是可靠传输协议,虽然 Ethernet 有 CRC-32 校验,但是网络传输必须要考虑数据损坏的情况,对于关键的网络应用,check sum 是必不可少的。对于 protobuf 这种紧凑的二进制格式而言,肉眼看不出数据有没有问题,需要用 check sum。
  • adler32 算法。我没有选用常见的 CRC-32,而是选用 adler32,因为它计算量小、速度比较快,强度和 CRC-32差不多。另外,zlib 和 java.unit.zip 都直接支持这个算法,不用我们自己实现。
  • type name 以 '\0' 结束。这是为了方便 troubleshooting,比如通过 tcpdump 抓下来的包可以用肉眼很容易看出 type name,而不用根据 nameLen 去一个个数字节。同时,为了方便接收方处理,加入了 nameLen,节省 strlen(),空间换时间。
  • 没有版本号。Protobuf Message 的一个突出优点是用 optional fields 来避免协议的版本号(凡是在 protobuf Message 里放版本号的人都没有理解 protobuf 的设计),让通信双方的程序能各自升级,便于系统演化。如果我设计的这个传输格式又把版本号加进去,那就画蛇添足了。具体请见本人《分布式系统的工程化开发方法》第 57 页:消息格式的选择。

 

示例代码

为了简单起见,采用 std::string 来作为打包的产物,仅为示例。

打包 encode 的代码:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L35

解包 decode 的代码:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L99

测试代码: https://github.com/chenshuo/recipes/blob/master/protobuf/codec_test.cc

如果以上代码编译通过,但是在运行时出现“cannot open shared object file”错误,一般可以用 sudo ldconfig 解决,前提是 libprotobuf.so 位于 /usr/local/lib,且 /etc/ld.so.conf 列出了这个目录。

$ make all # 如果你安装了 boost,可以 make whole

$ ./codec_test
./codec_test: error while loading shared libraries: libprotobuf.so.6: cannot open shared object file: No such file or directory

$ sudo ldconfig

 

与 muduo 集成

muduo 网络库将会集成对本文所述传输格式的支持(预计 0.1.9 版本),我会另外写一篇短文介绍 Protobuf Message <=> muduo::net::Buffer 的相互转化,使用 muduo::net::Buffer 来打包比上面 std::string 的代码还简单,它是专门为 non-blocking 网络库设计的 buffer class。

此外,我们可以写一个 codec 来自动完成转换,就行 asio/char/codec.h 那样。这样客户代码直接收到的就是 Message 对象,发送的时候也直接发送 Message 对象,而不需要和 Buffer 对象打交道。

消息的分发 (dispatching)

目前我们已经解决了消息的自动创建,在网络编程中,还有一个常见任务是把不同类型的 Message 分发给不同的处理函数,这同样可以借助 Descriptor 来完成。我在 muduo 里实现了 ProtobufDispatcherLite 和 ProtobufDispatcher 两个分发器,用户可以自己注册针对不同消息类型的处理函数。预计将会在 0.1.9 版本发布,您可以先睹为快:

初级版,用户需要自己做 down casting: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher_lite.cc

高级版,使用模板技巧,节省用户打字: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher.cc

基于 muduo 的 Protobuf RPC?

Google Protobuf 还支持 RPC,可惜它只提供了一个框架,没有开源网络相关的代码,muduo 正好可以填补这一空白。我目前还没有决定是不是让 muduo 也支持以 protobuf message 为消息格式的 RPC,muduo 还有很多事情要做,我也有很多博客文章打算写,RPC 这件事情以后再说吧。

注:Remote Procedure Call (RPC) 有广义和狭义两种意思。狭义的讲,一般特指 ONC RPC,就是用来实现 NFS 的那个东西;广义的讲,“以函数调用之名,行网络通信之实”都可以叫 RPC,比如 Java RMI,.Net Remoting,Apache Thriftlibevent RPC,XML-RPC 等等。

 

(待续)

posted @ 2011-04-03 15:56 陈硕 阅读(5377) | 评论 (1)编辑 收藏

C++ 工程实践(5):避免使用虚函数作为库的接口

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

 

摘要:作为 C++ 动态库的作者,应当避免使用虚函数作为库的接口。这么做会给保持二进制兼容性带来很大麻烦,不得不增加很多不必要的 interfaces,最终重蹈 COM 的覆辙。

本文主要讨论 Linux x86 平台,会继续举 Windows/COM 作为反面教材。

本文是上一篇《C++ 工程实践(4):二进制兼容性》的延续,在写这篇文章的时候,我原本以外大家都对“以 C++ 虚函数作为接口”的害处达成共识,我就写得比较简略,看来情况不是这样,我还得展开谈一谈。

“接口”有广义和狭义之分,本文用中文“接口”表示广义的接口,即一个库的代码界面;用英文 interface 表示狭义的接口,即只包含 virtual function 的 class,这种 class 通常没有 data member,在 Java 里有一个专门的关键字 interface 来表示它。

C++ 程序库的作者的生存环境

假设你是一个 shared library 的维护者,你的 library 被公司另外两三个团队使用了。你发现了一个安全漏洞,或者某个会导致 crash 的 bug 需要紧急修复,那么你修复之后,能不能直接部署 library 的二进制文件?有没有破坏二进制兼容性?会不会破坏别人团队已经编译好的投入生成环境的可执行文件?是不是要强迫别的团队重新编译链接,把可执行文件也发布新版本?会不会打乱别人的 release cycle?这些都是工程开发中经常要遇到的问题。

如果你打算新写一个 C++ library,那么通常要做以下几个决策:

  • 以什么方式发布?动态库还是静态库?(本文不考虑源代码发布这种情况,这其实和静态库类似。)
  • 以什么方式暴露库的接口?可选的做法有:以全局(含 namespace 级别)函数为接口、以 class 的 non-virtual 成员函数为接口、以 virtual 函数为接口(interface)。

(Java 程序员没有这么多需要考虑的,直接写 class 成员函数就行,最多考虑一下要不要给 method 或 class 标上 final。也不必考虑动态库静态库,都是 .jar 文件。)

在作出上面两个决策之前,我们考虑两个基本假设:

  • 代码会有 bug,库也不例外。将来可能会发布 bug fixes。
  • 会有新的功能需求。写代码不是一锤子买卖,总是会有新的需求冒出来,需要程序员往库里增加东西。这是好事情,让程序员不丢饭碗。

(如果你的代码第一次发布的时候就已经做到完美,将来不需要任何修改,那么怎么做都行,也就不必继续阅读本文。)

也就是说,在设计库的时候必须要考虑将来如何升级

基于以上两个基本假设来做决定。第一个决定很好做,如果需要 hot fix,那么只能用动态库;否则,在分布式系统中使用静态库更容易部署,这在前文中已经谈过。(“动态库比静态库节约内存”这种优势在今天看来已不太重要。)

以下本文假定你或者你的老板选择以动态库方式发布,即发布 .so 或 .dll 文件,来看看第二个决定怎么做。(再说一句,如果你能够以静态库方式发布,后面的麻烦都不会遇到。)

第二个决定不是那么容易做,关键问题是,要选择一种可扩展的 (extensible) 接口风格,让库的升级变得更轻松。“升级”有两层意思:

  • 对于 bug fix only 的升级,二进制库文件的替换应该兼容现有的二进制可执行文件,二进制兼容性方面的问题已经在前文谈过,这里从略。
  • 对于新增功能的升级,应该对客户代码的友好。升级库之后,客户端使用新功能的代价应该比较小。只需要包含新的头文件(这一步都可以省略,如果新功能已经加入原有的头文件中),然后编写新代码即可。而且,不要在客户代码中留下垃圾,后文我们会谈到什么是垃圾。

在讨论虚函数接口的弊端之前,我们先看看虚函数做接口的常见用法。

虚函数作为库的接口的两大用途

虚函数为接口大致有这么两种用法:

  1. 调用,也就是库提供一个什么功能(比如绘图 Graphics),以虚函数为接口方式暴露给客户端代码。客户端代码一般不需要继承这个 interface,而是直接调用其 member function。这么做据说是有利于接口和实现分离,我认为纯属脱了裤子放屁。
  2. 回调,也就是事件通知,比如网络库的“连接建立”、“数据到达”、“连接断开”等等。客户端代码一般会继承这个 interface,然后把对象实例注册到库里边,等库来回调自己。一般来说客户端不会自己去调用这些 member function,除非是为了写单元测试模拟库的行为。
  3. 混合,一个 class 既可以被客户端代码继承用作回调,又可以被客户端直接调用。说实话我没看出这么做的好处,但实际中某些面向对象的 C++ 库就是这么设计的。

对于“回调”方式,现代 C++ 有更好的做法,即 boost::function + boost::bind,见参考文献[4],muduo 的回调全部采用这种新方法,见《Muduo 网络编程示例之零:前言》。本文以下不考虑以虚函数为回调的过时做法。

对于“调用”方式,这里举一个虚构的图形库,这个库的功能是画线、画矩形、画圆弧:

   1: struct Point
   2: {
   3:   int x;
   4:   int y;
   5: };
   6:  
   7: class Graphics
   8: {
   9:   virtual void drawLine(int x0, int y0, int x1, int y1);
  10:   virtual void drawLine(Point p0, Point p1);
  11:  
  12:   virtual void drawRectangle(int x0, int y0, int x1, int y1);
  13:   virtual void drawRectangle(Point p0, Point p1);
  14:  
  15:   virtual void drawArc(int x, int y, int r);
  16:   virtual void drawArc(Point p, int r);
  17: };

这里略去了很多与本文主题无关细节,比如 Graphics 的构造与析构、draw*() 函数应该是 public、Graphics 应该不允许复制,还比如 Graphics 可能会用 pure virtual functions 等等,这些都不影响本文的讨论。

这个 Graphics 库的使用很简单,客户端看起来是这个样子。

Graphics* g = getGraphics();

g->drawLine(0, 0, 100, 200);

releaseGraphics(g); g = NULL;

似乎一切都很好,阳光明媚,符合“面向对象的原则”,但是一旦考虑升级,前景立刻变得昏暗。

虚函数作为接口的弊端

以虚函数作为接口在二进制兼容性方面有本质困难:“一旦发布,不能修改”。

假如我需要给 Graphics 增加几个绘图函数,同时保持二进制兼容性。这几个新函数的坐标以浮点数表示,我理想中的新接口是:

--- old/graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:13:30.000000000 +0800
@@ -7,11 +7,14 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
+  virtual void drawLine(double x0, double y0, double x1, double y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
+  virtual void drawArc(double x, double y, double r);
   virtual void drawArc(Point p, int r);
 };

受 C++ 二进制兼容性方面的限制,我们不能这么做。其本质问题在于 C++ 以 vtable[offset] 方式实现虚函数调用,而 offset 又是根据虚函数声明的位置隐式确定的,这造成了脆弱性。我增加了 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列发生了变化,现有的二进制可执行文件无法再用旧的 offset 调用到正确的函数。

怎么办呢?有一种危险且丑陋的做法:把新的虚函数放到 interface 的末尾,例如:

--- old/graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:58:22.000000000 +0800
@@ -7,11 +7,15 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
   virtual void drawArc(Point p, int r);
+
+  virtual void drawLine(double x0, double y0, double x1, double y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
+  virtual void drawArc(double x, double y, double r);
 };

这么做很丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数没有和原来的 drawLine() 函数呆在一起,造成阅读上的不便。这么做同时很危险,因为 Graphics 如果被继承,那么新增虚函数会改变派生类中的 vtable offset 变化,同样不是二进制兼容的。

另外有两种似乎安全的做法,这也是 COM 采用的办法:

1. 通过链式继承来扩展现有 interface,例如从 Graphics 派生出 Graphics2。

--- graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:58:35.000000000 +0800
@@ -7,11 +7,19 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
   virtual void drawArc(Point p, int r);
 };
+
+class Graphics2 : public Graphics
+{
+  using Graphics::drawLine;
+  using Graphics::drawRectangle;
+  using Graphics::drawArc;
+
+  // added in version 2
+  virtual void drawLine(double x0, double y0, double x1, double y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
+  virtual void drawArc(double x, double y, double r);
+};

将来如果继续增加功能,那么还会有 class Graphics3 : public Graphics2;以及 class Graphics4 : public Graphics3 等等。这么做和前面的做法一样丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数位于派生 Graphics2 interace 中,没有和原来的 drawLine() 函数呆在一起,造成割裂。

2. 通过多重继承来扩展现有 interface,例如定义一个与 Graphics class 有同样成员的 Graphics2,再让实现同时继承这两个 interfaces。

--- graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:16:45.000000000 +0800
@@ -7,11 +7,32 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
   virtual void drawArc(Point p, int r);
 };
+
+class Graphics2
+{
+  virtual void drawLine(int x0, int y0, int x1, int y1);
+  virtual void drawLine(double x0, double y0, double x1, double y1);
+  virtual void drawLine(Point p0, Point p1);
+
+  virtual void drawRectangle(int x0, int y0, int x1, int y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
+  virtual void drawRectangle(Point p0, Point p1);
+
+  virtual void drawArc(int x, int y, int r);
+  virtual void drawArc(double x, double y, double r);
+  virtual void drawArc(Point p, int r);
+};
+
+// 在实现中采用多重接口继承
+class GraphicsImpl : public Graphics,  // version 1
+                     public Graphics2, // version 2
+{
+  // ...
+};

这种带版本的 interface 的做法在 COM 使用者的眼中看起来是很正常的,解决了二进制兼容性的问题,客户端源代码也不受影响。

在我看来带版本的 interface 实在是很丑陋,因为每次改动都引入了新的 interface class,会造成日后客户端代码难以管理。比如,如果代码使用了 Graphics3 的功能,要不要把现有的 Graphics2 都替换掉?

  • 如果不替换,一个程序同时依赖多个版本的 Graphics,一直背着历史包袱。依赖的 Graphics 版本越积越多,将来如何管理得过来?
  • 如果要替换,为什么不相干的代码(现有的运行得好好的使用 Graphics2 的代码)也会因为别处用到了 Graphics3 而被修改?

这种二难境地纯粹是“以虚函数为库的接口”造成的。如果我们能直接原地扩充 class Graphics,就不会有这些屁事,见本文“推荐做法”一节。

假如 Linux 系统调用以 COM 接口方式实现

或许上面这个 Graphics 的例子太简单,没有让“以虚函数为接口”的缺点充分暴露出来,让我们看一个真实的案例:Linux Kernel。

Linux kernel 从 0.10 的 67 个系统调用发展到 2.6.37 的 340 个,kernel interface 一直在扩充,而且保持良好的兼容性,它保持兼容性的办法很土,就是给每个 system call 赋予一个终身不变的数字代号,等于把虚函数表的排列固定下来。点开本段开头的两个链接,你就能看到 fork() 在 Linux 0.10 和 Linux 2.6.37 里的代号都是 2。(系统调用的编号跟硬件平台有关,这里我们看的是 x86 32-bit 平台。)

试想假如 Linus 当初选择用 COM 接口的链式继承风格来描述,将会是怎样一种壮观的景象?为了避免扰乱视线,请移步观看近百层继承的代码。(先后关系与版本号不一定 100% 准确,我是用 git blame 去查的,现在列出的代码只从 0.01 到 2.5.31,相信已经足以展现 COM 接口方式的弊端。)

 

不要误认为“接口一旦发布就不能更改”是天经地义的,那不过是“以 C++ 虚函数为接口”的固有弊端,如果跳出这个框框去思考,其实 C++ 库的接口很容易做得更好。

为什么不能改?还不是因为用了C++ 虚函数作为接口。Java 的 interface 可以添加新函数,C 语言的库也可以添加新的全局函数,C++ class 也可以添加新 non-virtual 成员函数和 namespace 级别的 non-member 函数,这些都不需要继承出新 interface 就能扩充原有接口。偏偏 COM 的 interface 不能原地扩充,只能通过继承来 workaround,产生一堆带版本的 interfaces。有人说 COM 是二进制兼容性的正面例子,某深不以为然。COM 确实以一种最丑陋的方式做到了“二进制兼容”。脆弱与僵硬就是以 C++ 虚函数为接口的宿命。

相反,Linux 系统调用以编译期常数方式固定下来,万年不变,轻而易举地解决了这个问题。在其他面向对象语言(Java/C#)中,我也没有见过每改动一次就给 interface 递增版本号的诡异做法。

还是应了《The Zen of Python》中的那句话,Explicit is better than implicit, Flat is better than nested.

 

动态库的接口的推荐做法

取决于动态库的使用范围,有两类做法。

如果,动态库的使用范围比较窄,比如本团队内部的两三个程序在用,用户都是受控的,要发布新版本也比较容易协调,那么不用太费事,只要做好发布的版本管理就行了。再在可执行文件中使用 rpath 把库的完整路径确定下来。

比如现在 Graphics 库发布了 1.1.0 和 1.2.0 两个版本,这两个版本可以不必是二进制兼容。用户的代码从 1.1.0 升级到 1.2.0 的时候要重新编译一下,反正他们要用新功能都是要重新编译代码的。如果要原地打补丁,那么 1.1.1 应该和 1.1.0 二进制兼容,而 1.2.1 应该和 1.2.0 兼容。如果要加入新的功能,而新的功能与 1.2.0 不兼容,那么应该发布到 1.3.0 版本。

为了便于检查二进制兼容性,可考虑把库的代码的暴露情况分辨清楚。muduo 的头文件和 class 就有意识地分为用户可见和用户不可见两部分,见 http://blog.csdn.net/Solstice/archive/2010/08/29/5848547.aspx#_Toc32039。对于用户可见的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部分,在升级库的时候就不必在意。另外 muduo 本身设计来是以静态库方式发布,在二进制兼容性方面没有做太多的考虑。

 

如果库的使用范围很广,用户很多,各家的 release cycle 不尽相同,那么推荐 pimpl 技法[2, item 43],并考虑多采用 non-member non-friend function in namespace [1, item 23] [2, item 44 abd 57] 作为接口。这里以前面的 Graphics 为例,说明 pimpl 的基本手法。

1. 暴露的接口里边不要有虚函数,而且 sizeof(Graphics) == sizeof(Graphics::Impl*)。

class Graphics
{
 public:
  Graphics(); // outline ctor
  ~Graphics(); // outline dtor

  void drawLine(int x0, int y0, int x1, int y1);
  void drawLine(Point p0, Point p1);

  void drawRectangle(int x0, int y0, int x1, int y1);
  void drawRectangle(Point p0, Point p1);

  void drawArc(int x, int y, int r);
  void drawArc(Point p, int r);

 private:
  class Impl;
  boost::scoped_ptr<Impl> impl;
};

2. 在库的实现中把调用转发 (forward) 给实现 Graphics::Impl ,这部分代码位于 .so/.dll 中,随库的升级一起变化。

#include <graphics.h>

class Graphics::Impl
{
 public:
  void drawLine(int x0, int y0, int x1, int y1);
  void drawLine(Point p0, Point p1);

  void drawRectangle(int x0, int y0, int x1, int y1);
  void drawRectangle(Point p0, Point p1);

  void drawArc(int x, int y, int r);
  void drawArc(Point p, int r);
};

Graphics::Graphics()
  : impl(new Impl)
{
}

Graphics::~Graphics()
{
}

void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
  impl->drawLine(x0, y0, x1, y1);
}

void Graphics::drawLine(Point p0, Point p1)
{
  impl->drawLine(p0, p1);
}

// ...

3. 如果要加入新的功能,不必通过继承来扩展,可以原地修改,且很容易保持二进制兼容性。先动头文件:

--- old/graphics.h     2011-03-12 15:34:06.000000000 +0800
+++ new/graphics.h    2011-03-12 15:14:12.000000000 +0800
@@ -7,19 +7,22 @@
 class Graphics
 {
  public:
   Graphics(); // outline ctor
   ~Graphics(); // outline dtor

   void drawLine(int x0, int y0, int x1, int y1);
+  void drawLine(double x0, double y0, double x1, double y1);
   void drawLine(Point p0, Point p1);

   void drawRectangle(int x0, int y0, int x1, int y1);
+  void drawRectangle(double x0, double y0, double x1, double y1);
   void drawRectangle(Point p0, Point p1);

   void drawArc(int x, int y, int r);
+  void drawArc(double x, double y, double r);
   void drawArc(Point p, int r);

  private:
   class Impl;
   boost::scoped_ptr<Impl> impl;
 };

然后在实现文件里增加 forward,这么做不会破坏二进制兼容性,因为增加 non-virtual 函数不影响现有的可执行文件。

--- old/graphics.cc    2011-03-12 15:15:20.000000000 +0800
+++ new/graphics.cc   2011-03-12 15:15:26.000000000 +0800
@@ -1,35 +1,43 @@
 #include <graphics.h>

 class Graphics::Impl
 {
  public:
   void drawLine(int x0, int y0, int x1, int y1);
+  void drawLine(double x0, double y0, double x1, double y1);
   void drawLine(Point p0, Point p1);

   void drawRectangle(int x0, int y0, int x1, int y1);
+  void drawRectangle(double x0, double y0, double x1, double y1);
   void drawRectangle(Point p0, Point p1);

   void drawArc(int x, int y, int r);
+  void drawArc(double x, double y, double r);
   void drawArc(Point p, int r);
 };

 Graphics::Graphics()
   : impl(new Impl)
 {
 }

 Graphics::~Graphics()
 {
 }

 void Graphics::drawLine(int x0, int y0, int x1, int y1)
 {
   impl->drawLine(x0, y0, x1, y1);
 }

+void Graphics::drawLine(double x0, double y0, double x1, double y1)
+{
+  impl->drawLine(x0, y0, x1, y1);
+}
+
 void Graphics::drawLine(Point p0, Point p1)
 {
   impl->drawLine(p0, p1);
 }

采用 pimpl 多了一道 explicit forward 的手续,带来的好处是可扩展性与二进制兼容性,通常是划算的。pimpl 扮演了编译器防火墙的作用。

pimpl 不仅 C++ 语言可以用,C 语言的库同样可以用,一样带来二进制兼容性的好处,比如 libevent2 里边的 struct event_base 是个 opaque pointer,客户端看不到其成员,都是通过 libevent 的函数和它打交道,这样库的版本升级比较容易做到二进制兼容。

 

为什么 non-virtual 函数比 virtual 函数更健壮?因为 virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加载器 (loader) 会在程序启动时做决议(resolution),通过 mangled name 把可执行文件和动态库链接到一起。就像使用 Internet 域名比使用 IP 地址更能适应变化一样。

 

万一要跨语言怎么办?很简单,暴露 C 语言的接口。Java 有 JNI 可以调用 C 语言的代码;Python/Perl/Ruby 等等的解释器都是 C 语言编写的,使用 C 函数也不在话下。C 函数是 Linux 下的万能接口。

本文只谈了使用 class 为接口,其实用 free function 有时候更好(比如 muduo/base/Timestamp.h 除了定义 class Timestamp,还定义了 muduo::timeDifference() 等 free function),这也是 C++ 比 Java 等纯面向对象语言优越的地方。留给将来再细谈吧。

参考文献

[1] Scott Meyers, 《Effective C++》 第 3 版,条款 35:考虑 virtual 函数以外的其他选择;条款 23:宁以 non-member、non-friend 替换 member 函数

[2] Herb Sutter and Andrei Alexandrescu, 《C++ 编程规范》,条款 39:考虑将 virtual 函数做成 non-public,将 public 函数做成 non-virtual;条款 43:明智地使用 pimpl;条款 44:尽可能编写 nonmember, nonfriend 函数;条款 57:将 class 和其非成员函数接口放入同一个 namespace

[3] 孟岩,《function/bind的救赎(上)》,《回复几个问题》中的“四个半抽象”。

[4] 陈硕,《以 boost::function 和 boost:bind 取代虚函数》,《朴实的 C++ 设计》。

知识共享许可协议
作品采用知识共享署名-非商业性使用-相同方式共享 3.0 Unported许可协议进行许可。

posted @ 2011-03-13 09:04 陈硕 阅读(11886) | 评论 (8)编辑 收藏

C++ 工程实践(4):二进制兼容性

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

本文主要讨论 Linux x86/x86-64 平台,偶尔会举 Windows 作为反面教材。

C/C++ 的二进制兼容性 (binary compatibility) 有多重含义,本文主要在“头文件和库文件分别升级,可执行文件是否受影响”这个意义下讨论,我称之为 library (主要是 shared library,即动态链接库)的 ABI (application binary interface)。至于编译器与操作系统的 ABI 留给下一篇谈 C++ 标准与实践的文章。

什么是二进制兼容性

在解释这个定义之前,先看看 Unix/C 语言的一个历史问题:open() 的 flags 参数的取值。open(2) 函数的原型是

int open(const char *pathname, int flags);

其中 flags 的取值有三个: O_RDONLY,  O_WRONLY,  O_RDWR。

与一般人的直觉相反,这几个值不是按位或 (bitwise-OR) 的关系,即 O_RDONLY | O_WRONLY != O_RDWR。如果你想以读写方式打开文件,必须用 O_RDWR,而不能用 (O_RDONLY | O_WRONLY)。为什么?因为 O_RDONLY, O_WRONLY, O_RDWR 的值分别是 0, 1, 2。它们不满足按位或

那么为什么 C 语言从诞生到现在一直没有纠正这个不足之处?比方说把 O_RDONLY, O_WRONLY, O_RDWR 分别定义为 1, 2, 3,这样 O_RDONLY | O_WRONLY == O_RDWR,符合直觉。而且这三个值都是宏定义,也不需要修改现有的源代码,只需要改改系统的头文件就行了。

因为这么做会破坏二进制兼容性。对于已经编译好的可执行文件,它调用 open(2) 的参数是写死的,更改头文件并不能影响已经编译好的可执行文件。比方说这个可执行文件会调用 open(path, 1) 来文件,而在新规定中,这表示文件,程序就错乱了。

以上这个例子说明,如果以 shared library 方式提供函数库,那么头文件和库文件不能轻易修改,否则容易破坏已有的二进制可执行文件,或者其他用到这个 shared library 的 library。操作系统的 system call 可以看成 Kernel 与 User space 的 interface,kernel 在这个意义下也可以当成 shared library,你可以把内核从 2.6.30 升级到 2.6.35,而不需要重新编译所有用户态的程序。

所谓“二进制兼容性”指的就是在升级(也可能是 bug fix)库文件的时候,不必重新编译使用这个库的可执行文件或使用这个库的其他库文件,程序的功能不被破坏。

见 QT FAQ 的有关条款:http://developer.qt.nokia.com/faq/answer/you_frequently_say_that_you_cannot_add_this_or_that_feature_because_it_woul

在 Windows 下有恶名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll, mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,这是动态链接库的本质问题,怪不到 MFC 头上。

有哪些情况会破坏库的 ABI

到底如何判断一个改动是不是二进制兼容呢?这跟 C++ 的实现方式直接相关,虽然 C++ 标准没有规定 C++ 的 ABI,但是几乎所有主流平台都有明文或事实上的 ABI 标准。比方说 ARM 有 EABI,Intel Itanium 有 http://www.codesourcery.com/public/cxx-abi/abi.html,x86-64 有仿 Itanium 的 ABI,SPARC 和 MIPS 也都有明文规定的 ABI,等等。x86 是个例外,它只有事实上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI 还有多个版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 编译器也得按照 Visual C++ 或 G++ 的 ABI 来生成代码,否则就不能与系统其它部件兼容。

C++ ABI 的主要内容:

  • 函数参数传递的方式,比如 x86-64 用寄存器来传函数的前 4 个整数参数
  • 虚函数的调用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 来调用
  • struct 和 class 的内存布局,通过偏移量来访问数据成员
  • name mangling
  • RTTI 和异常处理的实现(以下本文不考虑异常处理)

C/C++ 通过头文件暴露出动态库的使用方法,这个“使用方法”主要是给编译器看的,编译器会据此生成二进制代码,然后在运行的时候通过装载器(loader)把可执行文件和动态库绑到一起。如何判断一个改动是不是二进制兼容,主要就是看头文件暴露的这份“使用说明”能否与新版本的动态库的实际使用方法兼容。因为新的库必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件来调用动态库。

这里举一些源代码兼容但是二进制代码不兼容例子

  • 给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
  • 增加虚函数,会造成 vtbl 里的排列变化。(不要考虑“只在末尾增加”这种取巧行为,因为你的 class 可能已被继承。)
  • 增加默认模板类型参数,比方说 Foo<T> 改为 Foo<T, Alloc=alloc<T> >,这会改变 name mangling
  • 改变 enum 的值,把 enum Color { Red = 3 }; 改为 Red = 4。这会造成错位。当然,由于 enum 自动排列取值,添加 enum 项也是不安全的,除非是在末尾添加。

给 class Bar 增加数据成员,造成 sizeof(Bar) 变大,以及内部数据成员的 offset 变化,这是不是安全的?通常不是安全的,但也有例外。

  • 如果客户代码里有 new Bar,那么肯定不安全,因为 new 的字节数不够装下新 Bar。相反,如果 library 通过 factory 返回 Bar* (并通过 factory 来销毁对象)或者直接返回 shared_ptr<Bar>,客户端不需要用到 sizeof(Bar),那么可能是安全的。
  • 如果客户代码里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全,因为 memberA 的新 Bar 的偏移可能会变。相反,如果只通过成员函数来访问对象的数据成员,客户端不需要用到 data member 的 offsets,那么可能是安全的。
  • 如果客户调用 pBar->setMemberA(xx); 而 Bar::setMemberA() 是个 inline function,那么肯定不安全,因为偏移量已经被 inline 到客户的二进制代码里了。如果 setMemberA() 是 outline function,其实现位于 shared library 中,会随着 Bar 的更新而更新,那么可能是安全的。

那么只使用 header-only 的库文件是不是安全呢?不一定。如果你的程序用了 boost 1.36.0,而你依赖的某个 library 在编译的时候用的是 1.33.1,那么你的程序和这个 library 就不能正常工作。因为 1.36.0 和 1.33.1 的 boost::function 的模板参数类型的个数不一样,其中一个多了 allocator。

这里有一份黑名单,列在这里的肯定是二级制不兼容,没有列出的也可能二进制不兼容,见 KDE 的文档:http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B

 

哪些做法多半是安全的

前面我说“不能轻易修改”,暗示有些改动多半是安全的,这里有一份白名单,欢迎添加更多内容。

只要库改动不影响现有的可执行文件的二进制代码的正确性,那么就是安全的,我们可以先部署新的库,让现有的二进制程序受益。

  • 增加新的 class
  • 增加 non-virtual 成员函数
  • 修改数据成员的名称,因为生产的二进制代码是按偏移量来访问的,当然,这会造成源码级的不兼容。
  • 还有很多,不一一列举了。

欢迎补充

反面教材:COM

在 C++ 中以虚函数作为接口基本上就跟二进制兼容性说拜拜了。具体地说,以只包含虚函数的 class (称为 interface class)作为程序库的接口,这样的接口是僵硬的,一旦发布,无法修改。

比方说 M$ 的 COM,其 DirectX 和 MSXML 都以 COM 组件方式发布,我们来看看它的带版本接口 (versioned interfaces):

  • IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
  • IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3

换话句话说,每次发布新版本都引入新的 interface class,而不是在现有的 interface 上做扩充。这样一样不能兼容现有的代码,强迫客户端代码也要改写。

回过头来看看 C 语言,C/Posix 这些年逐渐加入了很多新函数,同时,现有的代码不用修改也能运行得很好。如果要用这些新函数,直接用就行了,也基本不会修改已有的代码。相反,COM 里边要想用 IXMLDOMDocument3 的功能,就得把现有的代码从 IXMLDOMDocument 全部升级到 IXMLDOMDocument3,很讽刺吧。

tip:如果遇到鼓吹在 C++ 里使用面向接口编程的人,可以拿二进制兼容性考考他。

解决办法

采用静态链接

这个是王道。在分布式系统这,采用静态链接也带来部署上的好处,只要把可执行文件放到机器上就行运行,不用考虑它依赖的 libraries。目前 muduo 就是采用静态链接。

通过动态库的版本管理来控制兼容性

这需要非常小心检查每次改动的二进制兼容性并做好发布计划,比如 1.0.x 系列做到二进制兼容,1.1.x 系列做到二进制兼容,而 1.0.x 和 1.1.x 二进制不兼容。《程序员的自我修养》里边讲过 .so 文件的命名与二进制兼容性相关的话题,值得一读。 

用 pimpl 技法,编译器防火墙

在头文件中只暴露 non-virtual 接口,并且 class 的大小固定为 sizeof(Impl*),这样可以随意更新库文件而不影响可执行文件。当然,这么做有多了一道间接性,可能有一定的性能损失。见 Exceptional C++ 有关条款和 C++ Coding Standards 101.

Java 是如何应对的

Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做。就不存在“不能增加虚函数”,“不能修改 data member” 等问题。在 Java 里边用面向 interface 编程远比 C++ 更通用和自然,也没有上面提到的“僵硬的接口”问题。

(待续)

posted @ 2011-03-09 10:48 陈硕 阅读(12610) | 评论 (6)编辑 收藏

C++ 工程实践(3):采用有利于版本管理的代码格式

     摘要: 版本管理(version controlling)是每个程序员的基本技能,C++ 程序员也不例外。版本管理的基本功能之一是追踪代码变化,让你能清楚地知道代码是如何一步步变成现在的这个样子,以及每次 check-in 都具体改动了哪些内部。所谓“有利于版本管理”的代码格式,就是指在代码中合理使用换行符,对 diff 工具友好,让 diff 的结果清晰明了地表达代码的改动。  阅读全文

posted @ 2011-03-05 15:16 陈硕 阅读(3257) | 评论 (7)编辑 收藏

仅列出标题
共6页: 1 2 3 4 5 6 
<2024年3月>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

导航

统计

常用链接

随笔分类

随笔档案

相册

搜索

最新评论

阅读排行榜

评论排行榜