ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

《万字详解 | Go 基础从入门到入土》(并发实战篇)

《万字详解 | Go 基础从入门到入土》(并发实战篇) 三并发实战篇——错误处理与并发入门前两篇我们打牢了地基啃下了数据结构与接口。前面两篇还没有学习的同学可以在我的专栏里先学习前两篇的内容 现在我们迎来了 Go 语言封神的两个核心优雅的错误处理哲学以及让无数大厂痴迷的并发编程goroutine channel。收官之作全是硬核干货十一、错误处理Go 的错误处理跟别的语言完全不一样没有 try-catch错误是返回值。result,err:doSomething()iferr!nil{// 处理错误returnerr}看着啰嗦一开始我也觉得啰嗦写多了才发现这是 Go 的设计哲学——错误是值不是异常。为啥这么设计因为 try-catch 会让错误处理代码和正常代码混在一起控制流变得很难追踪。Go 的方式让你必须显式处理每一个错误写出来的代码虽然啰嗦但错误处理路径非常清晰。error 接口Go 标准库定义了一个error接口typeerrorinterface{Error()string}就这么简单——只要你的类型实现了Error() string方法它就是一个 error。Go 里几乎所有错误都是这个接口的实现。创建错误packagemainimport(errorsfmt)funcmain(){// 方式 1errors.New最简单的错误err1:errors.New(出错了)fmt.Println(err1)// 出错了// 方式 2fmt.Errorf带格式化的错误最常用name:利威尔err2:fmt.Errorf(用户 %s 不存在,name)fmt.Println(err2)// 用户 利威尔 不存在// 方式 3自定义错误类型可以携带更多信息typeMyErrorstruct{CodeintMsgstring}func(e*MyError)Error()string{returnfmt.Sprintf(错误码 %d: %s,e.Code,e.Msg)}err3:MyError{Code:404,Msg:页面没找到}fmt.Println(err3)// 错误码 404: 页面没找到}方式 3 的好处是自定义错误可以携带更多上下文信息比如错误码、时间戳等调用方可以根据这些信息做不同处理。错误包装Go 1.13实际开发中一个错误往往是从最底层一层层传上来的。传的时候你想把底层错误包进新的错误里方便追溯。Go 1.13 引入了错误包装packagemainimport(errorsfmt)varErrNotFounderrors.New(没找到)funcqueryDB(namestring)error{// %w 是包装把底层错误塞进新错误里returnfmt.Errorf(数据库查询失败: %w,ErrNotFound)}funcgetUser(namestring)error{iferr:queryDB(name);err!nil{returnfmt.Errorf(获取用户失败: %w,err)}returnnil}funcmain(){err:getUser(利威尔)fmt.Println(err)// 获取用户失败: 数据库查询失败: 没找到}注意%w和%v的区别%v只把错误的字符串拼进去%w把错误对象本身包进去后面可以用errors.Is/errors.As拆开错误判断errors.Is 和 errors.As拿到一个被包装过的错误怎么判断它是不是某类错误用errors.Ispackagemainimport(errorsfmt)varErrNotFounderrors.New(没找到)funcmain(){err:fmt.Errorf(查询失败: %w,ErrNotFound)// 判断 err 链中是否有 ErrNotFoundiferrors.Is(err,ErrNotFound){fmt.Println(是 NotFound 错误)}}如果想提取错误链中某个具体类型用errors.Aspackagemainimport(errorsfmt)typeMyErrorstruct{CodeintMsgstring}func(e*MyError)Error()string{returnfmt.Sprintf(错误码 %d: %s,e.Code,e.Msg)}funcmain(){original:MyError{Code:404,Msg:没找到}err:fmt.Errorf(包装: %w,original)vartarget*MyErroriferrors.As(err,target){fmt.Println(错误码:,target.Code)fmt.Println(消息:,target.Msg)}}panic 和 recover留给真正紧急的情况Go 还有一套恐慌 恢复机制panic 抛出严重错误recover 接住。packagemainimportfmtfuncrisky(){deferfunc(){ifr:recover();r!nil{fmt.Println(救命:,r)}}()panic(出大事了)}funcmain(){risky()fmt.Println(我没死)}但 Go 的哲学是日常错误用 errorpanic 只用于真正不可恢复的场景。啥叫真正不可恢复比如数组越界、空指针这类运行时错误程序启动时发现关键配置缺失没法继续跑程序员写代码时留下 bug触发不可能的状态业务错误用户不存在、参数非法、网络超时不应该用 panic应该用 error 返回。因为业务错误是预期的、可处理的panic 是非预期的、不可挽回的。panic 的传播路径当前函数 → defer 函数 → 上层函数 → … → main → 程序崩溃。recover 必须在 defer 里调用且只能在 panic 传播过程中生效。一旦 panic 完全传完recover 就接不住了。错误处理最佳实践最后给你几条实战建议不要忽略错误拿到 err 一定要处理不能_, _ : f()这样丢。Go 编译器会提醒你没用 err但有些场景比如_ db.Ping()会绕过提醒养成习惯。错误往上抛底层函数不用尝试处理所有错误把错误包装后返回上层。错误处理的责任应该由最了解业务的地方承担。main 里最后处理把所有错误都往上抛最后在 main 统一处理打印日志、退出码等。错误信息要有上下文错误信息应该告诉读者在干啥的时候出错了而不是只说出错了。比如fmt.Errorf(查询用户 %s 失败: %w, name, err)就比errors.New(查询失败)有用得多。区分错误类型用errors.Is判断错误类型做不同处理用errors.As提取详细信息。避免简单的字符串匹配。错误处理看着啰嗦但它是 Go 工程化的基础。写 Go 代码错误处理是基本功别偷懒。十二、并发入门 ★★★★ Go 的杀手锏并发是 Go 的杀手锏是它能火遍云原生时代的根本原因。啥是并发同时干多件事。比如你一边煮面一边看手机——这就是并发实际上是切换任务但因为切换快感觉是同时的。在程序里并发就是让多个任务看起来同时运行。12.1 goroutine轻量级并发Go 里开启一个并发任务一行代码搞定packagemainimportfmtfuncsay(sstring){fori:0;i3;i{fmt.Println(s)}}funcmain(){gosay(world)// 启动一个 goroutinesay(hello)// 主 goroutine 继续执行}加个go关键字新任务就并发跑了。say(world)在新 goroutine 里跑say(hello)在主 goroutine 里跑两个任务并发执行。goroutine vs 线程特性线程goroutine调度方操作系统Go 运行时初始栈1MB2KB切换开销大内核态小用户态数量级千级别百万级别为啥 goroutine 能这么轻因为 Go 运行时在用户空间做了大量工作动态栈增长goroutine 栈不够时自动扩容、工作偷取空闲的处理器从别的处理器偷任务、协作式调度在函数调用时检查是否需要让出 CPU。一个进程开几十万 goroutine 不是事儿开几十万线程早就 OOM 了。主 goroutine 退出 进程结束funcmain(){gofmt.Println(我是子 goroutine)// 主 goroutine 结束子 goroutine 没机会执行}主 goroutine 一旦退出整个进程就结束了不管子 goroutine 跑没跑完。所以你得想办法等子 goroutine 跑完。最常见的办法是用sync.WaitGrouppackagemainimport(fmtsync)funcworker(idint,wg*sync.WaitGroup){deferwg.Done()// 告诉 WaitGroup 这个任务完成了fmt.Printf(worker %d done\n,id)}funcmain(){varwg sync.WaitGroupfori:0;i5;i{wg.Add(1)// 告诉 WaitGroup 有一个新任务goworker(i,wg)}wg.Wait()// 等所有任务完成fmt.Println(所有 worker 跑完了)}Add加计数Done减计数Wait阻塞到计数为 0。这是 Go 并发的基本模式之一记住它。12.2 channelgoroutine 之间的通信管道为啥需要 channel因为 goroutine 之间要说话得有个介质——这就是 channel。你可以把它理解为两个 goroutine 之间的一根水管。一端灌水发送另一端出水接收。packagemainimportfmtfuncmain(){ch:make(chanint)// 创建一个 int 类型的 channelgofunc(){ch-42// 发送数据}()result:-ch// 接收数据fmt.Println(result)// 42}没有 channel 的话goroutine 之间没法通信——它们各自跑各自的谁也不知道谁在干啥。有了 channel它们就能协调工作了。Go 的并发哲学不要通过共享内存来通信而要通过通信来共享内存。听着绕口对吧翻译一下就是不要多个 goroutine 都改同一个变量用锁保证安全要goroutine 之间通过 channel 传消息避免直接共享数据为啥因为共享内存需要加锁锁用错了就出 bug。channel 是无锁的天然线程安全。无缓冲 vs 有缓冲// 无缓冲 channel发送和接收必须同时准备好手对手交接ch1:make(chanint)// 有缓冲 channel缓冲区满才阻塞带仓库的水管ch2:make(chanint,3)ch2-1// 不阻塞缓冲区有空位ch2-2// 不阻塞ch2-3// 不阻塞ch2-4// 阻塞缓冲区满了无缓冲 channel 是同步的——发送方发完得等接收方拿才能继续。像两个人面对面交接文件你得把文件递到我手里我拿了你才能松手。如果你还没递呢我就伸手拿就卡那了。有缓冲 channel 是异步的——发完直接走不管有没有人接。像往邮箱里塞信你把信塞进邮箱就走了邮箱空了也没事下次有人来收。邮箱满了才塞不进去。顺便一提channel是这种环形缓存区关闭 channel发送方负责关闭 channel接收方用v, ok : -ch检测是否关闭packagemainimportfmtfuncmain(){ch:make(chanint,3)ch-1ch-2close(ch)// 关闭 channel// 用 range 遍历会自动检测关闭forv:rangech{fmt.Println(v)// 1 2}// 也可以单独判断v,ok:-chif!ok{fmt.Println(channel 已关闭没数据了)}else{fmt.Println(还收到:,v)}}注意三条规则不要向已关闭的 channel 发送数据会 panic不要重复关闭 channel也会 panic关闭 channel 不是必须的如果你能确定不会有 goroutine 还在等数据关闭是好的实践12.3 select多路复用的抢答器如果一个 goroutine 要同时等多个 channel用select。它像抢答器——多个 case 同时等着哪个先好就执行哪个。packagemainimport(fmttime)funcmain(){ch1:make(chanstring)ch2:make(chanstring)gofunc(){time.Sleep(1*time.Second)ch1-来自 ch1}()gofunc(){time.Sleep(2*time.Second)ch2-来自 ch2}()select{casemsg:-ch1:fmt.Println(msg)casemsg:-ch2:fmt.Println(msg)case-time.After(3*time.Second):fmt.Println(超时了)}// 1 秒后 ch1 有数据打印来自 ch1}time.After返回一个 channel一段时间后会发送一个值。这个用法在超时控制里特别常见。select还有个default分支用于非阻塞操作select{casemsg:-ch:fmt.Println(收到:,msg)default:fmt.Println(channel 空着没消息)}加了default之后如果 channel 里没数据select 不会阻塞直接走 default 分支。12.4 并发安全基础并发最大的坑竞态条件race condition。packagemainimport(fmtsync)varcountintfuncmain(){varwg sync.WaitGroupfori:0;i1000;i{wg.Add(1)gofunc(){deferwg.Done()count// 看似一行其实是读-改-写三步}()}wg.Wait()fmt.Println(count ,count)// 不是 1000可能是 987、992 等等}count看似一行其实是三步读 count、加 1、写回 count。两个 goroutine 同时执行这三步就会出问题两个都读到 100都加 1都写回 101结果应该是 102 实际是 101——少加了一次。解决方案 1加互斥锁packagemainimport(fmtsync)var(mu sync.Mutex countint)funcincrement(){mu.Lock()defermu.Unlock()count}funcmain(){varwg sync.WaitGroupfori:0;i1000;i{wg.Add(1)gofunc(){deferwg.Done()increment()}()}wg.Wait()fmt.Println(count ,count)// 一定是 1000}互斥锁的原理很简单拿不到锁就等拿到锁才能进出来就释放锁。解决方案 2用 channel 串行化packagemainimport(fmtsync)funcmain(){ch:make(chanint,1)ch-0// 初始化varwg sync.WaitGroupfori:0;i1000;i{wg.Add(1)gofunc(){deferwg.Done()v:-ch// 取出vch-v// 放回}()}wg.Wait()fmt.Println(count ,-ch)// 一定是 1000}这里 channel 充当了互斥锁的角色——同一时刻只有一个 goroutine 能拿到数据。这种模式叫串行化 goroutine。实际开发中加锁更常用。channel 适合传递数据锁适合保护共享状态。12.5 检测竞态go run -raceGo 自带竞态检测器加-race参数就能开启go run-racemain.go开启后程序会监控所有共享变量的读写一旦发现竞态就打印警告。强烈建议所有并发代码都用-race跑一遍测试。gotest-race./... go build-race-omyapp生产环境也可以用-race编译的二进制代价是性能会下降 2-20 倍但能帮你抓到绝大多数竞态 bug。12.6 并发小实战并发爬虫光说不练假把式咱们写个简单的并发爬虫看看packagemainimport(fmtsynctime)// 模拟爬取一个 URLfuncfetch(urlstring)string{time.Sleep(100*time.Millisecond)// 模拟网络请求returnfmt.Sprintf(内容 from %s,url)}funcmain(){urls:[]string{https://a.com,https://b.com,https://c.com,https://d.com,https://e.com,}// 串行爬取start:time.Now()for_,url:rangeurls{result:fetch(url)fmt.Println(result)}fmt.Printf(串行耗时: %v\n,time.Since(start))// 并发爬取starttime.Now()varwg sync.WaitGroupfor_,url:rangeurls{wg.Add(1)gofunc(ustring){deferwg.Done()result:fetch(u)fmt.Println(result)}(url)}wg.Wait()fmt.Printf(并发耗时: %v\n,time.Since(start))}5 个 URL每个模拟 100ms 请求。串行需要约 500ms并发只需要约 100ms。差距非常明显——这就是并发的威力。12.7 并发总结口诀最后送你这几条口诀记住它们并发基本不会翻车goroutine 用go启动轻量但别乱开百万级也得考虑资源channel 是 goroutine 之间的水管无缓冲 同步手对手交接有缓冲 异步带仓库关闭 channel 只能由发送方做select 多路复用谁先好谁先动加 default 不阻塞共享变量必须加锁或用 channel 串行化调试加-race并发必备并发这块内容多写起来也容易出 bug。建议你多写几个小程序练手并发打印、并发计数器、并发下载器把这些基础模式玩熟后面学 GMP 调度、context 这些高级特性就轻松多了。十三、总结到这里Go 的基础部分就讲完了。我们过了一遍变量程序的地基砖块三种声明方式零值机制流程控制if、for、switch 三件套函数乐高积木多返回值可变参数闭包defer切片Go 的精髓ptrlencap 三要素append 扩容共享底层数组的坑Map键值对字典哈希桶结构并发不安全要加锁结构体字段集合组合代替继承Tag 配序列化接口Go 的灵魂隐式实现鸭子类型错误处理error 返回值errors.Is/Aspanic/recover 留给紧急情况并发入门goroutine 轻量并发channel 通信select 多路复用竞态条件与锁速记口诀变量函数流程切片 Map 结构接口错误并发九大模块全通。学习建议不要背 API多写代码。Go 的语法不算多但细节多光看记不住。刷 LeetCode 简单题巩固语法。推荐用 Go 刷算法题一举两得。推荐书《Go 语言圣经》网络上有免费的中文版、《Go 高级编程》。练手项目写一个并发爬虫、简易 HTTP 服务器、命令行小工具。这些小项目能把上面的知识点串起来。Go 的基础看着简单但细节多。这篇文章覆盖的内容足够你应付 80% 的日常开发了。后续想进阶的话可以学GMP 调度模型深入理解 goroutine 的底层调度内存管理逃逸分析、GC 三色标记接口底层eface 和 iface 的内存结构Map 底层负载因子、渐进式扩容、红黑树如果有http后端开发基础的同学可以尝试用go语言实现一个简单的项目了比如我在大一写的这款聊天室项目可以直接在我的GitHub仓库里找到源代码到这里《万字详解 | Go 基础从入门到入土》这个连载系列就全部完结了。从变量到并发感谢你陪我走完这段旅程。我是利威尔xuCSDN 的新人博主。文章里要是有错误或者讲得不到位的地方欢迎评论区拍砖。觉得有帮助的话三连支持一下——点赞、收藏、关注。下一篇我会写 Go 进阶的内容敬请期待。
返回列表