ns-2 コード記述指針 for TCP Class

今回のエントリは、極一部の人にしか伝わらない上、かなりの長文となっています。具体的には、ns-2への不満を吐いてみましたという感じです。別にこの方針に限る必要はないですが、ある程度のコーディング指針を意識して持ってくれたらな、と思います。動いたら何でも良いじゃあんまりにもあんまりだ、とか思ったりします。

はじめに

ns-2 というネットワークシミュレータが存在します。私の研究分野ではデファクト・スタンダードとなりつつあるシミュレータです。しかしながら、(リリース版として公開されているオリジナルの ns-2 のソースコードを含めて)ns-2 のソースコードには、いろいろと思う所があるので、今回は、守ると保守などが楽になるのでは、と私が考えることについて少し述べてみます。

ns-2 のソースコードを見ていて、目に付くソースコードは以下の2つです。

  • TcpAgent(多くの TCP の基となるクラス)に直接修正を加える
  • クラス内のメンバ関数を一旦全てコピーしてクラス名を変えた後、修正を加える

これらの方法のどちらにも言える問題点として、作成したソースコードの保守が難しいという事が挙げられます。ns-2 は、現在でもそれなりの頻度でバージョンアップが繰り返されています。オリジナルのソースコードにすら、そういったコピー&ペーストのコーディングが多々見受けられますので、開発グループはバージョンアップを行う度にそれら(コピーした)全てのソースコードをチェックしなければなりません。

また、各研究者が独自で実装したソースコードに関しては、状況はさらに悪化します。そのように独自で実装されたソースコードに関しては、当たり前ですが、開発グループのサポート外です。加えて、そのソースコードを書いた人は既に卒業してもういない、という事もしばしば起こります。その結果、多くの研究者は既にバージョンアップがなされているにも関わらず、そのソースコードを実行させるためだけに、古いバージョンの ns-2 を使い続けてしまいます。ns-2 では、バージョンアップされる度に少なからずのバグフィックスも行われているので、古いバージョンを使い続けることは、そういった観点(シミュレーション結果にバグの影響が及ぶかもしれない)から見てもあまり良くありません。

上記に加えて、TcpAgent に直接修正を加える場合においては、別の問題も発生します。それは、基底クラス(TcpAgent)の肥大化です。現在の最新バージョン(ver. 2.29)においても、TcpAgent には 64 のメンバ関数(メソッド)と 141 ものメンバ変数が存在しており、既にクラスの全体を把握する事はかなり困難な状況となっています。クラス(ベースとなるようなクラスの場合は特に)は完全かつ最小の原則で設計を行っていくべきですが、TcpAgent に関しては、最小とはかけ離れた方向へ進んでいます。

そこで、今回は上記のような状況を改善するために、保守性を向上させるコーディング方法に関して検討します。具体的には、ベースクラス(TcpAgent)で用意されているヘルパ関数を有効に利用することにより新しいTCP のコードを記述するときの修正量をできるだけ最小に抑え、バージョンアップの際にも容易に新しいバージョンに移行できることを目指します。

TcpAgent 主要メンバ関数

コーディング方法について検討していく前に、この章では TcpAgent の主要メンバ関数についてその概要を述べます。TcpAgent には、大きく分けて次の3つの(重要な)機能が存在します。

  • 別のノードからパケットを受信するための recv()
  • 別のノードへパケットを送信するための send_much() および output()
  • TCPの持つ各タイマがタイムアウトした際に呼ばれる timeout()

これらの3つの主要メソッドについて、次節以降にその概要を述べます。

recv()

TcpAgent において、最も重要なメンバ関数の一つです。TCP においては、ACK パケットの受信を契機として、RTT や輻輳ウィンドウサイズの更新が行われるため、頻繁にコピー&ペーストされて修正されるメソッドでもあります。recv() メソッドの概要は以下のようになります。

void TcpAgent::recv(Packet* pkt, Handler*)
{
    
    .....
    
    recv_helper(pkt);
    if (tcph->seqno() > last_ack_) {
        recv_newack_helper(pkt);
    }
    else if (tpph->seqno() == last_ack_) {
        
        .....
        
        if (++dupacks_ == numdupacks_ && !noFastRetrans_) {
            dupack_action();
        }
        else if (duacks_ < numdupacks_ && singleup_) {
            send_one();
        }
    }
    
    .....
    
    send_much(0, 0, maxburst_);
}

ACKパケットを受信すると、そのパケットが正常なACKパケットかそうではないACKパケット(重複ACK)かを判断し、それぞれの場合において必要な処理を行います。また、RTTや輻輳ウィンドウサイズもこのrecv()メソッド(からコールされるメソッド)によって更新されます。その後、send_much()メソッドをコールし、現在の状態(輻輳ウィンドウサイズなど)に基づいてデータパケットの送信を行います。

send_much()、output()

send_much()およびoutput()メソッドは、通信相手に対してパケットを送信するメソッドです。これらのメソッドの概要は以下のようになります。

void TcpAgent::send_much(int force, int reason, int maxburst)
{
    send_idle_helper();
    
    .....
    
    while (t_seqno_ <= highest_ack_ + win &
        & t_seqno_ < curseq_) {
        if (overhead == 0 || force || qs_approved_) {
            output(t_seqno_, reason);
            npackets++;
            
            .....
            
        }
        
        .....
        
        if (maxburst && npackets == maxburst) break;
    }
    send_helper(maxburst);
}

void TcpAgent::output(int seqno, int reason)
{
    Packet* p = allocpkt();
    
    .....
    
    output_helper(p);
    
    ++ndatapack_;
    ndatabytes_ += databytes;
    send(p, 0);
    
    .....
    
}

TCPは輻輳ウィンドウサイズの情報を基にして、現在、自分が送信することのできる数のパケットを一度に送信します。send_much()は、その数だけoutput()メソッドをコールする役割を持つメソッドです。そして、output()メソッド内で、新しいパケットを作成し、必要な情報をTCPヘッダに付加した後、実際にパケットを送信します(send()メソッド)。

timeout()

TCPは、スムーズなデータ転送を実現するために、いくつかのタイマを持っています(代表的なものは再送タイマ)。timeout()メソッドは、これらのタイマがタイムアウトした時にコールされるメソッドです。以下に、概要を述べます。

void TcpAgent::timeout(int tno)
{
    if (tno == TCP_TIMER_RTX) {
        
        .....
        
        send_much(0, TCP_REASON_TIMEOUT, maxburst);
    }
    else timeout_nonrtx(tno);
}

timeout()メソッドは、tnoによってタイマの種類を見分けます。tcp.hでは、現在のところ以下の6種類が定義されています

#define TCP_TIMER_RTX      0
#define TCP_TIMER_DELSND   1
#define TCP_TIMER_BURSTSND 2
#define TCP_TIMER_DELACK   3
#define TCP_TIMER_Q        4
#define TCP_TIMER_RESET    5

この中で、TCP_TIMER_RTXが再送タイマに当たります。TcpAgentでは、timeout()メソッド内では、再送タイマのタイムアウト時の処理のみを記述し、残りのタイマに関してはtimeout_nonrtx()メソッドに記述するというポリシーのようです。

ヘルパメソッド

前章において、メソッドの概要を記述した際に、いくつかの箇所を強調表示しました。具体的には、以下に示す8箇所です。

  • recv_helper(pkt);
  • recv_newack_helper(pkt);
  • dupack_action();
  • send_one();
  • send_idle_helper();
  • send_helper(maxburst);
  • output_helper(p);
  • timeout_nonrtx(tno);

これらのメソッドは、ヘルパメソッドと呼ばれています(厳密には、いくつかのメソッドはヘルパメソッドではないが、ここでは同様に扱う)。TcpAgentには、拡張性を考えて、コード上の要所に上記のようなヘルパメソッドが埋め込まれています。TcpAgentにおいては、これらのほとんどのメソッドに関しては、何も記述がされていない状態になっています。そこで、独自のTCPを作成する際にはこれらのメソッドをオーバーライドし、その中に必要な記述を加えることにより、既存のTcpAgentへの依存度を最小限に抑えられます。そのため(上記にtimeout()メソッドを加えた)、9種類のメソッドを有効に利用することにより、バージョンアップの際にも容易に移行が可能となるソースコードを記述することが可能となります。以下に、それぞれのヘルパメソッドのコールされるタイミングや注意点について、簡単に記述します。

パケット受信に関するヘルパ関数

パケット受信に関するヘルパ関数は、recv_helper()、recv_newack_helper()、dupack_action()、send_one()の4種類です(厳密には、dupack_action()、send_one()はヘルパメソッドではない)。これらのメソッドのコールされるタイミングはほとんど同じです。

受信されると、まず始めにrecv_helper()メソッドがコールされます。その後、受信したACKパケットのシーケンス番号とラストACK(もっとも直近に受信した新規ACKパケットのシーケンス番号)を比較し、新規ACKであれば、recv_newack_helper()がコールされます。また、重複ACKであった場合、1、2回の重複であればsend_one()がコールされ、3回の重複であればdupack_action()がコールされます。どのヘルパメソッドにおいても、ヘルパメソッドがコールされた時点では、TCP内部で保持されているパラメータは前回ACKパケットを受信したときのままです。

recv_newack_helper()、dupack_action()、send_one()メソッドにおいては、TcpAgentにおいても必要な処理が記述されています。主な処理として、前者の2つのメソッドでは、輻輳ウィンドウサイズの更新(opencwnd()、slowdown())、最後のメソッドでは“1個だけ”新たなデータパケットを送信するというものがあります。そのため、これらのメソッドをオーバーライドした場合、最後にスーパークラスの同名メソッドをコールする(e.g., TcpAgent::recv_newack_helper();)必要があります。スーパークラスで行われている処理を含めて全ての処理を記述する方法もありますが、移行を容易にするために、スーパークラスで行われていることはできるだけそのままスーパークラスに行わせる方が良いと考えられます。

新しいTCPの提案の際に修正される多くは、輻輳ウィンドウの制御方式ですので、上記に挙げたヘルパメソッドを把握することは、移行を容易にする観点から見ても、他の問題点をできるだけ切り離す観点から見ても非常に重要と考えられます(opencwnd()、slowdown()メソッドに関しては、後述する)。

パケット送信に関するヘルパメソッド

パケット送信に関するヘルパメソッドは、send_much()メソッドからコールされるsend_idle_helper()、send_helper()メソッド、output()メソッドからコールされるoutput_helper()メソッドです。

send_idle_helper()、send_helper()メソッドはそれぞれ、send_much()がコールされた直後、終了する直前にコールされます。それぞれのタイミングにおいて何らかの処理を加える必要がある場合には、該当メソッドをオーバーライドして変更します。

send_helper()メソッドには、引数としてmaxburstが渡されます。通常、TCPは輻輳ウィンドウサイズの値を基に一度に送信するパケット数を決定しますが、TcpAgentではそれとは別に、一度に最大に送ることのできる数(メンバ変数maxburst_。デフォルト値は0)が指定されています。maxburstは、今回一度に送信したパケット数が記憶されているので、その値を基に次回、一度に送信するパケット数を決定する場合などは、send_helper()メソッドで更新を行うようです。

一方、output_helper()メソッドは、TCPヘッダに必要な情報を付加した後、実際にパケットを送信する直前にコールされます。そのため、このヘルパメソッドはTCPヘッダのオプション領域に何らかの情報を付加したい場合などに利用します。

タイマのタイムアウトに関する(ヘルパ)メソッド

パケットの送受信時とは異なり、タイマのタイムアウト時にはヘルパメソッドは定義されていません。そのため、タイマのタイムアウト時に何らかの処理を行う際には、timeout()、およびtimeout_nonrtx()メソッドを直接オーバーライドし、必要な処理を記述することになります。この際も、加える記述はできるだけ拡張した機能のみに抑え、残りの処理はスーパークラスの同名メソッドをコールする(e.g., TcpAgent::timeout();)形を取るのが良いと考えられます。

輻輳ウィンドウサイズ更新のためのメソッド

この章では、最後に、輻輳ウィンドウサイズを更新するためのメソッドopencwnd()、slowdown()について触れておきます。先にも述べたように新規TCPを提案する際、その多くは輻輳ウィンドウサイズの制御方式に終始します。そのため、そういったTCPを実装する際には、opencwnd()(輻輳ウィンドウサイズを増加させるためのメソッド)およびslowdown()(輻輳ウィンドウサイズを減少させるためのメソッド)をオーバーライドして修正する方法が最も良いと考えられます。

しかし、これには一つ問題があります。以下に、TcpAgentにおける上記のメソッドの宣言を示します。

void opencwnd();
void slowdown(int how);

これを見ると、輻輳ウィンドウサイズを更新するためのメソッドは仮想化されていないことが分かります。このため、これらのメソッドをオーバーライドして修正した場合、期待する動作が得られない可能性があります(現在、TcpAgent外部からコールされているのは、recv()およびtimeout()メソッドなので、その可能性は低いが)。

また、仮想化されていないということは、TcpAgentの設計者がそれらのメソッドをオーバーライドされることを想定していないことを意味します。

virtual を使うと、基底クラスのポインタから派生クラスの関数が呼び出せるようになります。ということは、基底クラスのポインタから派生クラスの関数を呼び出したくない時は virtual を指定してはいけない、ということになります。

・・・(中略)・・・


オーバーライドによって振舞いを変えられてはいけない場合は結構あります。


何でもかんでもvirtualにしてはいけない

実際、wnd_option_というパラメータ(このパラメータ値によって輻輳ウィンドウサイズの制御方法を変更する)を用意していることから考えても、その是非は別として、当初の設計では輻輳ウィンドウサイズの制御は、TcpAgentに集めることを想定していたと考えられます。その意味でも、これらのメソッドをオーバーライドして修正することは好ましくないと考えられます。

したがって、輻輳ウィンドウサイズの制御方式を修正するには、以下の2つの方法が考えられます。

  • recv_helper()など各種ヘルパメソッドに輻輳ウィンドウサイズの制御も組み込む
  • 新しいwnd_option_を定義して、TcpAgentに直接組み込む

既存のソースコードへの依存度を最小にするという観点から見ると前者の方が良いと考えられますが、実際には時と場合によって柔軟に対応する必要がありそうです。

クラステンプレートの利用

最後に、クラステンプレートの利用について述べます。TCPには、既に多くの種類(e.g., Reno, NewReno, SACK, ...)が存在しています。ns-2においては、それらのTCPは別々に実装されています(e.g., RenoTcpAgent, NewRenoTcpAgent, Sack1TcpAgent, ...)。そのため、場合によっては継承するクラスだけ違うほとんど同じクラスが複数必要となる場合があります。例えば、SACKオプションがあります。新しいTCPを作成したとき、しばしばSACKオプションが有効な場合と無効な場合の2種類のTCPが必要となります。このとき、SACKオプションの有効なTCPは、Sack1TcpAgentという一つのクラスとして存在しています。そのため、SACKオプションが有効な場合と無効な場合で2種類のTCPクラスを新たに作成しなければなりません。これも、コピー&ペーストによる複製が増える原因の一つとなります。

そこで、テンプレートクラスを利用します。新規TCPを作成する際には、最初に以下のような宣言にしておきます。

template <BaseTcp>
class XXXTcpAgent : public virtual BaseTcp {

...

};

そして、以下のように必要な分だけtypedefしておくことで、不必要な複製を防ぐことができます。

typedef XXXTcpAgent<RenoTcpAgent> XXXRenoTcpAgent;
typedef XXXTcpAgent<Sack1TcpAgent> XXXSackTcpAgent;

尚、注意する点として、テンプレートクラスを使用した場合、継承元(BaseTcp)のメンバ変数やオーバーライドしてないメソッドに関しては、this->xxxのようにしてアクセスします。また、このように作成したTCPクラス内で、bindした変数に関しては、typedefしたクラス(に関連付けられているtclクラス)の分だけ、ns-default.tclに記述する必要があります。

TCPクラスのスケルトン

この節では最後に、これまでに述べたの指針を満たしたTCPクラスのスケルトンを示します。

// ヘッダファイル

template <class BaseTcp>
class ExTcpAgent : public virtual BaseTcp {
public:
    ExTcpAgent();
    virtual ~ExTcpAgent();
    
    virtual void timeout(int tno);
    virtual void timeout_nonrtx(int tno);
    
protected:
    virtual void send_idle_helper();
    virtual void send_helper(int maxburst);
    virtual void output_helper(Packet* pkt);
    virtual void recv_helper(Packet* pkt);
    virtual void recv_newack_helper(Packet* pkt);
    virtual void dupack_action();
    virtual void send_one();
    
    // 他、tcp_bind_init_all()など必要なメソッドを追加する
    
private:
    typedef BaseTcp super;
};

// 必要なものだけ、typedefしておく
typedef ExTcpAgent<RenoTcpAgent> ExRenoTcpAgent;
typedef ExTcpAgent<Sack1TcpAgent> ExSackTcpAgent;
// ソースファイル

template <BaseTcp>
void ExTcpAgent<BaseTcp>::send_idle_helper()
{
    .....
    
    super::send_idle_helper();
}

template <BaseTcp>
void ExTcpAgent<BaseTcp>::send_helper(int maxburst)
{
    .....
    
    super::send_helper(maxburst);
}

.....

おわりに

今回は、TCPクラスのns-2コードを記述する際の指針について述べてきました。コピー&ペーストによるコーディングは、単純な見にくさの観点以外においても多くの問題点を含んでいます。そのため、できるだけそういったコーディングは控え、最小かつ完全の原則でクラス設計およびコーディングを行っていくことが重要であると考えられます。

また、せっかくC++で記述されてあるのだから、C++が持つ機能(テンプレートなど)も有効に利用することで、さらにバージョン間の移行が容易なコードが書けるのでは、と思います。