24

What are the best practices for making a serial queue of NSURLSessionTasks ?

In my case, I need to:

  1. Fetch a URL inside a JSON file (NSURLSessionDataTask)
  2. Download the file at that URL (NSURLSessionDownloadTask)

Here’s what I have so far:

session = [NSURLSession sharedSession];

//Download the JSON:
NSURLRequest *dataRequest = [NSURLRequest requestWithURL:url];

NSURLSessionDataTask *task =
[session dataTaskWithRequest:dataRequest
           completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {

               //Figure out the URL of the file I want to download:
               NSJSONSerialization *json = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:nil];
               NSURL *downloadURL = [NSURL urlWithString:[json objectForKey:@"download_url"]];

               NSURLSessionDownloadTask *fileDownloadTask =
               [session downloadTaskWithRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:playlistURL]]
                              completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
                                  NSLog(@"completed!");
                              }];

               [fileDownloadTask resume];

           }
 ];

Apart from the fact that writing a completion block within another completion looks messy, I am getting an EXC_BAD_ACCESS error when I call [fileDownloadTask resume]... Even though fileDownloadTask is not nil!

So, what is the best of way of sequencing NSURLSessionTasks?

Eric
  • 16,003
  • 15
  • 87
  • 139

4 Answers4

18

You need to use this approach which is the most straight forward: https://stackoverflow.com/a/31386206/2308258

Or use an operation queue and make the tasks dependent on each others

=======================================================================

Regarding the HTTPMaximumConnectionsPerHost method

An easy way to implement a first-in first-out serial queue of NSURLSessionTasks is to run all tasks on a NSURLSession that has its HTTPMaximumConnectionsPerHost property set to 1

HTTPMaximumConnectionsPerHost only ensure that one shared connection will be used for the tasks of that session but it does not mean that they will be processed serially.

You can verify that on the network level using http://www.charlesproxy.com/, you wil discover that when setting HTTPMaximumConnectionsPerHost, your tasks will be still be started together at the same time by NSURLSession and not serially as believed.

Expriment 1:

  • Declaring a NSURLSession with HTTPMaximumConnectionsPerHost to 1
  • With task1: url = download.thinkbroadband.com/20MB.zip
  • With task2: url = download.thinkbroadband.com/20MB.zip

    1. calling [task1 resume];
    2. calling [task2 resume];

Result: task1 completionBlock is called then task2 completionBlock is called

The completion blocks might be called in the order you expected in case the tasks take the same amount of time however if you try to download two different thing using the same NSURLSession you will discover that NSURLSession does not have any underlying ordering of your calls but only completes whatever finishes first.

Expriment 2:

  • Declaring a NSURLSession with HTTPMaximumConnectionsPerHost to 1
  • task1: url = download.thinkbroadband.com/20MB.zip
  • task2: url = download.thinkbroadband.com/10MB.zip (smaller file)

    1. calling [task1 resume];
    2. calling [task2 resume];

Result: task2 completionBlock is called then task1 completionBlock is called

In conclusion you need to do the ordering yourself, NSURLSession does not have any logic about ordering requests it will just call the completionBlock of whatever finishes first even when setting the maximum number of connections per host to 1

PS: Sorry for the format of the post I do not have enough reputation to post screenshots.

Community
  • 1
  • 1
matanwrites
  • 812
  • 1
  • 10
  • 18
  • Timeout counts from [task resume], not from actual network request. This makes `HTTPMaximumConnectionsPerHost` approach almost useless. – szotp Oct 23 '19 at 12:47
13

Edit:

As mataejoon has pointed out, setting HTTPMaximumConnectionsPerHost to 1 will not guarantee that the connections are processed serially. Try a different approach (as in my original answer bellow) if you need a reliable serial queue of NSURLSessionTask.


An easy way to implement a first-in first-out serial queue of NSURLSessionTasks is to run all tasks on a NSURLSession that has its HTTPMaximumConnectionsPerHost property set to 1:

+ (NSURLSession *)session
{
    static NSURLSession *session = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{

        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];

        [configuration setHTTPMaximumConnectionsPerHost:1];

        session = [NSURLSession sessionWithConfiguration:configuration];

    });
    return session;
}

then add tasks to it in the order you want.

NSURLSessionDataTask *sizeTask = 
[[[self class] session] dataTaskWithURL:url 
                          completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
Peter Lapisu
  • 19,915
  • 16
  • 123
  • 179
Eric
  • 16,003
  • 15
  • 87
  • 139
  • Should we override `session:` method? I also ran into the same problem, any new approaches? – David Liu Jun 23 '14 at 15:58
  • @DavidLiu: You're not overriding the `session:` method; you create it from scratch, it's not part of the `NSURLSession` API. – Eric Jun 23 '14 at 22:08
  • you mean add this class method to NSURLSession via a new category? – David Liu Jun 24 '14 at 12:45
  • 1
    I mean add this class method to whichever class of yours is responsible for doing networking. Alternatively, it may also work as a category on `NSURLSession`. – Eric Jun 24 '14 at 22:08
  • Categories don't seem to work with NSURLSession and NSURLSessionTask classes :( – mph Nov 07 '14 at 20:54
  • Not works for me. I create 10 downloadTasks in right order, but they finished in confused order... Sample finish order: 897564312. – Sound Blaster Jan 28 '15 at 18:03
  • 1
    See answer regarding HTTPMaximumConnectionsPerHost below – Max MacLeod Dec 23 '15 at 10:50
  • this would have been good but it doesn't work, the internal network threads seem completely disconnected from the delegate queue. – malhal Sep 04 '16 at 01:31
  • The `maximumConnectionsPerHost` setting does not control in any way how many tasks can be executed at the same time. With HTTP Pipelining, several requests can be sent while the others are waiting for their responses and with HTTP 2 there can even be several responses received in parallel over one connection. So don't rely on that influencing the order of your task-completions in any way! – Joachim Kurz Dec 13 '16 at 09:41
  • @PeterLapisu: https://meta.stackexchange.com/questions/37738/when-or-should-you-delete-your-incorrect-answer – Eric Jun 08 '17 at 22:45
1
#import "SessionTaskQueue.h"

@interface SessionTaskQueue ()

@property (nonatomic, strong) NSMutableArray * sessionTasks;
@property (nonatomic, strong) NSURLSessionTask * currentTask;

@end

@implementation SessionTaskQueue

- (instancetype)init {

    self = [super init];
    if (self) {

        self.sessionTasks = [[NSMutableArray alloc] initWithCapacity:15];

    }
    return self;

}

- (void)addSessionTask:(NSURLSessionTask *)sessionTask {

    [self.sessionTasks addObject:sessionTask];
    [self resume];

}

// call in the completion block of the sessionTask
- (void)sessionTaskFinished:(NSURLSessionTask *)sessionTask {

    self.currentTask = nil;
    [self resume];

}

- (void)resume {

    if (self.currentTask) {
        return;
    }

    self.currentTask = [self.sessionTasks firstObject];
    if (self.currentTask) {
        [self.sessionTasks removeObjectAtIndex:0];
        [self.currentTask resume];
    }

}

@end

and use like this

    __block __weak NSURLSessionTask * wsessionTask;
    use_wself();

    wsessionTask = [[CommonServices shared] doSomeStuffWithCompletion:^(NSError * _Nullable error) {
        use_sself();

        [self.sessionTaskQueue sessionTaskFinished:wsessionTask];

        ...

    }];

    [self.sessionTaskQueue addSessionTask:wsessionTask];
Peter Lapisu
  • 19,915
  • 16
  • 123
  • 179
0

I use NSOperationQueue (as Owen has suggested). Put the NSURLSessionTasks in NSOperation subclasses and set any dependancies. Dependent tasks will wait until the task they are dependent on is completed before running but will not check the status (success or failure) so add some logic to control the process.

In my case, the first task checks if the user has a valid account and creates one if necessary. In the first task I update a NSUserDefault value to indicate the account is valid (or there is an error). The second task checks the NSUserDefault value and if all OK uses the user credentials to post some data to the server.

(Sticking the NSURLSessionTasks in separate NSOperation subclasses also made my code easier to navigate)

Add the NSOperation subclasses to the NSOperationQueue and set any dependencies:

 NSOperationQueue  *ftfQueue = [NSOperationQueue new];
 FTFCreateAccount *createFTFAccount = [[FTFCreateAccount alloc]init];
 [createFTFAccount setUserid:@"********"];  // Userid to be checked / created
 [ftfQueue addOperation:createFTFAccount];
 FTFPostRoute *postFTFRoute = [[FTFPostRoute alloc]init];
 [postFTFRoute addDependency:createFTFAccount];
 [ftfQueue addOperation:postFTFRoute];

In the first NSOperation subclass checks if account exists on server:

@implementation FTFCreateAccount
{
    NSString *_accountCreationStatus;
}



- (void)main {

    NSDate *startDate = [[NSDate alloc] init];
    float timeElapsed;

    NSString *ftfAccountStatusKey    = @"ftfAccountStatus";
    NSString *ftfAccountStatus    = (NSString *)[[NSUserDefaults standardUserDefaults] objectForKey:ftfAccountStatusKey];
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setValue:@"CHECKING" forKey:ftfAccountStatusKey];

    // Setup and Run the NSURLSessionTask
    [self createFTFAccount:[self userid]];

    // Hold it here until the SessionTask completion handler updates the _accountCreationStatus
    // Or the process takes too long (possible connection error)

    while ((!_accountCreationStatus) && (timeElapsed < 5.0)) {
        NSDate *currentDate = [[NSDate alloc] init];     
        timeElapsed = [currentDate timeIntervalSinceDate:startDate];    
    }
    if ([_accountCreationStatus isEqualToString:@"CONNECTION PROBLEM"] || !_accountCreationStatus) [self cancel];

    if ([self isCancelled]) {
        NSLog(@"DEBUG FTFCreateAccount Cancelled" );
        NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
        [userDefaults setValue:@"ERROR" forKey:ftfAccountStatusKey];            
    }    
}

In the next NSOperation post data:

@implementation FTFPostRoute
{
    NSString *_routePostStatus;
}

- (void)main {

    NSDate *startDate = [[NSDate alloc] init];
    float timeElapsed;
    NSString *ftfAccountStatusKey    = @"ftfAccountStatus";
    NSString *ftfAccountStatus    = (NSString *)[[NSUserDefaults standardUserDefaults] objectForKey:ftfAccountStatusKey];

    if ([ftfAccountStatus isEqualToString:@"ERROR"])
    {
        // There was a ERROR in creating / accessing the user account.  Cancel the post
        [self cancel];
    } else
    {
        // Call method to setup and run json post

        // Hold it here until a reply comes back from the operation
        while ((!_routePostStatus) && (timeElapsed < 3)) {
            NSDate *currentDate = [[NSDate alloc] init];          
            timeElapsed = [currentDate timeIntervalSinceDate:startDate];                
            NSLog(@"FTFPostRoute time elapsed: %f", timeElapsed);                
        }

    }


    if ([self isCancelled]) {
        NSLog(@"FTFPostRoute operation cancelled");
    }
}
Peter Todd
  • 8,561
  • 3
  • 32
  • 38
  • 1
    I like simplicity of your approach, but "holding" blocks spike CPU unnecessary. Did you change your approach later? – katit Aug 06 '14 at 00:25
  • @greentor The question is asking for a queue using NSURLSessionTasks which _do not_ inherit from NSOperation. An NSOperationQueue only will work with NSOperation's or objects which subclass NSOperation. Somebody correct me if I'm wrong? – skålfyfan Aug 21 '14 at 18:32
  • @katit I agree, holding blocks are a bad idea. My approach is now to have the completion handler call another operation or send a delegate message based on the json reply from the server. Operations are managed by a controller class which includes a timeout function that cancels the call if it takes too long. I'm also using Google app engine as a server now so that was an opportunity to redesign my calls. A lot more complex than the original NSOperation implementation but required by increased complexity of the app. – Peter Todd Aug 23 '14 at 11:03