IP→MAC対応をキャッシュするArpCacheとTTL設計
パケットを送るたびにARP requestを投げて応答を待つのは非効率です。一度解決したIP→MAC対応はしばらく使い回したい一方で、ネットワーク機器の入れ替えなどで対応関係が変わることもあるため、永久にキャッシュしておくわけにもいきません。この「使い回しつつ、いずれ捨てる」を扱うArpCacheを実装しました。
型設計
// l2-arp/include/l2arp/arp_cache.hpp
struct Ipv4AddressHash
{
std::size_t operator()(const Ipv4Address& ip) const noexcept;
};
class ArpCache
{
public:
explicit ArpCache(std::chrono::seconds ttl = std::chrono::seconds(60));
void insert(const Ipv4Address& ip, const MacAddress& mac);
std::optional<MacAddress> lookup(const Ipv4Address& ip) const;
private:
struct Entry
{
MacAddress mac;
std::chrono::steady_clock::time_point expires_at;
};
std::chrono::seconds ttl_;
std::unordered_map<Ipv4Address, Entry, Ipv4AddressHash> entries_;
};Ipv4Addressはstd::array<std::byte, 4>のエイリアスですが、標準ライブラリはstd::arrayに対するstd::hashの特殊化を提供していません。そのためstd::unordered_mapのキーとして直接使うにはハッシュ関数を自分で渡す必要があり、Ipv4AddressHashを第3テンプレート引数として指定しています。
// l2-arp/src/arp_cache.cpp
std::size_t Ipv4AddressHash::operator()(const Ipv4Address& ip) const noexcept
{
return (std::to_integer<std::size_t>(ip[0]) << 24)
| (std::to_integer<std::size_t>(ip[1]) << 16)
| (std::to_integer<std::size_t>(ip[2]) << 8)
| (std::to_integer<std::size_t>(ip[3]));
}4バイトを32bit整数に詰め直しているだけです。IPv4アドレスをそのまま整数として扱っているのと同じなので、典型的なアドレス分布に対して偏りの少ないハッシュになります。
TTLとlazy expiry
エントリの期限切れをどう扱うかには大きく2つの方式があります。バックグラウンドで定期的に全エントリを走査し期限切れを見つけ次第削除する能動的な方式と、何もせずlookupが呼ばれた瞬間に期限切れかどうかを判定するだけの受動的な方式です。今回は後者(lazy expiry)を選びました。タイマースレッドや、削除中に別スレッドが読みに来た場合の排他制御といった複雑さを持ち込まずに済むためです。トレードオフとして、一度insertされて二度とlookupされないエントリはメモリに残り続けます。今回のスコープでは実用上問題にならない単純化として許容しています。
期限の判定にはstd::chrono::steady_clockを使っています。壁時計(system_clock)はシステム時刻の変更(NTP補正など)で巻き戻ったり飛んだりする可能性があり、「TTLが経過したか」という単調増加する時間差の判定には向きません。
// l2-arp/src/arp_cache.cpp
void ArpCache::insert(const Ipv4Address& ip, const MacAddress& mac)
{
entries_[ip] = Entry{
.mac = mac,
.expires_at = std::chrono::steady_clock::now() + ttl_,
};
}
std::optional<MacAddress> ArpCache::lookup(const Ipv4Address& ip) const
{
auto it = entries_.find(ip);
if (it == entries_.end())
{
return std::nullopt;
}
if (std::chrono::steady_clock::now() >= it->second.expires_at)
{
return std::nullopt;
}
return it->second.mac;
}lookupは「エントリが存在しない」場合と「存在するが期限切れ」の場合をどちらもstd::nulloptにまとめています。呼び出し側の関心事は「今使えるMACアドレスがあるか」だけで、キャッシュミスの理由を区別する必要がないためです。insertはunordered_map::operator[]の挙動をそのまま使っており、既存キーがあれば新しいTTLで上書きします。
sleepなしでTTL失効をテストする
TTLが実際に効いているかをテストするには、通常は「短いTTLをセットしてsleepしてから確認する」という方法が思い浮かびますが、実時間のsleepに頼るテストは実行環境によって不安定になりがちです。
// l2-arp/tests/arp_cache_test.cpp
TEST_CASE("ArpCache: expired entry is not returned")
{
l2arp::ArpCache cache(std::chrono::seconds{0});
auto ip = ipv4(std::byte{192}, std::byte{168}, std::byte{1}, std::byte{1});
auto address = mac(std::byte{0x00}, std::byte{0x11}, std::byte{0x22}, std::byte{0x33}, std::byte{0x44}, std::byte{0x55});
cache.insert(ip, address);
CHECK_FALSE(cache.lookup(ip).has_value());
}TTLを0秒にすることでsleepを使わずに済みます。insert時のexpires_atは「その瞬間のnow()」になり、直後のlookupが呼ぶnow()はstd::chrono::steady_clockが単調増加であることが保証されているため、必ずそれ以降の時刻になります。実時間の経過を待つ代わりに、TTLの長さそのものをゼロにすることで、決定的に期限切れの状態を作っています。
RAIIとの関係
ArpCacheはデストラクタもコピー・ムーブの特殊メンバ関数も自分では書いていません。全部コンパイラ生成のままです。これはArpCacheが生のリソース(ファイルディスクリプタや生ポインタなど、デストラクタを持たない値)を直接持っていないためで、保持しているstd::unordered_map自身が内部でメモリ確保・解放のRAIIを実装済みだからです。ArpCacheがスコープを抜けると、暗黙生成されたデストラクタがentries_のデストラクタを呼び、そこでヒープ上のノードが解放されます。自分でRAIIのコードを書く必要があるのは、ソケットのfdのようなOSリソースを直接持つクラスだけで、ArpCacheはその対象になりません。
実機での確認
L2フレームキャプチャの実装で作成したarp_sniffにArpCacheを組み込み、観測したARPパケットのsender_ip/sender_macをinsertする前にlookupしておくことで、「初めて見たIPか、キャッシュ済みか」を表示するようにしました。
// l2-arp/apps/arp_sniff.cpp(抜粋)
bool known = cache.lookup(arp->sender_ip).has_value();
cache.insert(arp->sender_ip, arp->sender_mac);
std::cout << std::format(
"ARP {} sender={} ({}) target={} [{}]\n",
arp->operation == l2arp::ArpOperation::Request ? "request" : "reply",
l2arp::to_string(arp->sender_ip),
l2arp::to_string(arp->sender_mac),
l2arp::to_string(arp->target_ip),
known ? "cache hit" : "learned"
);実機で動かし、同一LAN上のホストからのARPを観測すると、初回は[learned]、TTL(60秒)以内の再観測は[cache hit]、60秒以上間隔を空けての再観測は再び[learned]になることを確認できました。合成データによるTTL=0のユニットテストとは別に、実際のTTLの長さで期限切れが機能することを実ネットワーク上で確認できたことになります。
参考リンク
- cppreference: std::unordered_map —— カスタムハッシュ関数を第3テンプレート引数として渡す方法を確認する際に参照します
- cppreference: std::chrono::steady_clock —— 単調増加が保証される時計の仕様です