引言
說起網絡 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端具體過程如下:
- 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))
- 創建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);
- 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)
- 將新創建的new_fd加入到epoll中並監聽其可讀等事件 client_ev.events = EPOLLIN; client_ev.data.fd = new_fd ; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_fd , &client_ev)
- 當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】獲取學習視頻