20

I've been using cURL to download about 1700+ files -- which total to about ~290MB -- in my iOS app. It takes about 5-7 minutes on my Internet connection to download all of them using cURL. But since not everyone has fast internet connection (especially when on the go), I decided to allow the files to be downloaded in the background, so that the user can do other things while waiting for the download to finish. This is where NSURLSession comes in.

Using NSURLSession, it takes about 20+ minutes on my Internet connection to download all of them while the app is in foreground. I don't mind it being slow when the app is in background, because I understand that it is up to the OS to schedule the downloads. But it's a problem when it's slow even when it's in foreground. Is this the expected behaviour? Is it because of the quantity of the files?

In case I'm not using NSURLSession correctly, here's a snippet of how I'm using it:

// Initialization

NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"<my-identifier>"];
sessionConfiguration.HTTPMaximumConnectionsPerHost = 40;

backgroundSession = [NSURLSession sessionWithConfiguration:sessionConfiguration
                                                  delegate:self
                                             delegateQueue:nil];

// ...

// Creating the tasks and starting the download
for (int i = 0; i < 20 && queuedRequests.count > 0; i++) {
    NSDictionary *requestInfo = [queuedRequests lastObject];
    NSURLSessionDownloadTask *downloadTask = [backgroundSession downloadTaskWithURL:[NSURL URLWithString:requestInfo[@"url"]]];
    ongoingRequests[@(downloadTask.taskIdentifier)] = requestInfo;
    [downloadTask resume];
    [queuedRequests removeLastObject];
    NSLog(@"Begin download file %d/%d: %@", allRequests.count - queuedRequests.count, allRequests.count, requestInfo[@"url"]);
}

// ...

// Somewhere in (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location

// After each download task is completed, grab a file to download from
// queuedRequests, and create another task

if (queuedRequests.count > 0) {
    requestInfo = [queuedRequests lastObject];
    NSURLSessionDownloadTask *newDownloadTask = [backgroundSession downloadTaskWithURL:[NSURL URLWithString:requestInfo[@"url"]]];
    ongoingRequests[@(newDownloadTask.taskIdentifier)] = requestInfo;

    [newDownloadTask resume];
    [queuedRequests removeLastObject];
    NSLog(@"Begin download file %d/%d: %@", allRequests.count - queuedRequests.count, allRequests.count, requestInfo[@"url"]);
}

I've also tried using multiple NSURLSession, but it's still slow. The reason I tried that is because when using cURL, I create multiple threads (around 20), and each thread will download a single file at a time.

It's also not possible for me to reduce the number of files by zipping it, because I need the app to be able to download individual files since I will update them from time to time. Basically, when the app starts, it will check if there are any files that have been updated, and only download those files. Since the files are stored in S3, and S3 doesn't have zipping service, I could not zip them into a single file on the fly.

Hasyimi Bahrudin
  • 849
  • 1
  • 8
  • 12
  • 1
    Try replacing `backgroundSessionConfigurationWithIdentifier:` with `defaultSessionConfiguration` and compare the results. I don't think iOS really cares if your app is running or not when downloading on a session configured for background downloads. It will always do those downloads on a separate process which throttles speed. – Filip Radelic Jan 30 '15 at 01:58
  • 1
    1. I agree with Filip: Temporarily try using non-background session and I think you might see performance difference. My anecdotal testing suggested that background session (even fired from foreground) was slower, though I haven't tested this recently. – Rob Jan 30 '15 at 02:25
  • 3
    2. When using background session, you don't need this "queue up the first 20 process". Just queue them all up. By metering them out like that, you're going to slow down the background process because it's going to be constantly re-firing up your app if suspended/terminated. Foreground session require controlling the degree of concurrency to avoid timeouts, but not for background sessions. (Then again, if I was controlling degree of concurrency in foreground session, I think operation queue is easier solution in that case. But that's perhaps academic if you're using background session.) – Rob Jan 30 '15 at 02:25
  • @FilipRadelic You're right. It's as fast as using `cURL` now. I'll try creating a separate `NSURLSession` to download when the app is switched to background. I'll post an answer if this works. – Hasyimi Bahrudin Jan 30 '15 at 03:25
  • @Rob I did try queueing them all up. But it seems that the queueing all 1700+ files at once takes minutes, and the download will only start after queueing is finished. – Hasyimi Bahrudin Jan 30 '15 at 03:27
  • 2
    @HasyimiBahrudin I just benchmarked the scheduling 97 downloads, and it took 2.9 seconds on simulator and 1.7 seconds on device, so I guess I could imagine that scheduling 1700 could take a minute or so (assuming the scale was linear). Interestingly, though, I'm not sure I'd be too worried about that delay because I saw the initial requests already finishing before the the scheduling was done, so as long as you (a) don't block the main thread scheduling these background requests; and (b) make sure to wrap it in a `beginBackgroundTask`, you should be fine. Better than 20 at a time, IMHO. – Rob Jan 30 '15 at 04:33
  • @Rob Ah, you're right. And actually the scheduling only takes seconds (like around 20 - 30 seconds.) It took minutes because I was removing object from an array in a loop. Thanks! :) – Hasyimi Bahrudin Jan 30 '15 at 04:55
  • @Rob It turns out that there is a problem when I schedule them all at one go. After a minute or so, all of the tasks will timeout. This does not happen when I only queue 20 tasks. – Hasyimi Bahrudin Jan 30 '15 at 09:30
  • As I tried to describe in my point #2, up above, that's true for foreground sessions. Not background sessions, though. – Rob Jan 30 '15 at 11:46
  • @Rob, I also experienced scheduling lots of _background_ downloads tasks, and it's definitely *not* linear. More the number of background tasks are, longer it will take. If the app enter background before the end of the scheduling, the background download will never finish! I think Hasyimi solution to split the tasks in chucks of N is a good one, because it ensure the app to schedule and download everything – Martin Sep 03 '16 at 16:10
  • @Martin - Hey, if that works for you, that's fine. I'd rather schedule everything (and just give the app enough time to finish the scheduling with `beginBackgroundTask`), but to each his own. FYI, if you follow this N-at-a-time approach, be aware that the subsequent batches will always have `discretionary` set to `true`. But, then, again, if you're downloading that much data, you probably should use discretionary connection, anyway. – Rob Sep 03 '16 at 18:04

1 Answers1

27

As mentioned by Filip and Rob in the comments, the slowness is because when NSURLSession is initialized with backgroundSessionConfigurationWithIdentifier:, the download tasks will be executed in the background regardless if the app is in the foreground. So I solved this issue by having 2 instances of NSURLSession: one for foreground download, and one for background download:

NSURLSessionConfiguration *foregroundSessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
foregroundSessionConfig.HTTPMaximumConnectionsPerHost = 40;

foregroundSession = [NSURLSession sessionWithConfiguration:foregroundSessionConfig
                                                  delegate:self
                                             delegateQueue:nil];
[foregroundSession retain];

NSURLSessionConfiguration *backgroundSessionConfig = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.terato.darknessfallen.BackgroundDownload"];
backgroundSessionConfig.HTTPMaximumConnectionsPerHost = 40;

backgroundSession = [NSURLSession sessionWithConfiguration:backgroundSessionConfig
                                                  delegate:self
                                             delegateQueue:nil];
[backgroundSession retain];

When the app is switched to background, I simply call cancelByProducingResumeData: on each of the download tasks that's still running, and then pass it to downloadTaskWithResumeData::

- (void)switchToBackground
{
    if (state == kDownloadManagerStateForeground) {
        [foregroundSession getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
            for (NSURLSessionDownloadTask *downloadTask in downloadTasks) {
                [downloadTask cancelByProducingResumeData:^(NSData *resumeData) {
                    NSURLSessionDownloadTask *downloadTask = [backgroundSession downloadTaskWithResumeData:resumeData];
                    [downloadTask resume];
                }];
            }
        }];

        state = kDownloadManagerStateBackground;
    }
}

Likewise, when the app is switched to foreground, I do the same but switched foregroundSession with backgroundSession:

- (void)switchToForeground
{
    if (state == kDownloadManagerStateBackground) {
        [backgroundSession getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
            for (NSURLSessionDownloadTask *downloadTask in downloadTasks) {
                [downloadTask cancelByProducingResumeData:^(NSData *resumeData) {
                    NSURLSessionDownloadTask *downloadTask = [foregroundSession downloadTaskWithResumeData:resumeData];
                    [downloadTask resume];
                }];
            }
        }];

        state = kDownloadManagerStateForeground;
    }
}

Also, don't forget to call beginBackgroundTaskWithExpirationHandler: before calling switchToBackground when the app is switched to background. This is to ensure that the method is allowed to complete while in background. Otherwise, it will only be called once the app enters foreground again.

Lal Krishna
  • 15,485
  • 6
  • 64
  • 84
Hasyimi Bahrudin
  • 849
  • 1
  • 8
  • 12
  • you call this twice, `foregroundSessionConfig.HTTPMaximumConnectionsPerHost = 40;` I think the second time you meant to use the object `backgroundSessionConfig` instead? – Albert Renshaw Oct 21 '15 at 01:29
  • 2015 and still in manual reference counting?! :) Btw, very good question and answer, thanks. – Martin Sep 03 '16 at 16:13
  • Is it shorter to schedule with `downloadTaskWithResumeData:` than `downloadTaskWithURL:` ? Because if no, and if you really have thousand of files, you'll never finish the foreground-to-background migration withing the 5 seconds that `- (void)applicationDidEnterBackground:(UIApplication *)application` gives to you – Martin Sep 03 '16 at 16:17
  • 1
    @Martin - So, just request extra time, if needed with [`beginBackgroundTask(withName:expirationHandler:)`](https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIApplication_Class/index.html#//apple_ref/occ/instm/UIApplication/beginBackgroundTaskWithName:expirationHandler:). That way you have 3 minutes after the app enters background mode to do the scheduling, not just 5 seconds. – Rob Sep 03 '16 at 17:59
  • thanks @Rob, I'm currently experimenting background tasks, this method seems to be a pretty good solution. – Martin Sep 05 '16 at 13:37
  • This answer is amazingly amazing! Thank you! – Chase Holland Jul 07 '17 at 02:56
  • Is this only available for downloads? What about uploads? – Cristiano Coelho Nov 14 '19 at 01:54
  • @CristianoCoelho i try to use sessionWithConfiguration instead of backgroundSessionConfigurationWithIdentifier, the speed of both download,upload have significantly when use in foreground. I haven't tried implementing all the code above but the issue about the slow download/upload might be it – 123 superatom Apr 14 '23 at 09:24