众所周知,多线程开发要处理各种同步和竞争问题,一不留神就会原地爆炸。
那么问题来了,如果手头有一份成品单线程代码,如何让它支持多线程并行?
本文将介绍一个最为简单粗暴的方式,重构改动1%以下,几乎不会引入任何问题,并且在目标场景下可以完美并行。
目标场景
将这份代码当做黑盒使用,不强求内部多线程,而是将输入输出当做一个任务,多个任务之间可以并行。
问题分析
该目标场景是最简单的并行场景了,理论上没任何数据竞争,多个任务之间的关系就和多个进程一样完全隔离的。
从“输入——执行——输出”这条链条来看,包装为任务的方式,输入输出先天就隔离开来,不存在数据竞争,数据竞争主要是执行过程中的内部状态,也就是副作用。
这不还是废话么,又绕回开头了。
不不不,仔细想想,引起副作用的内部状态是怎么来的?
原因很简单,就是内部无上下文封装,所有执行共享一个上下文,因此并行场景存在竞争。
而这些内部共享的上下文是怎么存在的呢?从编码角度来看,必然是静态变量。
解决方案
问题分析到这里,解决方案已经相当清晰了,让这些静态变量每次执行时都使用独立实例就行,这样上下文/状态自然就隔离开了。
要做到这一点,无需要重构数据结构和代码逻辑,只需要引入c++11的一个关键字——thread_local,在所有静态变量的声明处加上这个关键字即可。
thread_local关键字确保了该对象在每个线程上都有一个副本。然后我们再让每个任务都在一个线程里执行,问题就解决了。
残留问题
该手段下,每次执开启一个新线程和直接复用线程池,存在一定的差异。但实际运行结果,在绝大多数场景下应该是没区别的。
如果复用线程的话,在同一个线程里多次执行,内部状态还是沿用上次的,和修改前顺序调用多次一样。
此时内部状态是否会导致多次运行结果不一致,需要看目标代码本身的逻辑。
但考虑到修改之前的单线程代码,本来就是顺序执行多次,每次都沿用上一次的内部状态,所以理论上和修改后在线程池的运行结果不会存在差异。
示范案例
我手头有一个业务需求,将一组二进制数据文件转为excel表格导出。
工具库用的QtXlsx,该库是单线程设计,未对多线程做考虑。
实际表现是:
- 生成数据对象过程中可以并行,数据内容包含在独立的c++对象中。
- 保存至xlsx时,因为xlsx有数据复用的机制,相同的内容会引用同一个数据对象,因此库内部使用静态字典来整理这些复用的数据。
- 二进制文件读取到生成xlsx内存对象耗时大约24s(秒表计时),多任务可以并行。
- 保存文件过程无法并行,通过加锁进行同步,每个文件保存需要14s(手掐秒表)。
- 因此总耗时为24 + 14*4 = 80s。
全文搜索代码中的关键字,大约有十多个static对象。无脑给所有对象都加上thread_local标注后,编译运行:
- 总耗时38s(手动秒表);
- 38 = 24 + 14,符合设想结果,完美实现并行。
为确认并行计算并未出错,进行如下验证:
- 将二进制文件复制四份;
- 用互斥锁同步版批量导出四个文件;
- 用完美并行版批量导出四个文件;
- 将八个xlsx表格用7zip解压(-x的office文件本质是zip压缩包,包含xml格式+数据文件,文本表格的数据文件也是纯文本);
- 用diff对比八组xml和文本文件。
对比结果完全相同,说明本重构方案无bug,以上。