socket 是併發安全的嗎

  爲了更好的聊今天的話題,我們先假設一個場景。

我相信我讀者大部分都是做互聯網應用開發的,可能對遊戲的架構不太瞭解。

我們想象中的遊戲架構是下面這樣的。

想象中的遊戲架構

也就是用戶客戶端直接連接遊戲核心邏輯服務器,下面簡稱 GameServer。GameServer 主要負責實現各種玩法邏輯。

這當然是能跑起來,實現也很簡單。

但這樣會有個問題,因爲遊戲這塊蛋糕很大,所以總會遇到很多挺刑的事情。

如果讓用戶直連 GameServer,那相當於把 GameServer 的 ip 暴露給了所有人。

不賺錢還好,一旦遊戲賺錢,就會遇到各種攻擊。

你猜《羊了個羊》最火的時候爲啥老是崩潰?

假設一個遊戲服務器能承載 4k 玩家,一旦服務器遭受直接攻擊,那 4k 玩家都會被影響。

這攻擊的是服務器嗎?這明明攻擊的是老闆的錢包。

所以很多時候不會讓用戶直連 GameServer。

而是在前面加入一層網關層,下面簡稱 gateway。類似這樣。

實際的某些遊戲架構

GameServer 就躲在了 gateway 背後,用戶只能得到 gateway 的 IP。

然後將大概每 100 個用戶放在一個 gateway 裏,這樣如果真被攻擊,就算 gateway 崩了,受影響的也就那 100 個玩家。

由於大部分遊戲都使用 TCP 做開發,所以下面提到的連接,如果沒有特別說明,那都是指 TCP 連接

那麼問題來了。

假設有100個用戶連 gateway,那 gateway 跟 GameServer 之間也會是 100個連接嗎?

當然不會,gateway 跟 GameServer 之間的連接數會遠小於 100

因爲這 100 個用戶不會一直需要收發消息,總有空閒的時候,完全可以讓多個用戶複用同一條連接,將數據打包一起發送給 GameServer,這樣單個連接的利用率也高了,GameServer 也不再需要同時維持太多連接,可以節省了不少資源,這樣就可以多服務幾個大怨種金主。

我們知道,要對網絡連接寫數據,就要執行 send(socket_fd, data)

於是問題就來了。

已知多個用戶共用同一條連接

現在多個用戶要發數據,也就是多個用戶線程需要寫同一個 socket_fd

那麼,socket 是併發安全的嗎?能讓這多個線程同時併發寫嗎?

併發讀寫 socket

寫 TCP Socket 是線程安全的嗎?

對於 TCP,我們一般使用下面的方式創建 socket。

sockfd=socket(AF_INET,SOCK_STREAM, 0))

返回的sockfd是 socket 的句柄 id,用於在整個操作系統中唯一標識你的 socket 是哪個,可以理解爲 socket 的身份證 id

創建 socket 時,操作系統內核會順帶爲 socket 創建一個發送緩衝區和一個接收緩衝區。分別用於在發送和接收數據的時候給暫存一下數據

寫 socket 的方式有很多,既可以是send,也可以是write

但不管哪個,最後在內核裏都會走到 tcp_sendmsg() 函數下。

// net/ipv4/tcp.c
int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t size)
{
    // 加鎖
    lock_sock(sk);


    // ... 拷貝到發送緩衝區的相關操作


    // 解鎖
    release_sock(sk);
}

tcp_sendmsg的目的就是將要發送的數據放入到 TCP 的發送緩衝區中,此時並沒有所謂的發送數據出去,函數就返回了,內核後續再根據實際情況異步發送。關於這點,我在之前寫過的 《動圖圖解 | 代碼執行 send 成功後,數據就發出去了嗎?》有更詳細的介紹。

tcp_sendmsg 邏輯

tcp_sendmsg的代碼中可以看到,在對 socket 的緩衝區執行寫操作的時候,linux 內核已經自動幫我們加好了鎖,也就是說,是線程安全的

所以可以多線程不加鎖併發寫入數據嗎?

不能。

問題的關鍵在於鎖的粒度

但我們知道 TCP 有三大特點,面向連接,可靠的,基於字節流的協議。

TCP 是什麼

問題就出在這個 " 基於字節流 ",它是個源源不斷的二進制數據流,無邊界。來多少就發多少,但是能發多少,得看你的發送緩衝區還剩多少空間

舉個例子,假設 A 線程想發123數據包,B 線程想發456數據包。

A 和 B 線程同時執行send(),A 先搶到鎖,此時發送緩衝區就剩1個數據包的位置,那發了"1",然後發送緩衝區滿了,A 線程退出(非阻塞),當發送緩衝區騰出位置後,此時 AB 再次同時爭搶,這次被 B 先搶到了,B 發了"4"之後緩衝區又滿了,不得不退出。

重複這樣多次爭搶之後,原本的數據內容都被打亂了,變成了142356。因爲數據123是個整體456又是個整體,像現在這樣數據被打亂的話,接收方就算收到了數據也沒辦法正常解析

併發寫 socket_fd 導致數據異常

也就是說鎖的粒度其實是每次 " 寫操作 ",但每次寫操作並不保證能把消息寫完整

那麼問題就來了,那是不是我在寫整個完整消息之前加個鎖,整個消息都寫完之後再解鎖,這樣就好了?

類似下面這樣。

// 僞代碼
int safe_send(msg string)
{
    target_len = length(msg)
        have_send_len = 0
    // 加鎖
    lock();

    // 不斷循環直到發完整個完整消息
       do {
     send_len := send(sockfd,msg)
     have_send_len = have_send_len + send_len
       } while(have_send_len < target_len)
   

    // 解鎖
    unlock();

}

這也不行,我們知道加鎖這個事情是影響性能的,鎖的粒度越小,性能就越好。反之性能就越差。

當我們搶到了鎖,使用 send(sockfd,msg) 發送完整數據的時候,如果此時發送緩衝區正好一寫就滿了,那這個線程就得一直佔着這個鎖直到整個消息寫完。其他線程都在旁邊等它解鎖,啥事也幹不了,焦急難耐想着搶鎖。

但凡某個消息體稍微大點,這樣的問題就會變得更嚴重。整個服務的性能也會被這波神仙操作給拖垮

歸根結底還是因爲鎖的粒度太大了

有沒有更好的方式呢?

其實多個線程搶鎖,最後搶到鎖的線程才能進行寫操作,從本質上來看,就是將所有用戶發給 GameServer 邏輯服務器的消息給串行化了,

那既然是串行化,我完全可以在在業務代碼裏爲每個 socket_fd 配一個隊列來做,將數據在用戶態加鎖後塞到這個隊列裏,再單獨開一個線程,這個線程的工作就是發送消息給 socket_fd。

於是上面的場景就變成了下面這樣。

併發寫到加鎖隊列後由一個線程處理

於是在 gateway 層,多個用戶線程同時寫消息時,會去爭搶某個 socket_fd 對應的隊列,搶到鎖之後就寫數據到隊列。而真正執行 send(sockfd,msg) 的線程其實只有一個。它會從這個隊列中取數據,然後不加鎖的批量發送數據到 GameServer。

由於加鎖後要做的事情很簡單,也就塞個隊列而已,因此非常快。並且由於執行發送數據的只有單個線程,因此也不會有消息體亂序的問題。

讀 TCP Socket 是線程安全的嗎?

在前面有了寫 socket 是線程安全的結論,我們稍微翻一下源碼就能發現,讀 socket 其實也是加鎖了的,所以併發多線程讀 socket 這件事是線程安全的

// net/ipv4/tcp.c
int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
        size_t len, int nonblock, int flags, int *addr_len)
{

    // 加鎖
    lock_sock(sk);

    // ... 將數據從接收緩衝區拷貝到用戶緩衝區

    // 釋放鎖
    release_sock(sk);

}

但就算是線程安全,也不代表你可以用多個線程併發去讀。

因爲這個鎖,只保證你在讀 socket 接收緩衝區時,只有一個線程在讀,但並不能保證你每次的時候,都能正好讀到完整消息體後才返回。

所以雖然併發讀不報錯,但每個線程拿到的消息肯定都不全,因爲鎖的粒度並不保證能讀完完整消息。

TCP 是基於數據流的協議,數據流會源源不斷從網卡那送到接收緩衝區

如果此時接收緩衝區裏有兩條完整消息,比如 " 我是小白 "和"點贊在看走一波"。

有兩個線程 A 和 B 同時併發去讀的話,A 線程就可能讀到 “我是 點贊走一波", B 線程就可能讀到” 小白 在看"

兩條消息都變得不完整了。

併發讀 socket_fd 導致的數據異常

解決方案還是跟讀的時候一樣,讀 socket 的只能有一個線程,讀到了消息之後塞到加鎖隊列中,再將消息分開給到 GameServer 的多線程用戶邏輯模塊中去做處理。

單線程讀 socket_fd 後寫入加鎖隊列

讀寫 UDP Socket 是線程安全的嗎?

聊完 TCP,我們很自然就能想到另外一個傳輸層協議 UDP,那麼它是線程安全的嗎?

我們平時寫代碼的時候如果要使用 udp 發送消息,一般會像下面這樣操作。

ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags, const struct sockaddr *to, socklen_t addrlen);

而執行到底層,會到 linux 內核的udp_sendmsg函數中。

int udp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t len) {
   if (用到了MSG_MORE的功能) {
        lock_sock(sk);
    // 加入到發送緩衝區中
    release_sock(sk);
   } else {
        // 不加鎖,直接發送消息
   }
}

這裏我用僞代碼改了下,大概的含義就是用到MSG_MORE就加鎖,否則不加鎖將傳入的msg作爲一整個數據包直接發送。

首先需要搞清楚,MSG_MORE 是啥。它可以通過上面提到的sendto函數最右邊的flags字段進行設置。大概的意思是告訴內核,待會還有其他更多消息要一起發,先彆着急發出去。此時內核就會把這份數據先用發送緩衝區緩存起來,待會應用層說 ok 了,再一起發。

但是,我們一般也用不到 MSG_MORE

所以我們直接關注另外一個分支,也就是不加鎖直接發消息。

那是不是說明走了不加鎖的分支時,udp 發消息並不是線程安全的?

其實。還是線程安全的,不用lock_sock(sk)加鎖,單純是因爲沒必要

開啓MSG_MORE時多個線程會同時寫到同一個 socket_fd 對應的發送緩衝區中,然後再統一一起發送到 IP 層,因此需要有個鎖防止出現多個線程將對方寫的數據給覆蓋掉的問題。而不開啓MSG_MORE時,數據則會直接發送給 IP 層,就沒有了上面的煩惱。

再看下 udp 的接收函數udp_recvmsg,會發現情況也類似,這裏就不再贅述。

能否多線程同時併發讀或寫同一個 UDP socket?

在 TCP 中,線程安全不代表你可以併發地讀寫同一個 socket_fd,因爲哪怕內核態中加了lock_sock(sk),這個鎖的粒度並不覆蓋整個完整消息的多次分批發送,它只保證單次發送的線程安全,所以建議只用一個線程去讀寫一個 socket_fd。

那麼問題又來了,那 UDP 呢?會有一樣的問題嗎?

我們跟 TCP 對比下,大家就知道了。

TCP 不能用多線程同時讀和同時寫,是因爲它是基於數據流的協議。

那 UDP 呢?它是基於數據報的協議。

UDP 是什麼

基於數據流和基於數據報有什麼區別呢?

基於數據流,意味着發給內核底層的數據就跟水進入水管一樣,內核根本不知道什麼時候是個頭,沒有明確的邊界

而基於數據報,可以類比爲一件件快遞進入傳送管道一樣,內核很清楚拿到的是幾件快遞,快遞和快遞之間邊界分明

水滴和快遞的差異

那從我們使用的方式來看,應用層通過 TCP 去發數據,TCP 就先把它放到緩衝區中,然後就返回。至於什麼時候發數據,發多少數據,發的數據是剛剛應用層傳進去的一半還是全部都是不確定的,全看內核的心情。在接收端收的時候也一樣。

但 UDP 就不同,UDP 對應用層交下來的報文,既不合並,也不拆分,而是保留這些報文的邊界

無論應用層交給 UDP 多長的報文,UDP 都照樣發送,即一次發送一個報文。至於數據包太長,需要分片,那也是 IP 層的事情,跟 UDP 沒啥關係,大不了效率低一些。而接收方在接收數據報的時候,一次取一個完整的包,不存在 TCP 常見的半包和粘包問題。

正因爲基於數據報基於字節流的差異,TCP 發送端發 10 次字節流數據,接收端可以分 100 次去取數據,每次取數據的長度可以根據處理能力作調整;但 UDP 發送端發了 10 次數據報,那接收端就要在 10 次收完,且發了多少次,就取多少次,確保每次都是一個完整的數據報。

所以從這個角度來說,UDP 寫數據報的行爲是 "原子" 的,不存在發一半包或收一半包的問題,要麼整個包成功,要麼整個包失敗。因此多個線程同時讀寫,也就不會有 TCP 的問題。

所以,可以多個線程同時讀寫同一個 udp socket。

就算可以,我依然不建議大家這麼做。

爲什麼不建議使用多線程同時讀寫同一個 UDP socket

udp 本身是不可靠的協議,多線程高併發執行發送時,會對系統造成較大壓力,這時候丟包是常見的事情。雖然這時候應用層能實現重傳邏輯,但重傳這件事畢竟是越少越好。因此通常還會希望能有個應用層流量控制的功能,如果是單線程讀寫的話,就可以在同一個地方對流量實現調控。類似的,實現其他插件功能也會更加方便,比如給某些 vip 等級的老闆更快速的遊戲體驗啥的(我瞎說的)。

所以正確的做法,還是跟 TCP 一樣,不管外面有多少個線程,還是併發加鎖寫到一個隊列裏,然後起一個單獨的線程去做發送操作。

udp 併發寫加鎖隊列後再寫 socket_fd

總結

    1. 多線程併發讀 / 寫同一個 TCP socket 是線程安全的,因爲 TCP socket 的讀 / 寫操作都上鎖了。雖然線程安全,但依然不建議你這麼做,因爲 TCP 本身是基於數據流的協議,一份完整的消息數據可能會分開多次去寫 / 讀,內核的鎖只保證單次讀 / 寫 socket 是線程安全,鎖的粒度並不覆蓋整個完整消息。因此建議用一個線程去讀 / 寫 TCP socket。
    1. 多線程併發讀 / 寫同一個 UDP socket 也是線程安全的,因爲 UDP socket 的讀 / 寫操作也都上鎖了。UDP 寫數據報的行爲是 "原子" 的,不存在發一半包或收一半包的問題,要麼整個包成功,要麼整個包失敗。因此多個線程同時讀寫,也就不會有 TCP 的問題。雖然如此,但還是建議用一個線程去讀 / 寫 UDP socket。

最後

上面文章裏提到,建議用單線程的方式去讀 / 寫 socket,但每個 socket 都配一個線程這件事情,顯然有些奢侈,比如線程切換的代價也不小,那這種情況有什麼好的解決辦法嗎?

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