0%

Golang日常开发总结

1. map与sync.map

1.1 关键点

  • Go语言中的map/sycn.Map都是一种无序的键值对集合;
  • map是并发不安全的,如果并发地读写普通的Map,会报错误fatal error: concurrent map read and map write;这个要特别关注,所以一般结合并发锁一起使用;
  • sync.Map是并发安全的;

1.2 sync.Map和map+Mutex性能分析

参考原文:https://blog.csdn.net/wyg_031113/article/details/106282340

sync.Map和map+Mutex性能对比

测试序号 说明 writePer mod sync.Map ns/op map ns/op map/sync.Map
1 全写 1 0 4378931 2257652 0.515571495
2 1/2写 2 1 742785 1957077 2.634782609
3 1/10写 10 1 201720 1793032 8.888717034
4 1/20写 20 1 122396 1758052 14.36363933
5 1/40写 40 1 91850 1740995 18.9547632
6 1/50写 50 1 86850 1768100 20.35808866
7 1/100写 100 1 72843 1762022 24.18931126
8 1/500写 500 1 58097 1709021 29.41668244
9 1/1000写 1000 1 57137 1746151 30.56077498
10 1/5000写 5000 1 55446 1736288 31.31493706
11 只读的情况下 xx -1 37198 1648084 44.30571536

1.3 总结

  • sync.Map的性能高体现在读操作远多于写操作的时候。 极端情况下,只有读操作时,是普通map的性能的44.3倍;
  • 反过来,如果是全写,没有读,那么sync.Map还不如加普通map+mutex锁,只有普通map性能的一半;
  • 建议使用sync.Map时一定要考虑读定比例。当写操作只占总操作的<=1/10的时候,使用sync.Map性能会明显高很多;
  • map/sync.Map是无序的键值对集合,每次迭代都可能产生不一样的结果;

2. Slice

2.1 Slice与数组的关系/差异

Slice, 也叫切片,是一种存储特定类型的不定长序列,与数组相似,所不同的是,数组是定长的,而切片可以动态扩展,因此比数组灵活很多,一般使用[]T表示一个切片,其中的T表示类型。

另外,数组是值类型,在传给函数时,会复制一个数组的副本,当数组很大的时候,会消耗很多内存,而切片是引用类型,赋给函数,也只是复制切片的内存地址而已。

一个切片,由三个部分构成,底层数组、长度(len)、容量(cap),每个切片都对应一个底层数组,而多个切片之间,也可共享一个底层数组,切片提供了一个访问底层数组的功能,长度小于容量,容量可以动态改变,因此可以认为切片是一个动态数组。

2.2 作为参数传递

在go语言中的方法的参数都为值传递,切片也不例外,但是切片有一个指向数据地址的指针,所以就算是值传递,如果改变切片中的已有元素数据,也有可能影响到原有的切片

为什么是可能呢?我们来看下面的代码

1
2
3
4
5
6
7
8
9
10
11
func test(a []int) {
a = append(a, 1)
a[0] = 2
}

func main() {
s1 := make([]int, 1, 1)
log.Printf("%v\n", s1) // [0]
test(s1)
log.Printf("%v\n", s1) // [0]
}

这里的s1变量并没有被影响到数据;那么我们再来看下面的代码

1
2
3
4
5
6
7
8
9
10
11
func test(a []int) {
a = append(a, 1)
a[0] = 2
}

func main() {
s1 := make([]int, 1, 2)
log.Printf("%v\n", s1) // [0]
test(s1)
log.Printf("%v\n", s1) // [2]
}

这里的s1就已经被改变了,这是为什么呢?我们可以看看append方法的注解,如果目的切片容量足够,那么会直接添加到原切片指向的数据地址再返回一个拥有新长度和原数据地址的新切片,所以直接修改原切片长度内的数据会影响到原切片数据;目的切片容量不足时才会另外申请空间,此时新切片拥有新的数据地址,所以不会影响到原有切片。

注意append会涉及到扩容机制, 总是会返回一个新切片;原切片容量足够时,新切片指向的数据地址不变,依然是原切片指向的数据地址

2.3 作为方法返回值

当切片作为方法返回值时也有几点值得注意:

  • 只返回了一个切片的一部分时,并不会复制数据,而是直接指向原地址,这样的话如果一直在引用返回的切片时,整个切片都不会被释放,一直在内存中

如果返回的切片是一个很多地方都可以获取到的同一切片变量,比如

1
2
3
4
5
var a = []int{1, 2, 1}

func GetA() []int{
return a
}

那么此时也没有真正做到值传递,外面的修改可能会影响到a,并且如果有多个协程获取并修改的话,还有并发同步问题

我们可以以下面的方式返回一个指向新地址的slice解决上面的问题,做到真正的值传递

1
2
3
func GetA() []int{
return append([]int{}, a...)
}

2.4 并发安全

  • slice是并发不安全的, slice 中,如果 a[x] 和 b[y] 指向同一个内存区域,那么存在竞态关系

3. channel

通道(channel)则是用来传递数据的一个数据结构。 大部分时候 channel 都是和 goroutine 一起配合使用,用于多个 goroutine 之间进行数据传递。

channel使用需要注意以下特性:

  • channel是线程安全的,即在使用过程中,有多个协程同时向一个channel发送数据,或读取数据是完全可行的,不需要额外的操作;
  • 无缓冲的通道只有当发送方和接收方都准备好时才会传送数据,否则准备好的一方将会被阻塞;
  • 有缓存的channel区别在于只有当缓冲区被填满时,才会阻塞发送者,只有当缓冲区为空时才会阻塞接受者;
  • 从已关闭的channel接收数据是安全的,接收状态值 ok 是 false 时表明 channel 中已没有数据可以接收了;
  • 从有缓冲的channel中接收数据,缓存的数据获取完再没有数据可取时,状态值也是 false;
  • 向已关闭的channel中发送数据会造成 panic;

4. select

Golang中的select语句是用来处理与channel有关的I/O操作,select语句和switch语句稍微有点相似,也会有几个case和default分支。select语句中每一个case代表一个通信操作,可以在某个channel上进行发送或者接收,并且会包含一些语句组成的一个语句块,示例如下:

1
2
3
4
5
6
7
8
9
select {
case 表达式1:
<code>
case 表达式2:
<code>
default:
<code>
}

使用select的时候需要注意以下特性:

  • 每个case语句中必须包含或指向一个通道;
  • 当某个case中的通道可用时,将会执行对应的statement,此时其他case语句将被忽略;
  • 当存在多个case都可以运行时,select会随机地选择一个分支执行。未被选中的分支将不会被执行;
  • 如果没有任务case可以执行,并且分支中存在default子句,此时执行default分支;
  • 如果没有default字句,整个select将阻塞,直到某个通道可用为止;
  • 一个select最多执行一次case里的代码,需要一直检测case,外层加for循环;

5. 其他总结

  • 使用并发锁时,锁的力度尽量足够小,即时解锁,避免出现死锁;
  • 死锁在嵌套加锁时很容易出现,也相对容易排查;
  • 不要在带锁的for循环里面进行I/O操作
  • 在一个“nil”的slice中添加元素是没问题的,但对一个map做同样的事将会生成一个运行时的panic;
  • range迭代中,得到的值其实是元素的一份值拷贝,更新拷贝并不会更改原来的元素,即是拷贝的地址并不是原有元素的地址,如果要修改原有元素的值,应该使用索引直接访问;
  • defer延迟执行的函数,它的参数会在声明时候就会求出具体值,而不是在执行时才求值;
1
2
3
4
5
6
7
// 在 defer 函数中参数会提前求值
func main() {
var i = 1
defer fmt.Println("result: ", func() int { return i * 2 }())
i++
}
// result: 2