go性能优化 | 青训营

93 阅读2分钟

一 性能优化

1.1 性能优化手段

性能优化的入手点:

  • CPU性能优化
  • 内存性能优化
  • 使用火焰图进行优化
  • 性能优化实例剖析
  • Go中锁竞争维度的优化

常见性能优化手段:

  • 尽可能减少HTTP请求,比如合并css,使用精灵图
  • 使用CDN系统,实现就近访问
  • 启用gzip压缩,降低网页传输大小
  • 优化后端api服务性能(查找哪些哪些地方占用了过多资源)

当pprof开启后,每隔一段时间(10ms)收集当前堆栈信息,获取各个函数占用的cpu以及内存资源,当pprof完成之后,通过对这些数据进行分析,性能一个性能分析报告。

1.2 生成测试数据

package main

import (
	"flag"
	"fmt"
	"os"
	"runtime/pprof"
	"time"
)

func logicCode() {
	var c chan int
	for {
		select {
		case v := <- c:
			fmt.Printf("read from chan, v: %v\n", v)
		default:

		}
	}
}

func main() {

	var isCpuPprof bool
	flag.BoolVar(&isCpuPprof, "cpu", off, "turn cpu pprof on")
	flag.Parse()

	if isCpuPprof {
		file, err := os.Create("C:/temp/cpu.pprof")
		if err != nil {
			fmt.Printf("create cpu pprof failed, err:%v \n", err)
			return
		}
		pprof.StartCPUProfile(file)
		defer pprof.StopCPUProfile()
	}

	for i := 0; i < 8; i++ {
		go logicCode()
	}

	time.Sleep(30 * time.Second)

}

1.3 分析数据

go tool pprof .\cpu_pprof_exam.exe .\cpu.pprof

上述命令会进入命令行模式:

top5    # 查看前五的占用函数

1.4 go test

每次都手动导入pprof比较麻烦,可以直接使用go test测试时进行pprof。

步骤:

  • 1 先编译压力测试程序,如go test -c 生成可执行程序**_test
  • 2 分析cpu性能: ./**.test-test.bench=**-test.cpuprofile=./cpu.pprof
  • 3 分析内存:./**.test-test.bench=**.test.memeprofile=./mem.pprof

一 goroutine

1.1 goroutine 需要合理退出

Goroutine经常用来处理耗时操作,但是如果无限制创建协程,不考虑协程的退出和生命周期,容易造成goroutine的失控:

	ch := make(chan int)
	for {

		var dummy string
		fmt.Scan(&dummy)

		go func(ch chan int) {				// 模拟耗时操作
			for {
				data := <-ch
				fmt.Println(data)
			}
		}(ch)

		fmt.Println(runtime.NumGoroutine())
	}

程序运行后,随着输入的字符串越来越多,goroutine将会无限制地被创建,但并不会结束,为了避免这种情况,可以为协程添加退出条件:

	ch := make(chan int)
	for {

		var dummy string
		fmt.Scan(&dummy)

		// 设置强行退出条件,方式协程内部的退出条件无法触发
		if dummy == "quit" {
			for i := 0; i < runtime.NumGoroutine(); i++ {
				ch <- 0
			}
			continue
		}

		go func(ch chan int) {				// 模拟耗时操作
			for {
				data := <-ch
				if data == 0{				// 添加协程内部退出条件
					break
				}
				fmt.Println(data)
			}
		}(ch)

		fmt.Println(runtime.NumGoroutine())
	}

1.2 不能滥用goroutine

为了保证多个 goroutine 并发访问的安全性,通道也需要做一些锁操作,因此通道其实并不比锁高效。对于 TCP 来说, 一般是接收过程创建 goroutine 并发处理 。当套接字结束时,就要正常退出这些 goroutine。

package main

import (
	"fmt"
	"net"
	"time"
)

func socketRecv(conn net.Conn, exitChan chan string) {
	buf := make([]byte, 1024)
	for {								// 创建循环不停接收数据
		_, err := conn.Read(buf)		// 从套接字中读取数据
		if err != nil {
			break
		}
	}
	exitChan <- "exit"
}

func main() {
	conn, err := net.Dial("tcp", "www.163.com:80")
	if err != nil {
		fmt.Println(err)
		return
	}

	exit := make(chan string)
	go socketRecv(conn, exit)
	time.Sleep(time.Second)
	conn.Close()				// 主动关闭套接字,触发err
	fmt.Println(<-exit)

}

修改后的例子如下:

package main

import (
	"fmt"
	"net"
	"sync"
	"time"
)

func socketRecv(conn net.Conn, wg *sync.WaitGroup) {
	buf := make([]byte, 1024)
	for {								// 创建循环不停接收数据
		_, err := conn.Read(buf)		// 从套接字中读取数据
		if err != nil {
			break
		}
	}
	wg.Done()
}

func main() {
	conn, err := net.Dial("tcp", "www.163.com:80")
	if err != nil {
		fmt.Println(err)
		return
	}

	var wg sync.WaitGroup
	wg.Add(1)
	go socketRecv(conn, &wg)

	time.Sleep(time.Second)
	conn.Close()				// 主动关闭套接字,触发err

}

一 TCP粘包优雅处理

1.1 粘包产生的原因

在使用TCP协议分高发送数据时,为了提高发送效率,需要对封包进行拼包处理,将小包凑成大包,在TCP层可以节约包头的大小损耗,I/O层的调用损耗也可以有所降低。但此时也容易形成粘包,也就是没有按照预期的大小得到数据,数据包不完整。

在接收TCP封包时,接收缓冲区的大小与发送过来的TCP传输单元大小不等,这时候会造成两种情况:

  • 接收的数据大于等于接收缓冲区大小时,此时需要将数据复制到用户缓冲,接着读取后面的封包。
  • 接收的数据小于接收缓冲区大小时,此时需要继续等待后续的 TCP 封包。

在go语言的io包中有个函数ReadAtLeast()用来处理封装

func ReadAtLeast(r Reader, buf []byte, min int) (n int, err error) {
    if len(buf) < min {
        return 0, ErrShortBuffer
    }
    for n < min && err == nil {
        var nn int
        nn, err = r.Read(buf[n:])
        n += nn
    }
    if n >= min {
        err = nil
    } else if n > 0 && err == EOF {
        err = ErrUnexpectedEOF
    }
    return
}

在else判断中,读取目标已经完结,但是己经读取一些数据,也就是说,没法完成读取任务,发生了不可期望的终结错误。

ReadAtLeast还有个更好的封装:

func ReadFull(r Reader, buf []byte, min int) (n int, err error) {
    return ReadAtLeast(r, buf, len(buf))
}

这个函数只需要提供buf接收缓冲区切片,就可以将这个己经分配的buf填充满 。简单地说就是: 给多大空间,填充多少字节,直到填满空间。
使用ReadFull可以优雅地完成对TCP粘包的处理。

1.2 封包发送

封包格式:

  • Size:大小,代表Size后面包体的大小
  • Body:消息的二进制数据

在发送封包时,需要将封包的Size字段从无符号十六位整型转换为字节数组(利用binary包的Write函数):

// 二进制封包格式
type Packet struct {
	Size uint16 // 包体大小
	Body []byte // 包体数据
}

// 将数据写入dataWriter
func writePacket(dataWriter io.Writer, data []byte) error {

	// 准备一个字节数组缓冲
	var buf bytes.Buffer

	// 将Size写入缓冲
	err := binary.Write(&buf, binary.LittleEndian, uint16(len(data)))
	if err != nil {
		return err
	}

	// 写入包体数据
	_, err = buf.Write(data)
	if err != nil {
		return err
	}

	// 获取写入的完整数据
	out := buf.Bytes()

	// 写入完整数据
	_, err = dataWriter.Write(out)
	if err != nil {
		return err
	}

	return nil
}

1.3 连接器

连接器可以通过给定的地址和发送次数,不断地通过Socket给地址对应的连接发送封包。本例中,将通过循环构造的转为字符串的数字形成封包。由于速度快,包量大,因此能有效地造成粘包:

func Connector(addr string, sendTimes int) {

	// 创建连接
	conn, err := net.Dial("tpc", addr)
	if err != nil {
		fmt.Println("连接错误:", err)
		return
	}

	// 循环请求
	for i := 0; i < sendTimes; i++ {
		str := strconv.Itoa(i) // 将循环序号转化为字符串
		err := writePacket(conn, []byte(str))
		if err != nil {
			fmt.Println("发送错误:", err)
			break
		}
	}
}

1.4 接受器

连接器( Connector)只能发起到接收器( Acceptor)的连接, 一个接受器能接受多个来源连接。

接受器中侦昕的过程被放到一个独立的goroutine中,保证侦昕的过程不会阻塞其他逻辑的执行。在侦昕器停止时,需要使用 sync.WaitGroup进行同步,确认侦昕过程正常结 束。接受器不停的接受连接,当获取到连接时,为连接创建一个并发的会话处理的goroutine ( handleSession 函数)。

接受器开始侦昕后,使用 Wait()方法等待侦昕结束。当处理粘包的任务完成时俗,AcceptorStop()方法会被调用,结束侦昕,同时退出整个程序。接受器的实现过程如下:

// 接收器
type Acceptor struct {
	ls            net.Listener   // 保存侦听器
	wg            sync.WaitGroup // 侦听器同步
	OnSeesionData func(net.Conn, []byte) bool
}

// 异步开始侦听
func (a *Acceptor) Start(addr string) {
	go func(addr string) {
		a.wg.Add(1) // 侦听开始时,添加一个任务
		defer a.wg.Done()

		var err error
		a.ls, err = net.Listen("tcp", addr)
		if err != nil {
			fmt.Println("listen err = ", err)
			return
		}
		// 侦听循环
		for {
			conn, err := a.ls.Accept() // 新连接没到来时,Accept阻塞
			if err != nil {
				break
			}
			go handleSession(conn, a.OnSeesionData)
		}
	}(addr)
}

// 停止侦听
func (a *Acceptor) Stop() {
	a.ls.Close()
}

// 等待侦听完全停止
func (a *Acceptor) Wait() {
	a.wg.Wait()
}

func NewAcceptor() *Acceptor {
	return &Acceptor{}
}

1.5 封包读取

本例中的 readPacket()函数可以从 dataReader 中读取封包数据,并且将封包的 Size 和 Body 部分分离并返回 Packet 结构:

func readPacket(dataReader io.Reader) (pkt Packet, err error) {
	var sizeBuffer = make([]byte, 2) // Size为uint16类型,占据2个字节

	// 持续读取Size,直到读到为止
	_, err = io.ReadFull(dataReader, sizeBuffer)
	if err != nil {
		return
	}

	// 读取数据
	sizeReader := bytes.NewReader(sizeBuffer)
	err = binary.Read(sizeReader, binary.LittleEndian, &pkt.Size)
	if err != nil {
		return
	}

	// 分配包体大小
	pkt.Body = make([]byte, pkt.Size)

	// 读取包体数据
	_, err = io.ReadFull(dataReader, pkt.Body)
	return
}

1.6 会话处理

务器在接受一个连接后就会进入会话( Session )处理。会话处理是一个循环,不停地从 Socket 中接收数据并通过 readPacket()函数将数据转换为封包。会话处理会传入一个处理数据的回调,处理好的数据会通过这个回调传送到用户的逻辑回调函数( callback) :

// 连接的会话逻辑
func handleSession(conn net.Conn, callback func(net.Conn, []byte) bool) {
	dataReader := bufio.NewReader(conn) // 创建socket读取器
	for {                               // 循环接收数据
		pkt, err := readPacket(dataReader)
		if err != nil || !callback(conn, pkt.Body) {
			// 回调要求退出
			conn.Close()
			break
		}
	}
}

1.5 测试粘包处理

func main() {

	const TESTCOUNT = 10000 // 测试次数
	const addr = "127.0.0.1:3000"

	var recCounter int // 接收器

	a := NewAcceptor()
	a.Start(addr)

	a.OnSeesionData = func(conn net.Conn, data []byte) bool {
		str := string(data)
		n, err := strconv.Atoi(str)
		if err != nil || recCounter != n {
			panic("failed")
		}
		recCounter++

		if recCounter >= TESTCOUNT {
			a.Stop()
			return false
		}

		return true
	}

	// 连接器不断发送数据
	Connector(addr, TESTCOUNT)

	// 等待侦听器结束
	a.Wait()
}