概念
将一个复杂的任务分解成更简单的任务再一一解决, 使得每一个子程序更加易于理解并确保其正确, 这是我们常用的方法. 虽然给函数起名是一件痛苦的事情, 但大多数时候我们都乐于做这样的分解。
任务在执行过程中视情况动态地创建(派生)子任务, 然后聚合子任务的结果, 这种并发地处理子问题的方法就是fork/join(派生/聚合)模式。
例如,重复调用外部函数、斐波那契数列的递归等等会产生子任务的应用场景下,我们很自然就希望不用像以前那样让那些不直接依赖的子问题并行解决, 而是充分结合多线程并发地解决。
问题
- 如果执行通道不是固定线程数的线程池, 会产生很多线程
- 如果执行通道是固定线程数的线程池, 有很多的任务在等待子任务导致没有线程去执行子任务了
- 没等子任务完成, 父任务就返回了
- 子任务相互依赖, 导致奇怪的死锁
实现
线程池的实现
线程池的阻塞
相对于不限线程数的fork/join, 我们更期待固定线程数的线程池的fork/join, 但这样会死锁(所有线程都在创建完子任务后进入了等待子任务的阻塞状态,此时线程池内已经没有就绪的线程去处理子任务了),因此需要异步来提高性能和系统吞吐量。
异步和任务队列
实现任务队列,将任务和任务处理解耦,线程从任务队列去出任务进行处理,任务阻塞时异步处理子任务。
- 但仍然存在两个问题:
- 如果是有多个工作线程的情况, 子任务提交的子任务可能被其他线程拿掉而导致当前线程拿不到任务而退出, 此时任务队列是空的, 当前线程仍会进入阻塞等待, 但是没关系, 此时等待的子任务已经在执行了, 不会导致死锁。
- 一般任务队列是先进先出的, 那么当前线程不一定先执行自己提交的子任务, 也可能是执行任务队列中茫茫多的别人的任务, 那就冤了, 那得猴年马月才轮到自己的子任务, 这样cache也不友好. 而且, 别人的任务大概也有子任务, 这样无限制地调用当前线程, 导致调用栈会堆得很高, 高到可能爆栈。
- 解决办法:
-fork/join一般采用双端队列, 提交子任务的时候提交到队首, 保证无论哪个线程拿了队首任务, 都保证了子任务先被执行, 减少调用栈过深现象的发生, 调用栈很高得情况会比单端队列少一些。 - 更根本的解决方法是”直接切换调用栈”, 这便是n:m有栈协程的方案, 比如go语言的协程调度
双端队列
如果想尽量在本线程完成自己提交的子任务, 工作线程就需要维护一个自己的任务队列, 然后双端队列保证自己提交得子任务后进先出, 先取本线程的任务队列的任务来执行(这里用双端队列而不是栈是为了未来允许其他线程过来work stealing)。
每个工作线程都有一个双端任务队列之后,首先每个线程先尝试从自己的任务队列中取任务执行, 直到线程的任务队列为空, 再从线程池的公共任务队列取任务。而任务提交之前,先找一下有没有当前线程对应的任务队列, 没有才提交到线程池的任务队列中。
任务有大有小, 自己的大任务分解后很久都做不完怎么办? 其它线程闲着了这么办? 然后人们又让线程没任务的时候去帮其他线程, 这种玩法叫work stealing。
任务窃取
work stealing(工作窃取)帮助我们达成负载均衡后, 对于很多算法, 我们会递归地进行并发分解, 直到问题的”大小”小于某个阈值而不继续分解, 能充分地利用并发性。