在本文中,我將介紹我們實現的用於管理Linux網絡子系統的C++封裝程序,該程序使用Netlink協議和libnl3庫。在某些情況下,它顯著提升了配置功能的速度。我們還將探討為什麼決定放棄系統調用方式,並查看我們的性能基準測試結果。 

(5)Linux性能調優之網絡子系統_#linux

我們過去是如何管理 Linux 網絡子系統的

直到最近,在我們用於管理 Linux 中網絡接口、鄰居、路由和其他事項的項目中,我們使用了一個自定義的 exec 函數,其原型如下:

int exec(const std::string& cmd, std::string& result);

它接受一個 bash 命令作為輸入,並在網絡管理上下文中使用iproute2包。

exec 使用 popen 函數,該函數接收一個命令和一個系統調用。Popen 本身按順序調用:

  • pipe() 創建一個未命名的管道,
  • fork()創建子進程,
  • exec()運行傳遞的命令。

該函數pclose等待進程終止並關閉 I/O 流,其返回碼允許計算該函數的返回值exec

我們可以通過 cmd 參數傳遞 bash 命令——例如,ip link add Bridge type bridge要創建網絡橋接,結果行存儲傳遞的命令的輸出。

順便一提,我們專門使用 Linux 網絡協議棧來處理業務流量。我們知道存在各種開源網絡協議棧實現,包括DPDK和BPF技術,但我們的任務需要用到大量不同的協議。

使用 exec 非常簡單,這使我們能夠在構建MVP時快速添加新功能。但是,隨着時間的推移,我們決定放棄這種方法,原因如下:

性能- 每次執行調用都會導致進程創建、上下文切換、bash 命令執行和 Netlink 套接字創建。

代碼整潔性:exec 允許您在單個輸出中執行一組命令,每個命令依次執行,並用“&&”分隔。這消除了調用 iproute2 包的繁瑣結構。

錯誤處理——假設在多個 iproute2 調用中,一組命令發生錯誤。但是,如何知道執行的是哪個命令呢?

測試——由於我們無法正確模擬 Linux 網絡子系統的行為,因此很難使用exec來測試調用代碼。不過,我們後來添加了一個“wet”函數。該函數會檢查傳遞給 exec 的命令字符串是否返回預期結果——但這種測試方法仍然不夠理想。

(5)Linux性能調優之網絡子系統_#ARM_02

目前的方法運作方式如下:

下面的代碼段負責創建和配置網絡橋接。文章末尾,我將提供一個示例,演示如何使用我創建的包裝器執行相同的操作。

const std::string cmds = std::string("")
      + BASH_CMD + " -c \""
      + IP_CMD + " link add " + DOT1Q_BRIDGE_NAME + " up type bridge && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " mtu " + DOT1Q_BRIDGE_DEFAULT_MTU_STR + " && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " address " + gMacAddress.to_string() + " && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge vlan_filtering 1 && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge vlan_default_pvid 0 &&"
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge no_linklocal_learn 1";

std::string res;

int err = exec(cmds, res);
if (err)
    …

這是一個示例代碼,它允許我們從系統中獲取所需信息:

std::string res;
std::string cmd = "ip route get " + ipAddrStr + vrfName + " | sed -n 's/.*dev \\([^\\ ]*\\).*/\\1/p' | tr -d '\n'";
int ret = exec(cmd, res);

首先,我們使用 `ip route` 命令獲取指定 IP 地址和 VRF 名稱的路由。sed然後,我們使用該命令移除輸出結果,只保留接口名稱。最後,我們使用 `tr` 命令移除換行符。這種簡單的方法可以根據 IP 地址獲取特定 VRF 中的網絡接口名稱。順便一提,我遇到過一些函數,它們直接在調用代碼中處理 `iproute2` 的輸出字符串,從而進行輸出分析。

因此,我們逐漸放棄了它exec,開始創建我們自己的網絡子系統管理庫。 

我們是如何開發包裝器的

我們已經弄清楚了為什麼不滿意用於管理網絡子系統的 exec 函數,並且已經制定了新庫的需求:

  • 避免使用 popen 和調用iproute2,直接通過 Netlink API 與 Linux 內核通信。
  • 簡潔明瞭的界面,邏輯上與 iproute2 包的 CLI 非常接近。
  • 隱藏使用套接字和 Netlink 結構的工作。
  • 使用 GTest 和 GMock 對包裝器本身及其調用代碼進行可測試性測試。
  • 代碼簡潔且高質量(我們目前使用的是 C++ 20)。

就功能而言,該包裝器應該:

  • ip具有與這些命令類似的命令bridge; 
  • 能夠管理接口、路由、地址、FDB 記錄、VLAN 等。

新方法的主要區別在於進程與 Linux 內核的直接交互。 

(5)Linux性能調優之網絡子系統_#ARM_03

圖示展示瞭如何使用 Netlink API 的封裝器來操作網絡子系統。

我們分析了現有的解決方案,但未能找到滿足我們需求的 C++ 封裝庫:要麼功能有限,無法令人滿意;要麼這些解決方案直接使用了底層 Netlink API,這會使開發變得複雜。本項目大量使用了libnl3庫包來監聽網絡配置變更引起的內核更新。它提供了一個基於 Netlink 協議的 Linux 內核接口 API。

Netlink 是一種進程間通信 (IPC) 機制,主要用於內核空間進程和用户空間進程之間。它被開發為比 ioctl 更靈活的替代方案,並提供了一組主要用於配置和監控內核網絡子系統的接口。

該軟件包廣泛用於解決與 Linux 網絡協議棧相關的問題。然而,直接使用 libnl3 管理網絡子系統會帶來一些問題。首先,對於具備基本網絡知識的普通程序員來説,使用該庫非常耗時。其次,libnl3 不適合在高級代碼中高效使用,並且無法通過模擬 Linux 行為來解決我們的測試問題。

libnl3 的主要優勢在於其高級 Netlink API,它無需額外處理套接字和 Netlink 消息。這使得該庫成為實現我們 C++ 封裝器內部邏輯的理想工具。

為了實現所需的封裝功能,我們使用了 libnl3 套件中的兩個庫:libnl-core 和 libnl-route。前者包含用於管理 Netlink 套接字和緩存,以及生成和處理 Netlink 消息的函數。後者提供表示網絡實體的數據結構、用於配置這些實體的函數,以及將配置應用到系統的函數。

讓我們仔細看看 libnl-route 庫中用於處理網絡接口的 rtnl_link 結構體的示例。它的字段通過 set 方法進行設置,例如rtnl_link_set_mtu設置 MTU 和rtnl_link_set_master設置主接口索引等等。該結構體也包含類似的 get 方法。這類函數有很多,還有一些特定的屬性和方法,具體選擇取決於網絡接口類型。例如,對於 VLAN 接口,我們可以設置 VLAN ID 和其他標誌;對於 VRF 接口,我們可以設置路由表標識符。

libnl-route 中還有其他一些結構:

  • rtnl_addr — 地址, 
  • rtnl_neigh — 鄰居, 
  • rtnl_neightbl — 鄰居表,
  • rtnl_route — 路由, 
  • rtnl_rule — 路由規則, 
  • rtnl_tc — 交通管制。

我們立即決定,我們的庫應該包含一組對程序員友好的基本類,用於設置所需的網絡設置並將對象傳遞給一個特殊函數。該函數處理接收到的數據並將其應用到網絡子系統中。

例如,以下是使用純 libnl3 為接口設置 IP 地址的最簡代碼:

int setIpAddress(const std::string& ifName, const std::string& ipAddress)
{
   nl_sock* sock = nl_socket_alloc();
   int err = nl_connect(sock, NETLINK_ROUTE);
   if (err)
       return err;

   rtnl_addr* addrPtr = rtnl_addr_alloc();
   if (!addrPtr)
       return -NLE_NOMEM;

   int ifIndex = static_cast<int>(if_nametoindex(ifName.c_str()));
   if (!ifIndex)
       return -NLE_NODEV;

   rtnl_addr_set_ifindex(addrPtr, ifIndex);

   nl_addr* nlAddrPtr = nullptr;
   err = nl_addr_parse(ipAddress.c_str(), AF_UNSPEC, &nlAddrPtr);
   if (err)
       return err;

   err = rtnl_addr_set_local(addrPtr, nlAddrPtr);
   if (err)
       return err;

   err = rtnl_addr_add(sock, addrPtr, NLM_F_CREATE | NLM_F_EXCL);

   nl_addr_put(nlAddrPtr);
   rtnl_addr_put(addrPtr);
   nl_socket_free(sock);

   return err;
}

我們希望調用代碼中的函數大致像這樣,包含一個調用鏈和一個隱藏 libnl3 代碼的控制函數。在開發過程中,團隊需要大量使用網絡子系統,這種方法不僅降低了出錯的概率,而且顯著縮短了開發時間。

int setIpAddress(const std::string& ifName, const std::string& ipAddress)
{
   auto addrInfo = AddrInfo(ifName, ipAddress)
       .setScope(GLOBAL)
       ...
       .setValidLifeTime(50000);

   return add(addrInfo);
}

這就是以 Info 為後綴的一系列類的由來,它們以類似於 IP Link、IP Route、IP Address 等實用程序的方式組合網絡實體字段。例如,LinkInfo 類組合了所有類型通用的接口設置以及特定於特定類型的設置——例如 VLAN、Bridge 等,由於它們包含許多不同的字段,我們將其排除在外:

struct LinkInfo
{
    LinkInfo() = default;
    explicit LinkInfo(const std::string& linkName);
  explicit LinkInfo(const std::string& linkName, const std::string& linkType);
    ...
    std::optional<std::string> name;
    std::optional<uint32_t> index;
    std::optional<std::string> type;
    std::optional<std::string> linkLayerAddr;
    std::optional<uint32_t> mtu;
    ...
    std::optional<bool> isAdminUp;
    ...
    LinkInfo& setName(const std::string& value);
    LinkInfo& setType(const std::string& value);
    LinkInfo& setLinkLayerAddr(const std::string& value);
    LinkInfo& setMtu(const uint32_t value);
    ...
    LinkInfo& setAdmin(const bool enabled);
};

然而,這段代碼足以理解 LinkInfo 類及其類似類的基本原理。這些結構體的所有字段均為 std::optional 類型。它們通過便捷的調用鏈機制,使用 set 方法進行設置。

對於 LinkInfo 結構,關鍵字段是名稱。它允許我們在系統中唯一標識一個接口。其餘字段是可選的,例如索引、MTU、MAC 地址、類型等等。對於 FdbInfo 結構,關鍵字段是 MAC 地址和接口名稱,而可選字段包括目標地址、橋接名稱、VLAN ID、各種標誌以及 VxLAN 管理字段。 

每個 Info 實體都有自己的 Netlink 包裝器,例如 LinkNetlink 或 RouteNetlink。包裝器繼承自相應的接口,該接口提供了一組特定的函數:adddelget_ set...get

您可以使用負責套接字管理的工廠創建一個包裝器——這樣我們就不會創建多個相同類型的 Netlink 套接字:

struct NetlinkFactory final: public INetlinkFactory
{
    NetlinkFactory();
    virtual ~NetlinkFactory() override = default;
    std::shared_ptr<ILinkNetlink> createLinkNetlink() override;
    std::shared_ptr<IRouteNetlink> createRouteNetlink() override;
    ...
private:
    std::shared_ptr<ILinkNetlink> m_linkNetlink;
    std::shared_ptr<IRouteNetlink> m_routeNetlink;
    ...
    std::unordered_map<int, std::shared_ptr<nl_sock>> m_producerSocketMap;
}

本項目中所開發的庫的典型用途可以描述如下:

  1. 當網絡服務初始化時,會創建一個工廠。
  2. 藉助它,可以創建 Netlink 服務所需的包裝器。
  3. 該服務可以訂閲 Redis 數據庫中的變化以及核心中的事件——當相應的配置表發生變化或核心中的信息更新時,就會調用處理函數,這些處理函數使用 Netlink API 包裝器。

例如,用户通過命令行更改接口的管理狀態。這將觸發網絡服務所訂閲的數據庫的更改。它會調用一個處理函數,該函數會在 Linux 系統中設置接口的管理狀態。然後,該命令會被髮送到另一個服務,該服務會將端口的管理狀態應用到ASIC交換芯片。

測試包裝器和調用代碼

測試包裝器相關代碼通常分為兩部分:測試庫本身和測試使用它的代碼,其中我們使用“濕”對象。

為庫添加新功能總是需要編寫單元測試和集成測試。在第一類測試中,我們會填充 Info 基本結構體,並驗證它們是否已正確轉換為 libnl3 庫中的類型。

集成測試的一般測試場景如下所示:

  1. 填寫信息基本元素。
  2. 調用 Netlink 包裝器方法向內核發送 Netlink 消息並檢查返回碼。
  3. 從 Linux 網絡子系統接收數據,並將其與發送的數據進行比較。

所有項目組件都在 Docker 容器中構建。該庫的所有測試都在單獨的命名空間中運行,以避免影響構建容器:

sudo unshare --net build/tests/kfnetlink_tests --gtest_output=xml:junit-report.xml

這是一個用於測試使用我們包裝器方法的代碼的抽象示例。我們向被測類添加一個“濕”對象,並在測試中創建一個 Info 基本類型,設置一個預期,並調用所需的方法:

class IpMgrTest : public ::testing::Test
{
    std::unique_ptr<IpMgr> m_ipMgr;
    std::shared_ptr<MockAddrNetlink> m_mockAddrNetlink;
    ...
    IpMgrTest() : m_mockAddrNetlink(std::make_shared<StrictMock<MockAddrNetlink>>())
        , (m_ipMgr(m_mockAddrNetlink))
    {}
}

TEST_F(IntfTest, AddIPv6)
{
    auto addrInfo = AddrInfo("Ethernet1", "2001:db8:85a3::a2e:3:34/64")
        .setFamily(AF_INET6);

    EXPECT_CALL(*m_mockAddrNetlink, add(addrInfo))
        .Times(1)
        .WillOnce(Return(NLE_SUCCESS));
    ...
    ASSERT_TRUE(m_ipMgr->addIpAddress("Ethernet1", "2001:db8:85a3::a2e:3:34/64");
}

結果和基準

接下來我們來看最精彩的部分——結果。這裏有一個創建和設置橋接器的示例,以便與之前的版本進行比較: 

LinkInfo bridgeLinkInfo(DOT1Q_BRIDGE_NAME, "bridge");

int err = m_linkNetlink->add(bridgeLinkInfo);
if (err)
    ...

bridgeLinkInfo.setAdmin(true)
    .setLinkLayerAddr(macAddress)
    .setMtu(DOT1Q_BRIDGE_DEFAULT_MTU)
    .setBridgeVlanFiltering(true)
    .setBridgeVlanDefaultPvid(0)
    .setBridgeLinkLocalLearn(false);

err = m_linkNetlink->set(bridgeLinkInfo);
if (err)
    ...

新代碼更加簡潔。在示例中,封裝器的最終用户將獲得一個結構清晰、字段明確的高級控制界面,而無需對 Netlink 協議進行任何干預。

從內核獲取信息也很簡單。我們用所需的鍵字段填充結構體,調用 get 方法,即可獲得一個包含內核信息的對象作為輸出。但是,並非所有字段都可以設置,因此在檢索屬性值之前,我們需要檢查它是否存在,例如使用has_value

LinkInfo linkInfo("Ethernet1");
int err = m_linkNetlink->get(linkInfo);
if (err)
	...
if (linkInfo.isAdminUp.has_value() && linkInfo.isAdminUp.value())
{
	std::cout << "Ethernet1 is UP" << std::endl;
}

接下來,我們將評估所創建的封裝器的性能。為此,我們將把它的執行時間與使用 libnl3 編寫的exec函數std::system以及 iproute2 中用 C 語言實現的原始 Netlink 方法進行比較。我們還將看一個簡單的網絡場景示例,其中涉及創建虛擬接口。 

exec 和 system 函數不需要創建額外的對象——它們可以直接調用。原始的 Netlink 函數將採用 C 風格,但會為其、基於 libnl3 的函數以及已實現的包裝器創建特殊的類。這些類將消除每次函數調用時創建套接字和對象的需要。

基準測試主體本身包括調用被測函數並將配置回滾到默認狀態。我們不測量第二階段的耗時。

該代碼示例展示了一個使用 libnl3 實現的用於管理虛擬接口的類是如何編寫的。套接字在構造函數中初始化一次,之後在所有函數中都會使用它: 

class LibnlDummyManager
{

using LinkPtr =std::unique_ptr<rtnl_link, decltype(&rtnl_link_put)>;
using SocketPtr = std::unique_ptr<nl_sock, decltype(&nl_socket_free)>;

public:
   LibnlDummyManager() : m_socket(nl_socket_alloc(), nl_socket_free)
   {
       if (!m_socket)
           throw std::runtime_error("Failed to allocate memory to Netlink socket");

       if(int err = nl_connect(m_socket.get(), NETLINK_ROUTE); err)
           throw std::runtime_error("Failed to connect Netlink socket: " + std::string(nl_geterror(err)));
   }

   int createDummy(const std::string& dummyName)
   {
       LinkPtr linkPtr(rtnl_link_alloc(), rtnl_link_put);

       rtnl_link_set_name(linkPtr.get(), dummyName);
       rtnl_link_set_type(linkPtr.get(), "dummy");

       return rtnl_link_add(m_socket.get(), linkPtr.get(), NLM_F_REQUEST | NLM_F_CREATE | NLM_F_EXCL | NLM_F_ACK);
   }

   int setDummyUp(const std::string& dummyName)
   {
       LinkPtr oldLinkPtr(rtnl_link_alloc(), rtnl_link_put);
       LinkPtr newLinkPtr(rtnl_link_alloc(), rtnl_link_put);

       rtnl_link_set_name(oldLinkPtr.get(), dummyName.c_str());

       rtnl_link_set_name(newLinkPtr.get(), dummyName.c_str());
       rtnl_link_set_flags(newLinkPtr.get(), IFF_UP);

       return rtnl_link_change(m_socket.get(), oldLinkPtr.get(), newLinkPtr.get(), 0);
   }

private:
   SocketPtr m_socket;
};

WrapperDummyManager 類的結構類似:它使用我們的包裝器增加了一個抽象層,構造函數會接收一個 Netlink 包裝器的實例,該實例之前由工廠創建。

class WrapperDummyManager
{
public:
    WrapperDummyManager(std::shared_ptr<ILinkNetlink> linkNetlink)
        : m_linkNetlink(linkNetlink)
    {}

    int createDummy(const std::string& dummyName)
    {
        return m_linkNetlink->add(kfnl::LinkInfo(dummyName, "dummy"));
    }

    int setDummyUp(const std::string& dummyName)
    {
        return m_linkNetlink->set(kfnl::LinkInfo(dummyName).setAdmin(true));
    }

private:
    std::shared_ptr<ILinkNetlink> m_linkNetlink;
};

為了測量執行時間,我們使用了Google Benchmark。測試在一台配備 Intel Core i5-1235U 處理器、運行 Ubuntu 22.04 的計算機上進行:

運行 環境(12 x 4400 MHz CPU)
CPU 緩存
  L1 數據緩存 48 KiB (x6)
  L1 指令緩存 32 KiB (x6)
  L2 統一緩存 1280 KiB ( x6)
  L3 統一緩存 12288 KiB (x1)平均
負載 :0.91 , 0.92 , 0.92

對於上述函數,我們獲得了以下執行時間指標:

基準 

時間

 中央處理器

迭代

執行官

1592 美國

102 美國

7438

std::system

1552 美國

73.3 美國

9222

C++ 封裝器

186人

185 美製

3812

libnl3

168 美國

167人

3854

原始 Netlink

152 美國

151

4759

設置虛擬接口管理狀態所需的時間測量: 

基準

時間

中央處理器

迭代

執行官

1228 美國

70.3 美國

9639

std::system

1210 美國

52.4 美製

12827

C++ 封裝器

63.2 美製

61.6 美國

11277

libnl3

46.0 美國

44.0 美國

16314

原始 Netlink

36.1 美國

34.6 美製

19284

這些函數的std::system執行exec時間相近,但std::system兩者都略快一些。使用原始 Netlink 庫的函數速度最快;使用 libnl3 庫的函數慢 10-16 微秒。我們的封裝函數性能略差,比 libnl3 函數慢 17-18 微秒。這是我們為額外的抽象層、調用鏈以及代碼的“增強功能”和可測試性所付出的代價。

讓我們編譯庫和用於測量虛擬接口添加時間的代碼,並啓用 O2 優化標誌。現在,封裝函數和 libnl3 函數之間的差異幾乎可以忽略不計。我們的項目正是基於此優化級別構建的,這意味着使用 C++ 封裝函數代替原始的 Netlink 和 libnl3 不僅解決了我們所有的問題,而且性能也沒有顯著下降。

基準

時間

中央處理器

迭代

執行官

1502 美國

88.7 美國

7839

std::system

1490 美國

70.2 微克

10324

C++ 封裝器

156人

155 美國

4397

libnl3

154人

154人

4531

原始 Netlink  

144人

143人

5105

exec與使用系統調用的函數相比std::system,包裝器的速度明顯更高(無論是否進行優化):對於創建接口的操作,我們的函數比通過調用快約 9 倍exec,而對於設置管理狀態,快約 19 倍!

結論

Netlink 是 Linux 系統中一種現代便捷的網絡配置管理方式,但在 C++ 代碼中使用它必須滿足某些特定要求。通過 Netlink API 直接與內核交互,相比使用系統調用,可以將執行時間縮短一個數量級。 

然而,使用 Netlink 和類似工具通常既費時又複雜,因此封裝器就派上了用場。它們可以提高代碼的質量、可讀性和可測試性,使程序員能夠專注於應用程序的核心邏輯。