memory leak in dlopen / dlcose, or user error?

Calling dlopen then dlclose causes an increase in the amount of memory used by the program. If I create a loop that calls dlopen / dlclose repeatedly on the same dynamic library, memory usage increases continuously. Is this a bug, or am I using dlopen / dlclose incorrectly?

I can reproduce this by modifying the sample code in the Apple Developer docs Creating Dynamic Libraries. If I modify Runtime.c, changing the line void *lib_handle = dlopen(lib_name, RTLD_NOW); to add the infinite loop, as below:

void *lib_handle = dlopen(lib_name, RTLD_NOW);
for (int ii = 0; ; ++ii) {
    printf("loop %i\n", ii);
    int close_err = dlclose(lib_handle);
    printf("close error: %i\n", close_err);
    printf("dlopen(%s, RTLD_NOW)\n", lib_name);
    lib_handle = dlopen(lib_name, RTLD_NOW);
}

then opening and closing the dynamic library will succeed, but memory usage (as reported by top) will rapidly increase.

I'm running on x86_64 macOS 13.6.6. Full code for the modified Runtime.c is attached, the rest of the code is available in the Apple Developer docs.

Any suggestions?

Many thanks, Chris

Answered by DTS Engineer in 866955022

I tried this here in my office and didn’t see any leaks or unbounded memory growth. Here’s what I did:

  1. Using Xcode 26.1 on macOS 15.7.1 [1], I created a new project from the macOS > Command Line Tool template.

  2. I then added a dynamic library target to that.

  3. I populated them with the code shown at the end of this post.

  4. I build both targets using Xcode.

  5. I then ran the tool from Terminal:

    % ./Test806035
    will run
    will open and close, iteration: 0
    did close and close
    …
    will open and close, iteration: 999
    did close and close
    did run, press return to loop, pid: 47371
    
  6. In other Terminal window, I rans leaks against the process. It showed no leaks.

  7. I ran heap against the process:

    % heap Test806035
       …
       COUNT      BYTES       AVG   CLASS_NAME                                        TYPE    BINARY
       =====      =====       ===   ==========                                        ====    ======
         134       4288      32.0   Class.data (class_rw_t)                           C       libobjc.A.dylib
          27      11360     420.7   non-object                                                
           5        512     102.4   xpc_string_t (Storage)                            C       libxpc.dylib
           5        256      51.2   xpc_dictionary_t (Storage)                        C       libxpc.dylib
           5        240      48.0   xpc_string_t                                      ObjC    libxpc.dylib
           4        128      32.0   Class.methodCache._buckets (bucket_t)             C       libobjc.A.dylib
           2        192      96.0   xpc_dictionary_t                                  ObjC    libxpc.dylib
           1        128     128.0   dispatch_queue_t (serial)                         ObjC    libdispatch.dylib
           1         64      64.0   xpc_array_t (Storage)                             C       libxpc.dylib
           1         64      64.0   xpc_pipe_t                                        ObjC    libxpc.dylib
           1         48      48.0   Class.data.extended (class_rw_ext_t)              C       libobjc.A.dylib
           1         48      48.0   xpc_array_t                                       ObjC    libxpc.dylib
    
  8. Back in the tool window, I repeatedly pressed return and waited for it to complete.

  9. I repeated step 7.

  10. I compared the heap output from step 7 and 9. It was identical.

I recommend that you repeat this test to confirm my result. If you see anything different, let me know. Otherwise, you can start comparing my code to your code to see why your code shows unbounded memory growth when mine doesn’t.

If I had to guess, I’d say that this is most likely caused by the library you’re loading. Does it have any library initialiser functions? A common cause of those is C++ global constructors? If so, is it possible that those are leaking? Or that they’re not be destructed correctly?

You can use the -Wglobal-constructor option to check for C++ global constructors.

https://clang.llvm.org/docs/DiagnosticsReference.html#wglobal-constructors

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] You need to update (-: Seriously though, the chances of this being different between 15.6.1 and 15.7.1 are very low.


#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv) {
    #pragma unused(argc)
    #pragma unused(argv)
    printf("will run\n");

    while (true) {
        for (int ii = 0; ii < 1000; ++ii) {
            printf("will open and close, iteration: %d\n", ii);

            void * lib_handle = dlopen("./libLeakTest.dylib", RTLD_NOW);
            if (lib_handle == NULL) {
                printf("did not open, error: %s\n", dlerror());
                abort();
            }
            int err = dlclose(lib_handle);
            if (err != 0) {
                printf("close failed, error: %s\n", strerror(err));
                abort();
            }
            
            printf("did close and close\n");
        }
        printf("did run, press return to re-run, pid: %d\n", (int) getpid());
        char junk[16];
        fgets(junk, sizeof(junk), stdin);
    }
    return 0;
}

#include <stdio.h>

extern void LTDummyRun(void);

extern void LTDummyRun(void) {
    printf("LTDummyRun");
}
I'm running on x86_64 macOS 13.6.6

Are you able to reproduce this on a more modern version of macOS?

There’s a couple of reasons why this matters:

  • The dynamic linker has received significant updates in the last few years, so it’s possible that this is fixed already.
  • If that’s the case, there’s no point filing a bug against macOS 13 because there’s no release vehicle for such a fix.

Notwithstanding the above, be aware that dlclose has a significant limitation on macOS: In many cases its unable to actually unload the library. This limitation is fundamental to the system, rather than a bug. The dlclose man page lists some of the causes, but those are just the start.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks for the response! We've now tried this on macOS Sequoia Version 15.6.1 (24G90) (arm64) and still see the increasing memory usage.

I appreciate that dlclose might not unload the library, but I wouldn't expect it to then consume more resources on a reload.

I tried this here in my office and didn’t see any leaks or unbounded memory growth. Here’s what I did:

  1. Using Xcode 26.1 on macOS 15.7.1 [1], I created a new project from the macOS > Command Line Tool template.

  2. I then added a dynamic library target to that.

  3. I populated them with the code shown at the end of this post.

  4. I build both targets using Xcode.

  5. I then ran the tool from Terminal:

    % ./Test806035
    will run
    will open and close, iteration: 0
    did close and close
    …
    will open and close, iteration: 999
    did close and close
    did run, press return to loop, pid: 47371
    
  6. In other Terminal window, I rans leaks against the process. It showed no leaks.

  7. I ran heap against the process:

    % heap Test806035
       …
       COUNT      BYTES       AVG   CLASS_NAME                                        TYPE    BINARY
       =====      =====       ===   ==========                                        ====    ======
         134       4288      32.0   Class.data (class_rw_t)                           C       libobjc.A.dylib
          27      11360     420.7   non-object                                                
           5        512     102.4   xpc_string_t (Storage)                            C       libxpc.dylib
           5        256      51.2   xpc_dictionary_t (Storage)                        C       libxpc.dylib
           5        240      48.0   xpc_string_t                                      ObjC    libxpc.dylib
           4        128      32.0   Class.methodCache._buckets (bucket_t)             C       libobjc.A.dylib
           2        192      96.0   xpc_dictionary_t                                  ObjC    libxpc.dylib
           1        128     128.0   dispatch_queue_t (serial)                         ObjC    libdispatch.dylib
           1         64      64.0   xpc_array_t (Storage)                             C       libxpc.dylib
           1         64      64.0   xpc_pipe_t                                        ObjC    libxpc.dylib
           1         48      48.0   Class.data.extended (class_rw_ext_t)              C       libobjc.A.dylib
           1         48      48.0   xpc_array_t                                       ObjC    libxpc.dylib
    
  8. Back in the tool window, I repeatedly pressed return and waited for it to complete.

  9. I repeated step 7.

  10. I compared the heap output from step 7 and 9. It was identical.

I recommend that you repeat this test to confirm my result. If you see anything different, let me know. Otherwise, you can start comparing my code to your code to see why your code shows unbounded memory growth when mine doesn’t.

If I had to guess, I’d say that this is most likely caused by the library you’re loading. Does it have any library initialiser functions? A common cause of those is C++ global constructors? If so, is it possible that those are leaking? Or that they’re not be destructed correctly?

You can use the -Wglobal-constructor option to check for C++ global constructors.

https://clang.llvm.org/docs/DiagnosticsReference.html#wglobal-constructors

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] You need to update (-: Seriously though, the chances of this being different between 15.6.1 and 15.7.1 are very low.


#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv) {
    #pragma unused(argc)
    #pragma unused(argv)
    printf("will run\n");

    while (true) {
        for (int ii = 0; ii < 1000; ++ii) {
            printf("will open and close, iteration: %d\n", ii);

            void * lib_handle = dlopen("./libLeakTest.dylib", RTLD_NOW);
            if (lib_handle == NULL) {
                printf("did not open, error: %s\n", dlerror());
                abort();
            }
            int err = dlclose(lib_handle);
            if (err != 0) {
                printf("close failed, error: %s\n", strerror(err));
                abort();
            }
            
            printf("did close and close\n");
        }
        printf("did run, press return to re-run, pid: %d\n", (int) getpid());
        char junk[16];
        fgets(junk, sizeof(junk), stdin);
    }
    return 0;
}

#include <stdio.h>

extern void LTDummyRun(void);

extern void LTDummyRun(void) {
    printf("LTDummyRun");
}

Hi Quinn, thanks for your response. I can confirm that with your code, I don't see an unbounded increase in memory usage.

I've made some edits, and have produced two minimal examples, based on yours, that do show unbounded memory usage.

LeakTestCtor.c (attached) is a modification to your library code that adds a library constructor and destructor function. LeakTestGlobal.cpp (attached) adds a global vector. Neither are leaking. Can you reproduce the increasing memory usage?

Thanks, Chris

Interesting.

I decided to focus on LeakTestCtor.c because, frankly, I avoid C++ whenever I can (-:

I was able to reproduce the unbounded memory growth you’re seeing. I’m now using Xcode 26.2 on macOS 26.2.

I wanted to share some info on how I investigated this, mostly just for the benefit of future folks who stumble across this thread. First up, I ran the program with MallocStackLoggingNoCompact set:

% MallocStackLoggingNoCompact=1 ./Test806035

This makes it easier for debugging tools to tell what’s going on. You can learn more about this in the malloc man page.

At each did run point I ran leaks to save a memory graph of the process:

% mv g1.memorygraph g1.memgraph    
… In the other window, press Return and wait for the next run to complete …
% mv g2.memorygraph g2.memgraph
… and so on …

I’ve named these gN.memgraph, where g stands for generation.

I then diffed these generations using heap:

% heap -diffFrom g1.memgraph g2.memgraph 
…
Only showing allocations in (null) that are not in g1.memgraph.
…
   COUNT      BYTES       AVG   CLASS_NAME                  TYPE BINARY
   =====      =====       ===   ==========                  ==== ======
      16      20480    1280.0   malloc in atexit_register   C    libsystem_c.dylib

Note This sort of generational analysis is much easier in Instruments, but it’s always good to understand what’s going on behind the scenes.

That gives me a ‘class name’, allowing me to get addresses:

% heap --addresses='malloc in atexit_register' g2.memgraph 
…
0x101582ea0: malloc in atexit_register (1280 bytes)
…

And that gets the entire malloc history of that memory:

% malloc_history g2.memgraph 0x101582ea0          
malloc_history Report Version:  2.0
ALLOC 0x101582ea0-0x10158339f [size=1280]:  0x19b20dd54 (dyld) start | …

And with a bit of reformatting a backtrace of the allocation:

 0 0x19b401fb8 (libsystem_malloc.dylib) _malloc_zone_malloc_instrumented_or_legacy 
 1 0x19b3e97d8 (libsystem_malloc.dylib) _malloc
 2 0x19b4671ec (libsystem_c.dylib) atexit_register
 3 0x19b467170 (libsystem_c.dylib) __cxa_atexit
 4 0x100f344a4 (???) ???
 5 0x19b22acb0 (dyld) invocation function for block in dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeStat…
 6 0x19b268730 (dyld) invocation function for block in dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld…
 7 0x19b287534 (dyld) invocation function for block in mach_o::Header::forEachSection(void (mach_o::Header::Sectio…
 8 0x19b284158 (dyld) mach_o::Header::forEachLoadCommand(void (load_command const*, bool&) block_pointer) const
 9 0x19b2859f0 (dyld) mach_o::Header::forEachSection(void (mach_o::Header::SectionInfo const&, bool&) block_pointe…
10 0x19b268220 (dyld) dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter…
11 0x19b22aa68 (dyld) dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const
12 0x19b2328b0 (dyld) dyld4::JustInTimeLoader::runInitializers(dyld4::RuntimeState&) const
13 0x19b22b214 (dyld) dyld4::Loader::runInitializersBottomUp(dyld4::RuntimeState&, dyld3::Array<dyld4::Loader cons…
14 0x19b22fe50 (dyld) dyld4::Loader::runInitializersBottomUpPlusUpwardLinks(dyld4::RuntimeState&) const::$_0::oper…
15 0x19b22b530 (dyld) dyld4::Loader::runInitializersBottomUpPlusUpwardLinks(dyld4::RuntimeState&) const
16 0x19b24fad0 (dyld) dyld4::APIs::dlopen_from(char const*, int, void*)::$_0::operator()() const
17 0x19b24436c (dyld) dyld4::APIs::dlopen_from(char const*, int, void*)
18 0x19b243e70 (dyld) dyld4::APIs::dlopen(char const*, int)
19 0x100df04b8 (Test806035) main
20 0x19b20dd54 (dyld) start

So main (frame 19) calls dlopen (frame 18) which loads the library, eventually looking for its initialiser (frame 5) which runs (frame 4) and calls __cxa_atexit (frame 3). And it’s those atexit registrations that are building up in memory.

Normally this doesn’t happen because various factors cause the dynamic linker to refuse to unload a library. That means that each dlclose call ends up being a no-op, so subsequent dlopen calls return the same library without calling the initialiser again.

In this case, however, libLeakTest.dylib is simple enough that it can be unloaded, which then causes the initialise to ‘leak’ these atexit handlers.

Frankly, I was surprised that this didn’t crash when the program eventually called exit, but it turns out that the C++ runtime prevents that [1].


As to what you should do about this, I think it’d be reasonable for you to file a bug about this abandoned memory. I’m not sure if it’s fixable, but this code isn’t doing anything wrong so someone who knows this stuff better than I do should take a look.

Please post your bug number, just for the record.

In terms of what you should do in your real product, that’s hard to say. Like I said earlier, most macOS dynamic libraries can’t be unloaded, so problems like this tend to come up in test cases rather than in the real world.

If you’d care to share more big picture context, I might be able to offer more specific advice.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Via means I don’t fully understanding but, if you’re curious, pull on the __cxa_finalize_ranges thread.

memory leak in dlopen / dlcose, or user error?
 
 
Q