【由浅入深】2022版
Golang题目总结 详细原理
go试题总结
tag可以为结构体成员提供属性。常见的:
详细参考博客:
Go语言数据库操作之sqlx库使用
Go系列:结构体标签
协程泄漏是指协程创建之后没有得到释放。主要原因有:
其中 WaitGroup用法说明如下:
正常情况下,新激活的goroutine的结束过程是不可控制的,唯一可以保证终止goroutine的行为是main goroutine的终止。也就是说,我们并不知道哪个goroutine什么时候结束。
但很多情况下,我们正需要知道goroutine是否完成。这需要借助sync包的WaitGroup来实现。
WatiGroup是sync包中的一个struct类型,用来收集需要等待执行完成的goroutine。下面是它的定义:
type WaitGroup struct {
// Has unexported fields.
}
A WaitGroup waits for a collection of goroutines to finish. The main
goroutine calls Add to set the number of goroutines to wait for. Then each
of the goroutines runs and calls Done when finished. At the same time, Wait
can be used to block until all goroutines have finished.
A WaitGroup must not be copied after first use.
func (wg *WaitGroup) Add(delta int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()
它有3个方法:
也就是说,Add()用来增加要等待的goroutine的数量,Done()用来表示goroutine已经完成了,减少一次计数器,Wait()用来等待所有需要等待的goroutine完成。
下面是一个示例,通过示例很容易理解:
package main
import (
"fmt"
"sync"
"time"
)
func process(i int, wg *sync.WaitGroup) {
fmt.Println("started Goroutine ", i)
time.Sleep(2 * time.Second)
fmt.Printf("Goroutine %d ended\n", i)
wg.Done()
}
func main() {
no := 3
var wg sync.WaitGroup
for i := 0; i < no; i++ {
wg.Add(1)
go process(i, &wg)
}
wg.Wait()
fmt.Println("All go routines finished executing")
}
上面激活了3个goroutine,每次激活goroutine之前,都先调用Add()方法增加一个需要等待的goroutine计数。
每个goroutine都运行process()函数,这个函数在执行完成时需要调用Done()方法来表示goroutine的结束。
激活3个goroutine后,main goroutine会执行到Wait(),由于每个激活的goroutine运行的process()都需要睡眠2秒,所以main goroutine在Wait()这里会阻塞一段时间(大约2秒),当所有goroutine都完成后,等待计数器减为0,Wait()将不再阻塞,于是main goroutine得以执行后面的Println()。
还有一点需要特别注意的是process()中使用指针类型的*sync.WaitGroup作为参数,这里不能使用值类型的sync.WaitGroup作为参数,因为这意味着每个goroutine都拷贝一份wg,每个goroutine都使用自己的wg。这显然是不合理的,这3个goroutine应该共享一个wg,才能知道这3个goroutine都完成了。实际上,如果使用值类型的参数,main goroutine将会永久阻塞而导致产生死锁。
参考博客:Go基础系列:WaitGroup用法说明
golang内存管理基本是参考tcmalloc来进行的。go内存管理本质上是一个内存池,只不过内部做了很多优化:自动伸缩内存池大小,合理的切割内存块。
页Page:一块8K大小的内存空间。Go向操作系统申请和释放内存都是以页为单位的。
span : 内存块,一个或多个连续的 page 组成一个 span 。如果把 page 比喻成工人, span 可看成是小队,工人被分成若干个队伍,不同的队伍干不同的活。
Tcmalloc使用一种叫span的东东来管理内存分页,一个span可以包含几个连续分页。一个span的状态只有未分配(这时候在空闲链表中),作为大对象分配,或作为小对象分配(这时候span内记录了小对象的class size)。
sizeclass : 空间规格,每个 span 都带有一个 sizeclass ,标记着该 span 中的 page 应该如何使用。使用上面的比喻,就是 sizeclass 标志着 span 是一个什么样的队伍。
object : 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object 。假设 object 的大小是 16B , span 大小是 8K ,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object 。所谓内存分配,就是分配一个 object 出去。
mheap.spans :用来存储 page 和 span 信息,比如一个 span 的起始地址是多少,有几个 page,已使用了多大等等。
mheap.bitmap 存储着各个 span 中对象的标记信息,比如对象是否可回收等等。
mheap.arena_start : 将要分配给应用程序使用的空间。
mcentral
用途相同的span会以链表的形式组织在一起存放在mcentral中。这里用途用sizeclass来表示,就是该span存储哪种大小的对象。
找到合适的 span 后,会从中取一个 object 返回给上层使用。
mcache
为了提高内存并发申请效率,加入缓存层mcache。每一个mcache和处理器P对应。Go申请内存首先从P的mcache中分配,如果没有可用的span再从mcentral中获取
参考博客: Go 语言内存管理(二):Go 内存管理
全名是 thread cache malloc(线程缓存分配器)其内存管理分为线程内存和中央堆两部分。
小内存: 线程缓存队列 -> 中央堆 -> 中央页分配器(从系统分配)
大内存: 中央堆 -> 向系统请求
应该尽量减少大内存的分配和释放。
尽量先分配、后释放。
goroutine在go代码中无处不在,go程序会根据不同的情况去调度不同的goroutine,一个goroutine在某个时刻要么在运行,要么在等待,或者死亡。
goroutine的切换一般会在以下几种情况下发生:
参考博客: go高级进阶:goroutine的创建、休眠与恢复
ticker := time.NewTicker(5 * time.Second)
c := make(chan int, 5)
for i := 0; i < 5; i ++ {
go func(i int) {
tmp := rand.Intn(10)
println(tmp)
time.Sleep(time.Duration(tmp) * time.Second)
//tmp秒之后向 c 通道里发送一个数据
fmt.Println("I want to sleep ", tmp, "seconds!")
c <- i
}(i)
}
for {
select {
case i := <- c:
fmt.Printf("The %d goroutine is done.\n", i)
case <- ticker.C: // 通过 ticker.C 来得到定时器是否到时间
fmt.Println("Time to go out!")
os.Exit(5)
}
}
Go1.8采用三色标记法+混合写屏障
将对象标记为白色,灰色或黑色。
白色:不确定对象(默认色);黑色:存活对象。灰色:存活对象,子对象待处理。
标记开始时,先将所有对象加入白色集合(需要STW)。
首先将根对象标记为灰色,然后将一个对象从灰色集合取出,遍历其子对象,放入灰色集合。
同时将取出的对象放入黑色集合,直到灰色集合为空。
最后的白色集合对象就是需要清理的对象。
1. 第一步:在进入GC的三色标记阶段的一开始,所有对象都是白色的。
2. 第二步, 遍历根节点集合里的所有根对象,把根对象引用的对象标记为灰色,从白色集合放入灰色集合。
3. 第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
4. 第四步:重复第三步, 直到灰色集合中无任何对象。
5. 第五步:回收白色集合里的所有对象,本次垃圾回收结束。
这里所说的根节点集合里的根对象就是栈上的对象或者堆上的全局变量。
插入屏障:A指向B,将B标记为灰色
缺点:由于栈指针没有写屏障,栈指针可能会指向白对象,因此标记完需要STW,扫描栈区所有指针重新标记
删除屏障: A重新指向别的对象,A标记为灰色;开始时将整个栈置黑,次一级(堆对象)置灰
缺点:开始时需要STW,扫描所有goroutine栈,对所有根对象置黑,次一级对象置灰(次一级对象其实就是所有堆对象),这样做可以避免根对象持有白色指针,且所有堆对象都在灰色对象的保护下
B可能不会被清除,只能等待下一次gc;
A重新被置为灰,造成冗余扫描(波面回退)
上面的方法有一个缺陷,如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色。
一次完整的GC分为四个阶段:
基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步
总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从 2s降低到2us。
参考博客: 图解GC算法和垃圾回收原理
我们知道,字符串由字符组成,字符的底层由字节组成,而一个字符串在底层的表示是一个字节序列。在 Go 语言中,字符可以被分成两种类型处理:对占 1 个字节的英文类字符,可以使用 byte(或者 unit8 );对占 1 ~ 4 个字节的其他字符,可以使用 rune(或者 int32 ),如中文、特殊符号等。
下面,我们通过示例应用来具体感受一下。
// 使用内置函数 len() 统计字符串长度
fmt.Println(len("Go语言编程")) // 输出:14
前面说到,字符串在底层的表示是一个字节序列。其中,英文字符占用 1 字节,中文字符占用 3 字节,所以得到的长度 14 显然是底层占用字节长度,而不是字符串长度,这时,便需要用到 rune 类型。
// 转换成 rune 数组后统计字符串长度
fmt.Println(len([]rune("Go语言编程"))) // 输出:6
如果想要截取字符串中 ”Go语言“ 这一段,考虑到底层是一个字节序列,或者说是一个数组,通常情况下,我们会这样:
s := "Go语言编程"
// 8=2*1+2*3
fmt.Println(s[0:8]) // 输出:Go语言
结果符合预期。但是,按照字节的方式进行截取,必须预先计算出需要截取字符串的字节数,如果字节数计算错误,就会显示乱码,比如这样:
s := "Go语言编程"
fmt.Println(s[0:7]) // 输出:Go语�
此外,如果截取的字符串较长,那通过字节的方式进行截取显然不是一个高效准确的办法。那有没有不用计算字节数,简单又不会出现乱码的方法呢?不妨试试这样:
s := "Go语言编程"
// 转成 rune 数组,需要几个字符,取几个字符
fmt.Println(string([]rune(s)[:4])) // 输出:Go语言
参考博客:详解 Go 中的 rune 类型
Go 语言不支持可选参数(python 支持),也不支持方法重载(java支持)。
空结构体的内存地址都一样,并且不占用内存空间
struct{} 和 struct{}{}
type User struct {
Name string
Age int
}
而struct {} 是一个无元素的结构体类型,通常在没有信息存储时使用。优点是大小为0,不需要内存来存储struct {} 类型的值。
struct {} {} 是一个复合字面量,它构造了一个struct {} 类型的值,该值也是空。
var set map[string]struct{}
set = make(map[string]struct{})
set["red"] = struct{}{} // struct{}{} 构造了一个struct {}类型的值
set["blue"] = struct{}{}
_, ok := set["red"]
fmt.Println("Is red in the map?", ok)
_, ok = set["green"]
fmt.Println("Is green in the map?", ok)
输出内容:
Is red in the map? true
Is green in the map? false
map可以通过“comma ok”机制来获取该key是否存在,
_, ok := map[“key”],
如果没有对应的值,ok为false,这样可以通过定义成map[string]struct{}的形式,值不再占用内存。其值仅有两种状态,有或无
func sum(a int, b int) *int {
var c = a + b
return &c
}
这段代码表示接收两个整型,然后相加用变量存储起来、返回变量的指针。
这段代码在C程序员的眼中肯定是有问题的,但是在go语言中这是完全正常的代码。
因为这个变量c是分配在堆上的,如果我们返回的不是c的指针,而是c,那么c这个变量就会分配在栈上面,所以我们看到一个变量究竟分配在什么地方,go编译器会帮我们进行检测。
如果返回一个值的话(golang是值拷贝),那么原来的变量c对应的内存就会被回收,如果返回的是指针(&c),那么c对应的内存就不会被回收,因为go编译器知道要是回收了,那么返回的指针就获取不到指向的值了,于是就会把c分配到堆上。
那么编译器是如何检测的呢,答案是通过逃逸分析。
逃逸分析的标准:
首先可以肯定的是,如果函数里面的变量返回了一个地址,那么这个变量肯定会发生逃逸。
go编译器会判断变量的生命周期,如果编译器认为函数结束后,这个变量不再被外部的引用了,会分配到栈,否则分配到堆。
package main
import "fmt"
func sum(a int, b int) *int {
var c = a + b
var d = 1
var e = new(int)
fmt.Println(&d)
fmt.Println(&e)
return &c
}
//比如这里的变量d, 尽管通过&d获取了它的地址, 但是这仅仅是打印。
//而e虽然调用了new方法, 但这并不能成为分配到堆区的理由
//因为d和e并没有被外部引用, 所以不好意思, sum函数执行结束, 这两位老铁必须"见上帝"
//但是对于c, 我们返回了它的指针, 既然返回了指针, 那么就代表这个变量对应的内存可以被外部访问, 所以会逃逸到堆
两个结论:
逃逸分析演示
import "fmt"
func sum(a int, b int) *int {
var c = a + b
return &c
}
func main() {
var p = sum(1, 2)
fmt.Println(p)
}
通过命令go build -gcflags “-m -l” xxx.go观察golang是如何进行逃逸分析的:
# command-line-arguments
.\1.go:7:9: &c escapes to heap
.\1.go:6:6: moved to heap: c
.\1.go:12:13: p escapes to heap
.\1.go:12:13: main ... argument does not escape
我们看到变量c发生了逃逸,这和我们想的一样,但是为什么main函数里面的p居然也逃逸了。因为编译期间不确定变量类型的话,那么也会发生逃逸
内存回收
go内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。
小结
堆上动态内存分配的开销比栈要大很多,所以有时我们传递值比传递指针更有效率。因为复制是栈上完成的操作,开销要比变量逃逸到堆上再分配内存要少的多
参考博客: Go 语言中的变量究竟是分配在栈上、还是分配在堆上?逃逸分析告诉你答案
不可以。它们处于不同的调度器P中。对于子goroutine,正确的做法是:
Golang的错误处理机制 defer recover()
当程序出现异常时,会抛出一个panic来终止程序,如果不想让程序终止,可以通过defer recover() 来处理,见下图(这样处理就不会使程序崩溃):
defer使用注意:
更好性能的API框架, 由于使用了httprouter,速度提高了近40倍,基于httprouter开发的Web框架
它具有运行速度快,分组的路由器,良好的崩溃捕获和错误处理,非常好的支持中间件和 json
值得学习的一点是,httprouter 对下级节点的查找进行了优化,简单来说就是把当前节点的下级节点的首字母维护在本身,匹配时先进行索引的查找。
底层结构为双向链表,包含一个头结点和一个尾结点
不要通过共享内存的方式进行通信,而应该通过通信的方式共享内存。
在很多语言中,多个线程传递数据的方式一般是共享内存,为了解决线程竞争,需要限制同一时间能够读写这些变量的线程数。但在go里提供了一种不同的并发模型——通信顺序模型(communication sequential processes, CSP),goroutine 之间通过channel传递数据。channel中的数据遵循先进先出(FIFO)的设计。
还有需要说明的是,channel 是线程安全的,因为读和写的时候,channel 都会加上锁。
排序策略:将map的键值对转换为切片,然后排序输出
// map排序
// 1.先将map的key放到切片中
// 2.对切片进行排序
// 3.遍历切片,然后按照key来输出map的值
map1 := make(map[int]int,10)
map1[10] = 100
map1[1] = 13
map1[4] = 56
map1[8] = 90
fmt.Println(map1)
var keys [] int
for k, _ := range map1 {
keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
fmt.Printf("map1[%v]=%v\n", k, map1[k])
}
详见博客: Gin中间件
像 int、float、bool 和 string ,数组这些基本类型以及结构体类型都属于值类型
引用类型: 指针,slice,map,channel,接口,函数
请记住,go语言中所有的传参都是值传递,新拷贝了一个副本。
在Go语言里,虽然只有值传递,但是我们也可以修改原内容数据,只要参数是引用类型即可
下面代码举例数组,切片,指针,map传参:
func main() {
arr := [3]int{1, 2, 3}
// 测试数组的传递方式
//传递的是原数据的副本,两个数据的地址不同
changeArray(arr)
fmt.Println(arr) // 1,2,3
// 测试切片的传递方式
changeBySlice(arr[:])
fmt.Println(arr) //10,2,3
// 测试指针的传递方式
changeByPoint(&arr)
fmt.Println(arr) //20,2,3
// 测试map的传递方式
//使用make初始化map,内部其实传的是*hmap,修改后来的数据也会改变旧数据
var myMap = make(map[string]string)
myMap["1"] = "go"
myMap["2"] = "python"
//fmt.Println(myMap)
changeMap(myMap)
fmt.Println(myMap) // go python java
}
// 值传递
func changeArray(a [3]int) {
a[0] = 10
}
//slice 是个复合对象,虽然是值传递,但是拷贝值里指针指向的是同一个数组
// 切片底层的结构就是一个指向数组的指针,长度len,容量cap
func changeBySlice(a []int) {
a[0] = 10
}
// 传指针,这个没啥好说的
func changeByPoint(a *[3]int) {
(*a)[0] = 20
}
func changeMap(params map[string]string){
params["3"] = "java"
}
另外注意:
引用传递跟引用类型是两个概念
Channel 关闭后可读,为空的时候再读不会报错,返回0值
C++ 里可以直接对指针做算术运算(+、-、++、–)而 Go 里面不行
而且 Go 还提供了一些底层的库 reflect 和 unsafe,它们可以让使用者把任意一个 Go 指针转成 uintptr 类型的值,然后再像 C++ 一样对指针做算术运算
unsafe模块的文档中提到几条转换规则,理解了以后就很容易做指针运算了:
在C语言中,可以直接利用指针++的方式快速定位到下一个元素。Go语言中,这个方式是行不通的!!!
在Golang中,不能直接利用++的形式快速定位到下一个元素,其标识指针地址的uintptr类型,本质上是一个uint类型的数值,uintptr的运算实际上是uint的运算。
原因分析,主要原因是Go语言中p++不是跳转到与该指针临近的下一个指针,而是真正的指针地址的具体值+1
详见博客: Golang的指针运算方式
延迟函数执行按后进先出顺序执行,即先出现的defer最后执行
定义defer类似于入栈操作,执行defer类似于出栈操作
设计defer的初衷是简化函数返回时资源清理的动作,资源往往有依赖顺序,比如先申请A资源,再根据A资源申请B资源,根据B资源申请C资源,即申请顺序是:A–>B–>C,释放时往往又要反向进行。这就是把defer设计成LIFO的原因
每申请到一个用完需要释放的资源时,立即定义一个defer来释放资源是个很好的习惯。
参考博客:Go defer实现原理剖析
func main() {
var a uint
var b uint
b = 100
a = 101
c := b-a
fmt.Printf("值:%v\n%v\n%v",b,a+3,c)
}
结果:
值:100
104
18446744073709551615
Process finished with the exit code 0
原因:
:= 会进行类型的自动推导c为uint32位,所以系统会把负数的1的正负位当做最高进制来算,造成数值很大
正确写法:c:=int(a) - int(b)
Golang开发中如何解决共享变量问题
参考博客:使用共享变量实现并发
go 的官方包提供了相应的处理函数 reflect.DeepEqual(x, y interface{}) bool。
当不知道切片类型时,推荐使用此方法,因为其要去做反射判断,相对而言会比较耗时。
func ReflectEqual(x, y interface{}) bool {
return reflect.DeepEqual(x, y)
}
// 需要明确知道切片的类型,例如:
func ForEqual(x, y []byte) bool {
if len(x) != len(y) {
return false
}
if (x == nil) != (y == nil) {
return false
}
for i, v := range x {
if v != y[i] {
return false
}
}
return true
}
Go实现面向对象的两个关键是struct和interface。
type Page struct {
title string
content string
}
// 这个结构体里面的title和content是不能被导出的
// 引用这个包直接对title和content赋值将会编译不通过
// 比如这个包叫page,那么可以直接给page.Page.title赋值将会编译失败
type Page struct {
Title string
Content string
}
//这个结构体里面的Title和Content是可以被导出的
// 在包外可以直接对Title和Content直接赋值
// 比如这个包叫page,那么可以直接给page.Page.Title赋值
type A struct{}
type B struct{
A
}
// Go支持多重继承,就是在类型中嵌入所有必要的父类型。
Go语言实际上是没有继承(白盒复用)这种写法的,为了让类能够复用,Go使用了组合(黑盒复用)!
在设计模式中,“多用组合,少用继承”是一种非常常见的思想。Go语言干脆走向了一个极端,没有继承,只有组合。
无论是继承还是组合,本质上都是让代码能够更好地复用,让结构更加清晰。Go这种设计总体来讲还是利大于弊。
来一个简单的例子,通过制作三明治的过程来看看组合是怎么用的:
// 面包
type Bread struct {
}
// 培根
type Bacon struct {
}
//生菜
type Lettuct struct {
}
// 鸡蛋
type Egg struct {
}
// 通过组合的方法来做一个简单的三明治
type Sandwich struct {
bread Bread
bacon Bacon
lettuct Lettuct
egg Egg
}
func (b Bread) make() {
fmt.Println("将面包切片, 得到一些面包片")
}
func (b Bacon) make() {
fmt.Println("把培根放入面包片夹层中")
}
func (l Lettuct) make() {
fmt.Println("将生菜洗干净放入面包夹层")
}
func (e Egg) make() {
fmt.Println("将鸡蛋煎熟, 放入面包夹层")
}
func (s Sandwich) make() {
s.bread.make()
s.bacon.make()
s.lettuct.make()
s.egg.make()
fmt.Println("对着弄好的材料斜着切一下")
fmt.Println("得到两个三明治!")
}
func main() {
var sandwich Sandwich
sandwich.make()
}
正如很多面向对象的语言一样,Go也拥有接口。接口实际上是一种契约,里面包含了一些必须要实现的方法,如果某个类实现了这个接口里面所有的方法,那么就称为这个类实现了这个接口。
我喜欢用实际生活中存在的事物去类比,比如锅盖。要怎么实现一个锅盖的接口,这个接口文字描述可以是:只要能够盖住一定大小范围内的锅,都是锅盖。至于用玻璃材质去实现还是用木头材质去实现还是其他材质,这个接口并不去关注。
// 声明一个锅盖接口
// 内有一个cover方法
// 只要实现了cover方法的类,都称可以称为锅盖
type PotCover interface {
cover()
}
type GlassCover struct {
}
func (g GlassCover) cover() {
fmt.Println("玻璃锅盖")
}
// 定义一个男人类型
type Man struct {
name string
}
func main() {
// 声明c是一个锅盖
var c PotCover
// 把玻璃锅盖赋值给c
// 玻璃锅盖实现了cover方法
// 因此是个锅盖
c = new(GlassCover)
c.cover()
// 男人没有cover方法,没有实现锅盖接口
// 这里将会编译出错
c = new(Man)
}
interface{} 称为空接口,空接口是一个很有用的接口,因为它没有实现任何方法。所以任意变量都可以赋值给空接口。
比如我们定义一个map[string]int,这个map的值只能是int类型,不管怎么定义,map的值看起来都只能是一个单一的类型。这里空接口就能让map的值支持任意类型。
i := make(map[string]interface{})
i["0"] = 1
i["1"] = "hello"
fmt.Println(i)
// map[0:1 1:hello]
同样,函数的参数也可以使用空接口,这允许我们传入任意类型的参数。比如fmt.Println的参数就是个空接口,可以传入任意类型的参数。
并发通常应用在io操作较密集的地方,比如发起多个网络请求!通常串行发起网络请求是很慢的,大部分时间浪费在等待网络io上了。通过并发去请求能够显著提高整体耗时!
import (
"fmt"
"io/ioutil"
"net/http"
)
// 发起一个http get请求
// 把获取到的内容写入到channel
func httpget(url string, chc chan map[string]string) {
resp, err := http.Get(url)
if err != nil {
// 错误处理
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
// 错误处理
}
var i = make(map[string]string)
i[url] = string(body)
chc <- i
}
func main() {
chc := make(chan map[string]string)
go httpget("http://www.baidu.com/", chc)
go httpget("http://www.qq.com/", chc)
// 等待完成
var content map[string]string
content = <-chc
fmt.Println(content)
content = <-chc
fmt.Println(content)
}
因为程序逻辑本身的问题,同时读取到相同索引位,自然也就会产生覆盖的写入
详细参考博客:为啥go 里面map和slice不支持并发读写?