异步任务执行期间状态变了的几个坑
一个数据量比较大的画布应用,后台异步加载、重算数据,算完再渲染到 Canvas。这种结构踩了好几个坑,根子其实都出在同一件事上:异步任务跑的过程中,它依赖的外部状态已经被别的地方改了,任务回来还按旧状态干活,就出错。
下面这几个坑表现完全不一样,但捋到底都是这个原因。
整块空白
后台读完数据回来,往像素缓冲区里写的时候,要按”上一段的位置”算偏移。问题出在那个”上一段”——遍历的时候有些段因为被别的段完全包含而跳过了,结果拿”上一个没跳过的段”当基准,基准就错了,偏移算成负数,写缓冲越界,抛 IndexOutOfBoundsException。这个异常在渲染流程里被 catch 住了,程序不崩,但这一块就没写进去,画面上就是一片空白,啥报错都没有。
更隐蔽的是另一个根因:待渲染的段列表是个会被原地 clear() + 重建的 ArrayList。后台线程正迭代它的时候,主线程正好在 clear + add 重建,后台读到的就是个半新半旧的列表,照样算出错误的段。
第一个修法是基准只用”真正产生了段的项”,被跳过的不当参照。第二个是段列表改成不可变快照发布:
private volatile List<Segment> readSegmentList; |
读线程拿到的永远是内部一致的快照,不存在撕裂。这是 Java 里发布共享状态的标准姿势——构建好一个内容不再变的对象,通过 volatile 引用整体发布出去,别在共享对象上原地改。
还有个配套手段:重采样的时候自增一个 generation 号,异步分支写缓冲之前先校验自己的 generation 是不是最新的,过期了就丢弃这次结果,让外层重新发起。这能挡住”任务回来时坐标空间已经变了”——任务拿着旧坐标算完了,写的时候发现坐标空间已经被另一次重采样换掉了,就别写了。
加载失败后画面卡住
有个状态标志 isResampling 表示”正在重新算数据”,它本来在好几个地方置位、在回调里复位。后来为了让某个触发点更可靠,把复位收拢到”读盘回调”这一个地方。结果读盘的 catch 里只清了”正在更新缓冲”的标志,没去触发回调——一旦读盘抛异常,回调不走,isResampling 永远卡在 true,画面就卡在”重算中”的样子,该亮的亮着、该显示的标记也没了。
修法是失败也得走回调:catch 里带上一个 failed 标志去调回调,回调开头判断失败就把所有标志复位、恢复标记,而且不再补发(避免对着一直失败的读盘反复补发,演变成死循环)。
这里有个通用点:每个状态标志都得问一句——所有能置位的地方,是不是都有对应的复位点?异常路径算不算?把复位责任集中到一个回调上,就得保证所有路径(包括失败)都走得通那个回调。
滚动时画面一闪一闪
这个是顺手发现的。isResampling 这个标志还被滚动逻辑借用了:滚动的时候短暂置 true 再置 false,本意是顺带触一下”重算中”的视觉效果。但滚轮是高频操作,每次滚动都 true→false 一跳,肉眼就看到画面一闪一闪。
修法是滚动干脆不碰这个标志。滚动只是平移视口,不重新算数据,根本不需要”重算中”的提示,render() 自己会重画。
这是典型的标志语义过载:一个标志同时干两件事(真的在重算 / 滚动时亮一下),而这两件事的触发频率差好几个数量级(重算偶发、滚动高频),高频的那个就把偶发的视觉效果带成了闪烁。一个标志最好只表一个意思。
这几个坑串起来就一句话:别假设异步任务开始时看到的状态,等它回来时还一样。要么让任务能察觉自己已经过期(generation 号)然后丢弃重来;要么把任务要读的共享状态做成不可变快照,别让它读到一半被改;要么把状态机的每个置位都配上复位点,失败路径也算上。异步这一块,最容易出问题的不是任务本身,是任务回来那一刻和外面的状态对不对得上。
