Recent

Author Topic: Strange memory management behavior when cross-thread alloc/free is used  (Read 1858 times)

Hunter200165

  • Newbie
  • Posts: 4
Hello!

I have recently discovered strange behavior of memory management if the cross thread alloc/free happens.
If memory is allocated/deallocated in the same thread - everything is great.
However if memory is allocated on one thread and freed on the other - it becomes in some sort of suspended state, because it can be reused, but it hangs in the OS.

Small test was made to show the issue:
Code: Pascal  [Select][+][-]
  1. program test_mem;
  2.  
  3. {$IfDef FPC}
  4.     {$Mode DelphiUnicode}
  5.     {$H+}
  6. {$EndIf}
  7.  
  8. uses
  9.     {$IfDef Unix}
  10.     cthreads,
  11.     {$EndIf}
  12.     Classes;
  13.  
  14. // Test array
  15. var Q: array of Byte;
  16.  
  17. // Memory manager and heap status to get some feedback from MM
  18. var MemMng: TMemoryManager;
  19.     HeapStatus: TFPCHeapStatus;
  20.  
  21. // Thread function to finalize array
  22. procedure ThreadFunc;
  23. begin
  24.     // Just to see that function runs
  25.     WriteLn('ThreadFunc is running');
  26.     // And finalizes the array
  27.     Q := nil;
  28. end;
  29.  
  30. var T: TThread;
  31. begin
  32.     GetMemoryManager(MemMng);
  33.  
  34.     // Setting array to some size (10 GB in this case)
  35.     SetLength(Q, 10000000000);
  36.  
  37.     WriteLn('Array is filled');
  38.     // Wait for a user press (to profile with task manager/htop/etc)
  39.     ReadLn;
  40.  
  41.     // Create thread that will finalize the array
  42.     T := TThread.CreateAnonymousThread(ThreadFunc);
  43.     T.FreeOnTerminate := False;
  44.     T.Start;
  45.  
  46.     // And wait for it to finish
  47.     T.WaitFor;
  48.  
  49.     WriteLn('Array should be empty after that');
  50.     ReadLn;
  51.  
  52.     // (1) Get status of heap after thread "deallocated" memory
  53.     HeapStatus := MemMng.GetFPCHeapStatus;
  54.     WriteLn('MaxHeapSize ', HeapStatus.MaxHeapSize);
  55.     WriteLn('MaxHeapUsed ', HeapStatus.MaxHeapUsed);
  56.     WriteLn('CurrHeapSize ', HeapStatus.CurrHeapSize);
  57.     WriteLn('CurrHeapUsed ', HeapStatus.CurrHeapUsed);
  58.     WriteLn('CurrHeapFree ', HeapStatus.CurrHeapFree);
  59.  
  60.     // And we try to make the array of the same size again
  61.     SetLength(Q, 10000000000);
  62.  
  63.     // (2) And then get status of heap immediately after
  64.     HeapStatus := MemMng.GetFPCHeapStatus;
  65.     WriteLn('MaxHeapSize ', HeapStatus.MaxHeapSize);
  66.     WriteLn('MaxHeapUsed ', HeapStatus.MaxHeapUsed);
  67.     WriteLn('CurrHeapSize ', HeapStatus.CurrHeapSize);
  68.     WriteLn('CurrHeapUsed ', HeapStatus.CurrHeapUsed);
  69.     WriteLn('CurrHeapFree ', HeapStatus.CurrHeapFree);
  70.  
  71.     // Wait for a user to press Enter
  72.     ReadLn;
  73.     // And finalize array
  74.     Q := nil;
  75.  
  76.     // (3) Again, get status of heap
  77.     HeapStatus := MemMng.GetFPCHeapStatus;
  78.     WriteLn('MaxHeapSize ', HeapStatus.MaxHeapSize);
  79.     WriteLn('MaxHeapUsed ', HeapStatus.MaxHeapUsed);
  80.     WriteLn('CurrHeapSize ', HeapStatus.CurrHeapSize);
  81.     WriteLn('CurrHeapUsed ', HeapStatus.CurrHeapUsed);
  82.     WriteLn('CurrHeapFree ', HeapStatus.CurrHeapFree);
  83.  
  84.     ReadLn;
  85.  
  86.     T.Free;
  87.     WriteLn('Thread destroyed');
  88.     ReadLn;
  89. end.
  90.  

This code was tested on (it is mostly to point out that both trunk 3.3.1 and stable 3.2.2 versions of compiler produce the same results):
  • Windows 10 x64 (Free Pascal Compiler version 3.3.1-14239-g1437928ce6-dirty [2023/10/25] for x86_64)
  • Ubuntu 22.04.3 LTS (WSL, x64) (Free Pascal Compiler version 3.2.2+dfsg-9ubuntu1 [2022/04/11] for x86_64)
  • Debian GNU/Linux 12 (bookworm) x64 (Free Pascal Compiler version 3.3.1 [2023/10/12] for x86_64)

When array is created and finalized by thread, the output shows (1):
Code: [Select]
MaxHeapSize 10000203776
MaxHeapUsed 10000003904
CurrHeapSize 10000203776
CurrHeapUsed 10000003904
CurrHeapFree 199872
As can be seen - the CurrHeapUsed is 10 GB (and it is used by the program, if one will see the task manager/htop), however memory should have been freed. But if new array with the same size will be created - heap status will show absolutely the same (2):
Code: [Select]
MaxHeapSize 10000203776
MaxHeapUsed 10000003904
CurrHeapSize 10000203776
CurrHeapUsed 10000003904
CurrHeapFree 199872
So new array have reused memory, but this memory was stuck until the allocation was made, I suppose.
And after all, if newly allocated array is assigned nil, so basically destroyed (3):
Code: [Select]
MaxHeapSize 10000203776
MaxHeapUsed 10000003904
CurrHeapSize 196608
CurrHeapUsed 3840
CurrHeapFree 192768
Memory is fully freed (task manager/htop also show that).

My question is - do I do something wrong? Because in a project I am working on I have noticed this behavior and made a simplified showcase. As far as I understand - cross-thread memory management should be safe (at least on Linux and Windows, I do not care about other OSes)

Hunter200165

  • Newbie
  • Posts: 4
Re: Strange memory management behavior when cross-thread alloc/free is used
« Reply #1 on: October 26, 2023, 11:29:40 pm »
I also found out the new weird thing.

This time I have made a simple linked list test that uses cmem unit.
Code: Pascal  [Select][+][-]
  1. program test_list;
  2.  
  3. {$IfDef FPC}
  4.     {$Mode DelphiUnicode}
  5.     {$H+}
  6. {$EndIf}
  7.  
  8. uses
  9.     cmem;
  10.  
  11. type
  12.     // Simple linked list structure
  13.     PTest = ^TTest;
  14.     TTest = record
  15.         Next: PTest;
  16.         Data: Int64;
  17.     end;
  18.  
  19. var Q: PTest;
  20.     i: Int64;
  21.  
  22. procedure FreeFunc;
  23. var Prev: PTest;
  24. begin
  25.     // Iterate through the linked list and free all the nodes
  26.     while Assigned(Q) do begin
  27.         Prev := Q.Next;
  28.         Dispose(Q);
  29.         Q := Prev;
  30.     end;
  31. end;
  32.  
  33. var Tmp: PTest;
  34. begin
  35.     // Creating test linked list
  36.     New(Q);
  37.     Tmp := Q;
  38.  
  39.     // Generate around 4 GB of data
  40.     for i := 1 to 100000000 do begin
  41.         New(Tmp.Next);
  42.         Tmp.Data := i;
  43.         Tmp := Tmp.Next;
  44.     end;
  45.     Tmp.Next := nil;
  46.  
  47.     WriteLn('List is filled');
  48.     ReadLn;
  49.  
  50.     // And then destroying it through the function
  51.     FreeFunc;
  52.  
  53.     WriteLn('List is empty');
  54.     ReadLn;
  55. end.
  56.  

Note, that this code in difference to the previous one does not even use threading.

The strange part comes that on windows it works as expected — memory of process will grow up to approx 4 GB and then will shrink due to memory being freed.
However on linux I see on htop that memory is not released to the OS, so memory consumption only grows and never becomes smaller until the death of process.
The funny part in that is that standard memory manager of FPC works as expected on linux, so process memory grows and then shrinks; but standard memory manager does not normally work for cross-thread alloc/free for me

OSes used for testing are Windows 10 and Debian from the first message

Hunter200165

  • Newbie
  • Posts: 4
Re: Strange memory management behavior when cross-thread alloc/free is used
« Reply #2 on: October 27, 2023, 02:30:13 pm »
I am able to reproduce behavior from second message even in C (on Linux x64):

Code: C  [Select][+][-]
  1. #include <stdlib.h>
  2. #include <stdio.h>
  3.  
  4. struct test_t
  5. {
  6.     struct test_t* next;
  7.     int64_t data;
  8. };
  9.  
  10. int main()
  11. {
  12.     size_t const size = 1000000000;
  13.  
  14.     struct test_t* q, * prev;
  15.  
  16.     q = (struct test_t*)malloc(sizeof(struct test_t));
  17.     prev = q;
  18.     for (size_t i = 0; i < size; i += 1)
  19.     {
  20.         prev->next = (struct test_t*)malloc(sizeof(struct test_t));
  21.         prev->data = i;
  22.         prev = prev->next;
  23.     }
  24.     prev->next = NULL;
  25.  
  26.     printf("Allocated\n");
  27.     scanf("%*c");
  28.  
  29.     while (q)
  30.     {
  31.         prev = q->next;
  32.         free(q);
  33.         q = prev;
  34.     }
  35.  
  36.     printf("After deallocation\n");
  37.     scanf("%*c");
  38.  
  39.     return 0;
  40. }
  41.  

I think it is somehow related with situation where blocks are freed from first to last (as being in linked list memory allocation/deallocation). When the memory is continuous (like array), memory is deallocated as expected

Also, regarding the first post, I think I found the source of behavior in FPC memory manager: if memory manager sees, that memory is being freed in thread that did not allocated it - it posts free blocks in some sort of wait queue of allocator-thread; those blocks can be reused (as can be seen for the allocation of new array). However I still cannot understand how to clear those blocks so they are returned to the OS and process memory consumption is lowered
« Last Edit: October 27, 2023, 02:32:20 pm by Hunter200165 »

Hunter200165

  • Newbie
  • Posts: 4
Re: Strange memory management behavior when cross-thread alloc/free is used
« Reply #3 on: October 27, 2023, 03:36:22 pm »
Found solution for cmem (at least for Linux like Ubuntu/Debian x64). Hope it will be useful for somebody!

The whole situation was happening, because malloc/free usually operates on heap (which is happening with small blocks, like linked list nodes) and heap is not released to OS implicitly (it is reused for next allocations instead). Big blocks are allocated via mmap (in shared space of process), so when this memory is freed it is fully released back to OS.

Modern linux have malloc_trim function, which allows to release unused pages (which do not contain any allocated blocks). It can be defined in pascal as so:
Code: Pascal  [Select][+][-]
  1. uses
  2.     cmem;
  3.  
  4. {$IfDef Unix}
  5.     // Here LibName is constant that is defined by cmem unit
  6.     // On Linux it will equal 'c', which means libc
  7.     function MallocTrim(const Pad: PtrUInt): LongInt; cdecl; external LibName name 'malloc_trim';
  8. {$EndIf}
  9.  

Usage of function is rather simple:
Code: Pascal  [Select][+][-]
  1. begin
  2.     { Some allocation/deallocation }
  3.  
  4.     {$IfDef Unix}
  5.         // Parameter in malloc_trim means how much memory to preserve additionally (that can be used by malloc again without sbrk)
  6.         // If 0 is passed - no memory is left for process, means that every single page that does not contain any block will be released to an OS
  7.         MallocTrim(0);
  8.     {$EndIf}
  9.  
  10.     { Do some more memory management }
  11. end.
  12.  

 

TinyPortal © 2005-2018