1. 并发过高导致程序崩溃

先看一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
var wg sync.WaitGroup
for i := 0; i < math.MaxInt32; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println(i)
time.Sleep(time.Second)
}(i)
}
wg.Wait()
}

这个例子要创建 math.MaxInt32个协程,每个协程只是输出当前的编号,正常情况下,会乱序输出 0 ~math.MaxInt32 的数字,但实际执行一段时间后 直接panic。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
1298211
251117
1297752
panic: too many concurrent operations on a single file or socket (max 1048575)

goroutine 1300064 [running]:
internal/poll.(*fdMutex).rwlock(0xc000026120, 0x0?)
/usr/local/go/src/internal/poll/fd_mutex.go:147 +0x11b
internal/poll.(*FD).writeLock(...)
/usr/local/go/src/internal/poll/fd_mutex.go:239
internal/poll.(*FD).Write(0xc000026120, {0xc11b3ff928, 0x8, 0x8})
/usr/local/go/src/internal/poll/fd_unix.go:370 +0x72
os.(*File).write(...)
/usr/local/go/src/os/file_posix.go:48
os.(*File).Write(0xc000012018, {0xc11b3ff928?, 0x8, 0xc1255acf50?})
/usr/local/go/src/os/file.go:175 +0x65
fmt.Fprintln({0x10c6b88, 0xc000012018}, {0xc1255acf90, 0x1, 0x1})
/usr/local/go/src/fmt/print.go:285 +0x75
fmt.Println(...)
/usr/local/go/src/fmt/print.go:294
main.main.func1(0x0?)
/Users/shiguofu/Documents/hw.go:16 +0x8f
created by main.main
/Users/shiguofu/Documents/hw.go:14 +0x3c
panic: too many concurrent operations on a single file or socket (max 1048575)

报错信息也很明显,too many concurrent operations on a single file or socket

对单个 file/socket 的并发操作个数超过了系统最大值,这个错误是由于fmt.Sprintf引起的,它将格式化数据输出到标准输出。标准输出在linux系统中是文件描述符为1的文件,标准错误输出是2,标准输入是0。

总之就是系统资源耗尽了。

那假如去掉fmt这行输出呢?程序很可能就会因为内部不足而被迫退出,笔者尝试去掉跑出错误,电脑16G的,跑到5G多已经卡的不行, 就强行退出了。其实也好理解,每个协程约占用2K控件,1M个协程就是2G的内存,那Math.MaxInt 读者自己计算下。

2. 如何解决呢

2.1 利用channel的缓冲区大小来控制协程个数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
var wg sync.WaitGroup
ch := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
ch <- struct{}{}
wg.Add(1)
go func(i int) {
defer wg.Done()
log.Println(i)
time.Sleep(time.Second)
<-ch
}(i)
}
wg.Wait()
}
  • make(chan struct{}, 3) 缓冲区大小为3,没有被消费的情况下,最多发送3个就被阻塞了
  • 开启协程前 往通道写入数据,缓冲区满 就阻塞了
  • 协程结束后 消费通道数据,缓冲区就可以继续写入数据,就可以再创建新的协程

如下进行封装,可直接像waitgroup一样使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

import (
"context"
"sync"
)

const defaultSize = 32

// SizeWaitGroup the struct control limit of waitgroup
type SizeWaitGroup struct {
buf chan struct{} // buffer to buf the current number of goroutines
wg sync.WaitGroup // the real wait group
}

// NewSizeWaitGroup wait group with limit
func NewSizeWaitGroup(size int) *SizeWaitGroup {
if size <= 0 {
size = defaultSize
}
return &SizeWaitGroup{
buf: make(chan struct{}, size),
wg: sync.WaitGroup{},
}
}

// Add
func (c *SizeWaitGroup) Add() {
_ = c.AddWithContext(context.Background())
}

// AddWithContext
// blocking if the number of goroutines has been reached
func (c *SizeWaitGroup) AddWithContext(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case c.buf <- struct{}{}: // block
break
}
c.wg.Add(1)
return nil
}

// Done
func (c *SizeWaitGroup) Done() {
<-c.buf
c.wg.Done()
}

// Wait
func (c *SizeWaitGroup) Wait() {
c.wg.Wait()
}

2.2 调整系统资源上限

2.2.1 ulimit

有些场景下,即使我们有效地限制了协程的并发数量,但是仍旧出现了某一类资源不足的问题,例如:

  • too many open files
  • out of memory

操作系统通常会限制同时打开文件数量、栈空间大小等,ulimit -a 可以看到系统当前的设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
core file size          (blocks, -c) 0
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 63068
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 65535
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 63068
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited

我们可以使用 ulimit -n 999999,将同时打开的文件句柄数量调整为 999999 来解决这个问题,其他的参数也可以按需调整。

2.2.2 虚拟内存(virtual memory)

虚拟内存是一项非常常见的技术了,即在内存不足时,将磁盘映射为内存使用,比如 linux 下的交换分区(swap space)。

在linux创建交换分区

1
2
3
4
5
sudo fallocate -l 20G /mnt/.swapfile # 创建 20G 空文件
sudo mkswap /mnt/.swapfile # 转换为交换分区文件
sudo chmod 600 /mnt/.swapfile # 修改权限为 600
sudo swapon /mnt/.swapfile # 激活交换分区
free -m # 查看当前内存使用情况(包括交换分区)

关闭交换分区

1
2
sudo swapoff /mnt/.swapfile
rm -rf /mnt/.swapfile

磁盘的 I/O 读写性能和内存条相差是非常大的,例如 DDR3 的内存条读写速率很容易达到 20GB/s,但是 SSD 固态硬盘的读写性能通常只能达到 0.5GB/s,相差 40倍之多。因此,使用虚拟内存技术将硬盘映射为内存使用,显然会对性能产生一定的影响。如果应用程序只是在较短的时间内需要较大的内存,那么虚拟内存能够有效避免 out of memory 的问题。如果应用程序长期高频度读写大量内存,那么虚拟内存对性能的影响就比较明显了。