#作者:曹付江
文章目錄
- 1.并發過高導致程序崩潰
- 2. 如何解決
- 2.1 利用 channel 的緩存區
- 2.2 利用第三方庫
- 3 調整系統資源的上限
- 3.1 ulimit
- 3.2 虛擬內存(virtual memory)
1.并發過高導致程序崩潰
看一個非常簡單的例子:
func main() {var wg sync.WaitGroupfor 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 個協程的并發,約 2^31 = 2 億個,每個協程內部幾乎沒有做什么事情。正常的情況下呢,這個程序會亂序輸出 1 -> 2^31 個數字。
那實際運行的結果是怎么樣的呢?
$ go run main.go
...
150577
150578
panic: too many concurrent operations on a single file or socket (max 1048575)goroutine 1199236 [running]:
internal/poll.(*fdMutex).rwlock(0xc0000620c0, 0x0, 0xc0000781b0)/usr/local/go/src/internal/poll/fd_mutex.go:147 +0x13f
internal/poll.(*FD).writeLock(...)/usr/local/go/src/internal/poll/fd_mutex.go:239
internal/poll.(*FD).Write(0xc0000620c0, 0xc125ccd6e0, 0x11, 0x20, 0x0, 0x0, 0x0)/usr/local/go/src/internal/poll/fd_unix.go:255 +0x5e
fmt.Fprintf(0x10ed3e0, 0xc00000e018, 0x10d3024, 0xc, 0xc0e69b87b0, 0x1, 0x1, 0x11, 0x0, 0x0)/usr/local/go/src/fmt/print.go:205 +0xa5
fmt.Printf(...)/usr/local/go/src/fmt/print.go:213
main.main.func1(0xc0000180b0, 0x124c31)
… 運行的結果是程序直接崩潰了,關鍵的報錯信息是:
panic: too many concurrent operations on a single file or socket (max 1048575)
對單個 file/socket 的并發操作個數超過了系統上限,這個報錯是 fmt.Printf 函數引起的,fmt.Printf 將格式化后的字符串打印到屏幕,即標準輸出。在 linux 系統中,標準輸出也可以視為文件,內核(kernel)利用文件描述符(file descriptor)來訪問文件,標準輸出的文件描述符為 1,錯誤輸出文件描述符為 2,標準輸入的文件描述符為 0。
簡而言之,系統的資源被耗盡了。
那如果我們將 fmt.Printf 這行代碼去掉呢?那程序很可能會因為內存不足而崩潰。這一點更好理解,每個協程至少需要消耗 2KB 的空間,那么假設計算機的內存是 2GB,那么至多允許 2GB/2KB = 1M 個協程同時存在。那如果協程中還存在著其他需要分配內存的操作,那么允許并發執行的協程將會數量級地減少。
2. 如何解決
不同的應用程序,消耗的資源是不一樣的。比較推薦的方式的是:應用程序來主動限制并發的協程數量
2.1 利用 channel 的緩存區
// main_chan.go
func main() {var wg sync.WaitGroupch := 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 的 channel,在沒有被接收的情況下,至多發送 3 個消息則被阻塞。
- 開啟協程前,調用 ch <- struct{}{},若緩存區滿,則阻塞。
- 協程任務結束,調用 <-ch 釋放緩沖區。
- sync.WaitGroup 并不是必須的,例如 http 服務,每個請求天然是并發的,此時使用 channel 控制并發處理的任務數量,就不需要 sync.WaitGroup。
運行結果如下:
$ go run main_chan.go
2020/12/21 00:48:28 2
2020/12/21 00:48:28 0
2020/12/21 00:48:28 1
2020/12/21 00:48:29 3
2020/12/21 00:48:29 4
2020/12/21 00:48:29 5
2020/12/21 00:48:30 6
2020/12/21 00:48:30 7
2020/12/21 00:48:30 8
2020/12/21 00:48:31 9
從日志中可以很容易看到,每秒鐘只并發執行了 3 個任務,達到了協程并發控制的目的。
2.2 利用第三方庫
目前有很多第三方庫實現了協程池,可以很方便地用來控制協程的并發數量,比較受歡迎的有:
- Jeffail/tunny
- panjf2000/ants
以 tunny 舉例:
package mainimport ("log""time""github.com/Jeffail/tunny"
)
func main() {pool := tunny.NewFunc(3, func(i interface{}) interface{} {log.Println(i)time.Sleep(time.Second)return nil})defer pool.Close()for i := 0; i < 10; i++ {go pool.Process(i)}time.Sleep(time.Second * 4)
}
- tunny.NewFunc(3, f) 第一個參數是協程池的大小(poolSize),第二個參數是協程運行的函數(worker)。
- pool.Process(i) 將參數 i 傳遞給協程池定義好的 worker 處理。
- pool.Close() 關閉協程池。
運行結果如下:
$ go run main_tunny.go
2020/12/21 01:00:21 6
2020/12/21 01:00:21 1
2020/12/21 01:00:21 3
2020/12/21 01:00:22 8
2020/12/21 01:00:22 4
2020/12/21 01:00:22 7
2020/12/21 01:00:23 5
2020/12/21 01:00:23 2
2020/12/21 01:00:23 0
2020/12/21 01:00:24 9
3 調整系統資源的上限
3.1 ulimit
有些場景下,即使我們有效地限制了協程的并發數量,但是仍舊出現了某一類資源不足的問題,例如:
- too many open files
- out of memory
- …
例如分布式編譯加速工具,需要解析 gcc 命令以及依賴的源文件和頭文件,有些編譯命令依賴的頭文件可能有上百個,那這個時候即使我們將協程的并發數限制到 1000,也可能會超過進程運行時并發打開的文件句柄數量,但是分布式編譯工具,僅將依賴的源文件和頭文件分發到遠端機器執行,并不會消耗本機的內存和 CPU 資源,因此1000個并發并不高,這種情況下,降低并發數會影響編譯加速的效率,那能不能增加進程能同時打開的文件句柄數量呢?
操作系統通常會限制同時打開文件數量、棧空間大小等,ulimit -a 可以看到系統當前的設置:
$ ulimit -a
-t: cpu time (seconds) unlimited
-f: file size (blocks) unlimited
-d: data seg size (kbytes) unlimited
-s: stack size (kbytes) 8192
-c: core file size (blocks) 0
-v: address space (kbytes) unlimited
-l: locked-in-memory size (kbytes) unlimited
-u: processes 1418
-n: file descriptors 12800
我們可以使用 ulimit -n 999999,將同時打開的文件句柄數量調整為 999999 來解決這個問題,其他的參數也可以按需調整。
3.2 虛擬內存(virtual memory)
虛擬內存是一項非常常見的技術了,即在內存不足時,將磁盤映射為內存使用,比如 linux 下的交換分區(swap space)。
在 linux 上創建并使用交換分區是一件非常簡單的事情:
sudo fallocate -l 20G /mnt/.swapfile # 創建 20G 空文件
sudo mkswap /mnt/.swapfile # 轉換為交換分區文件
sudo chmod 600 /mnt/.swapfile # 修改權限為 600
sudo swapon /mnt/.swapfile # 激活交換分區
free -m # 查看當前內存使用情況(包括交換分區)
關閉交換分區也非常簡單:
sudo swapoff /mnt/.swapfile
rm -rf /mnt/.swapfile
磁盤的 I/O 讀寫性能和內存條相差是非常大的,例如 DDR3 的內存條讀寫速率很容易達到 20GB/s,但是 SSD 固態硬盤的讀寫性能通常只能達到 0.5GB/s,相差 40倍之多。因此,使用虛擬內存技術將硬盤映射為內存使用,顯然會對性能產生一定的影響。如果應用程序只是在較短的時間內需要較大的內存,那么虛擬內存能夠有效避免 out of memory 的問題。如果應用程序長期高頻度讀寫大量內存,那么虛擬內存對性能的影響就比較明顯了。