Linux(muduo網絡庫):19---muduo簡介之(使用教程:TCP網絡編程本質論、echo服務的實現、七步實現finger服務)

  • 本節主要介紹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(),因爲這樣有可能打亂數據的順序
  • 在非阻塞網絡編程中,爲什麼要使用應用層接收緩衝區?
  • 在非阻塞網絡編程中,如何設計並使用緩衝區?
    • 一方面我們希望減少系統調用,一次讀的數據越多越划算,那麼似乎應該準備一個大的緩衝區
    • 另一方面,我們希望減少內存佔用。如果有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”,然後客戶端關閉連接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章