1. 程式人生 > >Linux 4 7核心針對syncookie效能所做的優化

Linux 4 7核心針對syncookie效能所做的優化

由於端午節加班攢了兩天調休,週四,五就申請休假了,剛申請下來調休,老婆突然就決定帶著小小西北行了,週五出發,這次是去環青海…休假本為了放鬆,卻成了坑。週四先是去看了《加勒比海盜5》,然後我就覺得這假期不該申請,於公於私我都是政治不正確…其實我想說的是,休假比上班累很多!工作日在家裡忍耐老婆拖把在手,抱怨在口幾個小時(如果在公司有這種同事,離職即可,至少可以溝通吧,但在家裡,能離職嗎?能溝通嗎?),同時想著和我一樣加班的同事除了我之外都沒休假,覺得就我矯情…不管怎樣,我幹了錯事,但這不就正是我之為我的特徵嗎?明天老婆就帶小小出發了,我發誓我會睡一整天!
  …本文依然是以技術為主題,本次聊聊syncookie的效能和DDoS。
  雖然現在的核心都已經是4.11版本了,但本文依舊基於較老的核心版本舊事重提,就4.7版本的一個針對syncookie的一個優化書寫一段吹捧與嘲諷。
  自從4.4版本的Lockless TCP listener以來,針對TCP在大併發連線處理這塊一直都沒有更大的突破,也許在大多數開發者看來,擺脫了顯式大鎖的束縛,Lockless TCP listener已經徹底解放了,餘下的精力應該集中在更多的“業務邏輯”上了…有誰能指望基礎設施會持續日新月異呢?

附:關於Lockless TCP listener

關於Lockless TCP listener的意義和實現方式,可以參見以下的資源(其中有我2015年大悲哀時寫的):
Lockless TCP listener
Linux核心4.4版本帶來的網路新特性
多核心Linux核心路徑優化的不二法門之-多核心平臺TCP優化


事情果真就這樣結束了嗎?Lockless TCP listener真的就是事情的全部嗎?Linux 4.7核心對此給出了否定的回答。

Linux 4.7之前TCP連線處理問題

  我們已經知道,在TCP的接收主函式tcp_v4_rcv中,基於skb的元資料查詢socket的過程是無鎖的,查詢完畢之後,會針對找到的socket結果上鎖或者無鎖處理,邏輯非常清晰:

tcp_v4_rcv(skb)
{
    sk = lockless_lookup(skb);
    if (sk.is_listener) {
// Lockless begin
        process_handshake(sk, skb);
        new_sk = build_synack_sk(skb);
        new_sk.listener = sk;
    } else if (sk.is_synrecv) {
        listener = sk.lister;
        child_sk = build_child_sk(skb, sk);
        add_sk_into_acceptq(listener, child_sk);
// Lockless end
goto data; } else { data: lock(sk); process(sk, skb); unlock(sk); } }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

這個邏輯已經臻於完美了,至少在表面上看來確實如此!
  當我知道了4.7核心針對syncookie的優化之後,我便內窺了lockless_lookup內部,突破性的改進在於,4.7核心用真正的RCU callback替換了一個僅有的Atomic操作,做到了真正的無鎖化查詢!
  看來我們都被騙了,其實所謂的lockless_lookup並不是真正的lockless,為了應景和應題,本文只討論Listener socket,我們來看下它的邏輯:

lockless_lookup(skb)
{
    hash = hashfn(skb);
    hlist = listener_list[hash];
// 第一部分:#1-查詢socket
begin:
    sk_nulls_for_each_rcu(sk, node, hlist) {
        if (match(skb, sk)) {
            ret = sk;
        }
    }
// 第二部分:#2-與socket重新hash並插入hlist進行互斥    
    if (get_nulls_value(node) != hash) {
        goto begin;
    } 

// 第三部分:#3-與socket被釋放進行互斥   
    if (ret) {
        if (!atomic_inc_not_zero(ret))
            ret = NULL;
    }

    return ret;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

這個邏輯可以分為3個部分,我在註釋中已經標明,可以看到,雖然在呼叫者tcp_v4_rcv看來,查詢socket的操作是無鎖的,然而內窺其實現邏輯之後便會發現,它其實還是在內部進行了兩個輕量級的互斥操作。下面我來一個一個說。

nulls hlist互斥

由於在lockless_lookup被呼叫時是無鎖的,所以在sk_nulls_for_each_rcu遍歷過程中會出現以下情況造成遍歷混亂:
這裡寫圖片描述
這種情況下,常規的hlist是無法發現的,因為這種hlist以next為NULL視為連結串列的結束。不管一個node被重新hash到哪個連結串列,在結束的時候都會碰到NULL,此時你根本區別不出來這個NULL是不是一開始遍歷開始時那個hlist衝突連結串列的NULL。怎麼解決這個問題呢?上鎖肯定是不妥的,幸虧Linux核心有一個精妙的資料結構,即nulls hlist!下面我先來簡單地介紹一下這個精妙的hlist資料結構和標準的hlist有何不同。

差異:

  • nulls hlist不再以NULL結尾,而以一個大到2^31空間的任意值結尾
  • nulls hlist以node最低位是不是1標識是不是連結串列的結束

於是nulls hlist的結尾節點的next欄位可以編碼為高31位和低1位,如果低1位為1,那麼高31位便可以取出當初存進去的任意值,是不是很精妙呢?!之所以可以這麼做,原因很簡單,在計算機中,Linux核心資料結構的所有的地址都是對齊存放的,因此最低1位的資料位是空閒的,當然可以借為它用了。
  現在我們考慮這個nulls node的高31位存什麼資料好呢?答案很明確,當然是存該hlist的hash值了,這樣以下的操作一目瞭然:

init:
for (i = 0; i < INET_LHTABLE_SIZE; i++) {
    // 低1位和高31位的拼接:
    // 低1位儲存1,代表結束,新節點會插入到其前面
    // 高31位儲存該list的hash值
    listener_list[i].next = (1UL | (((long)i) << 1)) 
}

lookup:
hash1 = hashfn(skb);
hlist = listener_list[hash1];
sk_nulls_for_each_rcu...{
    ...
}
hash2 = get_nulls_value(node);
if (hash1 != hash2) {
    // 發現結束的時候已經不在開始遍歷的連結串列上了
    goto begin;
}
//.....
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

是不是很精妙呢?其實在Linux中,很多地方都用到了這個nulls hlist資料結構,我第一次看到它是在當年搞nf conntrack的時候。
  以上的敘述大致解釋了這個nulls hlist的精妙之處,說完了優點再看看它的問題,這個nulls hlist帶啦的不斷retry是一種消極嘗試,非常類似順序鎖讀操作,只要讀衝突便一直重複,直到某次沒有衝突,關於順序鎖,可以看一下read_seqbegin/read_seqretry以及write_seqlock這對夫妻和小三。
  為什麼需要這樣?答案是,在無鎖化的lookup中,必須這樣!因為你取出一個node和從該node取出下一個node之間是有時間差的,你沒有對這個時間差強制沒有任何保護措施,這就是根本原因,所以,消極的嘗試也未嘗不是一個好辦法。
  總結下根本原因,取出node和取出下一個node之間存在race!

原子變數互斥

剛剛說完了lockless_lookup的第二部分,下面看看第三部分,atomic_inc_not_zero帶來的互斥。
  我們知道,在sk_nulls_for_each_rcu找到一個匹配的socket並且nulls node檢查通過之後,在實際使用它之前,由於無鎖化呼叫,會存在race,此期間可能會有別的執行緒將該socket釋放到虛空,如何避免使用一個已經被釋放的socket呢?這個很簡單,操作原子計數器即可:

freeif (atomic_dec_and_test(sk)) {
    // 此往後,由於已經將ref減為0,別處的inc_not_zero將失敗,因此可以放心釋放socket了。
    free(sk);
}

lookup:
if (ret && !atomic_inc_not_zero(ret)) {
    ret = NULL;
    goto done;
}
// 此處後,由於已經增加了ref,引用的資料將是有效資料
//...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

雖然這個Atomic變數不是什麼鎖,但是在微觀上,操作它是要鎖匯流排的,即便在程式碼層面沒有看到任何lock字眼,但這是指令集的邏輯。當面對ddos攻擊的時候,試想同時會有多少的執行緒爭搶這個Atomic底下的匯流排資源!!這是一筆昂貴的開銷!
  為什麼非要有這麼一個操作呢?答案很明確,怕取到一個被釋放的socket從而導致核心資料混亂,簡單點說就是怕panic。所以必然要有個原子變數來保護一下,事實證明,這麼做還真不錯呢。然而把問題更上一層來談,為什麼核心資料會混亂導致panic?因為取出node和使用node之間存在race,在這兩個操作之間,node可能會被釋放掉。這一點和上面的“取出node和取出下一個node之間存在race”是不同的。


現在發現了2個race:

  • 取出node和取出下一個node之間;
  • 取出node和使用node之間。

但歸根結底,這兩個race是同一個問題導致,那就是socket被釋放(重新hash也有個先被釋放的過程)!如果一個socket在被lookup期間,不允許被釋放是否可以呢(你可以呼叫釋放操作,但在此期間,你要保證資料有效)?當然可以,如何做到就是一個簡單的事情了。如果能做到這一點並且真的做了,上述針對兩個race的兩個互斥就可以去掉了,TCP的新建連線數效能指標必然會有大幅度提升。

Linux 4.7的優化

Linux 4.7核心通過SOCK_RCU_FREE標識重構了sk_destruct的實現:

void sk_destruct(struct sock *sk)
{
    if (sock_flag(sk, SOCK_RCU_FREE))
        call_rcu(&sk->sk_rcu, __sk_destruct);
    else
        __sk_destruct(&sk->sk_rcu);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

如果攜帶有SOCK_RCU_FREE標識,便通過RCU callback進行釋放,我們知道,RCU callback的呼叫時機是必須經過一個grace period,而這個period通過rcu lock/unlock可以嚴格控制。
  一切顯得簡單明瞭。Linux 4.7核心僅為Listener socket設定了SOCK_RCU_FREE標識:

// 建立socket
__inet_hash(...)
{
    ...
    sock_set_flag(sk, SOCK_RCU_FREE);
    ...
}

// 從一個Listener socket派生子socket
inet_csk_clone_lock(...)
{
    struct sock *newsk = sk_clone_lock(sk, priority);
    if (newsk) {
        ...
        /* listeners have SOCK_RCU_FREE, not the children */
        sock_reset_flag(newsk, SOCK_RCU_FREE);
        ...
    }
    ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

這保證了在lockless_lookup呼叫中不必再擔心取到錯誤的資料和無效的資料,前提是lockless_lookup的呼叫必須有rcu鎖的保護。這很容易:

    rcu_read_lock();
    sk = lockless_lookup(skb);
    ...
done:
    rcu_read_unlock();
  • 1
  • 2
  • 3
  • 4
  • 5

當然,這個lock/unlock沒有體現在tcp_v4_rcv函式裡,而是體現在了ip_local_deliver_finish裡。

社群patch

以下是一個社群的patch:
[PATCH v2 net-next 06/11] tcp/dccp: do not touch listener sk_refcnt under synflood
作者詳細說明了取消原子變數操作後帶來的收益並且攜帶測試結果,我想這算是令人信服的,最重要的是,它已經被合入核心了。
  值得一提的是,這個patch僅僅針對新建連線效能有所提升,對TCP的傳輸效能沒有任何作用,競速者慎入。

關於DDoS的認知

我不想在這裡爆粗口,但我還是忍不住說“機器在被DDoS時僅僅關注CPU使用率的”都是XX。抗DDoS指標難道不該是服務不可用之前的最大pps嗎?
  下面談下CPU使用率的問題。
  關注這個並且時刻關注這個的基本都是玩過PC時代組裝機的那幫人,當然也包括我自己。可能也受點微軟的誤導,當然微軟也是傻逼。每次開啟工作管理員,發現CPU使用率超過50%的時候,是不是就覺得天都要塌下來了…繼續之前,我先說幾件事。
  中國很多人買了西裝之後,一直到扔掉都不會把袖口的商標撕掉,很多人買了沙發,一直到搬家把這沙發當舊貨賣掉時都不會撕開沙發上塑料薄膜,幾乎95%以上的人會在自己的iPhone或者
S6e/S7e上加個殼子貼個膜不是為了美觀而是為了怕劃痕,這就是我們金玉其外的性格,對於內在當然是內斂越好了。我們忽略了一個重要的東西,那就是除了內在的,其它的都是耗材。西裝就是用來穿的,穿破了再買,沙發就是用來坐的,真皮沙發外面套個塑料薄膜,難道就是為了不髒嗎?…CPU難道不就是用來飆的嗎?如果你花了100塊錢買了一塊CPU,結果它的利用率僅僅不到50%,你不是白花了50塊錢嗎?
  不要把耗材當古董來收藏!
  我們試想一臺伺服器被DDoS時會怎樣?它應該怎樣?如果伺服器被猛打,那麼CPU一定會飆高到幾乎100%,這就是DDoS的定義!那些號稱自己的伺服器在猛打時還能悠閒保持CPU利用率10%的,都是扯淡,他們是主動拒絕服務的騙子。你有100%的能力,卻只釋放出10%。哪怕是沒有被攻擊,只要CPU飆到70%以上,我相信很多不稱職的運維第一反應肯定是哪裡出故障了,這故障一定是實現上的bug導致,而不是訪問模式導致的流量異常。其實他們的這種行為也無可厚非,畢竟他們也是領薪水的,維持各項指標的正常能讓他們基本“稱職”,一旦有異常,那可能就意味著“失職”,如果平時CPU利用率都是30%,突然有一天變成了60%,他們一定會害怕背鍋失職,在運維眼裡,系統保持正常是最好的,如果指標優化了,那是研發的功勞,這是羚羊,鬣狗以及獅子之間的博弈,所得和所失也有一頓飯和一條命之間的差別。
  謎底很簡單,理論上講,只要CPU沒有持續100%,哪怕一直持續99%,也可以說CPU還有1%的空閒,此時CPU仍然可以說沒有滿載。加上排程開銷和統計誤差,一臺伺服器的CPU利用率持續保持在85%左右是最佳的,這說明它沒有在空轉浪費電能。如果你的伺服器CPU持續飆高到85%但是服務卻不可用了,那是你的服務程式設計的有問題,但幾乎可以肯定不是作業系統的問題。
  如果服務程式開發者覺得這不公平,說服他們其實也並不難,你拿一個原生Linux發行版裝上他的服務,如果是OS核心的問題,世界上這麼多人難道就沒有發現嗎?
  總之,DDoS是一種正常的現象,它並不是異常。抗DDoS很大意義上是指在面對DDoS時的反應,在CPU接近100%時儘可能保持高的pps。


  最後看個DDoS防護相關的實現細節,DDoS來臨前,Linux一般會開啟syncookie,此時會在返回的seq中編碼很多資訊,為了保證這些資訊的隱蔽性,編碼後的seq需要做雜湊(其實就是hash),然而Linux核心使用的是SHA演算法,這個演算法是不是太重了呢?
  在DDoS來臨時,要考慮的是此時主要矛盾是什麼?是怕別人猜出序列號從而進行後續的攻擊呢,還是說怕響應不過來當前的處理請求?我想都已經被打了,還是顧眼前會更加現實!雖然我也知道如果被人猜出了序列號,攻擊會更加嚴重,形成一個正反饋的爆炸點,鑑於此我肯定不會推薦使用不編碼的原始值作為序列號直接返回(即編碼後的裸資料),而是推薦一種簡單的雜湊,比如K值固定的簡單凱撒加密。是不是更簡單呢?
  別總是一提SHA,一提hash就碰撞個碰撞啥的,要看當前碰撞的後果嚴重嗎?不嚴重,碰撞又如何?!

關於本文

為什麼會有本文?
  我借本文的結尾,對重複造輪子表達一點自己的看法。
  早在2014年搞nf_conntrack的時候,接觸了nulls hlist,後來接觸到Fastsocket對TCP併發連線處理的優化,再後來我寫了個仿Fastsocket TCP無鎖握手處理的Demo,最後發現這些都在4.4版本釋出的時候合入了,所謂該來的終究會來的,其實這些思路都差不多,所謂正確的做法往往只有少數幾種,錯誤的方向卻無限多,稍不留意就會南轅北轍…關於TCP連線處理的持續優化過程,這並不是什麼黑科技,像阿里,新浪,Google,華為,甚至我自己想到的方案都差不多,只要有一方做出來並放出,其它的直接用就可以了,我的做法就是直接用4.7+版本。
  所以說,明知道高版本里終究會有的東西,或者已經有的東西,最快捷的且正確的做法難道不是直接移植嗎?為什麼要重新造輪子呢?重新造輪子難道不是最不值得提倡的嗎?
  以一個研發工程師的視角,重新造輪子當然有意義,因為這是自己的立身之本,研發工程師無所謂是不是重新造,他們關注的只是有能力造。但站在產品的角度,他們關注的只是結果,這是典型的過程控制和目標管理之間的思維差異!
  我寫了很多文章,有解析已有技術的,有闡述替代方案的,還有預測性的,但幾乎我沒有把它們引入工作,因為沒有人需要,所有人都知道什麼叫目標管理,每個人在表達自己觀點之前,早就準備
了一百萬個理由在背後作為掩護,其中很多都是偽的,偽引用,偽邏輯,最高境界就是偽道德,這就是現實,所以說我一般會避免跟人爭論,選擇保持安靜,默默地做對我來講是舒適的。
  我傾向於把一些觀點整理成文,然後Email抄送給我收藏的感興趣者,傳送給微信好友,偶爾發個朋友圈,如果有人真的對問題感興趣或者觀點不同,我想文字的回執會讓人謹慎得多,畢竟每個人都有三寸不爛之舌,扯幾句是零成本的,但是寫下來就會有負擔,至少在我看來,事情就是這樣。

BTW,本文中除了少數的個人觀點,其餘的都是從別處看的,是吧,這責任推卸的…

再分享一下我老師大神的人工智慧教程吧。零基礎!通俗易懂!風趣幽默!還帶黃段子!希望你也加入到我們人工智慧的隊伍中來!https://www.cnblogs.com/captainbed