最近在尝试用Go写一个百度网盘的第三方客户端,其中涉及到了网盘文件的下载,虽然用简单粗暴的对接http流和文件流就可以做到下载,但下载速度总是不尽如人意而且听上去也不够高端。但如果要调用aria2等专业下载工具的话又显得太笨重,于是就想着自己实现一份简单的多线程下载的工具。
基本思路
文件在下载开始的时候就预先分配好空间。在开始下载的时候给定一个下载线程的数量,每个线程负责一块文件的大小,使用HTTP头中的Range字段请求文件的局部数据,使用随机random access写入文件内容。
虽然使用了多线程下载,在有N条线程的情况下,理论上下载时间会变为1/N,但不可避免地,会有那么一条或者多条线程的下载速度特别慢,最终导致整体下载时间的延长。
解决方案也很容易就能想到,在其他的线程下载完成后,去帮助没有下载完成的线程去进行下载。虽然是个很简单的思路,但实现的时候却有很多因素需要考虑。
实现细节
如何切分任务
虽然说着切分下载任务,但文件不像蛋糕,可以切出一块来,因此需要一个切分文件的方案。我的方案是定义一个最小的文件块,我定了1MB,然后计算文件的块的数量,再去将这些个文件块分给各个下载线程
如何避免多个线程同时写造成的异常
虽然在我自己的实验中没有发生,但是理论上会出现两个线程同时往文件中写数据,并且产生异常的情况。我想到了两个解决方案:
- 为写入文件加一个全局的锁,并且只有一个文件输出流,每当线程想要写文件的时候,就需要去获得文件的锁,然后使用唯一的文件输出流,往文件中写。
- 下载线程不负责写文件,文件写由另外一个线程完成,写线程维护一个队列,下载线程获得数据内容后,将数据放进队列中,而写线程从队列中读取数据,写到文件中
方法1的缺点在于,下载线程在等待锁的过程中什么事情都做不了,在网络速度较快的情况下可能影响下载速度,但胜在简单粗暴。而方法2的缺点也很明显,逻辑复杂,需要维护一个额外的和缓存队列。
我选择了方法1,毕竟我家的小水管还不会让磁盘速度成为下载的瓶颈。
如何设计算法,进行线程调度
这一点是我认为最复杂的一点。首先调度器必须要:
- 知道当前文件的下载情况
- 知道所有线程的下载状态,HTTP请求的的启止点,当前的工作点
- 在得知以上信息后,选取合适的新的下载起止点
- 在选定起止点后,通知原有线程,任务内容有了变更
- 调度算法应该尽量的复用已有的HTTP连接,而不是优先去新建连接
第一点其实问题不大,可以通过维护一个全局的数据来实现,用不同的标志位来表示对应的文件块当前的下载情况。在有些调度算法中甚至不需要这个数据
第2、3、4、5点可以认为是一个完整的解决方案,分开来谈意义不大,有些解决方案并不需要其中一些数据。下面我给出我想到的几种实现方向
先插入一点,因为下载是时刻在进行的,所以调度算法可能在前一秒算出来的结果后一秒就过时了,因此我使用了一个全局的开关,所有线程都会去检查开关,当线程发现正在进行调度计算的时候,在完成当前文件块的的下载后,停止接收新的下载任务,等调度完成后继续下载。下面的讨论,如无特殊说明,均是在这个前提下的。
方案一
第一种方案也是我从最初实现衍生而来的。文件先等分为N份,分配给N个线程,每个线程知道自己的起点和终点,但并不会感知到其他线程的存在。
其他线程的在有一个线程完成下载后,线程自己去遍历文件的下载状态,找到最长的一块未被下载的空间,开始进行自己的下一次下载。
那么其他线程怎么知道自己的任务被抢了呢?每次线程拿任务前,都需要去检查要下载的下一个块有没有已经被他人抢先标记为下载中或者已下载,如果是,则认为本次下载完成。进入下载完成的处理逻辑
方案二
在思考的时候,先抛弃线程复用这个概念。将N个线程,改变为N个下载的资格,并且调度器持有所有线程的引用,知道所有线程当前的状态。
当一个线程下载完成后,下载资格就出现了空位,那么调度器只需要找到一个未完成任务最多的线程,对线程进行分裂,分裂成两个单独的线程,将旧有线程的下载等分,新线程使用空缺出来的下载资格。
这个方案不需要掌握整体的下载进度,因为在最初,文件已经被分到了所有的线程中,而我们之后的每一次分裂操作,都只是分裂了已有的下载任务,不会造成下载的缺失或重复
OK,我们的思路已经有了,我们再思考之前说的,线程真的不可以复用吗?答案当然是可以复用。分裂操作完全可以替换成两个旧有线程的状态变化。
其他的细节
识别不支持部分文件请求的URL,表现为,使用header中的range字段后,服务器返回的HTTP状态码不是206,而是200
对于有多次跳转的下载链接,应该先完成链接的挑战,再进行多线程的下载
加入任务拆分的阈值,如果当前任务已经小于拆分的阈值,则不进行拆分
由于网络原因,其中一个线程可能会下载失败,需要优雅地处理网络异常,并且尽可能继续完成下载工作
存在的继续改善的点
- 断点续传
很多现代的下载工具都具有断点续传的功能,但这个工具暂时还不具备