- 本节主要介绍muduo网络库的使用,其设计与实现将在后面系列文章讲解
- muduo只支持Linux 2.6.x下的并发非阻塞TCP网络编程,它的核心是每个IO线程一个事件循环,把IO事件分发到回调函数上
- 陈硕先生编写muduo网络库的目的之一就是简化日常的TCP网络编程,让程序员能把精力集中在业务逻辑的实现上,而不要天天和Sockets API较劲。借用Brooks的话说(http://www.cs.nottac.uk/~cah/G51ISS/Documents/NoSilverBullet.html),我希望muduo能减少网络编程中的偶发复杂性(accidental complexity)
一、TCP网络编程本质论
- 基于事件的非阻塞网络编程是编写高性能并发网络服务程序的主流模式:
- 头一次使用这种方式编程通常需要转换思维模式:把原来“主动调用recv()来接收数据,主动调用accept()来接受新连接,主动调用send()来发送数据”的思路换成“注册一个收数据的回调,网络库收到数 据会调用我,直接把数据提供给我,供我消费。注册一个接受连接的回调,网络库接受了新连接会回调我,直接把新的连接对象传给我,供我使用。需要发送数据的时候,只管往连接中写,网络库会负责无阻塞地发送。”
- 这种编程方式有点像Win32的消息循环,消息循环中的代码应该避免阻塞,否则会让整个窗口失去响应,同理,事件处理函数也应该避 免阻塞,否则会让网络服务失去响应
- 我认为,TCP网络编程最本质的是处理三个半事件:
- 1.连接的建立,包括服务端接受(accept)新连接和客户端成功发起(connect)连接。TCP连接一旦建立,客户端和服务端是平等的,可以各自收发数据
- 2.连接的断开,包括主动断开(close、shutdown)和被动断开 (read()返回0)
- 3.消息到达,文件描述符可读。这是最为重要的一个事件,对它的处理方式决定了网络编程的风格(阻塞还是非阻塞,如何处理分包, 应用层的缓冲如何设计,等等)
- 3.5消息发送完毕,这算半个。对于低流量的服务,可以不必关心这个事件;另外,这里的“发送完毕”是指将数据写入操作系统的缓冲区,将由TCP协议栈负责数据的发送与重传,不代表对方已经收到数据
细节问题
- 如果要主动关闭连接,如何保证对方已经收到全部数据?如果应用层有缓冲(这在非阻塞网络编程中是必需的,见下文),那么如何保证先发送完缓冲区中的数据,然后再断开连接?直接调用close()恐怕是不行的
- 如果主动发起连接,但是对方主动拒绝,如何定期(带back-off地)重试?
- 非阻塞网络编程该用边沿触发(edge trigger)还是水平触发(level trigger)?
- 如果是水平触发,那么什么时候关注EPOLLOUT事件?会不会造成busy-loop?
- 如果是边沿触发,如何防止漏读造成的饥饿?
- epoll()一定比poll()快吗?
- 在非阻塞网络编程中,为什么要使用应用层发送缓冲区?假设应用程序需要发送40kB数据,但是操作系统的TCP发送缓冲区只有25kB剩余空间,那么剩下的15kB数据怎么办?如果等待OS缓冲区可用,会阻塞当前线程,因为不知道对方什么时候收到并读取数据。因此网络库应该把这15kB数据缓存起来,放到这个TCP链接的应用层发送缓冲区中,等socket变得可写的时候立刻发送数据,这样“发送”操作不会阻塞。如果应用程序随后又要发送50kB数据,而此时发送缓冲区中尚有未发送的数据(若干kB),那么网络库应该将这50kB数据追加到发送缓冲区的末尾,而不能立刻尝试write(),因为这样有可能打乱数据的顺序
- 在非阻塞网络编程中,为什么要使用应用层接收缓冲区?
- 假如一次读到的数据不够一个完整的数据包,那么这些已经读到的数据是不是应该先暂存在某个地方,等剩余的数据收到之后再一并处理?见lighttpd关于\r\n\r\n分包的bug(https://redmine.lighttpd.net/issues/2105)
- 假如数据是一个字节一个字节地到达,间隔10ms,每个字节触发一次文件描述符可读(readable)事件,程序是否还能正常工作?lighttpd在这个问题上出过安全漏洞(https://download.lighttpd.net/lighttpd/security/lighttpd_sa_2010_01.txt)
- 在非阻塞网络编程中,如何设计并使用缓冲区?
- 一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区
- 另一方面,我们希望减少内存占用。如果有10000个并发连接, 每个连接一建立就分配各50kB的读写缓冲区(s)的话,将占用1GB内存, 而大多数时候这些缓冲区的使用率很低
- muduo用readv()结合栈上空间巧妙地解决了这个问题
- 如果使用发送缓冲区,万一接收方处理缓慢,数据会不会一直堆积在发送方,造成内存暴涨?如何做应用层的流量控制?
- 如何设计并实现定时器?并使之与网络IO共用一个线程,以避免锁
- 这些问题在muduo的代码中可以找到答案
二、echo服务实现
- muduo的使用非常简单,不需要从指定的类派生,也不用覆写虚函数,只需要注册几个回调函数去处理前面提到的三个半事件就行了
echo回显服务代码如下
- ①定义EchoServer class,不需要派生自任何基类
// echo.h #ifndef MUDUO_EXAMPLES_SIMPLE_ECHO_ECHO_H #define MUDUO_EXAMPLES_SIMPLE_ECHO_ECHO_H #include <muduo/net/TcpServer.h> class EchoServer { public: // 构造函数 EchoServer(muduo::net::EventLoop* loop, const muduo::net::InetAddress& listenAddr); // 启动服务 void start(); private: // 响应客户端连接 void onConnection(const muduo::net::TcpConnectionPtr& conn); // 响应客户端消息 void onMessage(const muduo::net::TcoConnectionPtr& conn, muduo::net::Buffer* buf, muduo::Timestamp time); // TcpServer对象 muduo::net::TcpServer server_; }; #endif // MUDUO_EXAMPLES_SIMPLE_ECHO_ECHO_H
- ②实现代码如下:
- onConnection()、onMessage():这两个函数体现了“基于事件编程”的典型做法,即程序主体是被动等待事件发生,事件发生之后网络库会调用(回调)事先注册的时间处理函数(event handler)
// echo.cc #include "examples/simple/echo/echo.h" #include "muduo/base/Logging.h" using std::placeholders::_1; using std::placeholders::_2; using std::placeholders::_3; // using namespace muduo; // using namespace muduo::net; // 构造TcpServer对象,为TcpServer对象注册回调函数 EchoServer::EchoServer(muduo::net::EventLoop* loop, const muduo::net::InetAddress& listenAddr) :server_(loop, listenAddr, "EchoServer") { server_.setConnectionCallback(std::bind(&EchoServer::onConnection, this, _1)); server_.setMessageCallback(std::bind(&EchoServer::onMessage, this, _1, _2, _3)); } // 调用TcpServer对象的start()函数,启动服务 void EchoServer::start() { server_.start(); } // 接收客户端连接,并打印相关信息 void EchoServer::onConnection(const muduo::net::TcpConnectionPtr& conn) { // perrAddress(): 返回对方地址(以InetAddress对象表示IP和port) // localAddress(): 返回本地地址(以InetAddress对象表示IP和port) // connected():返回bool值, 表明目前连接是建立还是断开 LOG_INFO << "EchoServer - " << conn->perrAddress().toIpPort() << "->" << conn->localAddress().toIpPort() << " is " << (conn->connected() ? "UP" : "DOWN"); } // 接收客户端数据,并将数据原封不动的返回给客户端 // conn参数: 收到数据的那个TCP连接 // buf参数: 是已经收到的数据,buf的数据会累积,直到用户从中取走(retrieve) 数据。注意buf是指针,表明用户代码可以修改(消费)buffer // time参数: 是收到数据的确切时间,即epoll_wait()返回的时间,注意这个时间通常比read()发生的时间略早,可以用于正确测量程序的消息处理延迟。另外,Timestamp对象采用pass-by-value,而不是pass-by-(const)reference, 这是有意的,因为在x86-64上可以直接通过寄存器传参 void EchoServer::onMessage(const muduo::net::TcpConnectionPtr& conn, muduo::net::Buffer* buf, muduo::Timestamp time) { // 将接收到的数据封装为一个消息 muduo::string msg(buf->retrieveAllAsString()); LOG_INFO << conn->name() << " echo " << msg.size() << " bytes, " << "data received at " << time.toString(); // 将消息再回送回去, 不必担心send(msg)是否完整地发送了数据,muduo网络库会帮我们管理发送缓冲区 conn->send(msg); }
- ③在main()函数用EventLoop让整个程序跑起来
// main.cc #include "examples/simple/echo/echo.h" #include "muduo/base/Logging.h" #include "muduo/net/EventLoop.h" #include <unistd.h> // using namespace muduo; // using namespace muduo::net; int main() { // 1.打印进程ID LOG_INFO << "pid = " << getpid(); // 2.初始化EventLoop、InetAddress对象, muduo::net::EventLoop loop; muduo::net::InetAddress listenAddr(2007); // 3.创建EchoServer, 启动服务 EchoServer server(&loop, listenAddr); server.start(); // 4.事件循环 loop.loop(); }
演示效果
- 启动服务端,监听端口为2000
- 使用telent去连接,左侧服务端显示新连接的客户端,右侧客户端收到“hello”消息
- 右侧客户端给服务端发送一条消息“HelloWorld”,客户端收到相同的“HelloWorld”回复
- 输入“ctrl+]”,然后输入quit退出telnet,左侧服务端显示客户端断开侵袭
- 总结:
- 完整的代码参阅:https://github.com/dongyusheng/csdn-code/blob/master/muduo/examples/simple/echo/
- 这个几十行的小程序实现了一个单线程并发的echo服务程序,可以同时处理多个连接
- 这个程序用到了TcpServer、EventLoop、TcpConnection、Buffer这几个class,也大致反映了这几个class的典型用法,后文还会详细介绍这几个class。注意,以后的代码大多会省略namespace
三、七步实现finger服务
- Python Twisted是一款非常好的网络库,它也采用Reactor作为网络编程的基本模型,所以从使用上与muduo颇有相似之处(当然,muduo没有deferreds)
- finger是Twisted文档的一个经典例子,本文展示如何用muduo来实现最简单的finger服务端
- 限于篇幅,只实现finger01~finger07
- 源码参阅:https://github.com/dongyusheng/csdn-code/tree/master/muduo/examples/twisted/finger
代码如下:
- ①拒绝连接:什么都不做,程序空等
// finger01.cc #include "muduo/net/EventLoop.h" using namespace muduo; using namespace muduo::net; int main() { EventLoop loop; loop.loop(); }
- ②接受新连接:在1079端口侦听新连接,接受连接之后什么都不做,程序空等。muduo会自动丢弃收到的数据
// finger02.cc #include "muduo/net/EventLoop.h" #include "muduo/net/TcpServer.h" using namespace muduo; using namespace muduo::net; int main() { EventLoop loop; TcpServer server(&loop, InetAddress(1079), "Finger"); server.start(); loop.loop(); }
- ③主动断开连接:接受新连接之后主动断开。以下省略头文件 和namespace
// finger03.cc #include "muduo/net/EventLoop.h" #include "muduo/net/TcpServer.h" using namespace muduo; using namespace muduo::net; void onConnection(const TcpConnectionPtr& conn) { if (conn->connected()) { conn->shutdown(); } } int main() { EventLoop loop; TcpServer server(&loop, InetAddress(1079), "Finger"); server.setConnectionCallback(onConnection); server.start(); loop.loop(); }
- ④读取用户名,然后断开连接:如果读到一行以\r\n结尾的消 息,就断开连接。注意这段代码有安全问题,如果恶意客户端不断发送 数据而不换行,会撑爆服务端的内存。另外,Buffer::findCRLF()是线性 查找,如果客户端每次发一个字节,服务端的时间复杂度为O(N2 ),会 消耗CPU资源
// finger04.cc #include "muduo/net/EventLoop.h" #include "muduo/net/TcpServer.h" using namespace muduo; using namespace muduo::net; void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime) { if (buf->findCRLF()) { conn->shutdown(); } } int main() { EventLoop loop; TcpServer server(&loop, InetAddress(1079), "Finger"); server.setMessageCallback(onMessage); server.start(); loop.loop(); }
- ⑤取用户名、输出错误信息,然后断开连接:如果读到一行 以\r\n结尾的消息,就发送一条出错信息,然后断开连接。安全问题同上
// finger05.cc #include "muduo/net/EventLoop.h" #include "muduo/net/TcpServer.h" using namespace muduo; using namespace muduo::net; void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime) { if (buf->findCRLF()) { conn->send("No such user\r\n"); conn->shutdown(); } } int main() { EventLoop loop; TcpServer server(&loop, InetAddress(1079), "Finger"); server.setMessageCallback(onMessage); server.start(); loop.loop(); }
- ⑥空的UserMap里查找用户:从一行消息中拿到用户名 (L30),在UserMap里查找,然后返回结果。安全问题同上
// finger06.cc #include "muduo/net/EventLoop.h" #include "muduo/net/TcpServer.h" #include <map> using namespace muduo; using namespace muduo::net; typedef std::map<string, string> UserMap; UserMap users; string getUser(const string& user) { string result = "No such user"; UserMap::iterator it = users.find(user); if (it != users.end()) { result = it->second; } return result; } void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime) { const char* crlf = buf->findCRLF(); if (crlf) { string user(buf->peek(), crlf); conn->send(getUser(user) + "\r\n"); buf->retrieveUntil(crlf + 2); conn->shutdown(); } } int main() { EventLoop loop; TcpServer server(&loop, InetAddress(1079), "Finger"); server.setMessageCallback(onMessage); server.start(); loop.loop(); }
- ⑦往UserMap里添加一个用户:与finger06.cc几乎完全一样,只是main()函数多了第一行代码
// finger07.cc #include "muduo/net/EventLoop.h" #include "muduo/net/TcpServer.h" #include <map> using namespace muduo; using namespace muduo::net; typedef std::map<string, string> UserMap; UserMap users; string getUser(const string& user) { string result = "No such user"; UserMap::iterator it = users.find(user); if (it != users.end()) { result = it->second; } return result; } void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime) { const char* crlf = buf->findCRLF(); if (crlf) { string user(buf->peek(), crlf); conn->send(getUser(user) + "\r\n"); buf->retrieveUntil(crlf + 2); conn->shutdown(); } } int main() { users["schen"] = "Happy and well"; //多了这一行 EventLoop loop; TcpServer server(&loop, InetAddress(1079), "Finger"); server.setMessageCallback(onMessage); server.start(); loop.loop(); }
演示效果
- 可以用telnet扮演客户端来测试我们的简单finger服务端
- 在一个命令行启动finer07程序,监听端口为1079
- 第一次使用telent去连接,输入“muduo”,显示无此用户,然后客户端关闭连接
- 第一次使用telent去连接,输入“schen”,显示“Happy and well”,然后客户端关闭连接