您當前的位置:首頁 > 文化

最佳化多核CPU的TCP新建連線效能--重排spinlock

作者:由 linux 發表于 文化時間:2020-04-22

傳統上講,Linux核心協議棧針對同一個Listener的TCP新建連線處理主要擁有兩個瓶頸點:

單一的accept佇列

單一的hash表(其實是兩張,listener hash,establish hash)

TCP的新建連線會頻繁操作上述兩個資料結構,在多核CPU情況(後面簡稱SMP)下,為了保證資料的一致性,lock是繞不開的。不管多少個並行處理的CPU,在TCP新建連線時,必然要在操作上述兩個資料結構時被序列化!這是悲哀的。

我們知道,隨著CPU核數的增多,每秒能接納的連線請求數也會隨著增多,但由於上述兩個序列化點的存在,這意味著lock衝突也會相應的增多!序列化的lock衝突意味著什麼?請考慮地鐵站入口,人們從多個大門湧入,最終卻只有一個安檢點,過了這個安檢點又呈現了多個閘機…

最終,隨著CPU核數的增多,效能並沒有能線性地增長,最終的CPU核數/效能曲線便呈現了一種上凸的趨勢。這一切都是因為鎖。

我們來看一下如何進一步拆解上面兩個問題。

單一accept佇列問題的解鎖

非常幸運,這個問題已經被google的reuseport機制解決了。詳情請自行搜尋reuseport相關的資料。

值得一提的是,新浪的fastsocket在google的reuseport機制基礎上做了一個比較優雅的封裝,使得應用程式不用修改就能享受到reuseport的收益,同時進一步地提高了TCP連線的可伸縮性問題。

單一establish hash表問題的解鎖

根據我上週的壓測,CPS資料獲取過程中,短連結會頻繁操作establish hash表,頻繁呼叫inet_hash,inet_unhash兩個函式(listener hash並不必在意,因為listener socket比較穩定,不會頻繁生成和銷燬),其中的熱點在兩個spinlock:

bool inet_ehash_insert(struct sock *sk, struct sock *osk)

{

struct inet_hashinfo *hashinfo = sk->sk_prot->h。hashinfo;

struct hlist_nulls_head *list;

struct inet_ehash_bucket *head;

spinlock_t *lock;

bool ret = true;

WARN_ON_ONCE(!sk_unhashed(sk));

sk->sk_hash = sk_ehashfn(sk);

head = inet_ehash_bucket(hashinfo, sk->sk_hash);

list = &head->chain;

// 以hash bucket來lock!!

lock = inet_ehash_lockp(hashinfo, sk->sk_hash);

spin_lock(lock); // 序列化lock

if (osk) {

WARN_ON_ONCE(sk->sk_hash != osk->sk_hash);

ret = sk_nulls_del_node_init_rcu(osk);

}

if (ret)

__sk_nulls_add_node_rcu(sk, list);

spin_unlock(lock);

return ret;

}

可以看到,在當前的Linux TCP實現中,每一個hash bucket擁有一個spinlock,其實粒度已經夠細了。

在以往的年代,這裡的效能更加糟糕!上述程式碼是4。14核心,幾乎就是最新的版本了,我們看一下它的示意圖:

最佳化多核CPU的TCP新建連線效能--重排spinlock

上圖的窘局其實是可以破解的,只需要把per slot的spinlock再做細分即可,改為per slot per CPU的spinlock,其實就是把每一個slot的連結串列攤開成per cpu的即可。這裡決定一個socket應該給哪個CPU先使用一個最簡單的策略,即呼叫inet_hash的時候哪個CPU在處理,就給哪個CPU。

為此,我們需要修改下面的資料結構:

struct inet_ehash_bucket {

struct hlist_nulls_head chain;

};

這個資料結構便是上圖中slot,我們需要將其改成:

struct inet_ehash_bucket {

// struct hlist_nulls_head chain[NR_CPUS]

struct hlist_nulls_head *chain;

};

我們稍微修改一下insert函式:

bool inet_ehash_insert(struct sock *sk, struct sock *osk)

{

struct inet_hashinfo *hashinfo = sk->sk_prot->h。hashinfo;

struct hlist_nulls_head *list;

struct inet_ehash_bucket *head;

spinlock_t *lock;

bool ret = true;

// 取當前CPU!

int cpu = smp_processor_id();

WARN_ON_ONCE(!sk_unhashed(sk));

sk->sk_hash = sk_ehashfn(sk);

sk->sk_hashcpu = cpu;

head = inet_ehash_bucket(hashinfo, sk->sk_hash);

// 取出對應CPU的list

head = &head[cpu];

list = &head->chain;

lock = inet_ehash_lockp(hashinfo, sk->sk_hash);

// 取出對應CPU的lock

lock = &lock[cpu];

spin_lock(lock);

if (osk) {

WARN_ON_ONCE(sk->sk_hash != osk->sk_hash);

ret = sk_nulls_del_node_init_rcu(osk);

}

if (ret)

__sk_nulls_add_node_rcu(sk, list);

spin_unlock(lock);

return ret;

}

是不是簡單快捷呢?對應的lookup也要修改,在lookup的過程中,不再recheck slot的一致性,而要recheck CPU的一致性:

struct sock *__inet_lookup_established(struct net *net,

struct inet_hashinfo *hashinfo,

const __be32 saddr, const __be16 sport,

const __be32 daddr, const u16 hnum,

const int dif, const int sdif)

{

INET_ADDR_COOKIE(acookie, saddr, daddr);

const __portpair ports = INET_COMBINED_PORTS(sport, hnum);

struct sock *sk;

const struct hlist_nulls_node *node;

unsigned int hash = inet_ehashfn(net, daddr, hnum, saddr, sport);

unsigned int slot = hash & hashinfo->ehash_mask;

struct inet_ehash_bucket *head = &hashinfo->ehash[slot];

int cpu = smp_processor_id(), self; // 從當前CPU開始!如果底層有做CPU繫結的話,這樣做就對了。

self = cpu;

begin:

head = &head[cpu];

if (hlist_nulls_empty(&head->chain)) {

goto recheck2;

}

sk_nulls_for_each_rcu(sk, node, &head->chain) {

。。。 // 邏輯不變,省略

}

if (get_nulls_value(node) != cpu) {

cpu = 0;

goto begin;

} else if (get_nulls_value(node) == cpu) {

recheck2:

cpu ++;

if (cpu >= nr_cpu_ids)

cpu = 0;

if (cpu == self)

goto out;

goto begin;

}

out:

sk = NULL;

found:

return sk;

}

同時,ehash的每一個slot在初始化的時候,都要初始化成per CPU的(當然,我這裡還沒有用per CPU的API),並且把hlist的null尾用CPU id來初始化!

現在讓我們看看採用per slot per CPU的新方案後,局面在觀感上變成了什麼樣子:

最佳化多核CPU的TCP新建連線效能--重排spinlock

我們知道,spinlock是不可睡眠的,除了被硬中斷打破,所有的CPU在呼叫inet_hash的時候,幾乎都是可以無競爭不自旋立即完成的。但是你可能注意到了,我在上文中沒有提到inet_unhash的呼叫,我們知道,unhash的時候也是要持有spinlock的,如何來保證unhash的呼叫者和當初hash的呼叫者是同一個CPU呢?

答案顯然是不能保證,因此正如nf_conntrack裡unconfirm list和dying list的per cpu處理那般,在呼叫unhash的時候,cpu變數必須從socket裡面取出來:

void inet_unhash(struct sock *sk)

{

struct inet_hashinfo *hashinfo = sk->sk_prot->h。hashinfo;

spinlock_t *lock;

bool listener = false;

int done;

if (sk_unhashed(sk))

return;

if (sk->sk_state == TCP_LISTEN) {

lock = &hashinfo->listening_hash[inet_sk_listen_hashfn(sk)]。lock;

listener = true;

} else {

// 取出hash時的cpu,確保從哪裡insert就從哪裡remove時而一致性。

int cpu = sk->sk_hashcpu;

if (cpu != smp_processor_id()) {

DEBUG(“Shit!:%d”, misstat++);

}

lock = inet_ehash_lockp(hashinfo, sk->sk_hash);

lock = &lock[cpu];

}

spin_lock_bh(lock);

。。。

}

現在問題來了。由於Linux排程器的排程策略影響,很有可能呼叫unhash時的CPU已經不是當初呼叫hash時的那個CPU了,最終在別的CPU上處理的unhash過程還是可能和其它一個呼叫hash過程的CPU競爭同一把鎖。然而這是沒有辦法的,排程器不屬於協議棧的範疇,我們能做的,僅僅是避免這種情況的發生,比如透過外部的機制或者工具,對程序和CPU進行強繫結或者弱繫結,盡最大的努力避免程序在CPU之間乒乓!

標簽: SK  hash  CPU  inet  lock