Use mmap With Care

May 2, 2019 by Benjamin Schaaf All Posts

When we implemented the git portion of Sublime Merge, we chose to use mmap for reading git object files. This turned out to be considerably more difficult than we had first thought. Using mmap in desktop applications has some serious caveats, and here's why:

You can follow along with the example project here.

Say you were reading some binary format, data you need throughout some application. You take the easiest approach and use a simple read to get the full contents of the file. You release your software and someone comes along and says: "I need to parse this 3GB file, but I only have 2GB of memory. Could you make it so that you're not using so much memory?". Of course you want to help them, so you do some searching and come to the conclusion that memory mapping is the perfect solution to this problem.

Memory mapped files work by mapping the full file into a virtual address space and then using page faults to determine which chunks to load into physical memory. In essence it allows you to access the file as if you had read the whole thing into memory, without actually doing so. Crucially it requires only a small change to the codebase:

And with that, you've fixed the memory usage issue. You've fixed the bug, people are happy and all is well until you get another support ticket. Your program is crashing with a SIGBUS.

Caveat 1: SIGBUS

SIGBUS (bus error) is a signal that happens when you try to access memory that has not been physically mapped. This is different to a SIGSEGV (segmentation fault) in that a segfault happens when an address is invalid, while a bus error means the address is valid but we failed to read/write.

As it turns out, the ticket comes from someone using a networked drive. Their network happened to disconnect while your memory mapped file was open, and since the file no longer existed the OS couldn't load it into ram for you and gave you a SIGBUS instead.

Because the OS is loading the file on demand, you now have this wonderful issue where any arbitrary read from an address into the memory mapped file can and will fail at some point.

Luckily on POSIX systems we have signal handlers, and SIGBUS is a signal we can handle. All you need to do is register a signal handler for SIGBUS when the program starts and jump back to our code to handle failures there.

Sadly our code actually has some edge cases we should consider:

Signal handlers are global, but signals themselves are per-thread. So you need to make sure you're not messing with any other threads by making all our data thread local. Let's also add some robustness by making sure we've called setjmp before longjmp.

Using setjmp and longjmping from a signal handler is actually unsafe. It seems to cause undefined behaviour, especially on MacOS. Instead we must use sigsetjmp and siglongjmp. Since we're jumping out of a signal handler, we need that signal handler to not block any future signals, so we must also pass SA_NODEFER to sigaction.

This is starting to get quite complicated, especially if you were to have multiple places where a SIGBUS could happen. Let's factor things out into functions to make the logic a little cleaner.

There, now you just need to remember to always call install_signal_handlers for every application, and wrap all file accesses with safe_mmap_try. Annoying, but manageable. So now you've covered POSIX systems, but what about Windows?

Caveat 2: Windows

Windows doesn't have mmap, but it does have MapViewOfFile. Both of these implement memory mapped files, but there's one important difference: Windows keeps a lock on the file, not allowing it to be deleted. Even with the Windows flag FILE_SHARE_DELETE deletion does not work. This is an issue when we expect another application to delete files from under us, such as git garbage collection.

One way around this with the windows API is to essentially disable the system file cache entirely, which just makes everything absurdly slow. The way Sublime Merge handles this is by releasing the memory mapped file on idle. Its not a pretty solution, but it works.

Windows also does not have a SIGBUS signal, but you can trivially use structured exception handling in safe_mmap_try instead:

Now all is well, your application functions on Windows. But then you decide that you would like some crash reporting, to make it easier to identify issues in the future. So you add Google Breakpad, but unbeknownst to you you've just broken Linux and MacOS again…

Caveat 3: 3rd Parties

The problem with using signal handlers is that they're global, across threads and libraries. If you have or have added a library like Breakpad that uses signals internally you're going to break your previously safe memory mapping.

Breakpad registers signal handlers at initialization time on Linux, including one for SIGBUS. These signal handlers override each other, so installation order is important. There is not a nice solution to these types of situations: You can't simply set and reset the signal handler in safe_mmap_try as that would break multithreaded applications. At Sublime HQ our solution was to turn an unhandled SIGBUS in our signal handler into a SIGSEGV. Not particularly elegant but it's a reasonable compromise.

On MacOS things get a little more complicated. XNU, the MacOS kernel, is based on Mach, one of the earliest microkernels. Instead of signals, Mach has an asynchronous, message based exception handling mechanism. For compatibility reasons signals are also supported, with Mach exceptions taking priority. If a library such as Breakpad registers for Mach exception messages, and handles those, it will prevent signals from being fired. This is of course at odds with our signal handling. The only workaround we've found so far involves patching Breakpad to not handle SIGBUS.

3rd party libraries are a problem because signals are global state accessible from everywhere. The only available solutions to this are unsatisfying workarounds.

Caveat 4: 32bit Support

Memory mapping may not use physical memory, but it does require virtual address space. On 32bit platforms your address space is ~4GB. So while your application may not use 4GB of memory, it will run out of address space if you try to memory map a too large file. This has the same result as being out of memory.

Sadly this doesn't have a workaround like the other issues, it is a hard limitation of how memory mapping works. You can now either rewrite the codebase to not memory map the whole file, live with crashes on 32bit systems or not support 32bit.

With Sublime Merge and Sublime Text 3.2 we took the "no 32bit support" route. Sublime Merge does not have a 32bit build available and Sublime Text disables git integration on 32bit versions.

An Alternative

I mentioned before that you can rewrite your code to not use memory mapping. Instead of passing around a long lived pointer into a memory mapped file all around the codebase, you can use functions such as pread to copy only the portions of the file that you require into memory. This is less elegant initially than using mmap, but it avoids all the problems you're otherwise going to have.

Through some quick benchmarks for the way Sublime Merge reads git object files, pread was around ⅔ as fast as mmap on linux. In hindsight it's difficult to justify using mmap over pread, but now the beast has been tamed and there's little reason to change any more.

If you think we've missed something or made a mistake, please leave us a message on the forums. We hope this helps some of you in your future endeavours. If you haven't already, check out our git client Sublime Merge.