极客时间已完结课程限时免费阅读

38|成果优化:怎么实现一个TCP服务器?(下)

38|成果优化:怎么实现一个TCP服务器?(下)-极客时间

38|成果优化:怎么实现一个TCP服务器?(下)

讲述:Tony Bai

时长23:57大小21.87M

你好,我是 Tony Bai。
在上一讲中,我们初步实现了一个基于 TCP 的自定义应用层协议的通信服务端。对于一个常驻内存的服务端而言,更高的性能以及更低的资源消耗,始终是后端开发人员的追求。同时,更高性能的服务程序,也意味着在处理相同数量访问请求的前提下,我们使用的机器数量更少,这可是为公司节省真金白银的有效策略。
而且,Go 语言最初设计时就被定位为“系统级编程语言”,这说明高性能也一直是 Go 核心团队的目标之一。很多来自动态类型语言的开发者转到 Go 语言,几乎都有着性能方面的考量。
所以,在实战篇的最后一讲,我们就结合上一讲实现的自定义应用层协议的通信服务端,看看优化 Go 程序使用的常用工具与套路,给你引引路。

Go 程序优化的基本套路

Go 程序的优化,也有着固定的套路可循,这里我将它整理成了这张示意图:
这张图也不难理解,我简单解释一下。
首先我们要建立性能基准。要想对程序实施优化,我们首先要有一个初始“参照物”,这样我们才能在执行优化措施后,检验优化措施是否有效,所以这是优化循环的第一步。
第二步是性能剖析。要想优化程序,我们首先要找到可能影响程序性能的“瓶颈点”,这一步的任务,就是通过各种工具和方法找到这些“瓶颈点”。
第三步是代码优化。我们要针对上一步找到的“瓶颈点”进行分析,找出它们成为瓶颈的原因,并有针对性地实施优化。
第四步是与基准比较,确定优化效果。这一步,我们会采集优化后的程序的性能数据,与第一步的性能基准进行比较,看执行上述的优化措施后,是否提升了程序的性能。
如果有提升,那就说明这一轮的优化是有效的。如果优化后的性能指标仍然没有达到预期,可以再执行一轮优化,这时我们就要用新的程序的性能指标作为新的性能基准,作为下一轮性能优化参考。
接下来我们就围绕这个优化循环,看看怎么对我们上一讲实现的自定义应用层协议的通信服务端进行优化。首先我们要做的是建立性能基准,这是 Go 应用性能优化的基础与前提。

建立性能基准

上一讲,我们已经初步实现了自定义应用层协议的通信服务端,那它的性能如何呢?
我们肯定不能拍脑门说这个程序性能很好、一般或很差吧?我们需要用数据说话,也就是为我们的 Go 程序建立性能基准。通过这个性能基准,我们不仅可以了解当前程序的性能水平,也可以据此判断后面的代码优化措施有没有起到效果。
建立性能基准的方式大概有两种,一种是通过编写 Go 原生提供的性能基准测试(benchmark test)用例来实现,这相当于对程序的局部热点建立性能基准,常用于一些算法或数据结构的实现,比如分布式全局唯一 ID 生成算法、树的插入 / 查找等。
另外一种是基于度量指标为程序建立起图形化的性能基准,这种方式适合针对程序的整体建立性能基准。而我们的自定义协议服务端程序就十分适合用这种方式,接下来我们就来看一下基于度量指标建立基准的一种可行方案。

建立观测设施

这些年,基于 Web 的可视化工具、开源监控系统以及时序数据库的兴起,给我们建立性能基准带来了很大的便利,业界有比较多成熟的工具组合可以直接使用。但业界最常用的还是 Prometheus+Grafana 的组合,这也是我日常使用比较多的组合,所以在这里我也使用这个工具组合来为我们的程序建立性能指标观测设施。
以 Docker 为代表的轻量级容器(container)的兴起,让这些工具的部署、安装都变得十分简单,这里我们就使用 docker-compose 工具,基于容器安装 Prometheus+Grafana 的组合。
我建议你使用一台 Linux 主机来安装这些工具,因为 docker 以及 docker-compose 工具,在 Linux 平台上的表现最为成熟稳定。我这里不再详细说明 docker 与 docker-compose 工具的安装方法了,你可以参考docker 安装教程以及docker-compose 安装教程自行在 Linux 上安装这两个工具。
这里我简单描述一下安装 Prometheus+Grafana 的组合的步骤。
首先,我们要在 Linux 主机上建立一个目录 monitor,这个目录下,我们创建 docker-compose.yml 文件,它的内容是这样的:
version: "3.2"
services:
prometheus:
container_name: prometheus
image: prom/prometheus:latest
network_mode: "host"
volumes:
- ./conf/tcp-server-prometheus.yml:/etc/prometheus/prometheus.yml
- /etc/localtime:/etc/localtime
restart: on-failure
grafana:
container_name: grafana
image: grafana/grafana:latest
network_mode: "host"
restart: on-failure
volumes:
- /etc/localtime:/etc/localtime
- ./data/grafana:/var/lib/grafana
# linux node_exporter
node_exporter:
image: quay.io/prometheus/node-exporter:latest
restart: always
container_name: node_exporter
command:
- '--path.rootfs=/host'
network_mode: host
pid: host
volumes:
- '/:/host:ro,rslave'
docker-compose.yml 是 docker-compose 工具的配置文件,基于这个配置文件,docker-compose 工具会拉取对应容器镜像文件,并在本地启动对应的容器。
我们这个 docker-compose.yml 文件中包含了三个工具镜像,分别是 Prometheus、Grafana 与 node-exporter。其中,node-exporter 是 prometheus 开源的主机度量数据的采集工具,通过 node exporter,我们可以采集到主机的 CPU、内存、磁盘、网络 I/O 等主机运行状态数据。结合这些数据,我们可以查看我们的应用在运行时的系统资源占用情况。
docker-compose.yml 中 Prometheus 容器挂载的 tcp-server-prometheus.yml 文件放在了 monitor/conf 下面,它的内容是这样:
global:
scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
# scrape_timeout is set to the global default (10s).
# Alertmanager configuration
alerting:
alertmanagers:
- static_configs:
- targets:
# - alertmanager:9093
# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
# - "first_rules.yml"
# - "second_rules.yml"
# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
- job_name: "prometheus"
# metrics_path defaults to '/metrics'
# scheme defaults to 'http'.
static_configs:
- targets: ["localhost:9090"]
- job_name: "tcp-server"
static_configs:
- targets: ["localhost:8889"]
- job_name: "node"
static_configs:
- targets: ["localhost:9100"]
我们看到,在上面 Prometheus 的配置文件的 scrpae_configs 下面,配置了三个采集 job,分别用于采集 Prometheus 自身度量数据、我们的 tcp server 的度量数据,以及 node-exporter 的度量数据。
grafana 容器会挂载本地的 data/grafana 路径到容器中,为了避免访问权限带来的问题,我们在创建 data/grafana 目录后,最好再为这个目录赋予足够的访问权限,比如:
$chmod -R 777 data
运行下面命令,docker-compose 就会自动拉取镜像,并启动 docker-compose.yml 中的三个容器:
$docker-compose -f docker-compose.yml up -d
等待一段时间后,执行 docker ps 命令,如果你能看到下面三个正在运行的容器,就说明我们的安装就成功了:
$docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
563d655cdf90 grafana/grafana:latest "/run.sh" 26 hours ago Up 26 hours grafana
65616d1b6d1a prom/prometheus:latest "/bin/prometheus --c…" 26 hours ago Up 26 hours prometheus
b29d3fef8572 quay.io/prometheus/node-exporter:latest "/bin/node_exporter …" 26 hours ago Up 26 hours node_exporter
为了更直观地了解到整个观测设施中各个工具之间的关系,我这里画了一幅示意图,对照着这幅图,你再来理解上面的配置与执行步骤会容易许多:

配置 Grafana

一旦成功启动,Prometheus 便会启动各个采集 job,从 tcp server 以及 node-exporter 中拉取度量数据,并存储在其时序数据库中,这个时候我们需要对 Grafana 进行一些简单配置,才能让这些数据以图形化的方式展现出来。
我们首先需要为 Grafana 配置一个新的数据源(data source),在数据源选择页面,我们选择 Prometheus,就像下图这样:
选择后,在 Prometheus 数据源配置页面,配置这个数据源的 HTTP URL 就可以了。如果你点击“Save & test”按钮后提示成功,那么数据源就配置好了。
接下来,我们再添加一个 node-exporter 仪表板(dashboard),把从 node-exporter 拉取的度量数据以图形化方式展示出来。这个时候我们不需要手工一个一个设置仪表板上的 panel,Grafana 官方有现成的 node-exporter 仪表板可用,我们只需要在 grafana 的 import 页面中输入相应的 dashboard ID,就可以导入相关仪表板的设置:
这里,我们使用的是 ID 为 1860 的 node-exporter 仪表板,导入成功后,进入这个仪表板页面,等待一段时间后,我们就可以看到类似下面的可视化结果:
好了,到这里 node-exporter 的度量数据,已经可以以图形化的形式呈现在我们面前了,那么我们的自定义协议的服务端的数据又如何采集与呈现呢?我们继续向下看。

在服务端埋入度量数据采集点

前面说了,我们要建立服务端的性能基准,那么哪些度量数据能反映出服务端的性能指标呢?这里我们定义三个度量数据项:
当前已连接的客户端数量(client_connected);
每秒接收消息请求的数量(req_recv_rate);
每秒发送消息响应的数量(rsp_send_rate)。
那么如何在服务端的代码中埋入这三个度量数据项呢?
我们将上一讲的 tcp-server-demo1 项目拷贝一份,形成 tcp-server-demo2 项目,我们要在 tcp-server-demo2 项目中实现这三个度量数据项的采集。
我们在 tcp-server-demo2 下,创建新的 metrics 包负责定义度量数据项,metrics 包的源码如下:
// tcp-server-demo2/metrics/metrics.go
package metrics
import (
"fmt"
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
ClientConnected prometheus.Gauge
ReqRecvTotal prometheus.Counter
RspSendTotal prometheus.Counter
)
func init() {
ReqRecvTotal = prometheus.NewCounter(prometheus.CounterOpts{
Name: "tcp_server_demo2_req_recv_total",
})
RspSendTotal = prometheus.NewCounter(prometheus.CounterOpts{
Name: "tcp_server_demo2_rsp_send_total",
})
ClientConnected = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "tcp_server_demo2_client_connected",
})
prometheus.MustRegister(ReqRecvTotal, RspSendTotal, ClientConnected)
// start the metrics server
metricsServer := &http.Server{
Addr: fmt.Sprintf(":%d", metricsHTTPPort),
}
mu := http.NewServeMux()
mu.Handle("/metrics", promhttp.Handler())
metricsServer.Handler = mu
go func() {
err := metricsServer.ListenAndServe()
if err != nil {
fmt.Println("prometheus-exporter http server start failed:", err)
}
}()
fmt.Println("metrics server start ok(*:8889)")
}
在这段代码中,我们使用 prometheus 提供的 go client 包中的类型定义了三个度量数据项。其中 ClientConnected 的类型为 prometheus.Gauge,Gauge 是对一个数值的即时测量值,它反映一个值的瞬时快照;而 ReqRecvTotal 和 RspSendTotal 的类型都为 prometheus.Counter。
Counter 顾名思义,就是一个计数器,可以累加,也可以减少。不过要想反映我们预期的每秒处理能力的指标,我们还需要将这两个计数器与 rate 函数一起使用才行,这个我们稍后再说。
我们在 metrics 包的 init 函数中启动了一个 http server,这个 server 监听 8889 端口,还记得我们前面 prometheus 配置文件中 tcp-server job 采集的目标地址吗?正是这个 8889 端口。也就是说,Prometheus 定期从 8889 端口拉取我们的度量数据项的值。
有了 metrics 包以及度量数据项后,我们还需要将度量数据项埋到服务端的处理流程中,我们来看对 main 包的改造:
// tcp-server-demo2/cmd/server/main.go
func handleConn(c net.Conn) {
metrics.ClientConnected.Inc() // 连接建立,ClientConnected加1
defer func() {
metrics.ClientConnected.Dec() // 连接断开,ClientConnected减1
c.Close()
}()
frameCodec := frame.NewMyFrameCodec()
for {
// read from the connection
// decode the frame to get the payload
// the payload is undecoded packet
framePayload, err := frameCodec.Decode(c)
if err != nil {
fmt.Println("handleConn: frame decode error:", err)
return
}
metrics.ReqRecvTotal.Add(1) // 收到并解码一个消息请求,ReqRecvTotal消息计数器加1
// do something with the packet
ackFramePayload, err := handlePacket(framePayload)
if err != nil {
fmt.Println("handleConn: handle packet error:", err)
return
}
// write ack frame to the connection
err = frameCodec.Encode(c, ackFramePayload)
if err != nil {
fmt.Println("handleConn: frame encode error:", err)
return
}
metrics.RspSendTotal.Add(1) // 返回响应后,RspSendTotal消息计数器减1
}
}
你可以看到,我们在每个连接的处理主函数 handleConn 中都埋入了各个度量数据项,并在特定事件发生时修改度量数据的值。
服务端建立完度量数据项后,我们还需要在 Grafana 中建立对应的仪表板来展示这些度量数据项,这一次,我们就需要手动创建仪表板 tcp-server-demo,并为仪表板手动添加 panel 了。
我们建立三个 panel:req_recv_rate、rsp_send_rate 和 client_connected,如下图所示:
client_connected panel 比较简单,我们直接取 tcp_server_demo2_client_connected 这个注册到 prometheus 中的度量项的值就可以了。
而 req_recv_rate 和 rsp_send_rate 就要结合度量项的值与rate 函数来实现。以 req_recv_rate 这个 panel 为例,它的 panel 配置是这样:
我们看到图中的 Metrics Browser 后面的表达式是:rate(tcp_server_demo2_req_recv_total[15s]),这个表达式返回的是在 15 秒内测得的 req_recv_total 的每秒速率,这恰恰是可以反映我们的服务端处理性能的指标。
好了,到这里,支持输出度量数据指标的服务端以及对应的 grafana 仪表板都准备好了。下面我们就来为服务端建立第一版的性能基准。

第一版性能基准

要建立性能基准,我们还需要一个可以对服务端程序“施加压力”的客户端模拟器,我们可以基于 tcp-server-demo1/cmd/client 实现这个模拟器。
新版模拟器的原理与 tcp-server-demo1/cmd/client 基本一致,所以具体的改造过程我这里就不多说了,新版模拟器的代码,我放在了 tcp-server-demo2/cmd/client 下面,你可以自行查看源码。
建立以及使用性能基准的前提,是服务端的压测的硬件条件要尽量保持一致,以保证得到的结果受外界干扰较少,性能基准才更有参考意义。我们在一个 4 核 8G 的 Centos Linux 主机上跑这个压力测试,后续的压测也是在同样的条件下。
压测的步骤很简单,首先在 tcp-server-demo2 下构建出 server 与 client 两个可执行程序。然后先启动 server,再启动 client。运行几分钟后,停掉程序就可以了,这时,我们在 grafana 的 tcp-server 的仪表板中,就能看到类似下面的图形化数据展示了:
从这张图中,我们大约看到服务端的处理性能大约在 18.5w/ 秒左右,我们就将这个结果作为服务端的第一个性能基准。

尝试用 pprof 剖析

按照这一讲开头的 Go 应用性能优化循环的思路,我们接下来就应该尝试对我们的服务端做性能剖析,识别出瓶颈点。
Go 是“自带电池”(battery included)的语言,拥有着让其他主流语言羡慕的工具链,Go 同样也内置了对 Go 代码进行性能剖析的工具:pprof
pprof 源自Google Perf Tools 工具套件,在 Go 发布早期就被集成到 Go 工具链中了,所以 pprof 也是 Gopher 最常用的、对 Go 应用进行性能剖析的工具。这里我们也使用这一工具对我们的服务端程序进行剖析。
Go 应用支持 pprof 性能剖析的方式有多种,最受 Gopher 青睐的是通过导入net/http/pprof包的方式。我们改造一下 tcp-server-demo2,让它通过这种方式支持 pprof 性能剖析。
改造后的代码放在 tcp-server-demo2-with-pprof 目录下,下面是支持 pprof 的 main 包的代码节选:
// tcp-server-demo2-with-pprof/cmd/server/main.go
import (
... ...
"net/http"
_ "net/http/pprof"
... ...
)
... ...
func main() {
go func() {
http.ListenAndServe(":6060", nil)
}()
... ...
}
从这个代码变更可以看到,我们只需要以空导入的方式导入 net/http/pprof 包,并在一个单独的 goroutine 中启动一个标准的 http 服务,就可以实现对 pprof 性能剖析的支持。pprof 工具可以通过 6060 端口采样到我们的 Go 程序的运行时数据。
接下来,我们就来进行性能剖析数据的采集。我们编译 tcp-server-demo2-with-pprof 目录下的 server 与 client,先后启动 server 与 client,让 client 对 server 保持持续的压力。
然后我们在自己的开发机上执行下面命令:
// 192.168.10.18为服务端的主机地址
$go tool pprof -http=:9090 http://192.168.10.18:6060/debug/pprof/profile
Fetching profile over HTTP from http://192.168.10.18:6060/debug/pprof/profile
Saved profile in /Users/tonybai/pprof/pprof.server.samples.cpu.004.pb.gz
Serving web UI on http://localhost:9090
go tool pprof 命令默认会从 http://192.168.10.18:6060/debug/pprof/profile 服务上,采集 CPU 类型的性能剖析数据,然后打开本地浏览器,默认显示如下页面:
debug/pprof/profile 提供的是 CPU 的性能采样数据。CPU 类型采样数据是性能剖析中最常见的采样数据类型。
一旦启用 CPU 数据采样,Go 运行时会每隔一段短暂的时间(10ms)就中断一次(由 SIGPROF 信号引发),并记录当前所有 goroutine 的函数栈信息。它能帮助我们识别出代码关键路径上出现次数最多的函数,而往往这个函数就是程序的一个瓶颈。上图我们沿着粗红线向下看,我们会看到下面图中的信息:
我们看到图中间的 Syscall 函数占据了一个最大的方框,并用黑体标记了出来,这就是我们程序的第一个瓶颈:花费太多时间在系统调用上了。在向上寻找,我们发现 Syscall 的调用者基本都是网络 read 和 write 导致的。

代码优化

好了,第一个瓶颈点已经找到!我们该进入优化循环的第三个环节:代码优化了。那么该如何优化代码呢?我们可以分为两个部分来看。

带缓存的网络 I/O

为什么网络 read 和 write 导致的 Syscall 会那么多呢?我们回顾一下第一版服务端的实现。
我们看到,在 handleConn 函数中,我们直接将 net.Conn 实例传给 frame.Decode 作为 io.Reader 参数的实参,这样,我们每次调用 Read 方法都是直接从 net.Conn 中读取数据,而 Read 将转变为一次系统调用(Syscall),哪怕是仅仅读取一个字节也是如此。因此,我们的优化目标是降低 net.Conn 的 Write 和 Read 的频率
那么如何降低 net.Conn 的读写频率呢?增加缓存不失为一个有效的方法。而且,我们的服务端采用的是一个 goroutine 处理一个客户端连接的方式,由于没有竞态,这个模型更适合在读写 net.Conn 时使用带缓存的方式。
所以,下面我们就来为 tcp-server-demo2 增加 net.Conn 的缓存读与缓存写。优化后的代码我放在了 tcp-server-demo3 下:
// tcp-server-demo3/cmd/server/main.go
func handleConn(c net.Conn) {
metrics.ClientConnected.Inc()
defer func() {
metrics.ClientConnected.Dec()
c.Close()
}()
frameCodec := frame.NewMyFrameCodec()
rbuf := bufio.NewReader(c)
wbuf := bufio.NewWriter(c)
defer wbuf.Flush()
for {
// read from the connection
// decode the frame to get the payload
// the payload is undecoded packet
framePayload, err := frameCodec.Decode(rbuf)
if err != nil {
fmt.Println("handleConn: frame decode error:", err)
return
}
metrics.ReqRecvTotal.Add(1)
// do something with the packet
ackFramePayload, err := handlePacket(framePayload)
if err != nil {
fmt.Println("handleConn: handle packet error:", err)
return
}
// write ack frame to the connection
err = frameCodec.Encode(wbuf, ackFramePayload)
if err != nil {
fmt.Println("handleConn: frame encode error:", err)
return
}
metrics.RspSendTotal.Add(1)
}
}
tcp-server-demo3 唯一的改动,就是 main 包中的 handleConn 函数。在这个函数中,我们新增了一个读缓存变量(rbuf)和一个写缓存变量(wbuf),我们用这两个变量替换掉传给 frameCodec.Decode 和 frameCodec.Encode 的 net.Conn 参数。
以 rbuf 为例,我们来看看它是如何起到降低 syscall 调用频率的作用的。
将 net.Conn 改为 rbuf 后,frameCodec.Decode 中的每次网络读取实际调用的都是 bufio.Reader 的 Read 方法。bufio.Reader.Read 方法内部,每次从 net.Conn 尝试读取其内部缓存大小的数据,而不是用户传入的希望读取的数据大小。这些数据缓存在内存中,这样,后续的 Read 就可以直接从内存中得到数据,而不是每次都要从 net.Conn 读取,从而降低 Syscall 调用的频率。
我们对优化后的 tcp-server-demo3 做一次压测,看看它的处理性能到底有没有提升,压测的步骤你可以参考前面的内容。压测后,我们得到下面的结果:
从图中可以看到,优化后的服务端的处理性能提升到 27w/s 左右,相比于第一版性能基准 (18.5w/s),性能提升了足有 45%。

重用内存对象

前面这个带缓存的网络 I/O,是我们从 CPU 性能采样数据中找到的“瓶颈点”。不过,在 Go 中还有另外一个十分重要的性能指标,那就是堆内存对象的分配。
因为 Go 是带有垃圾回收(GC)的语言,频繁的堆内存对象分配或分配较多,都会给 GC 带去较大压力,而 GC 的压力显然会转化为对 CPU 资源的消耗,从而挤压处理正常业务逻辑的 goroutine 的 CPU 时间。
下面我们就来采集一下 tcp-server-demo2-with-pprof 目录下的 server 的内存分配采样数据,看看有没有值得优化的点。
这次我们直接使用 go tool pprof 的命令行采集与交互模式。在启动 server 和 client 后,我们手工执行下面命令进行内存分配采样数据的获取:
$ go tool pprof http://192.168.10.18:6060/debug/pprof/allocs
Fetching profile over HTTP from http://192.168.10.18:6060/debug/pprof/allocs
Saved profile in /root/pprof/pprof.server.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz
File: server
Type: alloc_space
Time: Jan 23, 2022 at 6:05pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
数据获取到后,我们就可以使用 go tool pprof 提供的命令行交互指令,来查看各个函数的堆内存对象的分配情况,其中最常用的一个指令就是 top,执行 top 后,我们得到如下结果:
(pprof) top
Showing nodes accounting for 119.27MB, 97.93% of 121.79MB total
Dropped 31 nodes (cum <= 0.61MB)
Showing top 10 nodes out of 30
flat flat% sum% cum cum%
38MB 31.20% 31.20% 43.50MB 35.72% github.com/bigwhite/tcp-server-demo2/packet.Decode
28.50MB 23.40% 54.61% 28.50MB 23.40% github.com/bigwhite/tcp-server-demo2/frame.(*myFrameCodec).Decode
18MB 14.78% 69.39% 79MB 64.87% main.handlePacket
17.50MB 14.37% 83.76% 17.50MB 14.37% bytes.Join
9MB 7.39% 91.15% 9MB 7.39% encoding/binary.Write
5.50MB 4.52% 95.66% 5.50MB 4.52% github.com/bigwhite/tcp-server-demo2/packet.(*Submit).Decode (inline)
1.76MB 1.45% 97.11% 1.76MB 1.45% compress/flate.NewWriter
1MB 0.82% 97.93% 1MB 0.82% runtime.malg
0 0% 97.93% 1.76MB 1.45% bufio.(*Writer).Flush
0 0% 97.93% 1.76MB 1.45% compress/gzip.(*Writer).Write
top 命令的输出结果默认按flat(flat%)列从大到小的顺序输出。flat列的值在不同采样类型下表示的含义略有不同。
在 CPU 类型采样数据下,它表示函数自身代码在数据采样过程的执行时长;在上面的堆内存分配类型采样数据下,它表示在采用过程中,某个函数中堆内存分配大小的和。而flat%列的值表示这个函数堆内存分配大小占堆内存总分配大小的比例。
从上面的输出结果来看,packet.Decode 函数排在第一位。那么,现在我们就来深入探究一下 Decode 函数中究竟哪一行代码分配的堆内存量最大。我们使用 list 命令可以进一步进入 Decode 函数的源码中查看:
(pprof) list packet.Decode
Total: 121.79MB
ROUTINE ======================== github.com/bigwhite/tcp-server-demo2/packet.Decode in /root/baim/tcp-server-demo2-with-pprof/packet/packet.go
38MB 43.50MB (flat, cum) 35.72% of Total
. . 75: case CommandConn:
. . 76: return nil, nil
. . 77: case CommandConnAck:
. . 78: return nil, nil
. . 79: case CommandSubmit:
38MB 38MB 80: s := Submit{}
. 5.50MB 81: err := s.Decode(pktBody)
. . 82: if err != nil {
. . 83: return nil, err
. . 84: }
. . 85: return &s, nil
. . 86: case CommandSubmitAck:
(pprof)
我们看到,s := Submit{}这一行是分配内存的“大户”,每次服务端收到一个客户端 submit 请求时,都会在堆上分配一块内存表示 Submit 类型的实例。
这个在程序关键路径上的堆内存对象分配会给 GC 带去压力,我们要尽量避免或减小它的分配频度,一个可行的办法是尽量重用对象。
在 Go 中,一提到重用内存对象,我们就会想到了 sync.Pool。简单来说,sync.Pool 就是官方实现的一个可复用的内存对象池,使用 sync.Pool,我们可以减少堆对象分配的频度,进而降低给 GC 带去的压力。
我们继续在 tcp-server-demo3 的基础上,使用 sync.Pool 进行堆内存对象分配的优化,新版的代码放在了 tcp-server-demo3-with-syncpool 中。
新版代码相对于 tcp-server-demo3 有两处改动,第一处是在 packet.go 中,我们创建了一个 SubmitPool 变量,它的类型为 sync.Pool,这就是我们的内存对象池,池中的对象都是 Submit。这样我们在 packet.Decode 中收到 Submit 类型请求时,也不需要新分配一个 Submit 对象,而是直接从 SubmitPool 代表的 Pool 池中取出一个复用。这些代码变更如下:
// tcp-server-demo3-with-syncpool/packet/packet.go
var SubmitPool = sync.Pool{
New: func() interface{} {
return &Submit{}
},
}
func Decode(packet []byte) (Packet, error) {
commandID := packet[0]
pktBody := packet[1:]
switch commandID {
case CommandConn:
return nil, nil
case CommandConnAck:
return nil, nil
case CommandSubmit:
s := SubmitPool.Get().(*Submit) // 从SubmitPool池中获取一个Submit内存对象
err := s.Decode(pktBody)
if err != nil {
return nil, err
}
return s, nil
case CommandSubmitAck:
s := SubmitAck{}
err := s.Decode(pktBody)
if err != nil {
return nil, err
}
return &s, nil
default:
return nil, fmt.Errorf("unknown commandID [%d]", commandID)
}
}
第二处变更是在 Submit 对象用完后,归还回 Pool 池,最理想的“归还地点”是在 main 包的 handlePacket 函数中,这里处理完 Submit 消息后,Submit 对象就没有什么用了,于是我们在这里将其归还给 Pool 池,代码如下:
// tcp-server-demo3-with-syncpool/cmd/server/main.go
func handlePacket(framePayload []byte) (ackFramePayload []byte, err error) {
var p packet.Packet
p, err = packet.Decode(framePayload)
if err != nil {
fmt.Println("handleConn: packet decode error:", err)
return
}
switch p.(type) {
case *packet.Submit:
submit := p.(*packet.Submit)
submitAck := &packet.SubmitAck{
ID: submit.ID,
Result: 0,
}
packet.SubmitPool.Put(submit) // 将submit对象归还给Pool池
ackFramePayload, err = packet.Encode(submitAck)
if err != nil {
fmt.Println("handleConn: packet encode error:", err)
return nil, err
}
return ackFramePayload, nil
default:
return nil, fmt.Errorf("unknown packet type")
}
}
改完这两处后,我们的内存分配优化就完成了。
和前面一样,我们构建一下 tcp-server-demo3-with-syncpool 目录下的服务端,并使用客户端对其进行一次压测,压测几分钟后,我们就能看到如下的结果:
从采集的性能指标来看,优化后的服务端的处理能力平均可以达到 29.2w/s,这相比于上一次优化后的 27w/s,又小幅提升了 8% 左右。
到这里,按照我们在这一讲开头处所讲的性能优化循环,我们已经完成了一轮优化了,并且取得了不错的效果,现在可以将最新的性能指标作为新一版的性能基准了。
至于是否要继续新一轮的优化,这就要看当前的性能是否能满足你的要求了。如果满足,就不需再进行新的优化,否则你还需要继续一轮或几轮优化活动,直到性能满足你的要求。

小结

好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
在这一讲中,我们重点讲解了如何针对上一讲实现的第一版服务端进行优化。我们给出了 Go 程序优化的四步循环方法,这四步依次是建立性能基准、性能剖析、代码优化和与性能基准比较,确定优化效果。如果经过一轮优化,Go 应用的性能仍然无法达到你的要求,那么还可以按这个循环,进行多轮优化。
建立性能基准是整个优化过程的前提,基准提供了性能优化的起点与参照物。而建立性能基准的前提又是建立观测设施。观测设施的建立方法有很多,这里我们基于 Prometheus+Grafana 的组合,实现了一个可视化的观测平台。基于这个平台,我们为第一版服务端实现建立了性能基准。
另外,剖析 Go 应用性能有很多工具,而 Gopher 的最爱依然是 Go 原生提供的 pprof,我们可以以图形化的形式或命令行的方式,收集和展示获取到的采样数据。针对我们的服务端程序,我们进行了带缓冲的网络 I/O 以及重用内存对象的优化,取得了很不错的效果。

思考题

这一讲中,虽然我们对第一版服务端实现实施了两个有效的优化,但这个程序依然有可优化的点,你不妨找找,看看还能在哪些点上小幅提升服务端的性能。
欢迎你把这节课分享给感兴趣的朋友。我是 Tony Bai,我们下节课见。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 19

提建议

上一篇
37|代码操练:怎么实现一个TCP服务器?(中)
下一篇
结课测试|快来检验下你的学习成果吧!
unpreview
 写留言

精选留言(21)

  • 罗杰
    2022-01-28
    什么时候能和老师一样有如此丰富的优化经验呢,讲的非常精彩。

    作者回复: 👍

    共 2 条评论
    5
  • 多选参数
    2022-05-05
    原本以为最后的实战课只是写一个稍微大点的项目。结果,恰恰相反,老师使用一个极小的项目,带着走了一遍 Go 开发和调优的过程,这种方法论或者思想的传授,真的比单纯写代码可以学到更多,是在其他书籍或专栏所没有的,很喜欢这种方式!感谢老师! 简单总结下,最后实战课核心的内容: 1. Go 开发的流程结合自己的几个月的经历,总结下:(1)首先是明确问题或需求,之后根据问题或需求,提炼出需要用到的技术点,比如 socket 技术;(2)之后,去调研相关的技术或相关项目;(3)最后写设计方案;(4)根据设计方案,实现代码。(PS:这个过程可以循环迭代) 2. Go 程序优化的过程:(1)首先是明确衡量指标,比如某个函数的执行时间、程序在一定时间内可以处理的请求量、接受的数据包数量等等(并获取首次的指标情况);(2)之后,使用 pprof 获取 CPU、内存的使用情况,并分析使用情况,得到整个程序的瓶颈所在;(3)最后,根据分析得到的结果,优化源码;(4)优化源码后,再次测试衡量指标,根据新获取的指标情况,决定是否继续分析和优化。 另外,可以继续优化点的应该就是与 Submit 使用相同的地方,比如 submitAck。
    展开

    作者回复: 👍

    2
  • 2022-04-14
    bufio.Reader.Read 方法内部,每次从 net.Conn 尝试读取其内部缓存大小的数据,而不是用户传入的希望读取的数据大小 -----------这里有个疑问,server端for 循环里不是每次都是重新读取到的conn传过来的数据吗,也就是每次client端发送过来的payload都是要有一次必要的读取,为什么会减少读取的次数呢?

    作者回复: 是减少socket read系统调用的次数,也就是说增加buf read前,每次read,无论read 1个字节,还是read 100个字节,都要执行一次sys call。而增加buf read后的read会看buf中是否有足够的数据,如果有数据满足read需求,就不会执行syscall去读取底层的socket数据。当然之所以能做这么是因为bufio.Reader.Read每次从底层socket读取的数据并不是上层传入的大小,默认值为defaultBufSize ,即4096。

    共 2 条评论
    2
  • 菠萝吹雪—Code
    2022-09-14 来自北京
    精彩的优化过程!感谢老师这个专栏,很喜欢这种讲解知识点的方式!第一遍结束,第二遍开始记笔记分析每一行代码,第三遍再过下老师的两本书,应该算是完全入门go了

    作者回复: 书也买了啊,哈哈,嫡系了🤝

    1
  • Geek_25f93f
    2022-06-26
    老师,看网上有种说法。池化这种事情,Java很早很早之前也做过 现在都不怎么提了,go的编译器太弱了?

    作者回复: "管它黑猫白猫,能抓住老鼠就是好猫",至少目前sync.Pool能在一定场景下帮助我们提升性能。 go与java虽然都是gc语言,但由于类型系统的差异,gc所面对的环境也不同,有些事情不能以强弱来论。比如java gc做分代,而go gc没有分代,不是go gc不想做分代,而是经过实测分代对go gc的提升有限,因为go的许多生命周期很短的临时对象经过逃逸分析后是分配在栈上的,完全不需要在堆上分配。 当然和java比,go在gc方面的打磨时间还远远不够。这反过来说也是好事,目前go gc已经很不错了,未来只能更强大。作为Go应用开发者,我们将来可以做到“躺赢”:)。

    1
  • @%初%@
    2022-06-19
    老师,有个问题,sync.pool也就是内存池那块,请求过来之后,我看到是直接放到内存池,没有置空数据,那么在内存池获取的时候,会不会出现数据错乱的问题呢?

    作者回复: 好问题。使用sync.Pool时,复用内存对象当前数据可能会造成数据错乱的情况。因此如何处理这种问题,要看pool中的对象是什么以及如何用? 像我们这里,pool中对象是一个Submit的指针,而submit的结构如下: type Submit struct { ID string Payload []byte } 这个结构在每次decode时都会被全量覆盖: func (s *Submit) Decode(pktBody []byte) error { s.ID = string(pktBody[:8]) s.Payload = pktBody[8:] return nil } 也就是说虽然put时没有重置,但取出后重置了。 换成其他pool中对象和其他场景,的确需要考虑是否需要重置对象,以及是在put时还是get时进行重置。

    1
  • Mew151
    2022-10-13 来自北京
    老师,想问一下, tcp-server-demo2-with-pprof 的代码,我在我的 Mac 上分别启动 server 和 client ,过了一段时间后 server 报如下错误(打印是我加了些详细日志之后的): metrics server start ok(*:8889) server start ok(on *:8888) [2022-10-13 13:41:54.562] io.ReadFull: read tcp 127.0.0.1:8888->127.0.0.1:49689: read: operation timed out totalLen is 28, n is 20 [2022-10-13 13:41:54.562] handleConn: frame decode error: read tcp 127.0.0.1:8888->127.0.0.1:49689: read: operation timed out [2022-10-13 13:41:55.545] io.ReadFull: read tcp 127.0.0.1:8888->127.0.0.1:49754: read: operation timed out totalLen is 32, n is 15 [2022-10-13 13:41:55.545] handleConn: frame decode error: read tcp 127.0.0.1:8888->127.0.0.1:49754: read: operation timed out [2022-10-13 13:41:55.590] io.ReadFull: read tcp 127.0.0.1:8888->127.0.0.1:49729: read: operation timed out totalLen is 31, n is 26 ... 请问这是什么原因呢?我看程序里也没有设置 SetReadDeadline 的地方,为什么会报读超时呢
    展开

    作者回复: 在linux上跑一个小时,也没有出现问题。以前还真没在mac上跑过,我抽时间调查一下。

    共 3 条评论
  • 骚动
    2022-08-19 来自北京
    mac下的配置参考: 1. mac不能用host,需要用ports端口映射 version: "3.2" services: prometheus: container_name: prometheus image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./conf/tcp-server-prometheus.yml:/etc/prometheus/prometheus.yml - /etc/localtime:/etc/localtime restart: on-failure grafana: container_name: grafana image: grafana/grafana:latest restart: on-failure ports: - "3000:3000" volumes: - /etc/localtime:/etc/localtime - ./data/grafana:/var/lib/grafana node_exporter: image: quay.io/prometheus/node-exporter:latest restart: always container_name: node_exporter command: - '--path.rootfs=/host' ports: - "9100:9100" volumes: - /Users/zouqiang/Documents/docker/monitor/data/node_exporter 2. grafana上配置prometheus数据源时,url: http://本机ip:端口 , 不要用http://localhost:9090这种方式,因为容器间的localhost不是同一个localhost, 用主机名直接指定即可
    展开

    作者回复: 👍

  • 2022-07-04
    network_mode: host在mac不生效,改了ports映射的方式 The host networking driver only works on Linux hosts, and is not supported on Docker Desktop for Mac, Docker Desktop for Windows, or Docker EE for Windows Server.

    作者回复: 👍

  • 2022-06-30
    跟着老师的教程完整的测了一遍,优化的效果太明显了,非常感谢老师准备这么好的课程,跟着大佬学习

    作者回复: 👍

  • Geek_25f93f
    2022-06-27
    老师,这个frame.decode解包过程为什么不会出现粘包的现象啊?按理说收到这个frame部分也可能出现半包的情况啊,为什么我不管怎么试 在服务端加sleep时间 或者 加client的发送包体长度和协程数量,也没办法进入 if n != int(totalLen-4) 这个判断分支呀

    作者回复: 主要是因为io.ReadFull这个函数,可以查看一下它的manual。

  • 功夫熊猫
    2022-06-27
    老师,判断packet的是submit还是submitack的时候可不可以用反射。我感觉这个项目还有很大的空间,比如连接包的定义还有关闭包甚至重连包,甚至可以可以模拟tcp的超时重传之类的。哈哈,后面可以慢慢的做,谢谢老师

    作者回复: 1.能用type assert or type switch就不要用反射。 2. 这个工程只是一个用于学习讲解的样板工程,还有很大空间😊。

  • qiutian
    2022-06-10
    老师,我想问下怎么进的Grafana页面,我运行了起来,然后访问:3000或者其他几个端口,都访问不了?查看三个运行的容器可以看到,浏览器进不了Grafana页面

    作者回复: 访问3000端口就可以啊。

  • 马以
    2022-06-07
    甭管是美发届还是技术圈,托尼老师一直值得信赖

    作者回复: 👍😁

  • 邵康
    2022-06-03
    老师太NB了,完全不想入门课
  • ryanxw
    2022-05-18
    需要好好向大白老师学习,🐂

    作者回复: 👍

  • 木木
    2022-04-07
    老师给出的docker-compose.yml实测在linux下是没有问题的,但是在mac下不能直接运行。用mac的同学可以试试brew install 而不是docker来解决

    作者回复: 👍。

  • 木木
    2022-03-22
    我这里Frame的Decode函数在读totalLen的时候会有小概率读到一个错误的数值,请问有人知道这是为什么吗?

    作者回复: 可以试着将你收到的所有数据按字节逐个dump出来。可以使用hex.Dump这个函数。 btw同问:有遇到过这种错误情况的么?

    共 2 条评论
  • 江楠大盗
    2022-02-22
    在mac上的虚拟机里安装docker,修改了docker-compose.yml 文件,启动时进行了3000、9100、9090的端口映射,然后从mac的浏览器访问虚拟机的的3000端口,进入了grafana,配置了prometheus数据源,导入了1806仪表板,但是仪表板一直没有数据,老师能知道是哪里出了问题吗?谢谢

    作者回复: 专栏里的仪表板编号是1860吧,你导入的是1806?

  • Rayjun
    2022-02-20
    对于工程师来说,这种量化的能力非常重要,只有量化了数据,才能找到瓶颈,而不是通过感觉去判断,白老师能利用这些工具将量化的过程讲的这么清晰,太强了

    作者回复: 👍

新人学习立返 50% 购课币
去订阅《Tony Bai · Go语言第一课》新人首单¥59