miller
发布于

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

原文地址 colobu.com

前几天 Eran Yanay 在 Gophercon Israel 分享了一个讲座:Going Infinite, handling 1M websockets connections in Go, 介绍了使用 Go 实现支持百万连接的 websocket 服务器,引起了很大的反响。

前几天 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进行修改:

echo 2000500 > /proc/sys/fs/nr_open

ulimit -n 2000500

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

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

* soft nofile 2000500 

* hard nofile 2000500

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

# wc -l /proc/net/nf_conntrack

1024000

查看最大值

# cat /proc/sys/net/nf_conntrack_max

1024000

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

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

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

sysctl -w fs.file-max=2000500

sysctl -w fs.nr_open=2000500

sysctl -w net.nf_conntrack_max=2000500

ulimit -n 2000500

sysctl -w net.ipv4.tcp_mem='131072  262144  524288'

sysctl -w net.ipv4.tcp_rmem='8760  256960  4088000'

sysctl -w net.ipv4.tcp_wmem='8760  256960  4088000'

sysctl -w net.core.rmem_max=16384

sysctl -w net.core.wmem_max=16384

sysctl -w net.core.somaxconn=2048

sysctl -w net.ipv4.tcp_max_syn_backlog=2048

sysctl -w /proc/sys/net/core/netdev_max_backlog=2048

sysctl -w net.ipv4.tcp_tw_recycle=1

sysctl -w net.ipv4.tcp_tw_reuse=1

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

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

服务器

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

server.go

 package main

import (
    "io"
    "io/ioutil"
    "net"
    "net/http"
)

func main() {
    ln, err := net.Listen("tcp", ":8972")
    if err != nil {
        panic(err)
    }

    go func() {
        if err := http.ListenAndServe(":6060", nil); err != nil {
            log.Fatalf("pprof failed: %v", err)
        }
    }()
    var connections []net.Conn
    defer func() {
        for _, conn := range connections {
            conn.Close()
        }
    }()
    for {
        conn, e := ln.Accept()
        if e != nil {
            if ne, ok := e.(net.Error); ok && ne.Temporary() {
                log.Printf("accept temp err: %v", ne)
                continue
            }
            log.Printf("accept err: %v", e)
            return
        }
        go handleConn(conn)
        connections = append(connections, conn)
        if len(connections)%100 == 0 {
            log.Printf("total number of connections: %v", len(connections))
        }
    }
}

func handleConn(conn net.Conn) {
    io.Copy(ioutil.Discard, conn)
}

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

客户端

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

client.go


var (
    ip = flag.String("ip", "127.0.0.1", "server IP")
    connections = flag.Int("conn", 1, "number of tcp connections")
)

func main() {
    flag.Parse()
    addr := *ip + ":8972"
    log.Printf("连接到 %s", addr)
    var conns []net.Conn
    for i := 0; i < *connections; i++ {
        c, err := net.DialTimeout("tcp", addr, 10*time.Second)
        if err != nil {
            fmt.Println("failed to connect", i, err)
            i--
            continue
        }
        conns = append(conns, c)
        time.Sleep(time.Millisecond)
    }
    defer func() {
        for _, c := range conns {
            c.Close()
        }
    }()
    log.Printf("完成初始化 %d 连接", len(conns))
    tts := time.Second
    if *connections > 100 {
        tts = time.Millisecond * 5
    }
    for {
        for i := 0; i < len(conns); i++ {
            time.Sleep(tts)
            conn := conns[i]
            conn.Write([]byte("hello world\r\n"))
        }
    }
}

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

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

./setup.sh 20000 50 172.17.0.1

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

setup.sh

CONNECTIONS=$1

REPLICAS=$2

IP=$3

for (( c=0; c<${REPLICAS}; c++ ))
  do
      docker run -v $(pwd)/client:/client --name 1mclient_$c -d alpine /client \
      -conn=${CONNECTIONS} -ip=${IP}
  done

数据分析

使用以下工具查看性能:

  • 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 的方式自己管理读写。

服务器

服务器需要改造一下:

server.go


var epoller *epoll

func main() {
    setLimit()
    ln, err := net.Listen("tcp", ":8972")
    if err != nil {
        panic(err)
    }
    go func() {
        if err := http.ListenAndServe(":6060", nil); err != nil {
            log.Fatalf("pprof failed: %v", err)
        }
    }()
    epoller, err = MkEpoll()
    if err != nil {
        panic(err)
    }
    go start()
    for {
        conn, e := ln.Accept()
        if e != nil {
            if ne, ok := e.(net.Error); ok && ne.Temporary() {
                log.Printf("accept temp err: %v", ne)
                continue
            }
            log.Printf("accept err: %v", e)
            return
        }
        if err := epoller.Add(conn); err != nil {
            log.Printf("failed to add connection %v", err)
            conn.Close()
        }
    }
}

func start() {
    var buf = make([]byte, 8)
    for {
        connections, err := epoller.Wait()
        if err != nil {
            log.Printf("failed to epoll wait %v", err)
            continue
        }
        for _, conn := range connections {
            if conn == nil {
                break
            }
            if _, err := conn.Read(buf); err != nil {
                if err := epoller.Remove(conn); err != nil {
                    log.Printf("failed to remove %v", err)
                }
                conn.Close()
            }
        }
    }
}

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

epoll 的实现如下:


type epoll struct {
    fd int
    connections map[int]net.Conn
    lock *sync.RWMutex
}

func MkEpoll() (*epoll, error) {
    fd, err := unix.EpollCreate1(0)
    if err != nil {
        return nil, err
    }
    return &epoll{
        fd: fd,
        lock: &sync.RWMutex{},
        connections: make(map[int]net.Conn),
    }, nil
}

func (e *epoll) Add(conn net.Conn) error {
    fd := socketFD(conn)
    err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_ADD, fd, &unix.EpollEvent{Events: unix.POLLIN | unix.POLLHUP, Fd: int32(fd)})
    if err != nil {
        return err
    }
    e.lock.Lock()
    defer e.lock.Unlock()
    e.connections[fd] = conn
    if len(e.connections)%100 == 0 {
        log.Printf("total number of connections: %v", len(e.connections))
    }
    return nil
}

func (e *epoll) Remove(conn net.Conn) error {
    fd := socketFD(conn)
    err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_DEL, fd, nil)
    if err != nil {
        return err
    }
    e.lock.Lock()
    defer e.lock.Unlock()
    delete(e.connections, fd)
    if len(e.connections)%100 == 0 {
        log.Printf("total number of connections: %v", len(e.connections))
    }
    return nil
}

func (e *epoll) Wait() ([]net.Conn, error) {
    events := make([]unix.EpollEvent, 100)
    n, err := unix.EpollWait(e.fd, events, 100)
    if err != nil {
        return nil, err
    }
    e.lock.RLock()
    defer e.lock.RUnlock()
    var connections []net.Conn
    for i := 0; i < n; i++ {
        conn := e.connections[int(events[i].Fd)]
        connections = append(connections, conn)
    }
    return connections, nil
}
func socketFD(conn net.Conn) int {
    tcpConn := reflect.Indirect(reflect.ValueOf(conn)).FieldByName("conn")
    fdVal := tcpConn.FieldByName("fd")
    pfdVal := reflect.Indirect(fdVal).FieldByName("pfd")
    return int(pfdVal.FieldByName("Sysfd").Int())
}

客户端

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

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

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

./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 模式 (多线程事件处理) 还是看具体的业务。

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

参考

  1. https://mrotaru.wordpress.com/2013/10/10/scaling-to-12-million-concurrent-connections-how-migratorydata-did-it/
  2. https://stackoverflow.com/questions/22090229/how-did-whatsapp-achieve-2-million-connections-per-server
  3. https://github.com/eranyanay/1m-go-websockets
  4. https://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb

[Newer

[译]Go 开发中一些有用的模式

](https://colobu.com/2019/02/25/some-useful-patterns-in-go/)[Older

在 Linux 中查询 CPU 的核数

](https://colobu.com/2019/02/22/how-to-find-cpu-cores-in-linux/)

浏览 (362)
点赞
收藏
评论