Go 使并发之理显于简易.
尔书之曰:
go func() {
// do something concurrently
}()
倏尔,尔之码行于他之协程.
此简易,乃吾爱 Go 之由。然经于后端系统、通知管道、高流 API、生产服务之实载,吾得要义焉。
Go中多数并发之患,非不用并发所致。
其患乃在未明瓶颈所在,而妄用并发。
或患在锁之阙如。
然甚多时,尤以生产中Go服务为甚,其患适相反:
- 锁之过甚
- 持锁过久
- 网络I/O于关键段落内
- 永无退出之goroutines
- 无界goroutines之创生
- 值拷贝之WaitGroups
- 无取消策略之channels
是篇,吾欲述所睹之实系统并发之患,吾思之关于互斥锁与信号量,及吾常于其未成生产之变前,何以调试此等问题。
真实之患:偶然成次第之并发
一服务,外观似并发,内犹如单线程之应用。
此常发生于请求流程之大部分,隐于共享一锁之后.
此等模式,较诸众开发者所承,实为常见.
mu.Lock()
user.Name = "Test User"
sendEmail(user)
callDatabase(user)
mu.Unlock()
初观之,或似无虞。
开发者欲护共享之态。此意固善。然锁今所护者,非独共享之存也。实护其全流:
- 更一字
- 发一函
- 叩一库
- 或待络之输
- 或再试
- 或阻他协程久时
此非互斥矣.
此乃交通阻塞也.
凡欲求同锁者,必待全流毕而后可。是故纵使服务有百千协程,大半系统亦成次第之序。
所险者,CPU之用犹可视为常,甚或偏下。内存亦似无恙。然迟滞日增,通量渐减,而p95/p99之应时亦不稳矣。
是故锁争之患,独观基础设施之数,时难察也。
例证于生产之式:互斥锁内之邮简
设吾有服务,可更新用户之状,亦可发通知.
type Service struct {
mu sync.Mutex
state map[int]string
}
func (s *Service) ProcessUsers(users []User) {
s.mu.Lock()
defer s.mu.Unlock()
for _, user := range users {
s.state[user.ID] = "processed"
sendEmail(user) // slow network I/O inside the lock
}
}
此码于数据竞态之见,固无患.
然于效能之见,则危矣.
互斥锁当护最小之共享内存操作。不可护迟缓之外部工作,如:
- 寄信
- 呼另一微服务
- 数据库查询
- HTTP 请求
- 文件上传
- 记于迟缓外存之器
- 候第三方之API
記憶更新或需納秒或微秒。郵件呼喚或需毫秒或秒。
彼异实要也。
若锁被持而sendEmail驰,凡他协程所需者皆然s.mu阻于网呼之背。
更优之版本,别共享状态之变与缓工。
func (s *Service) ProcessUsers(users []User) {
emails := make([]User, 0, len(users))
s.mu.Lock()
for _, user := range users {
s.state[user.ID] = "processed"
emails = append(emails, user)
}
s.mu.Unlock()
for _, user := range emails {
sendEmail(user)
}
}
此已佳矣,盖锁唯护共享之图也。
然于实际之生产系统,吾常欲将迟缓之务推于队列或有限之工作者池也。
func (s *Service) ProcessUsers(users []User, jobs chan<- EmailJob) {
s.mu.Lock()
for _, user := range users {
s.state[user.ID] = "processed"
}
s.mu.Unlock()
for _, user := range users {
jobs <- EmailJob{UserID: user.ID, Email: user.Email}
}
}
今请求路径不直系于电子邮件提供者之迟滞。
此乃真解。
非止“用 goroutines”而已。
其修也,在界乎共内存、外设出入、及反压之阈也。
互斥锁非恶,巨要区乃恶。
吾时见开发者畏于互斥锁。
此非其道。
sync.Mutex至简至速,用之得宜,无碍。其弊非在互斥锁,而在要区之广。
此乃吾所谨记者。
mu.Lock()
// only touch shared memory here
mu.Unlock()
非此也。
mu.Lock()
// shared memory
// database call
// HTTP call
// email call
// JSON encoding
// logging
// metrics push
mu.Unlock()
一佳之批判段落,宜淡然无味。
常应作此三者之一。
- 阅共享之态
- 更新共享状态
- 将共享状态复制至本地变量
- 易指针
- 增计数器
- 增补于受护之片/映射
乃解之。
其后,为之费工于锁外。
机枢之下:互斥锁所赐之物
高论而言,互斥锁予汝互斥之效:一时唯有一协程得入防护之域。
然亦予汝内存序之保。
围棋之记忆模型,解锁之操作,先于后之锁操作于同互斥量同步。实践而言,此即一协程更新共享数据而解锁,他协程后锁同互斥量,可安全观测其更新。
此乃多开发者所忘之部分。
互斥锁非止“阻他协程”,亦在构安全可见之界于协程间。
无此界,异协程或同时而读写同内存,是谓数据竞态。既生数据竞态,则程序不复可自信而推究矣。
此故吾不喜“巧”之无锁代码,非有至理不可用也。
大抵后端之务,非需巧竞。
需明竞。
信号量:制其量,非夺其有
互斥锁者,多关乎共内存之有。
信号量者,关乎其量。
譬如,欲理一万用户,然不欲一时发一万书。
愚者或为之。
for _, user := range users {
go sendEmail(user)
}
此乃危殆,盖因其创无界并流也。
若users有万件物,汝造万道协程。若每道协程皆行网络之交,启连接,分内存,候外供,则汝之服务可先困,未困于邮供。
简之,用信号量之式可解此:
sem := make(chan struct{}, 20) // allow only 20 concurrent email sends
var wg sync.WaitGroup
for _, user := range users {
user := user
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
sendEmail(user)
}()
}
wg.Wait()
今代码犹用并作,然并作有度矣。
此细末于生产实为大要。
无度并作,非可扩展之计也。
乃迟滞之败也。
更优之作业池于生产之码
信号量之式虽善,然于常运行之务,吾每择作业池。
type EmailJob struct {
UserID int
Email string
}
func startEmailWorkers(ctx context.Context, workerCount int, jobs <-chan EmailJob) {
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
if err := sendEmailJob(ctx, job); err != nil {
// In real systems: log, retry, dead-letter, or expose metrics.
fmt.Printf("worker=%d failed to send email user_id=%d err=%v\n", workerID, job.UserID, err)
}
}
}
}(i)
}
go func() {
wg.Wait()
}()
}
此使汝得良善之操持也。
- 固定并发
- 更易之度
- 更易关机
- 更易重试之策
- 逆压更易
- 限流更易
此乃“吾用协程”与“吾设并发系统”之别也。
协程漏:不立爆之虫
协程漏乃Go中生产之常见患也。
其患险,盖因服务或非立时崩。或渐恶于时日之间。
今举一典型之例:
func process() error {
ch := make(chan result)
go func() {
ch <- heavyComputation()
}()
select {
case res := <-ch:
return handle(res)
case <-time.After(1 * time.Second):
return errors.New("timeout")
}
}
其弊幽微
ch无缓冲之设
若超时先至process则返。其后ch无受者待之
及heavyComputation()毕ch则协程欲入__JHSNS_SEG_1b4e1dfc_139__而永阻。
彼协程今已泄漏矣。
一协程之泄漏,未足为患。
千协程之泄漏,则大碍矣。
更安全之版本,乃用缓冲之信道:
func process() error {
ch := make(chan result, 1)
go func() {
ch <- heavyComputation()
}()
select {
case res := <-ch:
return handle(res)
case <-time.After(1 * time.Second):
return errors.New("timeout")
}
}
此可防协程于超时后,阻塞于发送之事。
然于实务,吾更尚情境而撤之。
func process(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
ch := make(chan result, 1)
go func() {
res := heavyComputation(ctx)
select {
case ch <- res:
case <-ctx.Done():
}
}()
select {
case res := <-ch:
return handle(res)
case <-ctx.Done():
return ctx.Err()
}
}
要义所在:
每 goroutine 皆需有出之路。
若不能道明goroutine何以止息,恐将有漏泄之患。
WaitGroup以值传递:小错大影
此误于代码检视中,甚易遺之。
func worker(wg sync.WaitGroup) { // wrong: copied by value
defer wg.Done()
// do work
}
sync.WaitGroup初用之后,不可复抄。
若以值传递之,则其内部状态为复制。工者呼之Done()于副本,非于主协程所候之原WaitGroup。
此可致僵局。
正本:
func worker(wg *sync.WaitGroup) {
defer wg.Done()
// do work
}
其用法:
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go worker(&wg)
}
wg.Wait()
此律亦适用于其他同步原语如sync.Mutex。
初用后勿复制之。
循环变量之陷
此昔为Go协程之最著名之Bug也:
for _, user := range users {
go func() {
sendEmail(user)
}()
}
依版本之异、境之殊,若不慎摄循环之变,则恐协程得非其值。
守势之式,犹简明也。
for _, user := range users {
user := user
go func() {
sendEmail(user)
}()
}
纵新版Go语言有所精进,吾仍喜此风格于生产代码,盖因其使变量之所属昭然于读者之目也。
文理通顺之并发,乃可持守之并发也。
吾如何于Go中调试锁竞争
吾若疑有并发之瓶颈,非始以臆测为始。
吾始以度量为之。
启用 pprof
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// start application
}
乃收 profil:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
互斥锁争用,启用互斥锁分析:
runtime.SetMutexProfileFraction(1)
乃察之。
go tool pprof http://localhost:6060/debug/pprof/mutex
二、察协程之数
协程数渐增,常为阻塞或漏泄之兆.
fmt.Println("goroutines:", runtime.NumGoroutine())
用于生产,当为度之显:
prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "go_goroutines_current",
Help: "Current number of goroutines.",
},
func() float64 {
return float64(runtime.NumGoroutine())
},
)
倾倒goroutine之栈
事滞时,goroutine之倾倒,若金之贵
curl http://localhost:6060/debug/pprof/goroutine?debug=2
寻多goroutine阻于同行
sync.(*Mutex).Lock
chan send
chan receive
net/http.(*Transport).RoundTrip
若五千协程困于同锁或通道,汝已得瓶颈之所在。
四、于试中用识种之器
go test -race ./...
赛程检测非免费,汝通常不于生产中运行之,然于持续集成与本地调试,实为至要。
吾之实用法则于Go并发之产
吾撰或审并发Go码时,所循之规如此:
1. 锁须其小
唯护其需之数据而锁之
勿锁请求数生之全境
2. 永勿将迟缓之I/O置入互斥锁内
勿于要害处调用数据库、HTTP、邮件、文件上传及第三方API。
3. 限并
勿创无节制之协程。
宜用工池、信号量、队列或速率限制。
4. 每协程须有歇止之径
用context.Context、通道闭或显式取消。
勿复制同步之器
共享时,以指针传 *sync.WaitGroup、*sync.Mutex 及相似之器
优化之先,必先度量
pprof用
、运行时之度,踪迹、记录,及协程之转储
臆断非调试之实 __JHSNS_SEG_1b4e1dfc_236__尚平淡之并发
至妙之并行代码,往往非巧思所成。
其理明,可量度,且易止息。
终章所思
Go予吾辈强并行之器,然未自予良并行之构。
协程价廉,然非无偿。
互斥锁速,然若持之绕缓务,可损通量。
一通道雅致,然若无人接收,则可泄其协程。
一WaitGroup简易,然若复制之,则可破汝全流。
于我而言,资深之Go工程非在于用尽所有并发原语,而在于知其不用之时,识其真实边界,察其系统负重之态。
尔后若再书此:
mu.Lock()
行前问一问:
吾所护者何,此锁何速可释?
此一问,可救汝服务于无声之生产瓶颈.
参考文献
- Go内存模型:https://go.dev/ref/mem
- 往矣
sync包之文牍:https://pkg.go.dev/sync - 诊察剖析之器:https://go.dev/doc/diagnostics
- 行博客:Go调度器与运行时札记:https://go.dev/blog/











