17
Feb
2012
 

Extending NSData and (not) Overriding dealloc

by Tom Harrington

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.

Comments

davedelong says:

Associated objects as deallocation observers is a neat trick, and one I’ve used before. But subclassing NSData isn’t hard. You just create a subclass, and forward all the method calls on to an internal NSData object that has the actual bytes (and created with -initWithBytesNoCopy:length:freeWhenDone:). Then you have a proper -dealloc method where you can both release the internal NSData object and munmap the mapped file.

binkin82 says:

Great post, but isn’t the fact that NSDataReadingMappedAlways doesn’t work on iOS a gigantic bug? Does it always fail to work, or just in some cases?

Dave, would that mean implementing -forwardInvocation: on the subclass and just having it forward to the internal NSData? And would that work for any class cluster?

davedelong says:

Tom: I suppose that could work, but it would be a lot slower. I would just do something like:

-(NSReturnType)frobnicateWithWadzinger:(NSWadzinger *)wad {
return [_internalData frobnicateWithWadzinger:wad];
}

clarkcox says:

FYI: NSData’s primitive methods are -length and -bytes.
Please file a bug on the documentation for not calling that out.

sethk says:

NSDataReadingMappedAlways assumes that you want the entire file contents in memory always. To get the advantages of faulting reads, use NSDataReadingMapped.

@ihunter says:

Beware! Associated objects are leaking with NSZombies enabled, they are currently unusable for running specific code upon deallocation.

sethk says:

@ihunter, this doesn’t seem like a big problem, because the only things you should be doing in dealloc calls are releasing resources. Since NSZombieEnabled causes no object memory to actually be freed, the issue of having an associated object that doesn’t get released seems unimportant. Try it with NSDeallocateZombies if you want some of the benefits without the memory leaks.

@sethk: What you describe for NSDataReadingMappedAlways matches Matt’s experience but contradicts the documentation. Even the constant’s name implies mapping– the idea that asking for the data to be “always mapped” means that it should instead be copied into memory is pretty strange. Maybe it’s just a horrendously bad constant name. As for NSDataReadingMapped, that’s deprecated, and part of the motivation here was to avoid deprecated API.

@ihunter: In addition to sethk’s comment, keep in mind that NSZombie is normally a debugging tool and is not typically used in release builds. I’m not concerned about getting different behavior in this case because it would only happen in private testing and would not likely interfere with the intended purpose of enabling NSZombies.

sethk says:

The documentation could definitely be more clear, but it looks like the new name for NSDataReadingMapped is NSDataReadingMappedIfSafe, which implies that it could fail in certain situations, such as when the file is on a removable medium. This is covered in the Foundation release notes for 10.7.

sethk says:

I’ve done more digging and dtruss(1) reports that NSDataReadingMapped and NSDataReadingMappedIfSafe behave identically when mapping a file from the local disk. Here are the relevant syscalls:

open("/mach_kernel\0", 0x0, 0x1B6)               = 4 0
fstat64(0x4, 0x7FFF5FE205B8, 0x0)                = 0 0
mmap(0x0, 0xED84E4, 0x1, 0x2, 0x4, 0x100001F)            = 0x14AB000 0
close(0x4)               = 0 0

The flags passed to mmap correspond to PROT_READ and MAP_FILE | MAP_PRIVATE.
This is on 10.7, where it’s possible to run DTrace interactively. If Matt Long’s situation was that he was able to create an NSData instance, but ran out of memory later while using it, it sounds like the memory was being locked somehow and couldn’t be evicted under memory pressure.

[…] Tom Harrington: 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. […]