Go 1.3垃圾回收器没有将服务器内存释放回系统

18

我们编写了最简单的TCP服务器(带有少量日志记录),以检查内存占用情况(请参见下面的tcp-server.go)。

该服务器仅接受连接并不执行任何操作。它在运行Ubuntu 12.04.4 LTS服务器(内核3.2.0-61-generic)上,并使用Go版本go1.3 linux/amd64。

附加的基准测试程序(pulse.go)在此示例中创建10k个连接,30秒后断开它们,重复此循环三次,然后连续重复1k个连接/断开的小脉冲。用于测试的命令是./pulse -big=10000 -bs=30。

第一个附加图是通过记录每当客户端数量变化时的runtime.ReadMemStats而获得的,第二个图是“top”所看到的服务器进程的RES内存大小。

服务器从可忽略的1.6KB内存开始。然后,内存由10k个连接的“大”脉冲设置为约60MB(如top所示),或者为约16MB的ReadMemStats的“SystemMemory”。正如预期的那样,当10K个脉冲结束时,正在使用的内存会降低,最终程序会释放内存回到操作系统,这可以通过灰色的“Released Memory”线看出。

问题在于系统内存(相应地,“top”所看到的RES内存)从未显着下降(尽管在第二个图中有所下降)。

我们希望确保针对偶尔出现的峰值的不规则流量的可伸缩性,以及能够在同一台计算机上运行具有不同时间峰值的多个服务器。是否有一种方法可以在合理的时间范围内有效地确保释放尽可能多的内存回到系统中?

第一个图

第二个图

代码https://gist.github.com/eugene-bulkin/e8d690b4db144f468bc5 :

server.go:

package main

import (
  "net"
  "log"
  "runtime"
  "sync"
)
var m sync.Mutex
var num_clients = 0
var cycle = 0

func printMem() {
  var ms runtime.MemStats
  runtime.ReadMemStats(&ms)
  log.Printf("Cycle #%3d: %5d clients | System: %8d Inuse: %8d Released: %8d Objects: %6d\n", cycle, num_clients, ms.HeapSys, ms.HeapInuse, ms.HeapReleased, ms.HeapObjects)
}

func handleConnection(conn net.Conn) {
  //log.Println("Accepted connection:", conn.RemoteAddr())
  m.Lock()
  num_clients++
  if num_clients % 500 == 0 {
    printMem()
  }
  m.Unlock()
  buffer := make([]byte, 256)
  for {
    _, err := conn.Read(buffer)
    if err != nil {
      //log.Println("Lost connection:", conn.RemoteAddr())
      err := conn.Close()
      if err != nil {
        log.Println("Connection close error:", err)
      }
      m.Lock()
      num_clients--
      if num_clients % 500 == 0 {
        printMem()
      }
      if num_clients == 0 {
        cycle++
      }
      m.Unlock()
      break
    }
  }
}

func main() {
  printMem()
  cycle++
  listener, err := net.Listen("tcp", ":3033")
  if err != nil {
    log.Fatal("Could not listen.")
  }
  for {
    conn, err := listener.Accept()
    if err != nil {
      log.Println("Could not listen to client:", err)
      continue
    }
    go handleConnection(conn)
  }
}

pulse.go:

package main

import (
  "flag"
  "net"
  "sync"
  "log"
  "time"
)

var (
  numBig = flag.Int("big", 4000, "Number of connections in big pulse")
  bigIters = flag.Int("i", 3, "Number of iterations of big pulse")
  bigSep = flag.Int("bs", 5, "Number of seconds between big pulses")
  numSmall = flag.Int("small", 1000, "Number of connections in small pulse")
  smallSep = flag.Int("ss", 20, "Number of seconds between small pulses")
  linger = flag.Int("l", 4, "How long connections should linger before being disconnected")
)

var m sync.Mutex

var active_conns = 0
var connections = make(map[net.Conn] bool)

func pulse(n int, linger int) {
  var wg sync.WaitGroup

  log.Printf("Connecting %d client(s)...\n", n)
  for i := 0; i < n; i++ {
    wg.Add(1)
    go func() {
      m.Lock()
      defer m.Unlock()
      defer wg.Done()
      active_conns++
      conn, err := net.Dial("tcp", ":3033")
      if err != nil {
        log.Panicln("Unable to connect: ", err)
        return
      }
      connections[conn] = true
    }()
  }
  wg.Wait()
  if len(connections) != n {
    log.Fatalf("Unable to connect all %d client(s).\n", n)
  }
  log.Printf("Connected %d client(s).\n", n)
  time.Sleep(time.Duration(linger) * time.Second)
  for conn := range connections {
    active_conns--
    err := conn.Close()
    if err != nil {
      log.Panicln("Unable to close connection:", err)
      conn = nil
      continue
    }
    delete(connections, conn)
    conn = nil
  }
  if len(connections) > 0 {
    log.Fatalf("Unable to disconnect all %d client(s) [%d remain].\n", n, len(connections))
  }
  log.Printf("Disconnected %d client(s).\n", n)
}

func main() {
  flag.Parse()
  for i := 0; i < *bigIters; i++ {
    pulse(*numBig, *linger)
    time.Sleep(time.Duration(*bigSep) * time.Second)
  }
  for {
    pulse(*numSmall, *linger)
    time.Sleep(time.Duration(*smallSep) * time.Second)
  }
}

3
也在这里提出了类似问题(并得到了回答):(https://groups.google.com/d/topic/golang-nuts/0WSOKnHGBZE/discussion)。(Alec,作为一个好的网民,当他们进行跨帖子发布时,提供链接是必须的。) - kostix
3个回答

19
首先,需要注意的是 Go 本身并不总是会收缩其内存空间:
参考https://groups.google.com/forum/#!topic/Golang-Nuts/vfmd6zaRQVs 中的说法,堆被释放了,您可以使用 runtime.ReadMemStats() 进行检查,但进程的虚拟地址空间并不会缩小——也就是说,您的程序将不会将内存返回给操作系统。在基于 Unix 的平台上,我们使用系统调用告诉操作系统它可以回收未使用的堆的部分,而这个功能在 Windows 平台上不可用。
但你不是在Windows上吗?
好吧,这个线程没有明确的说法,但它说:
请参考https://groups.google.com/forum/#!topic/golang-nuts/MC2hWpuT7Xc 中的说法,据我所知,内存在被 GC 标记为释放后大约5分钟后会被返回到操作系统。如果没有增加内存使用量,GC 每两分钟运行一次。所以最坏情况下需要7分钟才能被释放。
在这种情况下,我认为该切片没有被标记为已释放,而是正在使用中,因此永远不会被返回到操作系统。
您可能没有等待足够长的时间以进行 GC 扫描并进行操作系统返回扫描,这可能需要在最后一个“大”脉冲后长达7分钟。您可以使用 runtime.FreeOSMemory 显式强制执行此操作,但请记住,除非运行了 GC,否则它将不起作用。
(编辑:请注意,您可以使用 runtime.GC() 强制执行垃圾回收,但显然需要小心使用它;您可以将其与连接的突然下降同步)。作为轻微的旁注,我找不到明确的来源(除了第二个我发布的帖子中有人提到同样的事情),但我记得多次提到Go使用的并非全部是“真实”的内存。如果由运行时分配但实际未被程序使用,则无论 topMemStats 显示什么,操作系统实际上都可以使用该内存,因此程序实际使用的内存量通常会大大超报告。


编辑:正如Kostix在评论中指出并支持JimB的答案,这个问题被同时发布在Golang-nuts上,并且我们从Dmitri Vyukov那里得到了一个相当明确的答案:

https://groups.google.com/forum/#!topic/golang-nuts/0WSOKnHGBZE/discussion

我认为今天还没有解决方案。 大部分内存似乎都被 goroutine 栈占用,而我们不会将该内存释放给操作系统。 在下一个版本中,它将有所改善。

因此,我概述的只适用于堆变量,Goroutine 栈上的内存永远不会被释放。关于它如何与我最后一个“未显示分配的系统内存并非‘真实内存’”点交互仍有待观察。


我们实际上等了30多分钟:通过查看第一个图中灰线的位置,您可以看到内存已经释放了两次(大约在7分钟和9分钟)。runtime.ReadMemStats 显示的释放内存量与第二个图中 top 显示的 RES 下降完全对应。正如 JimB 在下面回答的,并且在我的交叉发布 [https://groups.google.com/forum/#!topic/golang-nuts/0WSOKnHGBZE/discussion] 中也回答了,问题是分配的 goroutine 不会将内存返回给操作系统,不幸的是... - Alec Matusis
问题的一部分在于,由于我不知道“短脉冲”之间的间隔时间,因此图表对程序运行时间的模糊性。无论如何,在你发表评论之前,我已经快速编辑了我的帖子,引用了Dmitri的答案。 - Linear
1
我知道这是一个旧的线程,但我相信runtime.FreeOSMemory()会强制进行垃圾回收,因此在调用runtime.FreeOSMemory()之前不需要调用runtime.GC()。编辑:没有注意到下面的答案实际上已经提到了这一点。 - Geoherna
现在go1.16已经改变了!请查看此链接:https://github.com/golang/go/commit/05e6d28849293266028c0bc9e9b0f8d0da38a2e2 - Lewis Chan

6
正如LinearZoetrope所说,您应该等待至少7分钟来检查有多少内存被释放了。有时需要进行两次垃圾回收,因此需要9分钟。如果这不起作用,或者时间太长,您可以定期调用FreeOSMemory(无需在之前调用runtime.GC(),因为debug.FreeOSMemory()会完成它)。像这样:http://play.golang.org/p/mP7_sMpX4F
package main

import (
    "runtime/debug"
    "time"
)

func main() {
    go periodicFree(1 * time.Minute)

    // Your program goes here

}

func periodicFree(d time.Duration) {
    tick := time.Tick(d)
    for _ = range tick {
        debug.FreeOSMemory()
    }
}

请注意,每次调用FreeOSMemory都需要一些时间(不多),如果GOMAXPROCS>1,则自Go1.3以来可以部分并行运行。


4
很遗憾,Goroutine堆栈目前无法释放。由于您需要同时连接10,000个客户端,因此需要10,000个Goroutine来处理它们。每个Goroutine都有8k的堆栈,在仅故障第一页的情况下,仍然需要至少40M的永久内存来处理最大连接数。目前还有一些待定修改可能会在go1.4中提供帮助(例如4k堆栈),但这是我们目前必须面对的事实。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接