URLSession downloadTask much slower than internet connection
Asked Answered
C

1

5

I'm using URLSession and downloadTask to download a file in the foreground. The download is much slower than expected. Other posts I found address the issue for background tasks.

let config = URLSessionConfiguration.default
config.httpMaximumConnectionsPerHost = 20
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

let request = URLRequest(url: url)
let completion: ((URL?, Error?) -> Void) = { (tempLocalUrl, error) in
  print("Download over")
}
value.completion = completion
value.task = self.session.downloadTask(with: request)

I'm observing a network usage of ~150kb/s while a speed test on my device reports a connection of 5MB/s

=== Edit

I can confirm that coding a multipart download (which is a bit of a pain to do) speeds up things by a lot.

Chaiken answered 9/1, 2017 at 11:53 Comment(7)
If I start a number of downloads at once, the network usage goes up to ~800kb/s, which is half way through what I needChaiken
Can you please provide your sample URL?Screamer
Sure. I'm downloading videos. One example can be: s3.amazonaws.com/mettavr/videos/… (8.5 Mb)Chaiken
How did you measure the speed?Screamer
It's hard to keep an exactly consistent bandwidth so tests with different settings are not run in the exact same conditions. However they are not expected to vary too much. I've been roughly measuring time as well as looking at the network usage reported with Xcode. With both metrics there's a ~10x difference with multipart download. So I think this leaves no room for inaccurate conclusion.Chaiken
I was on a bandwidth that's ~15MB/s and the default download task would use ~150ks/s, which is 1% of the bandwidth. The multipart download was using ~2-3Mb/s, which might be related to the number of parallel requests I'm making that I don't want to push too much (I was at 20)Chaiken
config.httpMaximumConnectionsPerHost = 20. Change host to 1 and then tryWormhole
R
7

If that helps anyone, here is my code to speed up the download. It splits the file download in a number of file parts downloads, which uses the available bandwidth more efficiently. It still feels wrong to have to do that...

The final usage is like:

// task.pause is not implemented yet
let task = FileDownloadManager.shared.download(from:someUrl)
task.delegate = self
task.resume()

and here's the code:

/// Holds a weak reverence
class Weak<T: AnyObject> {
  weak var value : T?
  init (value: T) {
    self.value = value
  }
}

enum DownloadError: Error {
  case missingData
}

/// Represents the download of one part of the file
fileprivate class DownloadTask {
  /// The position (included) of the first byte
  let startOffset: Int64
  /// The position (not included) of the last byte
  let endOffset: Int64
  /// The byte length of the part
  var size: Int64 { return endOffset - startOffset }
  /// The number of bytes currently written
  var bytesWritten: Int64 = 0
  /// The URL task corresponding to the download
  let request: URLSessionDownloadTask
  /// The disk location of the saved file
  var didWriteTo: URL?

  init(for url: URL, from start: Int64, to end: Int64, in session: URLSession) {
    startOffset = start
    endOffset = end

    var request = URLRequest(url: url)
    request.httpMethod = "GET"
    request.allHTTPHeaderFields?["Range"] = "bytes=\(start)-\(end - 1)"

    self.request = session.downloadTask(with: request)
  }
}

/// Represents the download of a file (that is done in multi parts)
class MultiPartsDownloadTask {

  weak var delegate: MultiPartDownloadTaskDelegate?
  /// the current progress, from 0 to 1
  var progress: CGFloat {
    var total: Int64 = 0
    var written: Int64 = 0
    parts.forEach({ part in
      total += part.size
      written += part.bytesWritten
    })
    guard total > 0 else { return 0 }
    return CGFloat(written) / CGFloat(total)
  }

  fileprivate var parts = [DownloadTask]()
  fileprivate var contentLength: Int64?
  fileprivate let url: URL
  private var session: URLSession
  private var isStoped = false
  private var isResumed = false
  /// When the download started
  private var startedAt: Date
  /// An estimate on how long left before the download is over
  var remainingTimeEstimate: CGFloat {
    let progress = self.progress
    guard progress > 0 else { return CGFloat.greatestFiniteMagnitude }
    return CGFloat(Date().timeIntervalSince(startedAt)) / progress * (1 - progress)
  }

  fileprivate init(from url: URL, in session: URLSession) {
    self.url = url
    self.session = session
    startedAt = Date()

    getRemoteResourceSize().then { [weak self] size -> Void in
      guard let wself = self else { return }
      wself.contentLength = size
      wself.createDownloadParts()

      if wself.isResumed {
        wself.resume()
      }
    }.catch { [weak self] error in
      guard let wself = self else { return }
      wself.isStoped = true
    }
  }

  /// Start the download
  func resume() {
    guard !isStoped else { return }
    startedAt = Date()
    isResumed = true
    parts.forEach({ $0.request.resume() })
  }

  /// Cancels the download
  func cancel() {
    guard !isStoped else { return }
    parts.forEach({ $0.request.cancel() })
  }

  /// Fetch the file size of a remote resource
  private func getRemoteResourceSize(completion: @escaping (Int64?, Error?) -> Void) {
    var headRequest = URLRequest(url: url)
    headRequest.httpMethod = "HEAD"
    session.dataTask(with: headRequest, completionHandler: { (data, response, error) in
      if let error = error {
        completion(nil, error)
        return
      }
      guard let expectedContentLength = response?.expectedContentLength else {
        completion(nil, FileCacheError.sizeNotAvailableForRemoteResource)
        return
      }
      completion(expectedContentLength, nil)
    }).resume()
  }

  /// Split the download request into multiple request to use more bandwidth
  private func createDownloadParts() {
    guard let size = contentLength else { return }

    let numberOfRequests = 20
    for i in 0..<numberOfRequests {
      let start = Int64(ceil(CGFloat(Int64(i) * size) / CGFloat(numberOfRequests)))
      let end = Int64(ceil(CGFloat(Int64(i + 1) * size) / CGFloat(numberOfRequests)))
      parts.append(DownloadTask(for: url, from: start, to: end, in: session))
    }
  }

  fileprivate func didFail(_ error: Error) {
    cancel()
    delegate?.didFail(self, error: error)
  }

  fileprivate func didFinishOnePart() {
    if parts.filter({ $0.didWriteTo != nil }).count == parts.count {
      mergeFiles()
    }
  }

  /// Put together the download files
  private func mergeFiles() {
    let ext = self.url.pathExtension
    let destination = Constants.tempDirectory
      .appendingPathComponent("\(String.random(ofLength: 5))")
      .appendingPathExtension(ext)

    do {
      let partLocations = parts.flatMap({ $0.didWriteTo })
      try FileManager.default.merge(files: partLocations, to: destination)
      delegate?.didFinish(self, didFinishDownloadingTo: destination)
      for partLocation in partLocations {
        do {
          try FileManager.default.removeItem(at: partLocation)
        } catch {
          report(error)
        }
      }
    } catch {
      delegate?.didFail(self, error: error)
    }
  }

  deinit {
    FileDownloadManager.shared.tasks = FileDownloadManager.shared.tasks.filter({
      $0.value !== self
    })
  }
}

protocol MultiPartDownloadTaskDelegate: class {
  /// Called when the download progress changed
  func didProgress(
    _ downloadTask: MultiPartsDownloadTask
  )

  /// Called when the download finished succesfully
  func didFinish(
    _ downloadTask: MultiPartsDownloadTask,
    didFinishDownloadingTo location: URL
  )

  /// Called when the download failed
  func didFail(_ downloadTask: MultiPartsDownloadTask, error: Error)
}

/// Manage files downloads
class FileDownloadManager: NSObject {
  static let shared = FileDownloadManager()
  private var session: URLSession!
  fileprivate var tasks = [Weak<MultiPartsDownloadTask>]()

  private override init() {
    super.init()
    let config = URLSessionConfiguration.default
    config.httpMaximumConnectionsPerHost = 50
    session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
  }

  /// Create a task to download a file
  func download(from url: URL) -> MultiPartsDownloadTask {
    let task = MultiPartsDownloadTask(from: url, in: session)
    tasks.append(Weak(value: task))
    return task
  }

  /// Returns the download task that correspond to the URL task
  fileprivate func match(request: URLSessionTask) -> (MultiPartsDownloadTask, DownloadTask)? {
    for wtask in tasks {
      if let task = wtask.value {
        for part in task.parts {
          if part.request == request {
            return (task, part)
          }
        }
      }
    }
    return nil
  }
}

extension FileDownloadManager: URLSessionDownloadDelegate {
  public func urlSession(
    _ session: URLSession,
    downloadTask: URLSessionDownloadTask,
    didWriteData bytesWritten: Int64,
    totalBytesWritten: Int64,
    totalBytesExpectedToWrite: Int64
  ) {
    guard let x = match(request: downloadTask) else { return }
    let multiPart = x.0
    let part = x.1

    part.bytesWritten = totalBytesWritten
    multiPart.delegate?.didProgress(multiPart)
  }

  func urlSession(
    _ session: URLSession,
    downloadTask: URLSessionDownloadTask,
    didFinishDownloadingTo location: URL
    ) {
    guard let x = match(request: downloadTask) else { return }
    let multiPart = x.0
    let part = x.1

    let ext = multiPart.url.pathExtension
    let destination = Constants.tempDirectory
      .appendingPathComponent("\(String.random(ofLength: 5))")
      .appendingPathExtension(ext)

    do {
      try FileManager.default.moveItem(at: location, to: destination)
    } catch {
      multiPart.didFail(error)
      return
    }

    part.didWriteTo = destination
    multiPart.didFinishOnePart()
  }

  func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    guard let error = error, let multipart = match(request: task)?.0 else { return }
    multipart.didFail(error)
  }
}

extension FileManager {
  /// Merge the files into one (without deleting the files)
  func merge(files: [URL], to destination: URL, chunkSize: Int = 1000000) throws {
    FileManager.default.createFile(atPath: destination.path, contents: nil, attributes: nil)
    let writer = try FileHandle(forWritingTo: destination)
    try files.forEach({ partLocation in
      let reader = try FileHandle(forReadingFrom: partLocation)
      var data = reader.readData(ofLength: chunkSize)
      while data.count > 0 {
        writer.write(data)
        data = reader.readData(ofLength: chunkSize)
      }
      reader.closeFile()
    })
    writer.closeFile()
  }
}
Renoir answered 18/1, 2017 at 19:24 Comment(1)
Is this still the best solution out there in 2024 (without using any 3rd party)?Jarib

© 2022 - 2024 — McMap. All rights reserved.