GoLang string类型深入分析

 更新时间:2023年01月28日 14:00:28   作者:i-neojos  
string 作为 go 语言中的基础类型,其实有一些需要反复揣摩的,可能是我们使用的场景太简单,也可能是我们不需要那可怜的一点优化来提高性能,对它也就没那么上心了

文章运行环境:go version go1.16.6 darwin/amd64

并发不安全

看下面的代码,大家觉得会输出什么?大多数人应该都会觉得输出""、abc、neoj 这三种情况,但真实的情况并不是这样,真实情况是只输出 “” 空字符串。

结合日常的工作,类似这种并发操作同一个变量的情况也比较常见,为什么业务没有发生异常问题?

var name string = ""
func main() {
	go func() {
		for {
			name = "abc"
		}
	}()
	go func() {
		for {
			name = "neoj"
		}
	}()
	for {
		fmt.Println(name)
	}
}

1.14 之后引入了 G 抢占式调度,那为什么代码中的两个协程没有执行呢?其实是编译器做了优化,这两个协程被省略掉了。

我们对代码做一点调整,在协程中加一行空的输出,输出结果中出现了一些特例,比如:neo、abca。其中,neo 字符串长度等于 abc 的长度,而 abca 的长度等于 neoj 的长度。

var name string = ""
func main() {
	go func() {
		for {
			name = "abc"
			fmt.Printf("")
		}
	}()
	go func() {
		for {
			name = "neoj"
			fmt.Printf("")
		}
	}()
	for {
		if name != "abc" && name != "neoj" {
			fmt.Println(name)
		}
	}
}

例子说明,string 的赋值并不是原子的。

Go 语言中 string 的内存结果如下,它包含两部分:Data 表示实际的数据部分,而 Len 表示字符串的长度。

所以,通过方法 len 来计算字符串的长度并不会有性能开销,len 方法会直接返回结构体的 Len 属性;而传递字符串类型的参数,使用指针类型和值类型,性能上也不会有太大差别。

type StringHeader struct {
	Data uintptr
	Len  int
}

字符串的并发不安全,主要就是给这两个字段的赋值,没有办法保证原子性。参考 runtime/string.go 中的源码,我们可以了解字符串生成过程。

并发赋值的情况下,Data 指向的地址和 Len 无法保证一一对应。所以,通过 Data 获取到内存的首地址,通过 Len 去读取指定长度的内存时,就会出现内存读取异常的情况。

func rawstring(size int) (s string, b []byte) {
	p := mallocgc(uintptr(size), nil, false)
	stringStructOf(&s).str = p
	stringStructOf(&s).len = size
	*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}
	return
}

rawstring 函数在字符串拼接的时候被调用,我们代码中创建一个字符串类型,每次都生成一份新的内存空间。特别强调,创建和字符串赋值需要区分开来。赋值的过程其实是值拷贝,拷贝的便是 StringHeader 结构体。

var name string = ""
func main() {
	blog := name
	fmt.Println(blog)
}

上面的变量 blog 是 name 的值拷贝,底层指向的字符串是同一块内存空间。这个赋值过程中,发生拷贝的只是外层的 StringHeader 对象。

Go 中通过 unsafe 包可以强制对内存数据做类型转换,我们将 blog 和 name 的内存地址打印出来比较一下。最终打印输出两个变量的地址和Data地址。可以看出,赋值前后,Data指向的地址并没有发生变化。

type StringHeader struct {
	Data uintptr
	Len  int
}
var name string = "g"
func main() {
	blog := name
	n := (*StringHeader)(unsafe.Pointer(&name))
	b := (*StringHeader)(unsafe.Pointer(&blog))
	fmt.Println(&n, n.Data)    // 0xc00018a020 17594869
	fmt.Println(&b, b.Data)    // 0xc00018a028 17594869
}

string 并发不安全读写,会导致线上服务偶发 panic。比如使用 json 对内存异常的 string 做序列化的时候。下面的例子中,其中一个协程用来赋值为空,非常容易复现 panic。

type People struct {
	Name string
}
var p *People = new(People)
func main() {
	go func() {
		for {
			p.Name = ""
		}
	}()
	go func() {
		for {
			p.Name = "neoj"
		}
	}()
	for {
		_, _ = json.Marshal(p)
	}
}

下面是 panic 的堆栈信息,空字符串的 Data 指向的是 nil 的地址,而并发导致 Len 字段有值,最终导致发生 panic。

竞态竞争

对同一个变量并发读写,如果没有使用辅助的同步操作,就会出现不符合预期的情况。直白的讲,我们开发完一个程序之后,针对同样的输入,会输出什么结果,我们是不确定的。

可以参考 The Go Memory Model 的介绍,强调一下数据竞争的概念:

A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package

幸运的是,Go 已经集成了现成的工具来诊断数据竞争:-race。在 go build、或者直接执行的时候,指定 -race 属性,系统会做数据竞争检测,并打印输出。

以最近的代码为例,如果你使用的也是 goland 编译器,只需要在 Run Configurations / Go tool arguments 中指定 -race 属性,运行程序,就会出现下面的检测结果:

面对生产环境,-race 有比较严重的性能开销,我们最好是开发环境做竞态检测。

-race 是通过编译器注入代码来执行检测的,在函数执行前、执行后都会做内存统计。也就是说:只有被执行到的代码才能被检测到。所以,如果开发阶段做竞态检测的话,一定要保证代码被执行到了。

再加上埋点的内存统计也是有策略的,也不可能保证存在数据竞争的代码就一定会被检测出来,最好可以多执行几次来避免这种情况。

字符串优化

因字符串并发读写导致的 panic,很容易被 Go 的字符串优化带偏。

我在第一次遇到这种情况的时候,想到的居然是:会不会是底层优化导致的。因为发生 panic 的代码用到了 map 的数据结构。这种想法很快被我用测试用例排除了。

[]byte 到 string 类型转换是比较常规的操作,正常情况下,转换都会申请了一份新的内存空间。但 Go 为了提高性能,在某些场景下 string 和 []byte 会共用一份内存空间,这种场景下也能写乱内存。

// slicebytetostringtmp returns a "string" referring to the actual []byte bytes.
//
func slicebytetostringtmp(ptr *byte, n int) (str string) {
	if raceenabled && n > 0 {
		racereadrangepc(unsafe.Pointer(ptr),
			uintptr(n),
			getcallerpc(),
			funcPC(slicebytetostringtmp))
	}
	if msanenabled && n > 0 {
		msanread(unsafe.Pointer(ptr), uintptr(n))
	}
	stringStructOf(&str).str = unsafe.Pointer(ptr)
	stringStructOf(&str).len = n
	return
}

程序中出现问题,还是要先充分审查自己开发的代码

到此这篇关于GoLang string类型深入分析的文章就介绍到这了,更多相关Go string内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Go语言中init函数与匿名函数使用浅析

    Go语言中init函数与匿名函数使用浅析

    这篇文章主要介绍了Go语言中init函数与匿名函数使用浅析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习吧
    2023-01-01
  • Golang 处理浮点数遇到的精度问题(使用decimal)

    Golang 处理浮点数遇到的精度问题(使用decimal)

    本文主要介绍了Golang 处理浮点数遇到的精度问题,不使用decimal会出大问题,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-02-02
  • golang flag简单用法

    golang flag简单用法

    本篇文章介绍了golang flag包的一个简单的用法,希望通过一个简单的实例,能让大家了解它的用法,从中获得启发
    2018-09-09
  • Go panic的三种产生方式细节探究

    Go panic的三种产生方式细节探究

    这篇文章主要介绍了Go panic的三种产生方式细节探究,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-12-12
  • 图文详解go语言反射实现原理

    图文详解go语言反射实现原理

    这篇文章主要介绍了图文详解go语言反射实现原理,本文图文并茂给大家介绍的非常详细,具有一定的参考借鉴价值,需要的朋友参考下吧,需要的朋友可以参考下
    2020-02-02
  • Go1.18新特性之泛型使用三步曲(小结)

    Go1.18新特性之泛型使用三步曲(小结)

    本文主要介绍了Go1.18新特性之泛型,是Go1.18的新特性,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-04-04
  • Golang中Channel实战技巧与一些说明

    Golang中Channel实战技巧与一些说明

    channel是Go语言内建的first-class类型,也是Go语言与众不同的特性之一,下面这篇文章主要给大家介绍了关于Golang中Channel实战技巧与一些说明的相关资料,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2022-11-11
  • Golang如何自定义logrus日志保存为日志文件

    Golang如何自定义logrus日志保存为日志文件

    这篇文章主要给大家介绍了关于Golang如何自定义logrus日志保存为日志文件的相关资料,logrus是目前Github上star数量最多的日志库,logrus功能强大,性能高效,而且具有高度灵活性,提供了自定义插件的功能,很多开源项目都是用了logrus来记录其日志,需要的朋友可以参考下
    2024-02-02
  • 使用Go语言实现心跳机制

    使用Go语言实现心跳机制

    心跳最典型的应用场景是是探测服务是否存活,这篇文章主要来和大家介绍一下如何使用Go语言实现一个简单的心跳程序,感兴趣的可以了解下
    2024-01-01
  • Go中阻塞以及非阻塞操作实现(Goroutine和main Goroutine)

    Go中阻塞以及非阻塞操作实现(Goroutine和main Goroutine)

    本文主要介绍了Go中阻塞以及非阻塞操作实现(Goroutine和main Goroutine),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2024-05-05

最新评论