使用 Golang 实现高 IO 性能超边缘缓存服务器

作者:李伟伟

  • 本文通过 Golang-零拷贝来解决现代超边缘缓存服务器开发中的两个关键问题:

    • 第一:如何使用 Golang 这一在各大互联网公司中越来越流行的、高效的工程语言来实现超边缘缓存服务器,以实现研发效率和软件性能的平衡。
    • 第二:如何在硬件资源条件较低的超边缘服务器上,通过低成本的优化手段,实现高性能 IO。
  • 阅读完本文后,你可以了解到对传统 IO 进行优化的4种方法,尤其会对零拷贝有一个更深、更详细的理解,理论上它可以让 CPU 拷贝数据的次数降低 50%、用户空间和内核空间切换的次数降低 50%、内存占用降低 66%。并且你能看到它在字节几千台超边缘缓存服务器上部署后,为点播、下载业务提供 Tb 级别带宽时的实际优化效果。

  • 同时,本文作者准备逐步开放 Golang 点直播零拷贝程序包。鼓励共建,把这块儿能力推出去,让其他业务能够快速复用。为超边缘计算中的 IO 密集型服务,提供开箱即用的优化手段。

1. 背景

超边缘服务器通常位于网络架构的边缘,远离中心机房,其网络带宽较低、CPU 和磁盘等硬件性能较低,这就需要通过软件层面的优化来提升服务器性能。

超边缘缓存服务器作为传输静态文件的网络服务器,其主要功能是通过网络将磁盘上的文件发送到客户端。在这一过程中,主要涉及磁盘 IO 和网络 IO。因此,优化 IO 性能是提升超边缘缓存服务器性能的关键。简言之,通过改进磁盘和网络 IO 的效率,能够显著提升服务器的响应速度和性能表现。

优化 IO 的常见方法有缓存、合并 IO 和多线程处理,但这些方法往往需要对业务架构进行较大幅度的调整,零拷贝(zero-copy)技术在业务上改造少(尤其是使用 Golang 的业务,只需要将readwrite调用换成io.CopyN即可,见后文详细分析),且能大幅度优化 IO 性能:减少 50% 的内核空间和用户空间的切换次数、减少 50% 的数据拷贝次数、减少 66% 的内存占用。

2. 传统IO流程

在传统 IO 流程中,从磁盘发送文件到网络的 IO 过程如下:

图1 传统 IO 流程

传统 IO 流程具体步骤:

Input 过程:

  • 应用程序发起read系统调用,操作系统接收到系统调用请求后,切换到内核空间
  • CPU 将read系统调用转化为 IO 请求发送给磁盘控制器
  • 磁盘控制器将数据从磁盘加载到磁盘控制器的缓冲区,数据拷贝完后,磁盘控制器给 CPU 发送 IO 中断信号
  • CPU 收到磁盘控制器的 IO 中断信号后,将数据从磁盘控制器缓冲区拷贝到内核缓冲区(page cache)
  • CPU 将数据从内核缓冲区再拷贝到用户缓冲区,read系统调用结束,切换回到用户空间

Output 过程:

  • 应用程序对数据处理后,发起write系统调用,操作系统接收到系统调用后,再次切换到内核空间
  • CPU 将数据从用户缓冲区拷贝到socket缓冲区
  • CPU 将数据从 socket 缓冲区拷贝到网卡缓冲区,系统调用返回,再次切回到用户空间
  • 网卡将数据发送出去

在传统 IO 流程中,主要存在 3 个问题:

  1. CPU 忙于拷贝数据,在高 QPS 场景下会导致 CPU 的负载过高
  2. 数据被多次拷贝,同一份数据占用多份内存
  3. 多次在用户空间和内核空之间切换上下文,CPU 处理耗时较多

优化 IO 流程主要是解决上面 3 个问题。

3. 对传统IO流程的优化

3.1 DMA

在传统 IO 流程中,数据读取、发送过程中,每次数据拷贝都需要 CPU 来完成,如果read/write请求不多,CPU还能在两次read/write之间去做其他事情,但是当read/write请求比较多时,CPU 会花费很多时间在数据拷贝上,如果有一个模块能够代替 CPU 做这些数据拷贝的工作,那么 CPU 就可以释放出来,做更多其他事情了, 这时,DMA 应运而生。

DMA(Direct Memory Access),又称直接内存访问技术,当 CPU 要从磁盘读取文件内容时,CPU 会告诉 DMA 读取哪些数据、读取到内存的哪个位置,DMA 就会从磁盘读取数据,并将数据写入到内存中对应位置,而此过程中 CPU就可以去做其他事情了,根本不需要参与到具体每个字节数据的搬运过程中了。另外,DMA 可以比 CPU 更快地进行数据传输,因为 CPU 架构决定了它专精于计算,而 DMA 是专门被设计用作传输数据的,它更擅长传输数据。

有 DMA 后,从磁盘发送文件到网络的 IO 流程如下:

图2 有 DMA 参与的 IO 流程

3.2 零拷贝

虽然 DMA 减少了 CPU 参与数据拷贝的次数,但是仍然需要 4 次数据拷贝、4 次用户空间和内核空间的切换,这两点既占用内存,又耗时。零拷贝技术能够减少数据拷贝次数、减少用户空间和内核空间相互切换的次数。

零拷贝技术实现的方式通常有 3 种:

  1. mmap + write
  2. sendfile
  3. sendfile + SG-DMA

3.2.1 mmap + write

read 系统调用的过程中会把内核缓冲区的数据拷贝到用户缓冲区里,为了减少这一步的开销,可以用mmap 替换 read 系统调用函数。

mmap 系统调用会直接把内核缓冲区里的数据映射到用户空间,这样,内核空间与用户空间就不需要再进行任何的数据拷贝操作。

图3 mmap + write

具体过程如下:

  1. 应用进程调用mmap 后,DMA 会把磁盘的数据拷贝到内核缓冲区里。接着,应用进程和内核共享这个缓冲区。
  2. 应用进程再调用 write,操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核空间,由 CPU 来搬运数据。
  3. 最后,通过 DMA 把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里。

因此,通过用 mmap来代替 read, 可以减少 1 次数据拷贝的过程。

但这还不是最理想的,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且系统调用还是 2 次,仍然需要 4 次上下文切换。

3.2.2 sendfile

Linux 提供了一个专门发送文件到网络的系统调用函数 sendfile,函数形式如下:

C 复制代码
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

首先,它可以替代前面的 readwrite 这两个系统调用,这样就可以减少 1 次系统调用,也就减少了 2 次内核空间和用户空间切换的开销。

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次内核空间和用户空间切换、3 次数据拷贝。

图4 sendfile

图4这个过程已经是一般语境下大家常说的"零拷贝":在内存层面没有在用户空间到内核空间的拷贝,但是由于仍然有内存拷贝,所以这还不是彻底的零拷贝。

3.2.3 sendfile + SG-DMA

如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access),可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

Bash 复制代码
# 查看网卡是否支持 scatter-gather 特性
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是,从 Linux 内核 2.4 版本开始起,对于网卡支持 SG-DMA 的情况( SG-DMA 需要硬件和操作系统同时支持), sendfile 系统调用的过程得到了进一步改善,具体过程如下:

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓冲区中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从内核缓冲区拷贝到 socket 缓冲区,又减少了 1 次数据拷贝。

图5 支持 SG-DMA 情况下的sendfile

图5是真正的零拷贝,不仅全程没有通过 CPU 来拷贝数据,而且没有在内存间相互拷贝数据

图5相对于图1,对比如下:

优势

  1. 内核空间和用户空间切换次数降低 50%,4 次 -> 2 次。
  2. 数据拷贝次数降低 50%,4 次 -> 2 次,而且 2 次数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运,降低了 CPU 负载。

劣势

  1. 在每次的响应过程中,无法在用户空间对从磁盘文件读取的数据进行处理。

以上优势比较明显,且劣势可以通过其他方式解决,比如:校验--新开协程专门进行异步的 CRC 校验,不必每次发送前进行文件数据的校验。

总结上面几种情况下的 IO 流程,其对比如下:

4. Golang-零拷贝实现

超边缘缓存服务器的 ARCH、OS 通常不是统一的,存在较大的异构性,在选择编程语言时需要兼顾使用的流行程度、高性能、易编程、支持跨平台,综合考虑后选择 Golang。

调用sendfile需要获取到发送文件的fdsocket fd,但是在使用Golang封装好的http.Server中获取底层的 socket fd 很麻烦,不过,在http.Handler中调用func CopyN(dst Writer, src Reader, n int64) (written int64, err error) ,能够默认调用系统调用sendfile或者splice。函数调用流程如下代码所示:

以下代码只展示与零拷贝调用流程相关的关键部分,其它用 ... 代表省略。以 Go1.21.8 版本为准。

go 复制代码
io.CopyN(http.ResponseWriter, *os.File, n)

func CopyN(dst Writer, src Reader, n int64) (written int64, err error) {
   written, err = Copy(dst, LimitReader(src, n))
   if written == n {
      return n, nil
   }
   ...
}

func Copy(dst Writer, src Reader) (written int64, err error) {
   return copyBuffer(dst, src, nil)
}

func CopyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
   if buf != nil && len(buf) == 0 {
      panic("empty buffer in CopyBuffer")
   }
   return copyBuffer(dst, src, buf)
}

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
   // If the reader has a WriteTo method, use it to do the copy.
 // Avoids an allocation and a copy.
if wt, ok := src.(WriterTo); ok {
      return wt.WriteTo(dst)
   }
   // Similarly, if the writer has a ReadFrom method, use it to do the copy.
   // 这里 http.ResponseWriter 的实现struct(type response struct), 它实现了 ReaderFrom 接口,且 http.ResponseWriter 的 ReadFrom 实现中调用了sendfile()系统调用:func (c *TCPConn) ReadFrom(r io.Reader) (int64, error) 
if rt, ok := dst.(ReaderFrom); ok {
      return rt.ReadFrom(src)
   }
   ...
}

// A response represents the server side of an HTTP response.
type response struct { // http.ResponseWriter 接口的实现结构
   conn             *conn
   ...
}

// src/net/http/server.go
// ReadFrom is here to optimize copying from an *os.File regular file
// to a *net.TCPConn with sendfile, or from a supported src type such
// as a *net.TCPConn on Linux with splice.
func (w *response) ReadFrom(src io.Reader) (n int64, err error) {
   ...
   // Our underlying w.conn.rwc is usually a *TCPConn (with its
 // own ReadFrom method). If not, just fall back to the normal
 // copy method.
   // 这一行很关键,将 http.ResponseWriter 通过 TCPConn.ReadFrom() 实现对系统函数 sendfile() 的调用
rf, ok := w.conn.rwc.(io.ReaderFrom)
   if !ok {
      return io.CopyBuffer(writerOnly{w}, src, buf)
   }
   ...
   return n, err
}

// A conn represents the server side of an HTTP connection.
type conn struct {
   ...
   // rwc is the underlying network connection.
 // This is never wrapped by other types and is the value given out
 // to CloseNotifier callers. It is usually of type *net.TCPConn or
 // *tls.Conn.
rwc net.Conn  // interface, 实际上是下面的TCPConn
   ...
}

// TCPConn is an implementation of the Conn interface for TCP network
// connections.
type TCPConn struct { // 实现了io.ReadFrom 方法
   conn   // net.Conn
}

// src/net/tcpsock.go
// ReadFrom implements the io.ReaderFrom ReadFrom method.
func (c *TCPConn) ReadFrom(r io.Reader) (int64, error) {
   ...
   n, err := c.readFrom(r)
   ...
   return n, err
}

// src/net/tcpsock_posix.go
func (c *TCPConn) readFrom(r io.Reader) (int64, error) {
   // 不会走splice,因为 r 既不是TCP connection,也不是a stream-oriented Unix connection. 
   // r 只是普通文件
   if n, err, handled := splice(c.fd, r); handled {
      return n, err
   }
   // sendFile()是对操作系统函数sendfile()的包装
   if n, err, handled := sendFile(c.fd, r); handled {
      return n, err
   }
   return genericReadFrom(c, r)
}

// src/net/splice_linux.go
// splice transfers data from r to c using the splice system call to minimize
// copies from and to userspace. c must be a TCP connection. Currently, splice
// is only enabled if r is a TCP or a stream-oriented Unix connection.
//
// If splice returns handled == false, it has performed no work.
func splice(c *netFD, r io.Reader) (written int64, err error, handled bool) {
   ...
   var s *netFD
   if tc, ok := r.(*TCPConn); ok {
      s = tc.fd
   } else if uc, ok := r.(*UnixConn); ok {
      if uc.fd.net != "unix" {
         return 0, nil, false
      }
      s = uc.fd
   } else {
      return 0, nil, false
   }

   written, handled, sc, err := poll.Splice(&c.pfd, &s.pfd, remain)
   if lr != nil {
      lr.N -= written
   }
   return written, wrapSyscallError(sc, err), handled
}

// src/net/sendfile_linux.go
// sendFile copies the contents of r to c using the sendfile
// system call to minimize copies.
//
// if handled == true, sendFile returns the number (potentially zero) of bytes
// copied and any non-EOF error.
//
// if handled == false, sendFile performed no work.
func sendFile(c *netFD, r io.Reader) (written int64, err error, handled bool) {
   var remain int64 = 1<<63 - 1 // by default, copy until EOF

lr, ok := r.(*io.LimitedReader)
   if ok {
      remain, r = lr.N, lr.R
      if remain <= 0 {
         return 0, nil, true
      }
   }
   f, ok := r.(*os.File)
   if !ok {
      return 0, nil, false
   }

   sc, err := f.SyscallConn()
   if err != nil {
      return 0, nil, false
   }

   var werr error
   err = sc.Read(func(fd uintptr) bool {
      written, werr, handled = poll.SendFile(&c.pfd, int(fd), remain)
      return true
   })
   ...
   return written, wrapSyscallError("sendfile", err), handled
}

// src/internal/poll/sendfile_linux.go
// SendFile wraps the sendfile system call.
func SendFile(dstFD *FD, src int, remain int64) (int64, error, bool) {
   ...
   dst := dstFD.Sysfd
   var (
      written int64
      err     error
      handled = true
   )
   for remain > 0 {
      n := maxSendfileSize
      if int64(n) > remain {
         n = int(remain)
      }
      // 最终调用系统的sendfile函数
      n, err1 := syscall.Sendfile(dst, src, nil, n)
      ...
   }
   return written, err, handled
}

5. 业务实践

零拷贝功能在字节几千台超边缘缓存服务器上线后,为点播、下载业务提供了 Tb 级别带宽的传输服务,内存、CPU、服务端发送速度均有很大的优化。

图6 已用内存的优化效果

图7 CPU 使用率的优化效果

图8 服务发送速度的优化效果

6. 未来展望

6.1 技术沉淀

未来会整理 Golang 零拷贝技术相关的函数使用,支持磁盘文件fdsocket-fd,也支持socket-fdsocket-fd两种情况下的零拷贝,减少业务在使用上的负担,比如使用 Golang 原生http.Server作为网络服务器时,难以获取到底层请求的socket-fd,以及ResponseWriter中实际的socket-fd,这个库将帮助大家解决这些繁琐而重要的问题。

6.2 KTLS

目前 Golang io.CopyN无法对数据进行 TLS 加密,如果要使用 TLS (比如 HTTPS),必须将数据拷贝到应用层进行TLS 加密,然后再发送出去。不过,Linux 已经支持 kernel TLS,不需要将数据拷贝到应用层也可进行TLS加解密,由内核完成加解密后直接交给TCP stack发送出去。Golang crypto/tls 目前不支持 kernel TLS,可以在同机上部署NGINX,NGINX 已经支持ssl_sendfile调用 kernel TLS,可以实现 HTTPS 的零拷贝转发。

Golang 团队大约每一两周会讨论社区提出的建议,并给出是否接纳建议的结论,其中 Golang 支持 KTLS 的建议已经被提出好几年了,一直没有什么实际进展,不过2024年05月又开始被 Golang 团队关注到,被加入到了每周的讨论列表中,敬请期待!

7. 附录

Golang-KTLS 提议:github.com/golang/go/i...

Golang 每周讨论提议的会议记录:github.com/golang/go/i...

Linux kernel TLS offload:docs.kernel.org/networking/...

相关推荐
桃园码工3 小时前
1-Gin介绍与环境搭建 --[Gin 框架入门精讲与实战案例]
go·gin·环境搭建
云中谷3 小时前
Golang 神器!go-decorator 一行注释搞定装饰器,v0.22版本发布
go·敏捷开发
彭亚川Allen5 小时前
优化了2年的性能,没想到最后被数据库连接池坑了一把
数据库·后端·性能优化
MClink6 小时前
Go怎么做性能优化工具篇之pprof
开发语言·性能优化·golang
京东零售技术8 小时前
Taro小程序开发性能优化实践
性能优化·taro
理想不理想v9 小时前
wepack如何进行性能优化
性能优化
苏三有春11 小时前
五分钟学会如何在GitHub上自动化部署个人博客(hugo框架 + stack主题)
git·go·github
fantasy_arch1 天前
CPU性能优化-磁盘空间和解析时间
网络·性能优化
我是前端小学生1 天前
Go语言中的方法和函数
go
码农老起1 天前
企业如何通过TDSQL实现高效数据库迁移与性能优化
数据库·性能优化