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


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

引言

說起網絡 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通信在內核已經維護這一個“連接”了,那麼在通信的整個過程中,內核都能隨時追蹤到這個“連接”。

<code>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);
/<code>

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 吧)。於是,在沒有好的決策因素的時候,超時似乎是一個比較好選擇,畢竟當所有的裁決手段都失效的時候,一切都要靠時間來沖淡。

另外還有一些關於c++ Linux後臺服務器開發的一些知識點分享:Linux,Nginx,MySQL,Redis,P2P,K8S,Docker,TCP/IP,協程,DPDK,webrtc,音視頻等等視頻。

喜歡的朋友可以後臺私信【1】獲取學習視頻


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


分享到:


相關文章: