Golang实现多线程并发下载
原来HTTP协议是如此的牛逼
大家都用过迅雷等下载工具,特点就是支持并发下载,断点续传。我们这里不介绍它,这个比较复杂了,逼人也不懂。本文只介绍狭义上的简易的断点续传和狭义上的多线程下载。跟之前一样,旨在研究原理,实际生活中基本没啥用,实测下来多线程下载比单线程下载还慢。。。太丢人了。
主要讲三个方面,如何HTTP的并发下载、通过Golang进行多协程开发、如何断点续传。
HTTP的并发下载
想要并发下载,就是把下载内容分块,然后并行下载这些块。这就要求服务器能够支持分块获取数据。大迅雷、电驴这种都有自己的协议,thunder://
这种,我们只研究原理,就说说HTTP协议对于并发的支持。
HTTP头 | 对应值 | 含义 | ||||
Content-Length | 14247 | HTTP响应的Body大小,下载的时候,Body就是文件,也可以认为是文件大小,单位是比特 | ||||
Content-Disposition | inline; filename=”bryce.jpg” | 是MIME协议的扩展,MIME协议指示MIME用户代理如何显示附加的文件。当浏览器接收到头时,它会激活文件下载。这里还包含了文件名 | ||||
Accept-Ranges | bytes | 允许客户端以bytes的形式获取文件 | ||||
Range | bytes=0-511 | 分块获取数据,这里表示获取第0到第511的数据,共512字节 |
如果要下载一个文件,想知道这些文件的信息,例如文件名、文件大小、是否支持并发下载、文件类型都可以从响应的头里面获取。如何在下载前获得到这些内容而不是下载中获取,可以用HTTP提供的HEAD方法。HEAD方法只响应HTTP的头部分,不包含Body部分。
req, err := http.NewRequest("HEAD", get.Url, nil)
resp, err := get.GetClient.Do(req)
获取文件类型、文件名等参数。HTTP从Url到Head在到Body,你都可以认为是字符串,也确实是字符串,但是解析的时候不要自己以字符串的方式处理,要不恶心死你。Url的解析大Golang有net/url
包支持,MIME有mime
包支持,这都是原生包,别的语言必然也支持。
get.ContentLength = int(resp.ContentLength)
get.MediaType, get.MediaParams, _ = mime.ParseMediaType(get.Header.Get("Content-Disposition"))
log.Printf("Get %s MediaType:%s, Filename:%s, Length %d.\n", get.Url, get.MediaType, get.MediaParams["filename"], get.ContentLength)
输出
2015/07/02 09:56:47 Get https://res.cloudinary.com/cyeam/image/upload/v1537933530/cyeam/bryce.jpg MediaType:inline, Filename:bryce.jpg, Length 14247.
如果响应头里面还包含了Accept-Ranges
,就说明服务器支持分块获取:
if get.Header.Get("Accept-Ranges") != "" {
log.Printf("Server %s support Range by %s.\n", get.Header.Get("Server"), get.Header.Get("Accept-Ranges"))
} else {
log.Printf("Server %s doesn't support Range.\n", get.Header.Get("Server"))
}
分模块下载,新建N个临时文件,我的命名规则是加个分块区间的后缀,例如bryce.jpg.0-512,这样可以省掉一个配置文件(主要是我懒的写)。将下载好的块存入临时文件里面,最后都下载完之后统一存入最终的文件里面。分块下载加个Range
头就可以了。
range_i := fmt.Sprintf("%d-%d", get.DownloadRange[i][0], get.DownloadRange[i][1])
log.Printf("Download #%d bytes %s.\n", i, range_i)
defer get.TempFiles[i].Close()
req, err := http.NewRequest("GET", get.Url, nil)
req.Header.Set("Range", "bytes="+range_i)
resp, err := get.GetClient.Do(req)
defer resp.Body.Close()
最后将下载好的保持到文件里。这里是等这个块都下载完之后再写入硬盘,下载完之后都是保持在内存里面。
cnt, err := io.Copy(get.TempFiles[i], resp.Body)
多线程开发
并发下载的时候,要启动N个协程,主线程这时需要阻塞,等待这N个协程下载完毕。先开始想用channel
自己写,不是特别会。。。用sync
包辅助实现,它支持WaitGroup
,正好可以解决我这里的问题。
在主线程里面启动N个协程,Add
方法可以理解成增加一个任务,任务计数器加一;Wait
方法用于阻塞,指导所有任务完成。
for i, _ := range get.DownloadRange {
get.WG.Add(1)
go get.Download(i)
}
get.WG.Wait()
在下载协程增加Done
函数,协程结束之后通知任务完成,任务计数器减一。
defer get.WG.Done()
断点续传
这块最简单,如果任务下载暂停了,就是传输的内容不足。第一步创建临时文件的文件名后缀有派上用场了,读取到块应有的大小之后,检查块实际大小。通过文件的os.FileInfo
就能获取到文件相关属性信息。这样再下载的时候就增加一个偏移量,跳过已经下载好的内容。
for i := 0; i < len(get.DownloadRange); i++ {
range_i := fmt.Sprintf("%d-%d", get.DownloadRange[i][0], get.DownloadRange[i][1])
temp_file, err := os.OpenFile(get.FilePath+"."+range_i, os.O_RDONLY|os.O_APPEND, 0)
if err != nil {
temp_file, _ = os.Create(get.FilePath + "." + range_i)
} else {
fi, err := temp_file.Stat()
if err == nil {
get.DownloadRange[i][0] += int(fi.Size())
}
}
get.TempFiles = append(get.TempFiles, temp_file)
}
大概简单的原理就是这些,前面说了,比项目无法用于实际用途,原因如下:
- 需要有一个线程池来并发下载,目前的设计在下载大文件时会导致并发数过大。已经完成下载的线程还能继续下载未完成的块,这个又涉及到下任务的动态分配和动态拆分;
- 并发下载时的块大小,这个值也很讲究,一般4096和字节是一个内存块单位,以此数字的倍数下载比较节约内存,目前我这里的块大小是根据并发数计算的,比较水;
- 前面提到了,这是狭义的多线程下载,前提是服务器必须支持Range,否则还是无法并发获取数据,实际在做的时候最起码是会有几台下载完的服务器,这样自己的服务器就能开发支持分块取数据的协议来支持并发下载,市面上的也都是这么干;
- 还有下载进度条,就是类似于
wget
命令在控制台展示进度条和下载速度的日志,这个不太会写,查了查,据说可以通过fmt.Println("abc\rcde")
实现,\r
表示回车符,可以回到行首,我没试过,大家可以试试。
本文所涉及到的完整源码请参考。
参考文献
- http协议 文件下载原理及多线程断点续传 - zhuhuiby
- Package sync - The Go Programming Language
- How to update command line output? - stackoverflow
原文链接:Golang实现多线程并发下载,转载请注明来源!
–EOF–