从线程池到 goroutine:并发思维的转变

写 Go 半年了,今天想聊聊我理解中客户端和 Go 在并发上最本质的差别。标题有点大,内容是务实的。 做移动端的时候,并发是个"危险操作":线程宝贵,要线程池;主线程神圣不可侵犯;到处想的是怎么避免并发。到了 Go 这边,第一课就是反转:goroutine 很便宜,并发是常态,要担心的是共享状态。 举一个我实际改过的例子。刚入职时我写了一个聚合接口,习惯性地写成了串行调用:先查用户信息,再查订单,再查优惠券,一个 200ms 的接口被我写成了 600ms。mentor 看了一眼说,这三个没依赖关系,用 errgroup 并发去。改完的代码大概长这样: g, ctx := errgroup.WithContext(ctx) var user User g.Go(func() error { var err error user, err = userRepo.Get(ctx, uid) return err }) var orders []Order g.Go(func() error { var err error orders, err = orderRepo.List(ctx, uid) return err }) if err := g.Wait(); err != nil { return nil, err } 这段代码现在看稀松平常,但当时给我震撼挺大的:在 OC 里做同样的事要 dispatch_group 加一堆样板,而 Go 里就几行。而且 WithContext 把"一个下游失败就取消其他请求"这件事也顺带做了。 但便宜是有代价的。 goroutine 开起来太容易,泄漏也就太容易。我见过最隐蔽的一个 bug:一个 goroutine 往无缓冲 channel 写数据,下游超时提前返回了,这个 goroutine 就永远阻塞在那里。流量一上来,内存曲线肉眼可见地往上爬。排查工具是 pprof 的 goroutine profile,看到几千个阻塞在同一个位置的 goroutine 时,那个画面我忘不了。 所以现在我的习惯是:每个 goroutine 都要有明确的退出路径。要么由 ctx 控制生命周期,要么用带缓冲的 channel 并接受可能丢数据,绝不裸开。这大概是我从线程池时代带过来、但用新方式表达的旧智慧:资源永远是有限的。

2021-09-23 · 芮靖安

写了七年移动端,我开始学 Go

去年年底提了内部转岗,从 iOS 组转到基础架构组,主要语言是 Go。今年三月正式过过去了。趁记忆新鲜,写写一个纯客户端背景的人学 Go 的真实体验,给想转的后端朋友一点参考。 语法层面:三天就敢写,三个月才敢合并。 Go 的语法是出了名的简单,关键字二十几个,我有 OC 和 Swift 的底子,看完 tour 一天就能上手写。但敢写和敢上线是两回事——前两个月我写的代码被 mentor 挑出的问题比写出来的功能还多:channel 用完不关、err 一路往上传但没人真正处理、map 并发读写直接 panic 上线。 客户端背景带来的三个思维定势: 总想找"主线程"。 客户端开发里 UI 更新必须在主线程,这个惯性让我一开始畏手畏脚。后端的世界里没有这个概念,goroutine 随便开,要担心的是别的东西。 不习惯显式的 err。 客户端习惯了异常和回调,if err != nil 的样板代码看得我密集恐惧症都犯了。后来才理解这是设计取舍:错误就是返回值的一部分,不该被藏起来。 低估了部署环境。 客户端最多适配几个 OS 版本,后端要面对的是内存、连接数、磁盘 IO,这些我以前从没操心过的东西,现在都是日常。 学习方法上最有效的一件事:mentor 让我先别看资料,直接读公司里一个成熟服务的源码,遇到不懂的再回头查。比从书上一章章啃快多了,而且读到的是"生产级的 Go"而不是"教科书式的 Go"。 转岗三个月,最大的感受是:客户端转后端,语法是最不重要的部分,真正要补的是对分布式系统的直觉。这条路还长,慢慢来。

2021-03-18 · 芮靖安

Flutter 入坑半年的一点感受

年初组里立了一个新项目,预算紧、工期紧,老板拍板用 Flutter。作为一个只写过原生的人,半年下来有了一些体感,写下来给同样背景的朋友参考。 先说好的。 开发效率是真的高,热重载这个体验一旦用上就回不去。UI 还原度也不错,设计师给的稿子基本一比一还原,而且 iOS / Android 两端的差异比我想象中小。我们一个三人小组,半年做了两个业务模块 + 一套通用组件库,换原生至少要五个人。 再说别扭的。 混合工程的路由和生命周期管理是重灾区。我们用 flutter_boost 做混合栈,内存占用比纯原生页面高不少,低端机上页面切换能感觉到掉帧。官方对混合开发的支持一直不算稳定,升级 Flutter 版本的时候 flutter_boost 兼容性经常掉链子。 Dart 语言本身没毛病,但生态里的包质量参差不齐。有个下拉刷新的库,作者弃坑半年,issue 堆了几百个没人回,最后我们自己 fork 修的。 崩溃排查体验比原生差。符号表一多,Crash 堆栈经常对不上,需要自己维护一套符号映射的流程,这块我们还在摸索。 我的整体判断:新项目、纯 UI 展示型页面,Flutter 是划算的;但如果是重交互、深度依赖系统能力的 App,混合开发的复杂度会吃掉你在开发效率上省下来的时间。 下半年项目主体功能交付了,我大概率会回去继续搞 iOS 组件化的收尾。Flutter 的知识先放着,看它后续发展吧。个人感觉跨端这个方向,五年内不会有终局,工具会一直换,但"用一套代码换两端"的需求是永恒的。

2020-07-14 · 芮靖安

2019 年终小结

年底了,照例写点流水账,权当给自己的记录。 今年工作上最大的事是组件化改造落地了,从 5 月开始到现在,底层库全部拆完,业务模块拆了三分之一。中间有一段时间特别沮丧:拆到一半的时候两头不靠,旧代码看不上,新架构没收益,全组人都在怀疑这件事的意义。挺过那一段之后就好了,现在新需求基本都能在独立模块里开发,这钱花得值。 技术上今年的收获: 把 Swift 在项目里的占比推到了 60%,剩下的是一些低频改动的老页面,不急着迁; 学了一点点响应式,Combine 发布之后把项目里一个手写的 EventBus 干掉了,代码量少了一半; 开始认真写单测。之前一直觉得 iOS 单测是玄学,用了 Quick/Nimble 之后看法有所改观,虽然覆盖率还是惨不忍睹的 11%。 生活上没什么可说的,跑步从三公里进步到八公里,然后 knee 出了点问题,医生说少跑。明年打算换游泳。 明年想做的事:把组件化收尾;学点服务端的东西,公司内部有 Go 的技术分享,听了一次感觉挺有意思;看书比今年多一点,今年只读完三本,惭愧。 最后感谢今年帮过我的同事和网上那些写了高质量博客的陌生人。我现在写这个博客的动力之一,就是希望哪天也能帮到别人一次。

2019-12-29 · 芮靖安

iOS 组件化改造:从私有 pod 源开始

App 三岁了,单工程 40 万行代码,全量编译要 15 分钟,改一行代码喝杯水回来才能跑真机。终于说服老板让我们做组件化。这篇记录前期的方案取舍,给后来人省点力气。 先说结论:不要一步到位上"总线"架构。 网上流传的组件化方案大多是 CTMediator 那一派,运行时解注册表调用。演示很漂亮,但真落地会发现调用关系全靠字符串,IDE 跳转变瞎,重构基本靠全局搜索。我们最后的方案保守得多: 底层工具库(网络、缓存、埋点)拆成独立的私有 pod,用 cocoapods-packager 发到自建的私有源; 业务模块先只在物理上拆目录,通过 protocol 文件互相依赖,暂时不做运行时解耦; 主工程退化为一个壳,只负责组装 tab 和路由。 这个方案跑了半年,编译时间从 15 分钟降到 4 分钟(业务模块可以用 pod install 之后按需引入调试),虽然没到网上吹的"秒级编译",但对日常开发体验的提升已经非常明显。 私有源的坑。 自建源用的是 nexus,权限管理一塌糊涂,最后是靠 git tag + CI 校验版本号一致性来保证不出乱子。后来听说公司内部有 Artifactory 的 license,就迁过去了,省心很多。建议准备做组件化的团队先解决制品库的问题,工具链不顺手,组件化推行不下去——这不是技术问题,是工程习惯问题。 下一阶段的计划是把业务模块间的 protocol 依赖收敛到一个独立的接口层仓库里,谁改接口谁发版。半年后 hopefully 能再写一篇续集,写写中间件方案到底要不要上。

2019-05-21 · 芮靖安

RecyclerView 卡顿排查的几个套路

最近接手了一个商品列表页,产品反馈"滑动的时候有一点点卡,但说不清楚哪里卡"。这种模糊的需求最难办,记录一下我的排查过程,下次遇到可以直接翻这篇。 第一步永远是先量化,不要靠手感。 打开开发者选项里的 GPU 渲染模式,或者直接接 Systrace 看帧耗时。我们的情况是滚动时偶发 30ms+ 的帧,但不是每一帧都卡。这种偶发型卡顿,onCreateViewHolder 的嫌疑最大。 然后按嫌疑大小逐个排除: onBindViewHolder 里有没有做 IO?我们的问题就出在这——图片文案里的表情包配置是从 SharedPreferences 同步读的,第一次滚动到该位置时阻塞了主线程。改成启动时预加载到内存就好了。 item 布局层级是否太深。用 Layout Inspector 看了下,我们最深的一层嵌套了 7 层,把多余的 LinearLayout 换成 ConstraintLayout 之后测量耗时明显下降。 setHasFixedSize(true) 加了吗?数据变化不改变 item 高度的话,加上能省一次全量布局。 DiffUtil 用了没?我们还在用 notifyDataSetChanged(),全量刷新导致大量无意义重绘,换成 DiffUtil 后滑动顺滑了一个档次。 最后一个不算套路的心得:别过早优化。上面这些做完之后帧耗时稳定在 16ms 以内了,我本来还想上 RecyclerView 的 prefetch 调参,试了下收益几乎为零,就回滚了。性能优化到"感知不到卡"就该停手,剩下的时间是业务的时间。

2018-11-06 · 芮靖安

从 Objective-C 到 Swift:一次不太顺利的迁移

公司的主 App 跑了三年多 Objective-C,今年年初终于下定决心往 Swift 迁。原计划三个月迁完,实际用了五个多月,中间踩的坑值得记一笔。 不要想着一次性迁完。 我们最初的方案是新开一个 Swift 分支,集中人力把旧代码全部翻译一遍。干了两个星期就发现这条路走不通:业务需求不会停,分支落后主干越来越多,合并冲突解到怀疑人生。后来改成混合编译,新的模块用 Swift 写,旧的 OC 代码按模块逐步迁,才把节奏理顺。 bridging header 是个大坑。 项目里如果既有 OC 引用 Swift,又有 Swift 引用 OC,Xcode 的报错信息基本不可读。我们最后的经验是:所有 OC 头文件里用到的第三方类型,尽量在 pch 里收口,别让 Swift 端直接碰到太深的头文件链。 还有一个血泪教训:Swift 4.2 之前 API 变动太剧烈了,我们迁移中途 Swift 4.1 发布,#selector 和 KVO 相关的写法改了不少,又返工了半个月。现在回头看,选对版本窗口期很重要,大版本发布前千万别开迁。 代码示例没什么好贴的,唯一想留的是这条规律:迁移期间新代码的 code review 强度要加倍。Swift 写起来太舒服,新人很容易在 optional 处理上放飞自我,! 满天飞。我们在 CI 里加了 SwiftLint 的 implicitly_unwrapped_optional 规则之后,线上 crash 率才慢慢降下来。 总结:迁移本身不难,难的是在业务不停的情况下迁。如果重来一次,我会在开始前先把模块依赖关系理清楚,从依赖最少的叶子模块开始迁,而不是按页面从上往下迁。

2018-04-12 · 芮靖安