您好,登录后才能下订单哦!
本篇讲的是Go程序的性能分析,下面提到的内容都是从事这项任务必备的一些知识和技巧。这些有助于我们真正理解以采样、收集、输出为代表的一系列操作步骤。
Go语言为程序开发者们提供了丰富的性能分析API,和非常好用的标准工具。这些API主要存在于下面三个包中:
另外,runtime包中还包含了一些更底层的API。这些都可以被用来收集或输出Go程序运行过程中的一些关键指标,并帮助我们生成相应的概要文件以供后续分析时使用。
标准工具主要有:
这两个工具,可以解析概要文件中的信息,并以人类易读的方式把这些信息展示出来。
go test命令,也可以在程序测试完成后生成概要文件。这样就可以很方便的使用前面那两个工具读取概要文件,并对被测程序的性能加以分析。这样就让程序性能测试的资料更加丰富,结果也更加精确和可信。
在Go语言中,用于分析程序性能的概要文件有三种:
这些概要文件中包含的都是:在某一段时间内,对Go程序的相关指标进行多次采样后得到的概要信息。
对于CPU概要文件,其中的每一段独立的概要信息都记录着在进行某一次采样的那个时刻,CPU上正在执行的Go代码。
对于内存概要文件,其中的每一段概要信息都记载着在某个采样时刻,正在执行的Go代码以及堆内存的使用请求,这里包含已分配和已释放的字节数量和对象数量。
对于阻塞概要文件,其中每一段概要信息都代表着Go程序中的一个goroutine的阻塞事件。
查看概要文件
在默认情况下,这些概要文件中的信息并不是普通的文本,它们是以二进制的形式展现的。如果使用常规的文本编辑器查看,看到的是乱码。需要用go tool pprof这个工具来查看。可以通过该工具进入一个基于命令行的交互式界面,并对指定的概要文件进行查阅:
$ go tool pprof cpuprofile.out
Type: cpu
Time: Nov 9, 2018 at 4:31pm (CST)
Duration: 7.96s, Total samples = 6.88s (86.38%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
关于这个工具的具体用法没有展开。建议在使用时,输入help查看帮助信息。
概要文件中的信息并不是普通的文本。而是通过protocol buffers生成的二进制数据流,或者说字节流。而protocol buffers是一种数据序列化协议,同时也是一个序列化工具。它可以把一个值,比如一个结构体或者一个字典,转换成一段字节流。这个过程叫序列化。也可以反过来,把生成的字节流转换为程序中的一个值,这叫反序列化。
Go语言从1.8版本开始,把所有的profile相关的信息生成工作都交给protocol buffers来做了。它有不少的优势。可以在序列化数据的同时对数据进行压缩,所以生成的字节流通常都要比其他格式(XML和JSON)占用的空间小很多。还支持自定义数据序列化和结构化的格式,也允许在保证向后兼容的前提下更新这种格式。这就是概要文件不使用普通文本格式保存的原因。
顺便提一下,protocol buffers的用途非常广泛,并且在诸如数据存储、数据传输等任务中有着很高的使用率。
Protocol Buffers,是Google公司开发的一种数据描述语言,类似于XML能够将结构化数据序列化,可用于数据存储、通信协议等方面。
更多相关的知识就不展开的。
采样CPU概要信息,需要用到runtime/pprof包中的API。要让程序开始对CPU概要信息进行采样,需要调用包中的StartCPUProfile函数。而在停止采样的时候,需要调用包中的StopCPUProfile函数。
runtime/pprof.StartCPUProfile函数在被调用的时候,先会去设定CPU概要信息的采样频率,并会在单独的goroutine中运行CPU概要信息的收集和输出。StartCPUProfile函数设定的采样频率总是固定的100Hz,就是每秒采样100次,或者说每10毫秒采样一次。
关于CPU的主频
CPU的主频是CPU内核工作的时钟频率,也常被称为:CPU clock speed。这个时钟频率的倒数即为时钟周期(clock cycle),也就是一个CPU内核执行一条运算指令所需的时间,单位秒。例如:主频为1000Hz的CPU,它的单个内核执行一条运算指令所需的时间为0.001秒,即1毫秒。又例如,现在常见的3.2GHz的多核CPU,其单个内核在1纳秒的时间里就可以至少执行三条运算指令。
采样频率设定的原因
StartCPUProfile函数设定的CPU概要信息采样频率,相对于现代的CPU主频来说是非常低的。这主要有两个方面的原因。
一、过高的采样频率会对Go程序的运行效率造成很明显的负面影响。因此,runtime包中StartCPUProfileRate函数在被调用的时候,会保证采样频率不超过1MHz,也就是只允许1微妙最多采样一次。StartCPUProfile函数正是通过调用这个函数来设定CPU概要信息的采样频率的。
二、经过大量的实现,GO语言团队发现100Hz是一个比较合适的设定。因为这样做既可以得到足够多、足够有用的概要信息,又不至于让程序的运行出现停滞。另外,操作系统对高频采样的处理能力也是有限的,一般情况下,超过500Hz就很可能得不到及时的响应的。
在StartCPUProfile函数执行之后,一个新启用的goroutine将会负责执行CPU概要信息的收集和输出,直到runtime/pprof包中的StopCPUProfile函数被成功调用。
StopCPUProfile函数也会调用runtime.SetCPUProfileRate函数,并把参数值就是采样频率设为0。这会让针对CPU概要信息的采样工作停止。同时还会给负责收集CPU概要信息的代码一个信号,告知收集工作也需要停止。在接到信号之后,那部分程序将会把这段时间内收集到的所有CPU概要信息,全部写入到我们在调用StartCPUProfile函数的时候指定的写入器中。只有在上述操作全部完成之后,StopCPUProfile函数才会返回。
上面已经分析了,首先要调用StartCPUProfile函数,要停止的时候就调用StopCPUProfile函数。中间就是需要进行测试的代码:
func main() {
// 打开文件,准备写入
filename := "cpuprofile2.out"
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Create File Error: %v", err)
return
}
defer f.Close()
// 进行采样
if err := startCPUProfile(f); err != nil {
fmt.Fprintf(os.Stderr, "CPU profile start error: %v\n", err)
return
}
/* 这里写需要测试的代码
*/
// 停止采样
stopCPUProfile()
}
func startCPUProfile(w io.Writer) error {
if w == nil {
return errors.New("nil File")
}
return pprof.StartCPUProfile(w)
}
func stopCPUProfile() {
pprof.StopCPUProfile()
}
被测试的代码
下面这段程序,应该就是纯粹为了看效果,是一段CPU密集型操作的代码:
// article48/common/op/cpu.go
package op
import (
"bytes"
"math/rand"
"strconv"
)
func CPUProfile() error {
max := 10000000
var buf bytes.Buffer
for i := 0; i < max; i++ {
num := rand.Int63n(int64(max))
str := strconv.FormatInt(num, 10)
buf.WriteString(str)
}
_ = buf.String()
return nil
}
包装被测试的函数
这里再额外做一步,对上面的函数进行一次包装,可以执行多次被测试的函数。所以下面要实现的函数要传入两个参数,一个是被测试的函数,一个是希望执行的次数:
// article48/common/common.go
package common
import (
"errors"
"fmt"
"time"
)
// 代表包含高负载操作的函数
type OpFunc func() error
func Execute(op OpFunc, times int) (err error) {
if op == nil {
return errors.New("操作函数为nil")
}
if times <= 0 {
return fmt.Errorf("执行次数不可用: %d", times)
}
var startTime time.Time
defer func() {
diff := time.Now().Sub(startTime)
fmt.Printf("执行持续时间: %s\n", diff)
if p := recover(); p != nil {
err = fmt.Errorf("fatal error: %v", p)
}
}()
startTime = time.Now()
for i := 0; i < times; i++ {
if err = op(); err != nil {
return
}
time.Sleep(time.Microsecond)
}
return
}
这个函数是要准备复用的。之后还会进行内存概要和阻塞概要的测试,也会有对应的测试代码。不过函数的签名都将是一样的:type OpFunc func() error
。
上面已经有了完整的被测试函数,以及包装被测试函数的函数。这里把之前不完整的采样测试的代码再补充完整:
package main
import (
"Go36/article48/common"
"Go36/article48/common/op"
"errors"
"fmt"
"io"
"os"
"runtime/pprof"
)
func main() {
// 打开文件,准备写入
filename := "cpuprofile.out"
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Create File Error: %v", err)
return
}
defer f.Close()
// 进行采样
if err := startCPUProfile(f); err != nil {
fmt.Fprintf(os.Stderr, "CPU profile start error: %v\n", err)
return
}
// 被测试的函数
if err := common.Execute(op.CPUProfile, 10); err != nil {
fmt.Fprintf(os.Stderr, "execute error: %v\n", err)
return
}
// 停止采样
stopCPUProfile()
}
func startCPUProfile(w io.Writer) error {
if w == nil {
return errors.New("nil File")
}
return pprof.StartCPUProfile(w)
}
func stopCPUProfile() {
pprof.StopCPUProfile()
}
现在可以执行上面的程序,生成性能分析报告:
PS H:\Go\src\Go36\article48\example01> go run main.go
执行持续时间: 8.3462144s
PS H:\Go\src\Go36\article48\example01>
执行后会生成一个二进制文件,需要用go tool pprof来查看
PS H:\Go\src\Go36\article48\example01> go tool pprof cpuprofile.out
Type: cpu
Time: Feb 12, 2019 at 7:33pm (CST)
Duration: 8.45s, Total samples = 8.50s (100.59%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
针对内存概要信息的采样会按照一点比例收集Go程序在运行期间的堆内存使用情况。
设定内存概要信息采样频率的方法很简单,只要为runtime.MemProfileRate变量赋值即可。
这个变量的含义是,平均每分配多少个字节,就对堆内存的使用情况进行一次采样。如果把该变量的值设为0,那么,Go语言运行时系统就会完全停止对内存概要信息的采样。该变量的缺省值是512KB,即512千字节。如果要设定这个采样频率,就要越早越好,并且只应该设定一次,否则就可能会对采集工作造成不良影响。比如,只在main函数的开始处设定一次。
之后,要获取内存概要信息,还需要调用WriteHeapProfile函数。该函数会把收集好的内存概要信息写到指定的写入器中。通过WriteHeapProfile函数得到的内存概要信息并不是实时的,它是一个快照,是在最近一次的内存垃圾收集工作完成时产生的。如果想要实时的信息,那么可以调用runtime.ReadMemStats函数。不过要特别注意,该函数会引起Go语言调度器的短暂停顿。
复用之前的common程序,这里需要一个会分配很多内存的测试代码:
// article48/common/op/cpu.go
package op
import (
"bytes"
"encoding/json"
"math/rand"
)
// box 代表数据盒子。
type box struct {
Str string
Code rune
Bytes []byte
}
func MemProfile() error {
max := 50000
var buf bytes.Buffer
for j := 0; j < max; j++ {
seed := rand.Intn(95) + 32
one := createBox(seed)
b, err := genJSON(one)
if err != nil {
return err
}
buf.Write(b)
buf.WriteByte('\t')
}
_ = buf.String()
return nil
}
func createBox(seed int) box {
if seed <= 0 {
seed = 1
}
var array []byte
size := seed * 8
for i := 0; i < size; i++ {
array = append(array, byte(seed))
}
return box{
Str: string(seed),
Code: rune(seed),
Bytes: array,
}
}
func genJSON(one box) ([]byte, error) {
return json.Marshal(one)
}
用下面的示例来运行这个测试:
package main
import (
"errors"
"fmt"
"os"
"Go36/article48/common"
"Go36/article48/common/op"
"runtime"
"runtime/pprof"
)
var memProfileRate = 8
func main() {
filename := "memprofile.out"
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Create File Error: %v", err)
return
}
defer f.Close()
startMemProfile()
if err := common.Execute(op.MemProfile, 10); err != nil {
fmt.Fprintf(os.Stderr, "execute error: %v\n", err)
return
}
if err := stopMemProfile(f); err != nil {
fmt.Fprintf(os.Stderr, "memory profile stop error: %v\n", err)
return
}
}
func startMemProfile() {
runtime.MemProfileRate = memProfileRate
}
func stopMemProfile(f *os.File) error {
if f == nil {
return errors.New("nil file")
}
return pprof.WriteHeapProfile(f)
}
调用SetBlockProfileRate函数,即可对阻塞概要信息的采样频率进行设定。
SetBlockProfileRate函数的参数rate是int类型。这个参数的含义是,只要发现一个阻塞事件的持续时间达到了rate纳秒,就可以对其进行采样。如果这个参数的值小于或等于0,就会完全停止对阻塞概要信息的采样。
另外还有一个blockprofilerate的包级私有变量uint64类型。这个变量的含义是,只要发现一个阻塞事件的持续时间跨越了多少个CPU时钟周期,就可以对其进行采样。这个变量的值是自动的通过rate参数来进行设置的。
这两个变量的区别仅仅是单位不同。SetBlockProfileRate函数会先对参数的rate值进行单位换算和必要的类型转换,然后,把换算的结果用原子操作赋值给blockprofilerate变量。由于此变量的缺省值是0,所以默认情况下不记录任何阻塞事件。
在需要获取阻塞概要信息的时候,要先调用Lookup函数,函数源码如下:
func Lookup(name string) *Profile {
lockProfiles()
defer unlockProfiles()
return profiles.m[name]
}
这个函数下面会再详细讲,目前只要传入"block"作为参数值。这里的"block"代表因争用同步原语而被阻塞的那些代码的堆栈跟踪信息,就是阻塞概要信息。该函数调用后会得到一个*Profile类型的值,就是Profile值。在这之后还需要调用这个Profile值的WriteTo方法,以驱使它把概要信息写进指定的写入器中。
这个WriteTo方法有两个参数,源码比较长,截取签名的部分:
func (p *Profile) WriteTo(w io.Writer, debug int) error {
// 省略程序实体
}
第一个参数是写入器,而第二个参数是代表概要信息详细程度的int类型参数debug。debug参数的可选值有三个,0、1或2:
用下面的函数来测试阻塞:
package op
import (
"math/rand"
"sync"
"time"
)
func BlockProfile() error {
max := 100
senderNum := max / 2
receiverNum := max / 4
ch2 := make(chan int, max/4)
var senderGroup sync.WaitGroup
senderGroup.Add(senderNum)
repeat := 50000
for j := 0; j < senderNum; j++ {
go send(ch2, &senderGroup, repeat)
}
go func() {
senderGroup.Wait()
close(ch2)
}()
var receiverGroup sync.WaitGroup
receiverGroup.Add(receiverNum)
for j := 0; j < receiverNum; j++ {
go receive(ch2, &receiverGroup)
}
receiverGroup.Wait()
return nil
}
func send(ch2 chan int, wg *sync.WaitGroup, repeat int) {
defer wg.Done()
time.Sleep(time.Millisecond * 10)
for k := 0; k < repeat; k++ {
elem := rand.Intn(repeat)
ch2 <- elem
}
}
func receive(ch2 chan int, wg *sync.WaitGroup) {
defer wg.Done()
for elem := range ch2 {
_ = elem
}
}
运行下面的示例中的代码,可以生成阻塞概要文件:
package main
import (
"errors"
"fmt"
"os"
"Go36/article48/common"
"Go36/article48/common/op"
"runtime"
"runtime/pprof"
)
var (
blockProfileRate = 2
debug = 0
)
func main() {
filename := "blockprofile.out"
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Create File Error: %v", err)
return
}
defer f.Close()
startBlockProfile()
if err := common.Execute(op.BlockProfile, 10); err != nil {
fmt.Fprintf(os.Stderr, "execute error: %v\n", err)
return
}
if err := stopBlockProfile(f); err != nil {
fmt.Fprintf(os.Stderr, "block profile error: %v\n", err)
return
}
}
func startBlockProfile() {
runtime.SetBlockProfileRate(blockProfileRate)
}
func stopBlockProfile(f *os.File) error {
if f == nil {
return errors.New("nil file")
}
return pprof.Lookup("block").WriteTo(f, debug)
}
这里讨论debug为2时的情况,此时就要根据Lookup函数的参数值来决定输出的细节内容了。
Lookup函数的功能是,提供与给定的名称相对应的概要信息。这个概要信息会由一个Profile值代表。如果该函数返回一个nil,那么就说明不存在与给定名称对应的概要信息。runtime/pprof包已经预先定义了6个概要名称。它们对应的概要信息收集方法和输出方法也都已经准备好了。这里直接拿来使用就可以了,把预定义好的名称传给name参数。具体是下面这些:
// goroutine - stack traces of all current goroutines
// heap - a sampling of memory allocations of live objects
// allocs - a sampling of all past memory allocations
// threadcreate - stack traces that led to the creation of new OS threads
// block - stack traces that led to blocking on synchronization primitives
// mutex - stack traces of holders of contended mutexes
收集当前正在使用的所有goroutine的堆栈跟踪信息。注意,这样的收集会引起Go语言调度器的短暂停顿。
调用该函数返回的Profile值的WriteTo方法时,如果参数debug的值大于或等于2,那么该方法就会输出所有goroutine的堆栈跟踪信息。这些信息可能会非常多。如果它们占用的空间超过了64M,那么相应的方法就会将超出的部分截掉。
收集与堆内存的分配和释放有关的采样信息。实际就是之前讨论的内存概要信息。
Lookup函数返回的Profile值的WriteTo方法被调用时,输出的内存概要信息默认以“在用空间”(inuse_space)的视角呈现。
在用空间,指已经被分配但还未被释放的内存空间。在这个视角下,go tool pprof工具并不会去理会已释放空间有关的那部分信息。
和上面的heap非常相似,也是收集与堆内存的分配和释放有关的采样信息,就是内存概要信息。
Lookup函数返回的Profile值的WriteTo方法被调用时,输出的内存概要信息默认以“已分配空间”(alloc_space)的视角呈现。
已分配空间,是所有的内存分配信息都会被呈现出来,无论这些内存空间在采样时是否已经被释放。
与heap的差别
差别只是debug参数为0时,WriteTo方法输出的概要信息会有细微的差别。如果debug大于0,那么输出的内容是完全相同的。
收集堆栈跟踪信息时,这些堆栈跟踪信息中的每一个都会描绘出一个代码调用链,这些调用链上的代码都导致新的操作系统线程产生。这样的Profile值的输出规格只有两种,取决于WriteTo方法的debug参数是否大于0。
是因争用同步原语而被阻塞的那些代码的堆栈跟踪信息。就是之前讨论的阻塞概要信息。这里输出规格只有两种,取决于debug是否大于0。
是曾经作为同步原语持有者的那些代码,它们的堆栈跟踪信息。输出规格也只有两种,取决于debug是否大于0。
同步原语
这里所说的同步原语,指的是存在于Go语言运行时系统内部的一种底层的同步工具,或者说一种同步机制。它是直接面向内存地址的,并以异步信号量和原子操作作为实现手段。通道、互斥锁、条件变量、WatiGroup,以及Go语言运行时系统本身,都会利用它来实现自己的功能。
在之前的测试代码的基础上,下面分别调用Lookup函数的每一个参数并且分别在debug是0、1、2时各执行了一次,生成了所有可能的概要信息的文件:
package main
import (
"Go36/article48/common"
"Go36/article48/common/op"
"fmt"
"os"
"runtime"
"runtime/pprof"
"time"
)
// profileNames 代表概要信息名称的列表。
var profileNames = []string{
"goroutine",
"heap",
"allocs",
"threadcreate",
"block",
"mutex",
}
// profileOps 代表为了生成不同的概要信息而准备的负载函数的字典。
var profileOps = map[string]common.OpFunc{
"goroutine": op.BlockProfile,
"heap": op.MemProfile,
"allocs": op.MemProfile,
"threadcreate": op.BlockProfile,
"block": op.BlockProfile,
"mutex": op.BlockProfile,
}
// debugOpts 代表debug参数的可选值列表。
var debugOpts = []int{
0,
1,
2,
}
func main() {
prepare()
for _, name := range profileNames {
for _, debug := range debugOpts {
err := genProfile(name, debug)
if err != nil {
return
}
time.Sleep(time.Millisecond)
}
}
}
func genProfile(name string, debug int) error {
fmt.Printf("Generate %s profile (debug: %d) ...\n", name, debug)
filename := fmt.Sprintf("%s_%d.out", name, debug)
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Create File Error: %v", err)
return err
}
defer f.Close()
if err = common.Execute(profileOps[name], 10); err != nil {
fmt.Fprintf(os.Stderr, "execute error: %v (%s)\n", err, filename)
return err
}
profile := pprof.Lookup(name)
err = profile.WriteTo(f, debug)
if err != nil {
fmt.Fprintf(os.Stderr, "write error: %v (%s)\n", err, filename)
return err
}
return nil
}
func prepare() {
runtime.MemProfileRate = 8
runtime.SetBlockProfileRate(2)
}
针对上层的应用,为基与HTTP协议的网络服务,添加性能分析接口。
这里做的是为之前的性能分析提供Web的浏览接口。上面生成的性能分析报告需要通过文件浏览器访问文本内容。通过这里的Web接口,则直接开启一个Web服务,直接用浏览器访问来浏览各种性能分析报告。
在一般情况下只要在程序中导入net/http/pprof包就可以了:
import _ "net/http/pprof"
然后启动网络服务并开始监听:
log.Println(http.ListenAndServe("localhost:8082", nil))
在运行这个程序之后,就可以在浏览器中访问下面的地址:
http://localhost:8082/debug/pprof
访问后会得到一个简约的网页。点击不同的连接,可以看到各种概要信息,这里自动就生成所有种类的概要信息了。
debug参数
每个子路径点进去就会看到这个种类的概要信息。这里url还有一个debug参数,这就是之前所讲的WriteTo方法里的debug参数。默认点进去都是1,可以改成别的参数。如果是2就是详细信息。如果是0就是二进制信息,这时是无法浏览的,而是会触发下载。
gc参数
另外还可以给url传一个gc参数,效果是控制是否在获取概要信息之前强制执行一次垃圾回收。只要它的值大于0,程序就会这样做。不过,这个参数仅对heap有效,就是仅在/debug/pprof/heap路径下有效。
一旦/debug/pprof/profile路径被访问,程序就会去执行对CPU概要信息的采样。它接受一个seconds的查询参数,就是采样工作需要持续多少秒。如果参数未被显式指定,那么采样工作会持续30秒。所以一旦点下该连接,就会卡住,直到完成采样。
另外,这里只会响应经protocol buffers转换的字节流,所以采样完成后,会触发下载。另外还可以通过go tool pprof工具直接读取这样的HTTP响应:
go tool pprof http://localhost:8082/debug/pprof/profile?seconds=60
这个Web页面还有一个路径,/debug/pprof/trace。在这个路径下,程序主要会利用runtime/trace包中的API来处理请求。
程序会先调用trace.Start函数,然后在查询参数seconds指定的持续时间之后再调用trace.Stop函数。这里的seconds的缺省值是1秒。而runtime/trace包的功用并没有展开。
还可以定制URL,下面是一个定制的示例:
package main
import (
"log"
"net/http"
"net/http/pprof"
"strings"
)
func main() {
mux := http.NewServeMux()
pathPrefix := "/d/pprof/"
mux.HandleFunc(pathPrefix,
func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, pathPrefix)
if name != "" {
pprof.Handler(name).ServeHTTP(w, r)
return
}
pprof.Index(w, r)
})
mux.HandleFunc(pathPrefix+"cmdline", pprof.Cmdline)
mux.HandleFunc(pathPrefix+"profile", pprof.Profile)
mux.HandleFunc(pathPrefix+"symbol", pprof.Symbol)
mux.HandleFunc(pathPrefix+"trace", pprof.Trace)
server := http.Server{
Addr: "localhost:8083",
Handler: mux,
}
if err := server.ListenAndServe(); err != nil {
if err == http.ErrServerClosed {
log.Println("HTTP server closed.")
} else {
log.Printf("HTTP server error: %v\n", err)
}
}
}
在这里例子中,定制mux的代码与包中的init函数很类型。默认的路径就是在init函数里实现的。并且之前直接用占位符导入net/http/pprof包的时候,就是执行这个init函数而生成了默认的访问路径。
在这里,使用net/http/pprof包要比直接使用runtime/pprof包方便和实用很多。通过合理运用,这个代码包可以为网络服务的监测提供有力的支撑。
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。