127-0-0-1 之本機網絡通信過程知多少 ?!

大家好,我是飛哥!

我們拆解完了 Linux 網絡包的接收過程,也搞定了網絡包的發送過程。內核收發網絡包整體流程就算是摸清楚了。

正在飛哥對這兩篇文章洋洋得意的時候,收到了一位讀者的發來的提問:“飛哥, 127.0.0.1 本機網絡 IO 是咋通信的”。額,,這題好像之前確實沒講到。。

現在本機網絡 IO 應用非常廣。在 php 中 一般 Nginx 和 php-fpm 是通過 127.0.0.1 來進行通信的。在微服務中,由於 side car 模式的應用,本機網絡請求更是越來越多。所以,我想如果能深度理解這個問題在實踐中將非常的有意義,在此感謝 @文武 的提出。

今天咱們就把 127.0.0.1 的網絡 IO 問題搞搞清楚!爲了方便討論,我把這個問題拆分成兩問:

鋪墊完畢,拆解正式開始!!

一、跨機網路通信過程

在開始講述本機通信過程之前,我們還是先回顧一下跨機網絡通信。

1.1 跨機數據發送

從 send 系統調用開始,直到網卡把數據發送出去,整體流程如下:

在這幅圖中,我們看到用戶數據被拷貝到內核態,然後經過協議棧處理後進入到了 RingBuffer 中。隨後網卡驅動真正將數據發送了出去。當發送完成的時候,是通過硬中斷來通知 CPU,然後清理 RingBuffer。

不過上面這幅圖並沒有很好地把內核組件和源碼展示出來,我們再從代碼的視角看一遍。

等網絡發送完畢之後。網卡在發送完畢的時候,會給 CPU 發送一個硬中斷來通知 CPU。收到這個硬中斷後會釋放 RingBuffer 中使用的內存。

1.2 跨機數據接收

當數據包到達另外一臺機器的時候,Linux 數據包的接收過程開始了。

當網卡收到數據以後,CPU 發起一箇中斷,以通知 CPU 有數據到達。當 CPU 收到中斷請求後,會去調用網絡驅動註冊的中斷處理函數,觸發軟中斷。ksoftirqd 檢測到有軟中斷請求到達,開始輪詢收包,收到後交由各級協議棧處理。當協議棧處理完並把數據放到接收隊列的之後,喚醒用戶進程(假設是阻塞方式)。

我們再同樣從內核組件和源碼視角看一遍。

1.3 跨機網絡通信匯總

二、本機發送過程

在第一節中,我們看到了跨機時整個網絡發送過程(嫌第一節流程圖不過癮,想繼續看源碼瞭解細節的同學可以參考 拆解 Linux 網絡包發送過程) 。

在本機網絡 IO 的過程中,流程會有一些差別。爲了突出重點,將不再介紹整體流程,而是隻介紹和跨機邏輯不同的地方。有差異的地方總共有兩個,分別是路由驅動程序

2.1 網絡層路由

發送數據會進入協議棧到網絡層的時候,網絡層入口函數是 ip_queue_xmit。在網絡層裏會進行路由選擇,路由選擇完畢後,再設置一些 IP 頭、進行一些 netfilter 的過濾後,將包交給鄰居子系統。

對於本機網絡 IO 來說,特殊之處在於在 local 路由表中就能找到路由項,對應的設備都將使用 loopback 網卡,也就是我們常見的 lo。

我們來詳細看看路由網絡層裏這段路由相關工作過程。從網絡層入口函數 ip_queue_xmit 看起。

//file: net/ipv4/ip_output.c
int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl)
{
 //檢查 socket 中是否有緩存的路由表
 rt = (struct rtable *)__sk_dst_check(sk, 0);
 if (rt == NULL) {
  //沒有緩存則展開查找
  //則查找路由項, 並緩存到 socket 中
  rt = ip_route_output_ports(...);
  sk_setup_caps(sk, &rt->dst);
 }

查找路由項的函數是 ip_route_output_ports,它又依次調用到 ip_route_output_flow、__ip_route_output_key、fib_lookup。調用過程省略掉,直接看 fib_lookup 的關鍵代碼。

//file:include/net/ip_fib.h
static inline int fib_lookup(struct net *net, const struct flowi4 *flp,
        struct fib_result *res)
{
 struct fib_table *table;

 table = fib_get_table(net, RT_TABLE_LOCAL);
 if (!fib_table_lookup(table, flp, res, FIB_LOOKUP_NOREF))
  return 0;

 table = fib_get_table(net, RT_TABLE_MAIN);
 if (!fib_table_lookup(table, flp, res, FIB_LOOKUP_NOREF))
  return 0;
 return -ENETUNREACH;
}

在 fib_lookup 將會對 local 和 main 兩個路由表展開查詢,並且是先查 local 後查詢 main。我們在 Linux 上使用命令名可以查看到這兩個路由表, 這裏只看 local 路由表(因爲本機網絡 IO 查詢到這個表就終止了)。

#ip route list table local
local 10.143.x.y dev eth0 proto kernel scope host src 10.143.x.y
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1

從上述結果可以看出,對於目的是 127.0.0.1 的路由在 local 路由表中就能夠找到了。fib_lookup 工作完成,返回__ip_route_output_key 繼續。

//file: net/ipv4/route.c
struct rtable *__ip_route_output_key(struct net *net, struct flowi4 *fl4)
{
 if (fib_lookup(net, fl4, &res)) {
 }
 if (res.type == RTN_LOCAL) {
  dev_out = net->loopback_dev;
  ...
 }

 rth = __mkroute_output(&res, fl4, orig_oif, dev_out, flags);
 return rth;
}

對於是本機的網絡請求,設備將全部都使用 net->loopback_dev, 也就是 lo 虛擬網卡。

接下來的網絡層仍然和跨機網絡 IO 一樣,最終會經過 ip_finish_output,最終進入到 鄰居子系統的入口函數 dst_neigh_output 中。

本機網絡 IO 需要進行 IP 分片嗎?因爲和正常的網絡層處理過程一樣會經過 ip_finish_output 函數。在這個函數中,如果 skb 大於 MTU 的話,仍然會進行分片。只不過 lo 的 MTU 比 Ethernet 要大很多。通過 ifconfig 命令就可以查到,普通網卡一般爲 1500,而 lo 虛擬接口能有 65535。

在鄰居子系統函數中經過處理,進入到網絡設備子系統(入口函數是 dev_queue_xmit)。

2.2 網絡設備子系統

網絡設備子系統的入口函數是 dev_queue_xmit。簡單回憶下之前講述跨機發送過程的時候,對於真的有隊列的物理設備,在該函數中進行了一系列複雜的排隊等處理以後,才調用 dev_hard_start_xmit,從這個函數 再進入驅動程序來發送。在這個過程中,甚至還有可能會觸發軟中斷來進行發送,流程如圖:

但是對於啓動狀態的迴環設備來說(q->enqueue 判斷爲 false),就簡單多了。沒有隊列的問題,直接進入 dev_hard_start_xmit。接着進入迴環設備的 “驅動” 裏的發送回調函數 loopback_xmit,將 skb “發送”出去。

我們來看下詳細的過程,從網絡設備子系統的入口 dev_queue_xmit 看起。

//file: net/core/dev.c
int dev_queue_xmit(struct sk_buff *skb)
{
 q = rcu_dereference_bh(txq->qdisc);
 if (q->enqueue) {//迴環設備這裏爲 false
  rc = __dev_xmit_skb(skb, q, dev, txq);
  goto out;
 }

 //開始迴環設備處理
 if (dev->flags & IFF_UP) {
  dev_hard_start_xmit(skb, dev, txq, ...);
  ...
 }
}

在 dev_hard_start_xmit 中還是將調用設備驅動的操作函數。

//file: net/core/dev.c
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
   struct netdev_queue *txq)
{
 //獲取設備驅動的回調函數集合 ops
 const struct net_device_ops *ops = dev->netdev_ops;

 //調用驅動的 ndo_start_xmit 來進行發送
 rc = ops->ndo_start_xmit(skb, dev);
 ...
}

2.3 “驅動” 程序

對於真實的 igb 網卡來說,它的驅動代碼都在 drivers/net/ethernet/intel/igb/igb_main.c 文件裏。順着這個路子,我找到了 loopback 設備的 “驅動” 代碼位置:drivers/net/loopback.c。在 drivers/net/loopback.c

//file:drivers/net/loopback.c
static const struct net_device_ops loopback_ops = {
 .ndo_init      = loopback_dev_init,
 .ndo_start_xmit= loopback_xmit,
 .ndo_get_stats64 = loopback_get_stats64,
};

所以對 dev_hard_start_xmit 調用實際上執行的是 loopback “驅動” 裏的 loopback_xmit。爲什麼我把 “驅動” 加個引號呢,因爲 loopback 是一個純軟件性質的虛擬接口,並沒有真正意義上的驅動,它的工作流程大致如圖。

我們再來看詳細的代碼。

//file:drivers/net/loopback.c
static netdev_tx_t loopback_xmit(struct sk_buff *skb,
     struct net_device *dev)
{
 //剝離掉和原 socket 的聯繫
 skb_orphan(skb);

 //調用netif_rx
 if (likely(netif_rx(skb) == NET_RX_SUCCESS)) {
 }
}

在 skb_orphan 中先是把 skb 上的 socket 指針去掉了(剝離了出來)。

注意,在本機網絡 IO 發送的過程中,傳輸層下面的 skb 就不需要釋放了,直接給接收方傳過去就行了。總算是省了一點點開銷。不過可惜傳輸層的 skb 同樣節約不了,還是得頻繁地申請和釋放。

接着調用 netif_rx,在該方法中 中最終會執行到 enqueue_to_backlog 中(netif_rx -> netif_rx_internal -> enqueue_to_backlog)。

//file: net/core/dev.c
static int enqueue_to_backlog(struct sk_buff *skb, int cpu,
         unsigned int *qtail)
{
 sd = &per_cpu(softnet_data, cpu);

 ...
 __skb_queue_tail(&sd->input_pkt_queue, skb);

 ...
 ____napi_schedule(sd, &sd->backlog);

在 enqueue_to_backlog 把要發送的 skb 插入 softnet_data->input_pkt_queue 隊列中並調用 ____napi_schedule 來觸發軟中斷。

//file:net/core/dev.c
static inline void ____napi_schedule(struct softnet_data *sd,
         struct napi_struct *napi)
{
 list_add_tail(&napi->poll_list, &sd->poll_list);
 __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

只有觸發完軟中斷,發送過程就算是完成了。

三、本機接收過程

在跨機的網絡包的接收過程中,需要經過硬中斷,然後才能觸發軟中斷。而在本機的網絡 IO 過程中,由於並不真的過網卡,所以網卡實際傳輸,硬中斷就都省去了。直接從軟中斷開始,經過 process_backlog 後送進協議棧,大體過程如圖。

接下來我們再看更詳細一點的過程。

在軟中斷被觸發以後,會進入到 NET_RX_SOFTIRQ 對應的處理方法 net_rx_action 中(至於細節參見 圖解 Linux 網絡包接收過程 一文中的 3.2 小節)。

//file: net/core/dev.c
static void net_rx_action(struct softirq_action *h){
 while (!list_empty(&sd->poll_list)) {
  work = n->poll(n, weight);
 }
}

我們還記得對於 igb 網卡來說,poll 實際調用的是 igb_poll 函數。那麼 loopback 網卡的 poll 函數是誰呢?由於 poll_list 裏面是 struct softnet_data 對象,我們在 net_dev_init 中找到了蛛絲馬跡。

//file:net/core/dev.c
static int __init net_dev_init(void)
{
 for_each_possible_cpu(i) {
  sd->backlog.poll = process_backlog;
 }
}

原來struct softnet_data 默認的 poll 在初始化的時候設置成了 process_backlog 函數,來看看它都幹了啥。

static int process_backlog(struct napi_struct *napi, int quota)
{
 while(){
  while ((skb = __skb_dequeue(&sd->process_queue))) {
   __netif_receive_skb(skb);
  }

  //skb_queue_splice_tail_init()函數用於將鏈表a連接到鏈表b上,
  //形成一個新的鏈表b,並將原來a的頭變成空鏈表。
  qlen = skb_queue_len(&sd->input_pkt_queue);
  if (qlen)
   skb_queue_splice_tail_init(&sd->input_pkt_queue,
         &sd->process_queue);
  
 }
}

這次先看對 skb_queue_splice_tail_init 的調用。源碼就不看了,直接說它的作用是把 sd->input_pkt_queue 裏的 skb 鏈到 sd->process_queue 鏈表上去。

然後再看 __skb_dequeue, __skb_dequeue 是從 sd->process_queue 上取下來包來處理。這樣和前面發送過程的結尾處就對上了。發送過程是把包放到了 input_pkt_queue 隊列裏,接收過程是在從這個隊列裏取出 skb。

最後調用 __netif_receive_skb 將 skb(數據) 送往協議棧。在此之後的調用過程就和跨機網絡 IO 又一致了。

送往協議棧的調用鏈是 __netif_receive_skb => __netif_receive_skb_core => deliver_skb 後 將數據包送入到 ip_rcv 中(詳情參見圖解 Linux 網絡包接收過程 一文中的 3.3 小節)。

網絡再往後依次是傳輸層,最後喚醒用戶進程,這裏就不多展開了。

四、本機網絡 IO 總結

我們來總結一下本機網絡 IO 的內核執行流程。

回想下跨機網絡 IO 的流程是

我們現在可以回顧下開篇的三個問題啦。

1)127.0.0.1 本機網絡 IO 需要經過網卡嗎?

通過本文的敘述,我們確定地得出結論,不需要經過網卡。即使了把網卡拔了本機網絡是否還可以正常使用的。

2)數據包在內核中是個什麼走向,和外網發送相比流程上有啥差別?

總的來說,本機網絡 IO 和跨機 IO 比較起來,確實是節約了一些開銷。發送數據不需要進 RingBuffer 的驅動隊列,直接把 skb 傳給接收協議棧(經過軟中斷)。但是在內核其它組件上,可是一點都沒少,系統調用、協議棧(傳輸層、網絡層等)、網絡設備子系統、鄰居子系統整個走了一個遍。連 “驅動” 程序都走了(雖然對於迴環設備來說只是一個純軟件的虛擬出來的東東)。所以即使是本機網絡 IO,也別誤以爲沒啥開銷。

最後再提一下,業界有公司基於 ebpf 來加速 istio 架構中 sidecar 代理和本地進程之間的通信。通過引入 BPF,纔算是繞開了內核協議棧的開銷,原理如下。

參見:https://cloud.tencent.com/developer/article/1671568

**留道思考題:**訪問本機 Server 時,使用 127.0.0.1 能比使用本機 ip(例如 192.168.x.x) 更快嗎?

對於這個問題,你是怎麼理解的,我想能看到你的見解!歡迎大家在評論區裏留言討論!!

我把我對上述思考題的分析過程藏到飛哥團隊新開的技術公衆號裏了(先不要着急去看哦)。也歡迎你來關注我們團隊這個嶄新的技術號,聚焦性能和架構相關技術的分享。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/6_OfoeD3ZpyQisY2F-4_bw