百万 Go TCP 连接的思考: epoll方式减少资源占用

    科技2022-08-01  105

    原文作者:smallnest

    前几天 Eran Yanay 在 Gophercon Israel 分享了一个讲座:Going Infinite, handling 1M websockets connections in Go

    , 介绍了使用Go实现支持百万连接的websocket服务器,引起了很大的反响。事实上,相关的技术在2017年的一篇技术中已经介绍: A Million WebSockets and Go, 这篇2017年文章的作者Sergey Kamardin也就是 Eran Yanay 项目中使用的ws库的作者。

    第一篇 百万 Go TCP 连接的思考: epoll方式减少资源占用第二篇 百万 Go TCP 连接的思考2: 百万连接的吞吐率和延迟第三篇 百万 Go TCP 连接的思考: 正常连接下的吞吐率和延迟

    相关代码已发布到github上: 1m-go-tcp-server。

    Sergey Kamardin 在 A Million WebSockets and Go 一文中介绍了epoll的使用(mailru/easygo,支持epoll on linux, kqueue onbsd, darwin), ws的zero copy的upgrade等技术。

    Eran Yanay的分享中对epoll的处理做了简化,而且提供了docker测试的脚本,很方便的在单机上进行百万连接的测试。

    2015年的时候我也曾作为百万连接的websocket的服务器的比较:使用四种框架分别实现百万websocket常连接的服务器 、七种WebSocket框架的性能比较。应该说,只要服务器硬件资源足够(内存和CPU), 实现百万连接的服务器并不是很难的事情,

    操作系统会为每一个连接分配一定的内存空间外(主要是内部网络数据结构sk_buff的大小、连接的读写缓存,sof),虽然这些可以进行调优,但是如果想使用正常的操作系统的TCP/IP栈的话,这些是硬性的需求。刨去这些,不同的编程语言不同的框架的设计,甚至是不同的需求场景,都会极大的影响TCP服务器内存的占用和处理。

    一般Go语言的TCP(和HTTP)的处理都是每一个连接启动一个goroutine去处理,因为我们被教导goroutine的不像thread, 它是很便宜的,可以在服务器上启动成千上万的goroutine。但是对于一百万的连接,这种goroutine-per-connection的模式就至少要启动一百万个goroutine,这对资源的消耗也是极大的。针对不同的操作系统和不同的Go版本,一个goroutine锁使用的最小的栈大小是2KB ~ 8 KB (go stack),如果在每个goroutine中在分配byte buffer用以从连接中读写数据,几十G的内存轻轻松松就分配出去了。

    所以Eran Yanay使用epoll的方式代替goroutine-per-connection的模式,使用一个goroutine代码一百万的goroutine, 另外使用ws减少buffer的分配,极大的减少了内存的占用,这也是大家热议的一个话题。

    当然诚如作者所言,他并不是要提供一个更好的优化的websocket框架,而是演示了采用一些技术进行的优化,通过阅读他的slide和代码,我们至少有以下疑问?

    -虽然支持百万连接,但是并发的吞吐率和延迟是怎样的?

    -服务器实现的是单goroutine的处理,如果业务代码耗时较长会怎么样

    -主要适合什么场景?

    吞吐率和延迟需要数据来支撑,但是显然这个单goroutine处理的模式不适合耗时较长的业务处理,"hello world"或者直接的简单的memory操作应该没有问题。对于百万连接但是并发量很小的场景,比如消息推送、页游等场景,这种实现应该是没有问题的。但是对于并发量很大,延迟要求比较低的场景,这种实现可能会存在问题。

    这篇文章和后续的两篇文章,将测试巨量连接/高并发/低延迟场景的几种服务器模式的性能,通过比较相应的连接、吞吐率、延迟,给读者一个有价值的选型参考。

    作为一个更通用的测试,我们实现的是TCP服务器,而不是websocket服务器。

    在实现一个TCP服务器的时候,首先你要问自己,到底你需要的是哪一个类型的服务器?当然你可能会回答,我都想要啊。但是对于一个单机服务器,资源是有限的,鱼与熊掌不可兼得,我们只能尽力挖掘单个服务器的能力,有些情况下必须通过堆服务器的方式解决,尤其在双十一、春节等时候,很大程度上都是通过扩容来解决的,这是因为单个服务器确确实实能力有限。

    尽管单个服务器能力有限,不同的设计取得的性能也是不一样的,这个系列的文章测试不同的场景、不同的设计对性能的影响以及总结,主要包括:

    -百万连接情况下的goroutine-per-connection模式服务器的资源占用

    -百万连接情况下的epoller模式服务器的资源占用

    -百万连接情况下epoller模式服务器的吞吐率和延迟 

    -客户端为单goroutine和多goroutine情况下epoller方式测试

    -服务器为多epoller情况下的吞吐率和延迟 (百万连接)

    -prefork模式的epoller服务器 (百万连接)

    -Reactor模式的epoller服务器 (百万连接)

    -正常连接下高吞吐服务器的性能(连接数<=5000)

    -I/O密集型epoll服务器

    -I/O密集型goroutine-per-connection服务器

    -CPU密集型epoll服务器

    -CPU密集型goroutine-per-connection服务器

    零、 测试环境的搭建

    我们在同一台机器上测试服务器和客户端。首先就是服务器参数的设置,主要是可以打开的文件数量。

    file-max是设置系统所有进程一共可以打开的文件数量。同时程序也可以通过setrlimit调用设置每个进程的限制。

    echo 2000500 > /proc/sys/fs/file-max或者 sysctl -w "fs.file-max=2000500"可以实时更改这个参数,但是重启之后会恢复为默认值。也可以修改/etc/sysctl.conf, 加入fs.file-max = 2000500重启或者sysctl -w生效。

    设置资源限制。首先修改/proc/sys/fs/nr_open,然后再用ulimit进行修改:

    1echo 2000500 > /proc/sys/fs/nr_open 2ulimit -n 2000500

    ulimit设置当前shell以及由它启动的进程的资源限制,所以你如果打开多个shell窗口,应该都要进行设置。

    当然如果你想重启以后也会使用这些参数,你需要修改/etc/sysctl.conf中的fs.nr_open参数和/etc/security/limits.conf的参数:

    1# vi /etc/security/limits.conf 2* soft nofile 2000500  3* hard nofile 2000500

    如果你开启了iptables,iptalbes会使用nf_conntrack模块跟踪连接,而这个连接跟踪的数量是有最大值的,当跟踪的连接超过这个最大值,就会导致连接失败。 通过命令查看

    1# wc -l /proc/net/nf_conntrack 2  1024000

    查看最大值

    1# cat /proc/sys/net/nf_conntrack_max 2 1024000

    可以通过修改这个最大值来解决这个问题

    在/etc/sysctl.conf添加内核参数 net.nf_conntrack_max = 2000500

    对于我们的测试来说,为了我们的测试方便,可能需要一些网络协议栈的调优,可以根据个人的情况进行设置。

    1sysctl -w fs.file-max=2000500 2sysctl -w fs.nr_open=2000500 3sysctl -w net.nf_conntrack_max=2000500 4ulimit -n 2000500 5sysctl -w net.ipv4.tcp_mem='131072  262144  524288' 6sysctl -w net.ipv4.tcp_rmem='8760  256960  4088000' 7sysctl -w net.ipv4.tcp_wmem='8760  256960  4088000' 8sysctl -w net.core.rmem_max=16384 9sysctl -w net.core.wmem_max=16384 10sysctl -w net.core.somaxconn=2048 11sysctl -w net.ipv4.tcp_max_syn_backlog=2048 12sysctl -w /proc/sys/net/core/netdev_max_backlog=2048 13sysctl -w net.ipv4.tcp_tw_recycle=1 14sysctl -w net.ipv4.tcp_tw_reuse=1

    另外,我的测试环境是是两颗 E5-2630 V4的CPU, 一共20个核,打开超线程40个逻辑核, 内存32G。

    一、 简单的支持百万连接的TCP服务器

    服务器

    首先我们实现一个百万连接的服务器,采用每个连接一个goroutine的模式(goroutine-per-conn)。

    1func main() { 2    ln, err := net.Listen("tcp", ":8972") 3    if err != nil { 4        panic(err) 5    } 6    go func() { 7        if err := http.ListenAndServe(":6060", nil); err != nil { 8            log.Fatalf("pprof failed: %v", err) 9        } 10    }() 11    var connections []net.Conn 12    defer func() { 13        for _, conn := range connections { 14            conn.Close() 15        } 16    }() 17    for { 18        conn, e := ln.Accept() 19        if e != nil { 20            if ne, ok := e.(net.Error); ok && ne.Temporary() { 21                log.Printf("accept temp err: %v", ne) 22                continue 23            } 24            log.Printf("accept err: %v", e) 25            return 26        } 27        go handleConn(conn) 28        connections = append(connections, conn) 29        if len(connections)0 == 0 { 30            log.Printf("total number of connections: %v", len(connections)) 31        } 32    } 33} 34func handleConn(conn net.Conn) { 35    io.Copy(ioutil.Discard, conn) 36}

    编译go build -o server server.go,然后运行./server。

    客户端

    客户端建立好连接后,不断的轮询每个连接,发送一个简单的hello world\n的消息。

    1var ( 2    ip          = flag.String("ip", "127.0.0.1", "server IP") 3    connections = flag.Int("conn", 1, "number of tcp connections") 4) 5func main() { 6    flag.Parse() 7    addr := *ip + ":8972" 8    log.Printf("连接到 %s", addr) 9    var conns []net.Conn 10    for i := 0; i < *connections; i++ { 11        c, err := net.DialTimeout("tcp", addr, 10*time.Second) 12        if err != nil { 13            fmt.Println("failed to connect", i, err) 14            i-- 15            continue 16        } 17        conns = append(conns, c) 18        time.Sleep(time.Millisecond) 19    } 20    defer func() { 21        for _, c := range conns { 22            c.Close() 23        } 24    }() 25    log.Printf("完成初始化 %d 连接", len(conns)) 26    tts := time.Second 27    if *connections > 100 { 28        tts = time.Millisecond * 5 29    } 30    for { 31        for i := 0; i < len(conns); i++ { 32            time.Sleep(tts) 33            conn := conns[i] 34            conn.Write([]byte("hello world\r\n")) 35        } 36    } 37}

    因为从一个IP连接到同一个服务器的某个端口最多也只能建立65535个连接,所以直接运行客户端没办法建立百万的连接。 Eran Yanay采用docker的方法确实让人眼前一亮(我以前都是通过手工设置多个ip的方式实现,采用docker的方式更简单)。

    我们使用50个docker容器做客户端,每个建立2万个连接,总共建立一百万的连接。

    1./setup.sh 20000 50 172.17.0.1

    setup.sh内容如下,使用几M大小的alpinedocker镜像跑测试:

    1#!/bin/bash address, 缺省是 172.17.0.1 2CONNECTIONS=$1 3REPLICAS=$2 4IP=$3 5#go build --tags "static netgo" -o client client.go 6for (( c=0; c<${REPLICAS}; c++ )) 7do 8    docker run -v $(pwd)/client:/client --name 1mclient_$c -d alpine /client \ 9    -conn=${CONNECTIONS} -ip=${IP} 10done

    数据分析

    使用以下工具查看性能:

    -dstat:查看机器的资源占用(cpu, memory,中断数和上下文切换次数)

    -ss:查看网络连接情况

    -pprof:查看服务器的性能

    -report.sh: 后续通过脚本查看延迟 

    可以看到建立连接后大约占了19G的内存,CPU占用非常小,网络传输1.4MB左右的样子。

    二、 服务器epoll方式实现

    和Eran Yanay最初指出的一样,上述方案使用了上百万的goroutine,耗费了太多了内存资源和调度,改为epoll模式,大大降低了内存的使用。Eran Yanay的epoll实现只针对Linux的epoll而实现,比mailru的easygo实现和使用起来要简单,我们采用他的这种实现方式。

    Go的net方式在Linux也是通过epoll方式实现的,为什么我们还要再使用epoll方式进行封装呢?原因在于Go将epoll方式封装再内部,对外并没有直接提供epoll的方式来使用。好处是降低的开发的难度,保持了Go类似"同步"读写的便利型,但是对于需要大量的连接的情况,我们采用这种每个连接一个goroutine的方式占用资源太多了,所以这一节介绍的就是hack连接的文件描述符,采用epoll的方式自己管理读写。

    服务器

    服务器需要改造一下:

    1var epoller *epoll 2func main() { 3    setLimit() 4    ln, err := net.Listen("tcp", ":8972") 5    if err != nil { 6        panic(err) 7    } 8    go func() { 9        if err := http.ListenAndServe(":6060", nil); err != nil { 10            log.Fatalf("pprof failed: %v", err) 11        } 12    }() 13    epoller, err = MkEpoll() 14    if err != nil { 15        panic(err) 16    } 17    go start() 18    for { 19        conn, e := ln.Accept() 20        if e != nil { 21            if ne, ok := e.(net.Error); ok && ne.Temporary() { 22                log.Printf("accept temp err: %v", ne) 23                continue 24            } 25            log.Printf("accept err: %v", e) 26            return 27        } 28        if err := epoller.Add(conn); err != nil { 29            log.Printf("failed to add connection %v", err) 30            conn.Close() 31        } 32    } 33} 34func start() { 35    var buf = make([]byte, 8) 36    for { 37        connections, err := epoller.Wait() 38        if err != nil { 39            log.Printf("failed to epoll wait %v", err) 40            continue 41        } 42        for _, conn := range connections { 43            if conn == nil { 44                break 45            } 46            if _, err := conn.Read(buf); err != nil { 47                if err := epoller.Remove(conn); err != nil { 48                    log.Printf("failed to remove %v", err) 49                } 50                conn.Close() 51            } 52        } 53    } 54}

    listener还是保持原来的样子,Accept一个新的客户端请求后,就把它加入到epoll的管理中。单独起一个 gorouting监听数据到来的事件,每次只最多读取100个事件。

    epoll的实现如下:

    1type epoll struct { 2    fd          int 3    connections map[int]net.Conn 4    lock        *sync.RWMutex 5} 6func MkEpoll() (*epoll, error) { 7    fd, err := unix.EpollCreate1(0) 8    if err != nil { 9        return nil, err 10    } 11    return &epoll{ 12        fd:          fd, 13        lock:        &sync.RWMutex{}, 14        connections: make(map[int]net.Conn), 15    }, nil 16} 17func (e *epoll) Add(conn net.Conn) error { 18    // Extract file descriptor associated with the connection 19    fd := socketFD(conn) 20    err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_ADD, fd, &unix.EpollEvent{Events: unix.POLLIN | unix.POLLHUP, Fd: int32(fd)}) 21    if err != nil { 22        return err 23    } 24    e.lock.Lock() 25    defer e.lock.Unlock() 26    e.connections[fd] = conn 27    if len(e.connections)0 == 0 { 28        log.Printf("total number of connections: %v", len(e.connections)) 29    } 30    return nil 31} 32func (e *epoll) Remove(conn net.Conn) error { 33    fd := socketFD(conn) 34    err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_DEL, fd, nil) 35    if err != nil { 36        return err 37    } 38    e.lock.Lock() 39    defer e.lock.Unlock() 40    delete(e.connections, fd) 41    if len(e.connections)0 == 0 { 42        log.Printf("total number of connections: %v", len(e.connections)) 43    } 44    return nil 45} 46func (e *epoll) Wait() ([]net.Conn, error) { 47    events := make([]unix.EpollEvent, 100) 48    n, err := unix.EpollWait(e.fd, events, 100) 49    if err != nil { 50        return nil, err 51    } 52    e.lock.RLock() 53    defer e.lock.RUnlock() 54    var connections []net.Conn 55    for i := 0; i < n; i++ { 56        conn := e.connections[int(events[i].Fd)] 57        connections = append(connections, conn) 58    } 59    return connections, nil 60} 61func socketFD(conn net.Conn) int { 62    //tls := reflect.TypeOf(conn.UnderlyingConn()) == reflect.TypeOf(&tls.Conn{}) 63    // Extract the file descriptor associated with the connection 64    //connVal := reflect.Indirect(reflect.ValueOf(conn)).FieldByName("conn").Elem() 65    tcpConn := reflect.Indirect(reflect.ValueOf(conn)).FieldByName("conn") 66    //if tls { 67    //  tcpConn = reflect.Indirect(tcpConn.Elem()) 68    //} 69    fdVal := tcpConn.FieldByName("fd") 70    pfdVal := reflect.Indirect(fdVal).FieldByName("pfd") 71    return int(pfdVal.FieldByName("Sysfd").Int()) 72}

    客户端

    还是运行上面的客户端,因为刚才已经建立了50个客户端的容器,我们需要先把他们删除:

    1docker rm -vf  $(docker ps -a --format '{ {.ID} } { {.Names} }'|grep '1mclient_' |awk '{print $1}')

    然后再启动50个客户端,每个客户端2万个连接进行进行测试

    1./setup.sh 20000 50 172.17.0.1

    数据分析

    使用以下工具查看性能:

    -dstat:查看机器的资源占用(cpu, memory,中断数和上下文切换次数)

    -ss:查看网络连接情况

    -pprof:查看服务器的性能

    -report.sh: 后续通过脚本查看延迟 

    可以看到建立连接后大约占了10G的内存,CPU占用非常小。

    有一个专门使用epoll实现的网络库tidwall/evio,可以专门开发epoll方式的网络程序。去年阿里中间件大赛,美团的王亚普使用evio库杀入到排行榜第五名,也是前五中唯一一个使用Go实现的代码,其它使用Go标准库实现的代码并没有达到6983 tps/s 的程序,这也说明了再一些场景下采用epoll方式也能带来性能的提升。(天池中间件大赛Golang版Service Mesh思路分享)

    但是也正如evio作者所说,evio并不能提到Go标准net库,它只使用特定的场景, 实现redis/haproxy等proxy。因为它是单goroutine处理处理的,或者你可以实现多goroutine的event-loop,但是针对一些I/O或者计算耗时的场景,未必能展现出它的优势出来。

    我们知道Redis的实现是单线程的,正如作者Clarifications about Redis and Memcached介绍的,Redis主要是内存中的数据操作,单线程根本不是瓶颈(持久化是独立线程)我们后续的测试也会印证这一点。所以epoll I/O dispatcher之后是采用单线程还是Reactor模式(多线程事件处理)还是看具体的业务。

    下一篇文章我们会继续测试百万连接情况下的吞吐率和延迟,这是上面的两篇文章所没有提到的。

    参考

    https://mrotaru.wordpress.com/2013/10/10/scaling-to-12-million-concurrent-connections-how-migratorydata-did-it/

    https://stackoverflow.com/questions/22090229/how-did-whatsapp-achieve-2-million-connections-per-server

    https://github.com/eranyanay/1m-go-websockets

    https://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb


    版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

    Golang语言社区

    ID:Golangweb

     www.GolangWeb.com

    游戏服务器架构丨分布式技术丨大数据丨游戏算法学习

    Processed: 0.009, SQL: 8