content: Add demystifying Ruby 3/3
This commit is contained in:
parent
3fa43a6215
commit
12f9b9fe57
1 changed files with 270 additions and 0 deletions
270
content/post/09-ruby-memory.md
Normal file
270
content/post/09-ruby-memory.md
Normal file
|
@ -0,0 +1,270 @@
|
||||||
|
---
|
||||||
|
title: "Demystifying Ruby ♦️ (3/3): Memory Shenanigans"
|
||||||
|
subtitle: "How does Ruby do memory management ?"
|
||||||
|
date: 2025-04-23
|
||||||
|
draft: false
|
||||||
|
author: Wilfried
|
||||||
|
tags: [dev, languages, ruby, demystifying-ruby]
|
||||||
|
---
|
||||||
|
|
||||||
|
Garbage collection ([GC](https://en.wikipedia.org/wiki/Garbage_collection_(computer_science))) is an essential process
|
||||||
|
in any programming language that manages memory for the programmer. In Ruby (this article is about
|
||||||
|
the [MRI](https://en.wikipedia.org/wiki/Ruby_MRI)), the garbage collector plays a crucial role in ensuring efficient
|
||||||
|
memory usage and preventing memory leaks.
|
||||||
|
|
||||||
|
## The Heap and the Stack
|
||||||
|
|
||||||
|
Memory in a Ruby program is divided into two main regions:
|
||||||
|
|
||||||
|
- **The Heap**: This is where dynamically allocated memory lives. Objects created at runtime, such as strings, arrays,
|
||||||
|
and hashes, are stored here. The garbage collector primarily operates on the heap to free up unused memory.
|
||||||
|
- **The Stack**: This is where method calls, local variables, and control flow data are stored. The stack is managed
|
||||||
|
automatically and follows a Last In, First Out (LIFO) structure. Each time a method is called, a new stack frame is
|
||||||
|
created; when the method completes, its stack frame is removed.
|
||||||
|
|
||||||
|
Ruby’s garbage collector is responsible for:
|
||||||
|
|
||||||
|
1. **Memory Allocation**: When a new object is created, Ruby allocates memory from the heap to store it.
|
||||||
|
2. **Garbage Collection**: When an object is no longer referenced, it becomes eligible for garbage collection. The GC
|
||||||
|
process reclaims memory from unused objects and makes it available for future allocations.
|
||||||
|
|
||||||
|
## Two side of the same coin: The Tail (AKA Collection)
|
||||||
|
|
||||||
|
Let’s dive in first into Garbage Collection. When thinking about objects, we can represent them as a tree of sub-objects
|
||||||
|
linked to a particular node known as the Root Set. Let’s consider this example.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
data = {
|
||||||
|
person: {
|
||||||
|
name: "John",
|
||||||
|
age: 30,
|
||||||
|
address: {
|
||||||
|
city: "Paris",
|
||||||
|
country: "France"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In this code example, `data` is stored on the stack while containing a pointer (reference) to a Hash object on the heap.
|
||||||
|
|
||||||
|
The hash can be described using this tree:
|
||||||
|
|
||||||
|
[](https://mermaid.live/edit#pako:eNptkM1OwzAQhF_F2kuLlFZtSdrGByT6h-CEKCcwB6tekghiRxtHokR5d9apQFDhi70z3-7YbuHgDIKEjHSVi8eNskJcPyt4cM6LPXoFL2I0uhIr1iqk2lnRio7VAAqx6s01m1aXKMXgzuV2IIZ7T4XNLs64DXM6Y-xyIoa31mOGdM5sA2MMYV3_Sdr27o7dQ-GPnHSvqaj_iTqBNwF0jfUU2B1pe8AzWFmI-N2FAempwQhKpFKHEtowSoHPsUQFko9G05sCZTvuqbR9cq78biPXZDnIV_1ec9VURnvcFJp_tPxRCa1BWocLgZwl_QyQLXyAjNPZeL6cJpM4The8pxEcQS7n45RXkkxZmieLpIvgsw-djJeLOP29ui_5K4UX)
|
||||||
|
|
||||||
|
Since memory is limited, the goal of garbage collection is to scan memory for unused data chunks that can be freed and
|
||||||
|
reused by the program.
|
||||||
|
|
||||||
|
Using this example, it our Ruby VM execute a line like this one
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
person[:address] = nil
|
||||||
|
```
|
||||||
|
|
||||||
|
This will be represented as
|
||||||
|
|
||||||
|
[](https://mermaid.live/edit#pako:eNptkctuwjAQRX_FGhZQySAeCRAjVSpPtauqdNWmCyseEovEjmxHhSL-vQ6IllbMwvbce2ZGtg-QaIHAIDW8zMjrPFbEx8N7DC9aO7JGF8MHabfvydRrJRqrFTmQo1fP6PRkzrypeIGMNJ90ppqktXZGqvTuHzf3HE89NuiS1qNymKL5ZRa1K4RBa__MWJxql95NpNv7Gc_cSHtjyBlc1aCulDM1uzRcJXgDPq9Jzq2d44YomZONzHPWED0cCUGJdUZvkTW6m7A36F_y9qcULmP9ckdJonNtLsDkqiFZ0CVd1T0nQP3jSgHMmQopFGgKXqdwqPkYXIYFxsD8UXCzjSFWR19TcvWmdXEpM7pKM2AbnlufVaXgDueS-28rflSDSqCZ1TcHFg5OPYAdYAcsiPqd4bgXdoMgGvk9orAHNh52Ih9h2PPSMByFRwpfp6HdzngURNdx_AYfa6O2)
|
||||||
|
|
||||||
|
This is where the garbage collector comes in. The GC will look for memory chunks not linked to the RootSet (blocks
|
||||||
|
highlighted in green) to mark those and collect them in order to free memory space. So that the memory looks like this,
|
||||||
|
after collection :
|
||||||
|
|
||||||
|
[](https://mermaid.live/edit#pako:eNpdj0FPwzAMhf-K5cuG1FUtNF2bAxJbL3BknCAcImLSCppUWSoBVf87XieENF-c9_y9xJnwzRtCiTbooYWnRjnguntR-Oh9hANFha-w2dzCjr2BwtE7mGBm94zuluGeh073JGH14Fu3gvUhhs7ZqwuuYU5bxm4yWN-7SJbCmcGEd-gMyhhGSrCn0OuTxOmUVxhb6kmh5KPR4UOhcjNnBu2eve__YsGPtkX5rj-PrMbB6EhNp_l3_wg5Q2HvRxdRlvlyBcoJv1AW9XVaVrnIiqLecq8T_EZZlWnNJUTOVim2Yk7wZ3kzSytWv36JX-U)
|
||||||
|
|
||||||
|
There are plenty of strategies to get this done. The most basic one is *mark and sweep.* As the name implies, it first:
|
||||||
|
|
||||||
|
- **marks: start from the root and follow edges, marking all reachable objects**
|
||||||
|
|
||||||
|
then it
|
||||||
|
|
||||||
|
- **sweeps: all objets that are not marked are freed**
|
||||||
|
|
||||||
|
Rince and repeat, and voila, you have a garbage collector.
|
||||||
|
|
||||||
|
**A Garbage Collector is a responsible for finding no longer used object and free the memory attached to it**
|
||||||
|
|
||||||
|
One issue with this approach is that we need a static view of the memory space—even if only briefly—because we can't
|
||||||
|
perform garbage collection while allocating memory.
|
||||||
|
|
||||||
|
his requires a "stop the world" pause while performing GC tasks, the program cannot execute other logic. Stopping the
|
||||||
|
entire program and scanning the complete object tree is slow and resource-intensive, suggesting we need a better
|
||||||
|
approach.
|
||||||
|
|
||||||
|
To address the limitations of basic garbage collection methods like "mark and sweep," Ruby employs a more advanced
|
||||||
|
technique called **generational garbage collection (GC)**. This approach is based on the observation that most objects
|
||||||
|
in a program are short-lived, meaning they are created and discarded quickly.
|
||||||
|
|
||||||
|
By dividing objects into different "generations" based on their lifespan, generational GC optimizes memory management by
|
||||||
|
focusing its efforts on newly created objects, which are more likely to be garbage, while less frequently scanning older
|
||||||
|
objects that are more likely to persist.
|
||||||
|
|
||||||
|
This will reduce the number of node to visit since the older objects will not be visited as often as the object in the
|
||||||
|
yonger ones.
|
||||||
|
|
||||||
|
**A Generational Garbage Collection ensure that we focus GC efforts on short lived objects, scanning the stable less often
|
||||||
|
to ensure a good balance between efficiency and performance**
|
||||||
|
|
||||||
|
[](https://mermaid.live/edit#pako:eNptUE1PwzAM_SuRLxvSmFpYuzUHpG3dJjghxgnCIaymraBJlaYSo-p_xylbx1cufn5-9nPcwE4nCBxSI8uM3cdCMTZ_FHCntWVbtAKeHFXVz1-KDSqfyhSYf6gxtiCmRFNpxRrW9vSSaCUL5GxwozM1YMOtNblKz3pFTAqZkuDSY8NrZTFFc6yiSoT67e0dvL1-xMqNSBKDVfXDfE38Lrd7Mr-VJq_-cd84ia6VNU61NlLt8I-sX2POzs-v2MLBRQeXJxif4MrBVQfXJ7iBEZ04T4BbU-MICjSFdCk0TiTAZligAE4wkeZVgFAt9ZRSPWhdHNuMrtMM-It8qyiry0RajHNJ5yl61tDGaJbuX8DDqJsBvIF34JPoYhzO_MCbTKIpRSrugc_CcUQvCHyiwmAatCP46Ey98WwaeN-e334CKr6kbg)
|
||||||
|
|
||||||
|
But there’s a hole in this—what if **Gen 1** is pointing to something in **Gen 0,** like in this example. If we strictly
|
||||||
|
scan Gen 0 memory space we don’t have the full picture to know that it’s actually referenced from a Gen 1 object and we
|
||||||
|
for sure, don’t want to free it!
|
||||||
|
|
||||||
|
For this to work Ruby uses a **Remembered Set**, to track specific references from Gen 1 to Gen 0, meaning than when we
|
||||||
|
check Gen 0, we also check the Remembered Set, in order, as the name suggest, not forget something, avoiding freeing a
|
||||||
|
still in used object. Additionally, Ruby uses what we call a **write barrier** to track modifications during garbage
|
||||||
|
collection. The write barrier monitors memory writes and updates the Remembered Set if needed.
|
||||||
|
|
||||||
|
**A Rembered Set ensures that when scanning young generation memory space, we still have some hints about the existing
|
||||||
|
link with older generation, to ensure we do not free an object referenced from the older generation**
|
||||||
|
|
||||||
|
Is this sufficient? Partially. As mentioned earlier in this article, scanning the memory space requires a *
|
||||||
|
*Stop-the-World** pause for the entire duration of the process to ensure proper memory cleanup. Rails applications, by
|
||||||
|
nature, tend to consume a significant amount of memory. The larger the memory footprint, the longer the pause for
|
||||||
|
garbage collection.
|
||||||
|
|
||||||
|
But what if we could make garbage collection **interruptible** ? If we could pause and resume the GC at will, we’d have
|
||||||
|
much better control over execution time, ensuring we don’t spend excessive time collecting garbage when an HTTP request
|
||||||
|
needs processing. This is where **tri-color marking GC** comes in, allowing incremental garbage collection while
|
||||||
|
minimizing application pauses.
|
||||||
|
|
||||||
|
[](https://mermaid.live/edit#pako:eNp9U9uO2jAQ_ZWRV_sWVgTCzUiVulDRSlt11W1VqaUPJhmSlCSOHKeQAv_esU0oy7adB2zjc5kzhj0LZYSMs1iJMoFP8-myAKonLZT-tmR2hYVQKxEjzGSWYahTWSzZd-h0XsF7oTYEMws8JqJCDu8iLHS6bkChCBOxyhDk6gfRKiI5dQsn-uFLkmqEz8UZeoCnLWJpnM3aan7EMBNpDjnmUjWkwznfGu6V3kKJBh6VDLGq0iI-tHvSO-3AQj6cGyIhit44nRZjpO4zEW7gIf2JLfpgfYiwMlcvGXNZ4DNzG-E0T5vGTOxNEVE39AlyDYsZzJowQzsZh6zqlXuLB4yxiNyXpl4Tzc6LXw7sxSxM3RPU5OQX7VxnNTUjnM3JbVAHuUhn_d329hbepnHSCWWhlag0VLrJsHKX9DhVNcc12D5gnWYZv1nb8iqt5Ab5TdfW6djZppFOeK_ceaHMpGqvp86prBWetFaxB7Yj0LjTV3YmzcltGI4Go-js1g_6Ivi3m-ttegoWmZ-P1TJuzvcvbq4NZ_cszVXW_9mRm5O5dmIe_QXTiHGtavRYjioX5sj2posl0wnm9ECctqbdJVsWR-KUovgqZd7SlKzjhPG1yCo61WUkNM5TQdn-QOhRUc1kXWjGB76VYHzPdoxP_Lvh2A8mgT-eDHvBwGMN474_vJtQdYN-tz_qjke9o8d-Wc_u3Xg0OP4G35VgPA)
|
||||||
|
|
||||||
|
|
||||||
|
and **black (live, retained in memory)**. This approach enables **incremental garbage collection**, making it
|
||||||
|
**interruptible**, so the process can pause and resume as needed, reducing long application pauses. Of couse, as in the
|
||||||
|
previous example, there is some new corner cases we need to handle :
|
||||||
|
|
||||||
|
1. what if a new object is created while GC is processing but paused: mark them directly as black object (using a write
|
||||||
|
barrier as in the remembered set)
|
||||||
|
2. what is we dereference objects alraedy processed: keep them as is, they will be marked white on the next GC loop!
|
||||||
|
|
||||||
|
he evolution of Ruby's garbage collection followed a long journey: from **Stop-the-World GC**, which paused the entire
|
||||||
|
program, to **Generational GC (Ruby 2.1)**, which optimized collection by focusing on young objects, and finally to
|
||||||
|
**Tri-Color Marking GC (Ruby 2.2)**, enabling incremental, interruptible garbage collection for reduced pauses and better
|
||||||
|
responsiveness
|
||||||
|
|
||||||
|
## Two side of the same coin: The Head (AKA Allocation)
|
||||||
|
|
||||||
|
Let’s take a look at how **Ruby allocates memory** for objects under the hood. In CRuby (MRI), Ruby doesn't use the
|
||||||
|
system's `malloc` for every object. Instead, it manages its own heap, which is divided into chunks called **pages**.
|
||||||
|
Each page contains multiple **slots**, and each slot is capable of storing **one Ruby object**.
|
||||||
|
|
||||||
|
MRI keeps track of which slots are available using a **free list**. Rather than using a separate data structure, Ruby
|
||||||
|
cleverly reuses the memory of free slots themselves: each free slot stores a pointer to the **next available slot**.
|
||||||
|
This forms a linked list of free slots, making allocation fast and efficient (no need to scan the entire heap !).
|
||||||
|
|
||||||
|
[](https://mermaid.live/edit#pako:eNqFUs1Kw0AQfpVlTgppbdps24QSUIt4qD-ovWhENs20jTZJ2WzAWnoTr4JexeJR8AF8nr6APoK7Sa2xBZ3L7s73twwzhk7kIVjQ42zYJydNJySy4sTNGg4cJe6I7GEQ8RE5ZD10IKOo2kXmIT9z4HP6eJui81bD5XbDt9cCFMxjgmmkyxEHfixIXxLI7O6BHA8iQcrrjQ3fduDcCX9sFaKnrtPXjKYrw3aMHimQA_cSO4JspqK8pCwls7enubFS7MhQdYZ4LS7UD8js-eXj_T6jGCsOlV-hldXQrRWJkQ81_g-lKw4070D_dNhvt1pLs8rmTQoFO5vB0kgWgLH07QVAMwBDT9mCJjfB98ASPEENAuQBU08YK5oDoo-BXAFLXj3Gr9QyTKRmyMLTKAq-ZTxKen2wumwQy1cylCuATZ_JlQoWXS4TkW9HSSjAqtPUA6wxXINlmOVita7TkmGYNXmaGowkp1o0ZVGqy1aV1uhEg5s0tFSs1wwzX5MvmU3qFw)
|
||||||
|
|
||||||
|
Once a page is full, Ruby allocates a new page for new objects to be stored.
|
||||||
|
|
||||||
|
A slot is about 40-bytes, and a page is about 16kb (meaning 400+ objects per page). Knowing this, we now have a new
|
||||||
|
question, what if we want to store a gigantic string
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
test = "hi" * 1_000_000
|
||||||
|
# 2 bytes * 1_000_000 = 2_000_000 bytes, so way more than 40!
|
||||||
|
```
|
||||||
|
|
||||||
|
For large objects like in this example (and other such as objects with many instance variables, big arrays, big hashes),
|
||||||
|
Ruby will **allocate the string’s data separately** on the **system** heap. It **does not store the full content of the
|
||||||
|
string in the slot**, but instead, it stores a **pointer** to the memory region where the string data is actually
|
||||||
|
stored.
|
||||||
|
|
||||||
|
Now let’s bridge the gap between allocation and garbage collection. From the first paragraph, we know that there is a
|
||||||
|
mechanism in place to identify data that needs to be freed, but what does this mean at a pages and slots level?
|
||||||
|
|
||||||
|
When Ruby’s GC runs, it doesn’t reclaim entire pages immediately. Instead, it works at the **slot level**. During the
|
||||||
|
sweep phase, every slot that holds an unreachable object is marked as **free** and linked back into the **free list**.
|
||||||
|
This means the slot is now ready for reuse in future allocations — super efficient, and no expensive system calls
|
||||||
|
needed.
|
||||||
|
|
||||||
|
But here’s the catch: even if most of the slots in a page are free, Ruby **can’t release the page itself to the system**
|
||||||
|
unless **all** the slots are free. That means pages with just a handful of live objects become **partially unusable
|
||||||
|
while still being reserved for Ruby** and not released to the operating system for other programs to use.
|
||||||
|
|
||||||
|
To deal with this memory fragmentation issue, Ruby introduced **compacting GC** in 2.7+. Compaction relocates live
|
||||||
|
objects from scattered pages into more densely packed ones. Once a page has **no live objects left**, it can be
|
||||||
|
completely **reclaimed**, either for reuse or even returned to the system and you have to call it manually
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
GC.compact
|
||||||
|
```
|
||||||
|
|
||||||
|
With all this theory in mind, we know have want we need to experiment with the Ruby VM !
|
||||||
|
|
||||||
|
## Fun with GC
|
||||||
|
|
||||||
|
After theory, comes practice, let’s look at some GC related code and how we can explore this Ruby code. `objspace` will
|
||||||
|
be used as it’s the standard way in Ruby MRI to get details about the GC state and trigger GC related routines.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
require 'objspace'
|
||||||
|
|
||||||
|
GC.start
|
||||||
|
puts GC.stat
|
||||||
|
|
||||||
|
10.times do
|
||||||
|
"a" * 100_000
|
||||||
|
end
|
||||||
|
|
||||||
|
GC.start
|
||||||
|
puts GC.stat
|
||||||
|
|
||||||
|
{:count=>11, :time=>4, :marking_time=>3, :sweeping_time=>0, :heap_allocated_pages=>24, :heap_sorted_length=>201, :heap_allocatable_pages=>177, :heap_available_slots=>24453, :heap_live_slots=>18007, :heap_free_slots=>6446, :heap_final_slots=>0, :heap_marked_slots=>17974, :heap_eden_pages=>24, :heap_tomb_pages=>0, :total_allocated_pages=>24, :total_freed_pages=>0, :total_allocated_objects=>62153, :total_freed_objects=>44146, :malloc_increase_bytes=>880, :malloc_increase_bytes_limit=>16777216, :minor_gc_count=>7, :major_gc_count=>4, :compact_count=>0, :read_barrier_faults=>0, :total_moved_objects=>0, :remembered_wb_unprotected_objects=>0, :remembered_wb_unprotected_objects_limit=>175, :old_objects=>17508, :old_objects_limit=>35016, :oldmalloc_increase_bytes=>880, :oldmalloc_increase_bytes_limit=>16777216}
|
||||||
|
{:count=>12, :time=>4, :marking_time=>3, :sweeping_time=>1, :heap_allocated_pages=>24, :heap_sorted_length=>201, :heap_allocatable_pages=>177, :heap_available_slots=>24453, :heap_live_slots=>18022, :heap_free_slots=>6431, :heap_final_slots=>0, :heap_marked_slots=>18020, :heap_eden_pages=>24, :heap_tomb_pages=>0, :total_allocated_pages=>24, :total_freed_pages=>0, :total_allocated_objects=>62252, :total_freed_objects=>44230, :malloc_increase_bytes=>832, :malloc_increase_bytes_limit=>16777216, :minor_gc_count=>7, :major_gc_count=>5, :compact_count=>0, :read_barrier_faults=>0, :total_moved_objects=>0, :remembered_wb_unprotected_objects=>0, :remembered_wb_unprotected_objects_limit=>179, :old_objects=>17952, :old_objects_limit=>35904, :oldmalloc_increase_bytes=>832, :oldmalloc_increase_bytes_limit=>16777216}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- One major GC occurred after the string allocations (`:major_gc_count` +1).
|
||||||
|
- Only 15 new live objects remained after allocations — Ruby GC cleaned up fast.
|
||||||
|
- Heap size stayed stable (no new pages allocated).
|
||||||
|
- GC was fast — total time stayed at 4ms, with minimal sweeping.
|
||||||
|
- No compaction was triggered (:compact_count still 0).
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
require 'objspace'
|
||||||
|
|
||||||
|
objs = []
|
||||||
|
|
||||||
|
10_000.times do
|
||||||
|
objs << "x" * rand(100..1000)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove some random ones to fragment the heap
|
||||||
|
5000.times { objs.delete_at(rand(objs.size)) }
|
||||||
|
|
||||||
|
GC.start
|
||||||
|
puts "Before compaction:"
|
||||||
|
puts GC.stat.slice(:heap_eden_pages, :heap_live_slots, :heap_free_slots, :heap_allocated_pages, :compact_count)
|
||||||
|
|
||||||
|
GC.compact
|
||||||
|
|
||||||
|
puts "\nAfter compaction:"
|
||||||
|
puts GC.stat.slice(:heap_eden_pages, :heap_live_slots, :heap_free_slots, :heap_allocated_pages, :compact_count)
|
||||||
|
|
||||||
|
Before compaction:
|
||||||
|
{:heap_eden_pages=>145, :heap_live_slots=>48028, :heap_free_slots=>76266, :heap_allocated_pages=>145, :compact_count=>0}
|
||||||
|
|
||||||
|
After compaction:
|
||||||
|
{:heap_eden_pages=>136, :heap_live_slots=>48054, :heap_free_slots=>68875, :heap_allocated_pages=>136, :compact_count=>1}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
| Value | Before | After | Commentary |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `:heap_allocated_pages` | 145 | **136** | ✅ **9 pages were freed** — compaction successfully reduced memory usage! |
|
||||||
|
| `:heap_live_slots` | 48028 | 48054 | +26 |
|
||||||
|
| `:heap_free_slots` | 76266 | **68875** | 🔻 Freed slots dropped because Ruby **repacked objects more densely**, using fewer pages |
|
||||||
|
| `:compact_count` | 0 | 1 | Ensuring we have desired state |
|
||||||
|
|
||||||
|
Ruby’s garbage collector handle memory management for you, quietly keeping things running smoothly behind the scenes.
|
||||||
|
It’s got all the right tools in its belt: generational collection for speed, tri-color marking for precision, and
|
||||||
|
compaction to tidy up the mess. Together, these techniques ensure that memory is used efficiently without bogging down
|
||||||
|
your app.
|
||||||
|
|
||||||
|
You don’t need to be a GC expert to write clean Ruby code, but knowing how it works can give you an edge — whether
|
||||||
|
you’re optimizing performance, tracking down memory issues, or just curious about what’s going on under the hood. So,
|
||||||
|
next time you hit that GC.start, you will have all the knowledge to understand what’s going on![^1]
|
||||||
|
|
||||||
|
[^1]: Special kudos to [Athoune](https://blog.garambrogne.net/) and [Chakiral](https://mastodon.social/@Chakiral@hostux.social) for the awesome and valuable review on all the articles of this series!
|
Loading…
Add table
Add a link
Reference in a new issue