聊聊 Go 併發安全

併發安全是最基本的常識,也是最容易忽,同時也考驗一個工程師 enginner 的語言基本功和代碼規範。

併發訪問修改變量,會導致各種不可預期的結果,最嚴重的就是程序 panic, 比如常見的 go 語言中 map concurrent read/write panic

先來講幾個例子,老生常談的 case, 再說說如何避免

字符串修改

下面是一個 concurrent read/write string 的例子

package main
import (
    "fmt"
    "time"
)
const (
    FIRST  = "WHAT THE"
    SECOND = "F*CK"
)
func main() {
    var s string
    go func() {
        i := 1
        for {
            i = 1 - i
            if i == 0 {
                s = FIRST
            } else {
                s = SECOND
            }
            time.Sleep(10)
        }
    }()
    for {
        if s == "WHAT" {
            panic(s)
        }
        fmt.Println(s)
        time.Sleep(10)
    }
}

一個 goroutine 反覆賦值字符串 s, 同時另外 main 去讀取變量 s, 如果發現字符串讀到的是 "WHAT" 就主動 panic

WHAT THE
WHAT THE
panic: WHAT

goroutine 1 [running]:
main.main()
 /Users/zerun.dong/code/gotest/string.go:26 +0x11a
exit status 2

上面代碼運行後,註定要 panic, 代碼的主觀意願是字符串賦值是原子的,要麼是 F*CK, 要麼是 WHAT THE, 爲什麼會出現 WHAT 呢?

// StringHeader is the runtime representation of a string.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type StringHeader struct {
 Data uintptr
 Len  int
}

在 go 語言中,字符串是由結構體 StringHeader 表示的,源碼中寫的清楚非併發安全,如果讀取字符串時,巧好有另外一個 goroutine 只更改了 uintptr 沒修改 Len, 那就會出現如上問題。

接口

再來舉一個 error 接口的例子,來自我司 POI 團隊。省去上下文,本質就是 error 變量併發修改導致的 panic

package main
import (
   "fmt"
   "github.com/myteksi/hystrix-go/hystrix"
   "time"
)
var FIRST error = hystrix.CircuitError{Message:"timeout"}
var SECOND error = nil
func main() {
   var err error
   go func() {
      i := 1
      for {
         i = 1 - i
         if i == 0 {
            err = FIRST
         } else {
            err = SECOND
         }
         time.Sleep(10)
      }
   }()
   for {
      if err != nil {
         fmt.Println(err.Error())
      }
      time.Sleep(10)
   }
}

復現 case 其實是一樣的

ITCN000312-MAC:gotest zerun.dong$ go run panic.go
hystrix: timeout
panic: value method github.com/myteksi/hystrix-go/hystrix.CircuitError.Error called using nil *CircuitError pointer

goroutine 1 [running]:
github.com/myteksi/hystrix-go/hystrix.(*CircuitError).Error(0x0, 0xc0000f4008, 0xc000088f40)
 <autogenerated>:1 +0x86
main.main()
 /Users/zerun.dong/code/gotest/panic.go:25 +0x82
exit status 2

來看一下 go 語言裏接口的定義

// 沒有方法的interface
type eface struct {
    _type *_type
    data  unsafe.Pointer
}
// 有方法的interface
type iface struct {
    tab  *itab
    data unsafe.Pointer
}

道理是一模一樣的,只要存在併發讀寫,就會出現所謂的 partial write, 結果就不可預期

看看 rust

fn main() {
    let a = String::from("abc");
    let b = a;

    println!("{}", b);
    println!("{}", a);
}

這是一段 rust 入門級代碼,運行會報錯:

ITCN000312-MAC:hello zerun.dong$ cargo run
   Compiling hello v0.1.0 (/Users/zerun.dong/projects/hello)
error[E0382]: borrow of moved value: `a`
 --> src/main.rs:6:20
  ||     let a = String::from("abc");
  |         - move occurs because `a` has type `String`, which does not implement the `Copy` trait
3 |     let b = a;
  |             - value moved here
...
6 |     println!("{}", a);
  |                    ^ value borrowed here after move

error: aborting due to previous error

因爲變量 a 己經被 move 走了,所以程序不可以再繼續使用該變量。這就是 rust ownership 所有權的概念。在編譯器層面就避免了上面提到的問題,當然 rust 學習曲線太陡。

如何保證安全

分好多層面來講這個事情

語言

簡單來講,鎖夠了,早年的 leveldb 就是一把大鎖擼遍全場

一把足矣,不夠的話,就分段鎖來個 100 把 ... 比如 statsd agent, 由於單個 agent 有把大鎖,多創建幾個 agent 就行了,同步不行換成異步 ...

很多代碼都沒有嚴苛到一把鎖就嚴重降低性能的程序,爲了程序的正確,切忌過早優化。尤其業務代碼,性能不行 asg 擴容堆機器。

CI/CD

靠工具的 linter 提示能做到一些顯示的檢查,包括不規範的代碼什麼的,都是可以的。但畢竟不是 rust 編譯器檢查,其實編譯器也並不是萬能的。

工程師

打鐵也要自身硬,以前 c/c++ 的程序員,每寫一行代碼,都知道傳入傳出的變量,是如何構造和析構的,否則內存泄漏了都不知道。

現在更高級的語言,內置 GC 帶來了開發效率的提升,但不代表工程師可以不思考了。如果真是那樣,是不是哪天 AI 就可以代替程序員了,好像是的...

可能這就是高級語言的不可能三角吧,開發效率、程序性能、運行時安全。聽說抖音廣告部門把 go 換成 c++ 後,解決了 latency long tail 問題,同時開發效率降低了四倍:(

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ybW8sW7Xq6XpqQzMQlIlHA