告知你不為人知的UDP-連接性和負載均衡


版權聲明:本文由黃日成 原創文章,轉載請注明出處: 
文章原文鏈接:https://www.qcloud.com/community/article/812444001486438028

來源:騰雲閣 https://www.qcloud.com/community

 

作者介紹:黃日成,手Q游戲中心后台開發,騰訊高級工程師。從事C++服務后台開發4年多,主要負責手Q游戲中心后台基礎系統、復雜業務系統開發,主導過手Q游戲公會、企鵝電競App-對戰系統等項目的后台系統設計,有豐富的后台架構經驗。

引言

作為文章”《從TCP三次握手說起—淺析TCP協議中的疑難雜症》”的姊妹篇,很早就計划寫篇關於UDP的文章,盡管UDP協議遠沒TCP協議那么龐大、復雜,但是,要想將UDP描述清楚,用好UDP卻要比TCP難不少,於是文章從下筆寫,到最終寫成,斷斷續續拖了好幾個月。
說起網絡socket,大家自然會想到TCP,用的最多也是TCP,UDP在大家的印象中是作為TCP的補充而存在,是無連接、不可靠、無序、無流量控制的傳輸層協議。UDP的無連接性已經深入人心,協議上的無連接性指的是一個UDP的Endpoint1(IP,PORT),可以向多個UDP的Endpointi(IP,PORT)發送數據包,也可以接收來自多個UDP的Endpointi(IP,PORT)的數據包。實現上,考慮這樣一個特殊情況:UDP Client 在Endpoint_C1只往UDP Server的Endpoint_S1發送數據包,並且只接收來自Endpoint_S1的數據包,把UDP通信雙方都固定下來,這樣不就形成一條單向的虛”連接”了么?

1. UDP的”連接性”

估計很多同學認為UDP的連接性只是將UDP通信雙方都固定下來了,一對一只是多對多的一個特例而已,這樣UDP連接不連接到無所謂了。果真如此嗎?其實不然,UDP的連接性可以帶來以下兩個好處:

1.1 高效率、低消耗
我們知道Linux系統有用戶空間(用戶態)和內核空間(內核態)之分,對於x86處理器以及大多數其它處理器,用戶空間和內核空間之前的切換是比較耗時(涉及到上下文的保存和恢復,一般3種情況下會發生用戶態到內核態的切換:發生系統調用時、產生異常時、中斷時)。那么對於一個高性能的服務應該減少頻繁不必要的上下文切換,如果切換無法避免,那么盡量減少用戶空間和內核空間的數據交換,減少數據拷貝。熟悉socket編程的同學對下面幾個系統調用應該比較熟悉了,由於UDP是基於用戶數據報的,只要數據包准備好就應該調用一次send或sendto進行發包,當然包的大小完全由應用層邏輯決定的。細看兩個系統調用的參數便知道,sendto比send的參數多2個,這就意味着每次系統調用都要多拷貝一些數據到內核空間,同時,參數到內核空間后,內核還需要初始化一些臨時的數據結構來存儲這些參數值(主要是對端Endpoint_S的地址信息),在數據包發出去后,內核還需要在合適的時候釋放這些臨時的數據結構。進行UDP通信的時候,如果首先調用connect綁定對端Endpoint_S的后,那么就可以直接調用send來給對端Endpoint_S發送UDP數據包了。用戶在connect之后,內核會永久維護一個存儲對端Endpoint_S的地址信息的數據結構,內核不再需要分配/刪除這些數據結構,只需要查找就可以了,從而減少了數據的拷貝。這樣對於connect方而言,該UDP通信在內核已經維護這一個“連接”了,那么在通信的整個過程中,內核都能隨時追蹤到這個“連接”。

int connect(int socket, const struct sockaddr *address, socklen_t address_len); ssize_t send(int socket, const void *buffer, size_t length, int flags); ssize_t sendto(int socket, const void *message, size_t length, int flags, const struct sockaddr *dest_addr, socklen_t dest_len); ssize_t recv(int socket, void *buffer, size_t length, int flags); ssize_t recvfrom(int socket, void *restrict buffer, size_t length, int flags, struct sockaddr *restrict address, socklen_t *restrict address_len); 

1.2 錯誤提示

相信大家寫UDP Socket程序的時候,有時候在第一次調用sendto給一個unconnected UDP socket發送UDP數據包時,接下來調用recvfrom()或繼續調sendto的時候會返回一個ECONNREFUSED錯誤。對於一個無連接的UDP是不會返回這個錯誤的,之所以會返回這個錯誤,是因為你明確調用了connect去連接遠端的Endpoint_S了。那么這個錯誤是怎么產生的呢?沒有調用connect的UDP Socket為什么無法返回這個錯誤呢?
當一個UDP socket去connect一個遠端Endpoint_S時,並沒有發送任何的數據包,其效果僅僅是在本地建立了一個五元組映射,對應到一個對端,該映射的作用正是為了和UDP帶外的ICMP控制通道捆綁在一起,使得UDP socket的接口含義更加豐滿。這樣內核協議棧就維護了一個從源到目的地的單向連接,當下層有ICMP(對於非IP協議,可以是其它機制)錯誤信息返回時,內核協議棧就能夠准確知道該錯誤是由哪個用戶socket產生的,這樣就能准確將錯誤轉發給上層應用了。對於下層是IP協議的時候,ICMP錯誤信息返回時,ICMP的包內容就是出錯的那個原始數據包,根據這個原始數據包可以找出一個五元組,根據該五元組就可以對應到一個本地的connect過的UDP socket,進而把錯誤消息傳輸給該socket,應用程序在調用socket接口函數的時候,就可以得到該錯誤消息了。
對於一個無“連接”的UDP,sendto系統調用后,內核在將數據包發送出去后,就釋放了存儲對端Endpoint_S的地址等信息的數據結構了,這樣在下層的協議有錯誤返回的時候,內核已經無法追蹤到源socket了。
這里有個注意點要說明一下,由於UDP和下層協議都是不可靠的協議,所以,不能總是指望能夠收到遠端回復的ICMP包,例如:中間的一個節點或本機禁掉了ICMP,socket api調用就無法捕獲這些錯誤了。

2 UDP的負載均衡

在多核(多CPU)的服務器中,為了充分利用機器CPU資源,TCP服務器大多采用accept/fork模式,TCP服務的MPM機制(multi processing module),不管是預先建立進程池,還是每到一個連接創建新線程/進程,總體都是源於accept/fork的變體。然而對於UDP卻無法很好的采用PMP機制,由於UDP的無連接性、無序性,它沒有通信對端的信息,不知道一個數據包的前置和后續,它沒有很好的辦法知道,還有沒后續的數據包以及如果有的話,過多久才會來,會來多久,因此UDP無法為其預先分配資源。

2.1 端口重用SO_REUSEADDR、SO_REUSEPORT

要進行多處理,就免不了要在相同的地址端口上處理數據,SO_REUSEADDR允許端口的重用,只要確保四元組的唯一性即可。對於TCP,在bind的時候所有可能產生四元組不唯一的bind都會被禁止(於是,ip相同的情況下,TCP套接字處於TIME_WAIT狀態下的socket,才可以重復綁定使用);對於connect,由於通信兩端中的本端已經明確了,那么只允許connect從來沒connect過的對端(在明確不會破壞四元組唯一性的connect才允許發送SYN包);對於監聽listen端,四元組的唯一性油connect端保證就OK了。

TCP通過連接來保證四元組的唯一性,一個connect請求過來,accept進程accept完這個請求后(當然不一定要單獨accept進程),就可以分配socket資源來標識這個連接,接着就可以分發給相應的worker進程去處理該連接后續的事情了。這樣就可以在多核服務器中,同時有多個worker進程來同時處理多個並發請求,從而達到負載均衡,CPU資源能夠被充分利用。

UDP的無連接狀態(沒有已有對端的信息),使得UDP沒有一個有效的辦法來判斷四元組是否沖突,於是對於新來的請求,UDP無法進行資源的預分配,於是多處理模式難以進行,最終只能“守株待兔“,UDP按照固定的算法查找目標UDP socket,這樣每次查到的都是UDP socket列表固定位置的socket。UDP只是簡單基於目的IP和目的端口來進行查找,這樣在一個服務器上多個進程內創建多個綁定相同IP地址(SO_REUSEADDR),相同端口的UDP socket,那么你會發現,只有最后一個創建的socket會接收到數據,其它的都是默默地等待,孤獨地等待永遠也收不到UDP數據。UDP這種只能單進程、單處理的方式將要破滅UDP高效的神話,你在一個多核的服務器上運行這樣的UDP程序,會發現只有一個核在忙,其他CPU核心處於空閑的狀態。創建多個綁定相同IP地址,相同端口的UDP程序,只會起到容災備份的作用,不會起到負載均衡的作用。

要實現多處理,那么就要改變UDP Socket查找的考慮因素,對於調用了connect的UDP Client而言,由於其具有了“連接”性,通信雙方都固定下來了,那么內核就可以根據4元組完全匹配的原則來匹配。於是對於不同的通信對端,可以查找到不同的UDP Socket從而實現多處理。而對於server端,在使用SO_REUSEPORT選項(linux 3.9以上內核),這樣在進行UDP socket查找的時候,源IP地址和源端口也參與進來了,內核查找算法可以保證:

[1] 固定的四元組的UDP數據包總是查找到同一個UDP Socket
[2] 不同的四元組的UDP數據包可能會查找到不同的UDP Socket

這樣對於不同client發來的數據包就能查找到不同的UDP socket從而實現多處理。這樣看來,似乎采用SO_REUSEADDR、SO_REUSEPORT這兩個socket選項並利用內核的socket查找算法,我們在多核CPU服務器上多個進程內創建多個綁定相同端口,相同IP地址的UDP socket就能做到負載均衡充分利用多核CPU資源了。然而事情遠沒這么順利、簡單。

2.2 UDP Socket列表變化問題

通過上面我們知道,在采用SO_REUSEADDR、SO_REUSEPORT這兩個socket選項后,內核會根據UDP數據包的4元組來查找本機上的所有相同目的IP地址,相同目的端口的socket中的一個socket的位置,然后以這個位置上的socket作為接收數據的socket。那么要確保來至同一個Client Endpoint的UDP數據包總是被同一個socket來處理,就需要保證整個socket鏈表的socket所處的位置不能改變,然而,如果socket鏈表中間的某個socket掛了的話,就會造成socket鏈表重新排序,這樣會引發問題。於是基本的解決方案是在整個服務過程中不能關閉UDP socket(當然也可以全部UDP socket都close掉,從新創建一批新的)。要保證這一點,我們需要所有的UDP socket的創建和關閉都由一個master進行來管理,worker進程只是負責處理對於的網絡IO任務,為此我們需要socket在創建的時候要帶有CLOEXEC標志(SOCK_CLOEXEC)。

2.3 UDP和Epoll結合 - UDP的Accept模型

到此,為了充分利用多核CPU資源,進行UDP的多處理,我們會預先創建多個進程,每個進程都創建一個或多個綁定相同端口,相同IP地址(SO_REUSEADDR、SO_REUSEPORT)的UDP socket,這樣利用內核的UDP socket查找算法來達到UDP的多進程負載均衡。然而,這完全依賴於Linux內核處理UDP socket查找時的一個算法,我們不能保證其它的系統或者未來的Linux內核不會改變算法的行為;同時,算法的查找能否做到比較好的均勻分布到不同的UDP socket,(每個處理進程只處理自己初始化時候創建的那些UDP socket)負載是否均衡是個問題。於是,我們多么想給UPD建立一個accept模型,按需分配UDP socket來處理。
在高性能Server編程中,對於TCP Server而已有比較成熟的解決方案,TCP天然的連接性可以充分利用epoll等高性能event機制,采用多路復用、異步處理的方式,哪個worker進程空閑就去accept連接請求來處理,這樣就可以達到比較高的並發,可以極限利用CPU資源。然而對於UDP server而言,由於整個Svr就一個UDP socket,接收並響應所有的client請求,於是也就不存在什么多路復用的問題了。UDP svr無法充分利用epoll的高性能event機制的主要原因是,UDP svr只有一個UDP socket來接收和響應所有client的請求。然而如果能夠為每個client都創建一個socket並虛擬一個“連接”與之對應,這樣不就可以充分利用內核UDP層的socket查找結果和epoll的通知機制了么。server端具體過程如下:

  1. UDP svr創建UDP socket fd,設置socket為REUSEADDR和REUSEPORT、同時bind本地地址local_addr
    listen_fd = socket(PF_INET, SOCK_DGRAM, 0)
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt,sizeof(opt))
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt))
    bind(listen_fd, (struct sockaddr *) &local_addr, sizeof(struct sockaddr))
  2. 創建epoll fd,並將listen_fd放到epoll中 並監聽其可讀事件
    epoll_fd = epoll_create(1000);
    ep_event.events = EPOLLIN|EPOLLET;
    ep_event.data.fd = listen_fd;
    epoll_ctl(epoll_fd , EPOLL_CTL_ADD, listen_fd, &ep_event)
    in_fds = epoll_wait(epoll_fd, in_events, 1000, -1);
  3. epoll_wait返回時,如果epoll_wait返回的事件fd是listen_fd,調用recvfrom接收client第一個UDP包並根據recvfrom返回的client地址, 創建一個新的socket(new_fd)與之對應,設置new_fd為REUSEADDR和REUSEPORT、同時bind本地地址local_addr,然后connect上recvfrom返回的client地址
    recvfrom(listen_fd, buf, sizeof(buf), 0, (struct sockaddr )&client_addr, &client_len)
    new_fd = socket(PF_INET, SOCK_DGRAM, 0)
    setsockopt(new_fd , SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse))
    setsockopt(new_fd , SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse))
    bind(new_fd , (struct sockaddr ) &local_addr, sizeof(struct sockaddr));
    connect(new_fd , (struct sockaddr *) &client_addr, sizeof(struct sockaddr)
  4. 將新創建的new_fd加入到epoll中並監聽其可讀等事件
    client_ev.events = EPOLLIN;
    client_ev.data.fd = new_fd ;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_fd , &client_ev)
  5. 當epoll_wait返回時,如果epoll_wait返回的事件fd是new_fd 那么就可以調用recvfrom來接收特定client的UDP包了
    recvfrom(new_fd , recvbuf, sizeof(recvbuf), 0, (struct sockaddr *)&client_addr, &client_len)

通過上面的步驟,這樣UDP svr就能充分利用epoll的事件通知機制了。第一次收到一個新的client的UDP數據包,就創建一個新的UDP socket和這個client對應,這樣接下來的數據交互和事件通知都能准確投遞到這個新的UDP socket fd了。

這里的UPD和Epoll結合方案,有以下幾個注意點

[1] client要使用固定的ip和端口和server端通信,也就是client需要bind本地local address。
如果client沒有bind本地local address,那么在發送UDP數據包的時候,可能是不同的Port了,這樣如果server端的new_fd connect的是client的Port_CA端口,那么當Client的Port_CB端口的UDP數據包來到server時,內核不會投遞到new_fd,相反是投遞到listen_fd。由於需要bind和listen fd一樣的IP地址和端口,因此SO_REUSEADDR和SO_REUSEPORT是必須的。
[2] 要小心處理上面步驟3中connect返回前,Client已經有多個UDP包到達Server端的情況。
如果server沒處理好這個情況,在connect返回前,有2個UDP包到達server端了,這樣server會new出兩個new_fd1和new_fd2分別connect到client,那么后續的client的UDP到達server的時候,內核會投遞UDP包給new_fd1和new_fd2中的一個

上面的UDP和Epoll結合的accept模型有個不好處理的小尾巴(也就是上面的注意點[2]),這個小尾巴的存在其本質是UDP和4元組沒有必然的對應關系,也就是UDP的無連接性。

2.3 UDP Fork 模型 - UDP accept模型之按需建立UDP處理進程

為了充分利用多核CPU(為簡化討論,不妨假設為8核),理想情況下,同時有8個工作進程在同時工作處理請求。於是我們會初始化8個綁定相同端口,相同IP地址(SO_REUSEADDR、SO_REUSEPORT)的UDP socket,接下來就靠內核的查找算法來達到client請求的負載均衡了。由於內核查找算法是固定的,於是,無形中所有的client被划分為8類,類型1的所有client請求全部被路由到工作進程1的UDP socket由工作進程1來處理,同樣類型2的client的請求也全部被工作進程2來處理。這樣的缺陷是明顯的,比較容易造成短時間的負載極端不均衡。

一般情況下,如果一個UDP包能夠標識一個請求,那么簡單的解決方案是每個UDP socket n的工作進程n,自行fork出多個子進程來處理類型n的client的請求。這樣每個子進程都直接recvfrom就OK了,拿到UDP請求包就處理,拿不到就阻塞。

然而,如果一個請求需要多個UDP包來標識的情況下,事情就沒那么簡單了,我們需要將同一個client的所有UDP包都路由到同一個工作子進程。為了簡化討論,我們將注意力集中在都是類型n的多個client請求UDP數據包到來的時候,我們怎么處理的問題,不同類型client的數據包路由問題交給內核了。這樣,我們需要一個master進程來監聽UDP socket的可讀事件,master進程監聽到可讀事件,就采用MSG_PEEK選項來recvfrom數據包,如果發現是新的Endpoit(ip、port)Client的UDP包,那么就fork一個新的進行來處理該Endpoit的請求。具體如下

[1] master進程監聽udp_socket_fd的可讀事件:pfd.fd = udp_socket_fd;pfd.events = POLLIN; poll(pfd, 1, -1);
當可讀事件到來,pfd.revents & POLLIN 為true。探測一下到來的UDP包是否是新的client的UDP包:recvfrom(pfd.fd, buf, MAXSIZE, MSG_PEEK, (struct sockaddr )pclientaddr, &addrlen);查找一下worker_list是否為該client創建過worker進程了。
[2] 如果沒有查找到,就fork()處理進程來處理該請求,並將該client信息記錄到worker_list中。查找到,那么continue,回到步驟[1]
[3] 每個worker子進程,保存自己需要處理的client信息pclientaddr。worker進程同樣也監聽udp_socket_fd的可讀事件。poll(pfd, 1, -1);當可讀事件到來,pfd.revents & POLLIN 為true。探測一下到來的UDP包是否是本進程需要處理的client的UDP包:recvfrom(pfd.fd, buf, MAXSIZE, MSG_PEEK, (struct sockaddr )pclientaddr_2, &addrlen); 比較一下pclientaddr和pclientaddr_2是否一致。

該fork模型很別扭,過多的探測行為,一個數據包來了,會”驚群”喚醒所有worker子進程,大家都去PEEK一把,最后只有一個worker進程能夠取出UDP包來處理。同時到來的數據包只能排隊被取出。更為嚴重的是,由於recvfrom的排他喚醒,可能會造成死鎖。考慮下面一個場景:
假設有worker1、worker2、worker3、和master共四個進程都阻塞在poll調用上,client1的一個新的UDP包過來,這個時候,四個進程會被同時喚醒,worker1比較神速,趕在其他進程前將UPD包取走了(worker1可以處理client1的UDP包),於是其他三個進程的recvfrom撲空,它們worker2、worker3、和master按序全部阻塞在recvfrom上睡眠(worker2、worker3排在master前面先睡眠的)。這個時候,一個新client4的UDP包packet4到來,(由於recvfrom的排他喚醒)這個時候只有worker2會從recvfrom的睡眠中醒來,然而worker而卻不能處理該請求UDP包。如果沒有新UDP包到來,那么packet4一直留在內核中,死鎖了。之所以recv是排他的,是為了避免“承諾給一個進程”的數據被其他進程取走了。
通過上面的討論,不管采用什么手段,UDP的accept模型總是那么別扭,總有一些無法自然處理的小尾巴。UDP的多路負載均衡方案不通用,不自然,其本因在於UPD的無連接性、無序性(無法標識數據的前續后繼)。我們不知道client還在不在,於是難於決策虛擬的”連接”何時終止,以及何時結束掉fork出來的worker子進程(我們不能無限fork吧)。於是,在沒有好的決策因素的時候,超時似乎是一個比較好選擇,畢竟當所有的裁決手段都失效的時候,一切都要靠時間來沖淡。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM