提高服務端性能的幾個socket選項


提高服務端性能的幾個socket選項

在之前的一篇文章中,作者在配置了SO_REUSEPORT選項之后,使得應用的性能提高了數十倍。現在介紹socket選項中如下幾個可以提升服務端性能的選項:

  • SO_REUSEADDR
  • SO_REUSEPORT
  • SO_ATTACH_REUSEPORT_CBPF/EBPF

驗證環境:OS:centos 7.8;內核:5.9.0-1.el7.elrepo.x86_64

默認行為

TCP/UDP連接主要靠五元組來區分一條鏈接。只要五元組不同,則視為不同的連接。

{protocol, src addr, src port, dest addr, dest port}

默認情況下,兩個sockets不能綁定相同的源地址和源端口。運行如下服務端代碼,然后使用nc 127.0.0.1 9999連接服務端,通過crtl+c中斷服務之后,此時可以在系統上看到到9999端口有一條連接處於TIME-WAIT狀態,再啟動服務端就可以看到Address already in use錯誤。

# ss -nta|grep TIME-WAIT
TIME-WAIT  0      0      127.0.0.1:9999               127.0.0.1:49040
//例1
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main(int argc, char const *argv[]) {
    int lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (lfd == -1) {
        perror("socket: ");
        return -1;
    }

    struct sockaddr_in sockaddr;
    memset(&sockaddr, 0, sizeof(struct sockaddr_in));
    sockaddr.sin_family = AF_INET;
    sockaddr.sin_port = htons(9999);
    inet_pton(AF_INET, "127.0.0.1", &sockaddr.sin_addr);
    
    if (bind(lfd, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) == -1) {
        perror("bind: ");
        return -1;
    }

    if (listen(lfd, 128) == -1) {
        perror("listen: ");
        return -1;
    }

    struct sockaddr_storage claddr;
    socklen_t addrlen = sizeof(struct sockaddr_storage);
    int cfd = accept(lfd, (struct sockaddr*)&claddr, &addrlen);
    if (cfd == -1) {
        perror("accept: ");
        return -1;
    }
    printf("client connected: %d\n", cfd);

    char buff[100];
    for (;;) {
        ssize_t num = read(cfd, buff, 100);
        if (num == 0) {
            printf("client close: %d\n", cfd);
            close(cfd);
            break;
        } else if (num == -1) {
            int no = errno;
            if (no != EINTR && no != EAGAIN && no != EWOULDBLOCK) {
                printf("client error: %d\n", cfd);
                close(cfd);
            }
        } else {
            if (write(cfd, buff, num) != num) {
                printf("client error: %d\n", cfd);
                close(cfd);
            }
        }
    }
    return 0;
}

只要源端口不同,源地址實際上就無關緊要。假設將socketA綁定到A:X,socketB綁定到B:Y,其中A和B為地址,X和Y為端口。只要X!=Y(端口不同),這兩個socket都能綁定成功。如果X==Y,只要A!=B(地址不同),這兩個socket也能綁定成功。如果一個socket綁定到了0.0.0.0:21,則表示該socket綁定了所有現有的本地地址,此時,其他socket不能綁定到任何本地地址的21端口上。否則同樣會出現Address already in use錯誤。

測試場景為:創建兩個綁定地址分別為0.0.0.0127.0.0.1的服務app1和app2。啟動app1-->nc連接app1-->ctrl+c斷開app1-->啟動app2,此時就會出現Address already in use錯誤。

TCP客戶端通常不會綁定IP地址,內核會根據路由表選擇連接需要的源地址;而服務端通常會綁定一個地址,如果綁定了INADDR_ANY,則內核會使用接收到的報文的目的地址作為服務端的源地址。IPv4地址綁定的規則如下:

IP Address IP Port Result
INADDR_ANY 0 Kernel chooses IP address and port
INADDR_ANY non zero Kernel chooses IP address, process specifies port
Local IP address 0 Process specifies IP address, kernel chooses port
Local IP address non zero Process specifies IP address and port

SO_REUSEADDR

在啟用SO_REUSEADDR 選項之后,就可以在TCP_LISTEN狀態復用本地地址,當然,主要是為了在TIME_WAIT狀態復用本地地址(如支持服務端快速重啟)。需要注意的是Linux中對該選項的實現與BSD不同:前者要求復用者和被復用者都必須設置SO_REUSEADDR 選項,而后者僅要求復用者設置SO_REUSEADDR 選項即可。參見Linux socket幫助文檔。

啟用SO_REUSEADDR 選項后,在例1中的bind前添加如下代碼,然后運行,此時不會再報錯:

    int optval = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

SO_REUSEPORT

使用SO_REUSEPORT選項之后,就可以完全復用端口(無論被復用者處理任何狀態)。SO_REUSEPORT的目的主要是為多核多線程環境提供並行處理能力。如可以啟用多個worker線程,這些worker線程綁定相同的地址和端口。當新接入一條流時,內核會使用流哈希算法選擇使用哪個socket。

SO_REUSEADDR 選項類似,使用SO_REUSEPORT選項時,同樣要求復用者和被復用者同時設置該選項,如果被復用者沒有設置,即使復用者設置了該選項,最終綁定還是失敗的。

使用SO_REUSEPORT選項時可以不使用SO_REUSEADDR 選項。設置方式為:

setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));

SO_ATTACH_REUSEPORT_CBPF/EBPF

BPF相關的socket選項介紹

socket選項中,與bpf相關的有的有如下四個選項

  • SO_ATTACH_FILTER(since Linux 2.2):給socket附加一個cBPF,用於過濾接收到的報文。

  • SO_ATTACH_BPF(since Linux 3.19) :給socket附加一個eBPF,用於過濾接收到的報文。其參數為bpf(2)返回的指向類型為BPF_PROG_TYPE_SOCKET_FILTER的程序的文件描述符

  • SO_ATTACH_REUSEPORT_CBPF:與SO_REUSEPORT 配合使用,用於將報文分給reuseport組(即配置了SO_REUSEPORT選項,且使用相同的本地地址接收報文 )中的socket。如果BPF程序返回了無效的值,則回退為SO_REUSEPORT 機制,與SO_ATTACH_FILTER使用相同的參數。

    socket按添加到組的順序進行編號(即UDP socket使用bind(2)的順序,或TCP socket使用listen(2)的順序),當一個reuseport組新增一個socket后,該socket會集成該組中的BPF程序。當一個reuseport組(通過close(2))移除一個socket時,組中的最后一個socket會轉移到closed位置。

  • SO_ATTACH_REUSEPORT_EBPF:與SO_ATTACH_BPF使用相同的參數。

  • SO_DETACH_FILTER(since Linux 2.2)/SO_DETACH_BPF(since Linux 3.19) :用於移除使用SO_ATTACH_FILTER/SO_ATTACH_BPF附加到socket的cBPF/eBPF。

  • SO_LOCK_FILTER :用於防止附加的過濾器被意外detach掉。

    Linux 4.5添加了對UDP的支持,Linux 4.6添加了對TCP的支持。

如何使用BPF socket選項

如何編寫BPF程序
  • 使用libpcap:如果是使用BPF對報文進行處理,官方推薦使用libpcap,即tcpdump使用的庫,該庫提供了使用BPF對報文進行處理的函數,可以方便地對報文進行操作。缺點是該庫完全屏蔽了BPF的實現,僅能使用它提供的庫函數。例如不能使用socket選項SO_ATTACH_FILTER將一個socket和BPF程序進行關聯。

  • 使用BPF指令集編寫BPF程序,可以參見內核官方給出的例子/tools/testing/selftests/net/reuseport_bpf.c。這種方式下編寫的BPF代碼很簡潔,但由於BPF指令集其實就是一個特殊的匯編語言,理解上比較晦澀,且維護起來比較困難。官方對為這類匯編也封裝了一些簡單的宏,可以參見/include/linux/filter.h

    下面以bpf(2)幫助手冊中的例子,看下如何使用BPF指令集編寫BPF程序:

    /* bpf+sockets example:
    * 1. create array map of 256 elements
    * 2. load program that counts number of packets received
    *    r0 = skb->data[ETH_HLEN + offsetof(struct iphdr, protocol)]
    *    map[r0]++
    * 3. attach prog_fd to raw socket via setsockopt()
    * 4. print number of received TCP/UDP packets every second
    */
    int main(int argc, char **argv)
    {
        int sock, map_fd, prog_fd, key;
        long long value = 0, tcp_cnt, udp_cnt;
    
        map_fd = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(key), sizeof(value), 256);
        if (map_fd < 0) {
            printf("failed to create map '%s'\n", strerror(errno));
            /* likely not run as root */
            return 1;
        }
    
        struct bpf_insn prog[] = {
            /* 該部分內容參見下表解析 */
        };
    
        prog_fd = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, prog, sizeof(prog) / sizeof(prog[0]), "GPL");
        sock = open_raw_sock("lo");
        assert(setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd)) == 0);
    
        for (;;) {
            key = IPPROTO_TCP;
            assert(bpf_lookup_elem(map_fd, &key, &tcp_cnt) == 0);
            key = IPPROTO_UDP;
            assert(bpf_lookup_elem(map_fd, &key, &udp_cnt) == 0);
            printf("TCP %lld UDP %lld packets\n", tcp_cnt, udp_cnt);
            sleep(1);
        }
    
        return 0;
    }
    

    bpf_insn prog[]中的內容如下:

    指令 解釋
    BPF_MOV64_REG(BPF_REG_6, BPF_REG_1), dst_reg = src_reg,即:r6 = r1
    BPF_LD_ABS(BPF_B, ETH_HLEN + offsetof(struct iphdr, protocol)), R0 = *(uint *) (skb->data + imm32),即:r0 = ip->proto,此時r0保存了IP報文中的協議號,可以為TCP/UDP等
    BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), *(uint *) (dst_reg + off16) = src_reg,即:*(u32 *)(fp - 4) = r0,將r0保存的協議號入棧,地址為fp - 4(4個字節是因為BPF_LD_ABS中用於保存協議號的imm32的大小為4個字節)
    BPF_MOV64_REG(BPF_REG_2, BPF_REG_10), r2 = fp,將幀指針地址傳給r2,下一步用於獲取棧中保存的協議號
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), dst_reg += imm32,即:r2 = r2 - 4,此時r2指向棧中保存的IP協議號
    BPF_LD_MAP_FD(BPF_REG_1, map_fd), 將本地創建的map_fd保存到寄存器中,即:r1 = map_fd
    BPF_CALL_FUNC(BPF_FUNC_map_lookup_elem), 調用map_lookup函數在map_fd中查找r2指針指向的key(索引)對應的value,即:r0 = map_lookup(r1, r2),r0為map中key對應的地址,map_fd[*r2]。BPF_FUNC_map_lookup_elem對應bpf_map_lookup_elem函數。
    BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2), if (dst_reg 'op' imm32) goto pc + off16,即:if (r0 == 0) goto pc+2(2個字節,16bits),如果沒有在map_fd中找到對應的值(r0,即對應協議號的報文),則跳轉到BPF_MOV64_IMM(BPF_REG_0, 0),返回0
    BPF_MOV64_IMM(BPF_REG_1, 1), 如果在map_fd中找到了r1 = 1,作為累加單位,1
    BPF_XADD(BPF_DW, BPF_REG_0, BPF_REG_1, 0, 0), *(u64 *) r0 += r1,即如果在map_fd中找到的期望的報文,則r0指向的值加1,相當於map_fd[key]++
    BPF_MOV64_IMM(BPF_REG_0, 0), r0 = 0,此時表示沒有找到任何期望的報文,數目為0
    BPF_EXIT_INSN(), return r0
  • 通過BPF輔助函數:可以比較方便地編寫BPF用戶態和內核態代碼。典型的例子可以參考內核源碼樹中提供的例子:/tools/testing/selftests/bpf/progs_test/select_reuseport.c/tools/testing/selftests/bpf/progs/test_select_reuseport_kern.c。具體用法可以學習XDP教程。這種方式相比使用BPF指令集在開發和維護上成本要低一些,但也有一些不便利的地方,即用戶態需要依賴內核態編譯出來的.o文件,因此在編譯上需要分成兩步。

    bpf提供的輔助函數接口可以參見官方文檔,類型參見bpf(2)

Note:建議借用xdp-tutorial中的Makefile編譯bpf內核態和用戶態的程序。

將socket與BPF程序關聯

有了上述知識,其實將socket與BPF程序關聯其實就是將BPF過濾出來的報文傳遞給這個關聯的socket。

提高UDP交互性能一文中,提高流量的一個方式就是使用BPF程序將socket與CPU核關聯起來,實際就是將一個socket與這個核上的流進行了關聯,防止因為哈希算法導致多條流爭用同一個socket導致性能下降,也提升了CPU緩存的命中率。

還有一點需要注意的是,使用BPF將socket與CPU核進行關聯之前,需要確保該socket所在的流不會漂移到其他核上,在提高UDP交互性能中使用了irqbalance的-h exact選項,防止沖突核漂移。

拓展

  • 系統參數net.ipv4.tcp_tw_reuse可以用於快速回收TIME_WAIT狀態的端口,但只適用於客戶端,且只在客戶端執行connect時才會生效。調用鏈如下:

    tcp_v4_pre_connect->inet_hash_connect->__inet_check_established->tcp_twsk_unique->sysctl_tcp_tw_reuse(內核參數值)

  • How do SO_REUSEADDR and SO_REUSEPORT differ? 這篇文章中對SO_REUSEADDR有如下描述,原意是說啟用SO_REUSEADDR之后,系統會將泛地址和非泛地址分開,如當一個socket綁定0.0.0.0:port時,另外一個socket可以成功綁定本地地址192.168.0.1:port ,但在本次測試中發現這種情況下也會失敗。

    With SO_REUSEADDR it will succeed, since 0.0.0.0 and 192.168.0.1 are not exactly the same address, one is a wildcard for all local addresses and the other one is a very specific local address

  • 當前cBPF格式用於在32位架構上執行JIT編譯;而eBPF指令集用於在x86-64, aarch64, s390x, powerpc64, sparc64, arm32, riscv64, riscv32 架構上執行JIT編譯。

參考


免責聲明!

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



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