Tuesday, January 8, 2013

On iOS URL Connection Parallelism and Thread Pools

The problem

Did you know iOS has a limit on the number of concurrent NSURLConnections to the same end-point? No? Maybe you should, we discovered it the hard way.

Create a new single-view app project on Xcode and copy the following snippet inside the viewDidLoad event of your view controller:


- (void)viewDidLoad
{
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
NSMutableArray *connections= [NSMutableArray array];
for (int i= 0; i < 4; i++) {
NSURL *url= [NSURL URLWithString:@"http://push.lightstreamer.com/lightstreamer/create_session.txt?LS_user=&LS_adapter_set=DEMO&LS_ios_version=1.0"];
NSURLRequest *req= [NSURLRequest requestWithURL:url];
NSURLConnection *conn= [[NSURLConnection alloc] initWithRequest:req delegate:self startImmediately:NO];
[connections addObject:conn];
}
for (NSURLConnection *conn in connections)
[conn start];
}


Then add some simple NSURLConnection delegate event handlers: 


- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
NSLog(@"Connection %p did receive response %d", connection, [(NSHTTPURLResponse *) response statusCode]);
}

- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
NSLog(@"Connection %p did receive %d bytes:\n%@", connection, [data length], [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] autorelease]);
}

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error
{
NSLog(@"Connection %p did fail with error %@", connection, error);
}

So, what does this code do? It will open 4 simultaneous stream sessions to Lightstreamer's demo server. There's nothing secret with this code: if you read Lightstreamer's Network Protocol Tutorial you can see the URL request is compliant with the documentation. Except for that little "LS_ios_version" argument, which is needed to overcome NSURLConnection buffering.

Run it and you will see 4 concurrent connections gracefully opening and then receiving Lightstreamer's keepalive message ("PROBE") once every 5 seconds. Everything looks fine.

Now try to set the number of connections (in the for loop) to 5 or more, and you will have a surprise. From the fifth connection on, nothing will happen. The connection won't open, and if you wait long enough you will get something like this:

2013-01-07 17:03:27.828 LSConcurrencyTest3[31820:c07] Connection 0x715dd60 did fail with error Error Domain=NSURLErrorDomain Code=-1001 "The request timed out." UserInfo=0xe03ea70 {NSErrorFailingURLStringKey=http://push.lightstreamer.com/lightstreamer/create_session.txt?LS_user=&LS_adapter_set=DEMO&LS_ios_version=1.0, NSErrorFailingURLKey=http://push.lightstreamer.com/lightstreamer/create_session.txt?LS_user=&LS_adapter_set=DEMO&LS_ios_version=1.0, NSLocalizedDescription=The request timed out., NSUnderlyingError=0x716d390 "The request timed out."}

Time out. Maybe you could think it's Lightstreamer's demo server which is limiting the number of concurrent connections. It's a reasonable doubt, so let's use something else. Let's try with the download of Apple's OS X 10.8.2 Combo Update, which is big enough (more on this later) to exhibit the same problem. Change the URL is this way:

NSURL *url= [NSURL URLWithString:@"http://support.apple.com/downloads/DL1581/en_US/OSXUpdCombo10.8.2.dmg"];

Before running, comment the connection:didReceiveData: delegate event handler or you console will be flooded. Now run it and you will see the same problem: 4 connections will connect and start, from the fifth on they will be frozen. And after a while a time out will appear.

The problem is evident with long running connections. Of course if your connections complete their purpose in a second or two you could never notice. But any long running connection exhibits this problem. And note that it happens both on the simulator and on any iOS device.

Possible explanation

First of all, why does this happen? We don't know for sure, but the most reasonable explanation is that the system is applying a limit that is suggested (but not mandated) in the HTTP protocol specification itself. See here:
As far as we know, there's no trace of this limit in Apple's documentation.

In our case, the problem pertains to streaming connections, which are typically open once the app starts and closed when the app terminates. A client may legitimately ask for more than 4 streaming connections to the same server. The underlying network connection details are completely hidden to Lightstreamer's client library users. They may simply not know that one more connection will be frozen while all the previous ones will run without a hitch. Moreover, the connection that is going to be frozen will end up in a time out error indistinguishable from a real network time out. You will have the perception your network is broken, when it is actually working perfectly. This may make it quite hard to track down the reason. 

The problem can't be solved directly, this limit is hard-coded in iOS. The only way to overcome the limit is to use different host names for the same end-point, i.e.: making the system think they are different end-points. Clearly, most of the times this is outside the reach of client-side developers. So, if you can't overcome the limit, at least try to live with it. Is there a way you can control how many connections are open toward the same end-point?

Solution

To solve the problem we developed our own thread pool implementation, featuring a configurable number of threads that will never be exceeded. It also features an unbound queue containing the operations it is going to to execute. Each time a thread completes an operation, it fetches the next from the queue. Once a thread has no more operations to execute, it is scheduled for disposal, and a timed collector will dispose of it soon after. Add a new operation and the thread will be reused, or a new one will be created.

Based on this generic thread pool, we created a structure called URL dispatcher, which uses thread pools to run URL connections, keeping always under control the connection limit mentioned before. Actually, the URL dispatcher keeps a separate thread pool for each end-point, and runs the URL connection requests with it. Since the number of threads is limited, the number of concurrent connection is too.

The URL dispatcher applies some heuristics on URL connection requests:
  • first of all, it makes a distinction between long running requests and short running requests;
  • it always keeps some spare threads available for short running requests (typically 1 out of 4), which in our case are usually control connections (subscription, messages, and so on);
  • when a long running connection is requested it is able to tell in advance if it will succeed or not (based on the current size of the thread pool of its end-point);
  • if a long running connection can't succeed because the limit has been reached, the requesting client can gracefully handle the condition;
  • finally, the Lightstreamer client uses this knowledge to notify the user and decide what to do: ignore the condition (the connection is going to be frozen), abort the connection or automatically switch to a polled connection (which uses short running requests); specific APIs to configure this behavior have been added. 
Thread pools and the URL dispatcher have been introduced with iOS client library version 1.2, and distributed with Lightstreamer server version 5.1. The StockList demo app, available on the App Store, already makes use of it. If you haven't upgraded to Lightstreamer 5.1 yet, this little piece of technology is worth the effort.

Update

The thread pool library has been released as open source and can be found on Lightstreamer's GitHub repository

Update #2

The article has been updated to reflect changed conditions in iOS 7: the maximum number of connections per end-point seems to have been decreased to 4, from 5 in iOS 6.

Moreover, the original article claimed that iOS operation queues do not strictly enforce the maximum number of concurrent operations. This behavior is no more reproducible under iOS 7, so this claim has been removed. Note that good reasons to write your own thread pool implementation still exist. One for all: controlling when threads may be disposed of, enabling customized thread reuse.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.