A couple of weeks ago Matt Long was having a problem with an app running out of memory. He had a ginormous data file he needed to load up and process, and that memory hit was more than the app could bear. It would load just fine, into an NSData, but before he could finish with it the app would run short of memory and die.
Until recently the obvious thing would have been to tell NSData to create a memory-mapped instance. Given NSString *path
pointing to a file, you could create an NSData with almost no memory hit regardless of file size by creating it as:
NSData *data = [NSData dataWithContentsOfMappedFile:path];
Starting with iOS 5 though, this method has been deprecated. Instead, what you’re supposed to do is:
NSError *error = nil;
NSData *data = [NSData dataWithContentsOfFile:path options:NSDataReadingMappedAlways error:&error];
So, fine, whatever, it’s a different call, so what? Well, it wasn’t working. Instruments was showing that the app was taking the full memory hit when the NSData was created. Mapping wasn’t working despite using NSDataReadingMappedAlways. So what could he do? The wheels of my mind started turning.
Memory mapped files
But first, a brief aside about memory mapping.
Memory mapping is a cool Unix trick that lets you load a file into memory without, as it were, actually loading it into memory. It’s a way of using virtual memory to your advantage when you have a really big file and you don’t want to spend the RAM on it.
Contrary to common misconception, iOS does have virtual memory. It just doesn’t create swap files. But the full power of virtual memory is at your disposal. When you create a memory mapped file, the operating system gives you a memory pointer that you can use to access the file’s data. It’s as if the file was already loaded into memory but had since been swapped back out. When you access bytes in the memory map, data blocks are selectively read from the file as needed, and disposed of when they aren’t.
In short, it’s exactly what you need when your data file is too big to load, and if NSData won’t do it, I’ll just have to force it.
By the power of Greyskull Unix!
To create memory mapped files NSData is making use of iOS’s excellent Unix core. NSData isn’t actually mapping files itself, instead it’s using the Unix mmap(2) call. I can use that too. Given an NSString *path pointing to a file, you can create an memory mapped file like this:
// Get an fd
int fd = open([path fileSystemRepresentation], O_RDONLY);
if (fd < 0) {
return nil;
}
// Get file size
NSDictionary *fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
if (fileAttributes == nil) {
close(fd);
return nil;
}
NSNumber *fileSize = [fileAttributes objectForKey:NSFileSize];
// mmap
void *mappedFile;
mappedFile = mmap(0, [fileSize intValue], PROT_READ, MAP_FILE|MAP_PRIVATE, fd, 0);
close(fd);
if (mappedFile == MAP_FAILED) {
NSLog(@"Map failed, errno=%d, %s", errno, strerror(errno));
return nil;
}
To call mmap(2) this code first gets a file descriptor for the file via open(2). That, combined with the file’s size, is enough to create the memory mapping. Once the mapping exists, the code disposes of the file descriptor via close(2). At this point, mappedFile points to a bunch of bytes which is more or less indistinguishable from what you’d get if you had actually read the file into memory. And NSData knows how to use byte blobs.
// Create the NSData
NSData *mappedData = [NSData dataWithBytesNoCopy:mappedFile length:[fileSize intValue] freeWhenDone:NO];
I can convert the pointer into an NSData using one of NSData’s longer convenience initializers. Why create it this way? Because (a) I don’t want to copy the bytes, since that would negate the advantage of memory mapping, and (b) I don’t want NSData to try and clean up those bytes when it gets deallocated.
The Complication
But I do need to clean up those bytes. I just don’t want NSData to do it, because it doesn’t know the bytes came from a mapping and won’t clean them up properly. I need to remove the memory map. What needs to happen is a call to munmap(2)
when the NSData deallocates.
So, what does the code need to look like in order to make this call? Ideally the cleanup should happen automatically. I could just call munmap(2)
directly. But that would mean keeping the map pointer around and then making a separate call. All to clean up what is, conceptually at least, an internal data structure. It would work but it’s ugly.
Anyway, with ARC there’s the chance that I’d make the call before the NSData deallocated, with disastrous results. Really what I’d like to do is wrap the code above into a convenient API where you can create the mapped NSData, use it, and dispose of it normally. Any extra cleanup should just happen. After all this is Cocoa and it’s supposed to work well.
My first thought was to subclass NSData and have the subclass store the map pointer. Then the subclass could call munmap(2)
from its -dealloc method. But NSData is a class cluster, and subclassing class clusters is kind of a pain in the ass. Class clusters are a bunch of classes that masquerade as a specific public class. Examples include NSString, NSArray, and others. And of course NSData.
You may recall that -init is not required to return an instance of the class you thought you were creating. That is, if you call
Foo *myFoo = [[Foo alloc] init];
…the resulting object is not required to actually be an instance of Foo. Class clusters are a case where this happens. When you create an NSData, what you’re probably getting is an instance of something like NSConcreteData. That class isn’t documented but it acts like an NSData and you generally can’t tell the difference.
Subclassing class clusters can be challenging. You need to override all of the superclass’s primitive methods. Those are the methods that access the object’s data directly. NSData’s primitive methods aren’t documented, either. If you don’t get them all you’ll get crashes with fairly incomprehensible exceptions like:
Catchpoint 6 (exception thrown).2012-02-07 21:04:10.620 MapTest[9719:f803] *
Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*
initialization method -initWithBytes:length:copy:freeWhenDone:bytesAreVM: cannot be
sent to an abstract object of class NSMappedData: Create a concrete instance!'
I could probably figure out what the primitive methods are but doing that without documentation has a strong whiff of reverse engineering. I’d really rather avoid that. But how else can I make sure the call happens? And can I make it happen automatically?
Adding dealloc code in a category
The method I had in mind for creating mapped NSData instances would look something like:
+ (NSData *)dataWithContentsOfReallyMappedFile:(NSString *)path;
I could put that in a category except that to make it easy to use I’d need to have some code to -dealloc that could call munmap(2)
. That’s more or less what what I’m going to do.
Wait, what? You can’t override -dealloc in a category. But thanks to associated objects, you don’t have to. If have some object A, and you associate a secondary object B with it, then when A gets deallocated, B will too. If you have something you really need to happen when A gets deallocated, you can call that code from B’s dealloc method. Bingo, deallocation code for A that runs in a separate class. As long as you don’t need to access A’s private internal data, anyway.
This could be pretty useful so I decided to write it as a generic system that I could use with memory mapped NSData instances but that isn’t tied to them. I created a generic class called DeallocationHandler:
@interface DeallocHandler : NSObject
@property (readwrite, copy) void (^theBlock)(void);
@end
@implementation DeallocHandler
@synthesize theBlock;
- (void)dealloc
{
if (theBlock != nil) {
theBlock();
}
}
@end
This doesn’t do much on its own. Give it a block and it will run the block when it gets deallocated. Where it gets interesting is when you use it in a category on NSObject:
static char *deallocArrayKey = "deallocArrayKey";
@implementation NSObject (deallocBlock)
- (void)addDeallocBlock:(void (^)(void))theBlock;
{
NSMutableArray *deallocBlocks = objc_getAssociatedObject(self, &deallocArrayKey);
if (deallocBlocks == nil) {
deallocBlocks = [NSMutableArray array];
objc_setAssociatedObject(self, &deallocArrayKey, deallocBlocks, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
DeallocHandler *handler = [[DeallocHandler alloc] init];
[handler setTheBlock:theBlock];
[deallocBlocks addObject:handler];
}
@end
The -addDeallocBlock: method associates an array of DeallocationHandler instances with the target object. Each DeallocationHandler in turn has a block provided by the caller. The upshot is that, when the target object deallocates, all of those blocks will be run. This lets me attach dealloc-time code to any object. In fact I could add as many blocks as I needed and they’d run in order. One minor caveat is that you really need to watch the memory references in these blocks. If the block references the object that it’s attached to, then the circular references mean that the object won’t ever get deallocated. This is just standard safe block usage, though.
Getting back to NSData
Using the deallocation block scheme I can add a block calling munmap(2)
to my NSData object by adding this code right after I create it:
[mappedData addDeallocBlock:^{
munmap(mappedFile, [fileSize intValue]);
}];
This leaves me with two categories instead of just one, but in the end I can just create mapped NSData instances and not bother with special cleanup code in my app.
Conclusion
Never forget that Apple’s APIs are not a menu of possibilities. They’re a set of tools, but you can and should build your own tools when you need them. Unix is available on iOS, so don’t be afraid to use it. In this example we’ve seen how to:
- Add deallocation code to any object without subclassing.
- Create memory mapped NSData instances even though the official API has been deprecated and the new one doesn’t currently work.
The code described in this post can be found at Github.
Update: In response to queries I’ve had via Twitter, please be aware that the code described here assumes that you’re using ARC. If you’re not using ARC you’ll need to add a line that releases the new DeallocHandler before -addDeallocBlock: returns.